Browse Source

Merge branch 'olympia' into olympia_vnft_schema_mappings

ondratra 3 years ago
parent
commit
b92a04fa45
81 changed files with 1603 additions and 544 deletions
  1. 7 1
      .env
  2. 7 2
      .github/workflows-disabled/run-network-tests.yml
  3. 32 2
      .github/workflows/deploy-playground.yml
  4. 3 1
      .github/workflows/run-integration-tests.yml
  5. 17 10
      Cargo.lock
  6. 8 1
      README.md
  7. 14 12
      build-node-docker.sh
  8. 493 135
      cli/README.md
  9. 3 3
      cli/package.json
  10. 51 42
      cli/scripts/content-test.sh
  11. 98 0
      cli/scripts/membership-test.sh
  12. 15 12
      cli/src/Api.ts
  13. 2 1
      cli/src/Types.ts
  14. 102 143
      cli/src/base/AccountsCommandBase.ts
  15. 23 7
      cli/src/base/ApiCommandBase.ts
  16. 10 6
      cli/src/base/ContentDirectoryCommandBase.ts
  17. 40 17
      cli/src/base/DefaultCommandBase.ts
  18. 112 0
      cli/src/base/MembershipsCommandBase.ts
  19. 4 0
      cli/src/base/UploadCommandBase.ts
  20. 89 0
      cli/src/base/WorkingGroupCommandBase.ts
  21. 7 65
      cli/src/base/WorkingGroupsCommandBase.ts
  22. 7 2
      cli/src/commands/account/chooseMember.ts
  23. 24 3
      cli/src/commands/account/forget.ts
  24. 4 0
      cli/src/commands/content/addCuratorToGroup.ts
  25. 4 0
      cli/src/commands/content/channel.ts
  26. 4 0
      cli/src/commands/content/channels.ts
  27. 4 2
      cli/src/commands/content/createChannel.ts
  28. 4 5
      cli/src/commands/content/createChannelCategory.ts
  29. 3 0
      cli/src/commands/content/createCuratorGroup.ts
  30. 4 5
      cli/src/commands/content/createVideo.ts
  31. 4 5
      cli/src/commands/content/createVideoCategory.ts
  32. 4 0
      cli/src/commands/content/curatorGroup.ts
  33. 4 1
      cli/src/commands/content/curatorGroups.ts
  34. 1 0
      cli/src/commands/content/deleteChannel.ts
  35. 1 0
      cli/src/commands/content/deleteChannelCategory.ts
  36. 1 0
      cli/src/commands/content/deleteVideo.ts
  37. 1 0
      cli/src/commands/content/deleteVideoCategory.ts
  38. 1 0
      cli/src/commands/content/removeChannelAssets.ts
  39. 4 0
      cli/src/commands/content/removeCuratorFromGroup.ts
  40. 1 0
      cli/src/commands/content/reuploadAssets.ts
  41. 4 0
      cli/src/commands/content/setCuratorGroupStatus.ts
  42. 4 0
      cli/src/commands/content/setFeaturedVideos.ts
  43. 1 0
      cli/src/commands/content/updateChannel.ts
  44. 1 0
      cli/src/commands/content/updateChannelCategory.ts
  45. 1 0
      cli/src/commands/content/updateChannelCensorshipStatus.ts
  46. 1 0
      cli/src/commands/content/updateVideo.ts
  47. 1 0
      cli/src/commands/content/updateVideoCategory.ts
  48. 1 0
      cli/src/commands/content/updateVideoCensorshipStatus.ts
  49. 4 0
      cli/src/commands/content/video.ts
  50. 4 0
      cli/src/commands/content/videos.ts
  51. 29 0
      cli/src/commands/membership/addStakingAccount.ts
  52. 89 0
      cli/src/commands/membership/buy.ts
  53. 42 0
      cli/src/commands/membership/details.ts
  54. 55 0
      cli/src/commands/membership/update.ts
  55. 37 0
      cli/src/commands/membership/updateAccounts.ts
  56. 1 1
      cli/src/commands/working-groups/application.ts
  57. 5 1
      cli/src/commands/working-groups/apply.ts
  58. 5 1
      cli/src/commands/working-groups/cancelOpening.ts
  59. 1 1
      cli/src/commands/working-groups/createOpening.ts
  60. 1 1
      cli/src/commands/working-groups/evictWorker.ts
  61. 1 1
      cli/src/commands/working-groups/opening.ts
  62. 1 1
      cli/src/commands/working-groups/openings.ts
  63. 1 1
      cli/src/commands/working-groups/overview.ts
  64. 1 1
      cli/src/commands/working-groups/setDefaultGroup.ts
  65. 1 1
      devops/aws/cloudformation/infrastructure.yml
  66. 1 1
      devops/aws/cloudformation/single-instance-docker.yml
  67. 1 1
      devops/aws/cloudformation/single-instance.yml
  68. 41 11
      devops/aws/deploy-playground-playbook.yml
  69. 1 1
      devops/aws/deploy-playground.sh
  70. 1 0
      devops/aws/deploy-single-node.sample.cfg
  71. 9 3
      devops/aws/templates/Playground-Caddyfile.j2
  72. 7 12
      docker-compose.yml
  73. 2 2
      query-node/mappings/src/workingGroups.ts
  74. 6 4
      query-node/run-tests.sh
  75. 1 1
      scripts/generate-weights.sh
  76. 5 3
      setup.sh
  77. 14 7
      start.sh
  78. 1 1
      tests/integration-tests/src/fixtures/workingGroups/CreateUpcomingOpeningsFixture.ts
  79. 2 2
      tests/integration-tests/src/fixtures/workingGroups/utils.ts
  80. 1 1
      tests/network-tests/run-migration-tests.sh
  81. 1 1
      tests/network-tests/run-test-node-docker.sh

+ 7 - 1
.env

@@ -61,5 +61,11 @@ DISTRIBUTOR_1_ACCOUNT_URI=//testing//worker//Distribution//${DISTRIBUTOR_1_WORKE
 DISTRIBUTOR_2_WORKER_ID=1
 DISTRIBUTOR_2_ACCOUNT_URI=//testing//worker//Distribution//${DISTRIBUTOR_2_WORKER_ID}
 
+# Membership Faucet
+SCREENING_AUTHORITY_SEED=//Alice
+INVITING_MEMBER_ID=0
+
 # joystream/node docker image tag
-JOYSTREAM_NODE_TAG=latest
+# We do not provide a default value - scripts that startup a joystream-node service
+# Should be explicit about what version to use.
+# JOYSTREAM_NODE_TAG=latest

+ 7 - 2
.github/workflows-disabled/run-network-tests.yml

@@ -112,6 +112,7 @@ jobs:
           mkdir -p ${HOME}/.local/share/joystream-cli
           joystream-cli api:setUri ws://localhost:9944
           export RUNTIME=sumer
+          export TARGET_RUNTIME_TAG=latest
           tests/network-tests/run-migration-tests.sh
 
   basic_runtime:
@@ -140,7 +141,9 @@ jobs:
       - name: Ensure tests are runnable
         run: yarn workspace network-tests build
       - name: Execute network tests
-        run: tests/network-tests/run-full-tests.sh
+        run: |
+          export RUNTIME=latest
+          tests/network-tests/run-full-tests.sh
 
   new_chain_setup:
     name: Initialize new chain
@@ -172,5 +175,7 @@ jobs:
       # Bring up hydra query-node development instance, then run content directory
       # integration tests
       - name: Execute Tests
-        run: tests/network-tests/test-setup-new-chain.sh
+        run: |
+          export RUNTIME=latest
+          tests/network-tests/test-setup-new-chain.sh
 

+ 32 - 2
.github/workflows/deploy-playground.yml

@@ -23,6 +23,10 @@ on:
         description: 'Additional identifier to include in stack name'
         required: false
         default: 'playground'
+      skipChainSetup:
+        description: 'Optionally skip running newChainSetup script (true or false)'
+        required: true
+        default: 'false'
       # TODO: customDomain instead of ip_address.nip.io
       # customDomain:
       #   description: 'DNS hostname to use for deployment'
@@ -34,7 +38,7 @@ defaults:
 
 jobs:
   deploy-playground:
-    name: Create an EC2 instance and configure docker-compose stack
+    name: Deploy Playground Job
     runs-on: ubuntu-latest
     env:
       STACK_NAME: ${{ github.event.inputs.stackNamePrefix }}-${{ github.event.inputs.branchName }}-${{ github.run_number }}
@@ -52,6 +56,16 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: us-east-1
 
+      - name: Check if CloudFormation stack exists
+        id: stack_exists
+        run: |
+          if aws cloudformation describe-stacks --stack-name ${{ env.STACK_NAME }} >/dev/null 2>/dev/null; then
+            echo "Stack already exists"
+            exit 1
+          else
+            echo "Stack does not exist"
+          fi
+
       - name: Deploy to AWS CloudFormation
         uses: aws-actions/aws-cloudformation-github-deploy@v1
         id: deploy_stack
@@ -73,4 +87,20 @@ jobs:
             ${{ steps.deploy_stack.outputs.PublicIp }}
           options: |
             --extra-vars "git_repo=${{ github.event.inputs.gitRepo }} \
-                          branch_name=${{ github.event.inputs.branchName }}"
+                          branch_name=${{ github.event.inputs.branchName }} \
+                          skip_chain_setup=${{ github.event.inputs.skipChainSetup }}"
+
+      - name: Save the endpoints file as an artifact
+        uses: actions/upload-artifact@v2
+        with:
+          name: endpoints
+          path: devops/aws/endpoints.json
+
+      - name: Delete CloudFormation Stack if any step failed
+        # Skip if stack already existed
+        if: failure() && steps.stack_exists.outcome != 'failure'
+        run: |
+          echo "Deleting ${{ env.STACK_NAME }} stack"
+          aws cloudformation delete-stack --stack-name ${{ env.STACK_NAME }}
+          echo "Waiting for ${{ env.STACK_NAME }} to be deleted..."
+          aws cloudformation wait stack-delete-complete --stack-name ${{ env.STACK_NAME }}

+ 3 - 1
.github/workflows/run-integration-tests.yml

@@ -109,4 +109,6 @@ jobs:
         run: yarn workspace integration-tests build
       # Bring up hydra query-node development instance, then run integration tests
       - name: Execute Tests
-        run: query-node/run-tests.sh
+        run: |
+          export JOYSTREAM_NODE_TAG=latest
+          query-node/run-tests.sh

+ 17 - 10
Cargo.lock

@@ -424,9 +424,9 @@ checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff"
 
 [[package]]
 name = "bindgen"
-version = "0.57.0"
+version = "0.59.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd4865004a46a0aafb2a0a5eb19d3c9fc46ee5f063a6cfc605c69ac9ecf5263d"
+checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8"
 dependencies = [
  "bitflags",
  "cexpr",
@@ -678,9 +678,9 @@ dependencies = [
 
 [[package]]
 name = "cexpr"
-version = "0.4.0"
+version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
 dependencies = [
  "nom",
 ]
@@ -3078,9 +3078,9 @@ dependencies = [
 
 [[package]]
 name = "librocksdb-sys"
-version = "6.17.3"
+version = "6.20.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5da125e1c0f22c7cae785982115523a0738728498547f415c9054cb17c7e89f9"
+checksum = "c309a9d2470844aceb9a4a098cf5286154d20596868b75a6b36357d2bb9ca25d"
 dependencies = [
  "bindgen",
  "cc",
@@ -3333,6 +3333,12 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
 [[package]]
 name = "miniz_oxide"
 version = "0.4.4"
@@ -3534,11 +3540,12 @@ checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
 
 [[package]]
 name = "nom"
-version = "5.1.2"
+version = "7.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af"
+checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109"
 dependencies = [
  "memchr",
+ "minimal-lexical",
  "version_check",
 ]
 
@@ -6548,9 +6555,9 @@ dependencies = [
 
 [[package]]
 name = "shlex"
-version = "0.1.1"
+version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fdf1b9db47230893d76faad238fd6097fd6d6a9245cd7a4d90dbd639536bbd2"
+checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3"
 
 [[package]]
 name = "signal-hook"

+ 8 - 1
README.md

@@ -77,7 +77,14 @@ A step by step guide to setup a full node and validator on the Joystream testnet
 ### Integration tests
 
 ```bash
-tests/integration-tests/run-full-tests.sh
+# Make sure yarn packages are built
+yarn build:packages
+
+# Build the test joystream-node
+TEST_NODE=true yarn build:node:docker
+
+# Run tests
+./query-node/run-tests.sh
 ```
 
 ### Contributing

+ 14 - 12
build-node-docker.sh

@@ -2,23 +2,25 @@
 
 set -e
 
-if ! command -v docker-compose &> /dev/null
-then
-  echo "docker-compose not found. Skipping docker image builds."
-  exit 0
-fi
+# Looks for a cached joystream/node image matching code shasum.
+# Search order: local repo then dockerhub. If no cached image is found we build it.
 
-# Fetch a cached joystream/node image if one is found matching code shasum instead of building
 CODE_HASH=`scripts/runtime-code-shasum.sh`
 IMAGE=joystream/node:${CODE_HASH}
-echo "Trying to fetch cached ${IMAGE} image"
-docker pull ${IMAGE} || :
 
+# Look for image locally
 if ! docker inspect ${IMAGE} > /dev/null;
 then
-  echo "Fetch failed, building image locally"
-  docker-compose build joystream-node
+  # Not found, try to fetch from remote repo
+  echo "Trying to fetch cached ${IMAGE} image"
+  docker pull ${IMAGE} || :
+
+  # If we didn't find it, build it
+  if ! docker inspect ${IMAGE} > /dev/null;
+  then
+    echo "Building ${IMAGE}.."
+    docker build . --file joystream-node.Dockerfile --tag ${IMAGE} --build-arg TEST_NODE=${TEST_NODE}
+  fi
 else
-  echo "Tagging cached image as 'latest'"
-  docker image tag ${IMAGE} joystream/node:latest
+  echo "Found ${IMAGE} in local repo"
 fi

File diff suppressed because it is too large
+ 493 - 135
cli/README.md


+ 3 - 3
cli/package.json

@@ -108,9 +108,6 @@
       "@oclif/plugin-warn-if-update-available"
     ],
     "topics": {
-      "council": {
-        "description": "Council-related information and activities like voting, becoming part of the council etc."
-      },
       "account": {
         "description": "Accounts management - create, import or switch currently used account"
       },
@@ -122,6 +119,9 @@
       },
       "content": {
         "description": "Interactions with content directory module - managing vidoes, channels, assets, categories and curator groups"
+      },
+      "membership": {
+        "description": "Membership management - buy a new membership, update membership, manage membership keys"
       }
     }
   },

+ 51 - 42
cli/scripts/content-test.sh

@@ -4,61 +4,70 @@ set -e
 SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
 cd $SCRIPT_PATH
 
+mkdir ~/tmp || true
 echo "{}" > ~/tmp/empty.json
 
 export AUTO_CONFIRM=true
+export OCLIF_TS_NODE=0
+
+yarn workspace @joystream/cli build
+
+CLI=../bin/run
 
 # Init content lead
 GROUP=contentWorkingGroup yarn workspace api-scripts initialize-lead
 # Add integration tests lead key (in case the script is executed after ./start.sh)
-yarn joystream-cli account:import --suri //testing//worker//Content//0 --name "Test content lead key" --password "" || true
+${CLI} account:forget --name "Test content lead key" || true
+${CLI} account:import --suri //testing//worker//Content//0 --name "Test content lead key" --password "" || true
 # Test create/update/remove category
-yarn joystream-cli content:createVideoCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createVideoCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createVideoCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createChannelCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createChannelCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createChannelCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:updateVideoCategory -i ./examples/content/UpdateCategory.json 2
-yarn joystream-cli content:updateChannelCategory -i ./examples/content/UpdateCategory.json 2
-yarn joystream-cli content:deleteChannelCategory 3
-yarn joystream-cli content:deleteVideoCategory 3
+${CLI} content:createVideoCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createVideoCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createVideoCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createChannelCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createChannelCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createChannelCategory -i ../examples/content/CreateCategory.json
+${CLI} content:updateVideoCategory -i ../examples/content/UpdateCategory.json 2
+${CLI} content:updateChannelCategory -i ../examples/content/UpdateCategory.json 2
+${CLI} content:deleteChannelCategory 3
+${CLI} content:deleteVideoCategory 3
 # Group 1 - a valid group
-yarn joystream-cli content:createCuratorGroup
-yarn joystream-cli content:setCuratorGroupStatus 1 1
-yarn joystream-cli content:addCuratorToGroup 1 0
+${CLI} content:createCuratorGroup
+${CLI} content:setCuratorGroupStatus 1 1
+${CLI} content:addCuratorToGroup 1 0
 # Group 2 - test removeCuratorFromGroup
-yarn joystream-cli content:createCuratorGroup
-yarn joystream-cli content:addCuratorToGroup 2 0
-yarn joystream-cli content:removeCuratorFromGroup 2 0
+${CLI} content:createCuratorGroup
+${CLI} content:addCuratorToGroup 2 0
+${CLI} content:removeCuratorFromGroup 2 0
 # Create/update channel
-yarn joystream-cli content:createChannel -i ./examples/content/CreateChannel.json --context Member || true
-yarn joystream-cli content:createChannel -i ./examples/content/CreateChannel.json --context Curator || true
-yarn joystream-cli content:createChannel -i ~/tmp/empty.json --context Member || true
-yarn joystream-cli content:updateChannel -i ./examples/content/UpdateChannel.json 1 || true
+${CLI} content:createChannel -i ../examples/content/CreateChannel.json --context Member || true
+${CLI} content:createChannel -i ../examples/content/CreateChannel.json --context Curator || true
+${CLI} content:createChannel -i ~/tmp/empty.json --context Member || true
+${CLI} content:updateChannel -i ../examples/content/UpdateChannel.json 1 || true
 # Create/update video
-yarn joystream-cli content:createVideo -i ./examples/content/CreateVideo.json -c 1 || true
-yarn joystream-cli content:createVideo -i ./examples/content/CreateVideo.json -c 2 || true
-yarn joystream-cli content:createVideo -i ~/tmp/empty.json -c 2 || true
-yarn joystream-cli content:updateVideo -i ./examples/content/UpdateVideo.json 1 || true
+${CLI} content:createVideo -i ../examples/content/CreateVideo.json -c 1 || true
+${CLI} content:createVideo -i ../examples/content/CreateVideo.json -c 2 || true
+${CLI} content:createVideo -i ~/tmp/empty.json -c 2 || true
+${CLI} content:updateVideo -i ../examples/content/UpdateVideo.json 1 || true
 # Set featured videos
-yarn joystream-cli content:setFeaturedVideos 1,2
-yarn joystream-cli content:setFeaturedVideos 2,3
+${CLI} content:setFeaturedVideos 1,2
+${CLI} content:setFeaturedVideos 2,3
 # Update channel censorship status
-yarn joystream-cli content:updateChannelCensorshipStatus 1 1 --rationale "Test"
-yarn joystream-cli content:updateVideoCensorshipStatus 1 1 --rationale "Test"
+${CLI} content:updateChannelCensorshipStatus 1 1 --rationale "Test"
+${CLI} content:updateVideoCensorshipStatus 1 1 --rationale "Test"
 # Display-only commands
-yarn joystream-cli content:videos
-yarn joystream-cli content:video 1
-yarn joystream-cli content:channels
-yarn joystream-cli content:channel 1
-yarn joystream-cli content:curatorGroups
-yarn joystream-cli content:curatorGroup 1
+${CLI} content:videos
+${CLI} content:video 1
+${CLI} content:channels
+${CLI} content:channel 1
+${CLI} content:curatorGroups
+${CLI} content:curatorGroup 1
 # Remove videos/channels/assets
-yarn joystream-cli content:removeChannelAssets -c 1 -o 0
-yarn joystream-cli content:deleteVideo -v 1 -f
-yarn joystream-cli content:deleteVideo -v 2 -f
-yarn joystream-cli content:deleteVideo -v 3 -f
-yarn joystream-cli content:deleteChannel -c 1 -f
-yarn joystream-cli content:deleteChannel -c 2 -f
-yarn joystream-cli content:deleteChannel -c 3 -f
+${CLI} content:removeChannelAssets -c 1 -o 0
+${CLI} content:deleteVideo -v 1 -f
+${CLI} content:deleteVideo -v 2 -f
+${CLI} content:deleteVideo -v 3 -f
+${CLI} content:deleteChannel -c 1 -f
+${CLI} content:deleteChannel -c 2 -f
+${CLI} content:deleteChannel -c 3 -f
+# Forget test content lead account
+${CLI} account:forget --name "Test content lead key"

+ 98 - 0
cli/scripts/membership-test.sh

@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
+cd $SCRIPT_PATH
+
+export AUTO_CONFIRM=true
+export OCLIF_TS_NODE=0
+
+yarn workspace @joystream/cli build
+
+CLI=../bin/run
+
+# Remove accounts added by previous test runs if needed
+${CLI} account:forget --name test_alice_member_controller_1 || true
+${CLI} account:forget --name test_alice_member_root_1 || true
+${CLI} account:forget --name test_alice_member_controller_2 || true
+${CLI} account:forget --name test_alice_member_staking || true
+
+# Create membership (controller: //Alice//controller, root: //Alice//root, sender: //Alice)
+MEMBER_HANDLE="alice-$(date +%s)"
+MEMBER_ID=`${CLI} membership:buy\
+  --about="Test about text"\
+  --avatarUri="http://example.com/example.jpg"\
+  --controllerKey="5FnEMwYzo9PRGkGV4CtFNaCNSEZWA3AxbpbxcxamxdvMkD19"\
+  --handle="$MEMBER_HANDLE"\
+  --name="Alice"\
+  --rootKey="5CVGusS1N7brUBqfVE1XgUeowHMD8o9xpk2mMXdFrrnLmM1v"\
+  --senderKey="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"`
+
+# Import //Alice//controller key
+${CLI} account:import\
+  --suri //Alice//controller\
+  --name test_alice_member_controller_1\
+  --password=""
+
+# Transfer some funds to //Alice//controller key
+${CLI} account:transferTokens\
+  --amount 10000\
+  --from 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY\
+  --to 5FnEMwYzo9PRGkGV4CtFNaCNSEZWA3AxbpbxcxamxdvMkD19
+
+# Update membership
+${CLI} membership:update\
+  --useMemberId="$MEMBER_ID"\
+  --newHandle="$MEMBER_HANDLE-updated"\
+  --newName="Alice Updated"\
+  --newAvatarUri="http://example.com/updated.jpg"\
+  --newAbout="Test about text updated"
+
+# Import //Alice//root key
+${CLI} account:import\
+  --suri //Alice//root\
+  --name test_alice_member_root_1\
+  --password=""
+
+# Transfer some funds to //Alice//root key
+${CLI} account:transferTokens\
+  --amount 10000\
+  --from 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY\
+  --to 5CVGusS1N7brUBqfVE1XgUeowHMD8o9xpk2mMXdFrrnLmM1v
+
+# Update accounts (//Alice//controller//0, //Alice//root//0)
+${CLI} membership:updateAccounts\
+  --useMemberId="$MEMBER_ID"\
+  --newControllerAccount="5E5JemkFX48JMRFraGZrjPwKL1HnhLkPrMQxaBvoSXPmzKab"\
+  --newRootAccount="5HBBGjABKMczXYGmGZe9un3VYia1BmedLsoXJFWAtBtGVahv"
+
+# Import //Alice//controller//0 key
+${CLI} account:import\
+  --suri //Alice//controller//0\
+  --name test_alice_member_controller_2\
+  --password=""
+
+# Transfer some funds to //Alice//controller//0 key
+${CLI} account:transferTokens\
+  --amount 10000\
+  --from 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY\
+  --to 5E5JemkFX48JMRFraGZrjPwKL1HnhLkPrMQxaBvoSXPmzKab
+
+# Import //Alice//staking key
+${CLI} account:import\
+  --suri //Alice//staking\
+  --name test_alice_member_staking\
+  --password=""
+
+# Add staking account (//Alice//staking)
+${CLI} membership:addStakingAccount\
+  --useMemberId="$MEMBER_ID"\
+  --address="5EheygkSi4q4QCN12d2Vy65EnoEtdJy6yw6o7XZpPRcaVJCS"\
+  --fundsSource="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"\
+  --withBalance="10000"
+
+# Remove imported accounts
+${CLI} account:forget --name test_alice_member_controller_1
+${CLI} account:forget --name test_alice_member_root_1
+${CLI} account:forget --name test_alice_member_controller_2
+${CLI} account:forget --name test_alice_member_staking

+ 15 - 12
cli/src/Api.ts

@@ -34,6 +34,7 @@ import {
 import { BagId, DataObject, DataObjectId } from '@joystream/types/storage'
 import QueryNodeApi from './QueryNodeApi'
 import { MembershipFieldsFragment } from './graphql/generated/queries'
+import { blake2AsHex } from '@polkadot/util-crypto'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 
@@ -163,8 +164,8 @@ export default class Api {
 
     return entries.map(([memberId, membership]) => ({
       id: memberId,
-      name: memberQnDataById.get(memberId.toString())?.metadata.name,
       handle: memberQnDataById.get(memberId.toString())?.handle,
+      meta: memberQnDataById.get(memberId.toString())?.metadata,
       membership,
     }))
   }
@@ -175,13 +176,13 @@ export default class Api {
     return memberDetails
   }
 
-  protected async membershipById(memberId: MemberId): Promise<MemberDetails | null> {
+  async memberDetailsById(memberId: MemberId | number): Promise<MemberDetails | null> {
     const membership = await this._api.query.members.membershipById(memberId)
-    return membership.isEmpty ? null : await this.memberDetails(memberId, membership)
+    return membership.isEmpty ? null : await this.memberDetails(createType('MemberId', memberId), membership)
   }
 
-  protected async expectedMembershipById(memberId: MemberId): Promise<MemberDetails> {
-    const member = await this.membershipById(memberId)
+  async expectedMemberDetailsById(memberId: MemberId | number): Promise<MemberDetails> {
+    const member = await this.memberDetailsById(memberId)
     if (!member) {
       throw new CLIError(`Expected member was not found by id: ${memberId.toString()}`)
     }
@@ -230,11 +231,7 @@ export default class Api {
     const stakingAccount = worker.staking_account_id
     const memberId = worker.member_id
 
-    const profile = await this.membershipById(memberId)
-
-    if (!profile) {
-      throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`)
-    }
+    const profile = await this.expectedMemberDetailsById(memberId)
 
     const stake = await this.fetchStake(worker.staking_account_id, group)
 
@@ -318,7 +315,7 @@ export default class Api {
   ): Promise<ApplicationDetails> {
     return {
       applicationId,
-      member: await this.expectedMembershipById(application.member_id),
+      member: await this.expectedMemberDetailsById(application.member_id),
       roleAccout: application.role_account_id,
       rewardAccount: application.reward_account_id,
       stakingAccount: application.staking_account_id,
@@ -453,7 +450,13 @@ export default class Api {
   }
 
   async stakingAccountStatus(account: string): Promise<StakingAccountMemberBinding | null> {
-    const status = await this.getOriginalApi().query.members.stakingAccountIdMemberStatus(account)
+    const status = await this._api.query.members.stakingAccountIdMemberStatus(account)
     return status.isEmpty ? null : status
   }
+
+  async isHandleTaken(handle: string): Promise<boolean> {
+    const handleHash = blake2AsHex(handle)
+    const existingMeber = await this._api.query.members.memberIdByHandleHash(handleHash)
+    return !existingMeber.isEmpty
+  }
 }

+ 2 - 1
cli/src/Types.ts

@@ -17,6 +17,7 @@ import {
   IChannelCategoryMetadata,
 } from '@joystream/metadata-protobuf'
 import { DataObjectCreationParameters } from '@joystream/types/storage'
+import { MembershipFieldsFragment } from './graphql/generated/queries'
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -103,7 +104,7 @@ export type OpeningDetails = {
 // Extended membership information (including optional query node data)
 export type MemberDetails = {
   id: MemberId
-  name?: string | null
+  meta?: MembershipFieldsFragment['metadata']
   handle?: string
   membership: Membership
 }

+ 102 - 143
cli/src/base/AccountsCommandBase.ts

@@ -6,9 +6,9 @@ import { CLIError } from '@oclif/errors'
 import ApiCommandBase from './ApiCommandBase'
 import { Keyring } from '@polkadot/api'
 import { formatBalance } from '@polkadot/util'
-import { MemberDetails, NamedKeyringPair } from '../Types'
+import { NamedKeyringPair } from '../Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
-import { memberHandle, toFixedLength } from '../helpers/display'
+import { toFixedLength } from '../helpers/display'
 import { MemberId, AccountId } from '@joystream/types/common'
 import { KeyringPair, KeyringInstance, KeyringOptions } from '@polkadot/keyring/types'
 import { KeypairType } from '@polkadot/util-crypto/types'
@@ -35,7 +35,6 @@ export const STAKING_ACCOUNT_CANDIDATE_STAKE = new BN(200)
  * Where: APP_DATA_PATH is provided by StateAwareCommandBase and ACCOUNTS_DIRNAME is a const (see above).
  */
 export default abstract class AccountsCommandBase extends ApiCommandBase {
-  private selectedMember: MemberDetails | undefined
   private _keyring: KeyringInstance | undefined
 
   private get keyring(): KeyringInstance {
@@ -199,6 +198,14 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     return this.keyring.getPair(key) as NamedKeyringPair
   }
 
+  getPairByName(name: string): NamedKeyringPair {
+    const pair = this.getPairs().find((p) => this.getAccountFileName(p.meta.name) === this.getAccountFileName(name))
+    if (!pair) {
+      throw new CLIError(`Account not found by name: ${name}`)
+    }
+    return pair
+  }
+
   async getDecodedPair(key: string | AccountId): Promise<NamedKeyringPair> {
     const pair = this.getPair(key.toString())
 
@@ -278,7 +285,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
 
     const longestNameLen: number = pairs.reduce((prev, curr) => Math.max(curr.meta.name.length, prev), 0)
     const nameColLength: number = Math.min(longestNameLen + 1, 20)
-    const chosenKey = await this.simplePrompt({
+    const chosenKey = await this.simplePrompt<string>({
       message,
       type: 'list',
       choices: pairs.map((p, i) => ({
@@ -321,165 +328,119 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  async setSelectedMember(selectedMember: MemberDetails): Promise<void> {
-    this.selectedMember = selectedMember
+  async setupStakingAccount(
+    memberId: MemberId,
+    member: Membership,
+    address?: string,
+    requiredStake: BN = new BN(0),
+    fundsSource?: string
+  ): Promise<string> {
+    if (fundsSource && !this.isKeyAvailable(fundsSource)) {
+      throw new CLIError(`Key ${chalk.magentaBright(fundsSource)} is not available!`)
+    }
 
-    await this.setPreservedState({ selectedMemberId: selectedMember.id.toString() })
-  }
+    if (!address) {
+      address = await this.promptForAnyAddress('Choose staking account')
+    }
+    const { balances } = await this.getApi().getAccountSummary(address)
+    const stakingStatus = await this.getApi().stakingAccountStatus(address)
 
-  async getRequiredMemberContext(useSelected = false, allowedIds?: MemberId[]): Promise<MemberDetails> {
-    if (
-      useSelected &&
-      this.selectedMember &&
-      (!allowedIds || allowedIds.some((id) => id.eq(this.selectedMember?.id)))
-    ) {
-      return this.selectedMember
+    if (balances.lockedBalance.gtn(0)) {
+      throw new CLIError('This account is already used for other staking purposes, choose a different account...')
     }
 
-    const membersDetails = allowedIds
-      ? await this.getApi().membersDetailsByIds(allowedIds)
-      : await this.getApi().allMembersDetails()
-    const availableMemberships = await Promise.all(
-      membersDetails.filter((m) => this.isKeyAvailable(m.membership.controller_account.toString()))
-    )
-
-    if (!availableMemberships.length) {
-      this.error(
-        `No ${allowedIds ? 'allowed ' : ''}member controller key available!` +
-          (allowedIds ? ` Allowed members: ${allowedIds.join(', ')}.` : ''),
-        {
-          exit: ExitCodes.AccessDenied,
-        }
+    if (stakingStatus && !stakingStatus.member_id.eq(memberId)) {
+      throw new CLIError(
+        'This account is already used as staking accout by other member, choose a different account...'
       )
-    } else if (availableMemberships.length === 1) {
-      this.selectedMember = availableMemberships[0]
-    } else {
-      this.selectedMember = await this.promptForMember(availableMemberships, 'Choose member context')
     }
 
-    return this.selectedMember
-  }
-
-  async promptForMember(availableMemberships: MemberDetails[], message = 'Choose a member'): Promise<MemberDetails> {
-    const memberIndex = await this.simplePrompt({
-      type: 'list',
-      message,
-      choices: availableMemberships.map((m, i) => ({
-        name: `id: ${m.id}, handle: ${memberHandle(m)}`,
-        value: i,
-      })),
-    })
-
-    return availableMemberships[memberIndex]
-  }
-
-  async promptForStakingAccount(stakeValue: BN, memberId: MemberId, member: Membership): Promise<string> {
-    this.log(`Required stake: ${formatBalance(stakeValue)}`)
-    let stakingAccount: string
-    while (true) {
-      stakingAccount = await this.promptForAnyAddress('Choose staking account')
-      const { balances } = await this.getApi().getAccountSummary(stakingAccount)
-      const stakingStatus = await this.getApi().stakingAccountStatus(stakingAccount)
-
-      if (balances.lockedBalance.gtn(0)) {
-        this.warn('This account is already used for other staking purposes, choose different account...')
-        continue
-      }
-
-      if (stakingStatus && !stakingStatus.member_id.eq(memberId)) {
-        this.warn('This account is already used as staking accout by other member, choose different account...')
-        continue
-      }
-
-      let additionalStakingAccountCosts = new BN(0)
-      if (!stakingStatus || (stakingStatus && stakingStatus.confirmed.isFalse)) {
-        if (!this.isKeyAvailable(stakingAccount)) {
-          this.warn(
-            'Account is not a confirmed staking account and cannot be directly accessed via CLI, choose different account...'
-          )
-          continue
-        }
-        this.warn(
-          `This account is not a confirmed staking account. ` +
-            `Additional funds (fees) may be required to set it as a staking account.`
+    let candidateTxFee = new BN(0)
+    if (!stakingStatus || (stakingStatus && stakingStatus.confirmed.isFalse)) {
+      if (!this.isKeyAvailable(address)) {
+        throw new CLIError(
+          'Account is not a confirmed staking account and cannot be directly accessed via CLI, choose different account...'
         )
-        if (!stakingStatus) {
-          additionalStakingAccountCosts = await this.getApi().estimateFee(
-            await this.getDecodedPair(stakingAccount),
-            this.getOriginalApi().tx.members.addStakingAccountCandidate(memberId)
-          )
-          additionalStakingAccountCosts = additionalStakingAccountCosts.add(STAKING_ACCOUNT_CANDIDATE_STAKE)
-        }
       }
-
-      const requiredStakingAccountBalance = stakeValue.add(additionalStakingAccountCosts)
-      const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
-      if (missingStakingAccountBalance.gtn(0)) {
-        this.warn(
-          `Not enough available staking account balance! Missing: ${chalk.cyan(
-            formatBalance(missingStakingAccountBalance)
-          )}.` +
-            (additionalStakingAccountCosts.gtn(0)
-              ? ` (includes ${formatBalance(
-                  additionalStakingAccountCosts
-                )} which is a required fee and candidate stake for adding a new staking account)`
-              : '')
-        )
-        const transferTokens = await this.simplePrompt({
-          type: 'confirm',
-          message: `Do you want to transfer ${chalk.cyan(
-            formatBalance(missingStakingAccountBalance)
-          )} from another account?`,
-        })
-        if (transferTokens) {
-          const key = await this.promptForAccount('Choose source account')
-          await this.sendAndFollowNamedTx(await this.getDecodedPair(key), 'balances', 'transferKeepAlive', [
-            stakingAccount,
-            missingStakingAccountBalance,
-          ])
-        } else {
-          continue
-        }
-      }
-
+      this.warn(
+        `This account is not a confirmed staking account. ` +
+          `Additional funds (fees) may be required to set it as a staking account.`
+      )
       if (!stakingStatus) {
-        await this.sendAndFollowNamedTx(
-          await this.getDecodedPair(stakingAccount),
-          'members',
-          'addStakingAccountCandidate',
-          [memberId]
+        candidateTxFee = await this.getApi().estimateFee(
+          await this.getDecodedPair(address),
+          this.getOriginalApi().tx.members.addStakingAccountCandidate(memberId)
         )
       }
+    }
 
-      if (!stakingStatus || stakingStatus.confirmed.isFalse) {
-        await this.sendAndFollowNamedTx(
-          await this.getDecodedPair(member.controller_account.toString()),
-          'members',
-          'confirmStakingAccount',
-          [memberId, stakingAccount]
-        )
+    const requiredStakingAccountBalance = !stakingStatus
+      ? requiredStake.add(candidateTxFee).add(STAKING_ACCOUNT_CANDIDATE_STAKE)
+      : requiredStake
+    const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
+    if (missingStakingAccountBalance.gtn(0)) {
+      this.warn(
+        `Not enough available staking account balance! Missing: ${chalk.cyanBright(
+          formatBalance(missingStakingAccountBalance)
+        )}.` +
+          (!stakingStatus
+            ? ` (required balance includes ${chalk.cyanBright(
+                formatBalance(candidateTxFee)
+              )} transaction fee and ${chalk.cyanBright(
+                formatBalance(STAKING_ACCOUNT_CANDIDATE_STAKE)
+              )} staking account candidate stake)`
+            : '')
+      )
+      const transferTokens = await this.requestConfirmation(
+        `Do you want to transfer ${chalk.cyan(formatBalance(missingStakingAccountBalance))} from another account?`
+      )
+      if (transferTokens) {
+        const key = fundsSource || (await this.promptForAccount('Choose source account'))
+        await this.sendAndFollowNamedTx(await this.getDecodedPair(key), 'balances', 'transferKeepAlive', [
+          address,
+          missingStakingAccountBalance,
+        ])
+      } else {
+        throw new CLIError('Missing amount not transferred to the staking account, aborting...')
       }
-
-      break
     }
 
-    return stakingAccount
-  }
+    if (!stakingStatus) {
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'members', 'addStakingAccountCandidate', [
+        memberId,
+      ])
+    }
 
-  private async initSelectedMember(): Promise<void> {
-    const memberIdString = this.getPreservedState().selectedMemberId
+    if (!stakingStatus || stakingStatus.confirmed.isFalse) {
+      await this.sendAndFollowNamedTx(
+        await this.getDecodedPair(member.controller_account.toString()),
+        'members',
+        'confirmStakingAccount',
+        [memberId, address]
+      )
+    }
 
-    const memberId = this.createType('MemberId', memberIdString)
-    const members = await this.getApi().membersDetailsByIds([memberId])
+    return address
+  }
 
-    // ensure selected member exists
-    if (!members.length) {
-      return
+  async promptForStakingAccount(requiredStake: BN, memberId: MemberId, member: Membership): Promise<string> {
+    this.log(`Required stake: ${formatBalance(requiredStake)}`)
+    while (true) {
+      const stakingAccount = await this.promptForAnyAddress('Choose staking account')
+      try {
+        await this.setupStakingAccount(memberId, member, stakingAccount.toString(), requiredStake)
+        return stakingAccount
+      } catch (e) {
+        if (e instanceof CLIError) {
+          this.warn(e.message)
+        } else {
+          throw e
+        }
+      }
     }
-
-    this.selectedMember = members[0]
   }
 
+
   async init(): Promise<void> {
     await super.init()
     try {
@@ -488,7 +449,5 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
       throw this.createDataDirInitError()
     }
     await this.initKeyring()
-
-    await this.initSelectedMember()
   }
 }

+ 23 - 7
cli/src/base/ApiCommandBase.ts

@@ -114,7 +114,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   async promptForApiUri(): Promise<string> {
-    let selectedNodeUri = await this.simplePrompt({
+    let selectedNodeUri = await this.simplePrompt<string>({
       type: 'list',
       message: 'Choose a node websocket api uri:',
       choices: [
@@ -502,6 +502,15 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   async sendAndFollowTx(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>): Promise<SubmittableResult> {
+    this.log(
+      chalk.magentaBright(
+        `\nSending ${tx.method.section}.${tx.method.method} extrinsic from ${
+          account.meta.name ? account.meta.name : account.address
+        }...`
+      )
+    )
+    this.log('Tx params:', this.humanize(tx.args))
+
     // Calculate fee and ask for confirmation
     const fee = await this.getApi().estimateFee(account, tx)
 
@@ -548,12 +557,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     method: Method,
     params: Submittable extends (...args: any[]) => any ? Parameters<Submittable> : []
   ): Promise<SubmittableResult> {
-    this.log(
-      chalk.magentaBright(
-        `\nSending ${module}.${method} extrinsic from ${account.meta.name ? account.meta.name : account.address}...`
-      )
-    )
-    this.log('Tx params:', this.humanize(params))
+    // TODO: Replace all usages with "sendAndFollowTx"
     const tx = await this.getUnaugmentedApi().tx[module][method](...params)
     return this.sendAndFollowTx(account, tx)
   }
@@ -566,6 +570,18 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return result.findRecord(section, method)?.event as EventType | undefined
   }
 
+  public getEvent<
+    S extends keyof AugmentedEvents<'promise'> & string,
+    M extends keyof AugmentedEvents<'promise'>[S] & string,
+    EventType = AugmentedEvents<'promise'>[S][M] extends AugmentedEvent<'promise', infer T> ? IEvent<T> : never
+  >(result: SubmittableResult, section: S, method: M): EventType {
+    const event = this.findEvent<S, M, EventType>(result, section, method)
+    if (!event) {
+      throw new Error(`Event ${section}.${method} not found in tx result: ${JSON.stringify(result.toHuman())}`)
+    }
+    return event
+  }
+
   async buildAndSendExtrinsic<
     Module extends keyof AugmentedSubmittables<'promise'>,
     Method extends keyof AugmentedSubmittables<'promise'>[Module] & string

+ 10 - 6
cli/src/base/ContentDirectoryCommandBase.ts

@@ -3,11 +3,11 @@ import { WorkingGroups } from '../Types'
 import { CuratorGroup, CuratorGroupId, ContentActor, Channel } from '@joystream/types/content'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
-import { RolesCommandBase } from './WorkingGroupsCommandBase'
 import { flags } from '@oclif/command'
 import { memberHandle } from '../helpers/display'
 import { MemberId } from '@joystream/types/common'
 import { createType } from '@joystream/types'
+import WorkingGroupCommandBase from './WorkingGroupCommandBase'
 
 const CHANNEL_CREATION_CONTEXTS = ['Member', 'Curator'] as const
 const CATEGORIES_CONTEXTS = ['Lead', 'Curator'] as const
@@ -20,7 +20,11 @@ type CategoriesContext = typeof CATEGORIES_CONTEXTS[number]
 /**
  * Abstract base class for commands related to content directory
  */
-export default abstract class ContentDirectoryCommandBase extends RolesCommandBase {
+export default abstract class ContentDirectoryCommandBase extends WorkingGroupCommandBase {
+  static flags = {
+    ...WorkingGroupCommandBase.flags,
+  }
+
   static channelCreationContextFlag = flags.enum({
     required: false,
     description: `Actor context to execute the command in (${CHANNEL_CREATION_CONTEXTS.join('/')})`,
@@ -41,7 +45,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
 
   async init(): Promise<void> {
     await super.init()
-    this.group = WorkingGroups.Curators // override group for RolesCommandBase
+    this._group = WorkingGroups.Curators // override group for RolesCommandBase
   }
 
   async promptForChannelCreationContext(
@@ -213,7 +217,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       this.warn('No Curator Groups to choose from!')
       this.exit(ExitCodes.InvalidInput)
     }
-    const selectedId = await this.simplePrompt({ message, type: 'list', choices })
+    const selectedId = await this.simplePrompt<number>({ message, type: 'list', choices })
 
     return selectedId
   }
@@ -223,7 +227,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     if (!choices.length) {
       return []
     }
-    const selectedIds = await this.simplePrompt({ message, type: 'checkbox', choices })
+    const selectedIds = await this.simplePrompt<number[]>({ message, type: 'checkbox', choices })
 
     return selectedIds
   }
@@ -242,7 +246,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       this.exit(ExitCodes.InvalidInput)
     }
 
-    const selectedCuratorId = await this.simplePrompt({
+    const selectedCuratorId = await this.simplePrompt<number>({
       message,
       type: 'list',
       choices,

+ 40 - 17
cli/src/base/DefaultCommandBase.ts

@@ -12,17 +12,29 @@ export default abstract class DefaultCommandBase extends Command {
   protected indentGroupsOpened = 0
   protected jsonPrettyIdent = ''
 
-  openIndentGroup() {
+  log(message?: unknown, ...args: unknown[]): void {
+    if (args.length) {
+      console.error(message, args)
+    } else {
+      console.error(message)
+    }
+  }
+
+  output(value: unknown): void {
+    console.log(value)
+  }
+
+  openIndentGroup(): void {
     console.group()
     ++this.indentGroupsOpened
   }
 
-  closeIndentGroup() {
+  closeIndentGroup(): void {
     console.groupEnd()
     --this.indentGroupsOpened
   }
 
-  async simplePrompt(question: DistinctQuestion) {
+  async simplePrompt<T = unknown>(question: DistinctQuestion): Promise<T> {
     const { result } = await inquirer.prompt([
       {
         ...question,
@@ -51,25 +63,36 @@ export default abstract class DefaultCommandBase extends Command {
     }
   }
 
-  private jsonPrettyIndented(line: string) {
+  async requestConfirmation(
+    message = 'Are you sure you want to execute this action?',
+    defaultVal = false
+  ): Promise<boolean> {
+    if (process.env.AUTO_CONFIRM === 'true' || parseInt(process.env.AUTO_CONFIRM || '')) {
+      return true
+    }
+    const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: defaultVal }])
+    return confirmed
+  }
+
+  private jsonPrettyIndented(line: string): string {
     return `${this.jsonPrettyIdent}${line}`
   }
 
-  private jsonPrettyOpen(char: '{' | '[') {
+  private jsonPrettyOpen(char: '{' | '['): string {
     this.jsonPrettyIdent += '    '
     return chalk.gray(char) + '\n'
   }
 
-  private jsonPrettyClose(char: '}' | ']') {
+  private jsonPrettyClose(char: '}' | ']'): string {
     this.jsonPrettyIdent = this.jsonPrettyIdent.slice(0, -4)
     return this.jsonPrettyIndented(chalk.gray(char))
   }
 
-  private jsonPrettyKeyVal(key: string, val: any): string {
+  private jsonPrettyKeyVal(key: string, val: unknown): string {
     return this.jsonPrettyIndented(chalk.magentaBright(`${key}: ${this.jsonPrettyAny(val)}`))
   }
 
-  private jsonPrettyObj(obj: { [key: string]: any }): string {
+  private jsonPrettyObj(obj: Record<string, unknown>): string {
     return (
       this.jsonPrettyOpen('{') +
       Object.keys(obj)
@@ -80,7 +103,7 @@ export default abstract class DefaultCommandBase extends Command {
     )
   }
 
-  private jsonPrettyArr(arr: any[]): string {
+  private jsonPrettyArr(arr: unknown[]): string {
     return (
       this.jsonPrettyOpen('[') +
       arr.map((v) => this.jsonPrettyIndented(this.jsonPrettyAny(v))).join(',\n') +
@@ -89,11 +112,11 @@ export default abstract class DefaultCommandBase extends Command {
     )
   }
 
-  private jsonPrettyAny(val: any): string {
+  private jsonPrettyAny(val: unknown): string {
     if (Array.isArray(val)) {
       return this.jsonPrettyArr(val)
     } else if (typeof val === 'object' && val !== null) {
-      return this.jsonPrettyObj(val)
+      return this.jsonPrettyObj(val as Record<string, unknown>)
     } else if (typeof val === 'string') {
       return chalk.green(`"${val}"`)
     }
@@ -102,26 +125,26 @@ export default abstract class DefaultCommandBase extends Command {
     return chalk.cyan(val)
   }
 
-  jsonPrettyPrint(json: string) {
+  jsonPrettyPrint(json: string): void {
     try {
       const parsed = JSON.parse(json)
-      console.log(this.jsonPrettyAny(parsed))
+      this.log(this.jsonPrettyAny(parsed))
     } catch (e) {
-      console.log(this.jsonPrettyAny(json))
+      this.log(this.jsonPrettyAny(json))
     }
   }
 
-  async finally(err: any) {
+  async finally(err: Error): Promise<void> {
     // called after run and catch regardless of whether or not the command errored
     // We'll force exit here, in case there is no error, to prevent console.log from hanging the process
     if (!err) this.exit(ExitCodes.OK)
     if (err && process.env.DEBUG === 'true') {
-      console.log(err)
+      this.log(err)
     }
     super.finally(err)
   }
 
-  async init() {
+  async init(): Promise<void> {
     inquirer.registerPrompt('datetime', inquirerDatepicker)
   }
 }

+ 112 - 0
cli/src/base/MembershipsCommandBase.ts

@@ -0,0 +1,112 @@
+import ExitCodes from '../ExitCodes'
+import { MemberDetails } from '../Types'
+import { memberHandle } from '../helpers/display'
+import { MemberId } from '@joystream/types/common'
+import { flags } from '@oclif/command'
+import AccountsCommandBase from './AccountsCommandBase'
+
+/**
+ * Abstract base class for membership-related commands / commands that require membership context.
+ */
+export default abstract class MembershipsCommandBase extends AccountsCommandBase {
+  private selectedMember: MemberDetails | undefined
+
+  static flags = {
+    useMemberId: flags.integer({
+      required: false,
+      description: 'Try using the specified member id as context whenever possible',
+    }),
+  }
+
+  async getRequiredMemberContext(
+    useSelected = false,
+    allowedIds?: MemberId[],
+    accountType: 'controller' | 'root' = 'controller'
+  ): Promise<MemberDetails> {
+    const flags = this.parse(this.constructor as typeof MembershipsCommandBase).flags
+
+    if (
+      useSelected &&
+      this.selectedMember &&
+      (!allowedIds || allowedIds.some((id) => id.eq(this.selectedMember?.id)))
+    ) {
+      return this.selectedMember
+    }
+
+    if (
+      flags.useMemberId !== undefined &&
+      (!allowedIds || allowedIds.some((id) => id.toNumber() === flags.useMemberId))
+    ) {
+      this.selectedMember = await this.getApi().expectedMemberDetailsById(flags.useMemberId)
+      return this.selectedMember
+    }
+
+    const membersDetails = allowedIds
+      ? await this.getApi().membersDetailsByIds(allowedIds)
+      : await this.getApi().allMembersDetails()
+    const availableMemberships = await Promise.all(
+      membersDetails.filter((m) =>
+        this.isKeyAvailable(
+          accountType === 'controller'
+            ? m.membership.controller_account.toString()
+            : m.membership.root_account.toString()
+        )
+      )
+    )
+
+    if (!availableMemberships.length) {
+      this.error(
+        `No ${allowedIds ? 'allowed ' : ''}member ${accountType} key available!` +
+          (allowedIds ? ` Allowed members: ${allowedIds.join(', ')}.` : ''),
+        {
+          exit: ExitCodes.AccessDenied,
+        }
+      )
+    } else if (availableMemberships.length === 1) {
+      this.selectedMember = availableMemberships[0]
+    } else {
+      this.selectedMember = await this.promptForMember(availableMemberships, 'Choose member context')
+    }
+
+    return this.selectedMember
+  }
+
+  async promptForMember(availableMemberships: MemberDetails[], message = 'Choose a member'): Promise<MemberDetails> {
+    const memberIndex = await this.simplePrompt<number>({
+      type: 'list',
+      message,
+      choices: availableMemberships.map((m, i) => ({
+        name: `id: ${m.id}, handle: ${memberHandle(m)}`,
+        value: i,
+      })),
+    })
+
+    return availableMemberships[memberIndex]
+  }
+
+  async setSelectedMember(selectedMember: MemberDetails): Promise<void> {
+    this.selectedMember = selectedMember
+
+    await this.setPreservedState({ selectedMemberId: selectedMember.id.toString() })
+  }
+
+  private async initSelectedMember(): Promise<void> {
+    const memberIdString = this.getPreservedState().selectedMemberId
+
+    const memberId = this.createType('MemberId', memberIdString)
+    const members = await this.getApi().membersDetailsByIds([memberId])
+
+    // ensure selected member exists
+    if (!members.length) {
+      return
+    }
+
+    this.selectedMember = members[0]
+  }
+
+  async init(): Promise<void> {
+    await super.init()
+
+    await this.initSelectedMember()
+  }
+}

+ 4 - 0
cli/src/base/UploadCommandBase.ts

@@ -35,6 +35,10 @@ ffmpeg.setFfprobePath(ffprobeInstaller.path)
  * Abstract base class for commands that require uploading functionality
  */
 export default abstract class UploadCommandBase extends ContentDirectoryCommandBase {
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   private fileSizeCache: Map<string, number> = new Map<string, number>()
   private maxFileSize: undefined | BN = undefined
   private progressBarOptions: Options = {

+ 89 - 0
cli/src/base/WorkingGroupCommandBase.ts

@@ -0,0 +1,89 @@
+import ExitCodes from '../ExitCodes'
+import { flags } from '@oclif/command'
+import { WorkingGroups, GroupMember } from '../Types'
+import _ from 'lodash'
+import MembershipsCommandBase from './MembershipsCommandBase'
+
+/**
+ * Abstract base class for commands relying on a specific working group context
+ */
+export default abstract class WorkingGroupCommandBase extends MembershipsCommandBase {
+  _group: WorkingGroups | undefined
+
+  protected get group(): WorkingGroups {
+    if (!this._group) {
+      this.error('Trying to access WorkingGroup before initialization', {
+        exit: ExitCodes.UnexpectedException,
+      })
+    }
+    return this._group
+  }
+
+  static flags = {
+    useWorkerId: flags.integer({
+      required: false,
+      description: 'Try using the specified worker id as context whenever possible',
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async init(): Promise<void> {
+    await super.init()
+    this._group = this.getPreservedState().defaultWorkingGroup
+  }
+
+  // Use when lead access is required in given command
+  async getRequiredLeadContext(): Promise<GroupMember> {
+    const lead = await this.getApi().groupLead(this.group)
+
+    if (!lead || !this.isKeyAvailable(lead.roleAccount)) {
+      this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    }
+
+    return lead
+  }
+
+  // Use when worker access is required in given command
+  async getRequiredWorkerContext(expectedKeyType: 'Role' | 'MemberController' = 'Role'): Promise<GroupMember> {
+    const flags = this.parse(this.constructor as typeof WorkingGroupCommandBase).flags
+
+    const groupMembers = await this.getApi().groupMembers(this.group)
+    const availableGroupMemberContexts = groupMembers.filter((m) =>
+      expectedKeyType === 'Role'
+        ? this.isKeyAvailable(m.roleAccount.toString())
+        : this.isKeyAvailable(m.profile.membership.controller_account.toString())
+    )
+
+    if (!availableGroupMemberContexts.length) {
+      this.error(`No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key available!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    } else if (availableGroupMemberContexts.length === 1) {
+      return availableGroupMemberContexts[0]
+    } else {
+      const matchingContext =
+        flags.useWorkerId !== undefined &&
+        (availableGroupMemberContexts.find((c) => c.workerId.toNumber() === flags.useWorkerId) ||
+          availableGroupMemberContexts.find((c) => c.memberId.toNumber() === flags.useMemberId))
+      if (matchingContext) {
+        return matchingContext
+      }
+      return await this.promptForWorker(availableGroupMemberContexts)
+    }
+  }
+
+  async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
+    const chosenWorkerIndex = await this.simplePrompt<number>({
+      message: `Choose the intended ${_.startCase(this.group)} Group Worker context:`,
+      type: 'list',
+      choices: groupMembers.map((groupMember, index) => ({
+        name: `Worker ID ${groupMember.workerId.toString()}`,
+        value: index,
+      })),
+    })
+
+    return groupMembers[chosenWorkerIndex]
+  }
+}

+ 7 - 65
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,73 +1,14 @@
 import ExitCodes from '../ExitCodes'
-import AccountsCommandBase from './AccountsCommandBase'
 import { flags } from '@oclif/command'
-import { WorkingGroups, AvailableGroups, GroupMember, OpeningDetails, ApplicationDetails } from '../Types'
-import _ from 'lodash'
+import { AvailableGroups, GroupMember, OpeningDetails, ApplicationDetails } from '../Types'
 import chalk from 'chalk'
 import { memberHandle } from '../helpers/display'
+import WorkingGroupCommandBase from './WorkingGroupCommandBase'
 
 /**
- * Abstract base class for commands that need to use gates based on user's roles
+ * Abstract base class for commands related to all working groups
  */
-export abstract class RolesCommandBase extends AccountsCommandBase {
-  group!: WorkingGroups
-
-  async init(): Promise<void> {
-    await super.init()
-    this.group = this.getPreservedState().defaultWorkingGroup
-  }
-
-  // Use when lead access is required in given command
-  async getRequiredLeadContext(): Promise<GroupMember> {
-    const lead = await this.getApi().groupLead(this.group)
-
-    if (!lead || !this.isKeyAvailable(lead.roleAccount)) {
-      this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
-        exit: ExitCodes.AccessDenied,
-      })
-    }
-
-    return lead
-  }
-
-  // Use when worker access is required in given command
-  async getRequiredWorkerContext(expectedKeyType: 'Role' | 'MemberController' = 'Role'): Promise<GroupMember> {
-    const groupMembers = await this.getApi().groupMembers(this.group)
-    const availableGroupMemberContexts = groupMembers.filter((m) =>
-      expectedKeyType === 'Role'
-        ? this.isKeyAvailable(m.roleAccount.toString())
-        : this.isKeyAvailable(m.profile.membership.controller_account.toString())
-    )
-
-    if (!availableGroupMemberContexts.length) {
-      this.error(`No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key available!`, {
-        exit: ExitCodes.AccessDenied,
-      })
-    } else if (availableGroupMemberContexts.length === 1) {
-      return availableGroupMemberContexts[0]
-    } else {
-      return await this.promptForWorker(availableGroupMemberContexts)
-    }
-  }
-
-  async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
-    const chosenWorkerIndex = await this.simplePrompt({
-      message: `Choose the intended ${_.startCase(this.group)} Group Worker context:`,
-      type: 'list',
-      choices: groupMembers.map((groupMember, index) => ({
-        name: `Worker ID ${groupMember.workerId.toString()}`,
-        value: index,
-      })),
-    })
-
-    return groupMembers[chosenWorkerIndex]
-  }
-}
-
-/**
- * Abstract base class for commands directly related to working groups
- */
-export default abstract class WorkingGroupsCommandBase extends RolesCommandBase {
+export default abstract class WorkingGroupsCommandBase extends WorkingGroupCommandBase {
   static flags = {
     group: flags.enum({
       char: 'g',
@@ -77,10 +18,11 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
       required: false,
       options: [...AvailableGroups],
     }),
+    ...WorkingGroupCommandBase.flags,
   }
 
   async promptForApplicationsToAccept(opening: OpeningDetails): Promise<number[]> {
-    const acceptedApplications = await this.simplePrompt({
+    const acceptedApplications = await this.simplePrompt<number[]>({
       message: 'Select succesful applicants',
       type: 'checkbox',
       choices: opening.applications.map((a) => ({
@@ -141,7 +83,7 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
     await super.init()
     const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase)
     if (flags.group) {
-      this.group = flags.group
+      this._group = flags.group
     }
     this.log(chalk.magentaBright('Current Group: ' + this.group))
   }

+ 7 - 2
cli/src/commands/account/chooseMember.ts

@@ -1,10 +1,14 @@
-import AccountsCommandBase from '../../base/AccountsCommandBase'
+//import AccountsCommandBase from '../../base/AccountsCommandBase'
+import MembershipsCommandBase from '../../base/MembershipsCommandBase'
 import { MemberDetails } from '../../Types'
 import chalk from 'chalk'
 import { flags } from '@oclif/command'
 import ExitCodes from '../../ExitCodes'
 
-export default class AccountChooseMember extends AccountsCommandBase {
+// TODO: remove this command - no longer needed when we have `useMemberId` flag in `MembershipsCommandBase`
+// TODO: remove methods from MembershipsCommandBase that are used exclusively by this command
+//export default class AccountChooseMember extends AccountsCommandBase {
+export default class AccountChooseMember extends MembershipsCommandBase {
   static description = 'Choose default member to use in the CLI'
   static flags = {
     memberId: flags.string({
@@ -12,6 +16,7 @@ export default class AccountChooseMember extends AccountsCommandBase {
       char: 'm',
       required: false,
     }),
+    ...MembershipsCommandBase.flags,
   }
 
   async run() {

+ 24 - 3
cli/src/commands/account/forget.ts

@@ -2,15 +2,36 @@ import fs from 'fs'
 import chalk from 'chalk'
 import ExitCodes from '../../ExitCodes'
 import AccountsCommandBase from '../../base/AccountsCommandBase'
+import { flags } from '@oclif/command'
 
-export default class AccountForget extends AccountsCommandBase {
+export default class AccountForgetCommand extends AccountsCommandBase {
   static description = 'Forget (remove) account from the list of available accounts'
 
+  static flags = {
+    address: flags.string({
+      required: false,
+      description: 'Address of the account to remove',
+      exclusive: ['name'],
+    }),
+    name: flags.string({
+      required: false,
+      description: 'Name of the account to remove',
+      exclusive: ['address'],
+    }),
+  }
+
   async run(): Promise<void> {
-    const selecteKey = await this.promptForAccount('Select an account to forget', false, false)
+    let { address, name } = this.parse(AccountForgetCommand).flags
+
+    if (!address && !name) {
+      address = await this.promptForAccount('Select an account to forget', false, false)
+    } else if (name) {
+      address = await this.getPairByName(name).address
+    }
+
     await this.requireConfirmation('Are you sure you want to PERMANENTLY FORGET this account?')
 
-    const accountFilePath = this.getAccountFilePath(this.getPair(selecteKey).meta.name)
+    const accountFilePath = this.getAccountFilePath(this.getPair(address || '').meta.name)
 
     try {
       fs.unlinkSync(accountFilePath)

+ 4 - 0
cli/src/commands/content/addCuratorToGroup.ts

@@ -16,6 +16,10 @@ export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBas
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const lead = await this.getRequiredLeadContext()
 

+ 4 - 0
cli/src/commands/content/channel.ts

@@ -13,6 +13,10 @@ export default class ChannelCommand extends ContentDirectoryCommandBase {
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async displayMembersSet(set: BTreeSet<MemberId>): Promise<void> {
     const ids = Array.from(set)
     const members = await this.getApi().membersDetailsByIds(ids)

+ 4 - 0
cli/src/commands/content/channels.ts

@@ -5,6 +5,10 @@ import { displayTable, shortAddress } from '../../helpers/display'
 export default class ChannelsCommand extends ContentDirectoryCommandBase {
   static description = 'List existing content directory channels.'
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const channels = await this.getApi().availableChannels()
 

+ 4 - 2
cli/src/commands/content/createChannel.ts

@@ -9,6 +9,7 @@ import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import UploadCommandBase from '../../base/UploadCommandBase'
 import chalk from 'chalk'
 import { ChannelMetadata } from '@joystream/metadata-protobuf'
+import { ChannelId } from '@joystream/types/common'
 
 export default class CreateChannelCommand extends UploadCommandBase {
   static description = 'Create channel inside content directory.'
@@ -19,6 +20,7 @@ export default class CreateChannelCommand extends UploadCommandBase {
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...UploadCommandBase.flags,
   }
 
   async run(): Promise<void> {
@@ -76,8 +78,8 @@ export default class CreateChannelCommand extends UploadCommandBase {
       channelCreationParameters,
     ])
 
-    const channelCreatedEvent = this.findEvent(result, 'content', 'ChannelCreated')
-    const channelId = channelCreatedEvent!.data[1]
+    const channelCreatedEvent = this.getEvent(result, 'content', 'ChannelCreated')
+    const channelId: ChannelId = channelCreatedEvent.data[1]
     this.log(chalk.green(`Channel with id ${chalk.cyanBright(channelId.toString())} successfully created!`))
 
     const dataObjectsUploadedEvent = this.findEvent(result, 'storage', 'DataObjectsUploaded')

+ 4 - 5
cli/src/commands/content/createChannelCategory.ts

@@ -4,7 +4,7 @@ import { ChannelCategoryInputParameters } from '../../Types'
 import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
 import { flags } from '@oclif/command'
 import { CreateInterface } from '@joystream/types'
-import { ChannelCategoryCreationParameters } from '@joystream/types/content'
+import { ChannelCategoryCreationParameters, ChannelCategoryId } from '@joystream/types/content'
 import { ChannelCategoryInputSchema } from '../../schemas/ContentDirectory'
 import chalk from 'chalk'
 import { ChannelCategoryMetadata } from '@joystream/metadata-protobuf'
@@ -18,6 +18,7 @@ export default class CreateChannelCategoryCommand extends ContentDirectoryComman
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async run(): Promise<void> {
@@ -44,10 +45,8 @@ export default class CreateChannelCategoryCommand extends ContentDirectoryComman
     )
 
     if (result) {
-      const event = this.findEvent(result, 'content', 'ChannelCategoryCreated')
-      this.log(
-        chalk.green(`ChannelCategory with id ${chalk.cyanBright(event?.data[0].toString())} successfully created!`)
-      )
+      const categoryId: ChannelCategoryId = this.getEvent(result, 'content', 'ChannelCategoryCreated').data[0]
+      this.log(chalk.green(`ChannelCategory with id ${chalk.cyanBright(categoryId.toString())} successfully created!`))
     }
   }
 }

+ 3 - 0
cli/src/commands/content/createCuratorGroup.ts

@@ -4,6 +4,9 @@ import chalk from 'chalk'
 export default class CreateCuratorGroupCommand extends ContentDirectoryCommandBase {
   static description = 'Create new Curator Group.'
   static aliases = ['createCuratorGroup']
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
 
   async run(): Promise<void> {
     const lead = await this.getRequiredLeadContext()

+ 4 - 5
cli/src/commands/content/createVideo.ts

@@ -4,7 +4,7 @@ import { asValidatedMetadata, metadataToBytes } from '../../helpers/serializatio
 import { VideoInputParameters, VideoFileMetadata } from '../../Types'
 import { createType } from '@joystream/types'
 import { flags } from '@oclif/command'
-import { VideoCreationParameters } from '@joystream/types/content'
+import { VideoCreationParameters, VideoId } from '@joystream/types/content'
 import { IVideoMetadata, VideoMetadata } from '@joystream/metadata-protobuf'
 import { VideoInputSchema } from '../../schemas/ContentDirectory'
 import chalk from 'chalk'
@@ -24,6 +24,7 @@ export default class CreateVideoCommand extends UploadCommandBase {
       description: 'ID of the Channel',
     }),
     context: ContentDirectoryCommandBase.channelManagementContextFlag,
+    ...UploadCommandBase.flags,
   }
 
   setVideoMetadataDefaults(metadata: IVideoMetadata, videoFileMetadata: VideoFileMetadata): IVideoMetadata {
@@ -89,10 +90,8 @@ export default class CreateVideoCommand extends UploadCommandBase {
       videoCreationParameters,
     ])
 
-    const videoCreatedEvent = this.findEvent(result, 'content', 'VideoCreated')
-    this.log(
-      chalk.green(`Video with id ${chalk.cyanBright(videoCreatedEvent?.data[2].toString())} successfully created!`)
-    )
+    const videoId: VideoId = this.getEvent(result, 'content', 'VideoCreated').data[2]
+    this.log(chalk.green(`Video with id ${chalk.cyanBright(videoId.toString())} successfully created!`))
 
     const dataObjectsUploadedEvent = this.findEvent(result, 'storage', 'DataObjectsUploaded')
     if (dataObjectsUploadedEvent) {

+ 4 - 5
cli/src/commands/content/createVideoCategory.ts

@@ -4,7 +4,7 @@ import { VideoCategoryInputParameters } from '../../Types'
 import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
 import { flags } from '@oclif/command'
 import { CreateInterface } from '@joystream/types'
-import { VideoCategoryCreationParameters } from '@joystream/types/content'
+import { VideoCategoryCreationParameters, VideoCategoryId } from '@joystream/types/content'
 import { VideoCategoryInputSchema } from '../../schemas/ContentDirectory'
 import chalk from 'chalk'
 import { VideoCategoryMetadata } from '@joystream/metadata-protobuf'
@@ -18,6 +18,7 @@ export default class CreateVideoCategoryCommand extends ContentDirectoryCommandB
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async run(): Promise<void> {
@@ -44,10 +45,8 @@ export default class CreateVideoCategoryCommand extends ContentDirectoryCommandB
     )
 
     if (result) {
-      const event = this.findEvent(result, 'content', 'VideoCategoryCreated')
-      this.log(
-        chalk.green(`VideoCategory with id ${chalk.cyanBright(event?.data[1].toString())} successfully created!`)
-      )
+      const categoryId: VideoCategoryId = this.getEvent(result, 'content', 'VideoCategoryCreated').data[1]
+      this.log(chalk.green(`VideoCategory with id ${chalk.cyanBright(categoryId.toString())} successfully created!`))
     }
   }
 }

+ 4 - 0
cli/src/commands/content/curatorGroup.ts

@@ -13,6 +13,10 @@ export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const { id } = this.parse(CuratorGroupCommand).args
     const group = await this.getCuratorGroup(id)

+ 4 - 1
cli/src/commands/content/curatorGroups.ts

@@ -4,8 +4,11 @@ import { displayTable } from '../../helpers/display'
 
 export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
   static description = 'List existing Curator Groups.'
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
 
-  async run() {
+  async run(): Promise<void> {
     const groups = await this.getApi().availableCuratorGroups()
 
     if (groups.length) {

+ 1 - 0
cli/src/commands/content/deleteChannel.ts

@@ -21,6 +21,7 @@ export default class DeleteChannelCommand extends ContentDirectoryCommandBase {
       default: false,
       description: 'Force-remove all associated channel data objects',
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async getDataObjectsInfoFromQueryNode(channelId: number): Promise<[string, BN][]> {

+ 1 - 0
cli/src/commands/content/deleteChannelCategory.ts

@@ -4,6 +4,7 @@ export default class DeleteChannelCategoryCommand extends ContentDirectoryComman
   static description = 'Delete channel category.'
   static flags = {
     context: ContentDirectoryCommandBase.categoriesContextFlag,
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

+ 1 - 0
cli/src/commands/content/deleteVideo.ts

@@ -23,6 +23,7 @@ export default class DeleteVideoCommand extends ContentDirectoryCommandBase {
       description: 'Force-remove all associated video data objects',
     }),
     context: ContentDirectoryCommandBase.channelManagementContextFlag,
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async getDataObjectsInfo(videoId: number): Promise<[string, BN][]> {

+ 1 - 0
cli/src/commands/content/deleteVideoCategory.ts

@@ -4,6 +4,7 @@ export default class DeleteVideoCategoryCommand extends ContentDirectoryCommandB
   static description = 'Delete video category.'
   static flags = {
     context: ContentDirectoryCommandBase.categoriesContextFlag,
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

+ 1 - 0
cli/src/commands/content/removeChannelAssets.ts

@@ -18,6 +18,7 @@ export default class RemoveChannelAssetsCommand extends ContentDirectoryCommandB
       description: 'ID of an object to remove',
     }),
     context: ContentDirectoryCommandBase.channelManagementContextFlag,
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async run(): Promise<void> {

+ 4 - 0
cli/src/commands/content/removeCuratorFromGroup.ts

@@ -16,6 +16,10 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const lead = await this.getRequiredLeadContext()
 

+ 1 - 0
cli/src/commands/content/reuploadAssets.ts

@@ -14,6 +14,7 @@ export default class ReuploadVideoAssetsCommand extends UploadCommandBase {
       required: true,
       description: 'Path to JSON file containing array of assets to reupload (contentIds and paths)',
     }),
+    ...UploadCommandBase.flags,
   }
 
   async run(): Promise<void> {

+ 4 - 0
cli/src/commands/content/setCuratorGroupStatus.ts

@@ -17,6 +17,10 @@ export default class SetCuratorGroupStatusCommand extends ContentDirectoryComman
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const lead = await this.getRequiredLeadContext()
 

+ 4 - 0
cli/src/commands/content/setFeaturedVideos.ts

@@ -11,6 +11,10 @@ export default class SetFeaturedVideosCommand extends ContentDirectoryCommandBas
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const { featuredVideoIds } = this.parse(SetFeaturedVideosCommand).args
 

+ 1 - 0
cli/src/commands/content/updateChannel.ts

@@ -23,6 +23,7 @@ export default class UpdateChannelCommand extends UploadCommandBase {
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...UploadCommandBase.flags,
   }
 
   static args = [

+ 1 - 0
cli/src/commands/content/updateChannelCategory.ts

@@ -16,6 +16,7 @@ export default class UpdateChannelCategoryCommand extends ContentDirectoryComman
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

+ 1 - 0
cli/src/commands/content/updateChannelCensorshipStatus.ts

@@ -11,6 +11,7 @@ export default class UpdateChannelCensorshipStatusCommand extends ContentDirecto
       required: false,
       description: 'rationale',
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

+ 1 - 0
cli/src/commands/content/updateVideo.ts

@@ -22,6 +22,7 @@ export default class UpdateVideoCommand extends UploadCommandBase {
       description: `Path to JSON file to use as input`,
     }),
     context: ContentDirectoryCommandBase.channelManagementContextFlag,
+    ...UploadCommandBase.flags,
   }
 
   static args = [

+ 1 - 0
cli/src/commands/content/updateVideoCategory.ts

@@ -17,6 +17,7 @@ export default class UpdateVideoCategoryCommand extends ContentDirectoryCommandB
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

+ 1 - 0
cli/src/commands/content/updateVideoCensorshipStatus.ts

@@ -11,6 +11,7 @@ export default class UpdateVideoCensorshipStatusCommand extends ContentDirectory
       required: false,
       description: 'rationale',
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

+ 4 - 0
cli/src/commands/content/video.ts

@@ -11,6 +11,10 @@ export default class VideoCommand extends ContentDirectoryCommandBase {
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const { videoId } = this.parse(VideoCommand).args
     const aVideo = await this.getApi().videoById(videoId)

+ 4 - 0
cli/src/commands/content/videos.ts

@@ -13,6 +13,10 @@ export default class VideosCommand extends ContentDirectoryCommandBase {
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const { channelId } = this.parse(VideosCommand).args
 

+ 29 - 0
cli/src/commands/membership/addStakingAccount.ts

@@ -0,0 +1,29 @@
+import BN from 'bn.js'
+import { flags } from '@oclif/command'
+import MembershipsCommandBase from '../../base/MembershipsCommandBase'
+
+export default class MembershipAddStakingAccountCommand extends MembershipsCommandBase {
+  static description = 'Associate a new staking account with an existing membership.'
+  static flags = {
+    address: flags.string({
+      required: false,
+      description: 'Address of the staking account to be associated with the member',
+    }),
+    withBalance: flags.integer({
+      required: false,
+      description: 'Allows optionally specifying required initial balance for the staking account',
+    }),
+    fundsSource: flags.string({
+      required: false,
+      description:
+        'If provided, this account will be used as funds source for the purpose of initializing the staking accout',
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { address, withBalance, fundsSource } = this.parse(MembershipAddStakingAccountCommand).flags
+    const { id, membership } = await this.getRequiredMemberContext()
+    await this.setupStakingAccount(id, membership, address, new BN(withBalance || 0), fundsSource)
+  }
+}

+ 89 - 0
cli/src/commands/membership/buy.ts

@@ -0,0 +1,89 @@
+import AccountsCommandBase from '../../base/AccountsCommandBase'
+import { flags } from '@oclif/command'
+import { IMembershipMetadata, MembershipMetadata } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import chalk from 'chalk'
+import { formatBalance } from '@polkadot/util'
+import ExitCodes from '../../ExitCodes'
+
+export default class MembershipBuyCommand extends AccountsCommandBase {
+  static description = 'Buy / register a new membership on the Joystream platform.'
+  static aliases = ['membership:create', 'membership:register']
+  static flags = {
+    handle: flags.string({
+      required: true,
+      description: "Member's handle",
+    }),
+    name: flags.string({
+      required: false,
+      description: "Member's first name / full name",
+    }),
+    avatarUri: flags.string({
+      required: false,
+      description: "Member's avatar uri",
+    }),
+    about: flags.string({
+      required: false,
+      description: "Member's md-formatted about text (bio)",
+    }),
+    controllerKey: flags.string({
+      required: false,
+      description: "Member's controller key. Can also be provided interactively.",
+    }),
+    rootKey: flags.string({
+      required: false,
+      description: "Member's root key. Can also be provided interactively.",
+    }),
+    senderKey: flags.string({
+      required: false,
+      description: 'Tx sender key. If not provided, controllerKey will be used by default.',
+    }),
+  }
+
+  async run(): Promise<void> {
+    const api = this.getOriginalApi()
+    let { handle, name, avatarUri, about, controllerKey, rootKey, senderKey } = this.parse(MembershipBuyCommand).flags
+
+    if (await this.getApi().isHandleTaken(handle)) {
+      this.error(`Provided handle (${chalk.magentaBright(handle)}) is already taken!`, { exit: ExitCodes.InvalidInput })
+    }
+
+    if (!controllerKey) {
+      controllerKey = await this.promptForAnyAddress('Choose member controller key')
+    }
+    if (!rootKey) {
+      rootKey = await this.promptForAnyAddress('Choose member root key')
+    }
+    senderKey =
+      senderKey ??
+      (this.isKeyAvailable(controllerKey) ? controllerKey : await this.promptForAccount('Choose tx sender key'))
+
+    const metadata: IMembershipMetadata = {
+      name,
+      about,
+      avatarUri,
+    }
+    const membershipPrice = await api.query.members.membershipPrice()
+    this.warn(
+      `Buying membership will cost additional ${chalk.cyanBright(
+        formatBalance(membershipPrice)
+      )} on top of the regular transaction fee.`
+    )
+    this.jsonPrettyPrint(JSON.stringify({ rootKey, controllerKey, senderKey, handle, metadata }))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(senderKey),
+      api.tx.members.buyMembership({
+        root_account: rootKey,
+        controller_account: controllerKey,
+        handle,
+        metadata: metadataToBytes(MembershipMetadata, metadata),
+      })
+    )
+
+    const memberId = this.getEvent(result, 'members', 'MembershipBought').data[0]
+    this.log(chalk.green(`Membership with id ${chalk.cyanBright(memberId.toString())} successfully created!`))
+    this.output(memberId.toString())
+  }
+}

+ 42 - 0
cli/src/commands/membership/details.ts

@@ -0,0 +1,42 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import AccountsCommandBase from '../../base/AccountsCommandBase'
+import { displayCollapsedRow, displayHeader, memberHandle } from '../../helpers/display'
+
+export default class MembershipDetailsCommand extends AccountsCommandBase {
+  static description = 'Display membership details by specified memberId.'
+  static aliases = ['membership:info', 'membership:inspect', 'membership:show']
+  static flags = {
+    memberId: flags.integer({
+      required: true,
+      char: 'm',
+      description: 'Member id',
+    }),
+  }
+
+  async run(): Promise<void> {
+    const { memberId } = this.parse(MembershipDetailsCommand).flags
+    const details = await this.getApi().expectedMemberDetailsById(memberId)
+
+    displayCollapsedRow({
+      'ID': details.id.toString(),
+      'Handle': memberHandle(details),
+      'IsVerified': details.membership.verified.toString(),
+      'Invites': details.membership.invites.toNumber(),
+    })
+
+    if (details.meta) {
+      displayHeader(`Metadata`)
+      displayCollapsedRow({
+        'Name': details.meta.name || chalk.gray('NOT SET'),
+        'About': details.meta.about || chalk.gray('NOT SET'),
+      })
+    }
+
+    displayHeader('Keys')
+    displayCollapsedRow({
+      'Root': details.membership.root_account.toString(),
+      'Controller': details.membership.controller_account.toString(),
+    })
+  }
+}

+ 55 - 0
cli/src/commands/membership/update.ts

@@ -0,0 +1,55 @@
+import { flags } from '@oclif/command'
+import { IMembershipMetadata, MembershipMetadata } from '@joystream/metadata-protobuf'
+import chalk from 'chalk'
+import MembershipsCommandBase from '../../base/MembershipsCommandBase'
+import { metadataToBytes } from '../../helpers/serialization'
+
+export default class MembershipUpdateCommand extends MembershipsCommandBase {
+  static description = 'Update existing membership metadata and/or handle.'
+  static flags = {
+    newHandle: flags.string({
+      required: false,
+      description: "Member's new handle",
+    }),
+    newName: flags.string({
+      required: false,
+      description: "Member's new first name / full name",
+    }),
+    newAvatarUri: flags.string({
+      required: false,
+      description: "Member's new avatar uri",
+    }),
+    newAbout: flags.string({
+      required: false,
+      description: "Member's new md-formatted about text (bio)",
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = this.getOriginalApi()
+    const { newHandle, newName, newAvatarUri, newAbout } = this.parse(MembershipUpdateCommand).flags
+    const {
+      id: memberId,
+      membership: { controller_account: controllerKey },
+    } = await this.getRequiredMemberContext()
+
+    const newMetadata: IMembershipMetadata | null =
+      newName !== undefined || newAvatarUri !== undefined || newAbout !== undefined
+        ? {
+            name: newName,
+            about: newAbout,
+            avatarUri: newAvatarUri,
+          }
+        : null
+    this.jsonPrettyPrint(JSON.stringify({ memberId, newHandle, newMetadata }))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(controllerKey),
+      api.tx.members.updateProfile(memberId, newHandle ?? null, metadataToBytes(MembershipMetadata, newMetadata))
+    )
+
+    this.log(chalk.green(`Membership with id ${chalk.cyanBright(memberId.toString())} successfully updated!`))
+  }
+}

+ 37 - 0
cli/src/commands/membership/updateAccounts.ts

@@ -0,0 +1,37 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import MembershipsCommandBase from '../../base/MembershipsCommandBase'
+
+export default class MembershipUpdateAccountsCommand extends MembershipsCommandBase {
+  static description = 'Update existing membership accounts/keys (root / controller).'
+  static flags = {
+    newControllerAccount: flags.string({
+      required: false,
+      description: "Member's new controller account/key",
+    }),
+    newRootAccount: flags.string({
+      required: false,
+      description: "Member's new root account/key",
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = this.getOriginalApi()
+    const { newControllerAccount, newRootAccount } = this.parse(MembershipUpdateAccountsCommand).flags
+    const {
+      id: memberId,
+      membership: { root_account: rootKey },
+    } = await this.getRequiredMemberContext(false, undefined, 'root')
+
+    this.jsonPrettyPrint(JSON.stringify({ memberId, newControllerAccount, newRootAccount }))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(rootKey),
+      api.tx.members.updateAccounts(memberId, newRootAccount ?? null, newControllerAccount ?? null)
+    )
+
+    this.log(chalk.green(`Accounts of member ${chalk.cyanBright(memberId.toString())} successfully updated!`))
+  }
+}

+ 1 - 1
cli/src/commands/working-groups/application.ts

@@ -15,7 +15,7 @@ export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsApplication)
 
     const application = await this.getApi().groupApplication(this.group, parseInt(args.wgApplicationId))

+ 5 - 1
cli/src/commands/working-groups/apply.ts

@@ -14,7 +14,11 @@ export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
     },
   ]
 
-  async run() {
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
     const { openingId } = this.parse(WorkingGroupsApply).args
     const memberContext = await this.getRequiredMemberContext()
 

+ 5 - 1
cli/src/commands/working-groups/cancelOpening.ts

@@ -12,7 +12,11 @@ export default class WorkingGroupsCancelOpening extends WorkingGroupsCommandBase
     },
   ]
 
-  async run() {
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsCancelOpening)
 
     // Lead-only gate

+ 1 - 1
cli/src/commands/working-groups/createOpening.ts

@@ -19,7 +19,6 @@ const OPENING_STAKE = new BN(2000)
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
   static description = 'Create working group opening (requires lead access)'
   static flags = {
-    ...WorkingGroupsCommandBase.flags,
     input: IOFlags.input,
     output: flags.string({
       char: 'o',
@@ -40,6 +39,7 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
         '(can be used to generate a "draft" which can be provided as input later)',
       dependsOn: ['output'],
     }),
+    ...WorkingGroupsCommandBase.flags,
   }
 
   createTxParams(

+ 1 - 1
cli/src/commands/working-groups/evictWorker.ts

@@ -18,7 +18,6 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
   ]
 
   static flags = {
-    ...WorkingGroupsCommandBase.flags,
     penalty: flags.string({
       description: 'Optional penalty in JOY',
       required: false,
@@ -27,6 +26,7 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
       description: 'Optional rationale',
       required: false,
     }),
+    ...WorkingGroupsCommandBase.flags,
   }
 
   async run(): Promise<void> {

+ 1 - 1
cli/src/commands/working-groups/opening.ts

@@ -16,7 +16,7 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsOpening)
 
     const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId))

+ 1 - 1
cli/src/commands/working-groups/openings.ts

@@ -7,7 +7,7 @@ export default class WorkingGroupsOpenings extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const openings = await this.getApi().openingsByGroup(this.group)
 
     const openingsRows = openings.map((o) => ({

+ 1 - 1
cli/src/commands/working-groups/overview.ts

@@ -10,7 +10,7 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const lead = await this.getApi().groupLead(this.group)
     const members = await this.getApi().groupMembers(this.group)
 

+ 1 - 1
cli/src/commands/working-groups/setDefaultGroup.ts

@@ -6,7 +6,7 @@ export default class SetDefaultGroupCommand extends WorkingGroupsCommandBase {
   static description = 'Change the default group context for working-groups commands.'
   static flags = { ...WorkingGroupsCommandBase.flags }
 
-  async run() {
+  async run(): Promise<void> {
     const {
       flags: { group },
     } = this.parse(SetDefaultGroupCommand)

+ 1 - 1
devops/aws/cloudformation/infrastructure.yml

@@ -111,7 +111,7 @@ Resources:
         BlockDeviceMappings:
           - DeviceName: /dev/sda1
             Ebs:
-              VolumeSize: '40'
+              VolumeSize: '120'
         UserData:
           Fn::Base64: !Sub |
             #!/bin/bash -xe

+ 1 - 1
devops/aws/cloudformation/single-instance-docker.yml

@@ -58,7 +58,7 @@ Resources:
         BlockDeviceMappings:
           - DeviceName: /dev/sda1
             Ebs:
-              VolumeSize: '30'
+              VolumeSize: '120'
         UserData:
           Fn::Base64: !Sub |
             #!/bin/bash -xe

+ 1 - 1
devops/aws/cloudformation/single-instance.yml

@@ -48,7 +48,7 @@ Resources:
         BlockDeviceMappings:
           - DeviceName: /dev/sda1
             Ebs:
-              VolumeSize: '30'
+              VolumeSize: '120'
         UserData:
           Fn::Base64: !Sub |
             #!/bin/bash -xe

+ 41 - 11
devops/aws/deploy-playground-playbook.yml

@@ -55,6 +55,19 @@
       command: yarn build:node:docker
       args:
         chdir: '{{ remote_code_path }}'
+      async: 3600
+      poll: 0
+      register: node_build_result
+
+    - name: Check on build node image async task
+      async_status:
+        jid: '{{ node_build_result.ansible_job_id }}'
+      register: job_result
+      until: job_result.finished
+      # Max number of times to check for status
+      retries: 36
+      # Check for the status every 100s
+      delay: 100
 
     - name: Run docker-compose
       command: yarn start
@@ -64,6 +77,7 @@
         PERSIST: 'true'
         COLOSSUS_1_URL: 'https://{{ inventory_hostname }}.nip.io/colossus-1/'
         DISTRIBUTOR_1_URL: 'https://{{ inventory_hostname }}.nip.io/distributor-1/'
+        SKIP_CHAIN_SETUP: '{{ skip_chain_setup }}'
       async: 1800
       poll: 0
       register: compose_result
@@ -93,16 +107,32 @@
         caddy_systemd_capabilities_enabled: true
         caddy_update: false
 
+    - name: Set endpoints
+      set_fact:
+        all_services: |
+          websocket_rpc: wss://{{ nip_domain }}/ws-rpc
+          http_rpc: https://{{ nip_domain }}/http-rpc
+          colossus: https://{{ nip_domain }}/colossus-1
+          distributor: https://{{ nip_domain }}/distributor-1
+          graphql_server: https://{{ nip_domain }}/query-node/server/graphql
+          graphql_server_websocket: wss://{{ nip_domain }}/query-node/server/graphql
+          indexer: https://{{ nip_domain }}/query-node/indexer/graphql
+          member_faucet: https://{{ nip_domain }}/member-faucet/register
+          orion: https://{{ nip_domain }}/orion/graphql
+      run_once: yes
+
     - name: Print endpoints
       debug:
-        msg:
-          - 'The services should now be accesible at:'
-          - 'Pioneer: https://{{ nip_domain }}/'
-          - 'WebSocket RPC: wss://{{ nip_domain }}/ws-rpc'
-          - 'HTTP RPC: https://{{ nip_domain }}/http-rpc'
-          - 'Colossus: https://{{ nip_domain }}/colossus-1'
-          - 'Distributor: https://{{ nip_domain }}/distributor-1'
-          - 'GraphQL server: https://{{ nip_domain }}/query-node/server/graphql'
-          - 'Indexer: https://{{ nip_domain }}/query-node/indexer/graphql'
-          - 'Member Faucet: https://{{ nip_domain }}/member-faucet/register'
-          - 'Orion: https://{{ nip_domain }}/orion/graphql'
+        msg: '{{ all_services | from_yaml }}'
+      run_once: yes
+
+    - name: Create config.json to serve as Caddy endpoint
+      copy:
+        content: '{{ all_services | from_yaml | to_json }}'
+        dest: '/home/ubuntu/config.json'
+
+    - name: Save output as file on local
+      copy:
+        content: '{{ all_services | from_yaml | to_json }}'
+        dest: 'endpoints.json'
+      delegate_to: localhost

+ 1 - 1
devops/aws/deploy-playground.sh

@@ -42,5 +42,5 @@ if [ $? -eq 0 ]; then
 
   echo -e "\n\n=========== Configuring node ==========="
   ansible-playbook -i $SERVER_IP, --private-key $KEY_PATH deploy-playground-playbook.yml \
-    --extra-vars "branch_name=$BRANCH_NAME git_repo=$GIT_REPO"
+    --extra-vars "branch_name=$BRANCH_NAME git_repo=$GIT_REPO skip_chain_setup=$SKIP_CHAIN_SETUP"
 fi

+ 1 - 0
devops/aws/deploy-single-node.sample.cfg

@@ -20,3 +20,4 @@ CHAIN_SPEC_FILE="https://github.com/Joystream/joystream/releases/download/v9.14.
 # For deploy playground playbook only
 GIT_REPO="https://github.com/Joystream/joystream.git"
 BRANCH_NAME="master"
+SKIP_CHAIN_SETUP="false"

+ 9 - 3
devops/aws/templates/Playground-Caddyfile.j2

@@ -28,6 +28,11 @@
     reverse_proxy localhost:8081
 }
 
+wss://{{ nip_domain }}/query-node/server* {
+    uri strip_prefix /query-node/server
+    reverse_proxy localhost:8081
+}
+
 {{ nip_domain }}/query-node/indexer* {
     uri strip_prefix /query-node/indexer
     reverse_proxy localhost:4000
@@ -43,7 +48,8 @@
     reverse_proxy localhost:3002
 }
 
-# Pioneer
-{{ nip_domain }}/* {
-    reverse_proxy localhost:3000
+{{ nip_domain }}/config.json {
+    root * /home/ubuntu
+    rewrite * /config.json
+    file_server
 }

+ 7 - 12
docker-compose.yml

@@ -1,16 +1,9 @@
-# Compiles new joystream/node and joystream/apps images if local images not found
-# and runs a complete joystream development network
-# To prevent build of docker images run docker-compose with "--no-build" arg
+# Complete joystream development network
 version: '3.4'
 services:
   joystream-node:
     image: joystream/node:$JOYSTREAM_NODE_TAG
     restart: unless-stopped
-    build:
-      # context is relative to the compose file
-      context: .
-      # dockerfile is relative to the context
-      dockerfile: joystream-node.Dockerfile
     container_name: joystream-node
     volumes:
       - chain-data:/data
@@ -264,14 +257,16 @@ services:
       - "127.0.0.1:6379:6379"
 
   faucet:
-    image: joystream/faucet:giza
+    image: joystream/faucet:olympia
     restart: on-failure
     container_name: faucet
+    env_file:
+      - .env
     environment:
-      - SCREENING_AUTHORITY_SEED=//Alice
+      - SCREENING_AUTHORITY_SEED=${SCREENING_AUTHORITY_SEED}
       - PORT=3002
-      - PROVIDER=ws://joystream-node:9944
-      - ENDOWMENT=0
+      - INVITING_MEMBER_ID=${INVITING_MEMBER_ID}
+      - PROVIDER=${JOYSTREAM_NODE_WS}
     ports:
       - "3002:3002"
 

+ 2 - 2
query-node/mappings/src/workingGroups.ts

@@ -177,7 +177,7 @@ export async function createWorkingGroupOpeningMetadata(
     description: description || undefined,
     shortDescription: shortDescription || undefined,
     hiringLimit: hiringLimit || undefined,
-    expectedEnding: expectedEndingTimestamp ? new Date(expectedEndingTimestamp) : undefined,
+    expectedEnding: expectedEndingTimestamp ? new Date(expectedEndingTimestamp * 1000) : undefined,
     applicationFormQuestions: [],
   })
 
@@ -249,7 +249,7 @@ async function handleAddUpcomingOpeningAction(
     metadata: openingMeta,
     group,
     rewardPerBlock: isSet(rewardPerBlock) && parseInt(rewardPerBlock) ? new BN(rewardPerBlock) : undefined,
-    expectedStart: expectedStart ? new Date(expectedStart) : undefined,
+    expectedStart: expectedStart ? new Date(expectedStart * 1000) : undefined,
     stakeAmount: isSet(minApplicationStake) && parseInt(minApplicationStake) ? new BN(minApplicationStake) : undefined,
     createdInEvent: statusChangedEvent,
   })

+ 6 - 4
query-node/run-tests.sh

@@ -8,13 +8,15 @@ set -a
 . ../.env
 set +a
 
+export JOYSTREAM_NODE_TAG=${JOYSTREAM_NODE_TAG:=$(../scripts/runtime-code-shasum.sh)}
+
 function cleanup() {
     # Show tail end of logs for the processor and indexer containers to
     # see any possible errors
-    (echo "\n\n## Processor Logs ##" && docker logs joystream_processor_1 --tail 50) || :
-    (echo "\n\n## Indexer Logs ##" && docker logs joystream_indexer_1 --tail 50) || :
-    (echo "\n\n## Indexer API Gateway Logs ##" && docker logs joystream_hydra-indexer-gateway_1 --tail 50) || :
-    (echo "\n\n## Graphql Server Logs ##" && docker logs joystream_graphql-server_1 --tail 50) || :
+    (echo "\n\n## Processor Logs ##" && docker logs processor --tail 50) || :
+    (echo "\n\n## Indexer Logs ##" && docker logs indexer --tail 50) || :
+    (echo "\n\n## Indexer API Gateway Logs ##" && docker logs hydra-indexer-gateway --tail 50) || :
+    (echo "\n\n## Graphql Server Logs ##" && docker logs graphql-server --tail 50) || :
     docker-compose down -v
 }
 

+ 1 - 1
scripts/generate-weights.sh

@@ -68,4 +68,4 @@ benchmark membership
 benchmark bounty
 benchmark blog
 benchmark joystream_utility
-#benchmark storage
+# benchmark storage

+ 5 - 3
setup.sh

@@ -9,9 +9,11 @@ if [[ "$OSTYPE" == "linux-gnu" ]]; then
     sudo apt-get update
     sudo apt-get install -y coreutils clang llvm jq curl gcc xz-utils sudo pkg-config unzip libc6-dev make libssl-dev python
     # docker
-    sudo apt-get install -y docker.io docker-compose containerd runc
-    # older linux distro may install old version of docker-compose
-    # Minimum required v1.29 - see https://docs.docker.com/compose/install/
+    sudo apt-get install -y docker.io containerd runc
+    # docker-compose
+    sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
+    sudo chmod +x /usr/local/bin/docker-compose
+    sudo ln -sf /usr/local/bin/docker-compose /usr/bin/docker-compose
 elif [[ "$OSTYPE" == "darwin"* ]]; then
     # install brew package manager
     if ! which brew >/dev/null 2>&1; then

+ 14 - 7
start.sh

@@ -2,6 +2,7 @@
 set -e
 
 # Run a complete joystream development network on your machine using docker
+export JOYSTREAM_NODE_TAG=${JOYSTREAM_NODE_TAG:=$(./scripts/runtime-code-shasum.sh)}
 
 INIT_CHAIN_SCENARIO=${INIT_CHAIN_SCENARIO:=setupNewChain}
 
@@ -25,13 +26,19 @@ fi
 docker-compose up -d joystream-node
 
 ## Init the chain with some state
-export SKIP_MOCK_CONTENT=true
-export SKIP_QUERY_NODE_CHECKS=true
-HOST_IP=$(tests/network-tests/get-host-ip.sh)
-export COLOSSUS_1_URL=${COLOSSUS_1_URL:="http://${HOST_IP}:3333"}
-export COLOSSUS_1_TRANSACTOR_KEY=$(docker run --rm --pull=always docker.io/parity/subkey:2.0.1 inspect ${COLOSSUS_1_TRANSACTOR_URI} --output-type json | jq .ss58Address -r)
-export DISTRIBUTOR_1_URL=${DISTRIBUTOR_1_URL:="http://${HOST_IP}:3334"}
-./tests/integration-tests/run-test-scenario.sh ${INIT_CHAIN_SCENARIO}
+if [[ $SKIP_CHAIN_SETUP != 'true' ]]; then
+  set -a
+  . ./.env
+  set +a
+
+  export SKIP_MOCK_CONTENT=true
+  export SKIP_QUERY_NODE_CHECKS=true
+  HOST_IP=$(tests/network-tests/get-host-ip.sh)
+  export COLOSSUS_1_URL=${COLOSSUS_1_URL:="http://${HOST_IP}:3333"}
+  export COLOSSUS_1_TRANSACTOR_KEY=$(docker run --rm --pull=always docker.io/parity/subkey:2.0.1 inspect ${COLOSSUS_1_TRANSACTOR_URI} --output-type json | jq .ss58Address -r)
+  export DISTRIBUTOR_1_URL=${DISTRIBUTOR_1_URL:="http://${HOST_IP}:3334"}
+  ./tests/integration-tests/run-test-scenario.sh ${INIT_CHAIN_SCENARIO}
+fi
 
 ## Member faucet
 docker-compose up -d faucet

+ 1 - 1
tests/integration-tests/src/fixtures/workingGroups/CreateUpcomingOpeningsFixture.ts

@@ -103,7 +103,7 @@ export class CreateUpcomingOpeningsFixture extends BaseWorkingGroupFixture {
         Utils.assert(qUpcomingOpening)
         assert.equal(
           qUpcomingOpening.expectedStart
-            ? new Date(qUpcomingOpening.expectedStart).getTime()
+            ? moment(qUpcomingOpening.expectedStart).unix()
             : qUpcomingOpening.expectedStart,
           expectedMeta.expectedStart || null
         )

+ 2 - 2
tests/integration-tests/src/fixtures/workingGroups/utils.ts

@@ -1,7 +1,7 @@
 import { IOpeningMetadata, OpeningMetadata } from '@joystream/metadata-protobuf'
 import { assert } from 'chai'
 import { OpeningMetadataFieldsFragment } from '../../graphql/generated/queries'
-
+import moment from 'moment'
 import { ApplicationFormQuestionType } from '../../graphql/generated/schema'
 
 export const queryNodeQuestionTypeToMetadataQuestionType = (
@@ -29,7 +29,7 @@ export const assertQueriedOpeningMetadataIsValid = (
   assert.equal(qOpeningMeta.shortDescription, shortDescription || null)
   assert.equal(qOpeningMeta.description, description || null)
   assert.equal(
-    qOpeningMeta.expectedEnding ? new Date(qOpeningMeta.expectedEnding).getTime() : qOpeningMeta.expectedEnding,
+    qOpeningMeta.expectedEnding ? moment(qOpeningMeta.expectedEnding).unix() : qOpeningMeta.expectedEnding,
     expectedEndingTimestamp || null
   )
   assert.equal(qOpeningMeta.hiringLimit, hiringLimit || null)

+ 1 - 1
tests/network-tests/run-migration-tests.sh

@@ -5,7 +5,7 @@ SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
 cd $SCRIPT_PATH
 
 # The joystream/node docker image tag which contains WASM runtime to upgrade chain with
-TARGET_RUNTIME_TAG=${TARGET_RUNTIME_TAG:=latest}
+TARGET_RUNTIME_TAG=${TARGET_RUNTIME_TAG:=$(../../scripts/runtime-code-shasum.sh)}
 # The joystream/node docker image tag to start the chain with
 RUNTIME_TAG=${RUNTIME_TAG:=sumer}
 # Post migration assertions by means of typescript scenarios required

+ 1 - 1
tests/network-tests/run-test-node-docker.sh

@@ -26,7 +26,7 @@ TREASURY_ACCOUNT=$(docker run --rm --pull=always docker.io/parity/subkey:2.0.1 i
 >&2 echo "treasury account from suri: ${TREASURY_ACCOUNT}"
 
 # The docker image tag to use for joystream/node
-RUNTIME=${RUNTIME:=latest}
+RUNTIME=${RUNTIME:=$(../../scripts/runtime-code-shasum.sh)}
 
 echo "{
   \"balances\":[

Some files were not shown because too many files changed in this diff