Bladeren bron

Merge branch 'olympia-playground' into faucet-init-3196

Mokhtar Naamani 3 jaren geleden
bovenliggende
commit
86fcdb8965
100 gewijzigde bestanden met toevoegingen van 4041 en 703 verwijderingen
  1. 9 3
      .github/workflows/create-ami.yml
  2. 48 15
      .github/workflows/deploy-node-network.yml
  3. 9 4
      .github/workflows/deploy-playground.yml
  4. 1 1
      .github/workflows/joystream-node-docker.yml
  5. 3 0
      .gitignore
  6. 19 3
      .pipelines/deploy-node-network-inputs.json
  7. 3 16
      Cargo.lock
  8. 0 1
      Cargo.toml
  9. 0 0
      chain-metadata.json
  10. 662 89
      cli/README.md
  11. 17 0
      cli/examples/working-groups/CreateOpening.json
  12. 6 0
      cli/examples/working-groups/UpdateMetadata.json
  13. 7 4
      cli/package.json
  14. 51 42
      cli/scripts/content-test.sh
  15. 93 0
      cli/scripts/forum-test.sh
  16. 98 0
      cli/scripts/membership-test.sh
  17. 61 0
      cli/scripts/working-groups-test.sh
  18. 131 27
      cli/src/Api.ts
  19. 1 0
      cli/src/Consts.ts
  20. 14 12
      cli/src/ExitCodes.ts
  21. 71 2
      cli/src/QueryNodeApi.ts
  22. 56 7
      cli/src/Types.ts
  23. 114 125
      cli/src/base/AccountsCommandBase.ts
  24. 78 21
      cli/src/base/ApiCommandBase.ts
  25. 11 7
      cli/src/base/ContentDirectoryCommandBase.ts
  26. 40 17
      cli/src/base/DefaultCommandBase.ts
  27. 165 0
      cli/src/base/ForumCommandBase.ts
  28. 112 0
      cli/src/base/MembershipsCommandBase.ts
  29. 1 0
      cli/src/base/StateAwareCommandBase.ts
  30. 4 0
      cli/src/base/UploadCommandBase.ts
  31. 104 0
      cli/src/base/WorkingGroupCommandBase.ts
  32. 7 65
      cli/src/base/WorkingGroupsCommandBase.ts
  33. 24 3
      cli/src/commands/account/forget.ts
  34. 2 1
      cli/src/commands/account/info.ts
  35. 4 0
      cli/src/commands/content/addCuratorToGroup.ts
  36. 4 0
      cli/src/commands/content/channel.ts
  37. 4 0
      cli/src/commands/content/channels.ts
  38. 4 2
      cli/src/commands/content/createChannel.ts
  39. 4 5
      cli/src/commands/content/createChannelCategory.ts
  40. 3 0
      cli/src/commands/content/createCuratorGroup.ts
  41. 4 5
      cli/src/commands/content/createVideo.ts
  42. 4 5
      cli/src/commands/content/createVideoCategory.ts
  43. 4 0
      cli/src/commands/content/curatorGroup.ts
  44. 4 1
      cli/src/commands/content/curatorGroups.ts
  45. 1 0
      cli/src/commands/content/deleteChannel.ts
  46. 1 0
      cli/src/commands/content/deleteChannelCategory.ts
  47. 1 0
      cli/src/commands/content/deleteVideo.ts
  48. 1 0
      cli/src/commands/content/deleteVideoCategory.ts
  49. 1 0
      cli/src/commands/content/removeChannelAssets.ts
  50. 4 0
      cli/src/commands/content/removeCuratorFromGroup.ts
  51. 1 0
      cli/src/commands/content/reuploadAssets.ts
  52. 4 0
      cli/src/commands/content/setCuratorGroupStatus.ts
  53. 4 0
      cli/src/commands/content/setFeaturedVideos.ts
  54. 1 0
      cli/src/commands/content/updateChannel.ts
  55. 1 0
      cli/src/commands/content/updateChannelCategory.ts
  56. 1 0
      cli/src/commands/content/updateChannelCensorshipStatus.ts
  57. 1 0
      cli/src/commands/content/updateVideo.ts
  58. 1 0
      cli/src/commands/content/updateVideoCategory.ts
  59. 1 0
      cli/src/commands/content/updateVideoCensorshipStatus.ts
  60. 4 0
      cli/src/commands/content/video.ts
  61. 4 0
      cli/src/commands/content/videos.ts
  62. 52 0
      cli/src/commands/forum/addPost.ts
  63. 91 0
      cli/src/commands/forum/categories.ts
  64. 65 0
      cli/src/commands/forum/category.ts
  65. 55 0
      cli/src/commands/forum/createCategory.ts
  66. 52 0
      cli/src/commands/forum/createThread.ts
  67. 53 0
      cli/src/commands/forum/deleteCategory.ts
  68. 54 0
      cli/src/commands/forum/moderatePost.ts
  69. 46 0
      cli/src/commands/forum/moderateThread.ts
  70. 51 0
      cli/src/commands/forum/moveThread.ts
  71. 37 0
      cli/src/commands/forum/posts.ts
  72. 46 0
      cli/src/commands/forum/setStickiedThreads.ts
  73. 38 0
      cli/src/commands/forum/threads.ts
  74. 49 0
      cli/src/commands/forum/updateCategoryArchivalStatus.ts
  75. 50 0
      cli/src/commands/forum/updateCategoryModeratorStatus.ts
  76. 29 0
      cli/src/commands/membership/addStakingAccount.ts
  77. 89 0
      cli/src/commands/membership/buy.ts
  78. 56 0
      cli/src/commands/membership/chooseMember.ts
  79. 42 0
      cli/src/commands/membership/details.ts
  80. 55 0
      cli/src/commands/membership/update.ts
  81. 37 0
      cli/src/commands/membership/updateAccounts.ts
  82. 11 3
      cli/src/commands/working-groups/application.ts
  83. 89 22
      cli/src/commands/working-groups/apply.ts
  84. 5 1
      cli/src/commands/working-groups/cancelOpening.ts
  85. 179 44
      cli/src/commands/working-groups/createOpening.ts
  86. 1 1
      cli/src/commands/working-groups/evictWorker.ts
  87. 13 10
      cli/src/commands/working-groups/fillOpening.ts
  88. 81 27
      cli/src/commands/working-groups/opening.ts
  89. 31 9
      cli/src/commands/working-groups/openings.ts
  90. 1 1
      cli/src/commands/working-groups/overview.ts
  91. 85 0
      cli/src/commands/working-groups/removeUpcomingOpening.ts
  92. 1 1
      cli/src/commands/working-groups/setDefaultGroup.ts
  93. 54 0
      cli/src/commands/working-groups/updateGroupMetadata.ts
  94. 151 0
      cli/src/graphql/generated/queries.ts
  95. 69 0
      cli/src/graphql/queries/workingGroups.graphql
  96. 6 1
      cli/src/schemas/ContentDirectory.ts
  97. 68 0
      cli/src/schemas/WorkingGroups.ts
  98. 0 37
      devops/aws/chain-spec-pioneer.yml
  99. 29 63
      devops/aws/cloudformation/infrastructure.yml
  100. 27 0
      devops/aws/configure-network.yml

+ 9 - 3
.github/workflows/create-ami.yml

@@ -4,6 +4,10 @@ name: Create AWS AMI
 
 on:
   workflow_dispatch:
+    inputs:
+      proposalParameters:
+        description: 'Proposal Parameters (refer to runtime/src/proposals_configuration/sample_proposal_parameters.json for sample)'
+        required: false
 
 jobs:
   build:
@@ -11,7 +15,7 @@ jobs:
     runs-on: ubuntu-latest
     env:
       STACK_NAME: create-joystream-node-ami-ga-${{ github.run_number }}
-      KEY_NAME: joystream-github-action-key
+      KEY_NAME: joystream-github-action-key-new
     steps:
       - name: Extract branch name
         shell: bash
@@ -57,8 +61,10 @@ jobs:
             ${{ steps.deploy_stack.outputs.PublicIp }}
           options: |
             --extra-vars "git_repo=https://github.com/${{ github.repository }} \
-                          branch_name=${{ steps.extract_branch.outputs.branch }} instance_id=${{ steps.deploy_stack.outputs.InstanceId }}
-                          ami_name=${{ env.ami_name }}"
+                          branch_name=${{ steps.extract_branch.outputs.branch }} \
+                          instance_id=${{ steps.deploy_stack.outputs.InstanceId }} \
+                          ami_name=${{ env.ami_name }} \
+                          proposal_parameters=${{ github.event.inputs.proposalParameters }}"
 
       - name: Delete CloudFormation Stack
         if: always()

+ 48 - 15
.github/workflows/deploy-node-network.yml

@@ -39,8 +39,20 @@ jobs:
           echo ::set-output name=ec2AMI::$(echo $jsonInput | jq -r '.ec2AMI.value')
           echo ::set-output name=networkSuffix::$(echo $jsonInput | jq -r '.networkSuffix.value')
           echo ::set-output name=deploymentType::$(echo $jsonInput | jq -r '.deploymentType.value')
+          echo ::set-output name=volumeSize::$(echo $jsonInput | jq -r '.volumeSize.value')
+          echo ::set-output name=rpcVolumeSize::$(echo $jsonInput | jq -r '.rpcVolumeSize.value')
+          echo ::set-output name=skipChainSetup::$(echo $jsonInput | jq -r '.skipChainSetup.value')
           initialBalancesFile=$(echo $jsonInput | jq -r '.initialBalancesFile.value')
           initialMembersFile=$(echo $jsonInput | jq -r '.initialMembersFile.value')
+          proposalParametersInput=$(echo $jsonInput | jq -r '.proposalParameters.value')
+
+          if [ $proposalParametersInput = "{}" ]
+          then
+            echo ::set-output name=proposalParameters::''
+          else
+            echo ::set-output name=proposalParameters::$proposalParametersInput
+          fi
+
           if [ -z "$initialBalancesFile" ]
           then
             echo ::set-output name=initialBalancesFilePath::''
@@ -48,6 +60,7 @@ jobs:
             wget $initialBalancesFile -O initial-balances.json
             echo ::set-output name=initialBalancesFilePath::'initial-balances.json'
           fi
+
           if [ -z "$initialMembersFile" ]
           then
             echo ::set-output name=initialMembersFilePath::''
@@ -66,6 +79,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
@@ -79,7 +102,9 @@ jobs:
             RPCEC2InstanceType=${{ steps.myoutputs.outputs.rpcInstanceType }},
             BuildEC2InstanceType=${{ steps.myoutputs.outputs.buildInstanceType }},
             EC2AMI=${{ steps.myoutputs.outputs.ec2AMI }},
-            NumberOfValidators=${{ steps.myoutputs.outputs.numberOfValidators }}
+            NumberOfValidators=${{ steps.myoutputs.outputs.numberOfValidators }},
+            VolumeSize=${{ steps.myoutputs.outputs.volumeSize }},
+            RPCVolumeSize=${{ steps.myoutputs.outputs.rpcVolumeSize }}
 
       - name: Prepare inventory for Ansible
         run: |
@@ -103,7 +128,8 @@ jobs:
 
       - name: Run playbook to compile joystream-node on build server
         uses: dawidd6/action-ansible-playbook@v2
-        if: steps.myoutputs.outputs.ec2AMI == ''
+        # Build binaries if AMI not specified or a custom proposals parameter is passed
+        if: steps.myoutputs.outputs.ec2AMI == '' || steps.myoutputs.outputs.proposalParameters != ''
         with:
           playbook: build-code.yml
           directory: devops/aws
@@ -112,23 +138,24 @@ jobs:
           options: |
             --inventory inventory
             --extra-vars "branch_name=${{ steps.myoutputs.outputs.branchName }} \
-                          git_repo=${{ steps.myoutputs.outputs.gitRepo }} data_path=mydata"
+                          git_repo=${{ steps.myoutputs.outputs.gitRepo }} data_path=mydata \
+                          proposal_parameters=${{ steps.myoutputs.outputs.proposalParameters }}"
 
       - name: Run playbook to install additional utils on build server
         uses: dawidd6/action-ansible-playbook@v2
         if: steps.myoutputs.outputs.ec2AMI == ''
         with:
-          playbook: setup-admin.yml
+          playbook: setup-build-server.yml
           directory: devops/aws
           requirements: requirements.yml
           key: ${{ secrets.SSH_PRIVATE_KEY }}
           options: |
             --inventory inventory
 
-      - name: Run playbook to configure chain-spec and pioneer
+      - name: Run playbook to configure chain-spec
         uses: dawidd6/action-ansible-playbook@v2
         with:
-          playbook: chain-spec-pioneer.yml
+          playbook: configure-network.yml
           directory: devops/aws
           requirements: requirements.yml
           key: ${{ secrets.SSH_PRIVATE_KEY }}
@@ -138,10 +165,16 @@ jobs:
                           number_of_validators=${{ steps.myoutputs.outputs.numberOfValidators }} \
                           git_repo=${{ steps.myoutputs.outputs.gitRepo }} \
                           deployment_type=${{ steps.myoutputs.outputs.deploymentType }} \
-                          bucket_name=${{ steps.deploy_stack.outputs.S3BucketName }} \
                           branch_name=${{ steps.myoutputs.outputs.branchName }} \
                           initial_members_file=${{ steps.myoutputs.outputs.initialMembersFilePath }} \
-                          initial_balances_file=${{ steps.myoutputs.outputs.initialBalancesFilePath }}"
+                          initial_balances_file=${{ steps.myoutputs.outputs.initialBalancesFilePath }} \
+                          skip_chain_setup=${{ steps.myoutputs.outputs.skipChainSetup }}"
+
+      - name: Terminate Build instance
+        continue-on-error: true
+        run: |
+          echo "Deleting build instance with id ${{ steps.deploy_stack.outputs.BuildInstanceId }}"
+          aws ec2 terminate-instances --instance-ids ${{ steps.deploy_stack.outputs.BuildInstanceId }}
 
       - name: Encrpyt the artifacts
         run: |
@@ -153,16 +186,16 @@ jobs:
           name: data-chainspec-auth
           path: devops/aws/chain-data.7z
 
-      - name: Print output URL's
-        run: |
-          echo -e "Pioneer URL: https://${{ steps.deploy_stack.outputs.DomainName }}"
-          echo -e "RPC: wss://${{ steps.deploy_stack.outputs.RPCPublicIp }}.nip.io/ws-rpc"
+      - 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
-        if: failure()
+        # Skip only if stack already existed or all steps passed succesfully
+        if: ( failure() || cancelled() ) && steps.stack_exists.outcome != 'failure'
         run: |
-          echo "Empty the bucket"
-          aws s3 rm s3://${{ steps.deploy_stack.outputs.S3BucketName }} --recursive || echo "No bucket"
           echo "Deleting ${{ env.STACK_NAME }} stack"
           aws cloudformation delete-stack --stack-name ${{ env.STACK_NAME }}
           echo "Waiting for ${{ env.STACK_NAME }} to be deleted..."

+ 9 - 4
.github/workflows/deploy-playground.yml

@@ -14,7 +14,7 @@ on:
       keyName:
         description: 'SSH key pair on AWS'
         required: false
-        default: 'joystream-github-action-key'
+        default: 'joystream-github-action-key-new'
       instanceType:
         description: 'AWS EC2 instance type (t2.micro, t2.large)'
         required: false
@@ -27,6 +27,9 @@ on:
         description: 'Optionally skip running newChainSetup script (true or false)'
         required: true
         default: 'false'
+      proposalParameters:
+        description: 'Proposal Parameters (refer to runtime/src/proposals_configuration/sample_proposal_parameters.json for sample)'
+        required: false
       # TODO: customDomain instead of ip_address.nip.io
       # customDomain:
       #   description: 'DNS hostname to use for deployment'
@@ -88,7 +91,9 @@ jobs:
           options: |
             --extra-vars "git_repo=${{ github.event.inputs.gitRepo }} \
                           branch_name=${{ github.event.inputs.branchName }} \
-                          skip_chain_setup=${{ github.event.inputs.skipChainSetup }}"
+                          skip_chain_setup=${{ github.event.inputs.skipChainSetup }} \
+                          stack_name=${{ env.STACK_NAME }} \
+                          proposal_parameters=${{ github.event.inputs.proposalParameters }}"
 
       - name: Save the endpoints file as an artifact
         uses: actions/upload-artifact@v2
@@ -97,8 +102,8 @@ jobs:
           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'
+        # Skip only if stack already existed or all steps passed successfully
+        if: ( failure() || cancelled() ) && steps.stack_exists.outcome != 'failure'
         run: |
           echo "Deleting ${{ env.STACK_NAME }} stack"
           aws cloudformation delete-stack --stack-name ${{ env.STACK_NAME }}

+ 1 - 1
.github/workflows/joystream-node-docker.yml

@@ -4,7 +4,7 @@ on: push
 
 env:
   REPOSITORY: joystream/node
-  KEY_NAME: joystream-github-action-key
+  KEY_NAME: joystream-github-action-key-new
 
 jobs:
   push-amd64:

+ 3 - 0
.gitignore

@@ -40,6 +40,9 @@ yarn*
 test-data/
 tmp.*
 
+# env variables saved as artifacts for builds
+runtime-inputs/
+
 .my_setup
 
 devops/infrastructure

+ 19 - 3
.pipelines/deploy-node-network-inputs.json

@@ -9,7 +9,7 @@
   },
   "keyName": {
     "description": "SSH key pair on AWS",
-    "value": "joystream-github-action-key"
+    "value": "joystream-github-action-key-new"
   },
   "numberOfValidators": {
     "description": "Number of validators to deploy",
@@ -28,8 +28,16 @@
     "value": "t2.micro"
   },
   "ec2AMI": {
-    "description": "Pre-built AMI ID (ami-095792100b6e43a67)",
-    "value": "ami-095792100b6e43a67"
+    "description": "Pre-built AMI ID",
+    "value": "ami-0ce5f13e91397239a"
+  },
+  "volumeSize": {
+    "description": "Validator and Build instance volume size in GB",
+    "value": "120"
+  },
+  "rpcVolumeSize": {
+    "description": "RPC Instance volume size in GB",
+    "value": "120"
   },
   "networkSuffix": {
     "description": "Network suffix that will be added to the network name",
@@ -50,5 +58,13 @@
   "encryptionKey": {
     "description": "Password to encrypt the artifacts",
     "value": "password"
+  },
+  "proposalParameters": {
+    "description": "Proposal Parameters",
+    "value": {}
+  },
+  "skipChainSetup": {
+    "description": "Set to true to skip running setup new chain scenario",
+    "value": true
   }
 }

+ 3 - 16
Cargo.lock

@@ -722,7 +722,7 @@ dependencies = [
 
 [[package]]
 name = "chain-spec-builder"
-version = "5.0.0"
+version = "5.1.0"
 dependencies = [
  "ansi_term 0.12.1",
  "enum-utils",
@@ -2365,7 +2365,6 @@ dependencies = [
  "pallet-grandpa",
  "pallet-im-online",
  "pallet-membership",
- "pallet-memo",
  "pallet-offences",
  "pallet-offences-benchmarking",
  "pallet-proposals-codex",
@@ -2383,7 +2382,7 @@ dependencies = [
  "pallet-timestamp",
  "pallet-transaction-payment",
  "pallet-transaction-payment-rpc-runtime-api",
- "pallet-utility 1.0.0",
+ "pallet-utility 1.1.0",
  "pallet-utility 2.0.1",
  "pallet-working-group",
  "parity-scale-codec",
@@ -3958,18 +3957,6 @@ dependencies = [
  "sp-std",
 ]
 
-[[package]]
-name = "pallet-memo"
-version = "5.0.0"
-dependencies = [
- "frame-support",
- "frame-system",
- "pallet-balances",
- "parity-scale-codec",
- "sp-arithmetic",
- "sp-std",
-]
-
 [[package]]
 name = "pallet-offences"
 version = "2.0.1"
@@ -4309,7 +4296,7 @@ dependencies = [
 
 [[package]]
 name = "pallet-utility"
-version = "1.0.0"
+version = "1.1.0"
 dependencies = [
  "frame-benchmarking",
  "frame-support",

+ 0 - 1
Cargo.toml

@@ -8,7 +8,6 @@ members = [
 	"runtime-modules/council",
 	"runtime-modules/forum",
 	"runtime-modules/membership",
-	"runtime-modules/memo",
 	"runtime-modules/referendum",
 	"runtime-modules/storage",
 	"runtime-modules/working-group",

File diff suppressed because it is too large
+ 0 - 0
chain-metadata.json


File diff suppressed because it is too large
+ 662 - 89
cli/README.md


+ 17 - 0
cli/examples/working-groups/CreateOpening.json

@@ -0,0 +1,17 @@
+{
+  "applicationDetails": "- Detail 1\n- Detail 2\n- Detail 3",
+  "expectedEndingTimestamp": 1893499200,
+  "hiringLimit": 10,
+  "shortDescription": "Example opening short description",
+  "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin sodales ligula purus, vel malesuada urna dignissim et. Vestibulum tincidunt gravida diam ac ultrices. Vivamus et dolor non turpis egestas cursus quis non sapien. Praesent eros ligula, faucibus id viverra nec, feugiat vel lacus. Etiam eget magna ipsum. In ac dolor hendrerit, sodales enim vel, lobortis neque. Sed feugiat egestas turpis non ultrices. Sed sed purus neque. Vestibulum feugiat elementum finibus. Vivamus eget pulvinar eros. Curabitur non dapibus turpis, nec egestas erat.",
+  "applicationFormQuestions": [
+    { "question": "What's your name?", "type": "TEXT" },
+    { "question": "How old are you?", "type": "TEXT" },
+    { "question": "Why are you a good candidate?", "type": "TEXTAREA" }
+  ],
+  "stakingPolicy": {
+    "amount": 2000,
+    "unstakingPeriod": 100800
+  },
+  "rewardPerBlock": 100
+}

+ 6 - 0
cli/examples/working-groups/UpdateMetadata.json

@@ -0,0 +1,6 @@
+{
+  "description": "Example working group description",
+  "about": "Example working group about text",
+  "status": "Example status",
+  "statusMessage": "Example status message"
+}

+ 7 - 4
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,12 @@
       },
       "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"
+      },
+      "forum": {
+        "description": "Forum working group activities (moderation, category management)"
       }
     }
   },
@@ -135,7 +138,7 @@
     "posttest": "yarn lint",
     "prepack": "rm -rf lib && tsc -b && oclif-dev manifest && oclif-dev readme",
     "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"",
-    "build": "tsc --build tsconfig.json",
+    "build": "rm -rf lib && tsc --build tsconfig.json",
     "version": "oclif-dev readme && git add README.md",
     "lint": "eslint ./src --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",

+ 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"

+ 93 - 0
cli/scripts/forum-test.sh

@@ -0,0 +1,93 @@
+#!/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
+
+# Init forum lead
+GROUP=forumWorkingGroup yarn workspace api-scripts initialize-lead
+# Add integration tests lead key (in case the script is executed after ./start.sh)
+yarn joystream-cli account:forget --name "Test forum lead key" || true
+yarn joystream-cli account:import --suri //testing//worker//Forum//0 --name "Test forum lead key" --password ""
+
+# Assume leader is the first worker
+LEADER_WORKER_ID="0"
+
+# Create test categories
+CATEGORY_1_ID=`${CLI} forum:createCategory -t "Test category 1" -d "Test category 1 description"`
+CATEGORY_2_ID=`${CLI} forum:createCategory -t "Test category 2" -d "Test category 2 description" -p ${CATEGORY_1_ID}`
+# Create test threads
+THREAD_1_ID=`${CLI} forum:createThread \
+  --categoryId ${CATEGORY_1_ID}\
+  --title "Test thread 1"\
+  --tags "tag1" "tag2" "tag3"\
+  --text "Test thread 1 initial post text"`
+THREAD_2_ID=`${CLI} forum:createThread \
+  --categoryId ${CATEGORY_2_ID}\
+  --title "Test thread 2"\
+  --tags "tag1" "tag2" "tag3"\
+  --text "Test thread 2 initial post text"`
+# Create test posts
+POST_1_ID=`${CLI} forum:addPost \
+  --categoryId ${CATEGORY_1_ID}\
+  --threadId ${THREAD_1_ID}\
+  --text "Test post 1"\
+  --editable`
+POST_2_ID=`${CLI} forum:addPost \
+  --categoryId ${CATEGORY_2_ID}\
+  --threadId ${THREAD_2_ID}\
+  --text "Test post 2"\
+  --editable`
+
+# Update category modrator permissions
+${CLI} forum:updateCategoryModeratorStatus --categoryId ${CATEGORY_1_ID} --workerId ${LEADER_WORKER_ID} --status active
+
+# Update category archival status as lead
+${CLI} forum:updateCategoryArchivalStatus --categoryId ${CATEGORY_1_ID} --archived yes --context Leader
+# Update category archival status as moderator
+${CLI} forum:updateCategoryArchivalStatus --categoryId ${CATEGORY_1_ID} --archived no --context Moderator
+
+# Move thread as lead
+${CLI} forum:moveThread --categoryId ${CATEGORY_1_ID} --threadId ${THREAD_1_ID} --newCategoryId ${CATEGORY_2_ID} --context Leader
+# Move thread as moderator
+${CLI} forum:moveThread --categoryId ${CATEGORY_2_ID} --threadId ${THREAD_2_ID} --newCategoryId ${CATEGORY_1_ID} --context Moderator
+
+# Set stickied threads as lead
+${CLI} forum:setStickiedThreads --categoryId ${CATEGORY_1_ID} --threadIds ${THREAD_2_ID} --context Leader
+# Set stickied threads as moderator
+${CLI} forum:setStickiedThreads --categoryId ${CATEGORY_2_ID} --threadIds ${THREAD_1_ID} --context Moderator
+
+# Moderate post as lead
+${CLI} forum:moderatePost \
+  --categoryId ${CATEGORY_2_ID}\
+  --threadId ${THREAD_1_ID}\
+  --postId ${POST_1_ID}\
+  --rationale "Leader test"\
+  --context Leader
+# Moderate post as moderator
+${CLI} forum:moderatePost \
+  --categoryId ${CATEGORY_1_ID}\
+  --threadId ${THREAD_2_ID}\
+  --postId ${POST_2_ID}\
+  --rationale "Moderator test"\
+  --context Moderator
+
+# Moderate thread as lead
+${CLI} forum:moderateThread --categoryId ${CATEGORY_2_ID} --threadId ${THREAD_1_ID} --rationale "Leader test" --context Leader
+# Moderate thread as moderator
+${CLI} forum:moderateThread --categoryId ${CATEGORY_1_ID} --threadId ${THREAD_2_ID} --rationale "Moderator test" --context Moderator
+
+# Delete category as moderator
+${CLI} forum:deleteCategory --categoryId ${CATEGORY_2_ID} --context Moderator
+# Delete category as lead
+${CLI} forum:deleteCategory --categoryId ${CATEGORY_1_ID} --context Leader
+
+# Forget test lead account
+yarn joystream-cli account:forget --name "Test forum 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

+ 61 - 0
cli/scripts/working-groups-test.sh

@@ -0,0 +1,61 @@
+#!/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
+
+# Use storage working group as default group
+TEST_LEAD_SURI="//testing//worker//Storage//0"
+
+# Init lead
+GROUP="storageWorkingGroup" yarn workspace api-scripts initialize-lead
+# CLI commands group
+GROUP="storageProviders"
+# Add integration tests lead key (in case the script is executed after ./start.sh)
+${CLI} account:forget --name "Test wg lead key" || true
+${CLI} account:import --suri ${TEST_LEAD_SURI} --name "Test wg lead key" --password "" || true
+# Set/update working group metadata
+${CLI} working-groups:updateGroupMetadata --group ${GROUP} -i ../examples/working-groups/UpdateMetadata.json
+# Create upcoming opening
+UPCOMING_OPENING_ID=`${CLI} working-groups:createOpening \
+  --group ${GROUP} \
+  --input ../examples/working-groups/CreateOpening.json \
+  --upcoming \
+  --startsAt 2030-01-01`
+# Delete upcoming opening
+${CLI} working-groups:removeUpcomingOpening --group ${GROUP} --id ${UPCOMING_OPENING_ID}
+# Create opening
+OPENING_ID=`${CLI} working-groups:createOpening \
+  --group ${GROUP} \
+  --input ../examples/working-groups/CreateOpening.json \
+  --stakeTopUpSource 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY`
+# Setup a staking account (//Alice//worker-stake)
+${CLI} account:forget --name "Test worker staking key" || true
+${CLI} account:import --suri //Alice//worker-stake --name "Test worker staking key" --password "" || true
+${CLI} membership:addStakingAccount \
+  --address 5Dyzr3jNj1JngvJPDf4dpjsgZqZaUSrhFMdmKJMYkziv74qt \
+  --withBalance 2000 \
+  --fundsSource 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY
+# Apply
+APPLICATION_ID=`${CLI} working-groups:apply \
+  --group ${GROUP} \
+  --openingId ${OPENING_ID} \
+  --roleAccount 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY \
+  --rewardAccount 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY \
+  --stakingAccount 5Dyzr3jNj1JngvJPDf4dpjsgZqZaUSrhFMdmKJMYkziv74qt \
+  --answers "Alice" "30" "I'm the best!"`
+# Fill opening
+${CLI} working-groups:fillOpening \
+  --group ${GROUP} \
+  --openingId ${OPENING_ID} \
+  --applicationIds ${APPLICATION_ID}
+# Forget test lead account and test worker staking account
+${CLI} account:forget --name "Test wg lead key"
+${CLI} account:forget --name "Test worker staking key"

+ 131 - 27
cli/src/Api.ts

@@ -1,9 +1,9 @@
 import BN from 'bn.js'
-import { createType, types } from '@joystream/types/'
+import { createType, types } from '@joystream/types'
 import { ApiPromise, WsProvider } from '@polkadot/api'
 import { SubmittableExtrinsic, AugmentedQuery } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
-import { Balance } from '@polkadot/types/interfaces'
+import { Balance, LockIdentifier } from '@polkadot/types/interfaces'
 import { KeyringPair } from '@polkadot/keyring/types'
 import { Codec, Observable } from '@polkadot/types/types'
 import { UInt } from '@polkadot/types'
@@ -21,7 +21,7 @@ import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { CLIError } from '@oclif/errors'
 import { Worker, WorkerId, OpeningId, Application, ApplicationId, Opening } from '@joystream/types/working-group'
 import { Membership, StakingAccountMemberBinding } from '@joystream/types/members'
-import { MemberId, ChannelId, AccountId } from '@joystream/types/common'
+import { MemberId, ChannelId, AccountId, ThreadId, PostId } from '@joystream/types/common'
 import {
   Channel,
   Video,
@@ -34,6 +34,10 @@ 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'
+import { Category, CategoryId, Post, Thread } from '@joystream/types/forum'
+import chalk from 'chalk'
+import _ from 'lodash'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 
@@ -155,7 +159,9 @@ export default class Api {
   }
 
   async membersDetails(entries: [MemberId, Membership][]): Promise<MemberDetails[]> {
-    const membersQnData = await this._qnApi?.membersByIds(entries.map(([id]) => id))
+    const membersQnData: MembershipFieldsFragment[] | undefined = await this._qnApi?.membersByIds(
+      entries.map(([id]) => id)
+    )
     const memberQnDataById = new Map<string, MembershipFieldsFragment>()
     membersQnData?.forEach((m) => {
       memberQnDataById.set(m.id, m)
@@ -163,8 +169,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 +181,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 +236,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)
 
@@ -254,18 +256,11 @@ export default class Api {
     }
   }
 
-  async workerByWorkerId(group: WorkingGroups, workerId: number): Promise<Worker> {
-    const nextId = await this.workingGroupApiQuery(group).nextWorkerId()
-
-    // This is chain specfic, but if next id is still 0, it means no workers have been added yet
-    if (workerId < 0 || workerId >= nextId.toNumber()) {
-      throw new CLIError('Invalid worker id!')
-    }
-
+  async workerByWorkerId(group: WorkingGroups, workerId: WorkerId | number): Promise<Worker> {
     const worker = await this.workingGroupApiQuery(group).workerById(workerId)
 
     if (worker.isEmpty) {
-      throw new CLIError('This worker is not active anymore')
+      throw new CLIError(`Worker ${chalk.magentaBright(workerId)} does not exist!`)
     }
 
     return worker
@@ -313,23 +308,26 @@ export default class Api {
   }
 
   protected async fetchApplicationDetails(
+    group: WorkingGroups,
     applicationId: number,
     application: Application
   ): Promise<ApplicationDetails> {
+    const qnData = await this._qnApi?.applicationDetailsById(group, applicationId)
     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,
       descriptionHash: application.description_hash.toString(),
       openingId: application.opening_id.toNumber(),
+      answers: qnData?.answers,
     }
   }
 
   async groupApplication(group: WorkingGroups, applicationId: number): Promise<ApplicationDetails> {
     const application = await this.applicationById(group, applicationId)
-    return await this.fetchApplicationDetails(applicationId, application)
+    return await this.fetchApplicationDetails(group, applicationId, application)
   }
 
   protected async groupOpeningApplications(group: WorkingGroups, openingId: number): Promise<ApplicationDetails[]> {
@@ -340,7 +338,7 @@ export default class Api {
     return Promise.all(
       applicationEntries
         .filter(([, application]) => application.opening_id.eqn(openingId))
-        .map(([id, application]) => this.fetchApplicationDetails(id.toNumber(), application))
+        .map(([id, application]) => this.fetchApplicationDetails(group, id.toNumber(), application))
     )
   }
 
@@ -361,6 +359,7 @@ export default class Api {
   }
 
   async fetchOpeningDetails(group: WorkingGroups, opening: Opening, openingId: number): Promise<OpeningDetails> {
+    const qnData = await this._qnApi?.openingDetailsById(group, openingId)
     const applications = await this.groupOpeningApplications(group, openingId)
     const type = opening.opening_type
     const stake = {
@@ -375,6 +374,7 @@ export default class Api {
       stake,
       createdAtBlock: opening.created.toNumber(),
       rewardPerBlock: opening.reward_per_block.unwrapOr(undefined),
+      metadata: qnData?.metadata || undefined,
     }
   }
 
@@ -453,7 +453,111 @@ 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
+  }
+
+  nonRivalrousLocks(): LockIdentifier[] {
+    const votingLockId = this._api.consts.referendum.stakingHandlerLockId
+    const boundStakingAccountLockId = this._api.consts.members.stakingCandidateLockId
+    const invitedMemberLockId = this._api.consts.members.invitedMemberLockId
+    const vestigLockId = this._api.createType('LockIdentifier', 'vesting ')
+
+    return [votingLockId, boundStakingAccountLockId, invitedMemberLockId, vestigLockId]
+  }
+
+  isLockRivalrous(lockId: LockIdentifier): boolean {
+    const nonRivalrousLocks = this.nonRivalrousLocks()
+    return !nonRivalrousLocks.some((nonRivalrousLockId) => nonRivalrousLockId.eq(lockId))
+  }
+
+  async areAccountLocksCompatibleWith(account: AccountId | string, lockId: LockIdentifier): Promise<boolean> {
+    const accountLocks = await this._api.query.balances.locks(account)
+    const accountHasRivalrousLock = accountLocks.some(({ id }) => this.isLockRivalrous(id))
+
+    return !this.isLockRivalrous(lockId) || !accountHasRivalrousLock
+  }
+
+  async forumCategoryExists(categoryId: CategoryId | number): Promise<boolean> {
+    const size = await this._api.query.forum.categoryById.size(categoryId)
+    return size.gtn(0)
+  }
+
+  async forumThreadExists(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<boolean> {
+    const size = await this._api.query.forum.threadById.size(categoryId, threadId)
+    return size.gtn(0)
+  }
+
+  async forumPostExists(threadId: ThreadId | number, postId: PostId | number): Promise<boolean> {
+    const size = await this._api.query.forum.postById.size(threadId, postId)
+    return size.gtn(0)
+  }
+
+  async forumCategoryAncestors(categoryId: CategoryId | number): Promise<[CategoryId, Category][]> {
+    const ancestors: [CategoryId, Category][] = []
+    let category = await this._api.query.forum.categoryById(categoryId)
+    while (category.parent_category_id.isSome) {
+      const parentCategoryId = category.parent_category_id.unwrap()
+      category = await this._api.query.forum.categoryById(parentCategoryId)
+      ancestors.push([parentCategoryId, category])
+    }
+    return ancestors
+  }
+
+  async forumCategoryModerators(categoryId: CategoryId | number): Promise<[CategoryId, WorkerId][]> {
+    const categoryAncestors = await this.forumCategoryAncestors(categoryId)
+
+    const moderatorIds = _.uniqWith(
+      _.flatten(
+        await Promise.all(
+          categoryAncestors
+            .map(([id]) => id as CategoryId | number)
+            .reverse()
+            .concat([categoryId])
+            .map(async (id) => {
+              const storageKeys = await this._api.query.forum.categoryByModerator.keys(id)
+              return storageKeys.map((k) => k.args)
+            })
+        )
+      ),
+      (a, b) => a[1].eq(b[1])
+    )
+
+    return moderatorIds
+  }
+
+  async getForumCategory(categoryId: CategoryId | number): Promise<Category> {
+    const category = await this._api.query.forum.categoryById(categoryId)
+    return category
+  }
+
+  async getForumThread(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<Thread> {
+    const thread = await this._api.query.forum.threadById(categoryId, threadId)
+    return thread
+  }
+
+  async getForumPost(threadId: ThreadId | number, postId: PostId | number): Promise<Post> {
+    const post = await this._api.query.forum.postById(threadId, postId)
+    return post
+  }
+
+  async forumCategories(): Promise<[CategoryId, Category][]> {
+    return this.entriesByIds(this._api.query.forum.categoryById)
+  }
+
+  async forumThreads(categoryId: CategoryId | number): Promise<[ThreadId, Thread][]> {
+    const entries = await this._api.query.forum.threadById.entries(categoryId)
+    return entries.map(([storageKey, thread]) => [storageKey.args[1], thread])
+  }
+
+  async forumPosts(threadId: ThreadId | number): Promise<[PostId, Post][]> {
+    const entries = await this._api.query.forum.postById.entries(threadId)
+    return entries.map(([storageKey, thread]) => [storageKey.args[1], thread])
+  }
 }

+ 1 - 0
cli/src/Consts.ts

@@ -0,0 +1 @@
+export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'

+ 14 - 12
cli/src/ExitCodes.ts

@@ -1,18 +1,20 @@
 enum ExitCodes {
   OK = 0,
 
-  InvalidInput = 400,
-  FileNotFound = 401,
-  InvalidFile = 402,
-  NoAccountFound = 403,
-  NoAccountSelected = 404,
-  AccessDenied = 405,
+  InvalidInput = 40,
+  FileNotFound = 41,
+  InvalidFile = 42,
+  NoAccountFound = 43,
+  NoAccountSelected = 44,
+  AccessDenied = 45,
 
-  UnexpectedException = 500,
-  FsOperationFailed = 501,
-  ApiError = 502,
-  StorageNodeError = 503,
-  ActionCurrentlyUnavailable = 504,
-  QueryNodeError = 505,
+  UnexpectedException = 50,
+  FsOperationFailed = 51,
+  ApiError = 52,
+  StorageNodeError = 53,
+  ActionCurrentlyUnavailable = 54,
+  QueryNodeError = 55,
+
+  // NOTE: never exceed exit code 255 or it will be modulated by `256` and create problems
 }
 export = ExitCodes

+ 71 - 2
cli/src/QueryNodeApi.ts

@@ -1,4 +1,4 @@
-import { StorageNodeInfo } from './Types'
+import { StorageNodeInfo, WorkingGroups } from './Types'
 import {
   ApolloClient,
   InMemoryCache,
@@ -28,10 +28,30 @@ import {
   GetMembersByIdsQuery,
   GetMembersByIdsQueryVariables,
   MembershipFieldsFragment,
+  WorkingGroupOpeningDetailsFragment,
+  OpeningDetailsByIdQuery,
+  OpeningDetailsByIdQueryVariables,
+  OpeningDetailsById,
+  WorkingGroupApplicationDetailsFragment,
+  ApplicationDetailsByIdQuery,
+  ApplicationDetailsByIdQueryVariables,
+  ApplicationDetailsById,
+  UpcomingWorkingGroupOpeningByEventQuery,
+  UpcomingWorkingGroupOpeningByEventQueryVariables,
+  UpcomingWorkingGroupOpeningByEvent,
+  UpcomingWorkingGroupOpeningDetailsFragment,
+  UpcomingWorkingGroupOpeningByIdQuery,
+  UpcomingWorkingGroupOpeningByIdQueryVariables,
+  UpcomingWorkingGroupOpeningById,
+  UpcomingWorkingGroupOpeningsByGroupQuery,
+  UpcomingWorkingGroupOpeningsByGroupQueryVariables,
+  UpcomingWorkingGroupOpeningsByGroup,
 } from './graphql/generated/queries'
 import { URL } from 'url'
 import fetch from 'cross-fetch'
 import { MemberId } from '@joystream/types/common'
+import { ApplicationId, OpeningId } from '@joystream/types/working-group'
+import { apiModuleByGroup } from './Api'
 
 export default class QueryNodeApi {
   private _qnClient: ApolloClient<NormalizedCacheObject>
@@ -44,7 +64,7 @@ export default class QueryNodeApi {
     links.push(new HttpLink({ uri, fetch }))
     this._qnClient = new ApolloClient({
       link: from(links),
-      cache: new InMemoryCache(),
+      cache: new InMemoryCache({ addTypename: false }),
       defaultOptions: { query: { fetchPolicy: 'no-cache', errorPolicy: 'all' } },
     })
   }
@@ -137,4 +157,53 @@ export default class QueryNodeApi {
       'memberships'
     )
   }
+
+  async openingDetailsById(
+    group: WorkingGroups,
+    id: OpeningId | number
+  ): Promise<WorkingGroupOpeningDetailsFragment | null> {
+    return this.uniqueEntityQuery<OpeningDetailsByIdQuery, OpeningDetailsByIdQueryVariables>(
+      OpeningDetailsById,
+      { id: `${apiModuleByGroup[group]}-${id.toString()}` },
+      'workingGroupOpeningByUniqueInput'
+    )
+  }
+
+  async applicationDetailsById(
+    group: WorkingGroups,
+    id: ApplicationId | number
+  ): Promise<WorkingGroupApplicationDetailsFragment | null> {
+    return this.uniqueEntityQuery<ApplicationDetailsByIdQuery, ApplicationDetailsByIdQueryVariables>(
+      ApplicationDetailsById,
+      { id: `${apiModuleByGroup[group]}-${id.toString()}` },
+      'workingGroupApplicationByUniqueInput'
+    )
+  }
+
+  async upcomingWorkingGroupOpeningByEvent(
+    blockNumber: number,
+    indexInBlock: number
+  ): Promise<UpcomingWorkingGroupOpeningDetailsFragment | null> {
+    return this.firstEntityQuery<
+      UpcomingWorkingGroupOpeningByEventQuery,
+      UpcomingWorkingGroupOpeningByEventQueryVariables
+    >(UpcomingWorkingGroupOpeningByEvent, { blockNumber, indexInBlock }, 'upcomingWorkingGroupOpenings')
+  }
+
+  async upcomingWorkingGroupOpeningById(id: string): Promise<UpcomingWorkingGroupOpeningDetailsFragment | null> {
+    return this.uniqueEntityQuery<UpcomingWorkingGroupOpeningByIdQuery, UpcomingWorkingGroupOpeningByIdQueryVariables>(
+      UpcomingWorkingGroupOpeningById,
+      { id },
+      'upcomingWorkingGroupOpeningByUniqueInput'
+    )
+  }
+
+  async upcomingWorkingGroupOpeningsByGroup(
+    group: WorkingGroups
+  ): Promise<UpcomingWorkingGroupOpeningDetailsFragment[]> {
+    return this.multipleEntitiesQuery<
+      UpcomingWorkingGroupOpeningsByGroupQuery,
+      UpcomingWorkingGroupOpeningsByGroupQueryVariables
+    >(UpcomingWorkingGroupOpeningsByGroup, { workingGroupId: apiModuleByGroup[group] }, 'upcomingWorkingGroupOpenings')
+  }
 }

+ 56 - 7
cli/src/Types.ts

@@ -1,5 +1,5 @@
 import BN from 'bn.js'
-import { Codec } from '@polkadot/types/types'
+import { Codec, IEvent } from '@polkadot/types/types'
 import { Balance, AccountId } from '@polkadot/types/interfaces'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { KeyringPair } from '@polkadot/keyring/types'
@@ -8,15 +8,27 @@ import { Membership } from '@joystream/types/members'
 import { MemberId } from '@joystream/types/common'
 import { Validator } from 'inquirer'
 import { ApiPromise } from '@polkadot/api'
-import { SubmittableModuleExtrinsics, QueryableModuleStorage, QueryableModuleConsts } from '@polkadot/api/types'
+import {
+  SubmittableModuleExtrinsics,
+  QueryableModuleStorage,
+  QueryableModuleConsts,
+  AugmentedEvent,
+} from '@polkadot/api/types'
 import { JSONSchema4 } from 'json-schema'
 import {
   IChannelMetadata,
   IVideoMetadata,
   IVideoCategoryMetadata,
   IChannelCategoryMetadata,
+  IOpeningMetadata,
+  IWorkingGroupMetadata,
 } from '@joystream/metadata-protobuf'
 import { DataObjectCreationParameters } from '@joystream/types/storage'
+import {
+  MembershipFieldsFragment,
+  WorkingGroupApplicationDetailsFragment,
+  WorkingGroupOpeningDetailsFragment,
+} from './graphql/generated/queries'
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -86,6 +98,7 @@ export type ApplicationDetails = {
   rewardAccount: AccountId
   descriptionHash: string
   openingId: number
+  answers?: WorkingGroupApplicationDetailsFragment['answers']
 }
 
 export type OpeningDetails = {
@@ -98,12 +111,13 @@ export type OpeningDetails = {
   type: OpeningType
   createdAtBlock: number
   rewardPerBlock?: Balance
+  metadata?: WorkingGroupOpeningDetailsFragment['metadata']
 }
 
 // Extended membership information (including optional query node data)
 export type MemberDetails = {
   id: MemberId
-  name?: string | null
+  meta?: MembershipFieldsFragment['metadata']
   handle?: string
   membership: Membership
 }
@@ -139,6 +153,23 @@ export type UnaugmentedApiPromise = Omit<ApiPromise, 'query' | 'tx' | 'consts'>
   consts: { [key: string]: QueryableModuleConsts }
 }
 
+// Event-related types
+export type EventSection = keyof ApiPromise['events'] & string
+export type EventMethod<Section extends EventSection> = keyof ApiPromise['events'][Section] & string
+export type EventType<
+  Section extends EventSection,
+  Method extends EventMethod<Section>
+> = ApiPromise['events'][Section][Method] extends AugmentedEvent<'promise', infer T> ? IEvent<T> & Codec : never
+
+export type EventDetails<E> = {
+  event: E
+  blockNumber: number
+  blockHash: string
+  blockTimestamp: number
+  indexInBlock: number
+}
+
+// Storage
 export type AssetToUpload = {
   dataObjectId: BN
   path: string
@@ -183,7 +214,21 @@ export type ChannelCategoryInputParameters = IChannelCategoryMetadata
 
 export type VideoCategoryInputParameters = IVideoCategoryMetadata
 
-type AnyNonObject = string | number | boolean | any[] | Long
+export type WorkingGroupOpeningInputParameters = Omit<IOpeningMetadata, 'applicationFormQuestions'> & {
+  stakingPolicy: {
+    amount: number
+    unstakingPeriod: number
+  }
+  rewardPerBlock?: number
+  applicationFormQuestions?: {
+    question: string
+    type: 'TEXTAREA' | 'TEXT'
+  }[]
+}
+
+export type WorkingGroupUpdateStatusInputParameters = IWorkingGroupMetadata
+
+type AnyPrimitive = string | number | boolean | Long
 
 // JSONSchema utility types
 
@@ -197,18 +242,22 @@ type AnyJSONSchema = RemoveIndex<JSONSchema4>
 export type JSONTypeName<T> = T extends string
   ? 'string' | ['string', 'null']
   : T extends number
-  ? 'number' | ['number', 'null']
+  ? 'number' | ['number', 'null'] | 'integer' | ['integer', 'null']
   : T extends boolean
   ? 'boolean' | ['boolean', 'null']
   : T extends any[]
   ? 'array' | ['array', 'null']
   : T extends Long
-  ? 'number' | ['number', 'null']
+  ? 'number' | ['number', 'null'] | 'integer' | ['integer', 'null']
   : 'object' | ['object', 'null']
 
 export type PropertySchema<P> = Omit<AnyJSONSchema, 'type' | 'properties'> & {
   type: JSONTypeName<P>
-} & (P extends AnyNonObject ? { properties?: never } : { properties: JsonSchemaProperties<P> })
+} & (P extends AnyPrimitive
+    ? { properties?: never }
+    : P extends (infer T)[]
+    ? { properties?: never; items: PropertySchema<T> }
+    : { properties: JsonSchemaProperties<P> })
 
 export type JsonSchemaProperties<T> = {
   [K in keyof Required<T>]: PropertySchema<Required<T>[K]>

+ 114 - 125
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'
@@ -18,6 +18,7 @@ import { mnemonicGenerate } from '@polkadot/util-crypto'
 import { validateAddress } from '../helpers/validation'
 import slug from 'slug'
 import { Membership } from '@joystream/types/members'
+import { LockIdentifier } from '@polkadot/types/interfaces'
 import BN from 'bn.js'
 
 const ACCOUNTS_DIRNAME = 'accounts'
@@ -35,7 +36,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 +199,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 +286,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,143 +329,124 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  async getRequiredMemberContext(useSelected = false, allowedIds?: MemberId[]): Promise<MemberDetails> {
-    if (
-      useSelected &&
-      this.selectedMember &&
-      (!allowedIds || allowedIds.some((id) => id.eq(this.selectedMember?.id)))
-    ) {
-      return this.selectedMember
+  async setupStakingAccount(
+    memberId: MemberId,
+    member: Membership,
+    address?: string,
+    requiredStake: BN = new BN(0),
+    fundsSource?: string,
+    lockId?: LockIdentifier
+  ): Promise<string> {
+    if (fundsSource && !this.isKeyAvailable(fundsSource)) {
+      throw new CLIError(`Key ${chalk.magentaBright(fundsSource)} is not available!`)
     }
 
-    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,
-        }
-      )
-    } else if (availableMemberships.length === 1) {
-      this.selectedMember = availableMemberships[0]
-    } else {
-      this.selectedMember = await this.promptForMember(availableMemberships, 'Choose member context')
+    if (!address) {
+      address = await this.promptForAnyAddress('Choose staking account')
     }
+    const { balances } = await this.getApi().getAccountSummary(address)
+    const stakingStatus = await this.getApi().stakingAccountStatus(address)
 
-    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
-      }
+    if (lockId && !this.getApi().areAccountLocksCompatibleWith(address, lockId)) {
+      throw new CLIError(
+        'This account is already used for other, incompatible staking purposes. Choose a different account...'
+      )
+    }
 
-      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.`
-        )
-        if (!stakingStatus) {
-          additionalStakingAccountCosts = await this.getApi().estimateFee(
-            await this.getDecodedPair(stakingAccount),
-            this.getOriginalApi().tx.members.addStakingAccountCandidate(memberId)
-          )
-          additionalStakingAccountCosts = additionalStakingAccountCosts.add(STAKING_ACCOUNT_CANDIDATE_STAKE)
-        }
-      }
+    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...'
+      )
+    }
 
-      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)`
-              : '')
+    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...'
         )
-        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.freeBalance)
+    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
+    if (!stakingStatus) {
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'members', 'addStakingAccountCandidate', [
+        memberId,
+      ])
     }
 
-    return stakingAccount
+    if (!stakingStatus || stakingStatus.confirmed.isFalse) {
+      await this.sendAndFollowNamedTx(
+        await this.getDecodedPair(member.controller_account.toString()),
+        'members',
+        'confirmStakingAccount',
+        [memberId, address]
+      )
+    }
+
+    return address
+  }
+
+  async promptForStakingAccount(
+    requiredStake: BN,
+    memberId: MemberId,
+    member: Membership,
+    lockId?: LockIdentifier
+  ): 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, undefined, lockId)
+        return stakingAccount
+      } catch (e) {
+        if (e instanceof CLIError) {
+          this.warn(e.message)
+        } else {
+          throw e
+        }
+      }
+    }
   }
 
   async init(): Promise<void> {

+ 78 - 21
cli/src/base/ApiCommandBase.ts

@@ -2,21 +2,32 @@ import ExitCodes from '../ExitCodes'
 import { CLIError } from '@oclif/errors'
 import StateAwareCommandBase from './StateAwareCommandBase'
 import Api from '../Api'
+import {
+  EventSection,
+  EventMethod,
+  EventType,
+  EventDetails,
+  ApiMethodArg,
+  ApiMethodNamedArgs,
+  ApiParamsOptions,
+  ApiParamOptions,
+  UnaugmentedApiPromise,
+} from '../Types'
 import { getTypeDef, Option, Tuple } from '@polkadot/types'
-import { Registry, Codec, TypeDef, TypeDefInfo, IEvent, DetectCodec } from '@polkadot/types/types'
+import { Registry, Codec, TypeDef, TypeDefInfo, DetectCodec, ISubmittableResult } from '@polkadot/types/types'
 import { Vec, Struct, Enum } from '@polkadot/types/codec'
 import { SubmittableResult, WsProvider, ApiPromise } from '@polkadot/api'
 import { KeyringPair } from '@polkadot/keyring/types'
 import chalk from 'chalk'
 import { InterfaceTypes } from '@polkadot/types/types/registry'
-import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions, UnaugmentedApiPromise } from '../Types'
 import { createParamOptions } from '../helpers/promptOptions'
-import { AugmentedSubmittables, SubmittableExtrinsic, AugmentedEvents, AugmentedEvent } from '@polkadot/api/types'
+import { AugmentedSubmittables, SubmittableExtrinsic } from '@polkadot/api/types'
 import { DistinctQuestion } from 'inquirer'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 import { DispatchError } from '@polkadot/types/interfaces/system'
 import QueryNodeApi from '../QueryNodeApi'
 import { formatBalance } from '@polkadot/util'
+import cli from 'cli-ux'
 import BN from 'bn.js'
 import _ from 'lodash'
 
@@ -93,13 +104,18 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         this.warn("You haven't provided a Joystream query node uri for the CLI to connect to yet!")
         queryNodeUri = await this.promptForQueryNodeUri()
       }
-      this.queryNodeApi = queryNodeUri
-        ? new QueryNodeApi(queryNodeUri, (err) => {
-            this.warn(`Query node error: ${err.networkError?.message || err.graphQLErrors?.join('\n')}`)
-          })
-        : null
+      if (queryNodeUri) {
+        cli.action.start(`Initializing the query node connection (${queryNodeUri})...`)
+        this.queryNodeApi = new QueryNodeApi(queryNodeUri, (err) => {
+          this.warn(`Query node error: ${err.networkError?.message || err.graphQLErrors?.join('\n')}`)
+        })
+        cli.action.stop()
+      } else {
+        this.queryNodeApi = null
+      }
 
       // Substrate api
+      cli.action.start(`Initializing the api connection (${apiUri})...`)
       const { metadataCache } = this.getPreservedState()
       this.api = await Api.create(apiUri, metadataCache, this.queryNodeApi || undefined)
 
@@ -110,11 +126,12 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         metadataCache[metadataKey] = await this.getOriginalApi().runtimeMetadata.toJSON()
         await this.setPreservedState({ metadataCache })
       }
+      cli.action.stop()
     }
   }
 
   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 +519,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,22 +574,53 @@ 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)
   }
 
-  public findEvent<
-    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 | undefined {
-    return result.findRecord(section, method)?.event as EventType | undefined
+  public findEvent<S extends EventSection, M extends EventMethod<S>, E = EventType<S, M>>(
+    result: SubmittableResult,
+    section: S,
+    method: M
+  ): E | undefined {
+    return result.findRecord(section, method)?.event as E | undefined
+  }
+
+  public getEvent<S extends EventSection, M extends EventMethod<S>, E = EventType<S, M>>(
+    result: SubmittableResult,
+    section: S,
+    method: M
+  ): E {
+    const event = this.findEvent<S, M, E>(result, section, method)
+    if (!event) {
+      throw new Error(`Event ${section}.${method} not found in tx result: ${JSON.stringify(result.toHuman())}`)
+    }
+    return event
+  }
+
+  async getEventDetails<S extends EventSection, M extends EventMethod<S>>(
+    result: ISubmittableResult,
+    section: S,
+    method: M
+  ): Promise<EventDetails<EventType<S, M>>> {
+    const api = this.getOriginalApi()
+    const { status } = result
+    const event = this.getEvent(result, section, method)
+
+    const blockHash = (status.isInBlock ? status.asInBlock : status.asFinalized).toString()
+    const blockNumber = (await api.rpc.chain.getHeader(blockHash)).number.toNumber()
+    const blockTimestamp = (await api.query.timestamp.now.at(blockHash)).toNumber()
+    const blockEvents = await api.query.system.events.at(blockHash)
+    const indexInBlock = blockEvents.findIndex(({ event: blockEvent }) => blockEvent.hash.eq(event.hash))
+
+    return {
+      event,
+      blockNumber,
+      blockHash,
+      blockTimestamp,
+      indexInBlock,
+    }
   }
 
   async buildAndSendExtrinsic<

+ 11 - 7
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,
@@ -287,7 +291,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     context: Exclude<keyof typeof ContentActor.typeDefinitions, 'Collaborator'>
   ): Promise<[ContentActor, string]> {
     if (context === 'Member') {
-      const { id, membership } = await this.getRequiredMemberContext()
+      const { id, membership } = await this.getRequiredMemberContext(true)
       return [
         createType<ContentActor, 'ContentActor'>('ContentActor', { Member: id }),
         membership.controller_account.toString(),

+ 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)
   }
 }

+ 165 - 0
cli/src/base/ForumCommandBase.ts

@@ -0,0 +1,165 @@
+import { Category, CategoryId, Post, PrivilegedActor, Thread } from '@joystream/types/forum'
+import { WorkingGroups } from '../Types'
+import { flags } from '@oclif/command'
+import WorkingGroupCommandBase from './WorkingGroupCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../ExitCodes'
+import { createType } from '@joystream/types'
+import { AccountId, PostId, ThreadId } from '@joystream/types/common'
+import { WorkerId } from '@joystream/types/working-group'
+
+const FORUM_MODERATION_CONTEXT = ['Leader', 'Moderator'] as const
+
+type ForumModerationContext = typeof FORUM_MODERATION_CONTEXT[number]
+
+/**
+ * Abstract base class for commands related to forum management
+ */
+export default abstract class ForumCommandBase extends WorkingGroupCommandBase {
+  static flags = {
+    ...WorkingGroupCommandBase.flags,
+  }
+
+  static forumModerationContextFlag = flags.enum({
+    required: false,
+    description: `Actor context to execute the command in (${FORUM_MODERATION_CONTEXT.join('/')})`,
+    options: [...FORUM_MODERATION_CONTEXT],
+  })
+
+  async init(): Promise<void> {
+    await super.init()
+    this._group = WorkingGroups.Forum // override group for RolesCommandBase
+  }
+
+  async ensureCategoryExists(categoryId: CategoryId | number): Promise<void> {
+    const categoryExists = await this.getApi().forumCategoryExists(categoryId)
+    if (!categoryExists) {
+      this.error(`Category ${chalk.magentaBright(categoryId)} does not exist!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+
+  async ensureCategoryMutable(categoryId: CategoryId | number): Promise<void> {
+    const category = await this.getCategory(categoryId)
+    const ancestors = await this.getApi().forumCategoryAncestors(categoryId)
+    if (category.archived.isTrue || ancestors.some(([, category]) => category.archived.isTrue)) {
+      this.error(`Category ${chalk.magentaBright(categoryId)} is not mutable (belongs to archived category tree)!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+
+  async ensureThreadExists(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<void> {
+    const threadExists = await this.getApi().forumThreadExists(categoryId, threadId)
+    if (!threadExists) {
+      this.error(
+        `Thread ${chalk.magentaBright(threadId)} in category ${chalk.magentaBright(categoryId)} does not exist!`,
+        {
+          exit: ExitCodes.InvalidInput,
+        }
+      )
+    }
+  }
+
+  async ensurePostExists(threadId: ThreadId | number, postId: PostId | number): Promise<void> {
+    const postExists = await this.getApi().forumPostExists(threadId, postId)
+    if (!postExists) {
+      this.error(`Post ${chalk.magentaBright(postId)} in thread ${chalk.magentaBright(threadId)} does not exist!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+
+  async getThread(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<Thread> {
+    await this.ensureThreadExists(categoryId, threadId)
+    const thread = await this.getApi().getForumThread(categoryId, threadId)
+    return thread
+  }
+
+  async getCategory(categoryId: CategoryId | number): Promise<Category> {
+    await this.ensureCategoryExists(categoryId)
+    const category = await this.getApi().getForumCategory(categoryId)
+    return category
+  }
+
+  async getPost(threadId: ThreadId | number, postId: PostId | number): Promise<Post> {
+    await this.ensurePostExists(threadId, postId)
+    const post = await this.getApi().getForumPost(threadId, postId)
+    return post
+  }
+
+  async getIdsOfModeratorsWithAccessToCategories(categories: CategoryId[] | number[]): Promise<WorkerId[]> {
+    const moderatorsByCategory = await Promise.all(categories.map((id) => this.getApi().forumCategoryModerators(id)))
+    const categoriesCountByModeratorId = new Map<number, number>()
+    for (const moderators of moderatorsByCategory) {
+      for (const [, id] of moderators) {
+        categoriesCountByModeratorId.set(id.toNumber(), (categoriesCountByModeratorId.get(id.toNumber()) || 0) + 1)
+      }
+    }
+    return Array.from(categoriesCountByModeratorId.entries())
+      .filter(([, count]) => count === categories.length)
+      .map(([id]) => createType<WorkerId, 'WorkerId'>('WorkerId', id))
+  }
+
+  async getForumModeratorContext(categories: CategoryId[] | number[]): Promise<[AccountId, PrivilegedActor]> {
+    const moderators = await this.getIdsOfModeratorsWithAccessToCategories(categories)
+    try {
+      const worker = await this.getRequiredWorkerContext('Role', moderators)
+      return [
+        worker.roleAccount,
+        createType<PrivilegedActor, 'PrivilegedActor'>('PrivilegedActor', { Moderator: worker.workerId }),
+      ]
+    } catch (e) {
+      this.error(
+        `Moderator access to categories: ${categories
+          .map((id) => chalk.magentaBright(id.toString()))
+          .join(', ')} is required!`,
+        { exit: ExitCodes.AccessDenied }
+      )
+    }
+  }
+
+  async getForumLeadContext(): Promise<[AccountId, PrivilegedActor]> {
+    const lead = await this.getRequiredLeadContext()
+    return [lead.roleAccount, createType<PrivilegedActor, 'PrivilegedActor'>('PrivilegedActor', 'Lead')]
+  }
+
+  async getForumModerationContext(
+    categories: CategoryId[] | number[],
+    context?: ForumModerationContext
+  ): Promise<[AccountId, PrivilegedActor]> {
+    if (context === 'Leader') {
+      return this.getForumLeadContext()
+    }
+
+    if (context === 'Moderator') {
+      return this.getForumModeratorContext(categories)
+    }
+
+    // Context not explicitly set...
+
+    try {
+      const context = await this.getForumLeadContext()
+      this.log('Derived context: Forum Working Group Leader')
+      return context
+    } catch (e) {
+      // continue...
+    }
+
+    try {
+      const context = await this.getForumModeratorContext(categories)
+      this.log('Derived context: Moderator')
+      return context
+    } catch (e) {
+      // continue...
+    }
+
+    this.error(
+      `You need moderator permissions for forum categories: ${categories
+        .map((id) => chalk.magentaBright(id.toString()))
+        .join(', ')} in order to continue!`,
+      { exit: ExitCodes.AccessDenied }
+    )
+  }
+}

+ 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()
+  }
+}

+ 1 - 0
cli/src/base/StateAwareCommandBase.ts

@@ -14,6 +14,7 @@ type StateObject = {
   queryNodeUri: string | null | undefined
   defaultWorkingGroup: WorkingGroups
   metadataCache: Record<string, any>
+  selectedMemberId?: string
 }
 
 // State object default values

+ 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 = {

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

@@ -0,0 +1,104 @@
+import ExitCodes from '../ExitCodes'
+import { flags } from '@oclif/command'
+import { WorkingGroups, GroupMember } from '../Types'
+import _ from 'lodash'
+import MembershipsCommandBase from './MembershipsCommandBase'
+import { WorkerId } from '@joystream/types/working-group'
+
+/**
+ * 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',
+    allowedIds?: WorkerId[]
+  ): Promise<GroupMember> {
+    const flags = this.parse(this.constructor as typeof WorkingGroupCommandBase).flags
+
+    const groupMembers = await this.getApi().groupMembers(this.group)
+    const allowedGroupMembers = groupMembers.filter((m) => !allowedIds || allowedIds.some((id) => id.eq(m.workerId)))
+
+    const availableGroupMemberContexts = allowedGroupMembers.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 ${
+          allowedIds ? ` (from the allowed set of workers: ${allowedIds.map((id) => id.toString())})` : ''
+        } 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]
+  }
+
+  async ensureWorkerExists(workerId: WorkerId | number): Promise<void> {
+    await this.getApi().workerByWorkerId(this.group, workerId)
+  }
+}

+ 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))
   }

+ 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)

+ 2 - 1
cli/src/commands/account/info.ts

@@ -5,6 +5,7 @@ import { NameValueObj } from '../../Types'
 import { displayHeader, displayNameValueTable } from '../../helpers/display'
 import { formatBalance } from '@polkadot/util'
 import moment from 'moment'
+import { DEFAULT_DATE_FORMAT } from '../../Consts'
 
 export default class AccountInfo extends AccountsCommandBase {
   static description = 'Display detailed information about specified account'
@@ -31,7 +32,7 @@ export default class AccountInfo extends AccountsCommandBase {
       accountRows.push({ name: 'Account name', value: pair.meta.name })
       accountRows.push({ name: 'Type', value: pair.type })
       const creationDate = pair.meta.whenCreated
-        ? moment(pair.meta.whenCreated as string | number).format('YYYY-MM-DD HH:mm:ss')
+        ? moment(pair.meta.whenCreated as string | number).format(DEFAULT_DATE_FORMAT)
         : null
       if (creationDate) {
         accountRows.push({ name: 'Creation date', value: creationDate })

+ 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
 

+ 52 - 0
cli/src/commands/forum/addPost.ts

@@ -0,0 +1,52 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { ForumPostMetadata, IForumPostMetadata } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import { PostId } from '@joystream/types/common'
+
+export default class ForumAddPostCommand extends ForumCommandBase {
+  static description = 'Add forum post.'
+  static flags = {
+    categoryId: flags.integer({
+      required: true,
+      description: 'Id of the forum category of the parent thread',
+    }),
+    threadId: flags.integer({
+      required: true,
+      description: "Post's parent thread",
+    }),
+    text: flags.string({
+      required: true,
+      description: 'Post content (md-formatted text)',
+    }),
+    editable: flags.boolean({
+      required: false,
+      description: 'Whether the post should be editable',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadId, text, editable } = this.parse(ForumAddPostCommand).flags
+
+    await this.ensureThreadExists(categoryId, threadId)
+    await this.ensureCategoryMutable(categoryId)
+    const member = await this.getRequiredMemberContext()
+
+    // Replies not supported atm
+    const metadata: IForumPostMetadata = { text }
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadId, text, editable }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(member.membership.controller_account),
+      api.tx.forum.addPost(member.id, categoryId, threadId, metadataToBytes(ForumPostMetadata, metadata), editable)
+    )
+
+    const postId: PostId = this.getEvent(result, 'forum', 'PostAdded').data[0]
+    this.log(chalk.green(`ForumPost with id ${chalk.magentaBright(postId.toString())} successfully created!`))
+    this.output(postId.toString())
+  }
+}

+ 91 - 0
cli/src/commands/forum/categories.ts

@@ -0,0 +1,91 @@
+import { Category, CategoryId } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import { cli } from 'cli-ux'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { Tree } from 'cli-ux/lib/styled/tree'
+import { displayTable } from '../../helpers/display'
+
+export default class ForumCategoriesCommand extends ForumCommandBase {
+  static description =
+    'List existing forum categories by parent id (root categories by default) or displays a category tree.'
+
+  static flags = {
+    parentCategoryId: flags.integer({
+      char: 'p',
+      required: false,
+      description: 'Parent category id (only child categories will be listed)',
+    }),
+    tree: flags.boolean({
+      char: 'c',
+      required: false,
+      description: 'Display a category tree (with parentCategoryId as root, if specified)',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  recursivelyGenerateCategoryTree(
+    tree: Tree,
+    parents: [CategoryId, Category][],
+    allCategories: [CategoryId, Category][]
+  ): void {
+    for (const [parentId] of parents) {
+      const children = allCategories.filter(([, c]) => c.parent_category_id.unwrapOr(undefined)?.eq(parentId))
+      const childSubtree = cli.tree()
+      this.recursivelyGenerateCategoryTree(childSubtree, children, allCategories)
+      tree.insert(parentId.toString(), childSubtree)
+    }
+  }
+
+  buildCategoryTree(categories: [CategoryId, Category][], root?: number): Tree {
+    const tree = cli.tree()
+    let rootCategory: [CategoryId, Category] | undefined
+    if (root) {
+      rootCategory = categories.find(([id]) => id.toNumber() === root)
+      if (!rootCategory) {
+        this.error(`Category ${chalk.magentaBright(root)} not found!`)
+      }
+    }
+    const treeRootCategories = rootCategory ? [rootCategory] : categories.filter(([, c]) => c.parent_category_id.isNone)
+    this.recursivelyGenerateCategoryTree(tree, treeRootCategories, categories)
+    return tree
+  }
+
+  async run(): Promise<void> {
+    const { parentCategoryId, tree } = this.parse(ForumCategoriesCommand).flags
+
+    if (parentCategoryId !== undefined) {
+      await this.ensureCategoryExists(parentCategoryId)
+    }
+
+    const categories = await this.getApi().forumCategories()
+
+    if (tree) {
+      const categoryTree = this.buildCategoryTree(categories, parentCategoryId)
+      categoryTree.display()
+    } else {
+      const children = categories.filter(
+        ([, c]) => c.parent_category_id.unwrapOr(undefined)?.toNumber() === parentCategoryId
+      )
+      if (children.length) {
+        displayTable(
+          children.map(([id, c]) => ({
+            'ID': id.toString(),
+            'Direct subcategories': c.num_direct_subcategories.toNumber(),
+            'Direct threads': c.num_direct_threads.toNumber(),
+            'Direct modreators': c.num_direct_moderators.toNumber(),
+          })),
+          5
+        )
+      } else {
+        this.log(
+          `No ${
+            parentCategoryId !== undefined
+              ? `subcategories of category ${chalk.magentaBright(parentCategoryId)}`
+              : 'root categories'
+          } found`
+        )
+      }
+    }
+  }
+}

+ 65 - 0
cli/src/commands/forum/category.ts

@@ -0,0 +1,65 @@
+import { CategoryId } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { displayCollapsedRow, displayHeader, displayTable, memberHandle } from '../../helpers/display'
+import { GroupMember } from '../../Types'
+
+export default class ForumCategoryCommand extends ForumCommandBase {
+  static description = 'Display forum category details.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { categoryId } = this.parse(ForumCategoryCommand).flags
+    const category = await this.getCategory(categoryId)
+    const allCategories = await this.getApi().forumCategories()
+    const directSubcategories = allCategories.filter(
+      ([, c]) => c.parent_category_id.unwrapOr(undefined)?.toNumber() === categoryId
+    )
+    const moderatorsEntries = await this.getApi().forumCategoryModerators(categoryId)
+    const moderators = await Promise.all(
+      moderatorsEntries.map(
+        async ([categoryId, workerId]) =>
+          [categoryId, await this.getApi().groupMember(this.group, workerId.toNumber())] as [CategoryId, GroupMember]
+      )
+    )
+
+    displayCollapsedRow({
+      'ID': categoryId.toString(),
+      'No. direct subcategories': category.num_direct_subcategories.toString(),
+      'No. direct threads': category.num_direct_threads.toString(),
+      'No. direct moderators': category.num_direct_moderators.toString(),
+    })
+
+    displayHeader('Stickied threads')
+    if (category.sticky_thread_ids.length) {
+      this.log(category.sticky_thread_ids.map((id) => chalk.magentaBright(id.toString())).join(', '))
+    } else {
+      this.log('No stickied threads')
+    }
+
+    displayHeader('Direct subcategories')
+    this.log(directSubcategories.map(([id]) => chalk.magentaBright(id.toString())).join(', '))
+
+    displayHeader('Moderators')
+    if (moderators.length) {
+      displayTable(
+        moderators.map(([cId, moderator]) => ({
+          'Worker ID': moderator.workerId.toString(),
+          'Member Handle': memberHandle(moderator.profile),
+          'Access': cId.eq(categoryId) ? 'Direct' : `Ancestor (${cId.toString()})`,
+        })),
+        5
+      )
+    } else {
+      this.log('No moderators')
+    }
+  }
+}

+ 55 - 0
cli/src/commands/forum/createCategory.ts

@@ -0,0 +1,55 @@
+import { createType } from '@joystream/types'
+import { CategoryId } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { Option } from '@polkadot/types'
+
+export default class ForumCreateCategoryCommand extends ForumCommandBase {
+  static description = 'Create forum category.'
+  static flags = {
+    parentCategoryId: flags.integer({
+      char: 'p',
+      required: false,
+      description: 'Parent category id (in case of creating a subcategory)',
+    }),
+    title: flags.string({
+      char: 't',
+      required: true,
+      description: 'Category title',
+    }),
+    description: flags.string({
+      char: 'd',
+      required: true,
+      description: 'Category description',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { parentCategoryId, title, description } = this.parse(ForumCreateCategoryCommand).flags
+
+    if (parentCategoryId !== undefined) {
+      await this.ensureCategoryMutable(parentCategoryId)
+    }
+
+    const lead = await this.getRequiredLeadContext()
+
+    this.jsonPrettyPrint(JSON.stringify({ parentCategoryId, title, description }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      api.tx.forum.createCategory(
+        createType<Option<CategoryId>, 'Option<CategoryId>'>('Option<CategoryId>', parentCategoryId ?? null),
+        title,
+        description
+      )
+    )
+
+    const categoryId: CategoryId = this.getEvent(result, 'forum', 'CategoryCreated').data[0]
+    this.log(chalk.green(`ForumCategory with id ${chalk.magentaBright(categoryId.toString())} successfully created!`))
+    this.output(categoryId.toString())
+  }
+}

+ 52 - 0
cli/src/commands/forum/createThread.ts

@@ -0,0 +1,52 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { ForumThreadMetadata, IForumThreadMetadata } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import { ThreadId } from '@joystream/types/common'
+
+export default class ForumCreateThreadCommand extends ForumCommandBase {
+  static description = 'Create forum thread.'
+  static flags = {
+    categoryId: flags.integer({
+      required: true,
+      description: 'Id of the forum category the thread should be created in',
+    }),
+    title: flags.string({
+      required: true,
+      description: 'Thread title',
+    }),
+    tags: flags.string({
+      required: false,
+      multiple: true,
+      description: 'Space-separated tags to associate with the thread',
+    }),
+    text: flags.string({
+      required: true,
+      description: 'Initial post text',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, title, tags, text } = this.parse(ForumCreateThreadCommand).flags
+
+    await this.ensureCategoryMutable(categoryId)
+    const member = await this.getRequiredMemberContext()
+
+    const metadata: IForumThreadMetadata = { title, tags }
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, metadata }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(member.membership.controller_account),
+      // Polls not supported atm
+      api.tx.forum.createThread(member.id, categoryId, metadataToBytes(ForumThreadMetadata, metadata), text, null)
+    )
+
+    const threadId: ThreadId = this.getEvent(result, 'forum', 'ThreadCreated').data[1]
+    this.log(chalk.green(`ForumThread with id ${chalk.magentaBright(threadId.toString())} successfully created!`))
+    this.output(threadId.toString())
+  }
+}

+ 53 - 0
cli/src/commands/forum/deleteCategory.ts

@@ -0,0 +1,53 @@
+import { AccountId } from '@joystream/types/common'
+import { PrivilegedActor } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import ExitCodes from '../../ExitCodes'
+
+export default class ForumDeleteCategoryCommand extends ForumCommandBase {
+  static description = 'Delete forum category provided it has no existing subcategories and threads.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Id of the category to delete',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, context } = this.parse(ForumDeleteCategoryCommand).flags
+
+    const category = await this.getCategory(categoryId)
+    let key: AccountId, actor: PrivilegedActor
+
+    if (category.parent_category_id.isNone) {
+      if (context === 'Moderator') {
+        this.error('Moderator cannot delete root categories!', { exit: ExitCodes.AccessDenied })
+      }
+      ;[key, actor] = await this.getForumLeadContext()
+    } else {
+      ;[key, actor] = await this.getForumModerationContext([category.parent_category_id.unwrap()], context)
+    }
+
+    if (category.num_direct_subcategories.gtn(0)) {
+      this.error('Cannot remove a category with existing subcategories!', { exit: ExitCodes.InvalidInput })
+    }
+
+    if (category.num_direct_threads.gtn(0)) {
+      this.error('Cannot remove a category with existing threads!', { exit: ExitCodes.InvalidInput })
+    }
+
+    await this.requireConfirmation(
+      `Are you sure you want to remove forum category ${chalk.magentaBright(categoryId)}?`,
+      true
+    )
+
+    await this.sendAndFollowTx(await this.getDecodedPair(key), api.tx.forum.deleteCategory(actor, categoryId))
+
+    this.log(chalk.green(`Forum category ${chalk.magentaBright(categoryId)} successfully removed!`))
+  }
+}

+ 54 - 0
cli/src/commands/forum/moderatePost.ts

@@ -0,0 +1,54 @@
+import { flags } from '@oclif/command'
+import { formatBalance } from '@polkadot/util'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumModeratePostCommand extends ForumCommandBase {
+  static description = 'Moderate a forum post and slash the associated stake.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Forum thread id',
+    }),
+    postId: flags.integer({
+      char: 'p',
+      required: true,
+      description: 'Forum post id',
+    }),
+    rationale: flags.string({
+      char: 'r',
+      required: true,
+      description: 'Rationale behind the post moderation.',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadId, postId, rationale, context } = this.parse(ForumModeratePostCommand).flags
+
+    await this.ensureCategoryExists(categoryId)
+    await this.ensureCategoryMutable(categoryId)
+    await this.ensureThreadExists(categoryId, threadId)
+    const post = await this.getPost(threadId, postId)
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadId, postId, rationale }))
+    this.warn(`Post stake of ${formatBalance(post.cleanup_pay_off)} will be slashed!`)
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.moderatePost(actor, categoryId, threadId, postId, rationale)
+    )
+
+    this.log(chalk.green(`Post ${chalk.magentaBright(postId.toString())} successfully moderated!`))
+  }
+}

+ 46 - 0
cli/src/commands/forum/moderateThread.ts

@@ -0,0 +1,46 @@
+import { flags } from '@oclif/command'
+import { formatBalance } from '@polkadot/util'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumModerateThreadCommand extends ForumCommandBase {
+  static description = 'Moderate a forum thread and slash the associated stake.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Id of the forum category the thread is currently in',
+    }),
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Forum thread id',
+    }),
+    rationale: flags.string({
+      char: 'r',
+      required: true,
+      description: 'Rationale behind the thread moderation.',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadId, context, rationale } = this.parse(ForumModerateThreadCommand).flags
+
+    const thread = await this.getThread(categoryId, threadId)
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadId, rationale }))
+    this.warn(`Thread stake of ${formatBalance(thread.cleanup_pay_off)} will be slashed!`)
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.moderateThread(actor, categoryId, threadId, rationale)
+    )
+
+    this.log(chalk.green(`Thread ${chalk.magentaBright(threadId.toString())} successfully moderated!`))
+  }
+}

+ 51 - 0
cli/src/commands/forum/moveThread.ts

@@ -0,0 +1,51 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumMoveThreadCommand extends ForumCommandBase {
+  static description = 'Move forum thread to a different category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: "Thread's current category id",
+    }),
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Forum thread id',
+    }),
+    newCategoryId: flags.integer({
+      char: 'n',
+      required: true,
+      description: "Thread's new category id",
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, newCategoryId, threadId, context } = this.parse(ForumMoveThreadCommand).flags
+
+    await this.ensureThreadExists(categoryId, threadId)
+    await this.ensureCategoryExists(newCategoryId)
+    const [key, actor] = await this.getForumModerationContext([categoryId, newCategoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ threadId, categoryId, newCategoryId }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.moveThreadToCategory(actor, categoryId, threadId, newCategoryId)
+    )
+
+    this.log(
+      chalk.green(
+        `Thread ${chalk.magentaBright(threadId.toString())} successfully moved from category ${chalk.magentaBright(
+          categoryId.toString()
+        )} to category ${chalk.magentaBright(newCategoryId.toString())}!`
+      )
+    )
+  }
+}

+ 37 - 0
cli/src/commands/forum/posts.ts

@@ -0,0 +1,37 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+
+export default class ForumPostsCommand extends ForumCommandBase {
+  static description = 'List existing forum posts in given thread.'
+  static flags = {
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Thread id (only posts in this thread will be listed)',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { threadId } = this.parse(ForumPostsCommand).flags
+
+    const posts = await this.getApi().forumPosts(threadId)
+
+    if (posts.length) {
+      displayTable(
+        posts.map(([id, p]) => ({
+          'ID': id.toString(),
+          'Cleanup payoff': formatBalance(p.cleanup_pay_off),
+          'Author member id': p.author_id.toString(),
+          'Last edited': `#${p.last_edited.toNumber()}`,
+        })),
+        5
+      )
+    } else {
+      this.log(`No posts in thread ${chalk.magentaBright(threadId)} found`)
+    }
+  }
+}

+ 46 - 0
cli/src/commands/forum/setStickiedThreads.ts

@@ -0,0 +1,46 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumSetStickiedThreadsCommand extends ForumCommandBase {
+  static description = 'Set stickied threads in a given category.'
+  static flags = {
+    categoryId: flags.integer({
+      required: true,
+      description: 'Forum category id',
+    }),
+    threadIds: flags.integer({
+      required: false,
+      multiple: true,
+      description: 'Space-separated thread ids',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadIds, context } = this.parse(ForumSetStickiedThreadsCommand).flags
+
+    await this.ensureCategoryExists(categoryId)
+    await Promise.all((threadIds || []).map((threadId) => this.ensureThreadExists(categoryId, threadId)))
+
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadIds }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.setStickiedThreads(actor, categoryId, threadIds)
+    )
+
+    this.log(
+      chalk.green(
+        `Threads ${chalk.magentaBright(
+          threadIds.map((id) => chalk.magentaBright(id)).join(', ')
+        )} successfully set as stickied in category ${categoryId}!`
+      )
+    )
+  }
+}

+ 38 - 0
cli/src/commands/forum/threads.ts

@@ -0,0 +1,38 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+
+export default class ForumThreadsCommand extends ForumCommandBase {
+  static description = 'List existing forum threads in given category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Category id (only threads in this category will be listed)',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { categoryId } = this.parse(ForumThreadsCommand).flags
+
+    await this.ensureCategoryExists(categoryId)
+    const threads = await this.getApi().forumThreads(categoryId)
+
+    if (threads.length) {
+      displayTable(
+        threads.map(([id, t]) => ({
+          'ID': id.toString(),
+          'Cleanup payoff': formatBalance(t.cleanup_pay_off),
+          'Author member id': t.author_id.toString(),
+          'No. posts': t.number_of_posts.toString(),
+        })),
+        5
+      )
+    } else {
+      this.log(`No threads in category ${chalk.magentaBright(categoryId)} found`)
+    }
+  }
+}

+ 49 - 0
cli/src/commands/forum/updateCategoryArchivalStatus.ts

@@ -0,0 +1,49 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumUpdateCategoryArchivalStatusCommand extends ForumCommandBase {
+  static description = 'Update archival status of a forum category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    archived: flags.enum<'yes' | 'no'>({
+      options: ['yes', 'no'],
+      required: true,
+      description: 'Whether the category should be archived',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, archived, context } = this.parse(ForumUpdateCategoryArchivalStatusCommand).flags
+    const category = await this.getCategory(categoryId)
+    if (category.archived.isTrue === (archived === 'yes')) {
+      this.error(
+        `Category ${chalk.magentaBright(categoryId.toString())} is already ${archived === 'no' ? 'not ' : ''}archived!`
+      )
+    }
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, archived }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.updateCategoryArchivalStatus(actor, categoryId, archived === 'yes')
+    )
+
+    this.log(
+      chalk.green(
+        `Archival status of category ${chalk.magentaBright(
+          categoryId.toString()
+        )} successfully updated to: ${chalk.magentaBright(archived === 'yes' ? 'archived' : 'not archived')}!`
+      )
+    )
+  }
+}

+ 50 - 0
cli/src/commands/forum/updateCategoryModeratorStatus.ts

@@ -0,0 +1,50 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumUpdateCategoryModeratorStatusCommand extends ForumCommandBase {
+  static description = 'Update moderator status of a worker in relation to a category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    workerId: flags.integer({
+      char: 'w',
+      required: true,
+      description: 'Forum working group worker id',
+    }),
+    status: flags.enum<'active' | 'disabled'>({
+      options: ['active', 'disabled'],
+      required: true,
+      description: 'Status of the moderator membership in the category',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, workerId, status } = this.parse(ForumUpdateCategoryModeratorStatusCommand).flags
+    const lead = await this.getRequiredLeadContext()
+
+    await this.ensureCategoryExists(categoryId)
+    await this.ensureWorkerExists(workerId)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, workerId, status }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      api.tx.forum.updateCategoryMembershipOfModerator(workerId, categoryId, status === 'active')
+    )
+
+    this.log(
+      chalk.green(
+        `Worker ${chalk.magentaBright(workerId.toString())} moderation permissions for category ${chalk.magentaBright(
+          categoryId.toString()
+        )} successfully ${status === 'active' ? 'granted' : 'revoked'}`
+      )
+    )
+  }
+}

+ 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())
+  }
+}

+ 56 - 0
cli/src/commands/membership/chooseMember.ts

@@ -0,0 +1,56 @@
+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 MembershipChooseMember extends MembershipsCommandBase {
+  static description = 'Choose default member to use in the CLI'
+  static flags = {
+    memberId: flags.string({
+      description: 'Select member (if available)',
+      char: 'm',
+      required: false,
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async run() {
+    const { memberId } = this.parse(MembershipChooseMember).flags
+
+    const selectedMember = memberId
+      ? await this.selectKnownMember(memberId)
+      : await this.getRequiredMemberContext(false)
+
+    await this.setSelectedMember(selectedMember)
+
+    this.log(
+      chalk.greenBright(
+        `\nMember switched to id ${chalk.magentaBright(
+          selectedMember.id
+        )} (account: ${selectedMember.membership.controller_account.toString()})!`
+      )
+    )
+  }
+
+  async selectKnownMember(memberIdString: string): Promise<MemberDetails> {
+    const memberId = this.createType('MemberId', memberIdString)
+    const members = await this.getApi().membersDetailsByIds([memberId])
+
+    if (!members.length) {
+      this.error(`Selected member id not found among known members!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    }
+
+    const selectedMember = members[0]
+
+    if (!this.isKeyAvailable(selectedMember.membership.controller_account)) {
+      this.error(`Selected member's account is not imported to CLI!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    }
+
+    return selectedMember
+  }
+}

+ 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!`))
+  }
+}

+ 11 - 3
cli/src/commands/working-groups/application.ts

@@ -1,5 +1,6 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { displayCollapsedRow, displayHeader, memberHandle } from '../../helpers/display'
+import chalk from 'chalk'
 
 export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
   static description = 'Shows an overview of given application by Working Group Application ID'
@@ -15,7 +16,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))
@@ -23,13 +24,20 @@ export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
     displayHeader(`Details`)
     const applicationRow = {
       'Application ID': application.applicationId,
+      'Opening ID': application.openingId.toString(),
       'Member handle': memberHandle(application.member),
       'Role account': application.roleAccout.toString(),
       'Reward account': application.rewardAccount.toString(),
       'Staking account': application.stakingAccount.toString(),
-      'Description': application.descriptionHash.toString(),
-      'Opening ID': application.openingId.toString(),
     }
     displayCollapsedRow(applicationRow)
+
+    if (application.answers) {
+      displayHeader(`Application form`)
+      application.answers?.forEach((a) => {
+        this.log(chalk.bold(a.question.question))
+        this.log(a.answer)
+      })
+    }
   }
 }

+ 89 - 22
cli/src/commands/working-groups/apply.ts

@@ -2,34 +2,74 @@ import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { Option } from '@polkadot/types'
 import { apiModuleByGroup } from '../../Api'
 import { CreateInterface } from '@joystream/types'
-import { StakeParameters } from '@joystream/types/working-group'
+import { ApplicationId, StakeParameters } from '@joystream/types/working-group'
+import { flags } from '@oclif/command'
+import ExitCodes from '../../ExitCodes'
+import { metadataToBytes } from '../../helpers/serialization'
+import { ApplicationMetadata } from '@joystream/metadata-protobuf'
+import chalk from 'chalk'
 
 export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
   static description = 'Apply to a working group opening (requires a membership)'
-  static args = [
-    {
-      name: 'openingId',
+
+  static flags = {
+    openingId: flags.integer({
       description: 'Opening ID',
+      required: true,
+    }),
+    roleAccount: flags.string({
+      description: 'Future worker role account',
+      required: false,
+    }),
+    rewardAccount: flags.string({
+      description: 'Future worker reward account',
       required: false,
-    },
-  ]
+    }),
+    stakingAccount: flags.string({
+      description: "Account to hold applicant's / worker's stake",
+      required: false,
+    }),
+    answers: flags.string({
+      multiple: true,
+      description: "Answers for opening's application form questions (sorted by question index)",
+    }),
+    ...WorkingGroupsCommandBase.flags,
+  }
 
-  async run() {
-    const { openingId } = this.parse(WorkingGroupsApply).args
+  async run(): Promise<void> {
+    let { openingId, roleAccount, rewardAccount, stakingAccount, answers } = this.parse(WorkingGroupsApply).flags
     const memberContext = await this.getRequiredMemberContext()
 
-    const opening = await this.getApi().groupOpening(this.group, parseInt(openingId))
+    const opening = await this.getApi().groupOpening(this.group, openingId)
 
-    const roleAccount = await this.promptForAnyAddress('Choose role account')
-    const rewardAccount = await this.promptForAnyAddress('Choose reward account')
+    if (!roleAccount) {
+      roleAccount = await this.promptForAnyAddress('Choose role account')
+    }
+
+    if (!rewardAccount) {
+      rewardAccount = await this.promptForAnyAddress('Choose reward account')
+    }
 
     let stakeParams: CreateInterface<Option<StakeParameters>> = null
+    const stakeLockId = this.getOriginalApi().consts[apiModuleByGroup[this.group]].stakingHandlerLockId
     if (opening.stake) {
-      const stakingAccount = await this.promptForStakingAccount(
-        opening.stake.value,
-        memberContext.id,
-        memberContext.membership
-      )
+      if (!stakingAccount) {
+        stakingAccount = await this.promptForStakingAccount(
+          opening.stake.value,
+          memberContext.id,
+          memberContext.membership,
+          stakeLockId
+        )
+      } else {
+        await this.setupStakingAccount(
+          memberContext.id,
+          memberContext.membership,
+          stakingAccount,
+          opening.stake.value,
+          undefined,
+          stakeLockId
+        )
+      }
 
       stakeParams = {
         stake: opening.stake.value,
@@ -37,12 +77,36 @@ export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
       }
     }
 
-    // TODO: Custom json?
-    const description = await this.simplePrompt({
-      message: 'Application description',
-    })
+    let applicationFormAnswers = (answers || []).map((answer, i) => ({ question: `Question ${i}`, answer }))
+    if (opening.metadata) {
+      const questions = opening.metadata.applicationFormQuestions
+      if (!answers || !answers.length) {
+        answers = []
+        for (const i in questions) {
+          const { question } = questions[i]
+          const answer = await this.simplePrompt<string>({ message: `Application form question ${i}: ${question}` })
+          answers.push(answer)
+        }
+      }
+      if (answers.length !== questions.length) {
+        this.error(`Unexpected number of answers! Expected: ${questions.length}, Got: ${answers.length}!`, {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      applicationFormAnswers = questions.map(({ question }, i) => ({
+        question: question || '',
+        answer: answers[i],
+      }))
+    } else {
+      this.warn('Could not fetch opening metadata from query node! Application form answers cannot be validated.')
+    }
+
+    this.jsonPrettyPrint(
+      JSON.stringify({ openingId, roleAccount, rewardAccount, stakingAccount, applicationFormAnswers })
+    )
+    await this.requireConfirmation('Do you confirm the provided input?')
 
-    await this.sendAndFollowNamedTx(
+    const result = await this.sendAndFollowNamedTx(
       await this.getDecodedPair(memberContext.membership.controller_account.toString()),
       apiModuleByGroup[this.group],
       'applyOnOpening',
@@ -53,9 +117,12 @@ export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
           role_account_id: roleAccount,
           reward_account_id: rewardAccount,
           stake_parameters: stakeParams,
-          description,
+          description: metadataToBytes(ApplicationMetadata, { answers }),
         }),
       ]
     )
+    const applicationId: ApplicationId = this.getEvent(result, apiModuleByGroup[this.group], 'AppliedOnOpening').data[1]
+    this.log(chalk.greenBright(`Application with id ${chalk.magentaBright(applicationId)} succesfully created!`))
+    this.output(applicationId.toString())
   }
 }

+ 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

+ 179 - 44
cli/src/commands/working-groups/createOpening.ts

@@ -1,25 +1,31 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
-import { GroupMember } from '../../Types'
+import { GroupMember, WorkingGroupOpeningInputParameters } from '../../Types'
+import { WorkingGroupOpeningInputSchema } from '../../schemas/WorkingGroups'
 import chalk from 'chalk'
 import { apiModuleByGroup } from '../../Api'
 import { JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-import OpeningParamsSchema from '../../schemas/json/WorkingGroupOpening.schema.json'
-import { WorkingGroupOpening as OpeningParamsJson } from '../../schemas/typings/WorkingGroupOpening.schema'
 import { IOFlags, getInputJson, ensureOutputFileIsWriteable, saveOutputJsonToFile } from '../../helpers/InputOutput'
 import ExitCodes from '../../ExitCodes'
 import { flags } from '@oclif/command'
 import { AugmentedSubmittables } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
-import BN from 'bn.js'
 import { CLIError } from '@oclif/errors'
-
-const OPENING_STAKE = new BN(2000)
+import {
+  IOpeningMetadata,
+  IWorkingGroupMetadataAction,
+  OpeningMetadata,
+  WorkingGroupMetadataAction,
+} from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import { OpeningId } from '@joystream/types/working-group'
+import Long from 'long'
+import moment from 'moment'
+import { UpcomingWorkingGroupOpeningDetailsFragment } from '../../graphql/generated/queries'
+import { DEFAULT_DATE_FORMAT } from '../../Consts'
 
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
-  static description = 'Create working group opening (requires lead access)'
+  static description = 'Create working group opening / upcoming opening (requires lead access)'
   static flags = {
-    ...WorkingGroupsCommandBase.flags,
     input: IOFlags.input,
     output: flags.string({
       char: 'o',
@@ -40,13 +46,37 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
         '(can be used to generate a "draft" which can be provided as input later)',
       dependsOn: ['output'],
     }),
+    stakeTopUpSource: flags.string({
+      required: false,
+      description:
+        "If provided - this account (key) will be used as default funds source for lead stake top up (in case it's needed)",
+    }),
+    upcoming: flags.boolean({
+      description: 'Whether the opening should be an upcoming opening',
+    }),
+    startsAt: flags.string({
+      required: false,
+      description: `If upcoming opening - the expected opening start date (${DEFAULT_DATE_FORMAT})`,
+      dependsOn: ['upcoming'],
+    }),
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  prepareMetadata(openingParamsJson: WorkingGroupOpeningInputParameters): IOpeningMetadata {
+    return {
+      ...openingParamsJson,
+      applicationFormQuestions: openingParamsJson.applicationFormQuestions?.map((q) => ({
+        question: q.question,
+        type: OpeningMetadata.ApplicationFormQuestion.InputType[q.type],
+      })),
+    }
   }
 
   createTxParams(
-    openingParamsJson: OpeningParamsJson
+    openingParamsJson: WorkingGroupOpeningInputParameters
   ): Parameters<AugmentedSubmittables<'promise'>['membershipWorkingGroup']['addOpening']> {
     return [
-      openingParamsJson.description,
+      metadataToBytes(OpeningMetadata, this.prepareMetadata(openingParamsJson)),
       'Regular',
       {
         stake_amount: openingParamsJson.stakingPolicy.amount,
@@ -57,10 +87,12 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     ]
   }
 
-  async promptForData(lead: GroupMember, rememberedInput?: OpeningParamsJson): Promise<OpeningParamsJson> {
+  async promptForData(
+    rememberedInput?: WorkingGroupOpeningInputParameters
+  ): Promise<WorkingGroupOpeningInputParameters> {
     const openingDefaults = rememberedInput
-    const openingPrompt = new JsonSchemaPrompter<OpeningParamsJson>(
-      (OpeningParamsSchema as unknown) as JSONSchema,
+    const openingPrompt = new JsonSchemaPrompter<WorkingGroupOpeningInputParameters>(
+      WorkingGroupOpeningInputSchema,
       openingDefaults
     )
     const openingParamsJson = await openingPrompt.promptAll()
@@ -68,67 +100,172 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     return openingParamsJson
   }
 
-  async getInputFromFile(filePath: string): Promise<OpeningParamsJson> {
-    const inputParams = await getInputJson<OpeningParamsJson>(filePath, (OpeningParamsSchema as unknown) as JSONSchema)
-
-    return inputParams as OpeningParamsJson
+  async getInputFromFile(filePath: string): Promise<WorkingGroupOpeningInputParameters> {
+    return getInputJson<WorkingGroupOpeningInputParameters>(filePath, WorkingGroupOpeningInputSchema)
   }
 
-  async promptForStakeTopUp(stakingAccount: string): Promise<void> {
-    this.log(`You need to stake ${chalk.bold(formatBalance(OPENING_STAKE))} in order to create a new opening.`)
+  async promptForStakeTopUp({ stake, stakingAccount }: GroupMember, fundsSource?: string): Promise<void> {
+    const newStake = this.getOriginalApi().consts[apiModuleByGroup[this.group]].leaderOpeningStake.add(stake)
+    this.log(
+      `You need to increase your lead stake to ${chalk.bold(formatBalance(newStake))} in order to create a new opening.`
+    )
 
-    const [balances] = await this.getApi().getAccountsBalancesInfo([stakingAccount])
-    const missingBalance = OPENING_STAKE.sub(balances.availableBalance)
+    const [balances] = await this.getApi().getAccountsBalancesInfo([stakingAccount.toString()])
+    const missingBalance = newStake.sub(balances.freeBalance)
     if (missingBalance.gtn(0)) {
       await this.requireConfirmation(
         `Do you wish to transfer remaining ${chalk.bold(
           formatBalance(missingBalance)
         )} to your staking account? (${stakingAccount})`
       )
-      const account = await this.promptForAccount('Choose account to transfer the funds from')
-      await this.sendAndFollowNamedTx(await this.getDecodedPair(account), 'balances', 'transferKeepAlive', [
+      if (!fundsSource) {
+        fundsSource = await this.promptForAccount('Choose account to transfer the funds from')
+      }
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(fundsSource), 'balances', 'transferKeepAlive', [
         stakingAccount,
         missingBalance,
       ])
     }
   }
 
+  async createOpening(lead: GroupMember, inputParameters: WorkingGroupOpeningInputParameters): Promise<OpeningId> {
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...this.createTxParams(inputParameters))
+    )
+    const openingId: OpeningId = this.getEvent(result, apiModuleByGroup[this.group], 'OpeningAdded').data[0]
+    this.log(chalk.green(`Opening with id ${chalk.magentaBright(openingId)} successfully created!`))
+    this.output(openingId.toString())
+    return openingId
+  }
+
+  async createUpcomingOpening(
+    lead: GroupMember,
+    actionMetadata: IWorkingGroupMetadataAction
+  ): Promise<UpcomingWorkingGroupOpeningDetailsFragment | undefined> {
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      this.getOriginalApi().tx[apiModuleByGroup[this.group]].setStatusText(
+        metadataToBytes(WorkingGroupMetadataAction, actionMetadata)
+      )
+    )
+    const { indexInBlock, blockNumber } = await this.getEventDetails(
+      result,
+      apiModuleByGroup[this.group],
+      'StatusTextChanged'
+    )
+    if (this.isQueryNodeUriSet()) {
+      let createdUpcomingOpening: UpcomingWorkingGroupOpeningDetailsFragment | null = null
+      let currentAttempt = 0
+      const maxRetryAttempts = 5
+      while (!createdUpcomingOpening && currentAttempt <= maxRetryAttempts) {
+        ++currentAttempt
+        createdUpcomingOpening = await this.getQNApi().upcomingWorkingGroupOpeningByEvent(blockNumber, indexInBlock)
+        if (!createdUpcomingOpening && currentAttempt <= maxRetryAttempts) {
+          this.log(
+            `Waiting for the upcoming opening to be processed by the query node (${currentAttempt}/${maxRetryAttempts})...`
+          )
+          await new Promise((resolve) => setTimeout(resolve, 6000))
+        }
+      }
+      if (!createdUpcomingOpening) {
+        this.error('Could not fetch the upcoming opening from the query node', { exit: ExitCodes.QueryNodeError })
+      }
+      this.log(
+        chalk.green(`Upcoming opening with id ${chalk.magentaBright(createdUpcomingOpening.id)} successfully created!`)
+      )
+      this.output(createdUpcomingOpening.id)
+      return createdUpcomingOpening
+    } else {
+      this.log(`StatusTextChanged event emitted in block ${blockNumber}, index: ${indexInBlock}`)
+      this.warn('Query node uri not set, cannot confirm whether the upcoming opening was succesfully created')
+    }
+  }
+
+  validateUpcomingOpeningStartDate(dateStr: string): string | true {
+    const momentObj = moment(dateStr, DEFAULT_DATE_FORMAT)
+    if (!momentObj.isValid()) {
+      return `Unrecognized date format: ${dateStr}`
+    }
+    const ts = momentObj.unix()
+    if (ts <= moment().unix()) {
+      return 'Upcoming opening start date should be in the future!'
+    }
+    return true
+  }
+
+  async getUpcomingOpeningExpectedStartTimestamp(dateStr: string | undefined): Promise<number> {
+    if (dateStr) {
+      const validationResult = this.validateUpcomingOpeningStartDate(dateStr)
+      if (validationResult === true) {
+        return moment(dateStr).unix()
+      } else {
+        this.warn(`Invalid opening start date provided: ${validationResult}`)
+      }
+    }
+    dateStr = await this.simplePrompt<string>({
+      message: `Expected upcoming opening start date (${DEFAULT_DATE_FORMAT}):`,
+      validate: (dateStr) => this.validateUpcomingOpeningStartDate(dateStr),
+    })
+    return moment(dateStr).unix()
+  }
+
+  prepareCreateUpcomingOpeningMetadata(
+    inputParameters: WorkingGroupOpeningInputParameters,
+    expectedStartTs: number
+  ): IWorkingGroupMetadataAction {
+    return {
+      addUpcomingOpening: {
+        metadata: {
+          rewardPerBlock: inputParameters.rewardPerBlock ? Long.fromNumber(inputParameters.rewardPerBlock) : undefined,
+          expectedStart: expectedStartTs,
+          minApplicationStake: Long.fromNumber(inputParameters.stakingPolicy.amount),
+          metadata: this.prepareMetadata(inputParameters),
+        },
+      },
+    }
+  }
+
   async run(): Promise<void> {
     // lead-only gate
     const lead = await this.getRequiredLeadContext()
 
     const {
-      flags: { input, output, edit, dryRun },
+      flags: { input, output, edit, dryRun, stakeTopUpSource, upcoming, startsAt },
     } = this.parse(WorkingGroupsCreateOpening)
 
+    const expectedStartTs = upcoming ? await this.getUpcomingOpeningExpectedStartTimestamp(startsAt) : 0
+
     ensureOutputFileIsWriteable(output)
 
     let tryAgain = false
-    let rememberedInput: OpeningParamsJson | undefined
+    let rememberedInput: WorkingGroupOpeningInputParameters | undefined
     do {
       if (edit) {
         rememberedInput = await this.getInputFromFile(input as string)
       }
       // Either prompt for the data or get it from input file
       const openingJson =
-        !input || edit || tryAgain
-          ? await this.promptForData(lead, rememberedInput)
-          : await this.getInputFromFile(input)
+        !input || edit || tryAgain ? await this.promptForData(rememberedInput) : await this.getInputFromFile(input)
 
       // Remember the provided/fetched data in a variable
       rememberedInput = openingJson
 
-      await this.promptForStakeTopUp(lead.stakingAccount.toString())
+      if (!upcoming) {
+        await this.promptForStakeTopUp(lead, stakeTopUpSource)
+      }
 
-      // Generate and ask to confirm tx params
-      const txParams = this.createTxParams(openingJson)
-      this.jsonPrettyPrint(JSON.stringify(txParams))
-      const confirmed = await this.simplePrompt({
-        type: 'confirm',
-        message: 'Do you confirm these extrinsic parameters?',
-      })
+      const createUpcomingOpeningActionMeta = this.prepareCreateUpcomingOpeningMetadata(
+        rememberedInput,
+        expectedStartTs
+      )
+
+      this.jsonPrettyPrint(
+        JSON.stringify(upcoming ? { WorkingGroupMetadataAction: createUpcomingOpeningActionMeta } : rememberedInput)
+      )
+      const confirmed = await this.requestConfirmation('Do you confirm the provided input?')
       if (!confirmed) {
-        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+        tryAgain = await this.requestConfirmation('Try again with remembered input?')
         continue
       }
 
@@ -148,17 +285,15 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
 
       // Send the tx
       try {
-        await this.sendAndFollowTx(
-          await this.getDecodedPair(lead.roleAccount),
-          this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...txParams)
-        )
-        this.log(chalk.green('Opening successfully created!'))
+        upcoming
+          ? await this.createUpcomingOpening(lead, createUpcomingOpeningActionMeta)
+          : await this.createOpening(lead, rememberedInput)
         tryAgain = false
       } catch (e) {
         if (e instanceof CLIError) {
           this.warn(e.message)
         }
-        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+        tryAgain = await this.requestConfirmation('Try again with remembered input?')
       }
     } while (tryAgain)
   }

+ 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> {

+ 13 - 10
cli/src/commands/working-groups/fillOpening.ts

@@ -2,31 +2,34 @@ import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { apiModuleByGroup } from '../../Api'
 import chalk from 'chalk'
 import { createType } from '@joystream/types'
+import { flags } from '@oclif/command'
 
 export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
   static description = "Allows filling working group opening that's currently in review. Requires lead access."
-  static args = [
-    {
-      name: 'wgOpeningId',
-      required: true,
-      description: 'Working Group Opening ID',
-    },
-  ]
 
   static flags = {
+    openingId: flags.integer({
+      required: true,
+      description: 'Working Group Opening ID',
+    }),
+    applicationIds: flags.integer({
+      multiple: true,
+      description: 'Accepted application ids',
+    }),
     ...WorkingGroupsCommandBase.flags,
   }
 
   async run(): Promise<void> {
-    const { args } = this.parse(WorkingGroupsFillOpening)
+    let { openingId, applicationIds } = this.parse(WorkingGroupsFillOpening).flags
 
     // Lead-only gate
     const lead = await this.getRequiredLeadContext()
 
-    const openingId = parseInt(args.wgOpeningId)
     const opening = await this.getOpeningForLeadAction(openingId)
 
-    const applicationIds = await this.promptForApplicationsToAccept(opening)
+    if (!applicationIds || !applicationIds.length) {
+      applicationIds = await this.promptForApplicationsToAccept(opening)
+    }
 
     await this.sendAndFollowNamedTx(
       await this.getDecodedPair(lead.roleAccount),

+ 81 - 27
cli/src/commands/working-groups/opening.ts

@@ -1,48 +1,82 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { displayTable, displayCollapsedRow, displayHeader, shortAddress, memberHandle } from '../../helpers/display'
 import { formatBalance } from '@polkadot/util'
+import { flags } from '@oclif/command'
+import moment from 'moment'
+import { OpeningDetails } from '../../Types'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+import { UpcomingWorkingGroupOpeningDetailsFragment } from '../../graphql/generated/queries'
+import { DEFAULT_DATE_FORMAT } from '../../Consts'
 
 export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
-  static description = 'Shows an overview of given working group opening by Working Group Opening ID'
-  static args = [
-    {
-      name: 'wgOpeningId',
-      required: true,
-      description: 'Working Group Opening ID',
-    },
-  ]
+  static description = 'Shows detailed information about working group opening / upcoming opening by id'
 
   static flags = {
+    id: flags.string({
+      required: true,
+      description: 'Opening / upcoming opening id (depending on --upcoming flag)',
+    }),
+    upcoming: flags.boolean({
+      description: 'Whether the opening is an upcoming opening',
+    }),
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const { args } = this.parse(WorkingGroupsOpening)
-
-    const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId))
-
-    // TODO: Opening desc?
-
+  openingDetails(opening: OpeningDetails): void {
     displayHeader('Opening details')
-    const openingRow = {
+    displayCollapsedRow({
       'Opening ID': opening.openingId,
       'Opening type': opening.type.type,
       'Created': `#${opening.createdAtBlock}`,
-      'Reward per block': formatBalance(opening.rewardPerBlock),
-    }
-    displayCollapsedRow(openingRow)
+      'Reward per block': opening.rewardPerBlock ? formatBalance(opening.rewardPerBlock) : '-',
+    })
+  }
 
+  openingStakingPolicy(opening: OpeningDetails): void {
     displayHeader('Staking policy')
-    if (opening.stake) {
-      const stakingRow = {
-        'Stake amount': formatBalance(opening.stake.value),
-        'Unstaking period': opening.stake.unstakingPeriod.toLocaleString() + ' blocks',
-      }
-      displayCollapsedRow(stakingRow)
-    } else {
-      this.log('NONE')
+    displayCollapsedRow({
+      'Stake amount': formatBalance(opening.stake.value),
+      'Unstaking period': opening.stake.unstakingPeriod.toLocaleString() + ' blocks',
+    })
+  }
+
+  upcomingOpeningDetails(upcomingOpening: UpcomingWorkingGroupOpeningDetailsFragment): void {
+    displayHeader('Upcoming opening details')
+    displayCollapsedRow({
+      'Upcoming Opening ID': upcomingOpening.id,
+      'Expected start': upcomingOpening.expectedStart
+        ? moment(upcomingOpening.expectedStart).format(DEFAULT_DATE_FORMAT)
+        : '?',
+      'Reward per block': upcomingOpening.rewardPerBlock ? formatBalance(upcomingOpening.rewardPerBlock) : '?',
+    })
+  }
+
+  upcomingOpeningStakingPolicy(upcomingOpening: UpcomingWorkingGroupOpeningDetailsFragment): void {
+    if (upcomingOpening.stakeAmount) {
+      displayHeader('Staking policy')
+      displayCollapsedRow({
+        'Stake amount': formatBalance(upcomingOpening.stakeAmount),
+      })
+    }
+  }
+
+  openingMetadata(opening: OpeningDetails | UpcomingWorkingGroupOpeningDetailsFragment): void {
+    const { metadata } = opening
+    if (metadata) {
+      displayHeader('Metadata')
+      this.jsonPrettyPrint(
+        JSON.stringify({
+          ...metadata,
+          expectedEnding: metadata.expectedEnding
+            ? moment(metadata.expectedEnding).format(DEFAULT_DATE_FORMAT)
+            : undefined,
+        })
+      )
     }
+  }
 
+  openingApplications(opening: OpeningDetails): void {
     displayHeader(`Applications (${opening.applications.length})`)
     const applicationsRows = opening.applications.map((a) => ({
       'ID': a.applicationId,
@@ -53,4 +87,24 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
     }))
     displayTable(applicationsRows, 5)
   }
+
+  async run(): Promise<void> {
+    const { id, upcoming } = this.parse(WorkingGroupsOpening).flags
+
+    if (upcoming) {
+      const upcomingOpening = await this.getQNApi().upcomingWorkingGroupOpeningById(id)
+      if (!upcomingOpening) {
+        this.error(`Upcoming opening by id ${chalk.magentaBright(id)} was not found!`, { exit: ExitCodes.InvalidInput })
+      }
+      this.upcomingOpeningDetails(upcomingOpening)
+      this.upcomingOpeningStakingPolicy(upcomingOpening)
+      this.openingMetadata(upcomingOpening)
+    } else {
+      const opening = await this.getApi().groupOpening(this.group, parseInt(id))
+      this.openingDetails(opening)
+      this.openingStakingPolicy(opening)
+      this.openingMetadata(opening)
+      this.openingApplications(opening)
+    }
+  }
 }

+ 31 - 9
cli/src/commands/working-groups/openings.ts

@@ -1,20 +1,42 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { flags } from '@oclif/command'
 import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+import moment from 'moment'
+import { DEFAULT_DATE_FORMAT } from '../../Consts'
 
 export default class WorkingGroupsOpenings extends WorkingGroupsCommandBase {
-  static description = 'Shows an overview of given working group openings'
+  static description = 'Lists active/upcoming openings in a given working group'
   static flags = {
+    upcoming: flags.boolean({
+      description: 'List upcoming openings (active openings are listed by default)',
+    }),
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const openings = await this.getApi().openingsByGroup(this.group)
+  async run(): Promise<void> {
+    const { upcoming } = this.parse(WorkingGroupsOpenings).flags
 
-    const openingsRows = openings.map((o) => ({
-      'Opening ID': o.openingId,
-      Type: o.type.type,
-      Applications: o.applications.length,
-    }))
-    displayTable(openingsRows, 5)
+    let rows: { [k: string]: string | number }[]
+    if (upcoming) {
+      const upcomingOpenings = await this.getQNApi().upcomingWorkingGroupOpeningsByGroup(this.group)
+      rows = upcomingOpenings.map((o) => ({
+        'Upcoming opening ID': o.id,
+        'Starts at': o.expectedStart ? moment(o.expectedStart).format(DEFAULT_DATE_FORMAT) : '?',
+        'Reward/block': o.rewardPerBlock ? formatBalance(o.rewardPerBlock) : '?',
+        'Stake': o.stakeAmount ? formatBalance(o.stakeAmount) : '?',
+      }))
+    } else {
+      const openings = await this.getApi().openingsByGroup(this.group)
+      rows = openings.map((o) => ({
+        'Opening ID': o.openingId,
+        Type: o.type.type,
+        Applications: o.applications.length,
+        'Reward/block': formatBalance(o.rewardPerBlock),
+        'Stake': formatBalance(o.stake.value),
+      }))
+    }
+
+    displayTable(rows, 5)
   }
 }

+ 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)
 

+ 85 - 0
cli/src/commands/working-groups/removeUpcomingOpening.ts

@@ -0,0 +1,85 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import chalk from 'chalk'
+import { apiModuleByGroup } from '../../Api'
+import { flags } from '@oclif/command'
+import { IWorkingGroupMetadataAction, WorkingGroupMetadataAction } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import ExitCodes from '../../ExitCodes'
+
+export default class WorkingGroupsRemoveUpcomingOpening extends WorkingGroupsCommandBase {
+  static description =
+    'Remove an existing upcoming opening by sending RemoveUpcomingOpening metadata signal (requires lead access)'
+
+  static flags = {
+    id: flags.string({
+      char: 'i',
+      required: true,
+      description: `Id of the upcoming opening to remove`,
+    }),
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async checkIfUpcomingOpeningExists(id: string): Promise<void> {
+    if (this.isQueryNodeUriSet()) {
+      const upcomingOpening = await this.getQNApi().upcomingWorkingGroupOpeningById(id)
+      this.log(`Upcoming opening by id ${id} found:`)
+      this.jsonPrettyPrint(JSON.stringify(upcomingOpening))
+      this.log('\n')
+    } else {
+      this.warn('Query node uri not set, cannot verify if upcoming opening exists!')
+    }
+  }
+
+  async checkIfUpcomingOpeningRemoved(id: string): Promise<void> {
+    if (this.isQueryNodeUriSet()) {
+      let removed = false
+      let currentAttempt = 0
+      const maxRetryAttempts = 5
+      while (!removed && currentAttempt <= maxRetryAttempts) {
+        ++currentAttempt
+        removed = !(await this.getQNApi().upcomingWorkingGroupOpeningById(id))
+        if (!removed && currentAttempt <= maxRetryAttempts) {
+          this.log(
+            `Waiting for the upcoming opening removal to be processed by the query node (${currentAttempt}/${maxRetryAttempts})...`
+          )
+          await new Promise((resolve) => setTimeout(resolve, 6000))
+        }
+      }
+      if (!removed) {
+        this.error('Could not confirm upcoming opening removal against the query node', {
+          exit: ExitCodes.QueryNodeError,
+        })
+      }
+      this.log(chalk.green(`Upcoming opening with id ${chalk.magentaBright(id)} successfully removed!`))
+    } else {
+      this.warn('Query node uri not set, cannot verify if upcoming opening was removed!')
+    }
+  }
+
+  async run(): Promise<void> {
+    const { id } = this.parse(WorkingGroupsRemoveUpcomingOpening).flags
+    // lead-only gate
+    const lead = await this.getRequiredLeadContext()
+
+    await this.checkIfUpcomingOpeningExists(id)
+
+    const actionMetadata: IWorkingGroupMetadataAction = {
+      'removeUpcomingOpening': {
+        id,
+      },
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ WorkingGroupMetadataAction: actionMetadata }))
+
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      this.getOriginalApi().tx[apiModuleByGroup[this.group]].setStatusText(
+        metadataToBytes(WorkingGroupMetadataAction, actionMetadata)
+      )
+    )
+
+    await this.checkIfUpcomingOpeningRemoved(id)
+  }
+}

+ 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)

+ 54 - 0
cli/src/commands/working-groups/updateGroupMetadata.ts

@@ -0,0 +1,54 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { WorkingGroupUpdateStatusInputParameters } from '../../Types'
+import { WorkingGroupUpdateStatusInputSchema } from '../../schemas/WorkingGroups'
+import chalk from 'chalk'
+import { apiModuleByGroup } from '../../Api'
+import { getInputJson } from '../../helpers/InputOutput'
+import { flags } from '@oclif/command'
+import { IWorkingGroupMetadataAction, WorkingGroupMetadataAction } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+
+export default class WorkingGroupsUpdateMetadata extends WorkingGroupsCommandBase {
+  static description =
+    'Update working group metadata (description, status etc.). The update will be atomic (just like video / channel metadata updates)'
+
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    // lead-only gate
+    const lead = await this.getRequiredLeadContext()
+
+    const {
+      flags: { input: inputFilePath },
+    } = this.parse(WorkingGroupsUpdateMetadata)
+
+    const input = await getInputJson<WorkingGroupUpdateStatusInputParameters>(
+      inputFilePath,
+      WorkingGroupUpdateStatusInputSchema
+    )
+    const actionMetadata: IWorkingGroupMetadataAction = {
+      'setGroupMetadata': {
+        newMetadata: input,
+      },
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(actionMetadata))
+
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      this.getOriginalApi().tx[apiModuleByGroup[this.group]].setStatusText(
+        metadataToBytes(WorkingGroupMetadataAction, actionMetadata)
+      )
+    )
+    this.log(chalk.green(`Working group metadata successfully updated!`))
+  }
+}

+ 151 - 0
cli/src/graphql/generated/queries.ts

@@ -52,6 +52,71 @@ export type GetDataObjectsByVideoIdQueryVariables = Types.Exact<{
 
 export type GetDataObjectsByVideoIdQuery = { storageDataObjects: Array<DataObjectInfoFragment> }
 
+export type WorkingGroupOpeningMetadataFieldsFragment = {
+  description?: Types.Maybe<string>
+  shortDescription?: Types.Maybe<string>
+  hiringLimit?: Types.Maybe<number>
+  expectedEnding?: Types.Maybe<any>
+  applicationDetails?: Types.Maybe<string>
+  applicationFormQuestions: Array<{ question?: Types.Maybe<string>; type: Types.ApplicationFormQuestionType }>
+}
+
+export type WorkingGroupOpeningDetailsFragment = { metadata: WorkingGroupOpeningMetadataFieldsFragment }
+
+export type WorkingGroupApplicationDetailsFragment = {
+  answers: Array<{ answer: string; question: { question?: Types.Maybe<string> } }>
+}
+
+export type UpcomingWorkingGroupOpeningDetailsFragment = {
+  id: string
+  groupId: string
+  expectedStart?: Types.Maybe<any>
+  stakeAmount?: Types.Maybe<any>
+  rewardPerBlock?: Types.Maybe<any>
+  metadata: WorkingGroupOpeningMetadataFieldsFragment
+}
+
+export type OpeningDetailsByIdQueryVariables = Types.Exact<{
+  id: Types.Scalars['ID']
+}>
+
+export type OpeningDetailsByIdQuery = {
+  workingGroupOpeningByUniqueInput?: Types.Maybe<WorkingGroupOpeningDetailsFragment>
+}
+
+export type ApplicationDetailsByIdQueryVariables = Types.Exact<{
+  id: Types.Scalars['ID']
+}>
+
+export type ApplicationDetailsByIdQuery = {
+  workingGroupApplicationByUniqueInput?: Types.Maybe<WorkingGroupApplicationDetailsFragment>
+}
+
+export type UpcomingWorkingGroupOpeningByEventQueryVariables = Types.Exact<{
+  blockNumber: Types.Scalars['Int']
+  indexInBlock: Types.Scalars['Int']
+}>
+
+export type UpcomingWorkingGroupOpeningByEventQuery = {
+  upcomingWorkingGroupOpenings: Array<UpcomingWorkingGroupOpeningDetailsFragment>
+}
+
+export type UpcomingWorkingGroupOpeningsByGroupQueryVariables = Types.Exact<{
+  workingGroupId: Types.Scalars['ID']
+}>
+
+export type UpcomingWorkingGroupOpeningsByGroupQuery = {
+  upcomingWorkingGroupOpenings: Array<UpcomingWorkingGroupOpeningDetailsFragment>
+}
+
+export type UpcomingWorkingGroupOpeningByIdQueryVariables = Types.Exact<{
+  id: Types.Scalars['ID']
+}>
+
+export type UpcomingWorkingGroupOpeningByIdQuery = {
+  upcomingWorkingGroupOpeningByUniqueInput?: Types.Maybe<UpcomingWorkingGroupOpeningDetailsFragment>
+}
+
 export const MemberMetadataFields = gql`
   fragment MemberMetadataFields on MemberMetadata {
     name
@@ -106,6 +171,50 @@ export const DataObjectInfo = gql`
     }
   }
 `
+export const WorkingGroupOpeningMetadataFields = gql`
+  fragment WorkingGroupOpeningMetadataFields on WorkingGroupOpeningMetadata {
+    description
+    shortDescription
+    hiringLimit
+    expectedEnding
+    applicationDetails
+    applicationFormQuestions {
+      question
+      type
+    }
+  }
+`
+export const WorkingGroupOpeningDetails = gql`
+  fragment WorkingGroupOpeningDetails on WorkingGroupOpening {
+    metadata {
+      ...WorkingGroupOpeningMetadataFields
+    }
+  }
+  ${WorkingGroupOpeningMetadataFields}
+`
+export const WorkingGroupApplicationDetails = gql`
+  fragment WorkingGroupApplicationDetails on WorkingGroupApplication {
+    answers {
+      question {
+        question
+      }
+      answer
+    }
+  }
+`
+export const UpcomingWorkingGroupOpeningDetails = gql`
+  fragment UpcomingWorkingGroupOpeningDetails on UpcomingWorkingGroupOpening {
+    id
+    groupId
+    expectedStart
+    stakeAmount
+    rewardPerBlock
+    metadata {
+      ...WorkingGroupOpeningMetadataFields
+    }
+  }
+  ${WorkingGroupOpeningMetadataFields}
+`
 export const GetMembersByIds = gql`
   query getMembersByIds($ids: [ID!]) {
     memberships(where: { id_in: $ids }) {
@@ -152,3 +261,45 @@ export const GetDataObjectsByVideoId = gql`
   }
   ${DataObjectInfo}
 `
+export const OpeningDetailsById = gql`
+  query openingDetailsById($id: ID!) {
+    workingGroupOpeningByUniqueInput(where: { id: $id }) {
+      ...WorkingGroupOpeningDetails
+    }
+  }
+  ${WorkingGroupOpeningDetails}
+`
+export const ApplicationDetailsById = gql`
+  query applicationDetailsById($id: ID!) {
+    workingGroupApplicationByUniqueInput(where: { id: $id }) {
+      ...WorkingGroupApplicationDetails
+    }
+  }
+  ${WorkingGroupApplicationDetails}
+`
+export const UpcomingWorkingGroupOpeningByEvent = gql`
+  query upcomingWorkingGroupOpeningByEvent($blockNumber: Int!, $indexInBlock: Int!) {
+    upcomingWorkingGroupOpenings(
+      where: { createdInEvent: { inBlock_eq: $blockNumber, indexInBlock_eq: $indexInBlock } }
+    ) {
+      ...UpcomingWorkingGroupOpeningDetails
+    }
+  }
+  ${UpcomingWorkingGroupOpeningDetails}
+`
+export const UpcomingWorkingGroupOpeningsByGroup = gql`
+  query upcomingWorkingGroupOpeningsByGroup($workingGroupId: ID!) {
+    upcomingWorkingGroupOpenings(where: { group: { id_eq: $workingGroupId } }, orderBy: createdAt_DESC) {
+      ...UpcomingWorkingGroupOpeningDetails
+    }
+  }
+  ${UpcomingWorkingGroupOpeningDetails}
+`
+export const UpcomingWorkingGroupOpeningById = gql`
+  query upcomingWorkingGroupOpeningById($id: ID!) {
+    upcomingWorkingGroupOpeningByUniqueInput(where: { id: $id }) {
+      ...UpcomingWorkingGroupOpeningDetails
+    }
+  }
+  ${UpcomingWorkingGroupOpeningDetails}
+`

+ 69 - 0
cli/src/graphql/queries/workingGroups.graphql

@@ -0,0 +1,69 @@
+fragment WorkingGroupOpeningMetadataFields on WorkingGroupOpeningMetadata {
+  description
+  shortDescription
+  hiringLimit
+  expectedEnding
+  applicationDetails
+  applicationFormQuestions {
+    question
+    type
+  }
+}
+
+fragment WorkingGroupOpeningDetails on WorkingGroupOpening {
+  metadata {
+    ...WorkingGroupOpeningMetadataFields
+  }
+}
+
+fragment WorkingGroupApplicationDetails on WorkingGroupApplication {
+  answers {
+    question {
+      question
+    }
+    answer
+  }
+}
+
+fragment UpcomingWorkingGroupOpeningDetails on UpcomingWorkingGroupOpening {
+  id
+  groupId
+  expectedStart
+  stakeAmount
+  rewardPerBlock
+  metadata {
+    ...WorkingGroupOpeningMetadataFields
+  }
+}
+
+query openingDetailsById($id: ID!) {
+  workingGroupOpeningByUniqueInput(where: { id: $id }) {
+    ...WorkingGroupOpeningDetails
+  }
+}
+
+query applicationDetailsById($id: ID!) {
+  workingGroupApplicationByUniqueInput(where: { id: $id }) {
+    ...WorkingGroupApplicationDetails
+  }
+}
+
+query upcomingWorkingGroupOpeningByEvent($blockNumber: Int!, $indexInBlock: Int!) {
+  upcomingWorkingGroupOpenings(
+    where: { createdInEvent: { inBlock_eq: $blockNumber, indexInBlock_eq: $indexInBlock } }
+  ) {
+    ...UpcomingWorkingGroupOpeningDetails
+  }
+}
+
+query upcomingWorkingGroupOpeningsByGroup($workingGroupId: ID!) {
+  upcomingWorkingGroupOpenings(where: { group: { id_eq: $workingGroupId } }, orderBy: createdAt_DESC) {
+    ...UpcomingWorkingGroupOpeningDetails
+  }
+}
+
+query upcomingWorkingGroupOpeningById($id: ID!) {
+  upcomingWorkingGroupOpeningByUniqueInput(where: { id: $id }) {
+    ...UpcomingWorkingGroupOpeningDetails
+  }
+}

+ 6 - 1
cli/src/schemas/ContentDirectory.ts

@@ -94,7 +94,12 @@ export const VideoInputSchema: JsonSchema<VideoInputParameters> = {
         },
       },
     },
-    persons: { type: 'array' },
+    persons: {
+      type: 'array',
+      items: {
+        type: 'integer',
+      },
+    },
     publishedBeforeJoystream: {
       type: 'object',
       properties: {

+ 68 - 0
cli/src/schemas/WorkingGroups.ts

@@ -0,0 +1,68 @@
+import { WorkingGroupOpeningInputParameters, WorkingGroupUpdateStatusInputParameters, JsonSchema } from '../Types'
+
+export const WorkingGroupOpeningInputSchema: JsonSchema<WorkingGroupOpeningInputParameters> = {
+  type: 'object',
+  additionalProperties: false,
+  required: ['stakingPolicy'],
+  properties: {
+    applicationDetails: {
+      type: 'string',
+    },
+    expectedEndingTimestamp: {
+      type: 'integer',
+      minimum: Math.floor(Date.now() / 1000),
+    },
+    hiringLimit: {
+      type: 'integer',
+      minimum: 1,
+    },
+    shortDescription: {
+      type: 'string',
+    },
+    description: {
+      type: 'string',
+    },
+    applicationFormQuestions: {
+      type: 'array',
+      items: {
+        type: 'object',
+        additionalProperties: false,
+        required: ['question'],
+        properties: {
+          question: {
+            type: 'string',
+            minLength: 1,
+          },
+          type: {
+            type: 'string',
+            enum: ['TEXTAREA', 'TEXT'],
+          },
+        },
+      },
+    },
+    stakingPolicy: {
+      type: 'object',
+      additionalProperties: false,
+      required: ['amount', 'unstakingPeriod'],
+      properties: {
+        amount: { type: 'integer', minimum: 2000 },
+        unstakingPeriod: { type: 'integer', minimum: 43201 },
+      },
+    },
+    rewardPerBlock: {
+      type: 'integer',
+      minimum: 1,
+    },
+  },
+}
+
+export const WorkingGroupUpdateStatusInputSchema: JsonSchema<WorkingGroupUpdateStatusInputParameters> = {
+  type: 'object',
+  additionalProperties: false,
+  properties: {
+    about: { type: 'string' },
+    description: { type: 'string' },
+    status: { type: 'string' },
+    statusMessage: { type: 'string' },
+  },
+}

+ 0 - 37
devops/aws/chain-spec-pioneer.yml

@@ -1,37 +0,0 @@
----
-# Configure chain spec, start joystream-node service on the servers and build Pioneer
-
-- name: Create and copy the chain-spec file
-  hosts: all
-
-  tasks:
-    - name: Generate chain-spec file and data keys either on localhost or admin server
-      include_role:
-        name: common
-        tasks_from: chain-spec-node-keys
-      vars:
-        local_or_admin: "{{ groups['build'][0] if run_on_admin_server|bool else 'localhost' }}"
-        admin_code_dir: "{{ remote_code_path if run_on_admin_server|bool else local_dir }}"
-
-- name: Copy secret, auth and start joystream-node service for validators
-  hosts: validators
-  gather_facts: no
-
-  roles:
-    - validators
-
-- name: Configure RPC service and start it
-  hosts: rpc
-  gather_facts: no
-
-  roles:
-    - rpc
-
-- name: Build Pioneer and copy artifacts to S3
-  hosts: build
-  gather_facts: no
-
-  tasks:
-    - include_role:
-        name: admin
-        tasks_from: deploy-pioneer

+ 29 - 63
devops/aws/cloudformation/infrastructure.yml

@@ -2,7 +2,7 @@
 # This is comprised of:
 #   - N validators
 #   - One RPC node
-#   - s3 bucket with a build of Pionner
+#   - One Build instance
 
 AWSTemplateFormatVersion: 2010-09-09
 
@@ -34,16 +34,23 @@ Parameters:
     Description: Number of validator instances to launch
     Type: Number
     Default: 2
+  VolumeSize:
+    Description: Validator and Build instance volume size in GB
+    Type: Number
+    Default: 120
+  RPCVolumeSize:
+    Description: RPC Instance volume size in GB
+    Type: Number
+    Default: 120
 
 Conditions:
-  HasAMIId: !Not [!Equals [!Ref EC2AMI, ""]]
+  HasAMIId: !Not [!Equals [!Ref EC2AMI, '']]
 
 Resources:
   SecurityGroup:
     Type: AWS::EC2::SecurityGroup
     Properties:
-      GroupDescription:
-        !Sub 'Internal Security group for validator nodes ${AWS::StackName}'
+      GroupDescription: !Sub 'Internal Security group for validator nodes ${AWS::StackName}'
       SecurityGroupIngress:
         - IpProtocol: tcp
           FromPort: 30333
@@ -60,8 +67,7 @@ Resources:
   RPCSecurityGroup:
     Type: AWS::EC2::SecurityGroup
     Properties:
-      GroupDescription:
-        !Sub 'Internal Security group for RPC nodes ${AWS::StackName}'
+      GroupDescription: !Sub 'Internal Security group for RPC nodes ${AWS::StackName}'
       SecurityGroupIngress:
         - IpProtocol: tcp
           FromPort: 9933
@@ -111,7 +117,7 @@ Resources:
         BlockDeviceMappings:
           - DeviceName: /dev/sda1
             Ebs:
-              VolumeSize: '120'
+              VolumeSize: !Ref VolumeSize
         UserData:
           Fn::Base64: !Sub |
             #!/bin/bash -xe
@@ -161,7 +167,7 @@ Resources:
       DesiredCapacity: !Ref NumberOfValidators
       AvailabilityZones:
         Fn::GetAZs:
-          Ref: "AWS::Region"
+          Ref: 'AWS::Region'
       MixedInstancesPolicy:
         LaunchTemplate:
           LaunchTemplateSpecification:
@@ -172,7 +178,7 @@ Resources:
       Tags:
         - Key: Name
           Value: !Sub '${AWS::StackName}'
-          PropagateAtLaunch: "true"
+          PropagateAtLaunch: 'true'
 
   RPCInstance:
     Type: AWS::EC2::Instance
@@ -183,6 +189,10 @@ Resources:
       LaunchTemplate:
         LaunchTemplateId: !Ref InstanceLaunchTemplate
         Version: !GetAtt InstanceLaunchTemplate.LatestVersionNumber
+      BlockDeviceMappings:
+        - DeviceName: /dev/sda1
+          Ebs:
+            VolumeSize: !Ref RPCVolumeSize
       Tags:
         - Key: Name
           Value: !Sub '${AWS::StackName}_rpc'
@@ -208,71 +218,27 @@ Resources:
       Timeout: '600'
       Count: !Ref NumberOfValidators
 
-  S3Bucket:
-    Type: AWS::S3::Bucket
-    Properties:
-      AccessControl: PublicRead
-      WebsiteConfiguration:
-        IndexDocument: index.html
-
-  BucketPolicy:
-    Type: AWS::S3::BucketPolicy
-    Properties:
-      PolicyDocument:
-        Id: PublicPolicy
-        Version: 2012-10-17
-        Statement:
-          - Sid: PublicReadForGetBucketObjects
-            Effect: Allow
-            Principal: '*'
-            Action: 's3:GetObject'
-            Resource: !Sub "arn:aws:s3:::${S3Bucket}/*"
-      Bucket: !Ref S3Bucket
-
-  CloudFrontDistribution:
-    Type: AWS::CloudFront::Distribution
-    Properties:
-      DistributionConfig:
-        Origins:
-        - DomainName: !Select [1, !Split ["//", !GetAtt S3Bucket.WebsiteURL]]
-          Id: pioneer-origin-s3
-          CustomOriginConfig:
-            OriginProtocolPolicy: http-only
-        DefaultCacheBehavior:
-          TargetOriginId: pioneer-origin-s3
-          ViewerProtocolPolicy: redirect-to-https
-          ForwardedValues:
-            QueryString: true
-        Enabled: true
-        HttpVersion: http2
-
 Outputs:
   AutoScalingId:
     Description: The Auto Scaling ID
-    Value:  !Ref AutoScalingGroup
+    Value: !Ref AutoScalingGroup
     Export:
-      Name: !Sub "${AWS::StackName}AutoScalingGroup"
+      Name: !Sub '${AWS::StackName}AutoScalingGroup'
 
   RPCPublicIp:
     Description: The DNS name for the created instance
-    Value:  !Sub "${RPCInstance.PublicIp}"
+    Value: !Sub '${RPCInstance.PublicIp}'
     Export:
-      Name: !Sub "${AWS::StackName}RPCPublicIp"
+      Name: !Sub '${AWS::StackName}RPCPublicIp'
 
   BuildPublicIp:
     Description: The DNS name for the created instance
-    Value:  !Sub "${BuildInstance.PublicIp}"
-    Export:
-      Name: !Sub "${AWS::StackName}BuildPublicIp"
-
-  S3BucketName:
-    Value: !Ref S3Bucket
-    Description: Name of S3 bucket to hold website content
+    Value: !Sub '${BuildInstance.PublicIp}'
     Export:
-      Name: !Sub "${AWS::StackName}S3BucketName"
+      Name: !Sub '${AWS::StackName}BuildPublicIp'
 
-  DomainName:
-    Description: CloudFront Domain Name
-    Value:  !Sub "${CloudFrontDistribution.DomainName}"
+  BuildInstanceId:
+    Description: Build instance ID
+    Value: !Ref BuildInstance
     Export:
-      Name: !Sub "${AWS::StackName}DomainName"
+      Name: !Sub '${AWS::StackName}BuildInstanceId'

+ 27 - 0
devops/aws/configure-network.yml

@@ -0,0 +1,27 @@
+---
+# Configure chain spec, start joystream-node and other services on the servers
+
+- name: Create and copy the chain-spec file
+  hosts: all
+
+  tasks:
+    - name: Generate chain-spec file and data keys on build server
+      include_role:
+        name: common
+        tasks_from: chain-spec-node-keys
+      vars:
+        build_instance: "{{ groups['build'][0] }}"
+
+- name: Copy secret, auth and start joystream-node service for validators
+  hosts: validators
+  gather_facts: no
+
+  roles:
+    - validators
+
+- name: Configure RPC service and start it
+  hosts: rpc
+  gather_facts: no
+
+  roles:
+    - rpc

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