瀏覽代碼

Merge pull request #2744 from Joystream/olympia_dev

Merging olympia_dev branch into olympia
shamil-gadelshin 3 年之前
父節點
當前提交
55cf2c88df
共有 100 個文件被更改,包括 4084 次插入2792 次删除
  1. 3 1
      .dockerignore
  2. 6 21
      .env
  3. 63 0
      .github/workflows/create-ami.yml
  4. 6 2
      .github/workflows/integration-tests.yml
  5. 6 2
      .github/workflows/joystream-cli.yml
  6. 2 2
      .github/workflows/joystream-node-benchmarks.yml
  7. 1 1
      .github/workflows/joystream-node-docker.yml
  8. 4 2
      .github/workflows/joystream-types.yml
  9. 22 0
      .github/workflows/metadata-protobuf.yml
  10. 12 4
      .github/workflows/run-integration-tests.yml
  11. 6 2
      .github/workflows/storage-node.yml
  12. 4 0
      .gitignore
  13. 211 187
      Cargo.lock
  14. 3 4
      Cargo.toml
  15. 8 8
      README.md
  16. 10 10
      analyses/fee-analysis/main_notebook.ipynb
  17. 19 4
      apps.Dockerfile
  18. 35 0
      build-docker-images.sh
  19. 10 0
      build-npm-packages.sh
  20. 0 4
      build-packages.sh
  21. 0 55
      build.sh
  22. 3 0
      cli/.eslintignore
  23. 1 0
      cli/.gitignore
  24. 1 0
      cli/.prettierignore
  25. 296 365
      cli/README.md
  26. 32 0
      cli/codegen.yml
  27. 54 0
      cli/content-test.sh
  28. 3 0
      cli/examples/content/CreateCategory.json
  29. 10 0
      cli/examples/content/CreateChannel.json
  30. 20 0
      cli/examples/content/CreateVideo.json
  31. 3 0
      cli/examples/content/UpdateCategory.json
  32. 5 0
      cli/examples/content/UpdateChannel.json
  33. 8 0
      cli/examples/content/UpdateVideo.json
  34. 二進制
      cli/examples/content/avatar-photo-1.png
  35. 二進制
      cli/examples/content/avatar-photo-2.png
  36. 二進制
      cli/examples/content/cover-photo-1.png
  37. 二進制
      cli/examples/content/cover-photo-2.png
  38. 二進制
      cli/examples/content/video.mp4
  39. 25 15
      cli/package.json
  40. 300 302
      cli/src/Api.ts
  41. 636 0
      cli/src/QueryNodeApiSchema.generated.ts
  42. 132 117
      cli/src/Types.ts
  43. 322 127
      cli/src/base/AccountsCommandBase.ts
  44. 153 48
      cli/src/base/ApiCommandBase.ts
  45. 105 360
      cli/src/base/ContentDirectoryCommandBase.ts
  46. 14 1
      cli/src/base/DefaultCommandBase.ts
  47. 0 70
      cli/src/base/MediaCommandBase.ts
  48. 4 4
      cli/src/base/StateAwareCommandBase.ts
  49. 288 0
      cli/src/base/UploadCommandBase.ts
  50. 26 68
      cli/src/base/WorkingGroupsCommandBase.ts
  51. 0 48
      cli/src/commands/account/choose.ts
  52. 15 38
      cli/src/commands/account/create.ts
  53. 0 40
      cli/src/commands/account/current.ts
  54. 29 28
      cli/src/commands/account/export.ts
  55. 3 9
      cli/src/commands/account/forget.ts
  56. 51 32
      cli/src/commands/account/import.ts
  57. 56 0
      cli/src/commands/account/info.ts
  58. 22 0
      cli/src/commands/account/list.ts
  59. 33 50
      cli/src/commands/account/transferTokens.ts
  60. 11 0
      cli/src/commands/api/getQueryNodeEndpoint.ts
  61. 15 16
      cli/src/commands/api/inspect.ts
  62. 39 0
      cli/src/commands/api/setQueryNodeEndpoint.ts
  63. 3 3
      cli/src/commands/api/setUri.ts
  64. 0 79
      cli/src/commands/content-directory/addClassSchema.ts
  65. 0 44
      cli/src/commands/content-directory/addMaintainerToClass.ts
  66. 0 55
      cli/src/commands/content-directory/class.ts
  67. 0 24
      cli/src/commands/content-directory/classes.ts
  68. 0 50
      cli/src/commands/content-directory/createClass.ts
  69. 0 18
      cli/src/commands/content-directory/createCuratorGroup.ts
  70. 0 58
      cli/src/commands/content-directory/createEntity.ts
  71. 0 45
      cli/src/commands/content-directory/entities.ts
  72. 0 44
      cli/src/commands/content-directory/entity.ts
  73. 0 57
      cli/src/commands/content-directory/initialize.ts
  74. 0 35
      cli/src/commands/content-directory/removeCuratorGroup.ts
  75. 0 45
      cli/src/commands/content-directory/removeEntity.ts
  76. 0 44
      cli/src/commands/content-directory/removeMaintainerFromClass.ts
  77. 0 55
      cli/src/commands/content-directory/updateClassPermissions.ts
  78. 0 61
      cli/src/commands/content-directory/updateEntityPropertyValues.ts
  79. 12 5
      cli/src/commands/content/addCuratorToGroup.ts
  80. 44 0
      cli/src/commands/content/channel.ts
  81. 25 0
      cli/src/commands/content/channels.ts
  82. 66 0
      cli/src/commands/content/createChannel.ts
  83. 53 0
      cli/src/commands/content/createChannelCategory.ts
  84. 20 0
      cli/src/commands/content/createCuratorGroup.ts
  85. 94 0
      cli/src/commands/content/createVideo.ts
  86. 53 0
      cli/src/commands/content/createVideoCategory.ts
  87. 5 8
      cli/src/commands/content/curatorGroup.ts
  88. 1 2
      cli/src/commands/content/curatorGroups.ts
  89. 35 0
      cli/src/commands/content/deleteChannelCategory.ts
  90. 35 0
      cli/src/commands/content/deleteVideoCategory.ts
  91. 15 8
      cli/src/commands/content/removeCuratorFromGroup.ts
  92. 32 0
      cli/src/commands/content/reuploadAssets.ts
  93. 8 5
      cli/src/commands/content/setCuratorGroupStatus.ts
  94. 24 0
      cli/src/commands/content/setFeaturedVideos.ts
  95. 82 0
      cli/src/commands/content/updateChannel.ts
  96. 53 0
      cli/src/commands/content/updateChannelCategory.ts
  97. 77 0
      cli/src/commands/content/updateChannelCensorshipStatus.ts
  98. 69 0
      cli/src/commands/content/updateVideo.ts
  99. 54 0
      cli/src/commands/content/updateVideoCategory.ts
  100. 78 0
      cli/src/commands/content/updateVideoCensorshipStatus.ts

+ 3 - 1
.dockerignore

@@ -2,6 +2,8 @@ target/
 **node_modules*
 .tmp/
 .vscode/
-query-node/generated
 query-node/**/dist
 query-node/lib
+cli/
+tests/
+!tests/integration-tests/proposal-parameters.json

+ 6 - 21
.env

@@ -6,7 +6,6 @@ PROJECT_NAME=query_node
 # overriden in local env files
 # DB config
 INDEXER_DB_NAME=query_node_indexer
-PROCESSOR_DB_NAME=query_node_processor
 DB_NAME=query_node_processor
 DB_USER=postgres
 DB_PASS=postgres
@@ -22,30 +21,10 @@ TYPEORM_LOGGING=error
 #    Indexer options      #
 ###########################
 
-# Substrate endpoint to source events from
-WS_PROVIDER_ENDPOINT_URI=ws://localhost:9944/
-
 # Block height to start indexing from.
 # Note, that if there are already some indexed events, this setting is ignored
 BLOCK_HEIGHT=0
 
-# Redis cache server
-REDIS_URI=redis://localhost:6379/0
-
-###########################
-#    Processor options    #
-###########################
-
-# Where the mapping scripts are located
-TYPES_JSON=./types/augment/all/defs.json
-
-# Indexer GraphQL API endpoint to fetch indexed events
-INDEXER_ENDPOINT_URL=http://localhost:4000/graphql
-
-# Block height from which the processor starts. Note that if
-# there are already processed events in the database, this setting is ignored
-BLOCK_HEIGHT=0
-
 ###############################
 #    Processor GraphQL API    #
 ###############################
@@ -54,3 +33,9 @@ GRAPHQL_SERVER_PORT=4002
 GRAPHQL_SERVER_HOST=localhost
 WARTHOG_APP_PORT=4002
 WARTHOG_APP_HOST=localhost
+
+# Default configuration is to use the docker container
+WS_PROVIDER_ENDPOINT_URI=ws://joystream-node:9944/
+
+# If running joystream-node on host machine you can use following address to reach it instead
+# WS_PROVIDER_ENDPOINT_URI=ws://host.docker.internal:9944/

+ 63 - 0
.github/workflows/create-ami.yml

@@ -0,0 +1,63 @@
+name: Build code and create AMI
+
+on:
+  push:
+    branches:
+      - master
+      - olympia
+      - test_branch
+
+jobs:
+  build:
+    name: Build the code and run setup
+    runs-on: ubuntu-latest
+    env:
+      STACK_NAME: joystream-github-action-${{ github.run_number }}
+      KEY_NAME: joystream-github-action-key
+    steps:
+    - name: Extract branch name
+      shell: bash
+      run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
+      id: extract_branch
+
+    - name: Set AMI Name environment variable
+      shell: bash
+      run: echo "ami_name=joystream-${{ steps.extract_branch.outputs.branch }}-${{ github.run_number }}" >> $GITHUB_ENV
+      id: ami_name
+
+    - name: Checkout
+      uses: actions/checkout@v2
+
+    - name: Configure AWS credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+        aws-region: us-east-1
+
+    - name: Deploy to AWS CloudFormation
+      uses: aws-actions/aws-cloudformation-github-deploy@v1
+      id: deploy_stack
+      with:
+        name: ${{ env.STACK_NAME }}
+        template: devops/infrastructure/single-instance.yml
+        no-fail-on-empty-changeset: "1"
+        parameter-overrides: "KeyName=${{ env.KEY_NAME }}"
+
+    - name: Install Ansible dependencies
+      run: pipx inject ansible-base boto3 botocore
+
+    - name: Run playbook
+      uses: dawidd6/action-ansible-playbook@v2
+      with:
+        playbook: github-action-playbook.yml
+        directory: devops/infrastructure
+        requirements: requirements.yml
+        key: ${{ secrets.SSH_PRIVATE_KEY }}
+        inventory: |
+          [all]
+          ${{ 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 }}
+                        stack_name=${{ env.STACK_NAME }} ami_name=${{ env.ami_name }}"

+ 6 - 2
.github/workflows/integration-tests.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -17,6 +17,8 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile
+        yarn workspace @joystream/types build
+        yarn workspace @joystream/metadata-protobuf build
         yarn workspace integration-tests checks --quiet
 
   network_build_osx:
@@ -24,7 +26,7 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -34,4 +36,6 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
+        yarn workspace @joystream/types build
+        yarn workspace @joystream/metadata-protobuf build
         yarn workspace integration-tests checks --quiet

+ 6 - 2
.github/workflows/joystream-cli.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -17,6 +17,8 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile
+        yarn workspace @joystream/types build
+        yarn workspace @joystream/metadata-protobuf build
         yarn workspace @joystream/cli checks --quiet
     - name: yarn pack test
       run: |
@@ -29,7 +31,7 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -39,6 +41,8 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
+        yarn workspace @joystream/types build
+        yarn workspace @joystream/metadata-protobuf build
         yarn workspace @joystream/cli checks --quiet
     - name: yarn pack test
       run: |

+ 2 - 2
.github/workflows/joystream-node-benchmarks.yml

@@ -10,7 +10,7 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
       - uses: technote-space/get-diff-action@v3
         with:
           PREFIX_FILTER: |
@@ -35,7 +35,7 @@ jobs:
       - name: Build
         run: |
           pushd node
-          WASM_BUILD_TOOLCHAIN=nightly-2020-10-06 cargo build --release --features runtime-benchmarks
+          WASM_BUILD_TOOLCHAIN=nightly-2021-03-24 cargo build --release --features runtime-benchmarks
           popd
         if: env.GIT_DIFF
 

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

@@ -10,7 +10,7 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
 
       - id: compute_shasum
         name: Compute runtime code shasum

+ 4 - 2
.github/workflows/joystream-types.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -17,6 +17,7 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile
+        yarn workspace @joystream/types build
         yarn workspace @joystream/types checks --quiet
     - name: npm pack test
       run: |
@@ -30,7 +31,7 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -40,6 +41,7 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
+        yarn workspace @joystream/types build
         yarn workspace @joystream/types checks --quiet
     - name: npm pack test
       run: |

+ 22 - 0
.github/workflows/metadata-protobuf.yml

@@ -0,0 +1,22 @@
+name: metadata-protobuf
+on: [pull_request, push]
+
+jobs:
+  schemas_checks:
+    name: Checks
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: [14.x]
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: test protobuf
+      run: |
+        yarn install --frozen-lockfile
+        yarn workspace @joystream/metadata-protobuf build
+        yarn workspace @joystream/metadata-protobuf checks --quiet
+        yarn workspace @joystream/metadata-protobuf test

+ 12 - 4
.github/workflows/run-integration-tests.yml

@@ -25,10 +25,12 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
 
       - id: compute_shasum
         name: Compute runtime code shasum
+        env:
+          TEST_NODE: true
         run: |
           export RUNTIME_CODE_SHASUM=`scripts/runtime-code-shasum.sh`
           echo "::set-output name=shasum::${RUNTIME_CODE_SHASUM}"
@@ -66,7 +68,10 @@ jobs:
       - name: Build new joystream/node image
         run: |
           if ! [ -f joystream-node-docker-image.tar.gz ]; then
-            docker build . --file joystream-node.Dockerfile --tag joystream/node
+            docker build .\
+              --file joystream-node.Dockerfile\
+              --tag joystream/node\
+              --build-arg TEST_NODE=true
             docker save --output joystream-node-docker-image.tar joystream/node
             gzip joystream-node-docker-image.tar
             cp joystream-node-docker-image.tar.gz ~/docker-images/
@@ -86,7 +91,7 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
       - name: Get artifacts
         uses: actions/download-artifact@v2
         with:
@@ -96,7 +101,10 @@ jobs:
           docker load --input joystream-node-docker-image.tar.gz
           docker images
       - name: Install packages and dependencies
-        run: yarn install --frozen-lockfile
+        run: |
+          yarn install --frozen-lockfile
+          yarn workspace @joystream/types build
+          yarn workspace @joystream/metadata-protobuf build
       - name: Ensure tests are runnable
         run: yarn workspace integration-tests build
       # Bring up hydra query-node development instance, then run integration tests

+ 6 - 2
.github/workflows/storage-node.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -17,14 +17,16 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile
+        yarn workspace @joystream/types build
         yarn workspace storage-node checks --quiet
+        yarn workspace storage-node build
 
   storage_node_build_osx:
     name: MacOS Checks
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -34,4 +36,6 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
+        yarn workspace @joystream/types build
         yarn workspace storage-node checks --quiet
+        yarn workspace storage-node build

+ 4 - 0
.gitignore

@@ -35,3 +35,7 @@ yarn*
 
 # eslint cache
 **/.eslintcache
+
+# test data for local node tests
+test-data/
+tmp.*

文件差異過大導致無法顯示
+ 211 - 187
Cargo.lock


+ 3 - 4
Cargo.toml

@@ -10,15 +10,14 @@ members = [
 	"runtime-modules/membership",
 	"runtime-modules/memo",
 	"runtime-modules/referendum",
-	"runtime-modules/service-discovery",
 	"runtime-modules/storage",
 	"runtime-modules/working-group",
-	"runtime-modules/content-directory",
+	"runtime-modules/content",
 	"runtime-modules/constitution",
 	"runtime-modules/staking-handler",
 	"runtime-modules/bounty",
-    "runtime-modules/blog",
-    "runtime-modules/utility",
+	"runtime-modules/blog",
+	"runtime-modules/utility",
 	"node",
 	"utils/chain-spec-builder/",
 ]

+ 8 - 8
README.md

@@ -12,7 +12,7 @@ functionality to support the [various roles](https://www.joystream.org/roles) th
 The following tools are required for building, testing and contributing to this repo:
 
 - [Rust](https://www.rust-lang.org/tools/install) toolchain - _required_
-- [nodejs](https://nodejs.org/) v12.x - _required_
+- [nodejs](https://nodejs.org/) v14.x - _required_
 - [yarn classic](https://classic.yarnpkg.com/en/docs/install) package manager v1.22.x- _required_
 - [docker](https://www.docker.com/get-started) and docker-compose - _optional_
 - [ansible](https://www.ansible.com/) - _optional_
@@ -25,12 +25,11 @@ After cloning the repo run the following initialization scripts:
 # Install rust toolchain
 ./setup.sh
 
-# Install npm package dependencies
-# Also good habit to run this when switching between branches
-yarn install
+# Install npm package dependencies, build packages and docker images
+yarn build
 
-# run some tests
-yarn cargo-checks
+# start a local development network
+yarn start
 ```
 
 ## Software
@@ -75,6 +74,7 @@ The HEAD of the master branch should always be used for the correct version of t
 ```sh
 git checkout master
 yarn install
+yarn build:packages
 yarn workspace pioneer start
 ```
 
@@ -89,8 +89,8 @@ You can also run your our own joystream-node:
 
 ```sh
 git checkout master
-WASM_BUILD_TOOLCHAIN=nightly-2020-10-06 cargo build --release
-./target/release/joystream-node -- --pruning archive --chain testnets/joy-testnet-4.json
+WASM_BUILD_TOOLCHAIN=nightly-2021-03-24 cargo build --release
+./target/release/joystream-node -- --pruning archive --chain testnets/joy-testnet-5.json
 ```
 
 Wait for the node to sync to the latest block, then change pioneer settings "remote node" option to "Local Node", or follow the link below:

+ 10 - 10
analyses/fee-analysis/main_notebook.ipynb

@@ -1087,7 +1087,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",
@@ -1473,7 +1473,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",
@@ -1828,7 +1828,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",
@@ -2399,7 +2399,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",
@@ -2698,7 +2698,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",
@@ -2952,7 +2952,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",
@@ -3415,7 +3415,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",
@@ -3706,7 +3706,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",
@@ -3897,7 +3897,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",
@@ -4176,7 +4176,7 @@
         "delete_category_lead",
         "delete_category_moderator",
         "create_thread",
-        "edit_thread_title",
+        "edit_thread_metadata",
         "update_thread_archival_status_lead",
         "update_thread_archival_status_moderator",
         "delete_thread_lead",

+ 19 - 4
apps.Dockerfile

@@ -1,4 +1,4 @@
-FROM node:12 as builder
+FROM node:14 as builder
 
 WORKDIR /joystream
 COPY . /joystream
@@ -12,8 +12,23 @@ ARG TYPEGEN_WS_URI
 # to ensure dev dependencies are installed.
 RUN yarn --frozen-lockfile
 
-# @joystream/types are built during postinstall
-RUN yarn workspace storage-node build
-RUN yarn workspace query-node-root build
+RUN yarn build:packages
+
+# Second stage to reduce image size, enable it when
+# all packages have correctly identified what is a devDependency and what is not.
+# It will reduce the image size by about 500MB (down from 2.2GB to 1.7GB)
+
+# # Remove files that are not needed after build.
+# # We will re-fetch only dependencies needed for running the apps.
+# RUN rm -fr node_modules/
+# RUN rm -fr .git/
+
+# FROM node:12
+# WORKDIR /joystream
+# COPY --from=builder /joystream/ /joystream/
+
+# # Skip installing devDependencies, since we have already built the packages.
+# ENV NODE_ENV=production
+# RUN yarn install --forzen-lockfile --production
 
 ENTRYPOINT [ "yarn" ]

+ 35 - 0
build-docker-images.sh

@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+
+set -e
+
+if ! command -v docker-compose &> /dev/null
+then
+  echo "docker-compose not found. Skipping docker image builds."
+  exit 0
+fi
+
+# Build or fetch cached joystream/node docker image
+if [[ "$SKIP_JOYSTREAM_NODE" = 1 || "$SKIP_JOYSTREAM_NODE" = "true" ]]; then
+  echo "Skipping build of joystream/node docker image."
+else
+  # Fetch a cached joystream/node image if one is found matching code shasum instead of building
+  CODE_HASH=`scripts/runtime-code-shasum.sh`
+  IMAGE=joystream/node:${CODE_HASH}
+  echo "Trying to fetch cached ${IMAGE} image"
+  docker pull ${IMAGE} || :
+
+  if ! docker inspect ${IMAGE} > /dev/null;
+  then
+    echo "Fetch failed, building image locally"
+    docker-compose build joystream-node
+  else
+    echo "Tagging cached image as 'latest'"
+    docker image tag ${IMAGE} joystream/node:latest
+  fi
+fi
+
+docker-compose up -d joystream-node
+
+# Build joystream/apps docker image
+echo "Building 'joystream/apps' docker image..."
+docker-compose build colossus

+ 10 - 0
build-npm-packages.sh

@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+set -e
+
+yarn
+yarn workspace @joystream/types build
+yarn workspace @joystream/metadata-protobuf build
+yarn workspace query-node-root build
+yarn workspace @joystream/cli build
+yarn workspace storage-node build

+ 0 - 4
build-packages.sh

@@ -1,4 +0,0 @@
-yarn workspace @joystream/types build
-yarn workspace @joystream/cd-schemas generate:all
-yarn workspace @joystream/cd-schemas build
-yarn workspace @joystream/metadata-protobuf build

+ 0 - 55
build.sh

@@ -1,55 +0,0 @@
-#!/usr/bin/env bash
-
-set -e
-
-yarn
-yarn workspace @joystream/types build
-yarn workspace @joystream/cd-schemas generate:all
-yarn workspace @joystream/cd-schemas build
-yarn workspace @joystream/cli build
-yarn workspace query-node-root build
-yarn workspace storage-node build
-# Not strictly needed during development, we run "yarn workspace pioneer start" to start
-# a dev instance, but will show highlight build issues
-yarn workspace pioneer build
-
-if ! command -v docker-compose &> /dev/null
-then
-  echo "docker-compose not found, skipping docker build!"
-else
-  # Build joystream/apps docker image
-  docker-compose build pioneer
-
-  # Optionally build joystream/node docker image
-  # TODO: Try to fetch a cached joystream/node image
-  # if one is found matching code shasum instead of building
-  while true
-  do
-    read -p "Rebuild joystream/node docker image? (y/N): " answer2
-
-    case $answer2 in
-    [yY]* ) docker-compose build joystream-node
-            break;;
-
-    [nN]* ) break;;
-
-    * )     break;;
-    esac
-  done
-fi
-
-# Build cargo crates: native binaries joystream/node, wasm runtime, and chainspec builder.
-while true
-do
-  read -p "Compile joystream node native binary? (y/N): " answer1
-
-  case $answer1 in
-   [yY]* ) yarn cargo-checks
-           yarn cargo-build
-           break;;
-
-   [nN]* ) break;;
-
-   * )     break;;
-  esac
-done

+ 3 - 0
cli/.eslintignore

@@ -1,2 +1,5 @@
 /lib
+# Temporarly ignore before merging `Sumer`
+/src/commands/media
+/src/commands/content-directory
 .eslintrc.js

+ 1 - 0
cli/.gitignore

@@ -6,3 +6,4 @@
 /tmp
 /yarn.lock
 node_modules
+/examples/content/*__rejectedContent.json

+ 1 - 0
cli/.prettierignore

@@ -1,2 +1,3 @@
 /lib/
 .nyc_output
+/examples

文件差異過大導致無法顯示
+ 296 - 365
cli/README.md


+ 32 - 0
cli/codegen.yml

@@ -0,0 +1,32 @@
+overwrite: true
+
+schema: '../query-node/generated/graphql-server/generated/schema.graphql'
+
+documents:
+  - './src/graphql/queries/*.graphql'
+
+config:
+  scalars:
+    Date: Date
+  preResolveTypes: true # avoid using Pick
+  skipTypename: true # skip __typename field in typings unless it's part of the query
+
+generates:
+  src/graphql/generated/schema.ts:
+    hooks:
+      afterOneFileWrite:
+        - prettier --write
+        - eslint --fix
+    plugins:
+      - typescript
+  src/graphql/generated/queries.ts:
+    preset: import-types
+    presetConfig:
+      typesPath: ./schema
+    hooks:
+      afterOneFileWrite:
+        - prettier --write
+        - eslint --fix
+    plugins:
+      - typescript-operations
+      - typescript-document-nodes

+ 54 - 0
cli/content-test.sh

@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
+cd $SCRIPT_PATH
+
+echo "{}" > ~/tmp/empty.json
+
+export AUTO_CONFIRM=true
+
+# Init content lead
+GROUP=contentDirectoryWorkingGroup yarn workspace api-scripts initialize-lead
+# 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
+# Group 1 - a valid group
+yarn joystream-cli content:createCuratorGroup
+yarn joystream-cli content:setCuratorGroupStatus 1 1
+yarn joystream-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
+# 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
+# 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
+# Set featured videos
+yarn joystream-cli content:setFeaturedVideos 1,2
+yarn joystream-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"
+# 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

+ 3 - 0
cli/examples/content/CreateCategory.json

@@ -0,0 +1,3 @@
+{
+  "name": "Nature"
+}

+ 10 - 0
cli/examples/content/CreateChannel.json

@@ -0,0 +1,10 @@
+{
+  "title": "Example Joystream Channel",
+  "description": "This is an awesome example channel!",
+  "isPublic": true,
+  "language": "en",
+  "category": 1,
+  "avatarPhotoPath": "./avatar-photo-1.png",
+  "coverPhotoPath": "./cover-photo-1.png",
+  "rewardAccount": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
+}

+ 20 - 0
cli/examples/content/CreateVideo.json

@@ -0,0 +1,20 @@
+{
+  "title": "Example Joystream Video",
+  "description": "This is an awesome example video!",
+  "videoPath": "./video.mp4",
+  "thumbnailPhotoPath": "./avatar-photo-1.png",
+  "language": "en",
+  "hasMarketing": false,
+  "isPublic": true,
+  "isExplicit": false,
+  "personsList": [],
+  "category": 1,
+  "license": {
+    "code": 1001,
+    "attribution": "by Joystream Contributors"
+  },
+  "publishedBeforeJoystream": {
+    "isPublished": true,
+    "date": "2020-01-01"
+  }
+}

+ 3 - 0
cli/examples/content/UpdateCategory.json

@@ -0,0 +1,3 @@
+{
+  "name": "Science"
+}

+ 5 - 0
cli/examples/content/UpdateChannel.json

@@ -0,0 +1,5 @@
+{
+  "title": "Example Joystream Channel [UPDATED!]",
+  "avatarPhotoPath": "./avatar-photo-2.png",
+  "rewardAccount": null
+}

+ 8 - 0
cli/examples/content/UpdateVideo.json

@@ -0,0 +1,8 @@
+{
+  "title": "Example Joystream Video [UPDATED!]",
+  "thumbnailPhotoPath": "./avatar-photo-2.png",
+  "publishedBeforeJoystream": {
+    "isPublished": false
+  },
+  "license": {}
+}

二進制
cli/examples/content/avatar-photo-1.png


二進制
cli/examples/content/avatar-photo-2.png


二進制
cli/examples/content/cover-photo-1.png


二進制
cli/examples/content/cover-photo-2.png


二進制
cli/examples/content/video.mp4


+ 25 - 15
cli/package.json

@@ -1,29 +1,34 @@
 {
   "name": "@joystream/cli",
   "description": "Command Line Interface for Joystream community and governance activities",
-  "version": "0.3.1",
+  "version": "0.6.0",
   "author": "Leszek Wiesner",
   "bin": {
     "joystream-cli": "./bin/run"
   },
   "bugs": "https://github.com/Joystream/joystream/issues",
   "dependencies": {
+    "@apollo/client": "^3.3.13",
+    "cross-fetch": "^3.0.6",
     "@apidevtools/json-schema-ref-parser": "^9.0.6",
     "@ffprobe-installer/ffprobe": "^1.1.0",
-    "@joystream/types": "^0.15.0",
-    "@joystream/cd-schemas": "^0.1.0",
+    "@joystream/types": "^0.17.0",
     "@oclif/command": "^1.5.19",
     "@oclif/config": "^1.14.0",
     "@oclif/plugin-autocomplete": "^0.2.0",
-    "@oclif/plugin-help": "^2.2.3",
+    "@oclif/plugin-help": "^3.2.2",
     "@oclif/plugin-not-found": "^1.2.4",
     "@oclif/plugin-warn-if-update-available": "^1.7.0",
-    "@polkadot/api": "^2.4.1",
+    "@polkadot/api": "5.3.2",
+    "@types/cli-progress": "^3.9.1",
     "@types/fluent-ffmpeg": "^2.1.16",
     "@types/inquirer": "^6.5.0",
+    "@types/mime-types": "^2.1.0",
     "@types/proper-lockfile": "^4.1.1",
     "@types/slug": "^0.9.1",
     "ajv": "^6.11.0",
+    "axios": "^0.21.1",
+    "cli-progress": "^3.9.0",
     "cli-ux": "^5.4.5",
     "fluent-ffmpeg": "^2.1.2",
     "inquirer": "^7.1.0",
@@ -35,6 +40,7 @@
     "it-first": "^1.0.4",
     "it-last": "^1.0.4",
     "it-to-buffer": "^1.0.4",
+    "mime-types": "^2.1.30",
     "moment": "^2.24.0",
     "proper-lockfile": "^4.1.1",
     "slug": "^2.1.1",
@@ -43,7 +49,7 @@
   "devDependencies": {
     "@oclif/dev-cli": "^1.22.2",
     "@oclif/test": "^1.2.5",
-    "@polkadot/ts": "^0.1.56",
+    "@polkadot/ts": "^0.4.4",
     "@types/chai": "^4.2.11",
     "@types/mocha": "^5.2.7",
     "@types/node": "^10.17.18",
@@ -56,10 +62,15 @@
     "nyc": "^14.1.1",
     "ts-node": "^8.8.2",
     "typescript": "^3.8.3",
-    "json-schema-to-typescript": "^9.1.1"
+    "json-schema-to-typescript": "^9.1.1",
+    "@graphql-codegen/cli": "^1.21.4",
+    "@graphql-codegen/typescript": "^1.22.0",
+    "@graphql-codegen/import-types-preset": "^1.18.1",
+    "@graphql-codegen/typescript-operations": "^1.17.16",
+    "@graphql-codegen/typescript-document-nodes": "^1.17.11"
   },
   "engines": {
-    "node": ">=12.18.0",
+    "node": ">=14.0.0",
     "yarn": "^1.22.0"
   },
   "publishConfig": {
@@ -101,11 +112,8 @@
       "working-groups": {
         "description": "Working group lead and worker actions"
       },
-      "content-directory": {
-        "description": "Interactions with content directory module - managing classes, schemas, entities and permissions"
-      },
-      "media": {
-        "description": "Higher-level content directory interactions, ie. publishing and curating content"
+      "content": {
+        "description": "Interactions with content directory module - managing vidoes, channels, assets, categories and curator groups"
       }
     }
   },
@@ -124,11 +132,13 @@
     "lint": "eslint ./src --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",
     "format": "prettier ./ --write",
-    "generate:schema-typings": "rm -rf ./src/json-schemas/typings && json2ts -i ./src/json-schemas/ -o ./src/json-schemas/typings/"
+    "generate:schema-typings": "rm -rf ./src/json-schemas/typings && json2ts -i ./src/json-schemas/ -o ./src/json-schemas/typings/ && yarn format",
+    "generate:graphql-typings": "graphql-codegen",
+    "generate:all": "yarn generate:schema-typings && yarn generate:graphql-typings"
   },
   "types": "lib/index.d.ts",
   "volta": {
-    "node": "12.18.2",
+    "node": "14.16.1",
     "yarn": "1.22.4"
   }
 }

+ 300 - 302
cli/src/Api.ts

@@ -1,92 +1,113 @@
 import BN from 'bn.js'
 import { types } from '@joystream/types/'
 import { ApiPromise, WsProvider } from '@polkadot/api'
-import { QueryableStorageMultiArg, SubmittableExtrinsic, QueryableStorageEntry } from '@polkadot/api/types'
+import { SubmittableExtrinsic, AugmentedQuery } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
-import { Balance, Moment, BlockNumber } from '@polkadot/types/interfaces'
+import { Balance } from '@polkadot/types/interfaces'
 import { KeyringPair } from '@polkadot/keyring/types'
-import { Codec, CodecArg } from '@polkadot/types/types'
-import { Option, Vec, UInt } from '@polkadot/types'
+import { Codec, Observable } from '@polkadot/types/types'
+import { UInt, Bytes } from '@polkadot/types'
 import {
   AccountSummary,
-  CouncilInfoObj,
-  CouncilInfoTuple,
-  createCouncilInfoObj,
   WorkingGroups,
   Reward,
   GroupMember,
-  OpeningStatus,
-  GroupOpeningStage,
-  GroupOpening,
-  GroupApplication,
-  openingPolicyUnstakingPeriodsKeys,
-  UnstakingPeriods,
-  StakingPolicyUnstakingPeriodKey,
+  ApplicationDetails,
+  OpeningDetails,
+  UnaugmentedApiPromise,
+  MemberDetails,
 } from './Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { CLIError } from '@oclif/errors'
-import ExitCodes from './ExitCodes'
 import {
   Worker,
   WorkerId,
-  RoleStakeProfile,
-  Opening as WGOpening,
-  Application as WGApplication,
+  OpeningId,
+  Application,
+  ApplicationId,
   StorageProviderId,
+  Opening,
 } from '@joystream/types/working-group'
+import { Membership, StakingAccountMemberBinding } from '@joystream/types/members'
+import { MemberId, ChannelId, AccountId } from '@joystream/types/common'
 import {
-  Opening,
-  Application,
-  OpeningStage,
-  ApplicationStageKeys,
-  ApplicationId,
-  OpeningId,
-  StakingPolicy,
-} from '@joystream/types/hiring'
-import { MemberId, Membership } from '@joystream/types/members'
-import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recurring-rewards'
-import { Stake, StakeId } from '@joystream/types/stake'
-
-import { InputValidationLengthConstraint } from '@joystream/types/common'
-import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity, EntityId } from '@joystream/types/content-directory'
-import { ContentId, DataObject } from '@joystream/types/media'
-import { ServiceProviderRecord, Url } from '@joystream/types/discovery'
-import _ from 'lodash'
+  Channel,
+  Video,
+  ChannelCategoryId,
+  VideoId,
+  CuratorGroupId,
+  CuratorGroup,
+  ChannelCategory,
+  VideoCategoryId,
+  VideoCategory,
+} from '@joystream/types/content'
+import { ContentId, DataObject } from '@joystream/types/storage'
+import { ApolloClient, InMemoryCache, HttpLink, NormalizedCacheObject, DocumentNode } from '@apollo/client/core'
+import fetch from 'cross-fetch'
+import { Maybe } from './graphql/generated/schema'
+import {
+  GetMemberById,
+  GetMemberByIdQuery,
+  GetMemberByIdQueryVariables,
+  MembershipFieldsFragment,
+} from './graphql/generated/queries'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
-const DEFAULT_DECIMALS = new BN(12)
 
 // Mapping of working group to api module
-export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
+export const apiModuleByGroup = {
   [WorkingGroups.StorageProviders]: 'storageWorkingGroup',
   [WorkingGroups.Curators]: 'contentDirectoryWorkingGroup',
+  [WorkingGroups.Forum]: 'forumWorkingGroup',
+  [WorkingGroups.Membership]: 'membershipWorkingGroup',
+  [WorkingGroups.Operations]: 'operationsWorkingGroup',
+  [WorkingGroups.Gateway]: 'gatewayWorkingGroup',
+} as const
+
+export const lockIdByWorkingGroup: { [K in WorkingGroups]: string } = {
+  [WorkingGroups.StorageProviders]: '0x0606060606060606',
+  [WorkingGroups.Curators]: '0x0707070707070707',
+  [WorkingGroups.Forum]: '0x0808080808080808',
+  [WorkingGroups.Membership]: '0x0909090909090909',
+  [WorkingGroups.Operations]: '0x0d0d0d0d0d0d0d0d',
+  [WorkingGroups.Gateway]: '0x0e0e0e0e0e0e0e0e',
 }
 
 // Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
 export default class Api {
   private _api: ApiPromise
-  private _cdClassesCache: [ClassId, Class][] | null = null
-
-  private constructor(originalApi: ApiPromise) {
+  private _queryNode?: ApolloClient<NormalizedCacheObject>
+  public isDevelopment = false
+
+  private constructor(
+    originalApi: ApiPromise,
+    isDevelopment: boolean,
+    queryNodeClient?: ApolloClient<NormalizedCacheObject>
+  ) {
+    this.isDevelopment = isDevelopment
     this._api = originalApi
+    this._queryNode = queryNodeClient
   }
 
   public getOriginalApi(): ApiPromise {
     return this._api
   }
 
-  private static async initApi(
-    apiUri: string = DEFAULT_API_URI,
-    metadataCache: Record<string, any>
-  ): Promise<ApiPromise> {
+  // Get api for use-cases where no type augmentations are desirable
+  public getUnaugmentedApi(): UnaugmentedApiPromise {
+    return (this._api as unknown) as UnaugmentedApiPromise
+  }
+
+  private static async initApi(apiUri: string = DEFAULT_API_URI, metadataCache: Record<string, any>) {
     const wsProvider: WsProvider = new WsProvider(apiUri)
-    const api = await ApiPromise.create({ provider: wsProvider, types, metadata: metadataCache })
+    const api = new ApiPromise({ provider: wsProvider, types, metadata: metadataCache })
+    await api.isReadyOrError
 
     // Initializing some api params based on pioneer/packages/react-api/Api.tsx
-    const [properties] = await Promise.all([api.rpc.system.properties()])
+    const [properties, chainType] = await Promise.all([api.rpc.system.properties(), api.rpc.system.chainType()])
 
-    const tokenSymbol = properties.tokenSymbol.unwrapOr('DEV').toString()
-    const tokenDecimals = properties.tokenDecimals.unwrapOr(DEFAULT_DECIMALS).toNumber()
+    const tokenSymbol = properties.tokenSymbol.unwrap()[0].toString()
+    const tokenDecimals = properties.tokenDecimals.unwrap()[0].toNumber()
 
     // formatBlanace config
     formatBalance.setDefaults({
@@ -94,29 +115,66 @@ export default class Api {
       unit: tokenSymbol,
     })
 
-    return api
+    return { api, properties, chainType }
   }
 
-  static async create(apiUri: string = DEFAULT_API_URI, metadataCache: Record<string, any>): Promise<Api> {
-    const originalApi: ApiPromise = await Api.initApi(apiUri, metadataCache)
-    return new Api(originalApi)
+  private static async createQueryNodeClient(uri: string) {
+    return new ApolloClient({
+      link: new HttpLink({ uri, fetch }),
+      cache: new InMemoryCache(),
+      defaultOptions: { query: { fetchPolicy: 'no-cache', errorPolicy: 'all' } },
+    })
   }
 
-  private queryMultiOnce(queries: Parameters<typeof ApiPromise.prototype.queryMulti>[0]): Promise<Codec[]> {
-    return new Promise((resolve, reject) => {
-      let unsub: () => void
-      this._api
-        .queryMulti(queries, (res) => {
-          // unsub should already be set at this point
-          if (!unsub) {
-            reject(new CLIError('API queryMulti issue - unsub method not set!', { exit: ExitCodes.ApiError }))
-          }
-          unsub()
-          resolve(res)
-        })
-        .then((unsubscribe) => (unsub = unsubscribe))
-        .catch((e) => reject(e))
-    })
+  static async create(
+    apiUri = DEFAULT_API_URI,
+    metadataCache: Record<string, any>,
+    queryNodeUri?: string
+  ): Promise<Api> {
+    const { api, chainType } = await Api.initApi(apiUri, metadataCache)
+    const queryNodeClient = queryNodeUri ? await this.createQueryNodeClient(queryNodeUri) : undefined
+    return new Api(api, chainType.isDevelopment || chainType.isLocal, queryNodeClient)
+  }
+
+  // Query-node: get entity by unique input
+  protected async uniqueEntityQuery<
+    QueryT extends { [k: string]: Maybe<Record<string, unknown>> | undefined },
+    VariablesT extends Record<string, unknown>
+  >(
+    query: DocumentNode,
+    variables: VariablesT,
+    resultKey: keyof QueryT
+  ): Promise<Required<QueryT>[keyof QueryT] | null | undefined> {
+    if (!this._queryNode) {
+      return
+    }
+    return (await this._queryNode.query<QueryT, VariablesT>({ query, variables })).data[resultKey] || null
+  }
+
+  // Query-node: get entities by "non-unique" input and return first result
+  protected async firstEntityQuery<
+    QueryT extends { [k: string]: unknown[] },
+    VariablesT extends Record<string, unknown>
+  >(
+    query: DocumentNode,
+    variables: VariablesT,
+    resultKey: keyof QueryT
+  ): Promise<QueryT[keyof QueryT][number] | null | undefined> {
+    if (!this._queryNode) {
+      return
+    }
+    return (await this._queryNode.query<QueryT, VariablesT>({ query, variables })).data[resultKey][0] || null
+  }
+
+  // Query-node: get multiple entities
+  protected async multipleEntitiesQuery<
+    QueryT extends { [k: string]: unknown[] },
+    VariablesT extends Record<string, unknown>
+  >(query: DocumentNode, variables: VariablesT, resultKey: keyof QueryT): Promise<QueryT[keyof QueryT] | undefined> {
+    if (!this._queryNode) {
+      return
+    }
+    return (await this._queryNode.query<QueryT, VariablesT>({ query, variables })).data[resultKey]
   }
 
   async bestNumber(): Promise<number> {
@@ -140,27 +198,6 @@ export default class Api {
     return { balances }
   }
 
-  async getCouncilInfo(): Promise<CouncilInfoObj> {
-    const queries: { [P in keyof CouncilInfoObj]: QueryableStorageMultiArg<'promise'> } = {
-      activeCouncil: this._api.query.council.activeCouncil,
-      termEndsAt: this._api.query.council.termEndsAt,
-      autoStart: this._api.query.councilElection.autoStart,
-      newTermDuration: this._api.query.councilElection.newTermDuration,
-      candidacyLimit: this._api.query.councilElection.candidacyLimit,
-      councilSize: this._api.query.councilElection.councilSize,
-      minCouncilStake: this._api.query.councilElection.minCouncilStake,
-      minVotingStake: this._api.query.councilElection.minVotingStake,
-      announcingPeriod: this._api.query.councilElection.announcingPeriod,
-      votingPeriod: this._api.query.councilElection.votingPeriod,
-      revealingPeriod: this._api.query.councilElection.revealingPeriod,
-      round: this._api.query.councilElection.round,
-      stage: this._api.query.councilElection.stage,
-    }
-    const results: CouncilInfoTuple = (await this.queryMultiOnce(Object.values(queries))) as CouncilInfoTuple
-
-    return createCouncilInfoObj(...results)
-  }
-
   async estimateFee(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>): Promise<Balance> {
     const paymentInfo = await tx.paymentInfo(account)
     return paymentInfo.partialFee
@@ -174,12 +211,10 @@ export default class Api {
   // TODO: This is a lot of repeated logic from "/pioneer/joy-utils/transport"
   // It will be refactored to "joystream-js" soon
   async entriesByIds<IDType extends UInt, ValueType extends Codec>(
-    apiMethod: QueryableStorageEntry<'promise'>,
-    firstKey?: CodecArg // First key in case of double maps
+    apiMethod: AugmentedQuery<'promise', (key: IDType) => Observable<ValueType>>
   ): Promise<[IDType, ValueType][]> {
-    const entries: [IDType, ValueType][] = (await apiMethod.entries<ValueType>(firstKey)).map(([storageKey, value]) => [
-      // If double-map (first key is provided), we map entries by second key
-      storageKey.args[firstKey !== undefined ? 1 : 0] as IDType,
+    const entries: [IDType, ValueType][] = (await apiMethod.entries()).map(([storageKey, value]) => [
+      storageKey.args[0] as IDType,
       value,
     ])
 
@@ -193,7 +228,7 @@ export default class Api {
   }
 
   protected async blockTimestamp(height: number): Promise<Date> {
-    const blockTime = (await this._api.query.timestamp.now.at(await this.blockHash(height))) as Moment
+    const blockTime = await this._api.query.timestamp.now.at(await this.blockHash(height))
 
     return new Date(blockTime.toNumber())
   }
@@ -203,15 +238,43 @@ export default class Api {
     return this._api.query[module]
   }
 
-  protected async membershipById(memberId: MemberId): Promise<Membership | null> {
-    const profile = (await this._api.query.members.membershipById(memberId)) as Membership
+  protected async fetchMemberQueryNodeData(memberId: MemberId): Promise<MembershipFieldsFragment | null | undefined> {
+    return this.uniqueEntityQuery<GetMemberByIdQuery, GetMemberByIdQueryVariables>(
+      GetMemberById,
+      {
+        id: memberId.toString(),
+      },
+      'membershipByUniqueInput'
+    )
+  }
+
+  async memberDetails(memberId: MemberId, membership: Membership): Promise<MemberDetails> {
+    const memberData = await this.fetchMemberQueryNodeData(memberId)
+
+    return {
+      id: memberId,
+      name: memberData?.metadata.name,
+      handle: memberData?.handle,
+      membership,
+    }
+  }
+
+  protected async membershipById(memberId: MemberId): Promise<MemberDetails | null> {
+    const membership = await this._api.query.members.membershipById(memberId)
+    return membership.isEmpty ? null : await this.memberDetails(memberId, membership)
+  }
 
-    // Can't just use profile.isEmpty because profile.suspended is Bool (which isEmpty method always returns false)
-    return profile.handle.isEmpty ? null : profile
+  protected async expectedMembershipById(memberId: MemberId): Promise<MemberDetails> {
+    const member = await this.membershipById(memberId)
+    if (!member) {
+      throw new CLIError(`Expected member was not found by id: ${memberId.toString()}`)
+    }
+
+    return member
   }
 
   async groupLead(group: WorkingGroups): Promise<GroupMember | null> {
-    const optLeadId = (await this.workingGroupApiQuery(group).currentLead()) as Option<WorkerId>
+    const optLeadId = await this.workingGroupApiQuery(group).currentLead()
 
     if (!optLeadId.isSome) {
       return null
@@ -220,33 +283,22 @@ export default class Api {
     const leadWorkerId = optLeadId.unwrap()
     const leadWorker = await this.workerByWorkerId(group, leadWorkerId.toNumber())
 
-    return await this.parseGroupMember(leadWorkerId, leadWorker)
-  }
-
-  protected async stakeValue(stakeId: StakeId): Promise<Balance> {
-    const stake = await this._api.query.stake.stakes<Stake>(stakeId)
-    return stake.value
-  }
-
-  protected async workerStake(stakeProfile: RoleStakeProfile): Promise<Balance> {
-    return this.stakeValue(stakeProfile.stake_id)
+    return await this.parseGroupMember(group, leadWorkerId, leadWorker)
   }
 
-  protected async workerReward(relationshipId: RewardRelationshipId): Promise<Reward> {
-    const rewardRelationship = await this._api.query.recurringRewards.rewardRelationships<RewardRelationship>(
-      relationshipId
+  protected async fetchStake(account: AccountId | string, group: WorkingGroups): Promise<Balance> {
+    return this._api.createType(
+      'Balance',
+      new BN(
+        (await this._api.query.balances.locks(account)).find((lock) => lock.id.eq(lockIdByWorkingGroup[group]))
+          ?.amount || 0
+      )
     )
-
-    return {
-      totalRecieved: rewardRelationship.total_reward_received,
-      value: rewardRelationship.amount_per_payout,
-      interval: rewardRelationship.payout_interval.unwrapOr(undefined)?.toNumber(),
-      nextPaymentBlock: rewardRelationship.next_payment_at_block.unwrapOr(new BN(0)).toNumber(),
-    }
   }
 
-  protected async parseGroupMember(id: WorkerId, worker: Worker): Promise<GroupMember> {
+  protected async parseGroupMember(group: WorkingGroups, id: WorkerId, worker: Worker): Promise<GroupMember> {
     const roleAccount = worker.role_account_id
+    const stakingAccount = worker.staking_account_id
     const memberId = worker.member_id
 
     const profile = await this.membershipById(memberId)
@@ -255,19 +307,17 @@ export default class Api {
       throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`)
     }
 
-    let stake: Balance | undefined
-    if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
-      stake = await this.workerStake(worker.role_stake_profile.unwrap())
-    }
+    const stake = await this.fetchStake(worker.staking_account_id, group)
 
-    let reward: Reward | undefined
-    if (worker.reward_relationship && worker.reward_relationship.isSome) {
-      reward = await this.workerReward(worker.reward_relationship.unwrap())
+    const reward: Reward = {
+      valuePerBlock: worker.reward_per_block.unwrapOr(undefined),
+      totalMissed: worker.missed_reward.unwrapOr(undefined),
     }
 
     return {
       workerId: id,
       roleAccount,
+      stakingAccount,
       memberId,
       profile,
       stake,
@@ -294,14 +344,14 @@ export default class Api {
 
   async groupMember(group: WorkingGroups, workerId: number) {
     const worker = await this.workerByWorkerId(group, workerId)
-    return await this.parseGroupMember(this._api.createType('WorkerId', workerId), worker)
+    return await this.parseGroupMember(group, this._api.createType('WorkerId', workerId), worker)
   }
 
   async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
     const workerEntries = await this.groupWorkers(group)
 
     const groupMembers: GroupMember[] = await Promise.all(
-      workerEntries.map(([id, worker]) => this.parseGroupMember(id, worker))
+      workerEntries.map(([id, worker]) => this.parseGroupMember(group, id, worker))
     )
 
     return groupMembers.reverse() // Sort by newest
@@ -311,248 +361,196 @@ export default class Api {
     return this.entriesByIds<WorkerId, Worker>(this.workingGroupApiQuery(group).workerById)
   }
 
-  async openingsByGroup(group: WorkingGroups): Promise<GroupOpening[]> {
-    let openings: GroupOpening[] = []
-    const nextId = await this.workingGroupApiQuery(group).nextOpeningId<OpeningId>()
-
-    // This is chain specfic, but if next id is still 0, it means no openings have been added yet
-    if (!nextId.eq(0)) {
-      const ids = Array.from(Array(nextId.toNumber()).keys()).reverse() // Sort by newest
-      openings = await Promise.all(ids.map((id) => this.groupOpening(group, id)))
-    }
+  async openingsByGroup(group: WorkingGroups): Promise<OpeningDetails[]> {
+    const openings = await this.entriesByIds<OpeningId, Opening>(this.workingGroupApiQuery(group).openingById)
 
-    return openings
+    return Promise.all(openings.map(([id, opening]) => this.fetchOpeningDetails(group, opening, id.toNumber())))
   }
 
-  protected async hiringOpeningById(id: number | OpeningId): Promise<Opening> {
-    const result = await this._api.query.hiring.openingById<Opening>(id)
-    return result
-  }
-
-  protected async hiringApplicationById(id: number | ApplicationId): Promise<Application> {
-    const result = await this._api.query.hiring.applicationById<Application>(id)
-    return result
-  }
-
-  async wgApplicationById(group: WorkingGroups, wgApplicationId: number): Promise<WGApplication> {
+  async applicationById(group: WorkingGroups, applicationId: number): Promise<Application> {
     const nextAppId = await this.workingGroupApiQuery(group).nextApplicationId<ApplicationId>()
 
-    if (wgApplicationId < 0 || wgApplicationId >= nextAppId.toNumber()) {
+    if (applicationId < 0 || applicationId >= nextAppId.toNumber()) {
       throw new CLIError('Invalid working group application ID!')
     }
 
-    const result = await this.workingGroupApiQuery(group).applicationById<WGApplication>(wgApplicationId)
-    return result
-  }
+    const result = await this.workingGroupApiQuery(group).applicationById(applicationId)
 
-  protected async parseApplication(wgApplicationId: number, wgApplication: WGApplication): Promise<GroupApplication> {
-    const appId = wgApplication.application_id
-    const application = await this.hiringApplicationById(appId)
+    if (result.isEmpty) {
+      throw new CLIError(`Application of ID=${applicationId} no longer exists!`)
+    }
 
-    const { active_role_staking_id: roleStakingId, active_application_staking_id: appStakingId } = application
+    return result
+  }
 
+  protected async fetchApplicationDetails(
+    applicationId: number,
+    application: Application
+  ): Promise<ApplicationDetails> {
     return {
-      wgApplicationId,
-      applicationId: appId.toNumber(),
-      wgOpeningId: wgApplication.opening_id.toNumber(),
-      member: await this.membershipById(wgApplication.member_id),
-      roleAccout: wgApplication.role_account_id,
-      stakes: {
-        application: appStakingId.isSome ? (await this.stakeValue(appStakingId.unwrap())).toNumber() : 0,
-        role: roleStakingId.isSome ? (await this.stakeValue(roleStakingId.unwrap())).toNumber() : 0,
-      },
-      humanReadableText: application.human_readable_text.toString(),
-      stage: application.stage.type as ApplicationStageKeys,
+      applicationId,
+      member: await this.expectedMembershipById(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(),
     }
   }
 
-  async groupApplication(group: WorkingGroups, wgApplicationId: number): Promise<GroupApplication> {
-    const wgApplication = await this.wgApplicationById(group, wgApplicationId)
-    return await this.parseApplication(wgApplicationId, wgApplication)
+  async groupApplication(group: WorkingGroups, applicationId: number): Promise<ApplicationDetails> {
+    const application = await this.applicationById(group, applicationId)
+    return await this.fetchApplicationDetails(applicationId, application)
   }
 
-  protected async groupOpeningApplications(group: WorkingGroups, wgOpeningId: number): Promise<GroupApplication[]> {
-    const wgApplicationEntries = await this.entriesByIds<ApplicationId, WGApplication>(
+  protected async groupOpeningApplications(group: WorkingGroups, openingId: number): Promise<ApplicationDetails[]> {
+    const applicationEntries = await this.entriesByIds<ApplicationId, Application>(
       this.workingGroupApiQuery(group).applicationById
     )
 
     return Promise.all(
-      wgApplicationEntries
-        .filter(([, /* id */ wgApplication]) => wgApplication.opening_id.eqn(wgOpeningId))
-        .map(([id, wgApplication]) => this.parseApplication(id.toNumber(), wgApplication))
+      applicationEntries
+        .filter(([, application]) => application.opening_id.eqn(openingId))
+        .map(([id, application]) => this.fetchApplicationDetails(id.toNumber(), application))
     )
   }
 
-  async groupOpening(group: WorkingGroups, wgOpeningId: number): Promise<GroupOpening> {
-    const nextId = ((await this.workingGroupApiQuery(group).nextOpeningId()) as OpeningId).toNumber()
+  async fetchOpening(group: WorkingGroups, openingId: number): Promise<Opening> {
+    const nextId = (await this.workingGroupApiQuery(group).nextOpeningId()).toNumber()
 
-    if (wgOpeningId < 0 || wgOpeningId >= nextId) {
+    if (openingId < 0 || openingId >= nextId) {
       throw new CLIError('Invalid working group opening ID!')
     }
 
-    const groupOpening = await this.workingGroupApiQuery(group).openingById<WGOpening>(wgOpeningId)
-
-    const openingId = groupOpening.hiring_opening_id.toNumber()
-    const opening = await this.hiringOpeningById(openingId)
-    const applications = await this.groupOpeningApplications(group, wgOpeningId)
-    const stage = await this.parseOpeningStage(opening.stage)
-    const type = groupOpening.opening_type
-    const { application_staking_policy: applSP, role_staking_policy: roleSP } = opening
-    const stakes = {
-      application: applSP.unwrapOr(undefined),
-      role: roleSP.unwrapOr(undefined),
-    }
+    const opening = await this.workingGroupApiQuery(group).openingById(openingId)
 
-    const unstakingPeriod = (period: Option<BlockNumber>) => period.unwrapOr(new BN(0)).toNumber()
-    const spUnstakingPeriod = (sp: Option<StakingPolicy>, key: StakingPolicyUnstakingPeriodKey) =>
-      sp.isSome ? unstakingPeriod(sp.unwrap()[key]) : 0
-
-    const unstakingPeriods: Partial<UnstakingPeriods> = {
-      'review_period_expired_application_stake_unstaking_period_length': spUnstakingPeriod(
-        applSP,
-        'review_period_expired_unstaking_period_length'
-      ),
-      'crowded_out_application_stake_unstaking_period_length': spUnstakingPeriod(
-        applSP,
-        'crowded_out_unstaking_period_length'
-      ),
-      'review_period_expired_role_stake_unstaking_period_length': spUnstakingPeriod(
-        roleSP,
-        'review_period_expired_unstaking_period_length'
-      ),
-      'crowded_out_role_stake_unstaking_period_length': spUnstakingPeriod(
-        roleSP,
-        'crowded_out_unstaking_period_length'
-      ),
+    if (opening.isEmpty) {
+      throw new CLIError(`Opening of ID=${openingId} no longer exists!`)
     }
 
-    openingPolicyUnstakingPeriodsKeys.forEach((key) => {
-      unstakingPeriods[key] = unstakingPeriod(groupOpening.policy_commitment[key])
-    })
+    return opening
+  }
+
+  async fetchOpeningDetails(group: WorkingGroups, opening: Opening, openingId: number): Promise<OpeningDetails> {
+    const applications = await this.groupOpeningApplications(group, openingId)
+    const type = opening.opening_type
+    const stake = {
+      unstakingPeriod: opening.stake_policy.leaving_unstaking_period.toNumber(),
+      value: opening.stake_policy.stake_amount,
+    }
 
     return {
-      wgOpeningId,
       openingId,
-      opening,
-      stage,
-      stakes,
       applications,
       type,
-      unstakingPeriods: unstakingPeriods as UnstakingPeriods,
+      stake,
+      createdAtBlock: opening.created.toNumber(),
+      rewardPerBlock: opening.reward_per_block.unwrapOr(undefined),
     }
   }
 
-  async parseOpeningStage(stage: OpeningStage): Promise<GroupOpeningStage> {
-    let status: OpeningStatus | undefined, stageBlock: number | undefined, stageDate: Date | undefined
-
-    if (stage.isOfType('WaitingToBegin')) {
-      const stageData = stage.asType('WaitingToBegin')
-      const currentBlockNumber = (await this._api.derive.chain.bestNumber()).toNumber()
-      const expectedBlockTime = (this._api.consts.babe.expectedBlockTime as Moment).toNumber()
-      status = OpeningStatus.WaitingToBegin
-      stageBlock = stageData.begins_at_block.toNumber()
-      stageDate = new Date(Date.now() + (stageBlock - currentBlockNumber) * expectedBlockTime)
-    }
-
-    if (stage.isOfType('Active')) {
-      const stageData = stage.asType('Active')
-      const substage = stageData.stage
-      if (substage.isOfType('AcceptingApplications')) {
-        status = OpeningStatus.AcceptingApplications
-        stageBlock = substage.asType('AcceptingApplications').started_accepting_applicants_at_block.toNumber()
-      }
-      if (substage.isOfType('ReviewPeriod')) {
-        status = OpeningStatus.InReview
-        stageBlock = substage.asType('ReviewPeriod').started_review_period_at_block.toNumber()
-      }
-      if (substage.isOfType('Deactivated')) {
-        status = substage.asType('Deactivated').cause.isOfType('Filled')
-          ? OpeningStatus.Complete
-          : OpeningStatus.Cancelled
-        stageBlock = substage.asType('Deactivated').deactivated_at_block.toNumber()
-      }
-      if (stageBlock) {
-        stageDate = new Date(await this.blockTimestamp(stageBlock))
-      }
-    }
-
-    return {
-      status: status || OpeningStatus.Unknown,
-      block: stageBlock,
-      date: stageDate,
-    }
+  async groupOpening(group: WorkingGroups, openingId: number): Promise<OpeningDetails> {
+    const opening = await this.fetchOpening(group, openingId)
+    return this.fetchOpeningDetails(group, opening, openingId)
   }
 
-  async getMemberIdsByControllerAccount(address: string): Promise<MemberId[]> {
-    const ids = await this._api.query.members.memberIdsByControllerAccountId<Vec<MemberId>>(address)
-    return ids.toArray()
+  async allMembers(): Promise<[MemberId, Membership][]> {
+    return this.entriesByIds<MemberId, Membership>(this._api.query.members.membershipById)
   }
 
-  async workerExitRationaleConstraint(group: WorkingGroups): Promise<InputValidationLengthConstraint> {
-    return await this.workingGroupApiQuery(group).workerExitRationaleText<InputValidationLengthConstraint>()
+  // Content directory
+  async availableChannels(): Promise<[ChannelId, Channel][]> {
+    return await this.entriesByIds<ChannelId, Channel>(this._api.query.content.channelById)
   }
 
-  // Content directory
-  async availableClasses(useCache = true): Promise<[ClassId, Class][]> {
-    return useCache && this._cdClassesCache
-      ? this._cdClassesCache
-      : (this._cdClassesCache = await this.entriesByIds<ClassId, Class>(this._api.query.contentDirectory.classById))
+  async availableVideos(): Promise<[VideoId, Video][]> {
+    return await this.entriesByIds<VideoId, Video>(this._api.query.content.videoById)
   }
 
   availableCuratorGroups(): Promise<[CuratorGroupId, CuratorGroup][]> {
-    return this.entriesByIds<CuratorGroupId, CuratorGroup>(this._api.query.contentDirectory.curatorGroupById)
+    return this.entriesByIds<CuratorGroupId, CuratorGroup>(this._api.query.content.curatorGroupById)
   }
 
   async curatorGroupById(id: number): Promise<CuratorGroup | null> {
-    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id)).toNumber()
-    return exists ? await this._api.query.contentDirectory.curatorGroupById<CuratorGroup>(id) : null
+    const exists = !!(await this._api.query.content.curatorGroupById.size(id)).toNumber()
+    return exists ? await this._api.query.content.curatorGroupById(id) : null
   }
 
   async nextCuratorGroupId(): Promise<number> {
-    return (await this._api.query.contentDirectory.nextCuratorGroupId<CuratorGroupId>()).toNumber()
+    return (await this._api.query.content.nextCuratorGroupId()).toNumber()
   }
 
-  async classById(id: number): Promise<Class | null> {
-    const c = await this._api.query.contentDirectory.classById<Class>(id)
-    return c.isEmpty ? null : c
+  async channelById(channelId: ChannelId | number | string): Promise<Channel> {
+    // isEmpty will not work for { MemmberId: 0 } ownership
+    const exists = !!(await this._api.query.content.channelById.size(channelId)).toNumber()
+    if (!exists) {
+      throw new CLIError(`Channel by id ${channelId.toString()} not found!`)
+    }
+    const channel = await this._api.query.content.channelById(channelId)
+
+    return channel
   }
 
-  async entitiesByClassId(classId: number): Promise<[EntityId, Entity][]> {
-    const entityEntries = await this.entriesByIds<EntityId, Entity>(this._api.query.contentDirectory.entityById)
-    return entityEntries.filter(([, entity]) => entity.class_id.toNumber() === classId)
+  async videosByChannelId(channelId: ChannelId | number | string): Promise<[VideoId, Video][]> {
+    const channel = await this.channelById(channelId)
+    if (channel) {
+      return Promise.all(
+        channel.videos.map(
+          async (videoId) => [videoId, await this._api.query.content.videoById(videoId)] as [VideoId, Video]
+        )
+      )
+    } else {
+      return []
+    }
   }
 
-  async entityById(id: number): Promise<Entity | null> {
-    const exists = !!(await this._api.query.contentDirectory.entityById.size(id)).toNumber()
-    return exists ? await this._api.query.contentDirectory.entityById<Entity>(id) : null
+  async videoById(videoId: VideoId | number | string): Promise<Video> {
+    const video = await this._api.query.content.videoById(videoId)
+    if (video.isEmpty) {
+      throw new CLIError(`Video by id ${videoId.toString()} not found!`)
+    }
+
+    return video
   }
 
-  async dataObjectByContentId(contentId: ContentId): Promise<DataObject | null> {
-    const dataObject = await this._api.query.dataDirectory.dataObjectByContentId<Option<DataObject>>(contentId)
-    return dataObject.unwrapOr(null)
+  async channelCategoryIds(): Promise<ChannelCategoryId[]> {
+    // There is currently no way to differentiate between unexisting and existing category
+    // other than fetching all existing category ids (event the .size() trick does not work, as the object is empty)
+    return (
+      await this.entriesByIds<ChannelCategoryId, ChannelCategory>(this._api.query.content.channelCategoryById)
+    ).map(([id]) => id)
   }
 
-  async ipnsIdentity(storageProviderId: number): Promise<string | null> {
-    const accountInfo = await this._api.query.discovery.accountInfoByStorageProviderId<ServiceProviderRecord>(
-      storageProviderId
+  async videoCategoryIds(): Promise<VideoCategoryId[]> {
+    // There is currently no way to differentiate between unexisting and existing category
+    // other than fetching all existing category ids (event the .size() trick does not work, as the object is empty)
+    return (await this.entriesByIds<VideoCategoryId, VideoCategory>(this._api.query.content.videoCategoryById)).map(
+      ([id]) => id
     )
-    return accountInfo.isEmpty || accountInfo.expires_at.toNumber() <= (await this.bestNumber())
-      ? null
-      : accountInfo.identity.toString()
   }
 
-  async getRandomBootstrapEndpoint(): Promise<string | null> {
-    const endpoints = await this._api.query.discovery.bootstrapEndpoints<Vec<Url>>()
-    const randomEndpoint = _.sample(endpoints.toArray())
-    return randomEndpoint ? randomEndpoint.toString() : null
+  async dataObjectsByContentIds(contentIds: ContentId[]): Promise<DataObject[]> {
+    const dataObjects = await this._api.query.dataDirectory.dataByContentId.multi<DataObject>(contentIds)
+    const notFoundIndex = dataObjects.findIndex((o) => o.isEmpty)
+    if (notFoundIndex !== -1) {
+      throw new CLIError(`DataObject not found by id ${contentIds[notFoundIndex].toString()}`)
+    }
+    return dataObjects
   }
 
-  async isAnyProviderAvailable(): Promise<boolean> {
-    const accounInfoEntries = await this.entriesByIds<StorageProviderId, ServiceProviderRecord>(
-      this._api.query.discovery.accountInfoByStorageProviderId
-    )
+  async storageProviderEndpoint(storageProviderId: StorageProviderId | number): Promise<string> {
+    const value = await this._api.query.storageWorkingGroup.workerStorage(storageProviderId)
+    return this._api.createType('Text', value).toString()
+  }
+
+  async allStorageProviderEndpoints(): Promise<string[]> {
+    const workerIds = (await this.groupWorkers(WorkingGroups.StorageProviders)).map(([id]) => id)
+    const workerStorages = await this._api.query.storageWorkingGroup.workerStorage.multi<Bytes>(workerIds)
+    return workerStorages.map((storage) => this._api.createType('Text', storage).toString())
+  }
 
-    const bestNumber = await this.bestNumber()
-    return !!accounInfoEntries.filter(([, info]) => info.expires_at.toNumber() > bestNumber).length
+  async stakingAccountStatus(account: string): Promise<StakingAccountMemberBinding | null> {
+    const status = await this.getOriginalApi().query.members.stakingAccountIdMemberStatus(account)
+    return status.isEmpty ? null : status
   }
 }

+ 636 - 0
cli/src/QueryNodeApiSchema.generated.ts

@@ -0,0 +1,636 @@
+export type Maybe<T> = T | null
+export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }
+export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }
+export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }
+/** All built-in and custom scalars, mapped to their actual values */
+export type Scalars = {
+  ID: string
+  String: string
+  Boolean: boolean
+  Int: number
+  Float: number
+  /** The javascript `Date` as string. Type represents date and time as the ISO Date string. */
+  DateTime: any
+  /** GraphQL representation of BigInt */
+  BigInt: any
+}
+
+export type BaseGraphQlObject = {
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+}
+
+export type BaseModel = BaseGraphQlObject & {
+  __typename?: 'BaseModel'
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+}
+
+export type BaseModelUuid = BaseGraphQlObject & {
+  __typename?: 'BaseModelUUID'
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+}
+
+export type BaseWhereInput = {
+  id_eq?: Maybe<Scalars['String']>
+  id_in?: Maybe<Array<Scalars['String']>>
+  createdAt_eq?: Maybe<Scalars['String']>
+  createdAt_lt?: Maybe<Scalars['String']>
+  createdAt_lte?: Maybe<Scalars['String']>
+  createdAt_gt?: Maybe<Scalars['String']>
+  createdAt_gte?: Maybe<Scalars['String']>
+  createdById_eq?: Maybe<Scalars['String']>
+  updatedAt_eq?: Maybe<Scalars['String']>
+  updatedAt_lt?: Maybe<Scalars['String']>
+  updatedAt_lte?: Maybe<Scalars['String']>
+  updatedAt_gt?: Maybe<Scalars['String']>
+  updatedAt_gte?: Maybe<Scalars['String']>
+  updatedById_eq?: Maybe<Scalars['String']>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['String']>
+  deletedAt_lt?: Maybe<Scalars['String']>
+  deletedAt_lte?: Maybe<Scalars['String']>
+  deletedAt_gt?: Maybe<Scalars['String']>
+  deletedAt_gte?: Maybe<Scalars['String']>
+  deletedById_eq?: Maybe<Scalars['String']>
+}
+
+export type Block = BaseGraphQlObject & {
+  __typename?: 'Block'
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+  block: Scalars['Int']
+  executedAt: Scalars['DateTime']
+  network: Network
+  membershipregisteredAtBlock?: Maybe<Array<Membership>>
+}
+
+export type BlockConnection = {
+  __typename?: 'BlockConnection'
+  totalCount: Scalars['Int']
+  edges: Array<BlockEdge>
+  pageInfo: PageInfo
+}
+
+export type BlockCreateInput = {
+  block: Scalars['Float']
+  executedAt: Scalars['DateTime']
+  network: Network
+}
+
+export type BlockEdge = {
+  __typename?: 'BlockEdge'
+  node: Block
+  cursor: Scalars['String']
+}
+
+export enum BlockOrderByInput {
+  CreatedAtAsc = 'createdAt_ASC',
+  CreatedAtDesc = 'createdAt_DESC',
+  UpdatedAtAsc = 'updatedAt_ASC',
+  UpdatedAtDesc = 'updatedAt_DESC',
+  DeletedAtAsc = 'deletedAt_ASC',
+  DeletedAtDesc = 'deletedAt_DESC',
+  BlockAsc = 'block_ASC',
+  BlockDesc = 'block_DESC',
+  ExecutedAtAsc = 'executedAt_ASC',
+  ExecutedAtDesc = 'executedAt_DESC',
+  NetworkAsc = 'network_ASC',
+  NetworkDesc = 'network_DESC',
+}
+
+export type BlockUpdateInput = {
+  block?: Maybe<Scalars['Float']>
+  executedAt?: Maybe<Scalars['DateTime']>
+  network?: Maybe<Network>
+}
+
+export type BlockWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  block_eq?: Maybe<Scalars['Int']>
+  block_gt?: Maybe<Scalars['Int']>
+  block_gte?: Maybe<Scalars['Int']>
+  block_lt?: Maybe<Scalars['Int']>
+  block_lte?: Maybe<Scalars['Int']>
+  block_in?: Maybe<Array<Scalars['Int']>>
+  executedAt_eq?: Maybe<Scalars['DateTime']>
+  executedAt_lt?: Maybe<Scalars['DateTime']>
+  executedAt_lte?: Maybe<Scalars['DateTime']>
+  executedAt_gt?: Maybe<Scalars['DateTime']>
+  executedAt_gte?: Maybe<Scalars['DateTime']>
+  network_eq?: Maybe<Network>
+  network_in?: Maybe<Array<Network>>
+}
+
+export type BlockWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
+export type DeleteResponse = {
+  id: Scalars['ID']
+}
+
+export type MembersByHandleFtsOutput = {
+  __typename?: 'MembersByHandleFTSOutput'
+  item: MembersByHandleSearchResult
+  rank: Scalars['Float']
+  isTypeOf: Scalars['String']
+  highlight: Scalars['String']
+}
+
+export type MembersByHandleSearchResult = Membership
+
+/** Stored information about a registered user */
+export type Membership = BaseGraphQlObject & {
+  __typename?: 'Membership'
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+  /** The unique handle chosen by member */
+  handle: Scalars['String']
+  /** Member's name */
+  name?: Maybe<Scalars['String']>
+  /** A Url to member's Avatar image */
+  avatarUri?: Maybe<Scalars['String']>
+  /** Short text chosen by member to share information about themselves */
+  about?: Maybe<Scalars['String']>
+  /** Member's controller account id */
+  controllerAccount: Scalars['String']
+  /** Member's root account id */
+  rootAccount: Scalars['String']
+  registeredAtBlock: Block
+  registeredAtBlockId: Scalars['String']
+  /** Timestamp when member was registered */
+  registeredAtTime: Scalars['DateTime']
+  /** How the member was registered */
+  entry: MembershipEntryMethod
+  /** Whether member has been verified by membership working group. */
+  isVerified: Scalars['Boolean']
+  /** Staking accounts bounded to membership. */
+  boundAccounts: Array<Scalars['String']>
+  /** Current count of invites left to send. */
+  inviteCount: Scalars['Int']
+  invitees: Array<Membership>
+  invitedBy?: Maybe<Membership>
+  invitedById?: Maybe<Scalars['String']>
+  referredMembers: Array<Membership>
+  referredBy?: Maybe<Membership>
+  referredById?: Maybe<Scalars['String']>
+}
+
+export type MembershipConnection = {
+  __typename?: 'MembershipConnection'
+  totalCount: Scalars['Int']
+  edges: Array<MembershipEdge>
+  pageInfo: PageInfo
+}
+
+export type MembershipCreateInput = {
+  handle: Scalars['String']
+  name?: Maybe<Scalars['String']>
+  avatarUri?: Maybe<Scalars['String']>
+  about?: Maybe<Scalars['String']>
+  controllerAccount: Scalars['String']
+  rootAccount: Scalars['String']
+  registeredAtBlockId: Scalars['ID']
+  registeredAtTime: Scalars['DateTime']
+  entry: MembershipEntryMethod
+  isVerified: Scalars['Boolean']
+  boundAccounts: Array<Scalars['String']>
+  inviteCount: Scalars['Float']
+  invitedById?: Maybe<Scalars['ID']>
+  referredById?: Maybe<Scalars['ID']>
+}
+
+export type MembershipEdge = {
+  __typename?: 'MembershipEdge'
+  node: Membership
+  cursor: Scalars['String']
+}
+
+export enum MembershipEntryMethod {
+  Paid = 'PAID',
+  Invited = 'INVITED',
+  Genesis = 'GENESIS',
+}
+
+export enum MembershipOrderByInput {
+  CreatedAtAsc = 'createdAt_ASC',
+  CreatedAtDesc = 'createdAt_DESC',
+  UpdatedAtAsc = 'updatedAt_ASC',
+  UpdatedAtDesc = 'updatedAt_DESC',
+  DeletedAtAsc = 'deletedAt_ASC',
+  DeletedAtDesc = 'deletedAt_DESC',
+  HandleAsc = 'handle_ASC',
+  HandleDesc = 'handle_DESC',
+  NameAsc = 'name_ASC',
+  NameDesc = 'name_DESC',
+  AvatarUriAsc = 'avatarUri_ASC',
+  AvatarUriDesc = 'avatarUri_DESC',
+  AboutAsc = 'about_ASC',
+  AboutDesc = 'about_DESC',
+  ControllerAccountAsc = 'controllerAccount_ASC',
+  ControllerAccountDesc = 'controllerAccount_DESC',
+  RootAccountAsc = 'rootAccount_ASC',
+  RootAccountDesc = 'rootAccount_DESC',
+  RegisteredAtBlockIdAsc = 'registeredAtBlockId_ASC',
+  RegisteredAtBlockIdDesc = 'registeredAtBlockId_DESC',
+  RegisteredAtTimeAsc = 'registeredAtTime_ASC',
+  RegisteredAtTimeDesc = 'registeredAtTime_DESC',
+  EntryAsc = 'entry_ASC',
+  EntryDesc = 'entry_DESC',
+  IsVerifiedAsc = 'isVerified_ASC',
+  IsVerifiedDesc = 'isVerified_DESC',
+  InviteCountAsc = 'inviteCount_ASC',
+  InviteCountDesc = 'inviteCount_DESC',
+  InvitedByIdAsc = 'invitedById_ASC',
+  InvitedByIdDesc = 'invitedById_DESC',
+  ReferredByIdAsc = 'referredById_ASC',
+  ReferredByIdDesc = 'referredById_DESC',
+}
+
+export type MembershipSystem = BaseGraphQlObject & {
+  __typename?: 'MembershipSystem'
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+  /** Initial invitation count of a new member. */
+  defaultInviteCount: Scalars['Int']
+  /** Current price to buy a membership. */
+  membershipPrice: Scalars['BigInt']
+  /** Amount of tokens diverted to invitor. */
+  referralCut: Scalars['BigInt']
+  /** The initial, locked, balance credited to controller account of invitee. */
+  invitedInitialBalance: Scalars['BigInt']
+}
+
+export type MembershipSystemConnection = {
+  __typename?: 'MembershipSystemConnection'
+  totalCount: Scalars['Int']
+  edges: Array<MembershipSystemEdge>
+  pageInfo: PageInfo
+}
+
+export type MembershipSystemCreateInput = {
+  defaultInviteCount: Scalars['Float']
+  membershipPrice: Scalars['BigInt']
+  referralCut: Scalars['BigInt']
+  invitedInitialBalance: Scalars['BigInt']
+}
+
+export type MembershipSystemEdge = {
+  __typename?: 'MembershipSystemEdge'
+  node: MembershipSystem
+  cursor: Scalars['String']
+}
+
+export enum MembershipSystemOrderByInput {
+  CreatedAtAsc = 'createdAt_ASC',
+  CreatedAtDesc = 'createdAt_DESC',
+  UpdatedAtAsc = 'updatedAt_ASC',
+  UpdatedAtDesc = 'updatedAt_DESC',
+  DeletedAtAsc = 'deletedAt_ASC',
+  DeletedAtDesc = 'deletedAt_DESC',
+  DefaultInviteCountAsc = 'defaultInviteCount_ASC',
+  DefaultInviteCountDesc = 'defaultInviteCount_DESC',
+  MembershipPriceAsc = 'membershipPrice_ASC',
+  MembershipPriceDesc = 'membershipPrice_DESC',
+  ReferralCutAsc = 'referralCut_ASC',
+  ReferralCutDesc = 'referralCut_DESC',
+  InvitedInitialBalanceAsc = 'invitedInitialBalance_ASC',
+  InvitedInitialBalanceDesc = 'invitedInitialBalance_DESC',
+}
+
+export type MembershipSystemUpdateInput = {
+  defaultInviteCount?: Maybe<Scalars['Float']>
+  membershipPrice?: Maybe<Scalars['BigInt']>
+  referralCut?: Maybe<Scalars['BigInt']>
+  invitedInitialBalance?: Maybe<Scalars['BigInt']>
+}
+
+export type MembershipSystemWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  defaultInviteCount_eq?: Maybe<Scalars['Int']>
+  defaultInviteCount_gt?: Maybe<Scalars['Int']>
+  defaultInviteCount_gte?: Maybe<Scalars['Int']>
+  defaultInviteCount_lt?: Maybe<Scalars['Int']>
+  defaultInviteCount_lte?: Maybe<Scalars['Int']>
+  defaultInviteCount_in?: Maybe<Array<Scalars['Int']>>
+  membershipPrice_eq?: Maybe<Scalars['BigInt']>
+  membershipPrice_gt?: Maybe<Scalars['BigInt']>
+  membershipPrice_gte?: Maybe<Scalars['BigInt']>
+  membershipPrice_lt?: Maybe<Scalars['BigInt']>
+  membershipPrice_lte?: Maybe<Scalars['BigInt']>
+  membershipPrice_in?: Maybe<Array<Scalars['BigInt']>>
+  referralCut_eq?: Maybe<Scalars['BigInt']>
+  referralCut_gt?: Maybe<Scalars['BigInt']>
+  referralCut_gte?: Maybe<Scalars['BigInt']>
+  referralCut_lt?: Maybe<Scalars['BigInt']>
+  referralCut_lte?: Maybe<Scalars['BigInt']>
+  referralCut_in?: Maybe<Array<Scalars['BigInt']>>
+  invitedInitialBalance_eq?: Maybe<Scalars['BigInt']>
+  invitedInitialBalance_gt?: Maybe<Scalars['BigInt']>
+  invitedInitialBalance_gte?: Maybe<Scalars['BigInt']>
+  invitedInitialBalance_lt?: Maybe<Scalars['BigInt']>
+  invitedInitialBalance_lte?: Maybe<Scalars['BigInt']>
+  invitedInitialBalance_in?: Maybe<Array<Scalars['BigInt']>>
+}
+
+export type MembershipSystemWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
+export type MembershipUpdateInput = {
+  handle?: Maybe<Scalars['String']>
+  name?: Maybe<Scalars['String']>
+  avatarUri?: Maybe<Scalars['String']>
+  about?: Maybe<Scalars['String']>
+  controllerAccount?: Maybe<Scalars['String']>
+  rootAccount?: Maybe<Scalars['String']>
+  registeredAtBlockId?: Maybe<Scalars['ID']>
+  registeredAtTime?: Maybe<Scalars['DateTime']>
+  entry?: Maybe<MembershipEntryMethod>
+  isVerified?: Maybe<Scalars['Boolean']>
+  boundAccounts?: Maybe<Array<Scalars['String']>>
+  inviteCount?: Maybe<Scalars['Float']>
+  invitedById?: Maybe<Scalars['ID']>
+  referredById?: Maybe<Scalars['ID']>
+}
+
+export type MembershipWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  handle_eq?: Maybe<Scalars['String']>
+  handle_contains?: Maybe<Scalars['String']>
+  handle_startsWith?: Maybe<Scalars['String']>
+  handle_endsWith?: Maybe<Scalars['String']>
+  handle_in?: Maybe<Array<Scalars['String']>>
+  name_eq?: Maybe<Scalars['String']>
+  name_contains?: Maybe<Scalars['String']>
+  name_startsWith?: Maybe<Scalars['String']>
+  name_endsWith?: Maybe<Scalars['String']>
+  name_in?: Maybe<Array<Scalars['String']>>
+  avatarUri_eq?: Maybe<Scalars['String']>
+  avatarUri_contains?: Maybe<Scalars['String']>
+  avatarUri_startsWith?: Maybe<Scalars['String']>
+  avatarUri_endsWith?: Maybe<Scalars['String']>
+  avatarUri_in?: Maybe<Array<Scalars['String']>>
+  about_eq?: Maybe<Scalars['String']>
+  about_contains?: Maybe<Scalars['String']>
+  about_startsWith?: Maybe<Scalars['String']>
+  about_endsWith?: Maybe<Scalars['String']>
+  about_in?: Maybe<Array<Scalars['String']>>
+  controllerAccount_eq?: Maybe<Scalars['String']>
+  controllerAccount_contains?: Maybe<Scalars['String']>
+  controllerAccount_startsWith?: Maybe<Scalars['String']>
+  controllerAccount_endsWith?: Maybe<Scalars['String']>
+  controllerAccount_in?: Maybe<Array<Scalars['String']>>
+  rootAccount_eq?: Maybe<Scalars['String']>
+  rootAccount_contains?: Maybe<Scalars['String']>
+  rootAccount_startsWith?: Maybe<Scalars['String']>
+  rootAccount_endsWith?: Maybe<Scalars['String']>
+  rootAccount_in?: Maybe<Array<Scalars['String']>>
+  registeredAtBlockId_eq?: Maybe<Scalars['ID']>
+  registeredAtBlockId_in?: Maybe<Array<Scalars['ID']>>
+  registeredAtTime_eq?: Maybe<Scalars['DateTime']>
+  registeredAtTime_lt?: Maybe<Scalars['DateTime']>
+  registeredAtTime_lte?: Maybe<Scalars['DateTime']>
+  registeredAtTime_gt?: Maybe<Scalars['DateTime']>
+  registeredAtTime_gte?: Maybe<Scalars['DateTime']>
+  entry_eq?: Maybe<MembershipEntryMethod>
+  entry_in?: Maybe<Array<MembershipEntryMethod>>
+  isVerified_eq?: Maybe<Scalars['Boolean']>
+  isVerified_in?: Maybe<Array<Scalars['Boolean']>>
+  inviteCount_eq?: Maybe<Scalars['Int']>
+  inviteCount_gt?: Maybe<Scalars['Int']>
+  inviteCount_gte?: Maybe<Scalars['Int']>
+  inviteCount_lt?: Maybe<Scalars['Int']>
+  inviteCount_lte?: Maybe<Scalars['Int']>
+  inviteCount_in?: Maybe<Array<Scalars['Int']>>
+  invitedById_eq?: Maybe<Scalars['ID']>
+  invitedById_in?: Maybe<Array<Scalars['ID']>>
+  referredById_eq?: Maybe<Scalars['ID']>
+  referredById_in?: Maybe<Array<Scalars['ID']>>
+}
+
+export type MembershipWhereUniqueInput = {
+  id?: Maybe<Scalars['ID']>
+  handle?: Maybe<Scalars['String']>
+}
+
+export enum Network {
+  Babylon = 'BABYLON',
+  Alexandria = 'ALEXANDRIA',
+  Rome = 'ROME',
+  Olympia = 'OLYMPIA',
+}
+
+export type PageInfo = {
+  __typename?: 'PageInfo'
+  hasNextPage: Scalars['Boolean']
+  hasPreviousPage: Scalars['Boolean']
+  startCursor?: Maybe<Scalars['String']>
+  endCursor?: Maybe<Scalars['String']>
+}
+
+export type ProcessorState = {
+  __typename?: 'ProcessorState'
+  lastCompleteBlock: Scalars['Float']
+  lastProcessedEvent: Scalars['String']
+  indexerHead: Scalars['Float']
+  chainHead: Scalars['Float']
+}
+
+export type Query = {
+  __typename?: 'Query'
+  blocks: Array<Block>
+  block?: Maybe<Block>
+  blocksConnection: BlockConnection
+  membershipSystems: Array<MembershipSystem>
+  membershipSystem?: Maybe<MembershipSystem>
+  membershipSystemsConnection: MembershipSystemConnection
+  memberships: Array<Membership>
+  membership?: Maybe<Membership>
+  membershipsConnection: MembershipConnection
+  membersByHandle: Array<MembersByHandleFtsOutput>
+}
+
+export type QueryBlocksArgs = {
+  offset?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
+  where?: Maybe<BlockWhereInput>
+  orderBy?: Maybe<BlockOrderByInput>
+}
+
+export type QueryBlockArgs = {
+  where: BlockWhereUniqueInput
+}
+
+export type QueryBlocksConnectionArgs = {
+  first?: Maybe<Scalars['Int']>
+  after?: Maybe<Scalars['String']>
+  last?: Maybe<Scalars['Int']>
+  before?: Maybe<Scalars['String']>
+  where?: Maybe<BlockWhereInput>
+  orderBy?: Maybe<BlockOrderByInput>
+}
+
+export type QueryMembershipSystemsArgs = {
+  offset?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
+  where?: Maybe<MembershipSystemWhereInput>
+  orderBy?: Maybe<MembershipSystemOrderByInput>
+}
+
+export type QueryMembershipSystemArgs = {
+  where: MembershipSystemWhereUniqueInput
+}
+
+export type QueryMembershipSystemsConnectionArgs = {
+  first?: Maybe<Scalars['Int']>
+  after?: Maybe<Scalars['String']>
+  last?: Maybe<Scalars['Int']>
+  before?: Maybe<Scalars['String']>
+  where?: Maybe<MembershipSystemWhereInput>
+  orderBy?: Maybe<MembershipSystemOrderByInput>
+}
+
+export type QueryMembershipsArgs = {
+  offset?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
+  where?: Maybe<MembershipWhereInput>
+  orderBy?: Maybe<MembershipOrderByInput>
+}
+
+export type QueryMembershipArgs = {
+  where: MembershipWhereUniqueInput
+}
+
+export type QueryMembershipsConnectionArgs = {
+  first?: Maybe<Scalars['Int']>
+  after?: Maybe<Scalars['String']>
+  last?: Maybe<Scalars['Int']>
+  before?: Maybe<Scalars['String']>
+  where?: Maybe<MembershipWhereInput>
+  orderBy?: Maybe<MembershipOrderByInput>
+}
+
+export type QueryMembersByHandleArgs = {
+  whereMembership?: Maybe<MembershipWhereInput>
+  skip?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
+  text: Scalars['String']
+}
+
+export type StandardDeleteResponse = {
+  __typename?: 'StandardDeleteResponse'
+  id: Scalars['ID']
+}
+
+export type Subscription = {
+  __typename?: 'Subscription'
+  stateSubscription: ProcessorState
+}

+ 132 - 117
cli/src/Types.ts

@@ -1,14 +1,22 @@
-import BN from 'bn.js'
-import { ElectionStage, Seat } from '@joystream/types/council'
-import { Option } from '@polkadot/types'
 import { Codec } from '@polkadot/types/types'
-import { BlockNumber, Balance, AccountId } from '@polkadot/types/interfaces'
+import { Balance, AccountId } from '@polkadot/types/interfaces'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { KeyringPair } from '@polkadot/keyring/types'
 import { WorkerId, OpeningType } from '@joystream/types/working-group'
-import { Membership, MemberId } from '@joystream/types/members'
-import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/hiring'
+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 { ContentId, ContentParameters } from '@joystream/types/storage'
+
+import { JSONSchema7, JSONSchema7Definition } from 'json-schema'
+import {
+  IChannelMetadata,
+  IVideoMetadata,
+  IVideoCategoryMetadata,
+  IChannelCategoryMetadata,
+} from '@joystream/metadata-protobuf'
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -24,44 +32,6 @@ export type AccountSummary = {
   balances: DeriveBalancesAll
 }
 
-// This function allows us to easily transform the tuple into the object
-// and simplifies the creation of consitent Object and Tuple types (seen below).
-export function createCouncilInfoObj(
-  activeCouncil: Seat[],
-  termEndsAt: BlockNumber,
-  autoStart: boolean,
-  newTermDuration: BN,
-  candidacyLimit: BN,
-  councilSize: BN,
-  minCouncilStake: Balance,
-  minVotingStake: Balance,
-  announcingPeriod: BlockNumber,
-  votingPeriod: BlockNumber,
-  revealingPeriod: BlockNumber,
-  round: BN,
-  stage: Option<ElectionStage>
-) {
-  return {
-    activeCouncil,
-    termEndsAt,
-    autoStart,
-    newTermDuration,
-    candidacyLimit,
-    councilSize,
-    minCouncilStake,
-    minVotingStake,
-    announcingPeriod,
-    votingPeriod,
-    revealingPeriod,
-    round,
-    stage,
-  }
-}
-// Object/Tuple containing council/councilElection information (council:info).
-// The tuple is useful, because that's how api.queryMulti returns the results.
-export type CouncilInfoTuple = Parameters<typeof createCouncilInfoObj>
-export type CouncilInfoObj = ReturnType<typeof createCouncilInfoObj>
-
 // Object with "name" and "value" properties, used for rendering simple CLI tables like:
 // Total balance:   100 JOY
 // Free calance:     50 JOY
@@ -71,19 +41,25 @@ export type NameValueObj = { name: string; value: string }
 export enum WorkingGroups {
   StorageProviders = 'storageProviders',
   Curators = 'curators',
+  Forum = 'forum',
+  Membership = 'membership',
+  Operations = 'operations',
+  Gateway = 'gateway',
 }
 
 // In contrast to Pioneer, currently only StorageProviders group is available in CLI
 export const AvailableGroups: readonly WorkingGroups[] = [
   WorkingGroups.StorageProviders,
   WorkingGroups.Curators,
+  WorkingGroups.Forum,
+  WorkingGroups.Membership,
+  WorkingGroups.Operations,
+  WorkingGroups.Gateway,
 ] as const
 
 export type Reward = {
-  totalRecieved: Balance
-  value: Balance
-  interval?: number
-  nextPaymentBlock: number // 0 = no incoming payment
+  totalMissed?: Balance
+  valuePerBlock?: Balance
 }
 
 // Compound working group types
@@ -91,83 +67,40 @@ export type GroupMember = {
   workerId: WorkerId
   memberId: MemberId
   roleAccount: AccountId
-  profile: Membership
-  stake?: Balance
-  reward?: Reward
+  stakingAccount: AccountId
+  profile: MemberDetails
+  stake: Balance
+  reward: Reward
 }
 
-export type GroupApplication = {
-  wgApplicationId: number
+export type ApplicationDetails = {
   applicationId: number
-  wgOpeningId: number
-  member: Membership | null
+  member: MemberDetails
   roleAccout: AccountId
-  stakes: {
-    application: number
-    role: number
-  }
-  humanReadableText: string
-  stage: ApplicationStageKeys
-}
-
-export enum OpeningStatus {
-  WaitingToBegin = 'WaitingToBegin',
-  AcceptingApplications = 'AcceptingApplications',
-  InReview = 'InReview',
-  Complete = 'Complete',
-  Cancelled = 'Cancelled',
-  Unknown = 'Unknown',
-}
-
-export type GroupOpeningStage = {
-  status: OpeningStatus
-  block?: number
-  date?: Date
-}
-
-export type GroupOpeningStakes = {
-  application?: StakingPolicy
-  role?: StakingPolicy
-}
-
-export const stakingPolicyUnstakingPeriodKeys = [
-  'crowded_out_unstaking_period_length',
-  'review_period_expired_unstaking_period_length',
-] as const
-
-export type StakingPolicyUnstakingPeriodKey = typeof stakingPolicyUnstakingPeriodKeys[number]
-
-export const openingPolicyUnstakingPeriodsKeys = [
-  'fill_opening_failed_applicant_application_stake_unstaking_period',
-  'fill_opening_failed_applicant_role_stake_unstaking_period',
-  'fill_opening_successful_applicant_application_stake_unstaking_period',
-  'terminate_application_stake_unstaking_period',
-  'terminate_role_stake_unstaking_period',
-  'exit_role_application_stake_unstaking_period',
-  'exit_role_stake_unstaking_period',
-] as const
-
-export type OpeningPolicyUnstakingPeriodsKey = typeof openingPolicyUnstakingPeriodsKeys[number]
-export type UnstakingPeriodsKey =
-  | OpeningPolicyUnstakingPeriodsKey
-  | 'crowded_out_application_stake_unstaking_period_length'
-  | 'crowded_out_role_stake_unstaking_period_length'
-  | 'review_period_expired_application_stake_unstaking_period_length'
-  | 'review_period_expired_role_stake_unstaking_period_length'
-
-export type UnstakingPeriods = {
-  [k in UnstakingPeriodsKey]: number
+  stakingAccount: AccountId
+  rewardAccount: AccountId
+  descriptionHash: string
+  openingId: number
 }
 
-export type GroupOpening = {
-  wgOpeningId: number
+export type OpeningDetails = {
   openingId: number
-  stage: GroupOpeningStage
-  opening: Opening
-  stakes: GroupOpeningStakes
-  applications: GroupApplication[]
+  stake: {
+    value: Balance
+    unstakingPeriod: number
+  }
+  applications: ApplicationDetails[]
   type: OpeningType
-  unstakingPeriods: UnstakingPeriods
+  createdAtBlock: number
+  rewardPerBlock?: Balance
+}
+
+// Extended membership information (including optional query node data)
+export type MemberDetails = {
+  id: MemberId
+  name?: string | null
+  handle?: string
+  membership: Membership
 }
 
 // Api-related
@@ -193,3 +126,85 @@ export type ApiMethodNamedArg = {
   value: ApiMethodArg
 }
 export type ApiMethodNamedArgs = ApiMethodNamedArg[]
+
+// Api without TypeScript augmentations for "query", "tx" and "consts" (useful when more type flexibility is needed)
+export type UnaugmentedApiPromise = Omit<ApiPromise, 'query' | 'tx' | 'consts'> & {
+  query: { [key: string]: QueryableModuleStorage<'promise'> }
+  tx: { [key: string]: SubmittableModuleExtrinsics<'promise'> }
+  consts: { [key: string]: QueryableModuleConsts }
+}
+
+// Content-related
+export enum AssetType {
+  AnyAsset = 1,
+}
+
+export type InputAsset = {
+  path: string
+  contentId: ContentId
+}
+
+export type InputAssetDetails = InputAsset & {
+  parameters: ContentParameters
+}
+
+export type VideoFFProbeMetadata = {
+  width?: number
+  height?: number
+  codecName?: string
+  codecFullName?: string
+  duration?: number
+}
+
+export type VideoFileMetadata = VideoFFProbeMetadata & {
+  size: number
+  container: string
+  mimeType: string
+}
+
+export type VideoInputParameters = Omit<IVideoMetadata, 'video' | 'thumbnailPhoto'> & {
+  videoPath?: string
+  thumbnailPhotoPath?: string
+}
+
+export type ChannelInputParameters = Omit<IChannelMetadata, 'coverPhoto' | 'avatarPhoto'> & {
+  coverPhotoPath?: string
+  avatarPhotoPath?: string
+  rewardAccount?: string
+}
+
+export type ChannelCategoryInputParameters = IChannelCategoryMetadata
+
+export type VideoCategoryInputParameters = IVideoCategoryMetadata
+
+type AnyNonObject = string | number | boolean | any[] | Long
+
+// JSONSchema utility types
+export type JSONTypeName<T> = T extends string
+  ? 'string' | ['string', 'null']
+  : T extends number
+  ? 'number' | ['number', 'null']
+  : T extends boolean
+  ? 'boolean' | ['boolean', 'null']
+  : T extends any[]
+  ? 'array' | ['array', 'null']
+  : T extends Long
+  ? 'number' | ['number', 'null']
+  : 'object' | ['object', 'null']
+
+export type PropertySchema<P> = Omit<
+  JSONSchema7Definition & {
+    type: JSONTypeName<P>
+    properties: P extends AnyNonObject ? never : JsonSchemaProperties<P>
+  },
+  P extends AnyNonObject ? 'properties' : ''
+>
+
+export type JsonSchemaProperties<T> = {
+  [K in keyof Required<T>]: PropertySchema<Required<T>[K]>
+}
+
+export type JsonSchema<T> = JSONSchema7 & {
+  type: 'object'
+  properties: JsonSchemaProperties<T>
+}

+ 322 - 127
cli/src/base/AccountsCommandBase.ts

@@ -1,18 +1,31 @@
 import fs from 'fs'
 import path from 'path'
-import slug from 'slug'
 import inquirer from 'inquirer'
 import ExitCodes from '../ExitCodes'
 import { CLIError } from '@oclif/errors'
 import ApiCommandBase from './ApiCommandBase'
 import { Keyring } from '@polkadot/api'
 import { formatBalance } from '@polkadot/util'
-import { NamedKeyringPair } from '../Types'
+import { MemberDetails, NamedKeyringPair } from '../Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
-import { toFixedLength } from '../helpers/display'
+import { memberHandle, 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'
+import { createTestKeyring } from '@polkadot/keyring/testing'
+import chalk from 'chalk'
+import { mnemonicGenerate } from '@polkadot/util-crypto'
+import { validateAddress } from '../helpers/validation'
+import slug from 'slug'
+import { Membership } from '@joystream/types/members'
+import BN from 'bn.js'
 
 const ACCOUNTS_DIRNAME = 'accounts'
-const SPECIAL_ACCOUNT_POSTFIX = '__DEV'
+export const DEFAULT_ACCOUNT_TYPE = 'sr25519'
+export const KEYRING_OPTIONS: KeyringOptions = {
+  type: DEFAULT_ACCOUNT_TYPE,
+}
+export const STAKING_ACCOUNT_CANDIDATE_STAKE = new BN(200)
 
 /**
  * Abstract base class for account-related commands.
@@ -22,16 +35,37 @@ const SPECIAL_ACCOUNT_POSTFIX = '__DEV'
  * Where: APP_DATA_PATH is provided by StateAwareCommandBase and ACCOUNTS_DIRNAME is a const (see above).
  */
 export default abstract class AccountsCommandBase extends ApiCommandBase {
+  private keyring: KeyringInstance | undefined
+
+  getKeyring(): KeyringInstance {
+    if (!this.keyring) {
+      this.error('Trying to access Keyring before AccountsCommandBase initialization', {
+        exit: ExitCodes.UnexpectedException,
+      })
+    }
+    return this.keyring
+  }
+
+  isKeyAvailable(key: AccountId | string): boolean {
+    return this.getKeyring()
+      .getPairs()
+      .some((p) => p.address === key.toString())
+  }
+
   getAccountsDirPath(): string {
     return path.join(this.getAppDataPath(), ACCOUNTS_DIRNAME)
   }
 
-  getAccountFilePath(account: NamedKeyringPair, isSpecial = false): string {
-    return path.join(this.getAccountsDirPath(), this.generateAccountFilename(account, isSpecial))
+  getAccountFileName(accountName: string): string {
+    return `${slug(accountName)}.json`
+  }
+
+  getAccountFilePath(accountName: string): string {
+    return path.join(this.getAccountsDirPath(), this.getAccountFileName(accountName))
   }
 
-  generateAccountFilename(account: NamedKeyringPair, isSpecial = false): string {
-    return `${slug(account.meta.name, '_')}__${account.address}${isSpecial ? SPECIAL_ACCOUNT_POSTFIX : ''}.json`
+  isAccountNameTaken(accountName: string): boolean {
+    return this.getPairs().some((p) => this.getAccountFileName(p.meta.name) === this.getAccountFileName(accountName))
   }
 
   private initAccountsFs(): void {
@@ -40,23 +74,59 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  saveAccount(account: NamedKeyringPair, password: string, isSpecial = false): void {
-    try {
-      const destPath = this.getAccountFilePath(account, isSpecial)
-      fs.writeFileSync(destPath, JSON.stringify(account.toJson(password)))
-    } catch (e) {
-      throw this.createDataWriteError()
+  async createAccount(
+    name?: string,
+    masterKey?: KeyringPair,
+    password?: string,
+    type?: KeypairType
+  ): Promise<NamedKeyringPair> {
+    while (!name || this.isAccountNameTaken(name)) {
+      if (name) {
+        this.warn(`Account ${chalk.magentaBright(name)} already exists... Try different name`)
+      }
+      name = await this.simplePrompt({ message: 'New account name' })
     }
-  }
 
-  // Add dev "Alice" and "Bob" accounts
-  initSpecialAccounts() {
-    const keyring = new Keyring({ type: 'sr25519' })
-    keyring.addFromUri('//Alice', { name: 'Alice' })
-    keyring.addFromUri('//Bob', { name: 'Bob' })
-    keyring
-      .getPairs()
-      .forEach((pair) => this.saveAccount({ ...pair, meta: { name: pair.meta.name as string } }, '', true))
+    if (!masterKey) {
+      const keyring = new Keyring(KEYRING_OPTIONS)
+      const mnemonic = mnemonicGenerate()
+      keyring.addFromMnemonic(mnemonic, { name, whenCreated: Date.now() }, type)
+      masterKey = keyring.getPairs()[0]
+      this.log(chalk.magentaBright(`${chalk.bold('New account memonic: ')}${mnemonic}`))
+    } else {
+      const existingAcc = this.getPairs().find((p) => p.address === masterKey!.address)
+      if (existingAcc) {
+        this.error(`Account with this key already exists (${chalk.magentaBright(existingAcc.meta.name)})`, {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      await this.requestPairDecoding(masterKey, 'Current account password')
+      if (!masterKey.meta.name) {
+        masterKey.meta.name = name
+      }
+    }
+
+    while (password === undefined) {
+      password = await this.promptForPassword("Set new account's password")
+      const password2 = await this.promptForPassword("Confirm new account's password")
+
+      if (password !== password2) {
+        this.warn('Passwords are not the same!')
+        password = undefined
+      }
+    }
+    if (!password) {
+      this.warn('Using empty password is not recommended!')
+    }
+
+    const destPath = this.getAccountFilePath(name)
+    fs.writeFileSync(destPath, JSON.stringify(masterKey.toJson(password)))
+
+    this.getKeyring().addPair(masterKey)
+
+    this.log(chalk.greenBright(`\nNew account succesfully created!`))
+
+    return masterKey as NamedKeyringPair
   }
 
   fetchAccountFromJsonFile(jsonBackupFilePath: string): NamedKeyringPair {
@@ -76,18 +146,20 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
       throw new CLIError('Provided backup file is not valid', { exit: ExitCodes.InvalidFile })
     }
 
-    // Force some default account name if none is provided in the original backup
     if (!accountJsonObj.meta) accountJsonObj.meta = {}
-    if (!accountJsonObj.meta.name) accountJsonObj.meta.name = 'Unnamed Account'
+    // Normalize the CLI account name based on file name
+    // (makes sure getFilePath(name) will always point to the correct file, preserving backward-compatibility
+    // with older CLI versions)
+    accountJsonObj.meta.name = path.basename(jsonBackupFilePath, '.json')
 
-    const keyring = new Keyring()
+    const keyring = new Keyring(KEYRING_OPTIONS)
     let account: NamedKeyringPair
     try {
       // Try adding and retrieving the keys in order to validate that the backup file is correct
       keyring.addFromJson(accountJsonObj)
       account = keyring.getPair(accountJsonObj.address) as NamedKeyringPair // We can be sure it's named, because we forced it before
     } catch (e) {
-      throw new CLIError('Provided backup file is not valid', { exit: ExitCodes.InvalidFile })
+      throw new CLIError(`Provided backup file is not valid (${e.message})`, { exit: ExitCodes.InvalidFile })
     }
 
     return account
@@ -103,7 +175,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  fetchAccounts(includeSpecial = false): NamedKeyringPair[] {
+  fetchAccounts(): NamedKeyringPair[] {
     let files: string[] = []
     const accountDir = this.getAccountsDirPath()
     try {
@@ -116,151 +188,274 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     return files
       .map((fileName) => {
         const filePath = path.join(accountDir, fileName)
-        if (!includeSpecial && filePath.includes(SPECIAL_ACCOUNT_POSTFIX + '.')) return null
         return this.fetchAccountOrNullFromFile(filePath)
       })
-      .filter((accObj) => accObj !== null) as NamedKeyringPair[]
+      .filter((account) => account !== null) as NamedKeyringPair[]
   }
 
-  getSelectedAccountFilename(): string {
-    return this.getPreservedState().selectedAccountFilename
+  getPairs(includeDevAccounts = true): NamedKeyringPair[] {
+    return this.getKeyring()
+      .getPairs()
+      .filter((p) => includeDevAccounts || !p.meta.isTesting) as NamedKeyringPair[]
   }
 
-  getSelectedAccount(): NamedKeyringPair | null {
-    const selectedAccountFilename = this.getSelectedAccountFilename()
-
-    if (!selectedAccountFilename) {
-      return null
-    }
+  getPair(key: string): NamedKeyringPair {
+    return this.getKeyring().getPair(key) as NamedKeyringPair
+  }
 
-    const account = this.fetchAccountOrNullFromFile(path.join(this.getAccountsDirPath(), selectedAccountFilename))
+  async getDecodedPair(key: string): Promise<NamedKeyringPair> {
+    const pair = this.getPair(key)
 
-    return account
+    return (await this.requestPairDecoding(pair)) as NamedKeyringPair
   }
 
-  // Use when account usage is required in given command
-  async getRequiredSelectedAccount(promptIfMissing = true): Promise<NamedKeyringPair> {
-    let selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
-    if (!selectedAccount) {
-      if (!promptIfMissing) {
-        this.error('No default account selected! Use account:choose to set the default account.', {
-          exit: ExitCodes.NoAccountSelected,
-        })
-      }
+  async requestPairDecoding(pair: KeyringPair, message?: string): Promise<KeyringPair> {
+    // Skip if pair already unlocked
+    if (!pair.isLocked) {
+      return pair
+    }
 
-      const accounts: NamedKeyringPair[] = this.fetchAccounts()
-      if (!accounts.length) {
-        this.error('No accounts available! Use account:import in order to import accounts into the CLI.', {
-          exit: ExitCodes.NoAccountFound,
-        })
-      }
+    // First - try decoding using empty string
+    try {
+      pair.decodePkcs8('')
+      return pair
+    } catch (e) {
+      // Continue...
+    }
 
-      this.warn('No default account selected!')
-      selectedAccount = await this.promptForAccount(accounts)
-      await this.setSelectedAccount(selectedAccount)
+    let isPassValid = false
+    while (!isPassValid) {
+      try {
+        const password = await this.promptForPassword(
+          message || `Enter ${pair.meta.name ? pair.meta.name : pair.address} account password`
+        )
+        pair.decodePkcs8(password)
+        isPassValid = true
+      } catch (e) {
+        this.warn('Invalid password... Try again.')
+      }
     }
 
-    return selectedAccount
+    return pair
   }
 
-  async setSelectedAccount(account: NamedKeyringPair): Promise<void> {
-    const accountFilename = fs.existsSync(this.getAccountFilePath(account, true))
-      ? this.generateAccountFilename(account, true)
-      : this.generateAccountFilename(account)
-
-    await this.setPreservedState({ selectedAccountFilename: accountFilename })
+  initKeyring(): void {
+    this.keyring = this.getApi().isDevelopment ? createTestKeyring(KEYRING_OPTIONS) : new Keyring(KEYRING_OPTIONS)
+    const accounts = this.fetchAccounts()
+    accounts.forEach((a) => this.getKeyring().addPair(a))
   }
 
-  async promptForPassword(message = "Your account's password") {
-    const { password } = await inquirer.prompt([{ name: 'password', type: 'password', message }])
+  async promptForPassword(message = "Your account's password"): Promise<string> {
+    const { password } = await inquirer.prompt([
+      {
+        name: 'password',
+        type: 'password',
+        message,
+      },
+    ])
 
     return password
   }
 
-  async requireConfirmation(
-    message = 'Are you sure you want to execute this action?',
-    defaultVal = false
-  ): Promise<void> {
-    const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: defaultVal }])
-    if (!confirmed) this.exit(ExitCodes.OK)
-  }
-
   async promptForAccount(
-    accounts: NamedKeyringPair[],
-    defaultAccount: NamedKeyringPair | null = null,
     message = 'Select an account',
+    createIfUnavailable = true,
+    includeDevAccounts = true,
     showBalances = true
-  ): Promise<NamedKeyringPair> {
-    let balances: DeriveBalancesAll[]
+  ): Promise<string> {
+    const pairs = this.getPairs(includeDevAccounts)
+
+    if (!pairs.length) {
+      this.warn('No accounts available!')
+      if (createIfUnavailable) {
+        await this.requireConfirmation('Do you want to create a new account?', true)
+        pairs.push(await this.createAccount())
+      } else {
+        this.exit()
+      }
+    }
+
+    let balances: DeriveBalancesAll[] = []
     if (showBalances) {
-      balances = await this.getApi().getAccountsBalancesInfo(accounts.map((acc) => acc.address))
+      balances = await this.getApi().getAccountsBalancesInfo(pairs.map((p) => p.address))
     }
-    const longestAccNameLength: number = accounts.reduce((prev, curr) => Math.max(curr.meta.name.length, prev), 0)
-    const accNameColLength: number = Math.min(longestAccNameLength + 1, 20)
-    const { chosenAccountFilename } = await inquirer.prompt([
-      {
-        name: 'chosenAccountFilename',
-        message,
-        type: 'list',
-        choices: accounts.map((account: NamedKeyringPair, i) => ({
-          name:
-            `${toFixedLength(account.meta.name, accNameColLength)} | ` +
-            `${account.address} | ` +
-            ((showBalances || '') &&
-              `${formatBalance(balances[i].availableBalance)} / ` + `${formatBalance(balances[i].votingBalance)}`),
-          value: this.generateAccountFilename(account),
-          short: `${account.meta.name} (${account.address})`,
-        })),
-        default: defaultAccount && this.generateAccountFilename(defaultAccount),
-      },
-    ])
 
-    return accounts.find((acc) => this.generateAccountFilename(acc) === chosenAccountFilename) as NamedKeyringPair
+    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({
+      message,
+      type: 'list',
+      choices: pairs.map((p, i) => ({
+        name:
+          `${toFixedLength(p.meta.name, nameColLength)} | ` +
+          `${p.address} | ` +
+          ((showBalances || '') &&
+            `${formatBalance(balances[i].availableBalance)} / ` + `${formatBalance(balances[i].votingBalance)}`),
+        value: p.address,
+      })),
+    })
+
+    return chosenKey
   }
 
-  async requestAccountDecoding(account: NamedKeyringPair): Promise<void> {
-    // Skip if account already unlocked
-    if (!account.isLocked) {
-      return
-    }
+  promptForCustomAddress(): Promise<string> {
+    return this.simplePrompt({
+      message: 'Provide custom address',
+      validate: (a) => validateAddress(a),
+    })
+  }
 
-    // First - try decoding using empty string
-    try {
-      account.decodePkcs8('')
-      return
-    } catch (e) {
-      // Continue...
+  async promptForAnyAddress(message = 'Select an address'): Promise<string> {
+    const type: 'available' | 'new' | 'custom' = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices: [
+        { name: 'Available account', value: 'available' },
+        { name: 'New account', value: 'new' },
+        { name: 'Custom address', value: 'custom' },
+      ],
+    })
+
+    if (type === 'available') {
+      return this.promptForAccount()
+    } else if (type === 'new') {
+      return (await this.createAccount()).address
+    } else {
+      return this.promptForCustomAddress()
     }
+  }
 
-    let isPassValid = false
-    while (!isPassValid) {
-      try {
-        const password = await this.promptForPassword()
-        account.decodePkcs8(password)
-        isPassValid = true
-      } catch (e) {
-        this.warn('Invalid password... Try again.')
-      }
+  async getRequiredMemberContext(): Promise<MemberDetails> {
+    // TODO: Limit only to a set of members provided by the user?
+    const allMembers = await this.getApi().allMembers()
+    const availableMembers = await Promise.all(
+      allMembers
+        .filter(([, m]) => this.isKeyAvailable(m.controller_account.toString()))
+        .map(([id, m]) => this.getApi().memberDetails(id, m))
+    )
+
+    if (!availableMembers.length) {
+      this.error('No member controller key available!', { exit: ExitCodes.AccessDenied })
+    } else if (availableMembers.length === 1) {
+      return availableMembers[0]
+    } else {
+      return this.promptForMember(availableMembers, 'Choose member context')
     }
   }
 
-  async getRequiredMemberId(): Promise<number> {
-    const account = await this.getRequiredSelectedAccount()
-    const memberIds = await this.getApi().getMemberIdsByControllerAccount(account.address)
-    if (!memberIds.length) {
-      this.error('Membership required to access this command!', { exit: ExitCodes.AccessDenied })
+  async promptForMember(availableMembers: MemberDetails[], message = 'Choose a member'): Promise<MemberDetails> {
+    const memberIndex = await this.simplePrompt({
+      type: 'list',
+      message,
+      choices: availableMembers.map((m, i) => ({
+        name: memberHandle(m),
+        value: i,
+      })),
+    })
+
+    return availableMembers[memberIndex]
+  }
+
+  async promptForStakingAccount(stakeValue: BN, memberId: MemberId, member: Membership): Promise<string> {
+    this.log(`Required stake: ${formatBalance(stakeValue)}`)
+    let stakingAccount: string
+    while (true) {
+      stakingAccount = await this.promptForAnyAddress('Choose staking account')
+      const { balances } = await this.getApi().getAccountSummary(stakingAccount)
+      const stakingStatus = await this.getApi().stakingAccountStatus(stakingAccount)
+
+      if (balances.lockedBalance.gtn(0)) {
+        this.warn('This account is already used for other staking purposes, choose different account...')
+        continue
+      }
+
+      if (stakingStatus && !stakingStatus.member_id.eq(memberId)) {
+        this.warn('This account is already used as staking accout by other member, choose different account...')
+        continue
+      }
+
+      let additionalStakingAccountCosts = new BN(0)
+      if (!stakingStatus || (stakingStatus && stakingStatus.confirmed.isFalse)) {
+        if (!this.isKeyAvailable(stakingAccount)) {
+          this.warn(
+            'Account is not a confirmed staking account and cannot be directly accessed via CLI, choose different account...'
+          )
+          continue
+        }
+        this.warn(
+          `This account is not a confirmed staking account. ` +
+            `Additional funds (fees) may be required to set it as a staking account.`
+        )
+        if (!stakingStatus) {
+          additionalStakingAccountCosts = await this.getApi().estimateFee(
+            await this.getDecodedPair(stakingAccount),
+            this.getOriginalApi().tx.members.addStakingAccountCandidate(memberId)
+          )
+          additionalStakingAccountCosts = additionalStakingAccountCosts.add(STAKING_ACCOUNT_CANDIDATE_STAKE)
+        }
+      }
+
+      const requiredStakingAccountBalance = stakeValue.add(additionalStakingAccountCosts)
+      const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
+      if (missingStakingAccountBalance.gtn(0)) {
+        this.warn(
+          `Not enough available staking account balance! Missing: ${chalk.cyan(
+            formatBalance(missingStakingAccountBalance)
+          )}.` +
+            (additionalStakingAccountCosts.gtn(0)
+              ? ` (includes ${formatBalance(
+                  additionalStakingAccountCosts
+                )} which is a required fee and candidate stake for adding a new staking account)`
+              : '')
+        )
+        const transferTokens = await this.simplePrompt({
+          type: 'confirm',
+          message: `Do you want to transfer ${chalk.cyan(
+            formatBalance(missingStakingAccountBalance)
+          )} from another account?`,
+        })
+        if (transferTokens) {
+          const key = await this.promptForAccount('Choose source account')
+          await this.sendAndFollowNamedTx(await this.getDecodedPair(key), 'balances', 'transferKeepAlive', [
+            stakingAccount,
+            missingStakingAccountBalance,
+          ])
+        } else {
+          continue
+        }
+      }
+
+      if (!stakingStatus) {
+        await this.sendAndFollowNamedTx(
+          await this.getDecodedPair(stakingAccount),
+          'members',
+          'addStakingAccountCandidate',
+          [memberId]
+        )
+      }
+
+      if (!stakingStatus || stakingStatus.confirmed.isFalse) {
+        await this.sendAndFollowNamedTx(
+          await this.getDecodedPair(member.controller_account.toString()),
+          'members',
+          'confirmStakingAccount',
+          [memberId, stakingAccount]
+        )
+      }
+
+      break
     }
 
-    return memberIds[0].toNumber() // FIXME: Temporary solution (just using the first one)
+    return stakingAccount
   }
 
   async init() {
     await super.init()
     try {
       this.initAccountsFs()
-      this.initSpecialAccounts()
     } catch (e) {
       throw this.createDataDirInitError()
     }
+    await this.initKeyring()
   }
 }

+ 153 - 48
cli/src/base/ApiCommandBase.ts

@@ -2,20 +2,22 @@ import ExitCodes from '../ExitCodes'
 import { CLIError } from '@oclif/errors'
 import StateAwareCommandBase from './StateAwareCommandBase'
 import Api from '../Api'
-import { getTypeDef, Option, Tuple, TypeRegistry } from '@polkadot/types'
-import { Registry, Codec, CodecArg, TypeDef, TypeDefInfo } from '@polkadot/types/types'
-
+import { getTypeDef, Option, Tuple } from '@polkadot/types'
+import { Registry, Codec, TypeDef, TypeDefInfo, IEvent } from '@polkadot/types/types'
 import { Vec, Struct, Enum } from '@polkadot/types/codec'
-import { ApiPromise, WsProvider } from '@polkadot/api'
+import { SubmittableResult, WsProvider } 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 } from '../Types'
 import { createParamOptions } from '../helpers/promptOptions'
-import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { AugmentedSubmittables, SubmittableExtrinsic, AugmentedEvents, AugmentedEvent } from '@polkadot/api/types'
 import { DistinctQuestion } from 'inquirer'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 import { DispatchError } from '@polkadot/types/interfaces/system'
+import { formatBalance } from '@polkadot/util'
+import BN from 'bn.js'
+import _ from 'lodash'
 
 export class ExtrinsicFailedError extends Error {}
 
@@ -24,18 +26,21 @@ export class ExtrinsicFailedError extends Error {}
  */
 export default abstract class ApiCommandBase extends StateAwareCommandBase {
   private api: Api | null = null
-  forceSkipApiUriPrompt = false
 
   getApi(): Api {
     if (!this.api) throw new CLIError('Tried to get API before initialization.', { exit: ExitCodes.ApiError })
     return this.api
   }
 
-  // Get original api for lower-level api calls
-  getOriginalApi(): ApiPromise {
+  // Shortcuts
+  getOriginalApi() {
     return this.getApi().getOriginalApi()
   }
 
+  getUnaugmentedApi() {
+    return this.getApi().getUnaugmentedApi()
+  }
+
   getTypesRegistry(): Registry {
     return this.getOriginalApi().registry
   }
@@ -44,30 +49,39 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return this.getOriginalApi().createType(typeName, value)
   }
 
-  async init() {
+  async init(skipConnection = false): Promise<void> {
     await super.init()
-    let apiUri: string = this.getPreservedState().apiUri
-    if (!apiUri) {
-      this.warn("You haven't provided a node/endpoint for the CLI to connect to yet!")
-      apiUri = await this.promptForApiUri()
-    }
+    if (!skipConnection) {
+      let apiUri: string = this.getPreservedState().apiUri
+
+      if (!apiUri) {
+        this.warn("You haven't provided a Joystream node websocket api uri for the CLI to connect to yet!")
+        apiUri = await this.promptForApiUri()
+      }
+
+      let queryNodeUri: string = this.getPreservedState().queryNodeUri
+      if (!queryNodeUri) {
+        this.warn("You haven't provided a Joystream query node uri for the CLI to connect to yet!")
+        queryNodeUri = await this.promptForQueryNodeUri()
+      }
 
-    const { metadataCache } = this.getPreservedState()
-    this.api = await Api.create(apiUri, metadataCache)
+      const { metadataCache } = this.getPreservedState()
+      this.api = await Api.create(apiUri, metadataCache, queryNodeUri === 'none' ? undefined : queryNodeUri)
 
-    const { genesisHash, runtimeVersion } = this.getOriginalApi()
-    const metadataKey = `${genesisHash}-${runtimeVersion.specVersion}`
-    if (!metadataCache[metadataKey]) {
-      // Add new entry to metadata cache
-      metadataCache[metadataKey] = await this.getOriginalApi().runtimeMetadata.toJSON()
-      await this.setPreservedState({ metadataCache })
+      const { genesisHash, runtimeVersion } = this.getOriginalApi()
+      const metadataKey = `${genesisHash}-${runtimeVersion.specVersion}`
+      if (!metadataCache[metadataKey]) {
+        // Add new entry to metadata cache
+        metadataCache[metadataKey] = await this.getOriginalApi().runtimeMetadata.toJSON()
+        await this.setPreservedState({ metadataCache })
+      }
     }
   }
 
   async promptForApiUri(): Promise<string> {
     let selectedNodeUri = await this.simplePrompt({
       type: 'list',
-      message: 'Choose a node/endpoint:',
+      message: 'Choose a node websocket api uri:',
       choices: [
         {
           name: 'Local node (ws://localhost:9944)',
@@ -101,6 +115,47 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return selectedNodeUri
   }
 
+  async promptForQueryNodeUri(): Promise<string> {
+    let selectedUri = await this.simplePrompt({
+      type: 'list',
+      message: 'Choose a query node endpoint:',
+      choices: [
+        {
+          name: 'Local query node (http://localhost:8081/graphql)',
+          value: 'http://localhost:8081/graphql',
+        },
+        {
+          name: 'Jsgenesis-hosted query node (https://hydra.joystream.org/graphql)',
+          value: 'https://hydra.joystream.org/graphql',
+        },
+        {
+          name: 'Custom endpoint',
+          value: '',
+        },
+        {
+          name: "No endpoint (if you don't use query node some features will not be available)",
+          value: 'none',
+        },
+      ],
+    })
+
+    if (!selectedUri) {
+      do {
+        selectedUri = await this.simplePrompt({
+          type: 'input',
+          message: 'Provide a query node endpoint',
+        })
+        if (!this.isApiUriValid(selectedUri)) {
+          this.warn('Provided uri seems incorrect! Please try again...')
+        }
+      } while (!this.isApiUriValid(selectedUri))
+    }
+
+    await this.setPreservedState({ queryNodeUri: selectedUri })
+
+    return selectedUri
+  }
+
   isApiUriValid(uri: string) {
     try {
       // eslint-disable-next-line no-new
@@ -111,6 +166,17 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return true
   }
 
+  isQueryNodeUriValid(uri: string) {
+    let url: URL
+    try {
+      url = new URL(uri)
+    } catch (_) {
+      return false
+    }
+
+    return url.protocol === 'http:' || url.protocol === 'https:'
+  }
+
   // This is needed to correctly handle some structs, enums etc.
   // Where the main typeDef doesn't provide enough information
   protected getRawTypeDef(type: keyof InterfaceTypes) {
@@ -124,7 +190,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return (
       '{\n' +
       Object.keys(obj)
-        .map((prop) => `  ${prop}${chalk.white(':' + obj[prop])}`)
+        .map((prop) => `  ${prop}${chalk.magentaBright(':' + obj[prop])}`)
         .join('\n') +
       '\n}'
     )
@@ -332,7 +398,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     method: string,
     paramsOptions?: ApiParamsOptions
   ): Promise<ApiMethodArg[]> {
-    const extrinsicMethod = this.getOriginalApi().tx[module][method]
+    const extrinsicMethod = (await this.getUnaugmentedApi().tx)[module][method]
     const values: ApiMethodArg[] = []
 
     this.openIndentGroup()
@@ -350,7 +416,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return values
   }
 
-  sendExtrinsic(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>) {
+  sendExtrinsic(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>): Promise<SubmittableResult> {
     return new Promise((resolve, reject) => {
       let unsubscribe: () => void
       tx.signAndSend(account, {}, (result) => {
@@ -369,12 +435,8 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
                 let errorMsg = dispatchError.toString()
                 if (dispatchError.isModule) {
                   try {
-                    // Need to assert that registry is of TypeRegistry type, since Registry intefrace
-                    // seems outdated and doesn't include DispatchErrorModule as possible argument for "findMetaError"
-                    const { name, documentation } = (this.getOriginalApi().registry as TypeRegistry).findMetaError(
-                      dispatchError.asModule
-                    )
-                    errorMsg = `${name} (${documentation})`
+                    const { name, docs } = this.getOriginalApi().registry.findMetaError(dispatchError.asModule)
+                    errorMsg = `${name} (${docs.join(', ')})`
                   } catch (e) {
                     // This probably means we don't have this error in the metadata
                     // In this case - continue (we'll just display dispatchError.toString())
@@ -382,7 +444,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
                 }
                 reject(new ExtrinsicFailedError(`Extrinsic execution error: ${errorMsg}`))
               } else if (event.method === 'ExtrinsicSuccess') {
-                resolve()
+                resolve(result)
               }
             })
         } else if (result.isError) {
@@ -400,11 +462,18 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     account: KeyringPair,
     tx: SubmittableExtrinsic<'promise'>,
     warnOnly = false // If specified - only warning will be displayed in case of failure (instead of error beeing thrown)
-  ): Promise<boolean> {
+  ): Promise<SubmittableResult | false> {
+    // Calculate fee and ask for confirmation
+    const fee = await this.getApi().estimateFee(account, tx)
+
+    await this.requireConfirmation(
+      `Tx fee of ${chalk.cyan(formatBalance(fee))} will be deduced from you account, do you confirm the transfer?`
+    )
+
     try {
-      await this.sendExtrinsic(account, tx)
+      const res = await this.sendExtrinsic(account, tx)
       this.log(chalk.green(`Extrinsic successful!`))
-      return true
+      return res
     } catch (e) {
       if (e instanceof ExtrinsicFailedError && warnOnly) {
         this.warn(`Extrinsic failed! ${e.message}`)
@@ -417,27 +486,63 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
   }
 
-  async sendAndFollowNamedTx(
+  private humanize(p: unknown): any {
+    if (Array.isArray(p)) {
+      return p.map((v) => this.humanize(v))
+    } else if (typeof p === 'object' && p !== null) {
+      if ((p as any).toHuman) {
+        return (p as Codec).toHuman()
+      } else if (p instanceof BN) {
+        return p.toString()
+      } else {
+        return _.mapValues(p, this.humanize.bind(this))
+      }
+    }
+
+    return p
+  }
+
+  async sendAndFollowNamedTx<
+    Module extends keyof AugmentedSubmittables<'promise'>,
+    Method extends keyof AugmentedSubmittables<'promise'>[Module] & string,
+    Submittable extends AugmentedSubmittables<'promise'>[Module][Method]
+  >(
     account: KeyringPair,
-    module: string,
-    method: string,
-    params: CodecArg[],
+    module: Module,
+    method: Method,
+    params: Submittable extends (...args: any[]) => any ? Parameters<Submittable> : [],
     warnOnly = false
-  ): Promise<boolean> {
-    this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
-    const tx = await this.getOriginalApi().tx[module][method](...params)
+  ): Promise<SubmittableResult | false> {
+    this.log(
+      chalk.magentaBright(
+        `\nSending ${module}.${method} extrinsic from ${account.meta.name ? account.meta.name : account.address}...`
+      )
+    )
+    console.log('Params:', this.humanize(params))
+    const tx = await this.getUnaugmentedApi().tx[module][method](...params)
     return await this.sendAndFollowTx(account, tx, warnOnly)
   }
 
-  async buildAndSendExtrinsic(
+  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
+  }
+
+  async buildAndSendExtrinsic<
+    Module extends keyof AugmentedSubmittables<'promise'>,
+    Method extends keyof AugmentedSubmittables<'promise'>[Module] & string
+  >(
     account: KeyringPair,
-    module: string,
-    method: string,
+    module: Module,
+    method: Method,
     paramsOptions?: ApiParamsOptions,
     warnOnly = false // If specified - only warning will be displayed (instead of error beeing thrown)
   ): Promise<ApiMethodArg[]> {
     const params = await this.promptForExtrinsicParams(module, method, paramsOptions)
-    await this.sendAndFollowNamedTx(account, module, method, params, warnOnly)
+    await this.sendAndFollowNamedTx(account, module, method, params as any, warnOnly)
 
     return params
   }
@@ -445,7 +550,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodNamedArgs {
     let draftJSONObj
     const parsedArgs: ApiMethodNamedArgs = []
-    const extrinsicMethod = this.getOriginalApi().tx[module][method]
+    const extrinsicMethod = this.getUnaugmentedApi().tx[module][method]
     try {
       // eslint-disable-next-line @typescript-eslint/no-var-requires
       draftJSONObj = require(draftFilePath)

+ 105 - 360
cli/src/base/ContentDirectoryCommandBase.ts

@@ -1,35 +1,19 @@
 import ExitCodes from '../ExitCodes'
 import { WorkingGroups } from '../Types'
-import { ReferenceProperty } from '@joystream/cd-schemas/types/extrinsics/AddClassSchema'
-import { FlattenRelations } from '@joystream/cd-schemas/types/utility'
-import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
-import {
-  Class,
-  ClassId,
-  CuratorGroup,
-  CuratorGroupId,
-  Entity,
-  EntityId,
-  Actor,
-  PropertyType,
-  Property,
-} from '@joystream/types/content-directory'
+import { CuratorGroup, CuratorGroupId, ContentActor, Channel } from '@joystream/types/content'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
-import { Codec } from '@polkadot/types/types'
-import AbstractInt from '@polkadot/types/codec/AbstractInt'
-import { AnyJson } from '@polkadot/types/types/helpers'
-import _ from 'lodash'
 import { RolesCommandBase } from './WorkingGroupsCommandBase'
-import { createType } from '@joystream/types'
-import chalk from 'chalk'
 import { flags } from '@oclif/command'
-import { DistinctQuestion } from 'inquirer'
+import { memberHandle } from '../helpers/display'
 
 const CONTEXTS = ['Member', 'Curator', 'Lead'] as const
-type Context = typeof CONTEXTS[number]
+const OWNER_CONTEXTS = ['Member', 'Curator'] as const
+const CATEGORIES_CONTEXTS = ['Lead', 'Curator'] as const
 
-type ParsedPropertyValue = { value: Codec | null; type: PropertyType['type']; subtype: PropertyType['subtype'] }
+type Context = typeof CONTEXTS[number]
+type OwnerContext = typeof OWNER_CONTEXTS[number]
+type CategoriesContext = typeof CATEGORIES_CONTEXTS[number]
 
 /**
  * Abstract base class for commands related to content directory
@@ -44,6 +28,20 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     options: [...CONTEXTS],
   })
 
+  static ownerContextFlag = flags.enum({
+    name: 'ownerContext',
+    required: false,
+    description: `Actor context to execute the command in (${OWNER_CONTEXTS.join('/')})`,
+    options: [...OWNER_CONTEXTS],
+  })
+
+  static categoriesContextFlag = flags.enum({
+    name: 'categoriesContext',
+    required: false,
+    description: `Actor context to execute the command in (${CATEGORIES_CONTEXTS.join('/')})`,
+    options: [...CATEGORIES_CONTEXTS],
+  })
+
   async promptForContext(message = 'Choose in which context you wish to execute the command'): Promise<Context> {
     return this.simplePrompt({
       message,
@@ -52,63 +50,94 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     })
   }
 
+  async promptForOwnerContext(
+    message = 'Choose in which context you wish to execute the command'
+  ): Promise<OwnerContext> {
+    return this.simplePrompt({
+      message,
+      type: 'list',
+      choices: OWNER_CONTEXTS.map((c) => ({ name: c, value: c })),
+    })
+  }
+
+  async promptForCategoriesContext(
+    message = 'Choose in which context you wish to execute the command'
+  ): Promise<CategoriesContext> {
+    return this.simplePrompt({
+      message,
+      type: 'list',
+      choices: CATEGORIES_CONTEXTS.map((c) => ({ name: c, value: c })),
+    })
+  }
+
   // Use when lead access is required in given command
   async requireLead(): Promise<void> {
-    await this.getRequiredLead()
+    await this.getRequiredLeadContext()
   }
 
-  async getCuratorContext(classNames: string[] = []): Promise<Actor> {
-    const curator = await this.getRequiredWorker()
-    const classes = await Promise.all(classNames.map(async (cName) => (await this.classEntryByNameOrId(cName))[1]))
-    const classMaintainers = classes.map(({ class_permissions: permissions }) => permissions.maintainers.toArray())
-
-    const groups = await this.getApi().availableCuratorGroups()
-    const availableGroupIds = groups
-      .filter(
-        ([groupId, group]) =>
-          group.active.valueOf() &&
-          classMaintainers.every((maintainers) => maintainers.some((m) => m.eq(groupId))) &&
-          group.curators.toArray().some((curatorId) => curatorId.eq(curator.workerId))
-      )
-      .map(([id]) => id)
+  async getCurationActorByChannel(channel: Channel): Promise<[ContentActor, string]> {
+    return channel.owner.isOfType('Curators')
+      ? await this.getContentActor('Lead')
+      : await this.getContentActor('Curator')
+  }
 
-    let groupId: number
-    if (!availableGroupIds.length) {
-      this.error(
-        'You do not have the required maintainer access to at least one of the following classes: ' +
-          classNames.join(', '),
-        { exit: ExitCodes.AccessDenied }
-      )
-    } else if (availableGroupIds.length === 1) {
-      groupId = availableGroupIds[0].toNumber()
+  async getChannelOwnerActor(channel: Channel): Promise<[ContentActor, string]> {
+    if (channel.owner.isOfType('Curators')) {
+      try {
+        return await this.getContentActor('Lead')
+      } catch (e) {
+        return await this.getCuratorContext(channel.owner.asType('Curators'))
+      }
     } else {
-      groupId = await this.promptForCuratorGroup('Select Curator Group context', availableGroupIds)
+      return await this.getContentActor('Member')
     }
-
-    return createType('Actor', { Curator: [groupId, curator.workerId.toNumber()] })
   }
 
-  async promptForClass(message = 'Select a class'): Promise<Class> {
-    const classes = await this.getApi().availableClasses()
-    const choices = classes.map(([, c]) => ({ name: c.name.toString(), value: c }))
-    if (!choices.length) {
-      this.warn('No classes exist to choose from!')
-      this.exit(ExitCodes.InvalidInput)
+  async getCategoryManagementActor(): Promise<[ContentActor, string]> {
+    try {
+      return await this.getContentActor('Lead')
+    } catch (e) {
+      return await this.getContentActor('Curator')
     }
-
-    const selectedClass = await this.simplePrompt({ message, type: 'list', choices })
-
-    return selectedClass
   }
 
-  async classEntryByNameOrId(classNameOrId: string): Promise<[ClassId, Class]> {
-    const classes = await this.getApi().availableClasses()
-    const foundClass = classes.find(([id, c]) => id.toString() === classNameOrId || c.name.toString() === classNameOrId)
-    if (!foundClass) {
-      this.error(`Class id not found by class name or id: "${classNameOrId}"!`)
+  async getCuratorContext(requiredGroupId?: CuratorGroupId): Promise<[ContentActor, string]> {
+    const curator = await this.getRequiredWorkerContext()
+
+    let groupId: number
+    if (requiredGroupId) {
+      const group = await this.getCuratorGroup(requiredGroupId.toNumber())
+      if (!group.active.valueOf()) {
+        this.error(`Curator group ${requiredGroupId.toString()} is no longer active`, { exit: ExitCodes.AccessDenied })
+      }
+      if (!Array.from(group.curators).some((curatorId) => curatorId.eq(curator.workerId))) {
+        this.error(`You don't belong to required curator group (ID: ${requiredGroupId.toString()})`, {
+          exit: ExitCodes.AccessDenied,
+        })
+      }
+      groupId = requiredGroupId.toNumber()
+    } else {
+      const groups = await this.getApi().availableCuratorGroups()
+      const availableGroupIds = groups
+        .filter(
+          ([, group]) =>
+            group.active.valueOf() && Array.from(group.curators).some((curatorId) => curatorId.eq(curator.workerId))
+        )
+        .map(([id]) => id)
+
+      if (!availableGroupIds.length) {
+        this.error("You don't belong to any active curator group!", { exit: ExitCodes.AccessDenied })
+      } else if (availableGroupIds.length === 1) {
+        groupId = availableGroupIds[0].toNumber()
+      } else {
+        groupId = await this.promptForCuratorGroup('Select Curator Group context', availableGroupIds)
+      }
     }
 
-    return foundClass
+    return [
+      this.createType('ContentActor', { Curator: [groupId, curator.workerId.toNumber()] }),
+      curator.roleAccount.toString(),
+    ]
   }
 
   private async curatorGroupChoices(ids?: CuratorGroupId[]) {
@@ -119,8 +148,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
         name:
           `Group ${id.toString()} (` +
           `${group.active.valueOf() ? 'Active' : 'Inactive'}, ` +
-          `${group.curators.toArray().length} member(s), ` +
-          `${group.number_of_classes_maintained.toNumber()} classes maintained)`,
+          `${Array.from(group.curators).length} member(s)), `,
         value: id.toNumber(),
       }))
   }
@@ -146,18 +174,12 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     return selectedIds
   }
 
-  async promptForClassReference(): Promise<ReferenceProperty['Reference']> {
-    const selectedClass = await this.promptForClass()
-    const sameOwner = await this.simplePrompt({ message: 'Same owner required?', ...BOOL_PROMPT_OPTIONS })
-    return { className: selectedClass.name.toString(), sameOwner }
-  }
-
   async promptForCurator(message = 'Choose a Curator', ids?: number[]): Promise<number> {
     const curators = await this.getApi().groupMembers(WorkingGroups.Curators)
     const choices = curators
       .filter((c) => (ids ? ids.includes(c.workerId.toNumber()) : true))
       .map((c) => ({
-        name: `${c.profile.handle.toString()} (Worker ID: ${c.workerId})`,
+        name: `${memberHandle(c.profile)} (Worker ID: ${c.workerId})`,
         value: c.workerId.toNumber(),
       }))
 
@@ -207,295 +229,18 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     return group
   }
 
-  async getEntity(
-    id: string | number,
-    requiredClass?: string,
-    ownerMemberId?: number,
-    requireSchema = true
-  ): Promise<Entity> {
-    if (typeof id === 'string') {
-      id = parseInt(id)
-    }
-
-    const entity = await this.getApi().entityById(id)
-
-    if (!entity) {
-      this.error(`Entity not found by id: ${id}`, { exit: ExitCodes.InvalidInput })
-    }
-
-    if (requiredClass) {
-      const [classId] = await this.classEntryByNameOrId(requiredClass)
-      if (entity.class_id.toNumber() !== classId.toNumber()) {
-        this.error(`Entity of id ${id} is not of class ${requiredClass}!`, { exit: ExitCodes.InvalidInput })
-      }
-    }
-
-    const { controller } = entity.entity_permissions
-    if (
-      ownerMemberId !== undefined &&
-      (!controller.isOfType('Member') || controller.asType('Member').toNumber() !== ownerMemberId)
-    ) {
-      this.error('Cannot execute this action for specified entity - invalid ownership.', {
-        exit: ExitCodes.AccessDenied,
-      })
-    }
-
-    if (requireSchema && !entity.supported_schemas.toArray().length) {
-      this.error(`${requiredClass || ''} entity of id ${id} has no schema support added!`)
-    }
-
-    return entity
-  }
-
-  async getAndParseKnownEntity<T>(id: string | number, className?: string): Promise<FlattenRelations<T>> {
-    const entity = await this.getEntity(id, className)
-    return this.parseToEntityJson<T>(entity)
-  }
-
-  async entitiesByClassAndOwner(classNameOrId: number | string, ownerMemberId?: number): Promise<[EntityId, Entity][]> {
-    const classId =
-      typeof classNameOrId === 'number' ? classNameOrId : (await this.classEntryByNameOrId(classNameOrId))[0].toNumber()
-
-    return (await this.getApi().entitiesByClassId(classId)).filter(([, entity]) => {
-      const controller = entity.entity_permissions.controller
-      return ownerMemberId !== undefined
-        ? controller.isOfType('Member') && controller.asType('Member').toNumber() === ownerMemberId
-        : true
-    })
-  }
-
-  async promptForEntityEntry(
-    message: string,
-    className: string,
-    propName?: string,
-    ownerMemberId?: number,
-    defaultId?: number | null
-  ): Promise<[EntityId, Entity]> {
-    const [classId, entityClass] = await this.classEntryByNameOrId(className)
-    const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
-
-    if (!entityEntries.length) {
-      this.log(`${message}:`)
-      this.error(`No choices available! Exiting...`, { exit: ExitCodes.UnexpectedException })
-    }
-
-    const choosenEntityId = await this.simplePrompt({
-      message,
-      type: 'list',
-      choices: entityEntries.map(([id, entity]) => {
-        const parsedEntityPropertyValues = this.parseEntityPropertyValues(entity, entityClass)
-        return {
-          name: (propName && parsedEntityPropertyValues[propName]?.value?.toString()) || `ID:${id.toString()}`,
-          value: id.toString(), // With numbers there are issues with "default"
-        }
-      }),
-      default: typeof defaultId === 'number' ? defaultId.toString() : undefined,
-    })
-
-    return entityEntries.find(([id]) => choosenEntityId === id.toString())!
-  }
-
-  async promptForEntityId(
-    message: string,
-    className: string,
-    propName?: string,
-    ownerMemberId?: number,
-    defaultId?: number | null
-  ): Promise<number> {
-    return (await this.promptForEntityEntry(message, className, propName, ownerMemberId, defaultId))[0].toNumber()
-  }
-
-  parseStoredPropertyInnerValue(value: Codec | null): AnyJson {
-    if (value === null) {
-      return null
-    }
-
-    if (value instanceof AbstractInt) {
-      return value.toNumber() // Integers (signed ones) are by default converted to hex when using .toJson()
-    }
-
-    return value.toJSON()
-  }
-
-  parseEntityPropertyValues(
-    entity: Entity,
-    entityClass: Class,
-    includedProperties?: string[]
-  ): Record<string, ParsedPropertyValue> {
-    const { properties } = entityClass
-    return Array.from(entity.getField('values').entries()).reduce((columns, [propId, propValue]) => {
-      const prop = properties[propId.toNumber()]
-      const propName = prop.name.toString()
-      const included = !includedProperties || includedProperties.some((p) => p.toLowerCase() === propName.toLowerCase())
-      const { type: propType, subtype: propSubtype } = prop.property_type
-
-      if (included) {
-        columns[propName] = {
-          // If type doesn't match (Boolean(false) for optional fields case) - use "null" as value
-          value: propType !== propValue.type || propSubtype !== propValue.subtype ? null : propValue.getValue(),
-          type: propType,
-          subtype: propSubtype,
-        }
-      }
-      return columns
-    }, {} as Record<string, ParsedPropertyValue>)
-  }
-
-  async parseToEntityJson<T = unknown>(entity: Entity): Promise<FlattenRelations<T>> {
-    const entityClass = (await this.classEntryByNameOrId(entity.class_id.toString()))[1]
-    return (_.mapValues(this.parseEntityPropertyValues(entity, entityClass), (v) =>
-      this.parseStoredPropertyInnerValue(v.value)
-    ) as unknown) as FlattenRelations<T>
-  }
-
-  async createEntityList(
-    className: string,
-    includedProps?: string[],
-    filters: [string, string][] = [],
-    ownerMemberId?: number
-  ): Promise<Record<string, string>[]> {
-    const [classId, entityClass] = await this.classEntryByNameOrId(className)
-    // Create object of default "[not set]" values (prevents breaking the table if entity has no schema support)
-    const defaultValues = entityClass.properties
-      .map((p) => p.name.toString())
-      .reduce((d, propName) => {
-        if (!includedProps || includedProps.includes(propName)) {
-          d[propName] = chalk.grey('[not set]')
-        }
-        return d
-      }, {} as Record<string, string>)
-
-    const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
-    const parsedEntities = (await Promise.all(
-      entityEntries.map(([id, entity]) => ({
-        'ID': id.toString(),
-        ...defaultValues,
-        ..._.mapValues(this.parseEntityPropertyValues(entity, entityClass, includedProps), (v) =>
-          v.value === null ? chalk.grey('[not set]') : v.value.toString()
-        ),
-      }))
-    )) as Record<string, string>[]
-
-    return parsedEntities.filter((entity) => filters.every(([pName, pValue]) => entity[pName] === pValue))
-  }
-
-  async getActor(context: typeof CONTEXTS[number], pickedClass: Class) {
-    let actor: Actor
+  async getContentActor(context: typeof CONTEXTS[number]): Promise<[ContentActor, string]> {
+    let contentActorContext: [ContentActor, string]
     if (context === 'Member') {
-      const memberId = await this.getRequiredMemberId()
-      actor = this.createType('Actor', { Member: memberId })
+      const { id, membership } = await this.getRequiredMemberContext()
+      contentActorContext = [this.createType('ContentActor', { Member: id }), membership.controller_account.toString()]
     } else if (context === 'Curator') {
-      actor = await this.getCuratorContext([pickedClass.name.toString()])
+      contentActorContext = await this.getCuratorContext()
     } else {
-      await this.getRequiredLead()
-
-      actor = this.createType('Actor', { Lead: null })
+      const lead = await this.getRequiredLeadContext()
+      contentActorContext = [this.createType('ContentActor', { Lead: null }), lead.roleAccount.toString()]
     }
 
-    return actor
-  }
-
-  isActorEntityController(actor: Actor, entity: Entity, isMaintainer: boolean): boolean {
-    const entityController = entity.entity_permissions.controller
-    return (
-      (isMaintainer && entityController.isOfType('Maintainers')) ||
-      (entityController.isOfType('Member') &&
-        actor.isOfType('Member') &&
-        entityController.asType('Member').eq(actor.asType('Member'))) ||
-      (entityController.isOfType('Lead') && actor.isOfType('Lead'))
-    )
-  }
-
-  async isEntityPropertyEditableByActor(entity: Entity, classPropertyId: number, actor: Actor): Promise<boolean> {
-    const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
-
-    const isActorMaintainer =
-      actor.isOfType('Curator') &&
-      entityClass.class_permissions.maintainers.toArray().some((groupId) => groupId.eq(actor.asType('Curator')[0]))
-
-    const isActorController = this.isActorEntityController(actor, entity, isActorMaintainer)
-
-    const {
-      is_locked_from_controller: isLockedFromController,
-      is_locked_from_maintainer: isLockedFromMaintainer,
-    } = entityClass.properties[classPropertyId].locking_policy
-
-    return (
-      (isActorController && !isLockedFromController.valueOf()) ||
-      (isActorMaintainer && !isLockedFromMaintainer.valueOf())
-    )
-  }
-
-  getQuestionsFromProperties(properties: Property[], defaults?: { [key: string]: unknown }): DistinctQuestion[] {
-    return properties.reduce((previousValue, { name, property_type: propertyType, required }) => {
-      const propertySubtype = propertyType.subtype
-      const questionType = propertySubtype === 'Bool' ? 'list' : 'input'
-      const isSubtypeNumber = propertySubtype.toLowerCase().includes('int')
-      const isSubtypeReference = propertyType.isOfType('Single') && propertyType.asType('Single').isOfType('Reference')
-
-      const validate = async (answer: string | number | null) => {
-        if (answer === null) {
-          return true // Can only happen through "filter" if property is not required
-        }
-
-        if ((isSubtypeNumber || isSubtypeReference) && parseInt(answer.toString()).toString() !== answer.toString()) {
-          return `Expected integer value!`
-        }
-
-        if (isSubtypeReference) {
-          try {
-            await this.getEntity(+answer, propertyType.asType('Single').asType('Reference')[0].toString())
-          } catch (e) {
-            return e.message || JSON.stringify(e)
-          }
-        }
-
-        return true
-      }
-
-      const optionalQuestionProperties = {
-        ...{
-          filter: async (answer: string) => {
-            if (required.isFalse && !answer) {
-              return null
-            }
-
-            // Only cast to number if valid
-            // Prevents inquirer bug not allowing to edit invalid values when casted to number
-            // See: https://github.com/SBoudrias/Inquirer.js/issues/866
-            if ((isSubtypeNumber || isSubtypeReference) && (await validate(answer)) === true) {
-              return parseInt(answer)
-            }
-
-            return answer
-          },
-          validate,
-        },
-        ...(propertySubtype === 'Bool' && {
-          choices: ['true', 'false'],
-          filter: (answer: string) => {
-            return answer === 'true' || false
-          },
-        }),
-      }
-
-      const isQuestionOptional = propertySubtype === 'Bool' ? '' : required.isTrue ? '(required)' : '(optional)'
-      const classId = isSubtypeReference
-        ? ` [Class Id: ${propertyType.asType('Single').asType('Reference')[0].toString()}]`
-        : ''
-
-      return [
-        ...previousValue,
-        {
-          name: name.toString(),
-          message: `${name} - ${propertySubtype}${classId} ${isQuestionOptional}`,
-          type: questionType,
-          ...optionalQuestionProperties,
-          ...(defaults && {
-            default: propertySubtype === 'Bool' ? JSON.stringify(defaults[name.toString()]) : defaults[name.toString()],
-          }),
-        },
-      ]
-    }, [] as DistinctQuestion[])
+    return contentActorContext
   }
 }

+ 14 - 1
cli/src/base/DefaultCommandBase.ts

@@ -38,6 +38,19 @@ export default abstract class DefaultCommandBase extends Command {
     return result
   }
 
+  async requireConfirmation(
+    message = 'Are you sure you want to execute this action?',
+    defaultVal = false
+  ): Promise<void> {
+    if (process.env.AUTO_CONFIRM === 'true' || parseInt(process.env.AUTO_CONFIRM || '')) {
+      return
+    }
+    const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: defaultVal }])
+    if (!confirmed) {
+      this.exit(ExitCodes.OK)
+    }
+  }
+
   private jsonPrettyIndented(line: string) {
     return `${this.jsonPrettyIdent}${line}`
   }
@@ -53,7 +66,7 @@ export default abstract class DefaultCommandBase extends Command {
   }
 
   private jsonPrettyKeyVal(key: string, val: any): string {
-    return this.jsonPrettyIndented(chalk.white(`${key}: ${this.jsonPrettyAny(val)}`))
+    return this.jsonPrettyIndented(chalk.magentaBright(`${key}: ${this.jsonPrettyAny(val)}`))
   }
 
   private jsonPrettyObj(obj: { [key: string]: any }): string {

+ 0 - 70
cli/src/base/MediaCommandBase.ts

@@ -1,70 +0,0 @@
-import ContentDirectoryCommandBase from './ContentDirectoryCommandBase'
-import { VideoEntity, KnownLicenseEntity, LicenseEntity } from '@joystream/cd-schemas/types/entities'
-import fs from 'fs'
-import { DistinctQuestion } from 'inquirer'
-import path from 'path'
-import os from 'os'
-
-const MAX_USER_LICENSE_CONTENT_LENGTH = 4096
-
-/**
- * Abstract base class for higher-level media commands
- */
-export default abstract class MediaCommandBase extends ContentDirectoryCommandBase {
-  async promptForNewLicense(): Promise<VideoEntity['license']> {
-    let licenseInput: LicenseEntity
-    const licenseType: 'known' | 'custom' = await this.simplePrompt({
-      type: 'list',
-      message: 'Choose license type',
-      choices: [
-        { name: 'Creative Commons', value: 'known' },
-        { name: 'Custom (user-defined)', value: 'custom' },
-      ],
-    })
-    if (licenseType === 'known') {
-      const [id, knownLicenseEntity] = await this.promptForEntityEntry('Choose License', 'KnownLicense', 'code')
-      const knownLicense = await this.parseToEntityJson<KnownLicenseEntity>(knownLicenseEntity)
-      licenseInput = { knownLicense: id.toNumber() }
-      if (knownLicense.attributionRequired) {
-        licenseInput.attribution = await this.simplePrompt({ message: 'Attribution' })
-      }
-    } else {
-      let licenseContent: null | string = null
-      while (licenseContent === null) {
-        try {
-          let licensePath: string = await this.simplePrompt({ message: 'Path to license file:' })
-          licensePath = path.resolve(process.cwd(), licensePath.replace(/^~/, os.homedir()))
-          licenseContent = fs.readFileSync(licensePath).toString()
-        } catch (e) {
-          this.warn("The file was not found or couldn't be accessed, try again...")
-        }
-        if (licenseContent !== null && licenseContent.length > MAX_USER_LICENSE_CONTENT_LENGTH) {
-          this.warn(`The license content cannot be more than ${MAX_USER_LICENSE_CONTENT_LENGTH} characters long`)
-          licenseContent = null
-        }
-      }
-      licenseInput = { userDefinedLicense: { new: { content: licenseContent } } }
-    }
-
-    return { new: licenseInput }
-  }
-
-  async promptForPublishedBeforeJoystream(current?: number | null): Promise<number | null> {
-    const publishedBefore = await this.simplePrompt({
-      type: 'confirm',
-      message: `Do you want to set optional first publication date (publishedBeforeJoystream)?`,
-      default: typeof current === 'number',
-    })
-    if (publishedBefore) {
-      const options = ({
-        type: 'datetime',
-        message: 'Date of first publication',
-        format: ['yyyy', '-', 'mm', '-', 'dd', ' ', 'hh', ':', 'MM', ' ', 'TT'],
-        initial: current && new Date(current * 1000),
-      } as unknown) as DistinctQuestion // Need to assert, because we use datetime plugin which has no TS support
-      const date = await this.simplePrompt(options)
-      return Math.floor(new Date(date).getTime() / 1000)
-    }
-    return null
-  }
-}

+ 4 - 4
cli/src/base/StateAwareCommandBase.ts

@@ -10,16 +10,16 @@ import { WorkingGroups } from '../Types'
 
 // Type for the state object (which is preserved as json in the state file)
 type StateObject = {
-  selectedAccountFilename: string
   apiUri: string
+  queryNodeUri: string
   defaultWorkingGroup: WorkingGroups
   metadataCache: Record<string, any>
 }
 
 // State object default values
 const DEFAULT_STATE: StateObject = {
-  selectedAccountFilename: '',
   apiUri: '',
+  queryNodeUri: '',
   defaultWorkingGroup: WorkingGroups.StorageProviders,
   metadataCache: {},
 }
@@ -91,7 +91,7 @@ export default abstract class StateAwareCommandBase extends DefaultCommandBase {
       fs.mkdirSync(this.getAppDataPath())
     }
     if (!fs.existsSync(this.getStateFilePath())) {
-      fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE))
+      fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE, null, 4))
     }
   }
 
@@ -117,7 +117,7 @@ export default abstract class StateAwareCommandBase extends DefaultCommandBase {
     const oldState: StateObject = this.getPreservedState()
     const newState: StateObject = { ...oldState, ...modifiedState }
     try {
-      fs.writeFileSync(stateFilePath, JSON.stringify(newState))
+      fs.writeFileSync(stateFilePath, JSON.stringify(newState, null, 4))
     } catch (e) {
       await unlock()
       throw this.createDataWriteError()

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

@@ -0,0 +1,288 @@
+import ContentDirectoryCommandBase from './ContentDirectoryCommandBase'
+import { VideoFFProbeMetadata, VideoFileMetadata, AssetType, InputAsset, InputAssetDetails } from '../Types'
+import { ContentId, ContentParameters } from '@joystream/types/storage'
+import { MultiBar, Options, SingleBar } from 'cli-progress'
+import { Assets } from '../json-schemas/typings/Assets.schema'
+import ExitCodes from '../ExitCodes'
+import ipfsHash from 'ipfs-only-hash'
+import fs from 'fs'
+import _ from 'lodash'
+import axios, { AxiosRequestConfig } from 'axios'
+import ffprobeInstaller from '@ffprobe-installer/ffprobe'
+import ffmpeg from 'fluent-ffmpeg'
+import path from 'path'
+import chalk from 'chalk'
+import mimeTypes from 'mime-types'
+
+ffmpeg.setFfprobePath(ffprobeInstaller.path)
+
+/**
+ * Abstract base class for commands that require uploading functionality
+ */
+export default abstract class UploadCommandBase extends ContentDirectoryCommandBase {
+  private fileSizeCache: Map<string, number> = new Map<string, number>()
+  private progressBarOptions: Options = {
+    format: `{barTitle} | {bar} | {value}/{total} KB processed`,
+  }
+
+  getFileSize(path: string): number {
+    const cachedSize = this.fileSizeCache.get(path)
+    return cachedSize !== undefined ? cachedSize : fs.statSync(path).size
+  }
+
+  normalizeEndpoint(endpoint: string) {
+    return endpoint.endsWith('/') ? endpoint : endpoint + '/'
+  }
+
+  createReadStreamWithProgressBar(
+    filePath: string,
+    barTitle: string,
+    multiBar?: MultiBar
+  ): {
+    fileStream: fs.ReadStream
+    progressBar: SingleBar
+  } {
+    // Progress CLI UX:
+    // https://github.com/oclif/cli-ux#cliprogress
+    // https://www.npmjs.com/package/cli-progress
+    const fileSize = this.getFileSize(filePath)
+    let processedKB = 0
+    const fileSizeKB = Math.ceil(fileSize / 1024)
+    const progress = multiBar
+      ? multiBar.create(fileSizeKB, processedKB, { barTitle })
+      : new SingleBar(this.progressBarOptions)
+
+    progress.start(fileSizeKB, processedKB, { barTitle })
+    return {
+      fileStream: fs
+        .createReadStream(filePath)
+        .pause() // Explicitly pause to prevent switching to flowing mode (https://nodejs.org/api/stream.html#stream_event_data)
+        .on('error', () => {
+          progress.stop()
+          this.error(`Error while trying to read data from: ${filePath}!`, {
+            exit: ExitCodes.FsOperationFailed,
+          })
+        })
+        .on('data', (data) => {
+          processedKB += data.length / 1024
+          progress.update(processedKB)
+        })
+        .on('end', () => {
+          progress.update(fileSizeKB)
+          progress.stop()
+        }),
+      progressBar: progress,
+    }
+  }
+
+  async getVideoFFProbeMetadata(filePath: string): Promise<VideoFFProbeMetadata> {
+    return new Promise<VideoFFProbeMetadata>((resolve, reject) => {
+      ffmpeg.ffprobe(filePath, (err, data) => {
+        if (err) {
+          reject(err)
+          return
+        }
+        const videoStream = data.streams.find((s) => s.codec_type === 'video')
+        if (videoStream) {
+          resolve({
+            width: videoStream.width,
+            height: videoStream.height,
+            codecName: videoStream.codec_name,
+            codecFullName: videoStream.codec_long_name,
+            duration: videoStream.duration !== undefined ? Math.ceil(Number(videoStream.duration)) || 0 : undefined,
+          })
+        } else {
+          reject(new Error('No video stream found in file'))
+        }
+      })
+    })
+  }
+
+  async getVideoFileMetadata(filePath: string): Promise<VideoFileMetadata> {
+    let ffProbeMetadata: VideoFFProbeMetadata = {}
+    try {
+      ffProbeMetadata = await this.getVideoFFProbeMetadata(filePath)
+    } catch (e) {
+      const message = e.message || e
+      this.warn(`Failed to get video metadata via ffprobe (${message})`)
+    }
+
+    const size = this.getFileSize(filePath)
+    const container = path.extname(filePath).slice(1)
+    const mimeType = mimeTypes.lookup(container) || `unknown`
+    return {
+      size,
+      container,
+      mimeType,
+      ...ffProbeMetadata,
+    }
+  }
+
+  async calculateFileIpfsHash(filePath: string): Promise<string> {
+    const { fileStream } = this.createReadStreamWithProgressBar(filePath, 'Calculating file hash')
+    const hash: string = await ipfsHash.of(fileStream)
+
+    return hash
+  }
+
+  validateFile(filePath: string): void {
+    // Basic file validation
+    if (!fs.existsSync(filePath)) {
+      this.error(`${filePath} - file does not exist under provided path!`, { exit: ExitCodes.FileNotFound })
+    }
+  }
+
+  assetUrl(endpointRoot: string, contentId: ContentId): string {
+    // This will also make sure the resulting url is a valid url
+    return new URL(`asset/v0/${contentId.encode()}`, this.normalizeEndpoint(endpointRoot)).toString()
+  }
+
+  async getRandomProviderEndpoint(): Promise<string | null> {
+    const endpoints = _.shuffle(await this.getApi().allStorageProviderEndpoints())
+    for (const endpoint of endpoints) {
+      try {
+        const url = new URL('swagger.json', this.normalizeEndpoint(endpoint)).toString()
+        await axios.head(url)
+        return endpoint
+      } catch (e) {
+        continue
+      }
+    }
+
+    return null
+  }
+
+  async generateContentParameters(filePath: string, type: AssetType): Promise<ContentParameters> {
+    return this.createType('ContentParameters', {
+      content_id: ContentId.generate(this.getTypesRegistry()),
+      type_id: type,
+      size: this.getFileSize(filePath),
+      ipfs_content_id: await this.calculateFileIpfsHash(filePath),
+    })
+  }
+
+  async prepareInputAssets(paths: string[], basePath?: string): Promise<InputAssetDetails[]> {
+    // Resolve assets
+    if (basePath) {
+      paths = paths.map((p) => basePath && path.resolve(path.dirname(basePath), p))
+    }
+    // Validate assets
+    paths.forEach((p) => this.validateFile(p))
+
+    // Return data
+    return await Promise.all(
+      paths.map(async (path) => {
+        const parameters = await this.generateContentParameters(path, AssetType.AnyAsset)
+        return {
+          path,
+          contentId: parameters.content_id,
+          parameters,
+        }
+      })
+    )
+  }
+
+  async uploadAsset(contentId: ContentId, filePath: string, endpoint?: string, multiBar?: MultiBar): Promise<void> {
+    const providerEndpoint = endpoint || (await this.getRandomProviderEndpoint())
+    if (!providerEndpoint) {
+      this.error('No active provider found!', { exit: ExitCodes.ActionCurrentlyUnavailable })
+    }
+    const uploadUrl = this.assetUrl(providerEndpoint, contentId)
+    const fileSize = this.getFileSize(filePath)
+    const { fileStream, progressBar } = this.createReadStreamWithProgressBar(
+      filePath,
+      `Uploading ${contentId.encode()}`,
+      multiBar
+    )
+    fileStream.on('end', () => {
+      // Temporarly disable because with Promise.all it breaks the UI
+      // cli.action.start('Waiting for the file to be processed...')
+    })
+
+    try {
+      const config: AxiosRequestConfig = {
+        headers: {
+          'Content-Type': '', // https://github.com/Joystream/storage-node-joystream/issues/16
+          'Content-Length': fileSize.toString(),
+        },
+        maxBodyLength: fileSize,
+      }
+      await axios.put(uploadUrl, fileStream, config)
+    } catch (e) {
+      progressBar.stop()
+      const msg = (e.response && e.response.data && e.response.data.message) || e.message || e
+      this.error(`Unexpected error when trying to upload a file: ${msg}`, {
+        exit: ExitCodes.ExternalInfrastructureError,
+      })
+    }
+  }
+
+  async uploadAssets(
+    assets: InputAsset[],
+    inputFilePath: string,
+    outputFilePostfix = '__rejectedContent'
+  ): Promise<void> {
+    const endpoint = await this.getRandomProviderEndpoint()
+    if (!endpoint) {
+      this.warn('No storage provider is currently available!')
+      this.handleRejectedUploads(
+        assets,
+        assets.map(() => false),
+        inputFilePath,
+        outputFilePostfix
+      )
+      this.exit(ExitCodes.ActionCurrentlyUnavailable)
+    }
+    const multiBar = new MultiBar(this.progressBarOptions)
+    // Workaround replacement for Promise.allSettled (which is only available in ES2020)
+    const results = await Promise.all(
+      assets.map(async (a) => {
+        try {
+          await this.uploadAsset(a.contentId, a.path, endpoint, multiBar)
+          return true
+        } catch (e) {
+          return false
+        }
+      })
+    )
+    this.handleRejectedUploads(assets, results, inputFilePath, outputFilePostfix)
+    multiBar.stop()
+  }
+
+  public assetsIndexes(originalPaths: (string | undefined)[], filteredPaths: string[]): (number | undefined)[] {
+    let lastIndex = -1
+    return originalPaths.map((path) => (filteredPaths.includes(path as string) ? ++lastIndex : undefined))
+  }
+
+  private handleRejectedUploads(
+    assets: InputAsset[],
+    results: boolean[],
+    inputFilePath: string,
+    outputFilePostfix: string
+  ): void {
+    // Try to save rejected contentIds and paths for reupload purposes
+    const rejectedAssetsOutput: Assets = []
+    results.forEach(
+      (r, i) =>
+        r === false && rejectedAssetsOutput.push({ contentId: assets[i].contentId.encode(), path: assets[i].path })
+    )
+    if (rejectedAssetsOutput.length) {
+      this.warn(
+        `Some assets were not uploaded successfully. Try reuploading them with ${chalk.magentaBright(
+          'content:reuploadAssets'
+        )}!`
+      )
+      console.log(rejectedAssetsOutput)
+      const outputPath = inputFilePath.replace('.json', `${outputFilePostfix}.json`)
+      try {
+        fs.writeFileSync(outputPath, JSON.stringify(rejectedAssetsOutput, null, 4))
+        this.log(`Rejected content ids successfully saved to: ${chalk.magentaBright(outputPath)}!`)
+      } catch (e) {
+        console.error(e)
+        this.warn(
+          `Could not write rejected content output to ${outputPath}. Try copying the output above and creating the file manually!`
+        )
+      }
+    }
+  }
+}

+ 26 - 68
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,19 +1,11 @@
 import ExitCodes from '../ExitCodes'
 import AccountsCommandBase from './AccountsCommandBase'
 import { flags } from '@oclif/command'
-import {
-  WorkingGroups,
-  AvailableGroups,
-  NamedKeyringPair,
-  GroupMember,
-  GroupOpening,
-  OpeningStatus,
-  GroupApplication,
-} from '../Types'
+import { WorkingGroups, AvailableGroups, GroupMember, OpeningDetails, ApplicationDetails } from '../Types'
 import _ from 'lodash'
-import { ApplicationStageKeys } from '@joystream/types/hiring'
 import chalk from 'chalk'
 import { IConfig } from '@oclif/config'
+import { memberHandle } from '../helpers/display'
 
 /**
  * Abstract base class for commands that need to use gates based on user's roles
@@ -28,11 +20,10 @@ export abstract class RolesCommandBase extends AccountsCommandBase {
   }
 
   // Use when lead access is required in given command
-  async getRequiredLead(): Promise<GroupMember> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+  async getRequiredLeadContext(): Promise<GroupMember> {
     const lead = await this.getApi().groupLead(this.group)
 
-    if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
+    if (!lead || !this.isKeyAvailable(lead.roleAccount)) {
       this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
         exit: ExitCodes.AccessDenied,
       })
@@ -42,38 +33,22 @@ export abstract class RolesCommandBase extends AccountsCommandBase {
   }
 
   // Use when worker access is required in given command
-  async getRequiredWorker(): Promise<GroupMember> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+  async getRequiredWorkerContext(expectedKeyType: 'Role' | 'MemberController' = 'Role'): Promise<GroupMember> {
     const groupMembers = await this.getApi().groupMembers(this.group)
-    const groupMembersByAccount = groupMembers.filter((m) => m.roleAccount.toString() === selectedAccount.address)
-
-    if (!groupMembersByAccount.length) {
-      this.error(`${_.startCase(this.group)} Group Worker access required for this command!`, {
-        exit: ExitCodes.AccessDenied,
-      })
-    } else if (groupMembersByAccount.length === 1) {
-      return groupMembersByAccount[0]
-    } else {
-      return await this.promptForWorker(groupMembersByAccount)
-    }
-  }
-
-  // Use when member controller access is required, but one of the associated roles is expected to be selected
-  async getRequiredWorkerByMemberController(): Promise<GroupMember> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
-    const memberIds = await this.getApi().getMemberIdsByControllerAccount(selectedAccount.address)
-    const controlledWorkers = (await this.getApi().groupMembers(this.group)).filter((groupMember) =>
-      memberIds.some((memberId) => groupMember.memberId.eq(memberId))
+    const availableGroupMemberContexts = groupMembers.filter((m) =>
+      expectedKeyType === 'Role'
+        ? this.isKeyAvailable(m.roleAccount.toString())
+        : this.isKeyAvailable(m.profile.membership.controller_account.toString())
     )
 
-    if (!controlledWorkers.length) {
-      this.error(`Member controller account with some associated ${this.group} group roles needs to be selected!`, {
+    if (!availableGroupMemberContexts.length) {
+      this.error(`No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key available!`, {
         exit: ExitCodes.AccessDenied,
       })
-    } else if (controlledWorkers.length === 1) {
-      return controlledWorkers[0]
+    } else if (availableGroupMemberContexts.length === 1) {
+      return availableGroupMemberContexts[0]
     } else {
-      return await this.promptForWorker(controlledWorkers)
+      return await this.promptForWorker(availableGroupMemberContexts)
     }
   }
 
@@ -113,33 +88,24 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
     }),
   }
 
-  async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
-    const acceptableApplications = opening.applications.filter((a) => a.stage === ApplicationStageKeys.Active)
+  async promptForApplicationsToAccept(opening: OpeningDetails): Promise<number[]> {
     const acceptedApplications = await this.simplePrompt({
       message: 'Select succesful applicants',
       type: 'checkbox',
-      choices: acceptableApplications.map((a) => ({
-        name: ` ${a.wgApplicationId}: ${a.member?.handle.toString()}`,
-        value: a.wgApplicationId,
+      choices: opening.applications.map((a) => ({
+        name: ` ${a.applicationId}: ${memberHandle(a.member)}`,
+        value: a.applicationId,
       })),
     })
 
     return acceptedApplications
   }
 
-  async getOpeningForLeadAction(id: number, requiredStatus?: OpeningStatus): Promise<GroupOpening> {
+  async getOpeningForLeadAction(id: number): Promise<OpeningDetails> {
     const opening = await this.getApi().groupOpening(this.group, id)
 
-    if (!opening.type.isOfType('Worker')) {
-      this.error('A lead can only manage Worker openings!', { exit: ExitCodes.AccessDenied })
-    }
-
-    if (requiredStatus && opening.stage.status !== requiredStatus) {
-      this.error(
-        `The opening needs to be in "${_.startCase(requiredStatus)}" stage! ` +
-          `This one is: "${_.startCase(opening.stage.status)}"`,
-        { exit: ExitCodes.InvalidInput }
-      )
+    if (!opening.type.isOfType('Regular')) {
+      this.error('A lead can only manage Regular openings!', { exit: ExitCodes.AccessDenied })
     }
 
     return opening
@@ -148,20 +114,12 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
   // An alias for better code readibility in case we don't need the actual return value
   validateOpeningForLeadAction = this.getOpeningForLeadAction
 
-  async getApplicationForLeadAction(id: number, requiredStatus?: ApplicationStageKeys): Promise<GroupApplication> {
+  async getApplicationForLeadAction(id: number): Promise<ApplicationDetails> {
     const application = await this.getApi().groupApplication(this.group, id)
-    const opening = await this.getApi().groupOpening(this.group, application.wgOpeningId)
-
-    if (!opening.type.isOfType('Worker')) {
-      this.error('A lead can only manage Worker opening applications!', { exit: ExitCodes.AccessDenied })
-    }
+    const opening = await this.getApi().groupOpening(this.group, application.openingId)
 
-    if (requiredStatus && application.stage !== requiredStatus) {
-      this.error(
-        `The application needs to have "${_.startCase(requiredStatus)}" status! ` +
-          `This one has: "${_.startCase(application.stage)}"`,
-        { exit: ExitCodes.InvalidInput }
-      )
+    if (!opening.type.isOfType('Regular')) {
+      this.error('A lead can only manage Regular opening applications!', { exit: ExitCodes.AccessDenied })
     }
 
     return application
@@ -194,6 +152,6 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
     if (flags.group) {
       this.group = flags.group
     }
-    this.log(chalk.white('Current Group: ' + this.group))
+    this.log(chalk.magentaBright('Current Group: ' + this.group))
   }
 }

+ 0 - 48
cli/src/commands/account/choose.ts

@@ -1,48 +0,0 @@
-import AccountsCommandBase from '../../base/AccountsCommandBase'
-import chalk from 'chalk'
-import ExitCodes from '../../ExitCodes'
-import { NamedKeyringPair } from '../../Types'
-import { flags } from '@oclif/command'
-
-export default class AccountChoose extends AccountsCommandBase {
-  static description = 'Choose default account to use in the CLI'
-  static flags = {
-    showSpecial: flags.boolean({
-      description: 'Whether to show special (DEV chain) accounts',
-      char: 'S',
-      required: false,
-    }),
-    address: flags.string({
-      description: 'Select account by address (if available)',
-      char: 'a',
-      required: false,
-    }),
-  }
-
-  async run() {
-    const { showSpecial, address } = this.parse(AccountChoose).flags
-    const accounts: NamedKeyringPair[] = this.fetchAccounts(!!address || showSpecial)
-    const selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
-
-    this.log(chalk.white(`Found ${accounts.length} existing accounts...\n`))
-
-    if (accounts.length === 0) {
-      this.warn('No account to choose from. Add accont using account:import or account:create.')
-      this.exit(ExitCodes.NoAccountFound)
-    }
-
-    let choosenAccount: NamedKeyringPair
-    if (address) {
-      const matchingAccount = accounts.find((a) => a.address === address)
-      if (!matchingAccount) {
-        this.error(`No matching account found by address: ${address}`, { exit: ExitCodes.InvalidInput })
-      }
-      choosenAccount = matchingAccount
-    } else {
-      choosenAccount = await this.promptForAccount(accounts, selectedAccount)
-    }
-
-    await this.setSelectedAccount(choosenAccount)
-    this.log(chalk.greenBright(`\nAccount switched to ${chalk.white(choosenAccount.address)}!`))
-  }
-}

+ 15 - 38
cli/src/commands/account/create.ts

@@ -1,47 +1,24 @@
-import chalk from 'chalk'
-import ExitCodes from '../../ExitCodes'
-import AccountsCommandBase from '../../base/AccountsCommandBase'
-import { Keyring } from '@polkadot/api'
-import { mnemonicGenerate } from '@polkadot/util-crypto'
-import { NamedKeyringPair } from '../../Types'
-
-type AccountCreateArgs = {
-  name: string
-}
+import AccountsCommandBase, { DEFAULT_ACCOUNT_TYPE } from '../../base/AccountsCommandBase'
+import { KeypairType } from '@polkadot/util-crypto/types'
+import { flags } from '@oclif/command'
 
 export default class AccountCreate extends AccountsCommandBase {
-  static description = 'Create new account'
+  static description = 'Create a new account'
 
-  static args = [
-    {
-      name: 'name',
-      required: true,
+  static flags = {
+    name: flags.string({
+      required: false,
       description: 'Account name',
-    },
-  ]
-
-  validatePass(password: string, password2: string): void {
-    if (password !== password2) this.error('Passwords are not the same!', { exit: ExitCodes.InvalidInput })
-    if (!password) this.error("You didn't provide a password", { exit: ExitCodes.InvalidInput })
+    }),
+    type: flags.enum<KeypairType>({
+      required: false,
+      description: `Account type (defaults to ${DEFAULT_ACCOUNT_TYPE})`,
+      options: ['sr25519', 'ed25519'],
+    }),
   }
 
   async run() {
-    const args: AccountCreateArgs = this.parse(AccountCreate).args as AccountCreateArgs
-    const keyring: Keyring = new Keyring()
-    const mnemonic: string = mnemonicGenerate()
-
-    keyring.addFromMnemonic(mnemonic, { name: args.name, whenCreated: Date.now() })
-    const keys: NamedKeyringPair = keyring.pairs[0] as NamedKeyringPair // We assigned the name above
-
-    const password = await this.promptForPassword("Set your account's password")
-    const password2 = await this.promptForPassword('Confirm your password')
-
-    this.validatePass(password, password2)
-
-    this.saveAccount(keys, password)
-
-    this.log(chalk.greenBright(`\nAccount succesfully created!`))
-    this.log(chalk.white(`${chalk.bold('Name:    ')}${args.name}`))
-    this.log(chalk.white(`${chalk.bold('Address: ')}${keys.address}`))
+    const { name, type } = this.parse(AccountCreate).flags
+    await this.createAccount(name, undefined, undefined, type)
   }
 }

+ 0 - 40
cli/src/commands/account/current.ts

@@ -1,40 +0,0 @@
-import AccountsCommandBase from '../../base/AccountsCommandBase'
-import { AccountSummary, NameValueObj, NamedKeyringPair } from '../../Types'
-import { displayHeader, displayNameValueTable } from '../../helpers/display'
-import { formatBalance } from '@polkadot/util'
-import moment from 'moment'
-
-export default class AccountCurrent extends AccountsCommandBase {
-  static description = 'Display information about currently choosen default account'
-  static aliases = ['account:info', 'account:default']
-
-  async run() {
-    const currentAccount: NamedKeyringPair = await this.getRequiredSelectedAccount(false)
-    const summary: AccountSummary = await this.getApi().getAccountSummary(currentAccount.address)
-
-    displayHeader('Account information')
-    const creationDate: string = currentAccount.meta.whenCreated
-      ? moment(currentAccount.meta.whenCreated as string | number).format('YYYY-MM-DD HH:mm:ss')
-      : '?'
-    const accountRows: NameValueObj[] = [
-      { name: 'Account name:', value: currentAccount.meta.name },
-      { name: 'Address:', value: currentAccount.address },
-      { name: 'Created:', value: creationDate },
-    ]
-    displayNameValueTable(accountRows)
-
-    displayHeader('Balances')
-    const balances = summary.balances
-    const balancesRows: NameValueObj[] = [
-      { name: 'Total balance:', value: formatBalance(balances.votingBalance) },
-      { name: 'Transferable balance:', value: formatBalance(balances.availableBalance) },
-    ]
-    if (balances.lockedBalance.gtn(0)) {
-      balancesRows.push({ name: 'Locked balance:', value: formatBalance(balances.lockedBalance) })
-    }
-    if (balances.reservedBalance.gtn(0)) {
-      balancesRows.push({ name: 'Reserved balance:', value: formatBalance(balances.reservedBalance) })
-    }
-    displayNameValueTable(balancesRows)
-  }
-}

+ 29 - 28
cli/src/commands/account/export.ts

@@ -4,10 +4,8 @@ import path from 'path'
 import ExitCodes from '../../ExitCodes'
 import AccountsCommandBase from '../../base/AccountsCommandBase'
 import { flags } from '@oclif/command'
-import { NamedKeyringPair } from '../../Types'
 
-type AccountExportFlags = { all: boolean }
-type AccountExportArgs = { path: string }
+type AccountExportArgs = { destPath: string }
 
 export default class AccountExport extends AccountsCommandBase {
   static description = 'Export account(s) to given location'
@@ -15,22 +13,30 @@ export default class AccountExport extends AccountsCommandBase {
 
   static args = [
     {
-      name: 'path',
+      name: 'destPath',
       required: true,
       description: 'Path where the exported files should be placed',
     },
   ]
 
   static flags = {
+    name: flags.string({
+      char: 'n',
+      description: 'Name of the account to export',
+      required: false,
+      exclusive: ['all'],
+    }),
     all: flags.boolean({
       char: 'a',
       description: `If provided, exports all existing accounts into "${AccountExport.MULTI_EXPORT_FOLDER_NAME}" folder inside given path`,
+      required: false,
+      exclusive: ['name'],
     }),
   }
 
-  exportAccount(account: NamedKeyringPair, destPath: string): string {
-    const sourceFilePath: string = this.getAccountFilePath(account)
-    const destFilePath: string = path.join(destPath, this.generateAccountFilename(account))
+  exportAccount(name: string, destPath: string): string {
+    const sourceFilePath: string = this.getAccountFilePath(name)
+    const destFilePath: string = path.join(destPath, this.getAccountFileName(name))
     try {
       fs.copyFileSync(sourceFilePath, destFilePath)
     } catch (e) {
@@ -43,32 +49,27 @@ export default class AccountExport extends AccountsCommandBase {
   }
 
   async run() {
-    const args: AccountExportArgs = this.parse(AccountExport).args as AccountExportArgs
-    const flags: AccountExportFlags = this.parse(AccountExport).flags as AccountExportFlags
-    const accounts: NamedKeyringPair[] = this.fetchAccounts()
-
-    if (!accounts.length) {
-      this.error('No accounts found!', { exit: ExitCodes.NoAccountFound })
-    }
+    const { destPath } = this.parse(AccountExport).args as AccountExportArgs
+    let { name, all } = this.parse(AccountExport).flags
+    const accounts = this.fetchAccounts()
 
-    if (flags.all) {
-      const destPath: string = path.join(args.path, AccountExport.MULTI_EXPORT_FOLDER_NAME)
+    if (all) {
+      const exportPath: string = path.join(destPath, AccountExport.MULTI_EXPORT_FOLDER_NAME)
       try {
-        if (!fs.existsSync(destPath)) fs.mkdirSync(destPath)
+        if (!fs.existsSync(exportPath)) fs.mkdirSync(exportPath)
       } catch (e) {
-        this.error(`Failed to create the export folder (${destPath})`, { exit: ExitCodes.FsOperationFailed })
+        this.error(`Failed to create the export folder (${exportPath})`, { exit: ExitCodes.FsOperationFailed })
       }
-      for (const account of accounts) this.exportAccount(account, destPath)
-      this.log(chalk.greenBright(`All accounts succesfully exported succesfully to: ${chalk.white(destPath)}!`))
+      for (const acc of accounts) {
+        this.exportAccount(acc.meta.name, exportPath)
+      }
+      this.log(chalk.greenBright(`All accounts succesfully exported to: ${chalk.magentaBright(exportPath)}!`))
     } else {
-      const destPath: string = args.path
-      const choosenAccount: NamedKeyringPair = await this.promptForAccount(
-        accounts,
-        null,
-        'Select an account to export'
-      )
-      const exportedFilePath: string = this.exportAccount(choosenAccount, destPath)
-      this.log(chalk.greenBright(`Account succesfully exported to: ${chalk.white(exportedFilePath)}`))
+      if (!name) {
+        name = await this.promptForAccount()
+      }
+      const exportedFilePath: string = this.exportAccount(name, destPath)
+      this.log(chalk.greenBright(`Account succesfully exported to: ${chalk.magentaBright(exportedFilePath)}`))
     }
   }
 }

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

@@ -2,22 +2,16 @@ import fs from 'fs'
 import chalk from 'chalk'
 import ExitCodes from '../../ExitCodes'
 import AccountsCommandBase from '../../base/AccountsCommandBase'
-import { NamedKeyringPair } from '../../Types'
 
 export default class AccountForget extends AccountsCommandBase {
   static description = 'Forget (remove) account from the list of available accounts'
 
   async run() {
-    const accounts: NamedKeyringPair[] = this.fetchAccounts()
+    const selecteKey = await this.promptForAccount('Select an account to forget', false, false)
+    await this.requireConfirmation('Are you sure you want to PERMANENTLY FORGET this account?')
 
-    if (!accounts.length) {
-      this.error('No accounts found!', { exit: ExitCodes.NoAccountFound })
-    }
-
-    const choosenAccount: NamedKeyringPair = await this.promptForAccount(accounts, null, 'Select an account to forget')
-    await this.requireConfirmation('Are you sure you want this account to be forgotten?')
+    const accountFilePath = this.getAccountFilePath(this.getPair(selecteKey).meta.name)
 
-    const accountFilePath: string = this.getAccountFilePath(choosenAccount)
     try {
       fs.unlinkSync(accountFilePath)
     } catch (e) {

+ 51 - 32
cli/src/commands/account/import.ts

@@ -1,44 +1,63 @@
-import fs from 'fs'
-import chalk from 'chalk'
-import path from 'path'
-import ExitCodes from '../../ExitCodes'
-import AccountsCommandBase from '../../base/AccountsCommandBase'
-import { NamedKeyringPair } from '../../Types'
-
-type AccountImportArgs = {
-  backupFilePath: string
-}
+import AccountsCommandBase, { DEFAULT_ACCOUNT_TYPE, KEYRING_OPTIONS } from '../../base/AccountsCommandBase'
+import { flags } from '@oclif/command'
+import Keyring from '@polkadot/keyring'
+import { KeypairType } from '@polkadot/util-crypto/types'
 
 export default class AccountImport extends AccountsCommandBase {
-  static description = 'Import account using JSON backup file'
+  static description = 'Import account using mnemonic phrase, seed, suri or json backup file'
 
-  static args = [
-    {
-      name: 'backupFilePath',
-      required: true,
+  static flags = {
+    name: flags.string({
+      required: false,
+      description: 'Account name',
+    }),
+    mnemonic: flags.string({
+      required: false,
+      description: 'Mnemonic phrase',
+      exclusive: ['backupFilePath', 'seed', 'suri'],
+    }),
+    seed: flags.string({
+      required: false,
+      description: 'Secret seed',
+      exclusive: ['backupFilePath', 'mnemonic', 'suri'],
+    }),
+    backupFilePath: flags.string({
+      required: false,
       description: 'Path to account backup JSON file',
-    },
-  ]
+      exclusive: ['mnemonic', 'seed', 'suri'],
+    }),
+    suri: flags.string({
+      required: false,
+      description: 'Substrate uri',
+      exclusive: ['mnemonic', 'seed', 'backupFilePath'],
+    }),
+    type: flags.enum<KeypairType>({
+      required: false,
+      description: `Account type (defaults to ${DEFAULT_ACCOUNT_TYPE})`,
+      options: ['sr25519', 'ed25519'],
+      exclusive: ['backupFilePath'],
+    }),
+  }
 
   async run() {
-    const args: AccountImportArgs = this.parse(AccountImport).args as AccountImportArgs
-    const backupAcc: NamedKeyringPair = this.fetchAccountFromJsonFile(args.backupFilePath)
-    const accountName: string = backupAcc.meta.name
-    const accountAddress: string = backupAcc.address
+    const { name, mnemonic, seed, backupFilePath, suri, type } = this.parse(AccountImport).flags
 
-    const sourcePath: string = args.backupFilePath
-    const destPath: string = path.join(this.getAccountsDirPath(), this.generateAccountFilename(backupAcc))
+    const keyring = new Keyring(KEYRING_OPTIONS)
 
-    try {
-      fs.copyFileSync(sourcePath, destPath)
-    } catch (e) {
-      this.error('Unexpected error while trying to copy input file! Permissions issue?', {
-        exit: ExitCodes.FsOperationFailed,
-      })
+    if (mnemonic) {
+      keyring.addFromMnemonic(mnemonic, {}, type)
+    } else if (seed) {
+      keyring.addFromSeed(Buffer.from(seed), {}, type)
+    } else if (suri) {
+      keyring.addFromUri(suri, {}, type)
+    } else if (backupFilePath) {
+      const pair = this.fetchAccountFromJsonFile(backupFilePath)
+      keyring.addPair(pair)
+    } else {
+      this._help()
+      return
     }
 
-    this.log(chalk.bold.greenBright(`ACCOUNT IMPORTED SUCCESFULLY!`))
-    this.log(chalk.bold.white(`NAME:    `), accountName)
-    this.log(chalk.bold.white(`ADDRESS: `), accountAddress)
+    await this.createAccount(name, keyring.getPairs()[0])
   }
 }

+ 56 - 0
cli/src/commands/account/info.ts

@@ -0,0 +1,56 @@
+import AccountsCommandBase from '../../base/AccountsCommandBase'
+import ExitCodes from '../../ExitCodes'
+import { validateAddress } from '../../helpers/validation'
+import { NameValueObj } from '../../Types'
+import { displayHeader, displayNameValueTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+import moment from 'moment'
+
+export default class AccountInfo extends AccountsCommandBase {
+  static description = 'Display detailed information about specified account'
+  static aliases = ['account:inspect']
+  static args = [
+    { name: 'address', required: false, description: 'An address to inspect (can also be provided interavtively)' },
+  ]
+
+  async run() {
+    let { address } = this.parse(AccountInfo).args
+
+    if (!address) {
+      address = await this.promptForAnyAddress()
+    } else if (validateAddress(address) !== true) {
+      this.error('Invalid address', { exit: ExitCodes.InvalidInput })
+    }
+
+    const summary = await this.getApi().getAccountSummary(address)
+
+    displayHeader('Account information')
+    const accountRows: NameValueObj[] = [{ name: 'Address:', value: address }]
+    if (this.isKeyAvailable(address)) {
+      const pair = this.getPair(address)
+      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')
+        : null
+      if (creationDate) {
+        accountRows.push({ name: 'Creation date', value: creationDate })
+      }
+    }
+    displayNameValueTable(accountRows)
+
+    displayHeader('Balances')
+    const balances = summary.balances
+    const balancesRows: NameValueObj[] = [
+      { name: 'Total balance:', value: formatBalance(balances.votingBalance) },
+      { name: 'Transferable balance:', value: formatBalance(balances.availableBalance) },
+    ]
+    if (balances.lockedBalance.gtn(0)) {
+      balancesRows.push({ name: 'Locked balance:', value: formatBalance(balances.lockedBalance) })
+    }
+    if (balances.reservedBalance.gtn(0)) {
+      balancesRows.push({ name: 'Reserved balance:', value: formatBalance(balances.reservedBalance) })
+    }
+    displayNameValueTable(balancesRows)
+  }
+}

+ 22 - 0
cli/src/commands/account/list.ts

@@ -0,0 +1,22 @@
+import AccountsCommandBase from '../../base/AccountsCommandBase'
+import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+
+export default class AccountList extends AccountsCommandBase {
+  static description = 'List all available accounts'
+
+  async run() {
+    const pairs = this.getPairs()
+    const balances = await this.getApi().getAccountsBalancesInfo(pairs.map((p) => p.address))
+
+    displayTable(
+      pairs.map((p, i) => ({
+        'Name': p.meta.name,
+        'Address': p.address,
+        'Available balance': formatBalance(balances[i].availableBalance),
+        'Total balance': formatBalance(balances[i].votingBalance),
+      })),
+      3
+    )
+  }
+}

+ 33 - 50
cli/src/commands/account/transferTokens.ts

@@ -1,67 +1,50 @@
+import { flags } from '@oclif/command'
 import BN from 'bn.js'
 import AccountsCommandBase from '../../base/AccountsCommandBase'
-import chalk from 'chalk'
 import ExitCodes from '../../ExitCodes'
-import { formatBalance } from '@polkadot/util'
-import { Hash } from '@polkadot/types/interfaces'
-import { NamedKeyringPair } from '../../Types'
-import { checkBalance, validateAddress } from '../../helpers/validation'
-
-type AccountTransferArgs = {
-  recipient: string
-  amount: string
-}
+import { checkBalance, isValidBalance, validateAddress } from '../../helpers/validation'
 
 export default class AccountTransferTokens extends AccountsCommandBase {
-  static description = 'Transfer tokens from currently choosen account'
-
-  static args = [
-    {
-      name: 'recipient',
-      required: true,
-      description: 'Address of the transfer recipient',
-    },
-    {
-      name: 'amount',
+  static description = 'Transfer tokens from any of the available accounts'
+
+  static flags = {
+    from: flags.string({
+      required: false,
+      description: 'Address of the sender (can also be provided interactively)',
+    }),
+    to: flags.string({
+      required: false,
+      description: 'Address of the recipient (can also be provided interactively)',
+    }),
+    amount: flags.string({
       required: true,
       description: 'Amount of tokens to transfer',
-    },
-  ]
+    }),
+  }
 
   async run() {
-    const args: AccountTransferArgs = this.parse(AccountTransferTokens).args as AccountTransferArgs
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
-    const amountBN: BN = new BN(args.amount)
+    let { from, to, amount } = this.parse(AccountTransferTokens).flags
 
-    // Initial validation
-    validateAddress(args.recipient, 'Invalid recipient address')
-    const accBalances = (await this.getApi().getAccountsBalancesInfo([selectedAccount.address]))[0]
-    checkBalance(accBalances, amountBN)
-
-    await this.requestAccountDecoding(selectedAccount)
+    if (!isValidBalance(amount)) {
+      this.error('Invalid transfer amount', { exit: ExitCodes.InvalidInput })
+    }
 
-    this.log(chalk.white('Estimating fee...'))
-    const tx = await this.getApi().createTransferTx(args.recipient, amountBN)
-    let estimatedFee: BN
-    try {
-      estimatedFee = await this.getApi().estimateFee(selectedAccount, tx)
-    } catch (e) {
-      this.error('Could not estimate the fee.', { exit: ExitCodes.UnexpectedException })
+    // Initial validation
+    if (!from) {
+      from = await this.promptForAccount('Select sender account')
+    } else if (!this.isKeyAvailable(from)) {
+      this.error('Sender key not available', { exit: ExitCodes.InvalidInput })
     }
-    const totalAmount: BN = amountBN.add(estimatedFee)
-    this.log(chalk.white('Estimated fee:', formatBalance(estimatedFee)))
-    this.log(chalk.white('Total transfer amount:', formatBalance(totalAmount)))
 
-    checkBalance(accBalances, totalAmount)
+    if (!to) {
+      to = await this.promptForAnyAddress('Select recipient')
+    } else if (validateAddress(to) !== true) {
+      this.error('Invalid recipient address', { exit: ExitCodes.InvalidInput })
+    }
 
-    await this.requireConfirmation('Do you confirm the transfer?')
+    const accBalances = (await this.getApi().getAccountsBalancesInfo([from]))[0]
+    checkBalance(accBalances, new BN(amount))
 
-    try {
-      const txHash: Hash = await tx.signAndSend(selectedAccount)
-      this.log(chalk.greenBright('Transaction succesfully sent!'))
-      this.log(chalk.white('Hash:', txHash.toString()))
-    } catch (e) {
-      this.error('Could not send the transaction.', { exit: ExitCodes.UnexpectedException })
-    }
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(from), 'balances', 'transferKeepAlive', [to, amount])
   }
 }

+ 11 - 0
cli/src/commands/api/getQueryNodeEndpoint.ts

@@ -0,0 +1,11 @@
+import StateAwareCommandBase from '../../base/StateAwareCommandBase'
+import chalk from 'chalk'
+
+export default class ApiGetQueryNodeEndpoint extends StateAwareCommandBase {
+  static description = 'Get current query node endpoint'
+
+  async run() {
+    const currentEndpoint: string = this.getPreservedState().queryNodeUri
+    this.log(chalk.green(currentEndpoint))
+  }
+}

+ 15 - 16
cli/src/commands/api/inspect.ts

@@ -1,13 +1,12 @@
 import { flags } from '@oclif/command'
 import { CLIError } from '@oclif/errors'
 import { displayNameValueTable } from '../../helpers/display'
-import { ApiPromise } from '@polkadot/api'
 import { Codec } from '@polkadot/types/types'
-import { ConstantCodec } from '@polkadot/metadata/Decorated/consts/types'
 import ExitCodes from '../../ExitCodes'
 import chalk from 'chalk'
-import { NameValueObj, ApiMethodArg } from '../../Types'
+import { NameValueObj, ApiMethodArg, UnaugmentedApiPromise } from '../../Types'
 import ApiCommandBase from '../../base/ApiCommandBase'
+import { AugmentedConst } from '@polkadot/api/types'
 
 // Command flags type
 type ApiInspectFlags = {
@@ -78,21 +77,21 @@ export default class ApiInspect extends ApiCommandBase {
 
   getMethodMeta(apiType: ApiType, apiModule: string, apiMethod: string) {
     if (apiType === 'query') {
-      return this.getOriginalApi().query[apiModule][apiMethod].creator.meta
+      return this.getUnaugmentedApi().query[apiModule][apiMethod].creator.meta
     } else {
       // Currently the only other optoin is api.consts
-      const method: ConstantCodec = this.getOriginalApi().consts[apiModule][apiMethod] as ConstantCodec
+      const method = (this.getUnaugmentedApi().consts[apiModule][apiMethod] as unknown) as AugmentedConst<'promise'>
       return method.meta
     }
   }
 
   getMethodDescription(apiType: ApiType, apiModule: string, apiMethod: string): string {
-    const description: string = this.getMethodMeta(apiType, apiModule, apiMethod).documentation.join(' ')
+    const description: string = this.getMethodMeta(apiType, apiModule, apiMethod).docs.join(' ')
     return description || 'No description available.'
   }
 
   getQueryMethodParamsTypes(apiModule: string, apiMethod: string): string[] {
-    const method = this.getOriginalApi().query[apiModule][apiMethod]
+    const method = this.getUnaugmentedApi().query[apiModule][apiMethod]
     const { type } = method.creator.meta
     if (type.isDoubleMap) {
       return [type.asDoubleMap.key1.toString(), type.asDoubleMap.key2.toString()]
@@ -105,7 +104,7 @@ export default class ApiInspect extends ApiCommandBase {
 
   getMethodReturnType(apiType: ApiType, apiModule: string, apiMethod: string): string {
     if (apiType === 'query') {
-      const method = this.getOriginalApi().query[apiModule][apiMethod]
+      const method = this.getUnaugmentedApi().query[apiModule][apiMethod]
       const {
         meta: { type, modifier },
       } = method.creator
@@ -126,7 +125,7 @@ export default class ApiInspect extends ApiCommandBase {
   // Validate the flags - throws an error if flags.type, flags.module or flags.method is invalid / does not exist in the api.
   // Returns type, module and method which validity we can be sure about (notice they may still be "undefined" if weren't provided).
   validateFlags(
-    api: ApiPromise,
+    api: UnaugmentedApiPromise,
     flags: ApiInspectFlags
   ): { apiType: ApiType | undefined; apiModule: string | undefined; apiMethod: string | undefined } {
     let apiType: ApiType | undefined
@@ -155,7 +154,7 @@ export default class ApiInspect extends ApiCommandBase {
   async requestParamsValues(paramTypes: string[]): Promise<ApiMethodArg[]> {
     const result: ApiMethodArg[] = []
     for (const [key, paramType] of Object.entries(paramTypes)) {
-      this.log(chalk.bold.white(`Parameter no. ${parseInt(key) + 1} (${paramType}):`))
+      this.log(chalk.bold.magentaBright(`Parameter no. ${parseInt(key) + 1} (${paramType}):`))
       const paramValue = await this.promptForParam(paramType)
       result.push(paramValue)
     }
@@ -164,7 +163,7 @@ export default class ApiInspect extends ApiCommandBase {
   }
 
   async run() {
-    const api: ApiPromise = this.getOriginalApi()
+    const api: UnaugmentedApiPromise = this.getUnaugmentedApi()
     const flags: ApiInspectFlags = this.parse(ApiInspect).flags as ApiInspectFlags
     const availableTypes: readonly string[] = TYPES_AVAILABLE
     const { apiType, apiModule, apiMethod } = this.validateFlags(api, flags)
@@ -192,7 +191,7 @@ export default class ApiInspect extends ApiCommandBase {
     }
     // Describing a method
     else if (apiType && apiModule && apiMethod) {
-      this.log(chalk.bold.white(`${apiType}.${apiModule}.${apiMethod}`))
+      this.log(chalk.bold.magentaBright(`${apiType}.${apiModule}.${apiMethod}`))
       const description: string = this.getMethodDescription(apiType, apiModule, apiMethod)
       this.log(`\n${description}\n`)
       const typesRows: NameValueObj[] = []
@@ -215,17 +214,17 @@ export default class ApiInspect extends ApiCommandBase {
     }
     // Displaying all available modules
     else if (apiType) {
-      this.log(chalk.bold.white('Available modules:'))
+      this.log(chalk.bold.magentaBright('Available modules:'))
       this.log(
         Object.keys(api[apiType])
-          .map((key) => chalk.white(key))
+          .map((key) => chalk.magentaBright(key))
           .join('\n')
       )
     }
     // Displaying all available types
     else {
-      this.log(chalk.bold.white('Available types:'))
-      this.log(availableTypes.map((type) => chalk.white(type)).join('\n'))
+      this.log(chalk.bold.magentaBright('Available types:'))
+      this.log(availableTypes.map((type) => chalk.magentaBright(type)).join('\n'))
     }
   }
 }

+ 39 - 0
cli/src/commands/api/setQueryNodeEndpoint.ts

@@ -0,0 +1,39 @@
+import chalk from 'chalk'
+import ApiCommandBase from '../../base/ApiCommandBase'
+import ExitCodes from '../../ExitCodes'
+
+type ApiSetQueryNodeEndpointArgs = { endpoint: string }
+
+export default class ApiSetQueryNodeEndpoint extends ApiCommandBase {
+  static description = 'Set query node endpoint'
+  static args = [
+    {
+      name: 'endpoint',
+      required: false,
+      description: 'Query node endpoint for the CLI to use',
+    },
+  ]
+
+  async init() {
+    await super.init()
+  }
+
+  async run() {
+    const { endpoint }: ApiSetQueryNodeEndpointArgs = this.parse(ApiSetQueryNodeEndpoint)
+      .args as ApiSetQueryNodeEndpointArgs
+    let newEndpoint = ''
+    if (endpoint) {
+      if (this.isQueryNodeUriValid(endpoint)) {
+        await this.setPreservedState({ queryNodeUri: endpoint })
+        newEndpoint = endpoint
+      } else {
+        this.error('Provided endpoint seems to be incorrect!', { exit: ExitCodes.InvalidInput })
+      }
+    } else {
+      newEndpoint = await this.promptForQueryNodeUri()
+    }
+    this.log(
+      chalk.greenBright('Query node endpoint successfuly changed! New endpoint: ') + chalk.magentaBright(newEndpoint)
+    )
+  }
+}

+ 3 - 3
cli/src/commands/api/setUri.ts

@@ -15,8 +15,8 @@ export default class ApiSetUri extends ApiCommandBase {
   ]
 
   async init() {
-    this.forceSkipApiUriPrompt = true
-    await super.init()
+    // Pass "skipConnection" arg to prevent command from exiting if current api uri is invalid
+    await super.init(true)
   }
 
   async run() {
@@ -32,6 +32,6 @@ export default class ApiSetUri extends ApiCommandBase {
     } else {
       newUri = await this.promptForApiUri()
     }
-    this.log(chalk.greenBright('Api uri successfuly changed! New uri: ') + chalk.white(newUri))
+    this.log(chalk.greenBright('Api uri successfuly changed! New uri: ') + chalk.magentaBright(newUri))
   }
 }

+ 0 - 79
cli/src/commands/content-directory/addClassSchema.ts

@@ -1,79 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import AddClassSchemaSchema from '@joystream/cd-schemas/schemas/extrinsics/AddClassSchema.schema.json'
-import { AddClassSchema } from '@joystream/cd-schemas/types/extrinsics/AddClassSchema'
-import { InputParser } from '@joystream/cd-schemas'
-import { JsonSchemaPrompter, JsonSchemaCustomPrompts } from '../../helpers/JsonSchemaPrompt'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
-import { Class } from '@joystream/types/content-directory'
-
-export default class AddClassSchemaCommand extends ContentDirectoryCommandBase {
-  static description = 'Add a new schema to a class inside content directory. Requires lead access.'
-
-  static flags = {
-    ...IOFlags,
-  }
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-    await this.requestAccountDecoding(account)
-
-    const { input, output } = this.parse(AddClassSchemaCommand).flags
-
-    let inputJson = await getInputJson<AddClassSchema>(input)
-    if (!inputJson) {
-      let selectedClass: Class | undefined
-      const customPrompts: JsonSchemaCustomPrompts = [
-        [
-          'className',
-          async () => {
-            selectedClass = await this.promptForClass('Select a class to add schema to')
-            return selectedClass.name.toString()
-          },
-        ],
-        [
-          'existingProperties',
-          async () => {
-            const choices = selectedClass!.properties.map((p, i) => ({ name: `${i}: ${p.name.toString()}`, value: i }))
-            if (!choices.length) {
-              return []
-            }
-            return await this.simplePrompt({
-              type: 'checkbox',
-              message: 'Choose existing properties to keep',
-              choices,
-            })
-          },
-        ],
-        [
-          /^newProperties\[\d+\]\.property_type\.(Single|Vector\.vec_type)\.Reference/,
-          async () => this.promptForClassReference(),
-        ],
-        [/^newProperties\[\d+\]\.property_type\.(Single|Vector\.vec_type)\.Text/, { message: 'Provide TextMaxLength' }],
-        [
-          /^newProperties\[\d+\]\.property_type\.(Single|Vector\.vec_type)\.Hash/,
-          { message: 'Provide HashedTextMaxLength' },
-        ],
-      ]
-
-      const prompter = new JsonSchemaPrompter<AddClassSchema>(
-        AddClassSchemaSchema as JSONSchema,
-        undefined,
-        customPrompts
-      )
-
-      inputJson = await prompter.promptAll()
-    }
-
-    this.jsonPrettyPrint(JSON.stringify(inputJson))
-    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
-
-    if (confirmed) {
-      saveOutputJson(output, `${inputJson.className}Schema.json`, inputJson)
-      const inputParser = new InputParser(this.getOriginalApi())
-      this.log('Sending the extrinsic...')
-      await this.sendAndFollowTx(account, await inputParser.parseAddClassSchemaExtrinsic(inputJson))
-    }
-  }
-}

+ 0 - 44
cli/src/commands/content-directory/addMaintainerToClass.ts

@@ -1,44 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-
-export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
-  static description = 'Add maintainer (Curator Group) to a class.'
-  static args = [
-    {
-      name: 'className',
-      required: false,
-      description: 'Name or ID of the class (ie. Video)',
-    },
-    {
-      name: 'groupId',
-      required: false,
-      description: 'ID of the Curator Group to add as class maintainer',
-    },
-  ]
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-
-    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
-
-    if (className === undefined) {
-      className = (await this.promptForClass()).name.toString()
-    }
-
-    const classId = (await this.classEntryByNameOrId(className))[0].toNumber()
-
-    if (groupId === undefined) {
-      groupId = await this.promptForCuratorGroup()
-    } else {
-      await this.getCuratorGroup(groupId)
-    }
-
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addMaintainerToClass', [classId, groupId])
-
-    console.log(
-      chalk.green(`Curator Group ${chalk.white(groupId)} added as maintainer to ${chalk.white(className)} class!`)
-    )
-  }
-}

+ 0 - 55
cli/src/commands/content-directory/class.ts

@@ -1,55 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-import { displayCollapsedRow, displayHeader, displayTable } from '../../helpers/display'
-
-export default class ClassCommand extends ContentDirectoryCommandBase {
-  static description = 'Show Class details by id or name.'
-  static args = [
-    {
-      name: 'className',
-      required: true,
-      description: 'Name or ID of the Class',
-    },
-  ]
-
-  async run() {
-    const { className } = this.parse(ClassCommand).args
-    const [id, aClass] = await this.classEntryByNameOrId(className)
-    const permissions = aClass.class_permissions
-    const maintainers = permissions.maintainers.toArray()
-
-    displayCollapsedRow({
-      'Name': aClass.name.toString(),
-      'ID': id.toString(),
-      'Any member': permissions.any_member.toString(),
-      'Entity creation blocked': permissions.entity_creation_blocked.toString(),
-      'All property values locked': permissions.all_entity_property_values_locked.toString(),
-      'Number of entities': aClass.current_number_of_entities.toNumber(),
-      'Max. number of entities': aClass.maximum_entities_count.toNumber(),
-      'Default entity creation voucher max.': aClass.default_entity_creation_voucher_upper_bound.toNumber(),
-    })
-
-    displayHeader(`Maintainers`)
-    this.log(
-      maintainers.length ? maintainers.map((groupId) => chalk.white(`Group ${groupId.toString()}`)).join(', ') : 'NONE'
-    )
-
-    displayHeader(`Properties`)
-    if (aClass.properties.length) {
-      displayTable(
-        aClass.properties.map((p, i) => ({
-          'Index': i,
-          'Name': p.name.toString(),
-          'Type': JSON.stringify(p.property_type.toJSON()),
-          'Required': p.required.toString(),
-          'Unique': p.unique.toString(),
-          'Controller lock': p.locking_policy.is_locked_from_controller.toString(),
-          'Maintainer lock': p.locking_policy.is_locked_from_maintainer.toString(),
-        })),
-        3
-      )
-    } else {
-      this.log('NONE')
-    }
-  }
-}

+ 0 - 24
cli/src/commands/content-directory/classes.ts

@@ -1,24 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-// import chalk from 'chalk'
-import { displayTable } from '../../helpers/display'
-
-export default class ClassesCommand extends ContentDirectoryCommandBase {
-  static description = 'List existing content directory classes.'
-
-  async run() {
-    const classes = await this.getApi().availableClasses()
-
-    displayTable(
-      classes.map(([id, c]) => ({
-        'ID': id.toString(),
-        'Name': c.name.toString(),
-        'Any member': c.class_permissions.any_member.toString(),
-        'Entities': c.current_number_of_entities.toNumber(),
-        'Schemas': c.schemas.length,
-        'Maintainers': c.class_permissions.maintainers.toArray().length,
-        'Properties': c.properties.length,
-      })),
-      3
-    )
-  }
-}

+ 0 - 50
cli/src/commands/content-directory/createClass.ts

@@ -1,50 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import CreateClassSchema from '@joystream/cd-schemas/schemas/extrinsics/CreateClass.schema.json'
-import { CreateClass } from '@joystream/cd-schemas/types/extrinsics/CreateClass'
-import { InputParser } from '@joystream/cd-schemas'
-import { JsonSchemaPrompter, JsonSchemaCustomPrompts } from '../../helpers/JsonSchemaPrompt'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
-
-export default class CreateClassCommand extends ContentDirectoryCommandBase {
-  static description = 'Create class inside content directory. Requires lead access.'
-  static flags = {
-    ...IOFlags,
-  }
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-    await this.requestAccountDecoding(account)
-
-    const { input, output } = this.parse(CreateClassCommand).flags
-    const existingClassnames = (await this.getApi().availableClasses()).map(([, aClass]) => aClass.name.toString())
-
-    let inputJson = await getInputJson<CreateClass>(input, CreateClassSchema as JSONSchema)
-    if (!inputJson) {
-      const customPrompts: JsonSchemaCustomPrompts<CreateClass> = [
-        [
-          'name',
-          {
-            validate: (className) => existingClassnames.includes(className) && 'A class with this name already exists!',
-          },
-        ],
-        ['class_permissions.maintainers', () => this.promptForCuratorGroups('Select class maintainers')],
-      ]
-
-      const prompter = new JsonSchemaPrompter<CreateClass>(CreateClassSchema as JSONSchema, undefined, customPrompts)
-
-      inputJson = await prompter.promptAll()
-    }
-
-    this.jsonPrettyPrint(JSON.stringify(inputJson))
-    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
-
-    if (confirmed) {
-      saveOutputJson(output, `${inputJson.name}Class.json`, inputJson)
-      this.log('Sending the extrinsic...')
-      const inputParser = new InputParser(this.getOriginalApi())
-      await this.sendAndFollowTx(account, inputParser.parseCreateClassExtrinsic(inputJson))
-    }
-  }
-}

+ 0 - 18
cli/src/commands/content-directory/createCuratorGroup.ts

@@ -1,18 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-
-export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
-  static description = 'Create new Curator Group.'
-  static aliases = ['addCuratorGroup']
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-
-    await this.requestAccountDecoding(account)
-    await this.buildAndSendExtrinsic(account, 'contentDirectory', 'addCuratorGroup')
-
-    const newGroupId = (await this.getApi().nextCuratorGroupId()) - 1
-    console.log(chalk.green(`New group succesfully created! (ID: ${chalk.white(newGroupId)})`))
-  }
-}

+ 0 - 58
cli/src/commands/content-directory/createEntity.ts

@@ -1,58 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import inquirer from 'inquirer'
-import { InputParser } from '@joystream/cd-schemas'
-import ExitCodes from '../../ExitCodes'
-
-export default class CreateEntityCommand extends ContentDirectoryCommandBase {
-  static description =
-    'Creates a new entity in the specified class (can be executed in Member, Curator or Lead context)'
-
-  static args = [
-    {
-      name: 'className',
-      required: true,
-      description: 'Name or ID of the Class',
-    },
-  ]
-
-  static flags = {
-    context: ContentDirectoryCommandBase.contextFlag,
-  }
-
-  async run() {
-    const { className } = this.parse(CreateEntityCommand).args
-    let { context } = this.parse(CreateEntityCommand).flags
-
-    if (!context) {
-      context = await this.promptForContext()
-    }
-
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-    const [, entityClass] = await this.classEntryByNameOrId(className)
-
-    const actor = await this.getActor(context, entityClass)
-
-    if (actor.isOfType('Member') && entityClass.class_permissions.any_member.isFalse) {
-      this.error('Choosen actor has no access to create an entity of this type', { exit: ExitCodes.AccessDenied })
-    }
-
-    const answers: {
-      [key: string]: string | number | null
-    } = await inquirer.prompt(this.getQuestionsFromProperties(entityClass.properties.toArray()))
-
-    this.jsonPrettyPrint(JSON.stringify(answers))
-    await this.requireConfirmation('Do you confirm the provided input?')
-
-    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
-      {
-        className: entityClass.name.toString(),
-        entries: [answers],
-      },
-    ])
-
-    const operations = await inputParser.getEntityBatchOperations()
-
-    await this.sendAndFollowNamedTx(currentAccount, 'contentDirectory', 'transaction', [actor, operations])
-  }
-}

+ 0 - 45
cli/src/commands/content-directory/entities.ts

@@ -1,45 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { displayTable } from '../../helpers/display'
-import { flags } from '@oclif/command'
-
-export default class EntitiesCommand extends ContentDirectoryCommandBase {
-  static description = 'Show entities list by class id or name.'
-  static args = [
-    {
-      name: 'className',
-      required: true,
-      description: 'Name or ID of the Class',
-    },
-    {
-      name: 'properties',
-      required: false,
-      description:
-        'Comma-separated properties to include in the results table (ie. code,name). ' +
-        'By default all property values will be included.',
-    },
-  ]
-
-  static flags = {
-    filters: flags.string({
-      required: false,
-      description:
-        'Comma-separated filters, ie. title="Some video",channelId=3.' +
-        'Currently only the = operator is supported.' +
-        'When multiple filters are provided, only the entities that match all of them together will be displayed.',
-    }),
-  }
-
-  async run() {
-    const { className, properties } = this.parse(EntitiesCommand).args
-    const { filters } = this.parse(EntitiesCommand).flags
-    const propsToInclude: string[] | undefined = (properties || undefined) && (properties as string).split(',')
-    const filtersArr: [string, string][] = filters
-      ? filters
-          .split(',')
-          .map((f) => f.split('='))
-          .map(([pName, pValue]) => [pName, pValue.replace(/^"(.+)"$/, '$1')])
-      : []
-
-    displayTable(await this.createEntityList(className, propsToInclude, filtersArr), 3)
-  }
-}

+ 0 - 44
cli/src/commands/content-directory/entity.ts

@@ -1,44 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-import { displayCollapsedRow, displayHeader } from '../../helpers/display'
-import _ from 'lodash'
-
-export default class EntityCommand extends ContentDirectoryCommandBase {
-  static description = 'Show Entity details by id.'
-  static args = [
-    {
-      name: 'id',
-      required: true,
-      description: 'ID of the Entity',
-    },
-  ]
-
-  async run() {
-    const { id } = this.parse(EntityCommand).args
-    const entity = await this.getEntity(id, undefined, undefined, false)
-    const { controller, frozen, referenceable } = entity.entity_permissions
-    const [classId, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
-    const propertyValues = this.parseEntityPropertyValues(entity, entityClass)
-
-    displayCollapsedRow({
-      'ID': id,
-      'Class name': entityClass.name.toString(),
-      'Class ID': classId.toNumber(),
-      'Supported schemas': JSON.stringify(entity.supported_schemas.toJSON()),
-      'Controller': controller.type + (controller.isOfType('Member') ? `(${controller.asType('Member')})` : ''),
-      'Frozen': frozen.toString(),
-      'Refrecencable': referenceable.toString(),
-      'Same owner references': entity.reference_counter.same_owner.toNumber(),
-      'Total references': entity.reference_counter.total.toNumber(),
-    })
-    displayHeader('Property values')
-    displayCollapsedRow(
-      _.mapValues(
-        propertyValues,
-        (v) =>
-          (v.value === null ? chalk.grey('[not set]') : v.value.toString()) +
-          ` ${chalk.green(`${v.type}<${v.subtype}>`)}`
-      )
-    )
-  }
-}

+ 0 - 57
cli/src/commands/content-directory/initialize.ts

@@ -1,57 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { InputParser, ExtrinsicsHelper, getInitializationInputs } from '@joystream/cd-schemas'
-import { flags } from '@oclif/command'
-
-export default class InitializeCommand extends ContentDirectoryCommandBase {
-  static description =
-    'Initialize content directory with input data from @joystream/content library or custom, provided one. Requires lead access.'
-
-  static flags = {
-    rootInputsDir: flags.string({
-      required: false,
-      description: 'Custom inputs directory (must follow @joystream/content directory structure)',
-    }),
-  }
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-    await this.requestAccountDecoding(account)
-
-    const {
-      flags: { rootInputsDir },
-    } = this.parse(InitializeCommand)
-
-    const { classInputs, schemaInputs, entityBatchInputs } = getInitializationInputs(rootInputsDir)
-
-    const currentClasses = await this.getApi().availableClasses()
-
-    if (currentClasses.length) {
-      this.log('There are already some existing classes in the current content directory.')
-      await this.requireConfirmation('Do you wish to continue anyway?')
-    }
-
-    const txHelper = new ExtrinsicsHelper(this.getOriginalApi())
-    const parser = new InputParser(this.getOriginalApi(), classInputs, schemaInputs, entityBatchInputs)
-
-    this.log(`Initializing classes (${classInputs.length} input files found)...\n`)
-    const classExtrinsics = parser.getCreateClassExntrinsics()
-    await txHelper.sendAndCheck(account, classExtrinsics, 'Class initialization failed!')
-
-    this.log(`Initializing schemas (${schemaInputs.length} input files found)...\n`)
-    const schemaExtrinsics = await parser.getAddSchemaExtrinsics()
-    await txHelper.sendAndCheck(account, schemaExtrinsics, 'Schemas initialization failed!')
-
-    this.log(`Initializing entities (${entityBatchInputs.length} input files found)`)
-    const entityOperations = await parser.getEntityBatchOperations()
-
-    this.log(`Sending Transaction extrinsic (${entityOperations.length} operations)...\n`)
-    await txHelper.sendAndCheck(
-      account,
-      [this.getOriginalApi().tx.contentDirectory.transaction({ Lead: null }, entityOperations)],
-      'Entity initialization failed!'
-    )
-
-    this.log('DONE')
-  }
-}

+ 0 - 35
cli/src/commands/content-directory/removeCuratorGroup.ts

@@ -1,35 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-import ExitCodes from '../../ExitCodes'
-
-export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
-  static description = 'Remove existing Curator Group.'
-  static args = [
-    {
-      name: 'id',
-      required: false,
-      description: 'ID of the Curator Group to remove',
-    },
-  ]
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-
-    let { id } = this.parse(AddCuratorGroupCommand).args
-    if (id === undefined) {
-      id = await this.promptForCuratorGroup('Select Curator Group to remove')
-    }
-
-    const group = await this.getCuratorGroup(id)
-
-    if (group.number_of_classes_maintained.toNumber() > 0) {
-      this.error('Cannot remove a group which has some maintained classes!', { exit: ExitCodes.InvalidInput })
-    }
-
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeCuratorGroup', [id])
-
-    console.log(chalk.green(`Curator Group ${chalk.white(id)} succesfully removed!`))
-  }
-}

+ 0 - 45
cli/src/commands/content-directory/removeEntity.ts

@@ -1,45 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { Actor } from '@joystream/types/content-directory'
-import ExitCodes from '../../ExitCodes'
-
-export default class RemoveEntityCommand extends ContentDirectoryCommandBase {
-  static description = 'Removes a single entity by id (can be executed in Member, Curator or Lead context)'
-  static flags = {
-    context: ContentDirectoryCommandBase.contextFlag,
-  }
-
-  static args = [
-    {
-      name: 'id',
-      required: true,
-      description: 'ID of the entity to remove',
-    },
-  ]
-
-  async run() {
-    let {
-      args: { id },
-      flags: { context },
-    } = this.parse(RemoveEntityCommand)
-
-    const entity = await this.getEntity(id, undefined, undefined, false)
-    const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
-
-    if (!context) {
-      context = await this.promptForContext()
-    }
-
-    const account = await this.getRequiredSelectedAccount()
-    const actor: Actor = await this.getActor(context, entityClass)
-    if (!actor.isOfType('Curator') && !this.isActorEntityController(actor, entity, false)) {
-      this.error('You are not the entity controller!', { exit: ExitCodes.AccessDenied })
-    }
-
-    await this.requireConfirmation(
-      `Are you sure you want to remove entity ${id} of class ${entityClass.name.toString()}?`
-    )
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeEntity', [actor, id])
-  }
-}

+ 0 - 44
cli/src/commands/content-directory/removeMaintainerFromClass.ts

@@ -1,44 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-
-export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
-  static description = 'Remove maintainer (Curator Group) from class.'
-  static args = [
-    {
-      name: 'className',
-      required: false,
-      description: 'Name or ID of the class (ie. Video)',
-    },
-    {
-      name: 'groupId',
-      required: false,
-      description: 'ID of the Curator Group to remove from maintainers',
-    },
-  ]
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-
-    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
-
-    if (className === undefined) {
-      className = (await this.promptForClass()).name.toString()
-    }
-
-    const [classId, aClass] = await this.classEntryByNameOrId(className)
-
-    if (groupId === undefined) {
-      groupId = await this.promptForCuratorGroup('Select a maintainer', aClass.class_permissions.maintainers.toArray())
-    } else {
-      await this.getCuratorGroup(groupId)
-    }
-
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeMaintainerFromClass', [classId, groupId])
-
-    console.log(
-      chalk.green(`Curator Group ${chalk.white(groupId)} removed as maintainer of ${chalk.white(className)} class!`)
-    )
-  }
-}

+ 0 - 55
cli/src/commands/content-directory/updateClassPermissions.ts

@@ -1,55 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import CreateClassSchema from '@joystream/cd-schemas/schemas/extrinsics/CreateClass.schema.json'
-import chalk from 'chalk'
-import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { CreateClass } from '@joystream/cd-schemas/types/extrinsics/CreateClass'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-
-export default class UpdateClassPermissionsCommand extends ContentDirectoryCommandBase {
-  static description = 'Update permissions in given class.'
-  static args = [
-    {
-      name: 'className',
-      required: false,
-      description: 'Name or ID of the class (ie. Video)',
-    },
-  ]
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-
-    let { className } = this.parse(UpdateClassPermissionsCommand).args
-
-    if (className === undefined) {
-      className = (await this.promptForClass()).name.toString()
-    }
-
-    const [classId, aClass] = await this.classEntryByNameOrId(className)
-    const currentPermissions = aClass.class_permissions
-
-    const customPrompts: JsonSchemaCustomPrompts = [
-      ['class_permissions.maintainers', () => this.promptForCuratorGroups('Select class maintainers')],
-    ]
-
-    const prompter = new JsonSchemaPrompter<CreateClass>(
-      CreateClassSchema as JSONSchema,
-      { class_permissions: currentPermissions.toJSON() as CreateClass['class_permissions'] },
-      customPrompts
-    )
-
-    const newPermissions = await prompter.promptSingleProp('class_permissions')
-
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'updateClassPermissions', [
-      classId,
-      newPermissions.any_member,
-      newPermissions.entity_creation_blocked,
-      newPermissions.all_entity_property_values_locked,
-      newPermissions.maintainers,
-    ])
-
-    console.log(chalk.green(`${chalk.white(className)} class permissions updated to:`))
-    this.jsonPrettyPrint(JSON.stringify(newPermissions))
-  }
-}

+ 0 - 61
cli/src/commands/content-directory/updateEntityPropertyValues.ts

@@ -1,61 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import inquirer from 'inquirer'
-import { InputParser } from '@joystream/cd-schemas'
-import ExitCodes from '../../ExitCodes'
-
-export default class UpdateEntityPropertyValues extends ContentDirectoryCommandBase {
-  static description =
-    'Updates the property values of the specified entity (can be executed in Member, Curator or Lead context)'
-
-  static args = [
-    {
-      name: 'id',
-      required: true,
-      description: 'ID of the Entity',
-    },
-  ]
-
-  static flags = {
-    context: ContentDirectoryCommandBase.contextFlag,
-  }
-
-  async run() {
-    const { id } = this.parse(UpdateEntityPropertyValues).args
-    let { context } = this.parse(UpdateEntityPropertyValues).flags
-
-    if (!context) {
-      context = await this.promptForContext()
-    }
-
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const entity = await this.getEntity(id)
-    const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
-    const defaults = await this.parseToEntityJson(entity)
-
-    const actor = await this.getActor(context, entityClass)
-
-    const isPropertEditableByIndex = await Promise.all(
-      entityClass.properties.map((p, i) => this.isEntityPropertyEditableByActor(entity, i, actor))
-    )
-    const filteredProperties = entityClass.properties.filter((p, i) => isPropertEditableByIndex[i])
-
-    if (!filteredProperties.length) {
-      this.error('No entity properties are editable by choosen actor', { exit: ExitCodes.AccessDenied })
-    }
-
-    const answers: {
-      [key: string]: string | number | null
-    } = await inquirer.prompt(this.getQuestionsFromProperties(filteredProperties, defaults))
-
-    this.jsonPrettyPrint(JSON.stringify(answers))
-    await this.requireConfirmation('Do you confirm the provided input?')
-
-    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
-
-    const operations = await inputParser.getEntityUpdateOperations(answers, entityClass.name.toString(), +id)
-
-    await this.sendAndFollowNamedTx(currentAccount, 'contentDirectory', 'transaction', [actor, operations])
-  }
-}

+ 12 - 5
cli/src/commands/content-directory/addCuratorToGroup.ts → cli/src/commands/content/addCuratorToGroup.ts

@@ -17,8 +17,7 @@ export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBas
   ]
 
   async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+    const lead = await this.getRequiredLeadContext()
 
     let { groupId, curatorId } = this.parse(AddCuratorToGroupCommand).args
 
@@ -34,9 +33,17 @@ export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBas
       await this.getCurator(curatorId)
     }
 
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addCuratorToGroup', [groupId, curatorId])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      'content',
+      'addCuratorToGroup',
+      [groupId, curatorId]
+    )
 
-    console.log(chalk.green(`Curator ${chalk.white(curatorId)} succesfully added to group ${chalk.white(groupId)}!`))
+    console.log(
+      chalk.green(
+        `Curator ${chalk.magentaBright(curatorId)} successfully added to group ${chalk.magentaBright(groupId)}!`
+      )
+    )
   }
 }

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

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+
+export default class ChannelCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Channel details by id.'
+  static args = [
+    {
+      name: 'channelId',
+      required: true,
+      description: 'Name or ID of the Channel',
+    },
+  ]
+
+  async run() {
+    const { channelId } = this.parse(ChannelCommand).args
+    const channel = await this.getApi().channelById(channelId)
+    if (channel) {
+      displayCollapsedRow({
+        'ID': channelId.toString(),
+        'Owner': JSON.stringify(channel.owner.toJSON()),
+        'IsCensored': channel.is_censored.toString(),
+        'RewardAccount': channel.reward_account ? channel.reward_account.toString() : 'NONE',
+      })
+
+      displayHeader(`Media`)
+
+      displayCollapsedRow({
+        'NumberOfVideos': channel.videos.length,
+        'NumberOfPlaylists': channel.playlists.length,
+        'NumberOfSeries': channel.series.length,
+      })
+
+      displayHeader(`MediaData`)
+
+      displayCollapsedRow({
+        'Videos': JSON.stringify(channel.videos.toJSON()),
+        'Playlists': JSON.stringify(channel.playlists.toJSON()),
+        'Series': JSON.stringify(channel.series.toJSON()),
+      })
+    } else {
+      this.error(`Channel not found by channel id: "${channelId}"!`)
+    }
+  }
+}

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

@@ -0,0 +1,25 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+// import chalk from 'chalk'
+import { displayTable } from '../../helpers/display'
+
+export default class ChannelsCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing content directory channels.'
+
+  async run() {
+    const channels = await this.getApi().availableChannels()
+
+    if (channels.length > 0) {
+      displayTable(
+        channels.map(([id, c]) => ({
+          'ID': id.toString(),
+          'Owner': JSON.stringify(c.owner.toJSON()),
+          'IsCensored': c.is_censored.toString(),
+          'RewardAccount': c.reward_account ? c.reward_account.toString() : 'NONE',
+        })),
+        3
+      )
+    } else {
+      this.log('There are no channels yet')
+    }
+  }
+}

+ 66 - 0
cli/src/commands/content/createChannel.ts

@@ -0,0 +1,66 @@
+import { getInputJson } from '../../helpers/InputOutput'
+import { ChannelInputParameters } from '../../Types'
+import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
+import { flags } from '@oclif/command'
+import { CreateInterface } from '@joystream/types'
+import { ChannelCreationParameters } from '@joystream/types/content'
+import { ChannelInputSchema } from '../../json-schemas/ContentDirectory'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import UploadCommandBase from '../../base/UploadCommandBase'
+import chalk from 'chalk'
+import { ChannelMetadata } from '@joystream/metadata-protobuf'
+
+export default class CreateChannelCommand extends UploadCommandBase {
+  static description = 'Create channel inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.ownerContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  async run() {
+    let { context, input } = this.parse(CreateChannelCommand).flags
+
+    // Context
+    if (!context) {
+      context = await this.promptForOwnerContext()
+    }
+    const [actor, address] = await this.getContentActor(context)
+
+    const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)
+    const meta = asValidatedMetadata(ChannelMetadata, channelInput)
+
+    const { coverPhotoPath, avatarPhotoPath } = channelInput
+    const assetsPaths = [coverPhotoPath, avatarPhotoPath].filter((v) => v !== undefined) as string[]
+    const inputAssets = await this.prepareInputAssets(assetsPaths, input)
+    const assets = inputAssets.map(({ parameters }) => ({ Upload: parameters }))
+    // Set assets indexes in the metadata
+    const [coverPhotoIndex, avatarPhotoIndex] = this.assetsIndexes([coverPhotoPath, avatarPhotoPath], assetsPaths)
+    meta.coverPhoto = coverPhotoIndex
+    meta.avatarPhoto = avatarPhotoIndex
+
+    const channelCreationParameters: CreateInterface<ChannelCreationParameters> = {
+      assets,
+      meta: metadataToBytes(ChannelMetadata, meta),
+      reward_account: channelInput.rewardAccount,
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ assets, metadata: meta }))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'createChannel', [
+      actor,
+      channelCreationParameters,
+    ])
+    if (result) {
+      const event = this.findEvent(result, 'content', 'ChannelCreated')
+      this.log(chalk.green(`Channel with id ${chalk.cyanBright(event?.data[1].toString())} successfully created!`))
+    }
+
+    await this.uploadAssets(inputAssets, input)
+  }
+}

+ 53 - 0
cli/src/commands/content/createChannelCategory.ts

@@ -0,0 +1,53 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+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 { ChannelCategoryInputSchema } from '../../json-schemas/ContentDirectory'
+import chalk from 'chalk'
+import { ChannelCategoryMetadata } from '@joystream/metadata-protobuf'
+
+export default class CreateChannelCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Create channel category inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  async run() {
+    const { context, input } = this.parse(CreateChannelCategoryCommand).flags
+
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
+
+    const channelCategoryInput = await getInputJson<ChannelCategoryInputParameters>(input, ChannelCategoryInputSchema)
+    const meta = asValidatedMetadata(ChannelCategoryMetadata, channelCategoryInput)
+
+    const channelCategoryCreationParameters: CreateInterface<ChannelCategoryCreationParameters> = {
+      meta: metadataToBytes(ChannelCategoryMetadata, meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(channelCategoryInput))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(address),
+      'content',
+      'createChannelCategory',
+      [actor, channelCategoryCreationParameters]
+    )
+
+    if (result) {
+      const event = this.findEvent(result, 'content', 'ChannelCategoryCreated')
+      this.log(
+        chalk.green(`ChannelCategory with id ${chalk.cyanBright(event?.data[0].toString())} successfully created!`)
+      )
+    }
+  }
+}

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

@@ -0,0 +1,20 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class CreateCuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Create new Curator Group.'
+  static aliases = ['createCuratorGroup']
+
+  async run() {
+    const lead = await this.getRequiredLeadContext()
+
+    await this.buildAndSendExtrinsic(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      'content',
+      'createCuratorGroup'
+    )
+
+    // TODO: Get id from event?
+    console.log(chalk.green(`New group succesfully created!`))
+  }
+}

+ 94 - 0
cli/src/commands/content/createVideo.ts

@@ -0,0 +1,94 @@
+import UploadCommandBase from '../../base/UploadCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
+import { VideoInputParameters, VideoFileMetadata } from '../../Types'
+import { CreateInterface } from '@joystream/types'
+import { flags } from '@oclif/command'
+import { VideoCreationParameters } from '@joystream/types/content'
+import { IVideoMetadata, VideoMetadata } from '@joystream/metadata-protobuf'
+import { integrateMeta } from '@joystream/metadata-protobuf/utils'
+import chalk from 'chalk'
+
+export default class CreateVideoCommand extends UploadCommandBase {
+  static description = 'Create video under specific channel inside content directory.'
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+    channelId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'ID of the Channel',
+    }),
+  }
+
+  setVideoMetadataDefaults(metadata: IVideoMetadata, videoFileMetadata: VideoFileMetadata): void {
+    const videoMetaToIntegrate = {
+      duration: videoFileMetadata.duration,
+      mediaPixelWidth: videoFileMetadata.width,
+      mediaPixelHeight: videoFileMetadata.height,
+    }
+    const mediaTypeMetaToIntegrate = {
+      codecName: videoFileMetadata.codecName,
+      container: videoFileMetadata.container,
+      mimeMediaType: videoFileMetadata.mimeType,
+    }
+    integrateMeta(metadata, videoMetaToIntegrate, ['duration', 'mediaPixelWidth', 'mediaPixelHeight'])
+    integrateMeta(metadata.mediaType || {}, mediaTypeMetaToIntegrate, ['codecName', 'container', 'mimeMediaType'])
+  }
+
+  async run() {
+    const { input, channelId } = this.parse(CreateVideoCommand).flags
+
+    // Get context
+    const channel = await this.getApi().channelById(channelId)
+    const [actor, address] = await this.getChannelOwnerActor(channel)
+
+    // Get input from file
+    const videoCreationParametersInput = await getInputJson<VideoInputParameters>(input)
+    const meta = asValidatedMetadata(VideoMetadata, videoCreationParametersInput)
+
+    // Assets
+    const { videoPath, thumbnailPhotoPath } = videoCreationParametersInput
+    const assetsPaths = [videoPath, thumbnailPhotoPath].filter((a) => a !== undefined) as string[]
+    const inputAssets = await this.prepareInputAssets(assetsPaths, input)
+    const assets = inputAssets.map(({ parameters }) => ({ Upload: parameters }))
+    // Set assets indexes in the metadata
+    const [videoIndex, thumbnailPhotoIndex] = this.assetsIndexes([videoPath, thumbnailPhotoPath], assetsPaths)
+    meta.video = videoIndex
+    meta.thumbnailPhoto = thumbnailPhotoIndex
+
+    // Try to get video file metadata
+    if (videoIndex !== undefined) {
+      const videoFileMetadata = await this.getVideoFileMetadata(inputAssets[videoIndex].path)
+      this.log('Video media file parameters established:', videoFileMetadata)
+      this.setVideoMetadataDefaults(meta, videoFileMetadata)
+    }
+
+    // Create final extrinsic params and send the extrinsic
+    const videoCreationParameters: CreateInterface<VideoCreationParameters> = {
+      assets,
+      meta: metadataToBytes(VideoMetadata, meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ assets, metadata: meta }))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'createVideo', [
+      actor,
+      channelId,
+      videoCreationParameters,
+    ])
+    if (result) {
+      const event = this.findEvent(result, 'content', 'VideoCreated')
+      const videoId = event?.data[2]
+      this.log(chalk.green(`Video with id ${chalk.cyanBright(videoId?.toString())} successfully created!`))
+    }
+
+    // Upload assets
+    await this.uploadAssets(inputAssets, input)
+  }
+}

+ 53 - 0
cli/src/commands/content/createVideoCategory.ts

@@ -0,0 +1,53 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+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 { VideoCategoryInputSchema } from '../../json-schemas/ContentDirectory'
+import chalk from 'chalk'
+import { VideoCategoryMetadata } from '@joystream/metadata-protobuf'
+
+export default class CreateVideoCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Create video category inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  async run() {
+    const { context, input } = this.parse(CreateVideoCategoryCommand).flags
+
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
+
+    const videoCategoryInput = await getInputJson<VideoCategoryInputParameters>(input, VideoCategoryInputSchema)
+    const meta = asValidatedMetadata(VideoCategoryMetadata, videoCategoryInput)
+
+    const videoCategoryCreationParameters: CreateInterface<VideoCategoryCreationParameters> = {
+      meta: metadataToBytes(VideoCategoryMetadata, meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(videoCategoryInput))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(address),
+      'content',
+      'createVideoCategory',
+      [actor, videoCategoryCreationParameters]
+    )
+
+    if (result) {
+      const event = this.findEvent(result, 'content', 'VideoCategoryCreated')
+      this.log(
+        chalk.green(`VideoCategory with id ${chalk.cyanBright(event?.data[1].toString())} successfully created!`)
+      )
+    }
+  }
+}

+ 5 - 8
cli/src/commands/content-directory/curatorGroup.ts → cli/src/commands/content/curatorGroup.ts

@@ -1,7 +1,7 @@
 import { WorkingGroups } from '../../Types'
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import chalk from 'chalk'
-import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+import { displayCollapsedRow, displayHeader, memberHandle } from '../../helpers/display'
 
 export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
   static description = 'Show Curator Group details by ID.'
@@ -16,23 +16,20 @@ export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
   async run() {
     const { id } = this.parse(CuratorGroupCommand).args
     const group = await this.getCuratorGroup(id)
-    const classesMaintained = (await this.getApi().availableClasses()).filter(([, c]) =>
-      c.class_permissions.maintainers.toArray().some((gId) => gId.toNumber() === parseInt(id))
-    )
     const members = (await this.getApi().groupMembers(WorkingGroups.Curators)).filter((curator) =>
-      group.curators.toArray().some((groupCurator) => groupCurator.eq(curator.workerId))
+      Array.from(group.curators).some((groupCurator) => groupCurator.eq(curator.workerId))
     )
 
     displayCollapsedRow({
       'ID': id,
       'Status': group.active.valueOf() ? 'Active' : 'Inactive',
     })
-    displayHeader(`Classes maintained (${classesMaintained.length})`)
-    this.log(classesMaintained.map(([, c]) => chalk.white(c.name.toString())).join(', '))
     displayHeader(`Group Members (${members.length})`)
     this.log(
       members
-        .map((curator) => chalk.white(`${curator.profile.handle} (WorkerID: ${curator.workerId.toString()})`))
+        .map((curator) =>
+          chalk.magentaBright(`${memberHandle(curator.profile)} (WorkerID: ${curator.workerId.toString()})`)
+        )
         .join(', ')
     )
   }

+ 1 - 2
cli/src/commands/content-directory/curatorGroups.ts → cli/src/commands/content/curatorGroups.ts

@@ -13,8 +13,7 @@ export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
         groups.map(([id, group]) => ({
           'ID': id.toString(),
           'Status': group.active.valueOf() ? 'Active' : 'Inactive',
-          'Classes maintained': group.number_of_classes_maintained.toNumber(),
-          'Members': group.curators.toArray().length,
+          'Members': Array.from(group.curators).length,
         })),
         5
       )

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

@@ -0,0 +1,35 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+
+export default class DeleteChannelCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Delete channel category.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+  }
+
+  static args = [
+    {
+      name: 'channelCategoryId',
+      required: true,
+      description: 'ID of the Channel Category',
+    },
+  ]
+
+  async run() {
+    const { context } = this.parse(DeleteChannelCategoryCommand).flags
+
+    const { channelCategoryId } = this.parse(DeleteChannelCategoryCommand).args
+
+    const channelCategoryIds = await this.getApi().channelCategoryIds()
+
+    if (channelCategoryIds.some((id) => id.toString() === channelCategoryId)) {
+      const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
+
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteChannelCategory', [
+        actor,
+        channelCategoryId,
+      ])
+    } else {
+      this.error('Channel category under given id does not exist...')
+    }
+  }
+}

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

@@ -0,0 +1,35 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+
+export default class DeleteVideoCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Delete video category.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+  }
+
+  static args = [
+    {
+      name: 'videoCategoryId',
+      required: true,
+      description: 'ID of the Video Category',
+    },
+  ]
+
+  async run() {
+    const { context } = this.parse(DeleteVideoCategoryCommand).flags
+
+    const { videoCategoryId } = this.parse(DeleteVideoCategoryCommand).args
+
+    const videoCategoryIds = await this.getApi().videoCategoryIds()
+
+    if (videoCategoryIds.some((id) => id.toString() === videoCategoryId)) {
+      const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
+
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteVideoCategory', [
+        actor,
+        videoCategoryId,
+      ])
+    } else {
+      this.error('Video category under given id does not exist...')
+    }
+  }
+}

+ 15 - 8
cli/src/commands/content-directory/removeCuratorFromGroup.ts → cli/src/commands/content/removeCuratorFromGroup.ts

@@ -17,8 +17,7 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
   ]
 
   async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+    const lead = await this.getRequiredLeadContext()
 
     let { groupId, curatorId } = this.parse(RemoveCuratorFromGroupCommand).args
 
@@ -27,20 +26,28 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
     }
 
     const group = await this.getCuratorGroup(groupId)
-    const groupCuratorIds = group.curators.toArray().map((id) => id.toNumber())
+    const groupCuratorIds = Array.from(group.curators).map((id) => id.toNumber())
 
     if (curatorId === undefined) {
       curatorId = await this.promptForCurator('Choose a Curator to remove', groupCuratorIds)
     } else {
       if (!groupCuratorIds.includes(parseInt(curatorId))) {
-        this.error(`Curator ${chalk.white(curatorId)} is not part of group ${chalk.white(groupId)}`)
+        this.error(`Curator ${chalk.magentaBright(curatorId)} is not part of group ${chalk.magentaBright(groupId)}`)
       }
       await this.getCurator(curatorId)
     }
 
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeCuratorFromGroup', [groupId, curatorId])
-
-    this.log(chalk.green(`Curator ${chalk.white(curatorId)} successfully removed from group ${chalk.white(groupId)}!`))
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      'content',
+      'removeCuratorFromGroup',
+      [groupId, curatorId]
+    )
+
+    this.log(
+      chalk.green(
+        `Curator ${chalk.magentaBright(curatorId)} successfully removed from group ${chalk.magentaBright(groupId)}!`
+      )
+    )
   }
 }

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

@@ -0,0 +1,32 @@
+import UploadCommandBase from '../../base/UploadCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import AssetsSchema from '../../json-schemas/Assets.schema.json'
+import { Assets as AssetsInput } from '../../json-schemas/typings/Assets.schema'
+import { flags } from '@oclif/command'
+import { ContentId } from '@joystream/types/storage'
+
+export default class ReuploadVideoAssetsCommand extends UploadCommandBase {
+  static description = 'Allows reuploading assets that were not successfully uploaded during channel/video creation'
+
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: 'Path to JSON file containing array of assets to reupload (contentIds and paths)',
+    }),
+  }
+
+  async run() {
+    const { input } = this.parse(ReuploadVideoAssetsCommand).flags
+
+    // Get input from file
+    const inputData = await getInputJson<AssetsInput>(input, AssetsSchema)
+    const inputAssets = inputData.map(({ contentId, path }) => ({
+      contentId: ContentId.decode(this.getTypesRegistry(), contentId),
+      path,
+    }))
+
+    // Upload assets
+    await this.uploadAssets(inputAssets, input, '')
+  }
+}

+ 8 - 5
cli/src/commands/content-directory/setCuratorGroupStatus.ts → cli/src/commands/content/setCuratorGroupStatus.ts

@@ -18,8 +18,7 @@ export default class SetCuratorGroupStatusCommand extends ContentDirectoryComman
   ]
 
   async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+    const lead = await this.getRequiredLeadContext()
 
     let { id, status } = this.parse(SetCuratorGroupStatusCommand).args
 
@@ -47,12 +46,16 @@ export default class SetCuratorGroupStatusCommand extends ContentDirectoryComman
       status = !!parseInt(status)
     }
 
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'setCuratorGroupStatus', [id, status])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      'content',
+      'setCuratorGroupStatus',
+      [id, status]
+    )
 
     console.log(
       chalk.green(
-        `Curator Group ${chalk.white(id)} status succesfully changed to: ${chalk.white(
+        `Curator Group ${chalk.magentaBright(id)} status successfully changed to: ${chalk.magentaBright(
           status ? 'Active' : 'Inactive'
         )}!`
       )

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

@@ -0,0 +1,24 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+
+export default class SetFeaturedVideosCommand extends ContentDirectoryCommandBase {
+  static description = 'Set featured videos. Requires lead access.'
+
+  static args = [
+    {
+      name: 'featuredVideoIds',
+      required: true,
+      description: 'Comma-separated video IDs (ie. 1,2,3)',
+    },
+  ]
+
+  async run() {
+    const { featuredVideoIds } = this.parse(SetFeaturedVideosCommand).args
+
+    const [actor, address] = await this.getContentActor('Lead')
+
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'setFeaturedVideos', [
+      actor,
+      (featuredVideoIds as string).split(','),
+    ])
+  }
+}

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

@@ -0,0 +1,82 @@
+import { getInputJson } from '../../helpers/InputOutput'
+import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
+import { ChannelInputParameters } from '../../Types'
+import { flags } from '@oclif/command'
+import UploadCommandBase from '../../base/UploadCommandBase'
+import { CreateInterface } from '@joystream/types'
+import { ChannelUpdateParameters } from '@joystream/types/content'
+import { ChannelInputSchema } from '../../json-schemas/ContentDirectory'
+import { ChannelMetadata } from '@joystream/metadata-protobuf'
+
+export default class UpdateChannelCommand extends UploadCommandBase {
+  static description = 'Update existing content directory channel.'
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'channelId',
+      required: true,
+      description: 'ID of the Channel',
+    },
+  ]
+
+  parseRewardAccountInput(rewardAccount?: string | null): string | null | Uint8Array {
+    if (rewardAccount === undefined) {
+      // Reward account remains unchanged
+      return null
+    } else if (rewardAccount === null) {
+      // Reward account changed to empty
+      return new Uint8Array([1, 0])
+    } else {
+      // Reward account set to new account
+      return rewardAccount
+    }
+  }
+
+  async run() {
+    const {
+      flags: { input },
+      args: { channelId },
+    } = this.parse(UpdateChannelCommand)
+
+    // Context
+    const channel = await this.getApi().channelById(channelId)
+    const [actor, address] = await this.getChannelOwnerActor(channel)
+
+    const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)
+    const meta = asValidatedMetadata(ChannelMetadata, channelInput)
+
+    const { coverPhotoPath, avatarPhotoPath, rewardAccount } = channelInput
+    const inputPaths = [coverPhotoPath, avatarPhotoPath].filter((p) => p !== undefined) as string[]
+    const inputAssets = await this.prepareInputAssets(inputPaths, input)
+    const assets = inputAssets.map(({ parameters }) => ({ Upload: parameters }))
+    // Set assets indexes in the metadata
+    const [coverPhotoIndex, avatarPhotoIndex] = this.assetsIndexes([coverPhotoPath, avatarPhotoPath], inputPaths)
+    meta.coverPhoto = coverPhotoIndex
+    meta.avatarPhoto = avatarPhotoIndex
+
+    const channelUpdateParameters: CreateInterface<ChannelUpdateParameters> = {
+      assets,
+      new_meta: metadataToBytes(ChannelMetadata, meta),
+      reward_account: this.parseRewardAccountInput(rewardAccount),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ assets, metadata: meta, rewardAccount }))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateChannel', [
+      actor,
+      channelId,
+      channelUpdateParameters,
+    ])
+
+    await this.uploadAssets(inputAssets, input)
+  }
+}

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

@@ -0,0 +1,53 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import { ChannelCategoryInputParameters } from '../../Types'
+import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
+import { CreateInterface } from '@joystream/types'
+import { ChannelCategoryUpdateParameters } from '@joystream/types/content'
+import { flags } from '@oclif/command'
+import { ChannelCategoryInputSchema } from '../../json-schemas/ContentDirectory'
+import { ChannelCategoryMetadata } from '@joystream/metadata-protobuf'
+export default class UpdateChannelCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Update channel category inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'channelCategoryId',
+      required: true,
+      description: 'ID of the Channel Category',
+    },
+  ]
+
+  async run() {
+    const { context, input } = this.parse(UpdateChannelCategoryCommand).flags
+
+    const { channelCategoryId } = this.parse(UpdateChannelCategoryCommand).args
+
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
+
+    const channelCategoryInput = await getInputJson<ChannelCategoryInputParameters>(input, ChannelCategoryInputSchema)
+    const meta = asValidatedMetadata(ChannelCategoryMetadata, channelCategoryInput)
+
+    const channelCategoryUpdateParameters: CreateInterface<ChannelCategoryUpdateParameters> = {
+      new_meta: metadataToBytes(ChannelCategoryMetadata, meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(channelCategoryInput))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateChannelCategory', [
+      actor,
+      channelCategoryId,
+      channelCategoryUpdateParameters,
+    ])
+  }
+}

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

@@ -0,0 +1,77 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+import { flags } from '@oclif/command'
+
+export default class UpdateChannelCensorshipStatusCommand extends ContentDirectoryCommandBase {
+  static description = 'Update Channel censorship status (Censored / Not censored).'
+  static flags = {
+    rationale: flags.string({
+      name: 'rationale',
+      required: false,
+      description: 'rationale',
+    }),
+  }
+
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Channel',
+    },
+    {
+      name: 'status',
+      required: false,
+      description: 'New censorship status of the channel (1 - censored, 0 - not censored)',
+    },
+  ]
+
+  async run() {
+    let {
+      args: { id, status },
+      flags: { rationale },
+    } = this.parse(UpdateChannelCensorshipStatusCommand)
+
+    const channel = await this.getApi().channelById(id)
+    const [actor, address] = await this.getCurationActorByChannel(channel)
+
+    if (status === undefined) {
+      status = await this.simplePrompt({
+        type: 'list',
+        message: 'Select new status',
+        choices: [
+          { name: 'Censored', value: true },
+          { name: 'Not censored', value: false },
+        ],
+      })
+    } else {
+      if (status !== '0' && status !== '1') {
+        this.error('Invalid status provided. Use "1" for censored and "0" for not censored.', {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      status = !!parseInt(status)
+    }
+
+    if (rationale === undefined) {
+      rationale = (await this.simplePrompt({
+        message: 'Please provide the rationale for updating the status',
+      })) as string
+    }
+
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateChannelCensorshipStatus', [
+      actor,
+      id,
+      status,
+      rationale,
+    ])
+
+    console.log(
+      chalk.green(
+        `Channel ${chalk.magentaBright(id)} censorship status successfully changed to: ${chalk.magentaBright(
+          status ? 'Censored' : 'Not censored'
+        )}!`
+      )
+    )
+  }
+}

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

@@ -0,0 +1,69 @@
+import { getInputJson } from '../../helpers/InputOutput'
+import { VideoInputParameters } from '../../Types'
+import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
+import UploadCommandBase from '../../base/UploadCommandBase'
+import { flags } from '@oclif/command'
+import { CreateInterface } from '@joystream/types'
+import { VideoUpdateParameters } from '@joystream/types/content'
+import { VideoInputSchema } from '../../json-schemas/ContentDirectory'
+import { VideoMetadata } from '@joystream/metadata-protobuf'
+
+export default class UpdateVideoCommand extends UploadCommandBase {
+  static description = 'Update video under specific id.'
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'videoId',
+      required: true,
+      description: 'ID of the Video',
+    },
+  ]
+
+  async run() {
+    const {
+      flags: { input },
+      args: { videoId },
+    } = this.parse(UpdateVideoCommand)
+
+    // Context
+    const video = await this.getApi().videoById(videoId)
+    const channel = await this.getApi().channelById(video.in_channel.toNumber())
+    const [actor, address] = await this.getChannelOwnerActor(channel)
+
+    const videoInput = await getInputJson<VideoInputParameters>(input, VideoInputSchema)
+    const meta = asValidatedMetadata(VideoMetadata, videoInput)
+
+    const { videoPath, thumbnailPhotoPath } = videoInput
+    const inputPaths = [videoPath, thumbnailPhotoPath].filter((p) => p !== undefined) as string[]
+    const inputAssets = await this.prepareInputAssets(inputPaths, input)
+    const assets = inputAssets.map(({ parameters }) => ({ Upload: parameters }))
+    // Set assets indexes in the metadata
+    const [videoIndex, thumbnailPhotoIndex] = this.assetsIndexes([videoPath, thumbnailPhotoPath], inputPaths)
+    meta.video = videoIndex
+    meta.thumbnailPhoto = thumbnailPhotoIndex
+
+    const videoUpdateParameters: CreateInterface<VideoUpdateParameters> = {
+      assets,
+      new_meta: metadataToBytes(VideoMetadata, meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ assets, newMetadata: meta }))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateVideo', [
+      actor,
+      videoId,
+      videoUpdateParameters,
+    ])
+
+    await this.uploadAssets(inputAssets, input)
+  }
+}

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

@@ -0,0 +1,54 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import { VideoCategoryInputParameters } from '../../Types'
+import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
+import { flags } from '@oclif/command'
+import { CreateInterface } from '@joystream/types'
+import { VideoCategoryUpdateParameters } from '@joystream/types/content'
+import { VideoCategoryInputSchema } from '../../json-schemas/ContentDirectory'
+import { VideoCategoryMetadata } from '@joystream/metadata-protobuf'
+
+export default class UpdateVideoCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Update video category inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'videoCategoryId',
+      required: true,
+      description: 'ID of the Video Category',
+    },
+  ]
+
+  async run() {
+    const { context, input } = this.parse(UpdateVideoCategoryCommand).flags
+
+    const { videoCategoryId } = this.parse(UpdateVideoCategoryCommand).args
+
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
+
+    const videoCategoryInput = await getInputJson<VideoCategoryInputParameters>(input, VideoCategoryInputSchema)
+    const meta = asValidatedMetadata(VideoCategoryMetadata, videoCategoryInput)
+
+    const videoCategoryUpdateParameters: CreateInterface<VideoCategoryUpdateParameters> = {
+      new_meta: metadataToBytes(VideoCategoryMetadata, meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(videoCategoryInput))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateVideoCategory', [
+      actor,
+      videoCategoryId,
+      videoCategoryUpdateParameters,
+    ])
+  }
+}

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

@@ -0,0 +1,78 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+import { flags } from '@oclif/command'
+
+export default class UpdateVideoCensorshipStatusCommand extends ContentDirectoryCommandBase {
+  static description = 'Update Video censorship status (Censored / Not censored).'
+  static flags = {
+    rationale: flags.string({
+      name: 'rationale',
+      required: false,
+      description: 'rationale',
+    }),
+  }
+
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Video',
+    },
+    {
+      name: 'status',
+      required: false,
+      description: 'New video censorship status (1 - censored, 0 - not censored)',
+    },
+  ]
+
+  async run() {
+    let {
+      args: { id, status },
+      flags: { rationale },
+    } = this.parse(UpdateVideoCensorshipStatusCommand)
+
+    const video = await this.getApi().videoById(id)
+    const channel = await this.getApi().channelById(video.in_channel.toNumber())
+    const [actor, address] = await this.getCurationActorByChannel(channel)
+
+    if (status === undefined) {
+      status = await this.simplePrompt({
+        type: 'list',
+        message: 'Select new status',
+        choices: [
+          { name: 'Censored', value: true },
+          { name: 'Not censored', value: false },
+        ],
+      })
+    } else {
+      if (status !== '0' && status !== '1') {
+        this.error('Invalid status provided. Use "1" for Censored and "0" for Not censored.', {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      status = !!parseInt(status)
+    }
+
+    if (rationale === undefined) {
+      rationale = (await this.simplePrompt({
+        message: 'Please provide the rationale for updating the status',
+      })) as string
+    }
+
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateVideoCensorshipStatus', [
+      actor,
+      id,
+      status,
+      rationale,
+    ])
+
+    console.log(
+      chalk.green(
+        `Video ${chalk.magentaBright(id)} censorship status successfully changed to: ${chalk.magentaBright(
+          status ? 'Censored' : 'Not censored'
+        )}!`
+      )
+    )
+  }
+}

部分文件因文件數量過多而無法顯示