Przeglądaj źródła

Merge branch 'olympia' into working_team_olympia_merge

# Conflicts:
#	.travis.yml
#	Cargo.lock
#	Cargo.toml
#	node/Cargo.toml
#	runtime/Cargo.toml
#	runtime/src/runtime_api.rs
Shamil Gadelshin 4 lat temu
rodzic
commit
5082930923
100 zmienionych plików z 5092 dodań i 817 usunięć
  1. 7 2
      .dockerignore
  2. 57 0
      .env
  3. 20 0
      .github/workflows/content-directory-schemas.yml
  4. 2 2
      .github/workflows/joystream-cli.yml
  5. 37 0
      .github/workflows/joystream-node-checks.yml
  6. 79 0
      .github/workflows/joystream-node-docker.yml
  7. 2 2
      .github/workflows/joystream-types.yml
  8. 2 2
      .github/workflows/network-tests.yml
  9. 2 2
      .github/workflows/pioneer.yml
  10. 225 0
      .github/workflows/run-network-tests.yml
  11. 2 2
      .github/workflows/storage-node.yml
  12. 4 1
      .gitignore
  13. 0 44
      .travis.yml
  14. 378 175
      Cargo.lock
  15. 2 0
      Cargo.toml
  16. 9 3
      README.md
  17. 14 0
      apps.Dockerfile
  18. 55 0
      build.sh
  19. 1 0
      cli/.eslintignore
  20. 7 3
      cli/.eslintrc.js
  21. 347 18
      cli/README.md
  22. 31 7
      cli/package.json
  23. 3 0
      cli/src/@types/@ffmpeg-installer/ffmpeg/index.d.ts
  24. 1 0
      cli/src/@types/inquirer-datepicker-prompt/index.d.ts
  25. 1 0
      cli/src/@types/ipfs-http-client/index.d.ts
  26. 1 0
      cli/src/@types/ipfs-only-hash/index.d.ts
  27. 87 5
      cli/src/Api.ts
  28. 2 0
      cli/src/ExitCodes.ts
  29. 7 201
      cli/src/Types.ts
  30. 30 3
      cli/src/base/AccountsCommandBase.ts
  31. 87 92
      cli/src/base/ApiCommandBase.ts
  32. 359 0
      cli/src/base/ContentDirectoryCommandBase.ts
  33. 5 0
      cli/src/base/DefaultCommandBase.ts
  34. 65 0
      cli/src/base/MediaCommandBase.ts
  35. 5 0
      cli/src/base/StateAwareCommandBase.ts
  36. 42 117
      cli/src/base/WorkingGroupsCommandBase.ts
  37. 1 1
      cli/src/commands/api/setUri.ts
  38. 79 0
      cli/src/commands/content-directory/addClassSchema.ts
  39. 42 0
      cli/src/commands/content-directory/addCuratorToGroup.ts
  40. 44 0
      cli/src/commands/content-directory/addMaintainerToClass.ts
  41. 55 0
      cli/src/commands/content-directory/class.ts
  42. 24 0
      cli/src/commands/content-directory/classes.ts
  43. 50 0
      cli/src/commands/content-directory/createClass.ts
  44. 18 0
      cli/src/commands/content-directory/createCuratorGroup.ts
  45. 39 0
      cli/src/commands/content-directory/curatorGroup.ts
  46. 25 0
      cli/src/commands/content-directory/curatorGroups.ts
  47. 45 0
      cli/src/commands/content-directory/entities.ts
  48. 44 0
      cli/src/commands/content-directory/entity.ts
  49. 50 0
      cli/src/commands/content-directory/initialize.ts
  50. 46 0
      cli/src/commands/content-directory/removeCuratorFromGroup.ts
  51. 35 0
      cli/src/commands/content-directory/removeCuratorGroup.ts
  52. 57 0
      cli/src/commands/content-directory/removeEntity.ts
  53. 44 0
      cli/src/commands/content-directory/removeMaintainerFromClass.ts
  54. 61 0
      cli/src/commands/content-directory/setCuratorGroupStatus.ts
  55. 55 0
      cli/src/commands/content-directory/updateClassPermissions.ts
  56. 53 0
      cli/src/commands/media/createChannel.ts
  57. 57 0
      cli/src/commands/media/curateContent.ts
  58. 25 0
      cli/src/commands/media/myChannels.ts
  59. 33 0
      cli/src/commands/media/myVideos.ts
  60. 44 0
      cli/src/commands/media/removeChannel.ts
  61. 49 0
      cli/src/commands/media/removeVideo.ts
  62. 97 0
      cli/src/commands/media/updateChannel.ts
  63. 106 0
      cli/src/commands/media/updateVideo.ts
  64. 59 0
      cli/src/commands/media/updateVideoLicense.ts
  65. 382 0
      cli/src/commands/media/uploadVideo.ts
  66. 197 58
      cli/src/commands/working-groups/createOpening.ts
  67. 1 1
      cli/src/commands/working-groups/decreaseWorkerStake.ts
  68. 1 1
      cli/src/commands/working-groups/evictWorker.ts
  69. 1 1
      cli/src/commands/working-groups/fillOpening.ts
  70. 1 4
      cli/src/commands/working-groups/increaseStake.ts
  71. 1 1
      cli/src/commands/working-groups/leaveRole.ts
  72. 22 0
      cli/src/commands/working-groups/setDefaultGroup.ts
  73. 1 1
      cli/src/commands/working-groups/slashWorker.ts
  74. 1 1
      cli/src/commands/working-groups/startAcceptingApplications.ts
  75. 1 1
      cli/src/commands/working-groups/startReviewPeriod.ts
  76. 1 1
      cli/src/commands/working-groups/terminateApplication.ts
  77. 1 1
      cli/src/commands/working-groups/updateRewardAccount.ts
  78. 1 1
      cli/src/commands/working-groups/updateRoleAccount.ts
  79. 3 3
      cli/src/commands/working-groups/updateWorkerReward.ts
  80. 109 0
      cli/src/helpers/InputOutput.ts
  81. 294 0
      cli/src/helpers/JsonSchemaPrompt.ts
  82. 1 0
      cli/src/helpers/display.ts
  83. 9 0
      cli/src/helpers/prompting.ts
  84. 73 0
      cli/src/json-schemas/WorkingGroupOpening.schema.json
  85. 60 0
      cli/src/json-schemas/typings/WorkingGroupOpening.schema.d.ts
  86. 0 59
      cli/src/promptOptions/addWorkerOpening.ts
  87. 3 1
      cli/tsconfig.json
  88. 10 0
      content-directory-schemas/.gitignore
  89. 1 0
      content-directory-schemas/.npmignore
  90. 279 0
      content-directory-schemas/README.md
  91. 52 0
      content-directory-schemas/examples/createChannel.ts
  92. 68 0
      content-directory-schemas/examples/createChannelWithoutTransaction.ts
  93. 76 0
      content-directory-schemas/examples/createVideo.ts
  94. 47 0
      content-directory-schemas/examples/updateChannelTitle.ts
  95. 47 0
      content-directory-schemas/examples/updateChannelTitleWithoutTransaction.ts
  96. 7 0
      content-directory-schemas/inputs/classes/ChannelClass.json
  97. 6 0
      content-directory-schemas/inputs/classes/ContentCategoryClass.json
  98. 7 0
      content-directory-schemas/inputs/classes/HttpMediaLocationClass.json
  99. 7 0
      content-directory-schemas/inputs/classes/JoystreamMediaLocationClass.json
  100. 6 0
      content-directory-schemas/inputs/classes/KnownLicenseClass.json

+ 7 - 2
.dockerignore

@@ -1,2 +1,7 @@
-**target*
-**node_modules*
+target/
+**node_modules*
+.tmp/
+.vscode/
+query-node/generated
+query-node/**/dist
+query-node/lib

+ 57 - 0
.env

@@ -0,0 +1,57 @@
+COMPOSE_PROJECT_NAME=joystream
+
+###########################
+#     Common settings     #
+###########################
+
+# The env variables below are by default used by all services and should be
+# overriden in local env files (e.g. ./generated/indexer) if needed
+# DB config
+DB_NAME=query_node
+DB_USER=postgres
+DB_PASS=postgres
+DB_HOST=localhost
+DB_PORT=5432
+DEBUG=index-builder:*
+TYPEORM_LOGGING=error
+
+###########################
+#    Indexer options      #
+###########################
+
+# Substrate endpoint to source events from
+WS_PROVIDER_ENDPOINT_URI=ws://joystream-node:9944/
+# Block height to start indexing from.
+# Note, that if there are already some indexed events, this setting is ignored
+BLOCK_HEIGHT=0
+
+# Custom types to register for Substrate API
+# TYPE_REGISTER_PACKAGE_NAME=
+# TYPE_REGISTER_PACKAGE_VERSION=
+# TYPE_REGISTER_FUNCTION=
+
+# Redis cache server
+REDIS_URI=redis://localhost:6379/0
+
+###########################
+#    Processor options    #
+###########################
+
+# Where the mapping scripts are located, relative to ./generated/indexer
+TYPES_JSON=../../typedefs.json
+
+# Indexer GraphQL API endpoint to fetch indexed events
+INDEXER_ENDPOINT_URL=http://localhost:4100/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    #
+###############################
+
+GRAPHQL_SERVER_PORT=4002
+GRAPHQL_SERVER_HOST=localhost
+WARTHOG_APP_PORT=4002
+WARTHOG_APP_HOST=localhost

+ 20 - 0
.github/workflows/content-directory-schemas.yml

@@ -0,0 +1,20 @@
+name: content-directory-schemas
+on: [pull_request, push]
+
+jobs:
+  schemas_checks:
+    name: Checks
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: [12.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: validate
+      run: |
+        yarn install --frozen-lockfile
+        yarn workspace cd-schemas checks --quiet

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

@@ -17,7 +17,7 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile
-        yarn workspace @joystream/cli checks
+        yarn workspace @joystream/cli checks --quiet
     - name: yarn pack test
       run: |
         yarn workspace @joystream/cli pack --filename cli-pack-test.tgz
@@ -39,7 +39,7 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
-        yarn workspace @joystream/cli checks
+        yarn workspace @joystream/cli checks --quiet
     - name: yarn pack test
       run: |
         yarn workspace @joystream/cli pack --filename cli-pack-test.tgz

+ 37 - 0
.github/workflows/joystream-node-checks.yml

@@ -0,0 +1,37 @@
+name: joystream-node-checks
+on:
+  pull_request:
+
+jobs:
+  checks:
+    name: joystream-node checks
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+      - uses: technote-space/get-diff-action@v3
+        with:
+          PREFIX_FILTER: |
+            node
+            runtime-modules
+            utils/chain-spec-builder
+          SUFFIX_FILTER: |
+            .rs
+          FILES: |
+            Cargo.lock
+            Cargo.toml
+
+      # TODO: Look for change in source code but no corresponding version bump of runtime or binaries
+      # - name: Check version modified correctly
+      #   if: env.GIT_DIFF
+
+      # This Building natively is not really necessary because we have the docker build which
+      # hapens in the run-network-tests workflow which is sufficient!
+      # - name: Build if runtime was modified
+      #   run: |
+      #     ./setup.sh
+      #     yarn cargo-checks
+      #     yarn cargo-build
+      #   if: env.GIT_DIFF

+ 79 - 0
.github/workflows/joystream-node-docker.yml

@@ -0,0 +1,79 @@
+name: joystream-node-docker
+on: push
+
+jobs:
+  build:
+    name: Build joystream/node Docker image
+    if: github.repository == 'Joystream/joystream'
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+
+      - id: compute_shasum
+        name: Compute runtime code shasum
+        run: |
+          export RUNTIME_CODE_SHASUM=`scripts/runtime-code-shasum.sh`
+          echo "::set-output name=shasum::${RUNTIME_CODE_SHASUM}"
+
+      - name: Setup cache directory
+        run: mkdir ~/docker-images
+
+      - name: Cache docker images
+        uses: actions/cache@v2
+        env:
+          cache-name: joystream-node-docker
+        with:
+          path: ~/docker-images
+          key: ${{ env.cache-name }}-${{ steps.compute_shasum.outputs.shasum }}
+
+      - name: Check if we have cached image
+        continue-on-error: true
+        run: |
+          if [ -f ~/docker-images/joystream-node-docker-image.tar.gz ]; then
+            docker load --input ~/docker-images/joystream-node-docker-image.tar.gz
+            cp ~/docker-images/joystream-node-docker-image.tar.gz .
+          fi
+
+      - name: Check if we have pre-built image on Dockerhub
+        continue-on-error: true
+        run: |
+          if ! [ -f joystream-node-docker-image.tar.gz ]; then
+            docker pull joystream/node:${{ steps.compute_shasum.outputs.shasum }}
+            docker image tag joystream/node:${{ steps.compute_shasum.outputs.shasum }} joystream/node:latest
+            docker save --output joystream-node-docker-image.tar joystream/node:latest
+            gzip joystream-node-docker-image.tar
+            cp joystream-node-docker-image.tar.gz ~/docker-images/
+          fi
+
+      - 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 save --output joystream-node-docker-image.tar joystream/node
+            gzip joystream-node-docker-image.tar
+            cp joystream-node-docker-image.tar.gz ~/docker-images/
+            echo "::set-env name=NEW_BUILD::true"
+          fi
+
+      - name: Save joystream/node image to Artifacts
+        uses: actions/upload-artifact@v2
+        with:
+          name: ${{ steps.compute_shasum.outputs.shasum }}-joystream-node-docker-image.tar.gz
+          path: joystream-node-docker-image.tar.gz
+
+      - name: Login to DockerHub
+        uses: docker/login-action@v1
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_PASSWORD }}
+        if: env.NEW_BUILD
+
+      - name: Publish new image to DockerHub
+        run: |
+          docker image tag joystream/node joystream/node:${{ steps.compute_shasum.outputs.shasum }}
+          docker push joystream/node:${{ steps.compute_shasum.outputs.shasum }}
+        if: env.NEW_BUILD
+  

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

@@ -17,7 +17,7 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile
-        yarn workspace @joystream/types checks
+        yarn workspace @joystream/types checks --quiet
     - name: npm pack test
       run: |
         cd types
@@ -40,7 +40,7 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
-        yarn workspace @joystream/types checks
+        yarn workspace @joystream/types checks --quiet
     - name: npm pack test
       run: |
         cd types

+ 2 - 2
.github/workflows/network-tests.yml

@@ -17,7 +17,7 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile
-        yarn workspace joystream-testing checks
+        yarn workspace network-tests checks --quiet
 
   network_build_osx:
     name: MacOS Checks
@@ -34,4 +34,4 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
-        yarn workspace joystream-testing checks
+        yarn workspace network-tests checks --quiet

+ 2 - 2
.github/workflows/pioneer.yml

@@ -51,7 +51,7 @@ jobs:
     - name: lint
       run: |
         yarn install --frozen-lockfile
-        yarn workspace pioneer lint
+        yarn workspace pioneer lint --quiet
 
   pioneer_lint_osx:
     name: MacOS Linting
@@ -68,4 +68,4 @@ jobs:
     - name: lint
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
-        yarn workspace pioneer lint
+        yarn workspace pioneer lint --quiet

+ 225 - 0
.github/workflows/run-network-tests.yml

@@ -0,0 +1,225 @@
+name: run-network-tests
+on:
+  pull_request:
+    types: [opened, synchronize]
+
+  workflow_dispatch:
+    # TODO: add an input so dispatcher can specify a list of tests to run,
+    # composed of the job ids separated by `:`
+    # for eg.
+    #   'network_tests_1:network_tests_3'
+    #   'network_tests_2'
+    # inputs:
+    #   test_to_run:
+    #     description: 'Tests to run'
+    #     required: false
+    #     default: 'all'
+
+jobs:
+  build_images:
+    name: Build joystream/node
+    runs-on: ubuntu-latest
+    outputs:
+      use_artifact: ${{ steps.compute_shasum.outputs.shasum }}-joystream-node-docker-image.tar.gz
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+
+      - id: compute_shasum
+        name: Compute runtime code shasum
+        run: |
+          export RUNTIME_CODE_SHASUM=`scripts/runtime-code-shasum.sh`
+          echo "::set-output name=shasum::${RUNTIME_CODE_SHASUM}"
+
+      - name: Setup cache directory
+        run: mkdir ~/docker-images
+
+      - name: Cache docker images
+        uses: actions/cache@v2
+        env:
+          cache-name: joystream-node-docker
+        with:
+          path: ~/docker-images
+          key: ${{ env.cache-name }}-${{ steps.compute_shasum.outputs.shasum }}
+
+      - name: Check if we have cached image
+        continue-on-error: true
+        run: |
+          if [ -f ~/docker-images/joystream-node-docker-image.tar.gz ]; then
+            docker load --input ~/docker-images/joystream-node-docker-image.tar.gz
+            cp ~/docker-images/joystream-node-docker-image.tar.gz .
+          fi
+
+      - name: Check if we have pre-built image on Dockerhub
+        continue-on-error: true
+        run: |
+          if ! [ -f joystream-node-docker-image.tar.gz ]; then
+            docker pull joystream/node:${{ steps.compute_shasum.outputs.shasum }}
+            docker image tag joystream/node:${{ steps.compute_shasum.outputs.shasum }} joystream/node:latest
+            docker save --output joystream-node-docker-image.tar joystream/node:latest
+            gzip joystream-node-docker-image.tar
+            cp joystream-node-docker-image.tar.gz ~/docker-images/
+          fi
+
+      - 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 save --output joystream-node-docker-image.tar joystream/node
+            gzip joystream-node-docker-image.tar
+            cp joystream-node-docker-image.tar.gz ~/docker-images/
+          fi
+
+      - name: Save joystream/node image to Artifacts
+        uses: actions/upload-artifact@v2
+        with:
+          name: ${{ steps.compute_shasum.outputs.shasum }}-joystream-node-docker-image.tar.gz
+          path: joystream-node-docker-image.tar.gz
+  
+  basic_runtime_with_upgrade:
+    name: Integration Tests (Runtime Upgrade)
+    needs: build_images
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+      - name: Get artifacts
+        uses: actions/download-artifact@v2
+        with:
+          name: ${{ needs.build_images.outputs.use_artifact }}
+      - name: Install artifacts
+        run: |
+          docker load --input joystream-node-docker-image.tar.gz
+          docker images
+      - name: Install packages and dependencies
+        run: yarn install --frozen-lockfile
+      - name: Ensure tests are runnable
+        run: yarn workspace network-tests build
+      - name: Execute network tests
+        run: RUNTIME=babylon tests/network-tests/run-tests.sh full
+
+  basic_runtime:
+    name: Integration Tests (New Chain)
+    needs: build_images
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+      - name: Get artifacts
+        uses: actions/download-artifact@v2
+        with:
+          name: ${{ needs.build_images.outputs.use_artifact }}
+      - name: Install artifacts
+        run: |
+          docker load --input joystream-node-docker-image.tar.gz
+          docker images
+      - name: Install packages and dependencies
+        run: yarn install --frozen-lockfile
+      - name: Ensure tests are runnable
+        run: yarn workspace network-tests build
+      - name: Execute network tests
+        run: tests/network-tests/run-tests.sh full
+
+  content_dir_init:
+    name: Content Directory Initialization
+    needs: build_images
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+      - name: Get artifacts
+        uses: actions/download-artifact@v2
+        with:
+          name: ${{ needs.build_images.outputs.use_artifact }}
+      - name: Install artifacts
+        run: |
+          docker load --input joystream-node-docker-image.tar.gz
+          docker images
+      - name: Install packages and dependencies
+        run: yarn install --frozen-lockfile
+      - name: Ensure tests are runnable
+        run: yarn workspace cd-schemas checks --quiet
+      - name: Start chain
+        run: docker-compose up -d joystream-node
+      - name: Initialize the content directory
+        run: yarn workspace cd-schemas initialize:dev
+
+  query_node:
+    name: Query Node Integration Tests
+    needs: build_images
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+      - name: Get artifacts
+        uses: actions/download-artifact@v2
+        with:
+          name: ${{ needs.build_images.outputs.use_artifact }}
+      - name: Install artifacts
+        run: |
+          docker load --input joystream-node-docker-image.tar.gz
+          docker images
+      - name: Install packages and dependencies
+        run: yarn install --frozen-lockfile
+      - name: Ensure query-node builds
+        run: yarn workspace query-node-root build
+      - name: Ensure tests are runnable
+        run: yarn workspace network-tests build
+      # Bring up hydra query-node development instance, then run content directory
+      # integration tests
+      - name: Execute Tests
+        run: query-node/run-tests.sh
+  
+  storage_node:
+    name: Storage Node Tests
+    needs: build_images
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+      - name: Get artifacts
+        uses: actions/download-artifact@v2
+        with:
+          name: ${{ needs.build_images.outputs.use_artifact }}
+      - name: Install artifacts
+        run: |
+          docker load --input joystream-node-docker-image.tar.gz
+          docker images
+      - name: Install packages and dependencies
+        run: yarn install --frozen-lockfile
+      - name: Build storage node
+        run: yarn workspace storage-node build
+      - name: Start Services
+        run: |
+          docker-compose up -d ipfs
+          docker-compose up -d joystream-node
+      - name: Configure and start development storage node
+        run: |
+          DEBUG=* yarn storage-cli dev-init
+          docker-compose up -d colossus
+      - name: Test uploading
+        run: |
+          WAIT_TIME=90
+          export DEBUG=joystream:*
+          for i in {1..4}; do
+            [ "$i" == "4" ] && exit -1
+            echo "Waiting for ipfs name registration"
+            sleep ${WAIT_TIME}
+            if yarn storage-cli upload ./pioneer/packages/apps/public/images/default-thumbnail.png 1 0; then
+              break
+            else
+              echo "Upload test failed, will retry"
+            fi
+          done

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

@@ -17,7 +17,7 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile
-        yarn workspace storage-node checks
+        yarn workspace storage-node checks --quiet
 
   storage_node_build_osx:
     name: MacOS Checks
@@ -34,4 +34,4 @@ jobs:
     - name: checks
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
-        yarn workspace storage-node checks
+        yarn workspace storage-node checks --quiet

+ 4 - 1
.gitignore

@@ -28,7 +28,10 @@ yarn*
 *.wasm
 
 # Temporary files
-.tmp/
+**.tmp/
 
 # Istanbul report output
 **.nyc_output/
+
+# eslint cache
+**/.eslintcache

+ 0 - 44
.travis.yml

@@ -1,44 +0,0 @@
-language: rust
-
-# Caching of the runtime .wasm blob poses a problem.
-# See: https://github.com/Joystream/joystream/issues/466
-# Always starting with a clean slate is probably better, it allows us to ensure
-# the WASM runtime is always rebuilt. It also allows us to detect when certain upstream dependencies
-# sometimes break the build. When cache is enabled do not use the produced WASM build.
-# This also means the binary should not be used to produce the final chainspec file (because the same
-# one is embedded in the binary)
-# cache: cargo
-
-rust: stable
-
-# Skip Rust build in a pull request if no rust project files were modified
-before_install:
-  - |
-    if [ "$TRAVIS_PULL_REQUEST" != "false" ]
-      then
-      if ! git diff --name-only $TRAVIS_COMMIT_RANGE | grep -qE "(.rs|Cargo.(lock|toml))$"
-        then
-        echo "No changes to Rust or Cargo Files, CI not running."
-        travis_terminate 0
-      fi
-    fi
-
-install:
-  - rustup install nightly-2020-07-12 --force
-  - rustup install nightly-2020-05-23 --force
-  - rustup target add wasm32-unknown-unknown --toolchain nightly-2020-05-23
-  # travis installs rust using rustup with the "minimal" profile so these tools are not installed by default
-  - rustup component add rustfmt
-  - rustup component add clippy --toolchain nightly-2020-07-12
-
-before_script:
-  - cargo fmt --all -- --check
-
-script:
-  - export WASM_BUILD_TOOLCHAIN=nightly-2020-05-23
-  - BUILD_DUMMY_WASM_BINARY=1 cargo +nightly-2020-07-12 clippy --release --all -- -D warnings
-  - travis_wait 75 cargo +nightly-2020-07-12 test --release --verbose --all -- --ignored
-  - cargo +nightly-2020-07-12 build --release
-  - ls -l ./target/release/wbuild/joystream-node-runtime/
-  - ./target/release/joystream-node --version
-  - ./target/release/chain-spec-builder --version

Plik diff jest za duży
+ 378 - 175
Cargo.lock


+ 2 - 0
Cargo.toml

@@ -20,6 +20,8 @@ members = [
 	"runtime-modules/versioned-store-permissions",
 	"runtime-modules/working-group",
 	"runtime-modules/working-team",
+	"runtime-modules/content-directory",
+	"runtime-modules/constitution",
 	"node",
 	"utils/chain-spec-builder/"
 ]

+ 9 - 3
README.md

@@ -18,7 +18,7 @@ The following tools are required for building, testing and contributing to this
 - [Rust](https://www.rust-lang.org/tools/install) toolchain - _required_
 - [nodejs](https://nodejs.org/) v12.x - _required_
 - [yarn classic](https://classic.yarnpkg.com/en/docs/install) package manager v1.22.x- _required_
-- [docker](https://www.docker.com/get-started) - _optional_
+- [docker](https://www.docker.com/get-started) and docker-compose - _optional_
 - [ansible](https://www.ansible.com/) - _optional_
 
 If you use VSCode as your code editor we recommend using the workspace [settings](devops/vscode/settings.json) for recommend eslint plugin to function properly.
@@ -93,8 +93,8 @@ You can also run your our own joystream-node:
 
 ```sh
 git checkout master
-WASM_BUILD_TOOLCHAIN=nightly-2020-05-23 cargo build --release
-./target/release/joystream-node -- --pruning archive --chain testnets/rome.json
+WASM_BUILD_TOOLCHAIN=nightly-2020-10-06 cargo build --release
+./target/release/joystream-node -- --pruning archive --chain testnets/joy-testnet-4.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:
@@ -105,6 +105,12 @@ Learn more about [joystream-node](node/README.md).
 
 A step by step guide to setup a full node and validator on the Joystream testnet, can be found [here](https://github.com/Joystream/helpdesk/tree/master/roles/validators).
 
+### Integration tests
+
+```bash
+tests/network-tests/run-tests.sh
+```
+
 ### Contributing
 
 We have lots of good first [issues](https://github.com/Joystream/joystream/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) open to help you get started on contributing code. If you are not a developer you can still make valuable contributions by testing our software and providing feedback and opening new issues.

+ 14 - 0
apps.Dockerfile

@@ -0,0 +1,14 @@
+FROM node:12 as builder
+
+WORKDIR /joystream
+COPY . /joystream
+
+# Do not set NODE_ENV=production until after running yarn install
+# to ensure dev dependencies are installed.
+RUN yarn install --frozen-lockfile
+
+RUN yarn workspace pioneer build
+RUN yarn workspace storage-node build
+RUN yarn workspace query-node-root build
+
+ENTRYPOINT [ "yarn" ]

+ 55 - 0
build.sh

@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+
+set -e
+
+yarn
+yarn workspace @joystream/types build
+yarn workspace cd-schemas generate:all
+yarn workspace 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

+ 1 - 0
cli/.eslintignore

@@ -1 +1,2 @@
 /lib
+.eslintrc.js

+ 7 - 3
cli/.eslintrc.js

@@ -2,6 +2,9 @@ module.exports = {
   env: {
     mocha: true,
   },
+  parserOptions: {
+    project: './tsconfig.json'
+  },
   extends: [
     // The oclif rules have some code-style/formatting rules which may conflict with
     // our prettier global settings. Disabling for now
@@ -11,7 +14,8 @@ module.exports = {
     // "oclif-typescript",
   ],
   rules: {
-    "no-unused-vars": "off", // Required by the typescript rule below
-    "@typescript-eslint/no-unused-vars": ["error"]
-  }
+    'no-unused-vars': 'off', // Required by the typescript rule below
+    '@typescript-eslint/no-unused-vars': ['error'],
+    '@typescript-eslint/no-floating-promises': 'error',
+  },
 }

+ 347 - 18
cli/README.md

@@ -44,7 +44,7 @@ $ npm install -g @joystream/cli
 $ joystream-cli COMMAND
 running command...
 $ joystream-cli (-v|--version|version)
-@joystream/cli/0.1.0 linux-x64 node-v13.12.0
+@joystream/cli/0.2.0 linux-x64 node-v13.12.0
 $ joystream-cli --help [COMMAND]
 USAGE
   $ joystream-cli COMMAND
@@ -76,8 +76,29 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli api:inspect`](#joystream-cli-apiinspect)
 * [`joystream-cli api:setUri [URI]`](#joystream-cli-apiseturi-uri)
 * [`joystream-cli autocomplete [SHELL]`](#joystream-cli-autocomplete-shell)
+* [`joystream-cli content-directory:addClassSchema`](#joystream-cli-content-directoryaddclassschema)
+* [`joystream-cli content-directory:addCuratorToGroup [GROUPID] [CURATORID]`](#joystream-cli-content-directoryaddcuratortogroup-groupid-curatorid)
+* [`joystream-cli content-directory:addMaintainerToClass [CLASSNAME] [GROUPID]`](#joystream-cli-content-directoryaddmaintainertoclass-classname-groupid)
+* [`joystream-cli content-directory:class CLASSNAME`](#joystream-cli-content-directoryclass-classname)
+* [`joystream-cli content-directory:classes`](#joystream-cli-content-directoryclasses)
+* [`joystream-cli content-directory:createClass`](#joystream-cli-content-directorycreateclass)
+* [`joystream-cli content-directory:createCuratorGroup`](#joystream-cli-content-directorycreatecuratorgroup)
+* [`joystream-cli content-directory:curatorGroup ID`](#joystream-cli-content-directorycuratorgroup-id)
+* [`joystream-cli content-directory:curatorGroups`](#joystream-cli-content-directorycuratorgroups)
+* [`joystream-cli content-directory:entities CLASSNAME [PROPERTIES]`](#joystream-cli-content-directoryentities-classname-properties)
+* [`joystream-cli content-directory:entity ID`](#joystream-cli-content-directoryentity-id)
+* [`joystream-cli content-directory:removeCuratorGroup [ID]`](#joystream-cli-content-directoryremovecuratorgroup-id)
+* [`joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]`](#joystream-cli-content-directoryremovemaintainerfromclass-classname-groupid)
+* [`joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]`](#joystream-cli-content-directorysetcuratorgroupstatus-id-status)
+* [`joystream-cli content-directory:updateClassPermissions [CLASSNAME]`](#joystream-cli-content-directoryupdateclasspermissions-classname)
 * [`joystream-cli council:info`](#joystream-cli-councilinfo)
 * [`joystream-cli help [COMMAND]`](#joystream-cli-help-command)
+* [`joystream-cli media:createChannel`](#joystream-cli-mediacreatechannel)
+* [`joystream-cli media:myChannels`](#joystream-cli-mediamychannels)
+* [`joystream-cli media:myVideos`](#joystream-cli-mediamyvideos)
+* [`joystream-cli media:updateChannel [ID]`](#joystream-cli-mediaupdatechannel-id)
+* [`joystream-cli media:updateVideo [ID]`](#joystream-cli-mediaupdatevideo-id)
+* [`joystream-cli media:uploadVideo FILEPATH`](#joystream-cli-mediauploadvideo-filepath)
 * [`joystream-cli working-groups:application WGAPPLICATIONID`](#joystream-cli-working-groupsapplication-wgapplicationid)
 * [`joystream-cli working-groups:createOpening`](#joystream-cli-working-groupscreateopening)
 * [`joystream-cli working-groups:decreaseWorkerStake WORKERID`](#joystream-cli-working-groupsdecreaseworkerstake-workerid)
@@ -288,6 +309,224 @@ EXAMPLES
 
 _See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v0.2.0/src/commands/autocomplete/index.ts)_
 
+## `joystream-cli content-directory:addClassSchema`
+
+Add a new schema to a class inside content directory. Requires lead access.
+
+```
+USAGE
+  $ joystream-cli content-directory:addClassSchema
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/content-directory/addClassSchema.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/addClassSchema.ts)_
+
+## `joystream-cli content-directory:addCuratorToGroup [GROUPID] [CURATORID]`
+
+Add Curator to existing Curator Group.
+
+```
+USAGE
+  $ joystream-cli content-directory:addCuratorToGroup [GROUPID] [CURATORID]
+
+ARGUMENTS
+  GROUPID    ID of the Curator Group
+  CURATORID  ID of the curator
+```
+
+_See code: [src/commands/content-directory/addCuratorToGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/addCuratorToGroup.ts)_
+
+## `joystream-cli content-directory:addMaintainerToClass [CLASSNAME] [GROUPID]`
+
+Add maintainer (Curator Group) to a class.
+
+```
+USAGE
+  $ joystream-cli content-directory:addMaintainerToClass [CLASSNAME] [GROUPID]
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the class (ie. Video)
+  GROUPID    ID of the Curator Group to add as class maintainer
+```
+
+_See code: [src/commands/content-directory/addMaintainerToClass.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/addMaintainerToClass.ts)_
+
+## `joystream-cli content-directory:class CLASSNAME`
+
+Show Class details by id or name.
+
+```
+USAGE
+  $ joystream-cli content-directory:class CLASSNAME
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the Class
+```
+
+_See code: [src/commands/content-directory/class.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/class.ts)_
+
+## `joystream-cli content-directory:classes`
+
+List existing content directory classes.
+
+```
+USAGE
+  $ joystream-cli content-directory:classes
+```
+
+_See code: [src/commands/content-directory/classes.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/classes.ts)_
+
+## `joystream-cli content-directory:createClass`
+
+Create class inside content directory. Requires lead access.
+
+```
+USAGE
+  $ joystream-cli content-directory:createClass
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/content-directory/createClass.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/createClass.ts)_
+
+## `joystream-cli content-directory:createCuratorGroup`
+
+Create new Curator Group.
+
+```
+USAGE
+  $ joystream-cli content-directory:createCuratorGroup
+
+ALIASES
+  $ joystream-cli addCuratorGroup
+```
+
+_See code: [src/commands/content-directory/createCuratorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/createCuratorGroup.ts)_
+
+## `joystream-cli content-directory:curatorGroup ID`
+
+Show Curator Group details by ID.
+
+```
+USAGE
+  $ joystream-cli content-directory:curatorGroup ID
+
+ARGUMENTS
+  ID  ID of the Curator Group
+```
+
+_See code: [src/commands/content-directory/curatorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/curatorGroup.ts)_
+
+## `joystream-cli content-directory:curatorGroups`
+
+List existing Curator Groups.
+
+```
+USAGE
+  $ joystream-cli content-directory:curatorGroups
+```
+
+_See code: [src/commands/content-directory/curatorGroups.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/curatorGroups.ts)_
+
+## `joystream-cli content-directory:entities CLASSNAME [PROPERTIES]`
+
+Show entities list by class id or name.
+
+```
+USAGE
+  $ joystream-cli content-directory:entities CLASSNAME [PROPERTIES]
+
+ARGUMENTS
+  CLASSNAME   Name or ID of the Class
+
+  PROPERTIES  Comma-separated properties to include in the results table (ie. code,name). By default all property values
+              will be included.
+
+OPTIONS
+  --filters=filters  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.
+```
+
+_See code: [src/commands/content-directory/entities.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/entities.ts)_
+
+## `joystream-cli content-directory:entity ID`
+
+Show Entity details by id.
+
+```
+USAGE
+  $ joystream-cli content-directory:entity ID
+
+ARGUMENTS
+  ID  ID of the Entity
+```
+
+_See code: [src/commands/content-directory/entity.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/entity.ts)_
+
+## `joystream-cli content-directory:removeCuratorGroup [ID]`
+
+Remove existing Curator Group.
+
+```
+USAGE
+  $ joystream-cli content-directory:removeCuratorGroup [ID]
+
+ARGUMENTS
+  ID  ID of the Curator Group to remove
+```
+
+_See code: [src/commands/content-directory/removeCuratorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/removeCuratorGroup.ts)_
+
+## `joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]`
+
+Remove maintainer (Curator Group) from class.
+
+```
+USAGE
+  $ joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the class (ie. Video)
+  GROUPID    ID of the Curator Group to remove from maintainers
+```
+
+_See code: [src/commands/content-directory/removeMaintainerFromClass.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/removeMaintainerFromClass.ts)_
+
+## `joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]`
+
+Set Curator Group status (Active/Inactive).
+
+```
+USAGE
+  $ joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]
+
+ARGUMENTS
+  ID      ID of the Curator Group
+  STATUS  New status of the group (1 - active, 0 - inactive)
+```
+
+_See code: [src/commands/content-directory/setCuratorGroupStatus.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/setCuratorGroupStatus.ts)_
+
+## `joystream-cli content-directory:updateClassPermissions [CLASSNAME]`
+
+Update permissions in given class.
+
+```
+USAGE
+  $ joystream-cli content-directory:updateClassPermissions [CLASSNAME]
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the class (ie. Video)
+```
+
+_See code: [src/commands/content-directory/updateClassPermissions.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/updateClassPermissions.ts)_
+
 ## `joystream-cli council:info`
 
 Get current council and council elections information
@@ -316,6 +555,96 @@ OPTIONS
 
 _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v2.2.3/src/commands/help.ts)_
 
+## `joystream-cli media:createChannel`
+
+Create a new channel on Joystream (requires a membership).
+
+```
+USAGE
+  $ joystream-cli media:createChannel
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/media/createChannel.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/createChannel.ts)_
+
+## `joystream-cli media:myChannels`
+
+Show the list of channels associated with current account's membership.
+
+```
+USAGE
+  $ joystream-cli media:myChannels
+```
+
+_See code: [src/commands/media/myChannels.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/myChannels.ts)_
+
+## `joystream-cli media:myVideos`
+
+Show the list of videos associated with current account's membership.
+
+```
+USAGE
+  $ joystream-cli media:myVideos
+
+OPTIONS
+  -c, --channel=channel  Channel id to filter the videos by
+```
+
+_See code: [src/commands/media/myVideos.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/myVideos.ts)_
+
+## `joystream-cli media:updateChannel [ID]`
+
+Update one of the owned channels on Joystream (requires a membership).
+
+```
+USAGE
+  $ joystream-cli media:updateChannel [ID]
+
+ARGUMENTS
+  ID  ID of the channel to update
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/media/updateChannel.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/updateChannel.ts)_
+
+## `joystream-cli media:updateVideo [ID]`
+
+Update existing video information (requires a membership).
+
+```
+USAGE
+  $ joystream-cli media:updateVideo [ID]
+
+ARGUMENTS
+  ID  ID of the Video to update
+```
+
+_See code: [src/commands/media/updateVideo.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/updateVideo.ts)_
+
+## `joystream-cli media:uploadVideo FILEPATH`
+
+Upload a new Video to a channel (requires a membership).
+
+```
+USAGE
+  $ joystream-cli media:uploadVideo FILEPATH
+
+ARGUMENTS
+  FILEPATH  Path to the media file to upload
+
+OPTIONS
+  -c, --channel=channel  ID of the channel to assign the video to (if omitted - one of the owned channels can be
+                         selected from the list)
+```
+
+_See code: [src/commands/media/uploadVideo.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/uploadVideo.ts)_
+
 ## `joystream-cli working-groups:application WGAPPLICATIONID`
 
 Shows an overview of given application by Working Group Application ID
@@ -330,7 +659,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/application.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/application.ts)_
@@ -352,7 +681,7 @@ OPTIONS
 
   -g, --group=group          (required) [default: storageProviders] The working group context in which the command
                              should be executed
-                             Available values are: storageProviders.
+                             Available values are: storageProviders, curators.
 
   -n, --draftName=draftName  Name of the draft to create the opening from.
 
@@ -375,7 +704,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/decreaseWorkerStake.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/decreaseWorkerStake.ts)_
@@ -394,7 +723,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/evictWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/evictWorker.ts)_
@@ -413,7 +742,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/fillOpening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/fillOpening.ts)_
@@ -429,7 +758,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/increaseStake.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/increaseStake.ts)_
@@ -445,7 +774,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/leaveRole.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/leaveRole.ts)_
@@ -464,7 +793,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/opening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/opening.ts)_
@@ -480,7 +809,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/openings.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/openings.ts)_
@@ -496,7 +825,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/overview.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/overview.ts)_
@@ -515,7 +844,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/slashWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/slashWorker.ts)_
@@ -534,7 +863,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/startAcceptingApplications.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/startAcceptingApplications.ts)_
@@ -553,7 +882,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/startReviewPeriod.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/startReviewPeriod.ts)_
@@ -572,7 +901,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/terminateApplication.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/terminateApplication.ts)_
@@ -591,7 +920,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/updateRewardAccount.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateRewardAccount.ts)_
@@ -610,7 +939,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/updateRoleAccount.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateRoleAccount.ts)_
@@ -629,7 +958,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/updateWorkerReward.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateWorkerReward.ts)_

+ 31 - 7
cli/package.json

@@ -8,20 +8,32 @@
   },
   "bugs": "https://github.com/Joystream/joystream/issues",
   "dependencies": {
-    "@joystream/types": "^0.13.0",
+    "@apidevtools/json-schema-ref-parser": "^9.0.6",
+    "@ffmpeg-installer/ffmpeg": "^1.0.20",
+    "@joystream/types": "^0.14.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-not-found": "^1.2.4",
     "@oclif/plugin-warn-if-update-available": "^1.7.0",
-    "@polkadot/api": "^0.96.1",
+    "@polkadot/api": "1.26.1",
+    "@types/fluent-ffmpeg": "^2.1.16",
     "@types/inquirer": "^6.5.0",
     "@types/proper-lockfile": "^4.1.1",
     "@types/slug": "^0.9.1",
     "ajv": "^6.11.0",
     "cli-ux": "^5.4.5",
+    "fluent-ffmpeg": "^2.1.2",
     "inquirer": "^7.1.0",
+    "inquirer-datepicker-prompt": "^0.4.2",
+    "ipfs-http-client": "^47.0.1",
+    "ipfs-only-hash": "^1.0.2",
+    "it-all": "^1.0.4",
+    "it-drain": "^1.0.3",
+    "it-first": "^1.0.4",
+    "it-last": "^1.0.4",
+    "it-to-buffer": "^1.0.4",
     "moment": "^2.24.0",
     "proper-lockfile": "^4.1.1",
     "slug": "^2.1.1",
@@ -42,7 +54,8 @@
     "mocha": "^5.2.0",
     "nyc": "^14.1.1",
     "ts-node": "^8.8.2",
-    "typescript": "^3.8.3"
+    "typescript": "^3.8.3",
+    "json-schema-to-typescript": "^9.1.1"
   },
   "engines": {
     "node": ">=12.18.0",
@@ -86,6 +99,12 @@
       },
       "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"
       }
     }
   },
@@ -101,9 +120,14 @@
     "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"",
     "build": "tsc --build tsconfig.json",
     "version": "oclif-dev readme && git add README.md",
-    "lint": "eslint ./ --quiet --ext .ts",
-    "checks": "yarn lint && tsc --noEmit --pretty && prettier ./ --check",
-    "format": "prettier ./ --write"
+    "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/"
   },
-  "types": "lib/index.d.ts"
+  "types": "lib/index.d.ts",
+  "volta": {
+    "node": "12.18.2",
+    "yarn": "1.22.4"
+  }
 }

+ 3 - 0
cli/src/@types/@ffmpeg-installer/ffmpeg/index.d.ts

@@ -0,0 +1,3 @@
+declare module '@ffmpeg-installer/ffmpeg' {
+  export const path: string
+}

+ 1 - 0
cli/src/@types/inquirer-datepicker-prompt/index.d.ts

@@ -0,0 +1 @@
+declare module 'inquirer-datepicker-prompt'

+ 1 - 0
cli/src/@types/ipfs-http-client/index.d.ts

@@ -0,0 +1 @@
+declare module 'ipfs-http-client'

+ 1 - 0
cli/src/@types/ipfs-only-hash/index.d.ts

@@ -0,0 +1 @@
+declare module 'ipfs-only-hash'

+ 87 - 5
cli/src/Api.ts

@@ -32,6 +32,7 @@ import {
   RoleStakeProfile,
   Opening as WGOpening,
   Application as WGApplication,
+  StorageProviderId,
 } from '@joystream/types/working-group'
 import {
   Opening,
@@ -47,6 +48,10 @@ import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recur
 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'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 const DEFAULT_DECIMALS = new BN(12)
@@ -54,11 +59,13 @@ const DEFAULT_DECIMALS = new BN(12)
 // Mapping of working group to api module
 export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
   [WorkingGroups.StorageProviders]: 'storageWorkingGroup',
+  [WorkingGroups.Curators]: 'contentDirectoryWorkingGroup',
 }
 
 // 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) {
     this._api = originalApi
@@ -68,9 +75,12 @@ export default class Api {
     return this._api
   }
 
-  private static async initApi(apiUri: string = DEFAULT_API_URI): Promise<ApiPromise> {
+  private static async initApi(
+    apiUri: string = DEFAULT_API_URI,
+    metadataCache: Record<string, any>
+  ): Promise<ApiPromise> {
     const wsProvider: WsProvider = new WsProvider(apiUri)
-    const api = await ApiPromise.create({ provider: wsProvider, types })
+    const api = await ApiPromise.create({ provider: wsProvider, types, metadata: metadataCache })
 
     // Initializing some api params based on pioneer/packages/react-api/Api.tsx
     const [properties] = await Promise.all([api.rpc.system.properties()])
@@ -87,8 +97,8 @@ export default class Api {
     return api
   }
 
-  static async create(apiUri: string = DEFAULT_API_URI): Promise<Api> {
-    const originalApi: ApiPromise = await Api.initApi(apiUri)
+  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)
   }
 
@@ -109,6 +119,10 @@ export default class Api {
     })
   }
 
+  async bestNumber(): Promise<number> {
+    return (await this._api.derive.chain.bestNumber()).toNumber()
+  }
+
   async getAccountsBalancesInfo(accountAddresses: string[]): Promise<DeriveBalancesAll[]> {
     const accountsBalances: DeriveBalancesAll[] = await Promise.all(
       accountAddresses.map((addr) => this._api.derive.balances.all(addr))
@@ -284,7 +298,7 @@ export default class Api {
   }
 
   async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
-    const workerEntries = await this.entriesByIds<WorkerId, Worker>(this.workingGroupApiQuery(group).workerById)
+    const workerEntries = await this.groupWorkers(group)
 
     const groupMembers: GroupMember[] = await Promise.all(
       workerEntries.map(([id, worker]) => this.parseGroupMember(id, worker))
@@ -293,6 +307,10 @@ export default class Api {
     return groupMembers.reverse() // Sort by newest
   }
 
+  groupWorkers(group: WorkingGroups): Promise<[WorkerId, Worker][]> {
+    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>()
@@ -473,4 +491,68 @@ export default class Api {
   async workerExitRationaleConstraint(group: WorkingGroups): Promise<InputValidationLengthConstraint> {
     return await this.workingGroupApiQuery(group).workerExitRationaleText<InputValidationLengthConstraint>()
   }
+
+  // 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))
+  }
+
+  availableCuratorGroups(): Promise<[CuratorGroupId, CuratorGroup][]> {
+    return this.entriesByIds<CuratorGroupId, CuratorGroup>(this._api.query.contentDirectory.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
+  }
+
+  async nextCuratorGroupId(): Promise<number> {
+    return (await this._api.query.contentDirectory.nextCuratorGroupId<CuratorGroupId>()).toNumber()
+  }
+
+  async classById(id: number): Promise<Class | null> {
+    const c = await this._api.query.contentDirectory.classById<Class>(id)
+    return c.isEmpty ? null : c
+  }
+
+  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 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 dataObjectByContentId(contentId: ContentId): Promise<DataObject | null> {
+    const dataObject = await this._api.query.dataDirectory.dataObjectByContentId<Option<DataObject>>(contentId)
+    return dataObject.unwrapOr(null)
+  }
+
+  async ipnsIdentity(storageProviderId: number): Promise<string | null> {
+    const accountInfo = await this._api.query.discovery.accountInfoByStorageProviderId<ServiceProviderRecord>(
+      storageProviderId
+    )
+    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 isAnyProviderAvailable(): Promise<boolean> {
+    const accounInfoEntries = await this.entriesByIds<StorageProviderId, ServiceProviderRecord>(
+      this._api.query.discovery.accountInfoByStorageProviderId
+    )
+
+    const bestNumber = await this.bestNumber()
+    return !!accounInfoEntries.filter(([, info]) => info.expires_at.toNumber() > bestNumber).length
+  }
 }

+ 2 - 0
cli/src/ExitCodes.ts

@@ -11,5 +11,7 @@ enum ExitCodes {
   UnexpectedException = 500,
   FsOperationFailed = 501,
   ApiError = 502,
+  ExternalInfrastructureError = 503,
+  ActionCurrentlyUnavailable = 504,
 }
 export = ExitCodes

+ 7 - 201
cli/src/Types.ts

@@ -1,31 +1,14 @@
 import BN from 'bn.js'
 import { ElectionStage, Seat } from '@joystream/types/council'
-import { Option, Text } from '@polkadot/types'
-import { Constructor, Codec } from '@polkadot/types/types'
-import { Struct, Vec } from '@polkadot/types/codec'
-import { u32 } from '@polkadot/types/primitive'
+import { Option } from '@polkadot/types'
+import { Codec } from '@polkadot/types/types'
 import { BlockNumber, 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 {
-  GenericJoyStreamRoleSchema,
-  JobSpecifics,
-  ApplicationDetails,
-  QuestionSections,
-  QuestionSection,
-  QuestionsFields,
-  QuestionField,
-  EntryInMembershipModuke,
-  HiringProcess,
-  AdditionalRolehiringProcessDetails,
-  CreatorDetails,
-} from '@joystream/types/hiring/schemas/role.schema.typings'
-import ajv from 'ajv'
 import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/hiring'
 import { Validator } from 'inquirer'
-import { JoyStructCustom } from '@joystream/types/common'
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -87,10 +70,14 @@ export type NameValueObj = { name: string; value: string }
 // Working groups related types
 export enum WorkingGroups {
   StorageProviders = 'storageProviders',
+  Curators = 'curators',
 }
 
 // In contrast to Pioneer, currently only StorageProviders group is available in CLI
-export const AvailableGroups: readonly WorkingGroups[] = [WorkingGroups.StorageProviders] as const
+export const AvailableGroups: readonly WorkingGroups[] = [
+  WorkingGroups.StorageProviders,
+  WorkingGroups.Curators,
+] as const
 
 export type Reward = {
   totalRecieved: Balance
@@ -183,183 +170,6 @@ export type GroupOpening = {
   unstakingPeriods: UnstakingPeriods
 }
 
-// Some helper structs for generating human_readable_text in working group opening extrinsic
-// Note those types are not part of the runtime etc., we just use them to simplify prompting for values
-// (since there exists functionality that handles that for substrate types like: Struct, Vec etc.)
-interface WithJSONable<T> {
-  toJSONObj: () => T
-}
-export class HRTJobSpecificsStruct
-  extends JoyStructCustom({
-    title: Text,
-    description: Text,
-  })
-  implements WithJSONable<JobSpecifics> {
-  get title(): string {
-    return this.getField('title').toString()
-  }
-
-  get description(): string {
-    return this.getField('description').toString()
-  }
-
-  toJSONObj(): JobSpecifics {
-    const { title, description } = this
-    return { title, description }
-  }
-}
-export class HRTEntryInMembershipModukeStruct
-  extends JoyStructCustom({
-    handle: Text,
-  })
-  implements WithJSONable<EntryInMembershipModuke> {
-  get handle(): string {
-    return this.getField('handle').toString()
-  }
-
-  toJSONObj(): EntryInMembershipModuke {
-    const { handle } = this
-    return { handle }
-  }
-}
-export class HRTCreatorDetailsStruct
-  extends JoyStructCustom({
-    membership: HRTEntryInMembershipModukeStruct,
-  })
-  implements WithJSONable<CreatorDetails> {
-  get membership(): EntryInMembershipModuke {
-    return this.getField('membership').toJSONObj()
-  }
-
-  toJSONObj(): CreatorDetails {
-    const { membership } = this
-    return { membership }
-  }
-}
-export class HRTHiringProcessStruct
-  extends JoyStructCustom({
-    details: Vec.with(Text),
-  })
-  implements WithJSONable<HiringProcess> {
-  get details(): AdditionalRolehiringProcessDetails {
-    return this.getField('details')
-      .toArray()
-      .map((v) => v.toString())
-  }
-
-  toJSONObj(): HiringProcess {
-    const { details } = this
-    return { details }
-  }
-}
-export class HRTQuestionFieldStruct
-  extends JoyStructCustom({
-    title: Text,
-    type: Text,
-  })
-  implements WithJSONable<QuestionField> {
-  get title(): string {
-    return this.getField('title').toString()
-  }
-
-  get type(): string {
-    return this.getField('type').toString()
-  }
-
-  toJSONObj(): QuestionField {
-    const { title, type } = this
-    return { title, type }
-  }
-}
-class HRTQuestionsFieldsVec extends Vec.with(HRTQuestionFieldStruct) implements WithJSONable<QuestionsFields> {
-  toJSONObj(): QuestionsFields {
-    return this.toArray().map((v) => v.toJSONObj())
-  }
-}
-export class HRTQuestionSectionStruct
-  extends JoyStructCustom({
-    title: Text,
-    questions: HRTQuestionsFieldsVec,
-  })
-  implements WithJSONable<QuestionSection> {
-  get title(): string {
-    return this.getField('title').toString()
-  }
-
-  get questions(): QuestionsFields {
-    return this.getField('questions').toJSONObj()
-  }
-
-  toJSONObj(): QuestionSection {
-    const { title, questions } = this
-    return { title, questions }
-  }
-}
-export class HRTQuestionSectionsVec extends Vec.with(HRTQuestionSectionStruct)
-  implements WithJSONable<QuestionSections> {
-  toJSONObj(): QuestionSections {
-    return this.toArray().map((v) => v.toJSONObj())
-  }
-}
-export class HRTApplicationDetailsStruct
-  extends JoyStructCustom({
-    sections: HRTQuestionSectionsVec,
-  })
-  implements WithJSONable<ApplicationDetails> {
-  get sections(): QuestionSections {
-    return this.getField('sections').toJSONObj()
-  }
-
-  toJSONObj(): ApplicationDetails {
-    const { sections } = this
-    return { sections }
-  }
-}
-export class HRTStruct
-  extends JoyStructCustom({
-    version: u32,
-    headline: Text,
-    job: HRTJobSpecificsStruct,
-    application: HRTApplicationDetailsStruct,
-    reward: Text,
-    creator: HRTCreatorDetailsStruct,
-    process: HRTHiringProcessStruct,
-  })
-  implements WithJSONable<GenericJoyStreamRoleSchema> {
-  get version(): number {
-    return this.getField('version').toNumber()
-  }
-
-  get headline(): string {
-    return this.getField('headline').toString()
-  }
-
-  get job(): JobSpecifics {
-    return this.getField('job').toJSONObj()
-  }
-
-  get application(): ApplicationDetails {
-    return this.getField('application').toJSONObj()
-  }
-
-  get reward(): string {
-    return this.getField('reward').toString()
-  }
-
-  get creator(): CreatorDetails {
-    return this.getField('creator').toJSONObj()
-  }
-
-  get process(): HiringProcess {
-    return this.getField('process').toJSONObj()
-  }
-
-  toJSONObj(): GenericJoyStreamRoleSchema {
-    const { version, headline, job, application, reward, creator, process } = this
-    return { version, headline, job, application, reward, creator, process }
-  }
-}
-
 // Api-related
 
 // Additional options that can be passed to ApiCommandBase.promptForParam in order to override
@@ -370,10 +180,6 @@ export type ApiParamOptions<ParamType = Codec> = {
     default: ParamType
     locked?: boolean
   }
-  jsonSchema?: {
-    struct: Constructor<Struct>
-    schemaValidator: ajv.ValidateFunction
-  }
   validator?: Validator
   nestedOptions?: ApiParamsOptions // For more complex params, like structs
 }

+ 30 - 3
cli/src/base/AccountsCommandBase.ts

@@ -216,14 +216,41 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
   }
 
   async requestAccountDecoding(account: NamedKeyringPair): Promise<void> {
-    const password: string = await this.promptForPassword()
+    // Skip if account already unlocked
+    if (!account.isLocked) {
+      return
+    }
+
+    // First - try decoding using empty string
     try {
-      account.decodePkcs8(password)
+      account.decodePkcs8('')
+      return
     } catch (e) {
-      this.error('Invalid password!', { exit: ExitCodes.InvalidInput })
+      // Continue...
+    }
+
+    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 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 })
+    }
+
+    return memberIds[0].toNumber() // FIXME: Temporary solution (just using the first one)
+  }
+
   async init() {
     await super.init()
     try {

+ 87 - 92
cli/src/base/ApiCommandBase.ts

@@ -2,19 +2,22 @@ import ExitCodes from '../ExitCodes'
 import { CLIError } from '@oclif/errors'
 import StateAwareCommandBase from './StateAwareCommandBase'
 import Api from '../Api'
-import { getTypeDef, Option, Tuple, Bytes } from '@polkadot/types'
-import { Registry, Codec, CodecArg, TypeDef, TypeDefInfo, Constructor } from '@polkadot/types/types'
+import { getTypeDef, Option, Tuple, TypeRegistry } from '@polkadot/types'
+import { Registry, Codec, CodecArg, TypeDef, TypeDefInfo } from '@polkadot/types/types'
 
 import { Vec, Struct, Enum } from '@polkadot/types/codec'
 import { ApiPromise, WsProvider } from '@polkadot/api'
 import { KeyringPair } from '@polkadot/keyring/types'
 import chalk from 'chalk'
 import { InterfaceTypes } from '@polkadot/types/types/registry'
-import ajv from 'ajv'
 import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions } from '../Types'
 import { createParamOptions } from '../helpers/promptOptions'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { DistinctQuestion } from 'inquirer'
+import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
+import { DispatchError } from '@polkadot/types/interfaces/system'
 
-class ExtrinsicFailedError extends Error {}
+export class ExtrinsicFailedError extends Error {}
 
 /**
  * Abstract base class for commands that require access to the API.
@@ -48,7 +51,17 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
       this.warn("You haven't provided a node/endpoint for the CLI to connect to yet!")
       apiUri = await this.promptForApiUri()
     }
-    this.api = await Api.create(apiUri)
+
+    const { metadataCache } = this.getPreservedState()
+    this.api = await Api.create(apiUri, 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> {
@@ -131,9 +144,15 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     // If no default provided - get default value resulting from providing empty string
     const defaultValueString =
       paramOptions?.value?.default?.toString() || this.createType(typeDef.type as any, '').toString()
+
+    let typeSpecificOptions: DistinctQuestion = { type: 'input' }
+    if (typeDef.type === 'bool') {
+      typeSpecificOptions = BOOL_PROMPT_OPTIONS
+    }
+
     const providedValue = await this.simplePrompt({
       message: `Provide value for ${this.paramName(typeDef)}`,
-      type: 'input',
+      ...typeSpecificOptions,
       // We want to avoid showing default value like '0x', because it falsely suggests
       // that user needs to provide the value as hex
       default: (defaultValueString === '0x' ? '' : defaultValueString) || undefined,
@@ -288,16 +307,6 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
       return paramOptions.value.default
     }
 
-    if (paramOptions?.jsonSchema) {
-      const { struct, schemaValidator } = paramOptions.jsonSchema
-      return await this.promptForJsonBytes(
-        struct,
-        typeDef.name,
-        paramOptions.value?.default as Bytes | undefined,
-        schemaValidator
-      )
-    }
-
     if (rawTypeDef.info === TypeDefInfo.Option) {
       return await this.promptForOption(typeDef, paramOptions)
     } else if (rawTypeDef.info === TypeDefInfo.Tuple) {
@@ -313,47 +322,9 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
   }
 
-  async promptForJsonBytes(
-    jsonStruct: Constructor<Struct>,
-    argName?: string,
-    defaultValue?: Bytes,
-    schemaValidator?: ajv.ValidateFunction
-  ) {
-    const JsonStructObject = jsonStruct
-    const rawType = new JsonStructObject(this.getTypesRegistry()).toRawType()
-    const typeDef = getTypeDef(rawType)
-
-    const defaultStruct =
-      defaultValue &&
-      new JsonStructObject(
-        this.getTypesRegistry(),
-        JSON.parse(Buffer.from(defaultValue.toHex().replace('0x', ''), 'hex').toString())
-      )
-
-    if (argName) {
-      typeDef.name = argName
-    }
-
-    let isValid = true
-    let jsonText: string
-    do {
-      const structVal = await this.promptForStruct(typeDef, createParamOptions(typeDef.name, defaultStruct))
-      jsonText = JSON.stringify(structVal.toJSON())
-      if (schemaValidator) {
-        isValid = Boolean(schemaValidator(JSON.parse(jsonText)))
-        if (!isValid) {
-          this.log('\n')
-          this.warn(
-            'Schema validation failed with:\n' +
-              schemaValidator.errors?.map((e) => chalk.red(`${chalk.bold(e.dataPath)}: ${e.message}`)).join('\n') +
-              '\nTry again...'
-          )
-          this.log('\n')
-        }
-      }
-    } while (!isValid)
-
-    return this.createType('Bytes', '0x' + Buffer.from(jsonText, 'ascii').toString('hex'))
+  // More typesafe version
+  async promptForType(type: keyof InterfaceTypes, options?: ApiParamOptions) {
+    return await this.promptForParam(type, options)
   }
 
   async promptForExtrinsicParams(
@@ -379,32 +350,45 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return values
   }
 
-  sendExtrinsic(account: KeyringPair, module: string, method: string, params: CodecArg[]) {
+  sendExtrinsic(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>) {
     return new Promise((resolve, reject) => {
-      const extrinsicMethod = this.getOriginalApi().tx[module][method]
       let unsubscribe: () => void
-      extrinsicMethod(...params)
-        .signAndSend(account, {}, (result) => {
-          // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
-          if (!result || !result.status) {
-            return
-          }
-
-          if (result.status.isInBlock) {
-            unsubscribe()
-            result.events
-              .filter(({ event: { section } }): boolean => section === 'system')
-              .forEach(({ event: { method } }): void => {
-                if (method === 'ExtrinsicFailed') {
-                  reject(new ExtrinsicFailedError('Extrinsic execution error!'))
-                } else if (method === 'ExtrinsicSuccess') {
-                  resolve()
+      tx.signAndSend(account, {}, (result) => {
+        // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
+        if (!result || !result.status) {
+          return
+        }
+
+        if (result.status.isInBlock) {
+          unsubscribe()
+          result.events
+            .filter(({ event }) => event.section === 'system')
+            .forEach(({ event }) => {
+              if (event.method === 'ExtrinsicFailed') {
+                const dispatchError = event.data[0] as DispatchError
+                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})`
+                  } catch (e) {
+                    // This probably means we don't have this error in the metadata
+                    // In this case - continue (we'll just display dispatchError.toString())
+                  }
                 }
-              })
-          } else if (result.isError) {
-            reject(new ExtrinsicFailedError('Extrinsic execution error!'))
-          }
-        })
+                reject(new ExtrinsicFailedError(`Extrinsic execution error: ${errorMsg}`))
+              } else if (event.method === 'ExtrinsicSuccess') {
+                resolve()
+              }
+            })
+        } else if (result.isError) {
+          reject(new ExtrinsicFailedError('Extrinsic execution error!'))
+        }
+      })
         .then((unsubFunc) => (unsubscribe = unsubFunc))
         .catch((e) =>
           reject(new ExtrinsicFailedError(`Cannot send the extrinsic: ${e.message ? e.message : JSON.stringify(e)}`))
@@ -412,37 +396,48 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     })
   }
 
-  async sendAndFollowExtrinsic(
+  async sendAndFollowTx(
     account: KeyringPair,
-    module: string,
-    method: string,
-    params: CodecArg[],
-    warnOnly = false // If specified - only warning will be displayed (instead of error beeing thrown)
-  ) {
+    tx: SubmittableExtrinsic<'promise'>,
+    warnOnly = false // If specified - only warning will be displayed in case of failure (instead of error beeing thrown)
+  ): Promise<boolean> {
     try {
-      this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
-      await this.sendExtrinsic(account, module, method, params)
+      await this.sendExtrinsic(account, tx)
       this.log(chalk.green(`Extrinsic successful!`))
+      return true
     } catch (e) {
       if (e instanceof ExtrinsicFailedError && warnOnly) {
-        this.warn(`${module}.${method} extrinsic failed! ${e.message}`)
+        this.warn(`Extrinsic failed! ${e.message}`)
+        return false
       } else if (e instanceof ExtrinsicFailedError) {
-        throw new CLIError(`${module}.${method} extrinsic failed! ${e.message}`, { exit: ExitCodes.ApiError })
+        throw new CLIError(`Extrinsic failed! ${e.message}`, { exit: ExitCodes.ApiError })
       } else {
         throw e
       }
     }
   }
 
+  async sendAndFollowNamedTx(
+    account: KeyringPair,
+    module: string,
+    method: string,
+    params: CodecArg[],
+    warnOnly = false
+  ): Promise<boolean> {
+    this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
+    const tx = await this.getOriginalApi().tx[module][method](...params)
+    return await this.sendAndFollowTx(account, tx, warnOnly)
+  }
+
   async buildAndSendExtrinsic(
     account: KeyringPair,
     module: string,
     method: string,
-    paramsOptions: ApiParamsOptions,
+    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.sendAndFollowExtrinsic(account, module, method, params, warnOnly)
+    await this.sendAndFollowNamedTx(account, module, method, params, warnOnly)
 
     return params
   }

+ 359 - 0
cli/src/base/ContentDirectoryCommandBase.ts

@@ -0,0 +1,359 @@
+import ExitCodes from '../ExitCodes'
+import { WorkingGroups } from '../Types'
+import { ReferenceProperty } from 'cd-schemas/types/extrinsics/AddClassSchema'
+import { FlattenRelations } from 'cd-schemas/types/utility'
+import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
+import {
+  Class,
+  ClassId,
+  CuratorGroup,
+  CuratorGroupId,
+  Entity,
+  EntityId,
+  Actor,
+} from '@joystream/types/content-directory'
+import { Worker } from '@joystream/types/working-group'
+import { CLIError } from '@oclif/errors'
+import { Codec } from '@polkadot/types/types'
+import _ from 'lodash'
+import { RolesCommandBase } from './WorkingGroupsCommandBase'
+import { createType } from '@joystream/types'
+import chalk from 'chalk'
+import { flags } from '@oclif/command'
+
+const CONTEXTS = ['Member', 'Curator', 'Lead'] as const
+type Context = typeof CONTEXTS[number]
+
+/**
+ * Abstract base class for commands related to content directory
+ */
+export default abstract class ContentDirectoryCommandBase extends RolesCommandBase {
+  group = WorkingGroups.Curators // override group for RolesCommandBase
+
+  static contextFlag = flags.enum({
+    name: 'context',
+    required: false,
+    description: `Actor context to execute the command in (${CONTEXTS.join('/')})`,
+    options: [...CONTEXTS],
+  })
+
+  async promptForContext(message = 'Choose in which context you wish to execute the command'): Promise<Context> {
+    return this.simplePrompt({
+      message,
+      type: 'list',
+      choices: CONTEXTS.map((c) => ({ name: c, value: c })),
+    })
+  }
+
+  // Use when lead access is required in given command
+  async requireLead(): Promise<void> {
+    await this.getRequiredLead()
+  }
+
+  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)
+
+    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()
+    } else {
+      groupId = await this.promptForCuratorGroup('Select Curator Group context', availableGroupIds)
+    }
+
+    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)
+    }
+
+    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}"!`)
+    }
+
+    return foundClass
+  }
+
+  private async curatorGroupChoices(ids?: CuratorGroupId[]) {
+    const groups = await this.getApi().availableCuratorGroups()
+    return groups
+      .filter(([id]) => (ids ? ids.some((allowedId) => allowedId.eq(id)) : true))
+      .map(([id, group]) => ({
+        name:
+          `Group ${id.toString()} (` +
+          `${group.active.valueOf() ? 'Active' : 'Inactive'}, ` +
+          `${group.curators.toArray().length} member(s), ` +
+          `${group.number_of_classes_maintained.toNumber()} classes maintained)`,
+        value: id.toNumber(),
+      }))
+  }
+
+  async promptForCuratorGroup(message = 'Select a Curator Group', ids?: CuratorGroupId[]): Promise<number> {
+    const choices = await this.curatorGroupChoices(ids)
+    if (!choices.length) {
+      this.warn('No Curator Groups to choose from!')
+      this.exit(ExitCodes.InvalidInput)
+    }
+    const selectedId = await this.simplePrompt({ message, type: 'list', choices })
+
+    return selectedId
+  }
+
+  async promptForCuratorGroups(message = 'Select Curator Groups'): Promise<number[]> {
+    const choices = await this.curatorGroupChoices()
+    if (!choices.length) {
+      return []
+    }
+    const selectedIds = await this.simplePrompt({ message, type: 'checkbox', choices })
+
+    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})`,
+        value: c.workerId.toNumber(),
+      }))
+
+    if (!choices.length) {
+      this.warn('No Curators to choose from!')
+      this.exit(ExitCodes.InvalidInput)
+    }
+
+    const selectedCuratorId = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices,
+    })
+
+    return selectedCuratorId
+  }
+
+  async getCurator(id: string | number): Promise<Worker> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    let curator
+    try {
+      curator = await this.getApi().workerByWorkerId(WorkingGroups.Curators, id)
+    } catch (e) {
+      if (e instanceof CLIError) {
+        throw new CLIError('Invalid Curator id!')
+      }
+      throw e
+    }
+
+    return curator
+  }
+
+  async getCuratorGroup(id: string | number): Promise<CuratorGroup> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    const group = await this.getApi().curatorGroupById(id)
+
+    if (!group) {
+      this.error('Invalid Curator Group id!', { exit: ExitCodes.InvalidInput })
+    }
+
+    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): Promise<FlattenRelations<T>> {
+    const entity = await this.getEntity(id)
+    return this.parseToKnownEntityJson<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()
+  }
+
+  parseEntityPropertyValues(
+    entity: Entity,
+    entityClass: Class,
+    includedProperties?: string[]
+  ): Record<string, { value: Codec; type: string }> {
+    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())
+
+      if (included) {
+        columns[propName] = {
+          value: propValue.getValue(),
+          type: `${prop.property_type.type}<${prop.property_type.subtype}>`,
+        }
+      }
+      return columns
+    }, {} as Record<string, { value: Codec; type: string }>)
+  }
+
+  async parseToKnownEntityJson<T>(entity: Entity): Promise<FlattenRelations<T>> {
+    const entityClass = (await this.classEntryByNameOrId(entity.class_id.toString()))[1]
+    return (_.mapValues(this.parseEntityPropertyValues(entity, entityClass), (v) =>
+      v.type !== 'Single<Bool>' && v.value.toJSON() === false ? null : v.value.toJSON()
+    ) 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?.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.toJSON() === false && v.type !== 'Single<Bool>' ? chalk.grey('[not set]') : v.value.toString()
+        ),
+      }))
+    )) as Record<string, string>[]
+
+    return parsedEntities.filter((entity) => filters.every(([pName, pValue]) => entity[pName] === pValue))
+  }
+}

+ 5 - 0
cli/src/base/DefaultCommandBase.ts

@@ -2,6 +2,7 @@ import ExitCodes from '../ExitCodes'
 import Command from '@oclif/command'
 import inquirer, { DistinctQuestion } from 'inquirer'
 import chalk from 'chalk'
+import inquirerDatepicker from 'inquirer-datepicker-prompt'
 
 /**
  * Abstract base class for pretty much all commands
@@ -103,4 +104,8 @@ export default abstract class DefaultCommandBase extends Command {
     if (!err) this.exit(ExitCodes.OK)
     super.finally(err)
   }
+
+  async init() {
+    inquirer.registerPrompt('datetime', inquirerDatepicker)
+  }
 }

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

@@ -0,0 +1,65 @@
+import ContentDirectoryCommandBase from './ContentDirectoryCommandBase'
+import { VideoEntity } from '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 license: VideoEntity['license']
+    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') {
+      license = { new: { knownLicense: await this.promptForEntityId('Choose License', 'KnownLicense', 'code') } }
+    } 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
+        }
+      }
+      license = { new: { userDefinedLicense: { new: { content: licenseContent } } } }
+    }
+
+    return license
+  }
+
+  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
+  }
+}

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

@@ -6,17 +6,22 @@ import lockFile from 'proper-lockfile'
 import DefaultCommandBase from './DefaultCommandBase'
 import os from 'os'
 import _ from 'lodash'
+import { WorkingGroups } from '../Types'
 
 // Type for the state object (which is preserved as json in the state file)
 type StateObject = {
   selectedAccountFilename: string
   apiUri: string
+  defaultWorkingGroup: WorkingGroups
+  metadataCache: Record<string, any>
 }
 
 // State object default values
 const DEFAULT_STATE: StateObject = {
   selectedAccountFilename: '',
   apiUri: '',
+  defaultWorkingGroup: WorkingGroups.StorageProviders,
+  metadataCache: {},
 }
 
 // State file path (relative to getAppDataPath())

+ 42 - 117
cli/src/base/WorkingGroupsCommandBase.ts

@@ -7,37 +7,24 @@ import {
   NamedKeyringPair,
   GroupMember,
   GroupOpening,
-  ApiMethodArg,
-  ApiMethodNamedArgs,
   OpeningStatus,
   GroupApplication,
 } from '../Types'
-import { apiModuleByGroup } from '../Api'
-import { CLIError } from '@oclif/errors'
-import fs from 'fs'
-import path from 'path'
 import _ from 'lodash'
 import { ApplicationStageKeys } from '@joystream/types/hiring'
 import chalk from 'chalk'
-
-const DEFAULT_GROUP = WorkingGroups.StorageProviders
-const DRAFTS_FOLDER = 'opening-drafts'
+import { IConfig } from '@oclif/config'
 
 /**
- * Abstract base class for commands related to working groups
+ * Abstract base class for commands that need to use gates based on user's roles
  */
-export default abstract class WorkingGroupsCommandBase extends AccountsCommandBase {
-  group: WorkingGroups = DEFAULT_GROUP
+export abstract class RolesCommandBase extends AccountsCommandBase {
+  group: WorkingGroups
 
-  static flags = {
-    group: flags.string({
-      char: 'g',
-      description:
-        'The working group context in which the command should be executed\n' +
-        `Available values are: ${AvailableGroups.join(', ')}.`,
-      required: true,
-      default: DEFAULT_GROUP,
-    }),
+  constructor(argv: string[], config: IConfig) {
+    super(argv, config)
+    // Can be modified by child class constructor
+    this.group = this.getPreservedState().defaultWorkingGroup
   }
 
   // Use when lead access is required in given command
@@ -46,7 +33,9 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
     const lead = await this.getApi().groupLead(this.group)
 
     if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
-      this.error('Lead access required for this command!', { exit: ExitCodes.AccessDenied })
+      this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
     }
 
     return lead
@@ -59,7 +48,9 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
     const groupMembersByAccount = groupMembers.filter((m) => m.roleAccount.toString() === selectedAccount.address)
 
     if (!groupMembersByAccount.length) {
-      this.error('Worker access required for this command!', { exit: ExitCodes.AccessDenied })
+      this.error(`${_.startCase(this.group)} Group Worker access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
     } else if (groupMembersByAccount.length === 1) {
       return groupMembersByAccount[0]
     } else {
@@ -88,7 +79,7 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
 
   async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
     const chosenWorkerIndex = await this.simplePrompt({
-      message: 'Choose the intended worker context:',
+      message: `Choose the intended ${_.startCase(this.group)} Group Worker context:`,
       type: 'list',
       choices: groupMembers.map((groupMember, index) => ({
         name: `Worker ID ${groupMember.workerId.toString()}`,
@@ -98,6 +89,29 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
 
     return groupMembers[chosenWorkerIndex]
   }
+}
+
+/**
+ * Abstract base class for commands directly related to working groups
+ */
+export default abstract class WorkingGroupsCommandBase extends RolesCommandBase {
+  group: WorkingGroups
+
+  constructor(argv: string[], config: IConfig) {
+    super(argv, config)
+    this.group = this.getPreservedState().defaultWorkingGroup
+  }
+
+  static flags = {
+    group: flags.enum({
+      char: 'g',
+      description:
+        'The working group context in which the command should be executed\n' +
+        `Available values are: ${AvailableGroups.join(', ')}.`,
+      required: false,
+      options: [...AvailableGroups],
+    }),
+  }
 
   async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
     const acceptableApplications = opening.applications.filter((a) => a.stage === ApplicationStageKeys.Active)
@@ -110,52 +124,7 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
       })),
     })
 
-    return acceptedApplications.sort() // Sort just in case, since runtime expects them to be sorted
-  }
-
-  async promptForNewOpeningDraftName() {
-    let draftName = ''
-    let fileExists = false
-    let overrideConfirmed = false
-
-    do {
-      draftName = await this.simplePrompt({
-        type: 'input',
-        message: 'Provide the draft name',
-        validate: (val) => (typeof val === 'string' && val.length >= 1) || 'Draft name is required!',
-      })
-
-      fileExists = fs.existsSync(this.getOpeningDraftPath(draftName))
-      if (fileExists) {
-        overrideConfirmed = await this.simplePrompt({
-          type: 'confirm',
-          message: 'Such draft already exists. Do you wish to override it?',
-          default: false,
-        })
-      }
-    } while (fileExists && !overrideConfirmed)
-
-    return draftName
-  }
-
-  async promptForOpeningDraft() {
-    let draftFiles: string[] = []
-    try {
-      draftFiles = fs.readdirSync(this.getOpeingDraftsPath())
-    } catch (e) {
-      throw this.createDataReadError(DRAFTS_FOLDER)
-    }
-    if (!draftFiles.length) {
-      throw new CLIError('No drafts available!', { exit: ExitCodes.FileNotFound })
-    }
-    const draftNames = draftFiles.map((fileName) => _.startCase(fileName.replace('.json', '')))
-    const selectedDraftName = await this.simplePrompt({
-      message: 'Select a draft',
-      type: 'list',
-      choices: draftNames,
-    })
-
-    return selectedDraftName
+    return acceptedApplications
   }
 
   async getOpeningForLeadAction(id: number, requiredStatus?: OpeningStatus): Promise<GroupOpening> {
@@ -219,56 +188,12 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
     return (await this.getWorkerForLeadAction(id, true)) as GroupMember & Required<Pick<GroupMember, 'stake'>>
   }
 
-  loadOpeningDraftParams(draftName: string): ApiMethodNamedArgs {
-    const draftFilePath = this.getOpeningDraftPath(draftName)
-    const params = this.extrinsicArgsFromDraft(apiModuleByGroup[this.group], 'addOpening', draftFilePath)
-
-    return params
-  }
-
-  getOpeingDraftsPath() {
-    return path.join(this.getAppDataPath(), DRAFTS_FOLDER)
-  }
-
-  getOpeningDraftPath(draftName: string) {
-    return path.join(this.getOpeingDraftsPath(), _.snakeCase(draftName) + '.json')
-  }
-
-  saveOpeningDraft(draftName: string, params: ApiMethodArg[]) {
-    const paramsJson = JSON.stringify(
-      params.map((p) => p.toJSON()),
-      null,
-      2
-    )
-
-    try {
-      fs.writeFileSync(this.getOpeningDraftPath(draftName), paramsJson)
-    } catch (e) {
-      throw this.createDataWriteError(DRAFTS_FOLDER)
-    }
-  }
-
-  private initOpeningDraftsDir(): void {
-    if (!fs.existsSync(this.getOpeingDraftsPath())) {
-      fs.mkdirSync(this.getOpeingDraftsPath())
-    }
-  }
-
   async init() {
     await super.init()
-    try {
-      this.initOpeningDraftsDir()
-    } catch (e) {
-      throw this.createDataDirInitError()
-    }
     const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase)
-    if (!AvailableGroups.includes(flags.group as any)) {
-      throw new CLIError(`Invalid group! Available values are: ${AvailableGroups.join(', ')}`, {
-        exit: ExitCodes.InvalidInput,
-      })
+    if (flags.group) {
+      this.group = flags.group
     }
-    this.group = flags.group as WorkingGroups
-
-    this.log(chalk.white('Group: ' + flags.group))
+    this.log(chalk.white('Current Group: ' + this.group))
   }
 }

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

@@ -16,7 +16,7 @@ export default class ApiSetUri extends ApiCommandBase {
 
   async init() {
     this.forceSkipApiUriPrompt = true
-    super.init()
+    await super.init()
   }
 
   async run() {

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

@@ -0,0 +1,79 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import AddClassSchemaSchema from 'cd-schemas/schemas/extrinsics/AddClassSchema.schema.json'
+import { AddClassSchema } from 'cd-schemas/types/extrinsics/AddClassSchema'
+import { InputParser } from '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))
+    }
+  }
+}

+ 42 - 0
cli/src/commands/content-directory/addCuratorToGroup.ts

@@ -0,0 +1,42 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Add Curator to existing Curator Group.'
+  static args = [
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'curatorId',
+      required: false,
+      description: 'ID of the curator',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, curatorId } = this.parse(AddCuratorToGroupCommand).args
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    if (curatorId === undefined) {
+      curatorId = await this.promptForCurator()
+    } else {
+      await this.getCurator(curatorId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addCuratorToGroup', [groupId, curatorId])
+
+    console.log(chalk.green(`Curator ${chalk.white(curatorId)} succesfully added to group ${chalk.white(groupId)}!`))
+  }
+}

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

@@ -0,0 +1,44 @@
+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!`)
+    )
+  }
+}

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

@@ -0,0 +1,55 @@
+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')
+    }
+  }
+}

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

@@ -0,0 +1,24 @@
+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
+    )
+  }
+}

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

@@ -0,0 +1,50 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import CreateClassSchema from 'cd-schemas/schemas/extrinsics/CreateClass.schema.json'
+import { CreateClass } from 'cd-schemas/types/extrinsics/CreateClass'
+import { InputParser } from '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))
+    }
+  }
+}

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

@@ -0,0 +1,18 @@
+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)})`))
+  }
+}

+ 39 - 0
cli/src/commands/content-directory/curatorGroup.ts

@@ -0,0 +1,39 @@
+import { WorkingGroups } from '../../Types'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+
+export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Curator Group details by ID.'
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Curator Group',
+    },
+  ]
+
+  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))
+    )
+
+    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()})`))
+        .join(', ')
+    )
+  }
+}

+ 25 - 0
cli/src/commands/content-directory/curatorGroups.ts

@@ -0,0 +1,25 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+// import chalk from 'chalk'
+import { displayTable } from '../../helpers/display'
+
+export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing Curator Groups.'
+
+  async run() {
+    const groups = await this.getApi().availableCuratorGroups()
+
+    if (groups.length) {
+      displayTable(
+        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,
+        })),
+        5
+      )
+    } else {
+      this.log('No Curator Groups available!')
+    }
+  }
+}

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

@@ -0,0 +1,45 @@
+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)
+  }
+}

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

@@ -0,0 +1,44 @@
+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.toJSON() === false && v.type !== 'Single<Bool>' ? chalk.grey('[not set]') : v.value.toString()) +
+          ` ${chalk.green(`${v.type}`)}`
+      )
+    )
+  }
+}

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

@@ -0,0 +1,50 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { CreateClass } from 'cd-schemas/types/extrinsics/CreateClass'
+import { getInputs, InputParser, ExtrinsicsHelper } from 'cd-schemas'
+import { AddClassSchema } from 'cd-schemas/types/extrinsics/AddClassSchema'
+import { EntityBatch } from 'cd-schemas/types/EntityBatch'
+
+export default class InitializeCommand extends ContentDirectoryCommandBase {
+  static description =
+    'Initialize content directory with input data from @joystream/content library. Requires lead access.'
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+    await this.requestAccountDecoding(account)
+
+    const classInputs = getInputs<CreateClass>('classes').map(({ data }) => data)
+    const schemaInputs = getInputs<AddClassSchema>('schemas').map(({ data }) => data)
+    const entityBatchInputs = getInputs<EntityBatch>('entityBatches').map(({ data }) => data)
+
+    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')
+  }
+}

+ 46 - 0
cli/src/commands/content-directory/removeCuratorFromGroup.ts

@@ -0,0 +1,46 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class RemoveCuratorFromGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove Curator from Curator Group.'
+  static args = [
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'curatorId',
+      required: false,
+      description: 'ID of the curator',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, curatorId } = this.parse(RemoveCuratorFromGroupCommand).args
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup()
+    }
+
+    const group = await this.getCuratorGroup(groupId)
+    const groupCuratorIds = group.curators.toArray().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)}`)
+      }
+      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)}!`))
+  }
+}

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

@@ -0,0 +1,35 @@
+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!`))
+  }
+}

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

@@ -0,0 +1,57 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { Actor } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+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()
+    let actor: Actor
+    if (context === 'Curator') {
+      actor = await this.getCuratorContext([entityClass.name.toString()])
+    } else if (context === 'Member') {
+      const memberId = await this.getRequiredMemberId()
+      if (
+        !entity.entity_permissions.controller.isOfType('Member') ||
+        entity.entity_permissions.controller.asType('Member').toNumber() !== memberId
+      ) {
+        this.error('You are not the entity controller!', { exit: ExitCodes.AccessDenied })
+      }
+      actor = createType('Actor', { Member: memberId })
+    } else {
+      actor = createType('Actor', { Lead: null })
+    }
+
+    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])
+  }
+}

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

@@ -0,0 +1,44 @@
+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!`)
+    )
+  }
+}

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

@@ -0,0 +1,61 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+
+export default class SetCuratorGroupStatusCommand extends ContentDirectoryCommandBase {
+  static description = 'Set Curator Group status (Active/Inactive).'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'status',
+      required: false,
+      description: 'New status of the group (1 - active, 0 - inactive)',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { id, status } = this.parse(SetCuratorGroupStatusCommand).args
+
+    if (id === undefined) {
+      id = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(id)
+    }
+
+    if (status === undefined) {
+      status = await this.simplePrompt({
+        type: 'list',
+        message: 'Select new status',
+        choices: [
+          { name: 'Active', value: true },
+          { name: 'Inactive', value: false },
+        ],
+      })
+    } else {
+      if (status !== '0' && status !== '1') {
+        this.error('Invalid status provided. Use "1" for Active and "0" for Inactive.', {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      status = !!parseInt(status)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'setCuratorGroupStatus', [id, status])
+
+    console.log(
+      chalk.green(
+        `Curator Group ${chalk.white(id)} status succesfully changed to: ${chalk.white(
+          status ? 'Active' : 'Inactive'
+        )}!`
+      )
+    )
+  }
+}

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

@@ -0,0 +1,55 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import CreateClassSchema from 'cd-schemas/schemas/extrinsics/CreateClass.schema.json'
+import chalk from 'chalk'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { CreateClass } from '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))
+  }
+}

+ 53 - 0
cli/src/commands/media/createChannel.ts

@@ -0,0 +1,53 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import ChannelEntitySchema from 'cd-schemas/schemas/entities/ChannelEntity.schema.json'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { InputParser } from 'cd-schemas'
+import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+
+export default class CreateChannelCommand extends ContentDirectoryCommandBase {
+  static description = 'Create a new channel on Joystream (requires a membership).'
+  static flags = {
+    ...IOFlags,
+  }
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = { Member: memberId }
+
+    await this.requestAccountDecoding(account)
+
+    const channelJsonSchema = (ChannelEntitySchema as unknown) as JSONSchema
+
+    const { input, output } = this.parse(CreateChannelCommand).flags
+
+    let inputJson = await getInputJson<ChannelEntity>(input, channelJsonSchema)
+    if (!inputJson) {
+      const customPrompts: JsonSchemaCustomPrompts = [
+        ['language', () => this.promptForEntityId('Choose channel language', 'Language', 'name')],
+        ['isCensored', 'skip'],
+      ]
+
+      const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, undefined, customPrompts)
+
+      inputJson = await prompter.promptAll(true)
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(inputJson))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      saveOutputJson(output, `${inputJson.title}Channel.json`, inputJson)
+      const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
+        {
+          className: 'Channel',
+          entries: [inputJson],
+        },
+      ])
+      const operations = await inputParser.getEntityBatchOperations()
+      await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations])
+    }
+  }
+}

+ 57 - 0
cli/src/commands/media/curateContent.ts

@@ -0,0 +1,57 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { InputParser } from 'cd-schemas'
+import { flags } from '@oclif/command'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+
+const CLASSES = ['Channel', 'Video'] as const
+const STATUSES = ['Accepted', 'Censored'] as const
+
+export default class CurateContentCommand extends ContentDirectoryCommandBase {
+  static description = `Set the curation status of given entity (${CLASSES.join('/')}). Requires Curator access.`
+  static flags = {
+    className: flags.enum({
+      options: [...CLASSES],
+      description: `Name of the class of the entity to curate (${CLASSES.join('/')})`,
+      char: 'c',
+      required: true,
+    }),
+    status: flags.enum({
+      description: `Specifies the curation status (${STATUSES.join('/')})`,
+      char: 's',
+      options: [...STATUSES],
+      required: true,
+    }),
+    id: flags.integer({
+      description: 'ID of the entity to curate',
+      required: true,
+    }),
+  }
+
+  async run() {
+    const { className, status, id } = this.parse(CurateContentCommand).flags
+
+    const account = await this.getRequiredSelectedAccount()
+    // Get curator actor with required maintainer access to $className (Video/Channel) class
+    const actor = await this.getCuratorContext([className])
+
+    await this.requestAccountDecoding(account)
+
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+
+    await this.getEntity(id, className) // Check if entity exists and is of given class
+
+    const entityUpdateInput: Partial<ChannelEntity & VideoEntity> = {
+      isCensored: status === 'Censored',
+    }
+
+    this.log(`Updating the ${className} with:`)
+    this.jsonPrettyPrint(JSON.stringify(entityUpdateInput))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      const operations = await inputParser.getEntityUpdateOperations(entityUpdateInput, className, id)
+      await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations], true)
+    }
+  }
+}

+ 25 - 0
cli/src/commands/media/myChannels.ts

@@ -0,0 +1,25 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { displayTable } from '../../helpers/display'
+import chalk from 'chalk'
+
+export default class MyChannelsCommand extends ContentDirectoryCommandBase {
+  static description = "Show the list of channels associated with current account's membership."
+
+  async run() {
+    const memberId = await this.getRequiredMemberId()
+
+    const props: (keyof ChannelEntity)[] = ['title', 'isPublic']
+
+    const list = await this.createEntityList('Channel', props, [], memberId)
+
+    if (list.length) {
+      displayTable(list, 3)
+      this.log(
+        `\nTIP: Use ${chalk.bold('content-directory:entity ID')} command to see more details about given channel`
+      )
+    } else {
+      this.log(`No channels created yet! Create a channel with ${chalk.bold('media:createChannel')}`)
+    }
+  }
+}

+ 33 - 0
cli/src/commands/media/myVideos.ts

@@ -0,0 +1,33 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+import { displayTable } from '../../helpers/display'
+import chalk from 'chalk'
+import { flags } from '@oclif/command'
+
+export default class MyVideosCommand extends ContentDirectoryCommandBase {
+  static description = "Show the list of videos associated with current account's membership."
+  static flags = {
+    channel: flags.integer({
+      char: 'c',
+      required: false,
+      description: 'Channel id to filter the videos by',
+    }),
+  }
+
+  async run() {
+    const memberId = await this.getRequiredMemberId()
+
+    const { channel } = this.parse(MyVideosCommand).flags
+    const props: (keyof VideoEntity)[] = ['title', 'isPublic', 'channel']
+    const filters: [string, string][] = channel !== undefined ? [['channel', channel.toString()]] : []
+
+    const list = await this.createEntityList('Video', props, filters, memberId)
+
+    if (list.length) {
+      displayTable(list, 3)
+      this.log(`\nTIP: Use ${chalk.bold('content-directory:entity ID')} command to see more details about given video`)
+    } else {
+      this.log(`No videos uploaded yet! Upload a video with ${chalk.bold('media:uploadVideo')}`)
+    }
+  }
+}

+ 44 - 0
cli/src/commands/media/removeChannel.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { Entity } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+import { ChannelEntity } from 'cd-schemas/types/entities'
+
+export default class RemoveChannelCommand extends ContentDirectoryCommandBase {
+  static description = 'Removes a channel (required controller access).'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Channel entity',
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+    } = this.parse(RemoveChannelCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = createType('Actor', { Member: memberId })
+
+    await this.requestAccountDecoding(account)
+
+    let channelEntity: Entity, channelId: number
+    if (id) {
+      channelId = parseInt(id)
+      channelEntity = await this.getEntity(channelId, 'Channel', memberId)
+    } else {
+      const [id, channel] = await this.promptForEntityEntry('Select a channel to remove', 'Channel', 'title', memberId)
+      channelId = id.toNumber()
+      channelEntity = channel
+    }
+    const channel = await this.parseToKnownEntityJson<ChannelEntity>(channelEntity)
+
+    await this.requireConfirmation(`Are you sure you want to remove "${channel.title}" channel?`)
+
+    const api = this.getOriginalApi()
+    this.log(`Removing Channel entity (ID: ${channelId})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, channelId))
+  }
+}

+ 49 - 0
cli/src/commands/media/removeVideo.ts

@@ -0,0 +1,49 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { Entity } from '@joystream/types/content-directory'
+import { VideoEntity } from 'cd-schemas/types/entities'
+import { createType } from '@joystream/types'
+
+export default class RemoveVideoCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove given Video entity and associated entities (VideoMedia, License) from content directory.'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Video entity',
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+    } = this.parse(RemoveVideoCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = createType('Actor', { Member: memberId })
+
+    await this.requestAccountDecoding(account)
+
+    let videoEntity: Entity, videoId: number
+    if (id) {
+      videoId = parseInt(id)
+      videoEntity = await this.getEntity(videoId, 'Video', memberId)
+    } else {
+      const [id, video] = await this.promptForEntityEntry('Select a video to remove', 'Video', 'title', memberId)
+      videoId = id.toNumber()
+      videoEntity = video
+    }
+
+    const video = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+
+    await this.requireConfirmation(`Are you sure you want to remove the "${video.title}" video?`)
+
+    const api = this.getOriginalApi()
+    this.log(`Removing the Video entity (ID: ${videoId})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, videoId))
+    this.log(`Removing the VideoMedia entity (ID: ${video.media})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, video.media))
+    this.log(`Removing the License entity (ID: ${video.license})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, video.license))
+  }
+}

+ 97 - 0
cli/src/commands/media/updateChannel.ts

@@ -0,0 +1,97 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import ChannelEntitySchema from 'cd-schemas/schemas/entities/ChannelEntity.schema.json'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { InputParser } from 'cd-schemas'
+import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { Actor, Entity } from '@joystream/types/content-directory'
+import { flags } from '@oclif/command'
+import { createType } from '@joystream/types'
+
+export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
+  static description = 'Update one of the owned channels on Joystream (requires a membership).'
+  static flags = {
+    ...IOFlags,
+    asCurator: flags.boolean({
+      description: 'Provide this flag in order to use Curator context for the update',
+      required: false,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the channel to update',
+      required: false,
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+      flags: { asCurator },
+    } = this.parse(UpdateChannelCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+
+    let memberId: number | undefined, actor: Actor
+
+    if (asCurator) {
+      actor = await this.getCuratorContext(['Channel'])
+    } else {
+      memberId = await this.getRequiredMemberId()
+      actor = createType('Actor', { Member: memberId })
+    }
+
+    await this.requestAccountDecoding(account)
+
+    let channelEntity: Entity, channelId: number
+    if (id) {
+      channelId = parseInt(id)
+      channelEntity = await this.getEntity(channelId, 'Channel', memberId)
+    } else {
+      const [id, channel] = await this.promptForEntityEntry('Select a channel to update', 'Channel', 'title', memberId)
+      channelId = id.toNumber()
+      channelEntity = channel
+    }
+
+    const currentValues = await this.parseToKnownEntityJson<ChannelEntity>(channelEntity)
+    this.jsonPrettyPrint(JSON.stringify(currentValues))
+
+    const channelJsonSchema = (ChannelEntitySchema as unknown) as JSONSchema
+
+    const { input, output } = this.parse(UpdateChannelCommand).flags
+
+    let inputJson = await getInputJson<ChannelEntity>(input, channelJsonSchema)
+    if (!inputJson) {
+      const customPrompts: JsonSchemaCustomPrompts<ChannelEntity> = [
+        [
+          'language',
+          () =>
+            this.promptForEntityId('Choose channel language', 'Language', 'name', undefined, currentValues.language),
+        ],
+      ]
+
+      if (!asCurator) {
+        // Skip isCensored is it's not updated by the curator
+        customPrompts.push(['isCensored', 'skip'])
+      }
+
+      const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, currentValues, customPrompts)
+
+      inputJson = await prompter.promptAll(true)
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(inputJson))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      saveOutputJson(output, `${inputJson.title}Channel.json`, inputJson)
+      const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+      const updateOperations = await inputParser.getEntityUpdateOperations(inputJson, 'Channel', channelId)
+      this.log('Sending the extrinsic...')
+      await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, updateOperations])
+    }
+  }
+}

+ 106 - 0
cli/src/commands/media/updateVideo.ts

@@ -0,0 +1,106 @@
+import VideoEntitySchema from 'cd-schemas/schemas/entities/VideoEntity.schema.json'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+import { InputParser } from 'cd-schemas'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { Actor, Entity } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+import { flags } from '@oclif/command'
+import MediaCommandBase from '../../base/MediaCommandBase'
+
+export default class UpdateVideoCommand extends MediaCommandBase {
+  static description = 'Update existing video information (requires controller/maintainer access).'
+  static flags = {
+    // TODO: ...IOFlags, - providing input as json
+    asCurator: flags.boolean({
+      description: 'Specify in order to update the video as curator',
+      required: false,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the Video to update',
+      required: false,
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+      flags: { asCurator },
+    } = this.parse(UpdateVideoCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+
+    let memberId: number | undefined, actor: Actor
+
+    if (asCurator) {
+      actor = await this.getCuratorContext(['Video'])
+    } else {
+      memberId = await this.getRequiredMemberId()
+      actor = createType('Actor', { Member: memberId })
+    }
+
+    await this.requestAccountDecoding(account)
+
+    let videoEntity: Entity, videoId: number
+    if (id) {
+      videoId = parseInt(id)
+      videoEntity = await this.getEntity(videoId, 'Video', memberId)
+    } else {
+      const [id, video] = await this.promptForEntityEntry('Select a video to update', 'Video', 'title', memberId)
+      videoId = id.toNumber()
+      videoEntity = video
+    }
+
+    const currentValues = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
+
+    const {
+      language: currLanguageId,
+      category: currCategoryId,
+      publishedBeforeJoystream: currPublishedBeforeJoystream,
+    } = currentValues
+
+    const customizedPrompts: JsonSchemaCustomPrompts<VideoEntity> = [
+      [
+        'language',
+        () => this.promptForEntityId('Choose Video language', 'Language', 'name', undefined, currLanguageId),
+      ],
+      [
+        'category',
+        () => this.promptForEntityId('Choose Video category', 'ContentCategory', 'name', undefined, currCategoryId),
+      ],
+      ['publishedBeforeJoystream', () => this.promptForPublishedBeforeJoystream(currPublishedBeforeJoystream)],
+    ]
+    const videoPrompter = new JsonSchemaPrompter<VideoEntity>(videoJsonSchema, currentValues, customizedPrompts)
+
+    // Prompt for other video data
+    const updatedProps: Partial<VideoEntity> = await videoPrompter.promptMultipleProps([
+      'language',
+      'category',
+      'title',
+      'description',
+      'thumbnailURL',
+      'duration',
+      'isPublic',
+      'isExplicit',
+      'hasMarketing',
+      'publishedBeforeJoystream',
+      'skippableIntroDuration',
+    ])
+
+    if (asCurator) {
+      updatedProps.isCensored = await videoPrompter.promptSingleProp('isCensored')
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(updatedProps))
+
+    // Parse inputs into operations and send final extrinsic
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+    const videoUpdateOperations = await inputParser.getEntityUpdateOperations(updatedProps, 'Video', videoId)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, videoUpdateOperations], true)
+  }
+}

+ 59 - 0
cli/src/commands/media/updateVideoLicense.ts

@@ -0,0 +1,59 @@
+import MediaCommandBase from '../../base/MediaCommandBase'
+import { LicenseEntity, VideoEntity } from 'cd-schemas/types/entities'
+import { InputParser } from 'cd-schemas'
+import { Entity } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+
+export default class UpdateVideoLicenseCommand extends MediaCommandBase {
+  static description = 'Update existing video license (requires controller/maintainer access).'
+  // TODO: ...IOFlags, - providing input as json
+
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the Video',
+      required: false,
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+    } = this.parse(UpdateVideoLicenseCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = createType('Actor', { Member: memberId })
+
+    await this.requestAccountDecoding(account)
+
+    let videoEntity: Entity, videoId: number
+    if (id) {
+      videoId = parseInt(id)
+      videoEntity = await this.getEntity(videoId, 'Video', memberId)
+    } else {
+      const [id, video] = await this.promptForEntityEntry('Select a video to update', 'Video', 'title', memberId)
+      videoId = id.toNumber()
+      videoEntity = video
+    }
+
+    const video = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const currentLicense = await this.getAndParseKnownEntity<LicenseEntity>(video.license)
+
+    this.log('Current license:', currentLicense)
+
+    const updateInput: Partial<VideoEntity> = {
+      license: await this.promptForNewLicense(),
+    }
+
+    const api = this.getOriginalApi()
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+    const videoUpdateOperations = await inputParser.getEntityUpdateOperations(updateInput, 'Video', videoId)
+
+    this.log('Setting new license...')
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.transaction(actor, videoUpdateOperations), true)
+
+    this.log(`Removing old License entity (ID: ${video.license})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, video.license))
+  }
+}

+ 382 - 0
cli/src/commands/media/uploadVideo.ts

@@ -0,0 +1,382 @@
+import VideoEntitySchema from 'cd-schemas/schemas/entities/VideoEntity.schema.json'
+import VideoMediaEntitySchema from 'cd-schemas/schemas/entities/VideoMediaEntity.schema.json'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+import { VideoMediaEntity } from 'cd-schemas/types/entities/VideoMediaEntity'
+import { InputParser } from 'cd-schemas'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { flags } from '@oclif/command'
+import fs from 'fs'
+import ExitCodes from '../../ExitCodes'
+import { ContentId } from '@joystream/types/media'
+import ipfsHash from 'ipfs-only-hash'
+import { cli } from 'cli-ux'
+import axios, { AxiosRequestConfig } from 'axios'
+import { URL } from 'url'
+import ipfsHttpClient from 'ipfs-http-client'
+import first from 'it-first'
+import last from 'it-last'
+import toBuffer from 'it-to-buffer'
+import ffmpegInstaller from '@ffmpeg-installer/ffmpeg'
+import ffmpeg from 'fluent-ffmpeg'
+import MediaCommandBase from '../../base/MediaCommandBase'
+
+ffmpeg.setFfmpegPath(ffmpegInstaller.path)
+
+const DATA_OBJECT_TYPE_ID = 1
+const MAX_FILE_SIZE = 500 * 1024 * 1024
+
+type VideoMetadata = {
+  width?: number
+  height?: number
+  codecName?: string
+  codecFullName?: string
+  duration?: number
+}
+
+export default class UploadVideoCommand extends MediaCommandBase {
+  static description = 'Upload a new Video to a channel (requires a membership).'
+  static flags = {
+    // TODO: ...IOFlags, - providing input as json
+    channel: flags.integer({
+      char: 'c',
+      required: false,
+      description:
+        'ID of the channel to assign the video to (if omitted - one of the owned channels can be selected from the list)',
+    }),
+  }
+
+  static args = [
+    {
+      name: 'filePath',
+      required: true,
+      description: 'Path to the media file to upload',
+    },
+  ]
+
+  private createReadStreamWithProgressBar(filePath: string, barTitle: string, fileSize?: number) {
+    // Progress CLI UX:
+    // https://github.com/oclif/cli-ux#cliprogress
+    // https://www.npmjs.com/package/cli-progress
+    if (!fileSize) {
+      fileSize = fs.statSync(filePath).size
+    }
+    const progress = cli.progress({ format: `${barTitle} | {bar} | {value}/{total} KB processed` })
+    let processedKB = 0
+    const fileSizeKB = Math.ceil(fileSize / 1024)
+    progress.start(fileSizeKB, processedKB)
+    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,
+    }
+  }
+
+  private async calculateFileIpfsHash(filePath: string, fileSize: number): Promise<string> {
+    const { fileStream } = this.createReadStreamWithProgressBar(filePath, 'Calculating file hash', fileSize)
+    const hash: string = await ipfsHash.of(fileStream)
+
+    return hash
+  }
+
+  private async getDiscoveryDataViaLocalIpfsNode(ipnsIdentity: string): Promise<any> {
+    const ipfs = ipfsHttpClient({
+      // TODO: Allow customizing node url:
+      // host: 'localhost', port: '5001', protocol: 'http',
+      timeout: 10000,
+    })
+
+    const ipnsAddress = `/ipns/${ipnsIdentity}/`
+    const ipfsName = await last(
+      ipfs.name.resolve(ipnsAddress, {
+        recursive: false,
+        nocache: false,
+      })
+    )
+    const data: any = await first(ipfs.get(ipfsName))
+    const buffer = await toBuffer(data.content)
+
+    return JSON.parse(buffer.toString())
+  }
+
+  private async getDiscoveryDataViaBootstrapEndpoint(storageProviderId: number): Promise<any> {
+    const bootstrapEndpoint = await this.getApi().getRandomBootstrapEndpoint()
+    if (!bootstrapEndpoint) {
+      this.error('No bootstrap endpoints available', { exit: ExitCodes.ApiError })
+    }
+    this.log('Bootstrap endpoint:', bootstrapEndpoint)
+    const discoveryEndpoint = new URL(`discover/v0/${storageProviderId}`, bootstrapEndpoint).toString()
+    try {
+      const data = (await axios.get(discoveryEndpoint)).data
+      return data
+    } catch (e) {
+      this.error(`Cannot retrieve data from bootstrap enpoint (${discoveryEndpoint})`, {
+        exit: ExitCodes.ExternalInfrastructureError,
+      })
+    }
+  }
+
+  private async getUploadUrlFromDiscoveryData(data: any, contentId: ContentId): Promise<string> {
+    if (typeof data === 'object' && data !== null && data.serialized) {
+      const unserialized = JSON.parse(data.serialized)
+      if (unserialized.asset && unserialized.asset.endpoint && typeof unserialized.asset.endpoint === 'string') {
+        return new URL(`asset/v0/${contentId.encode()}`, unserialized.asset.endpoint).toString()
+      }
+    }
+    this.error(`Unexpected discovery data: ${JSON.stringify(data)}`)
+  }
+
+  private async getUploadUrl(ipnsIdentity: string, storageProviderId: number, contentId: ContentId): Promise<string> {
+    let data: any
+    try {
+      this.log('Trying to connect to local ipfs node...')
+      data = await this.getDiscoveryDataViaLocalIpfsNode(ipnsIdentity)
+    } catch (e) {
+      this.warn("Couldn't get data from local ipfs node, resolving to bootstrap endpoint...")
+      data = await this.getDiscoveryDataViaBootstrapEndpoint(storageProviderId)
+    }
+
+    const uploadUrl = await this.getUploadUrlFromDiscoveryData(data, contentId)
+
+    return uploadUrl
+  }
+
+  private async getVideoMetadata(filePath: string): Promise<VideoMetadata | null> {
+    let metadata: VideoMetadata | null = null
+    const metadataPromise = new Promise<VideoMetadata>((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'))
+        }
+      })
+    })
+
+    try {
+      metadata = await metadataPromise
+    } catch (e) {
+      const message = e.message || e
+      this.warn(`Failed to get video metadata via ffprobe (${message})`)
+    }
+
+    return metadata
+  }
+
+  private async uploadVideo(filePath: string, fileSize: number, uploadUrl: string) {
+    const { fileStream, progressBar } = this.createReadStreamWithProgressBar(filePath, 'Uploading', fileSize)
+    fileStream.on('end', () => {
+      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(),
+        },
+        maxContentLength: MAX_FILE_SIZE,
+      }
+      await axios.put(uploadUrl, fileStream, config)
+      cli.action.stop()
+
+      this.log('File uploaded!')
+    } catch (e) {
+      progressBar.stop()
+      cli.action.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 run() {
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = { Member: memberId }
+
+    await this.requestAccountDecoding(account)
+
+    const {
+      args: { filePath },
+      flags: { channel: inputChannelId },
+    } = this.parse(UploadVideoCommand)
+
+    // Basic file validation
+    if (!fs.existsSync(filePath)) {
+      this.error('File does not exist under provided path!', { exit: ExitCodes.FileNotFound })
+    }
+
+    const { size: fileSize } = fs.statSync(filePath)
+    if (fileSize > MAX_FILE_SIZE) {
+      this.error(`File size too large! Max. file size is: ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(2)} MB`)
+    }
+
+    const videoMetadata = await this.getVideoMetadata(filePath)
+    this.log('Video media file parameters established:', { ...(videoMetadata || {}), size: fileSize })
+
+    // Check if any providers are available
+    if (!(await this.getApi().isAnyProviderAvailable())) {
+      this.error('No active storage providers available! Try again later...', {
+        exit: ExitCodes.ActionCurrentlyUnavailable,
+      })
+    }
+
+    // Start by prompting for a channel to make sure user has one available
+    let channelId: number
+    if (inputChannelId === undefined) {
+      channelId = await this.promptForEntityId(
+        'Select a channel to publish the video under',
+        'Channel',
+        'title',
+        memberId
+      )
+    } else {
+      await this.getEntity(inputChannelId, 'Channel', memberId) // Validates if exists and belongs to member
+      channelId = inputChannelId
+    }
+
+    // Calculate hash and create content id
+    const contentId = ContentId.generate(this.getTypesRegistry())
+    const ipfsCid = await this.calculateFileIpfsHash(filePath, fileSize)
+
+    this.log('Video identification established:', {
+      contentId: contentId.toString(),
+      encodedContentId: contentId.encode(),
+      ipfsHash: ipfsCid,
+    })
+
+    // Send dataDirectory.addContent extrinsic
+    await this.sendAndFollowNamedTx(account, 'dataDirectory', 'addContent', [
+      memberId,
+      contentId,
+      DATA_OBJECT_TYPE_ID,
+      fileSize,
+      ipfsCid,
+    ])
+
+    const dataObject = await this.getApi().dataObjectByContentId(contentId)
+    if (!dataObject) {
+      this.error('Data object could not be retrieved from chain', { exit: ExitCodes.ApiError })
+    }
+
+    this.log('Data object:', dataObject.toJSON())
+
+    // Get storage provider identity
+    const storageProviderId = dataObject.liaison.toNumber()
+    const ipnsIdentity = await this.getApi().ipnsIdentity(storageProviderId)
+
+    if (!ipnsIdentity) {
+      this.error('Storage provider IPNS identity could not be determined', { exit: ExitCodes.ApiError })
+    }
+
+    // Resolve upload url and upload the video
+    const uploadUrl = await this.getUploadUrl(ipnsIdentity, storageProviderId, contentId)
+    this.log('Resolved upload url:', uploadUrl)
+
+    await this.uploadVideo(filePath, fileSize, uploadUrl)
+
+    // Prompting for the data:
+
+    // Set the defaults
+    const videoMediaDefaults: Partial<VideoMediaEntity> = {
+      pixelWidth: videoMetadata?.width,
+      pixelHeight: videoMetadata?.height,
+    }
+    const videoDefaults: Partial<VideoEntity> = {
+      duration: videoMetadata?.duration,
+      skippableIntroDuration: 0,
+    }
+    // Create prompting helpers
+    const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
+    const videoMediaJsonSchema = (VideoMediaEntitySchema as unknown) as JSONSchema
+
+    const videoMediaPrompter = new JsonSchemaPrompter<VideoMediaEntity>(videoMediaJsonSchema, videoMediaDefaults)
+    const videoPrompter = new JsonSchemaPrompter<VideoEntity>(videoJsonSchema, videoDefaults)
+
+    // Prompt for the data
+    const encodingSuggestion =
+      videoMetadata && videoMetadata.codecFullName ? ` (suggested: ${videoMetadata.codecFullName})` : ''
+    const encoding = await this.promptForEntityId(
+      `Choose Video encoding${encodingSuggestion}`,
+      'VideoMediaEncoding',
+      'name'
+    )
+    const { pixelWidth, pixelHeight } = await videoMediaPrompter.promptMultipleProps(['pixelWidth', 'pixelHeight'])
+    const language = await this.promptForEntityId('Choose Video language', 'Language', 'name')
+    const category = await this.promptForEntityId('Choose Video category', 'ContentCategory', 'name')
+    const videoProps = await videoPrompter.promptMultipleProps([
+      'title',
+      'description',
+      'thumbnailURL',
+      'duration',
+      'isPublic',
+      'isExplicit',
+      'hasMarketing',
+      'skippableIntroDuration',
+    ])
+
+    const license = await videoPrompter.promptSingleProp('license', () => this.promptForNewLicense())
+    const publishedBeforeJoystream = await videoPrompter.promptSingleProp('publishedBeforeJoystream', () =>
+      this.promptForPublishedBeforeJoystream()
+    )
+
+    // Create final inputs
+    const videoMediaInput: VideoMediaEntity = {
+      encoding,
+      pixelWidth,
+      pixelHeight,
+      size: fileSize,
+      location: { new: { joystreamMediaLocation: { new: { dataObjectId: contentId.encode() } } } },
+    }
+    const videoInput: VideoEntity = {
+      ...videoProps,
+      channel: channelId,
+      language,
+      category,
+      license,
+      media: { new: videoMediaInput },
+      publishedBeforeJoystream,
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(videoInput))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    // Parse inputs into operations and send final extrinsic
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
+      {
+        className: 'Video',
+        entries: [videoInput],
+      },
+    ])
+    const operations = await inputParser.getEntityBatchOperations()
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations])
+  }
+}

+ 197 - 58
cli/src/commands/working-groups/createOpening.ts

@@ -1,89 +1,228 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
-import { ApiMethodArg, ApiMethodNamedArgs } from '../../Types'
+import { GroupMember } from '../../Types'
 import chalk from 'chalk'
-import { flags } from '@oclif/command'
 import { apiModuleByGroup } from '../../Api'
-import WorkerOpeningOptions from '../../promptOptions/addWorkerOpening'
-import { setDefaults } from '../../helpers/promptOptions'
+import HRTSchema from '@joystream/types/hiring/schemas/role.schema.json'
+import { GenericJoyStreamRoleSchema as HRTJson } from '@joystream/types/hiring/schemas/role.schema.typings'
+import { JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import WGOpeningSchema from '../../json-schemas/WorkingGroupOpening.schema.json'
+import { WorkingGroupOpening as WGOpeningJson } from '../../json-schemas/typings/WorkingGroupOpening.schema'
+import _ from 'lodash'
+import { IOFlags, getInputJson, ensureOutputFileIsWriteable, saveOutputJsonToFile } from '../../helpers/InputOutput'
+import Ajv from 'ajv'
+import ExitCodes from '../../ExitCodes'
+import { flags } from '@oclif/command'
+import { createType } from '@joystream/types'
 
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
   static description = 'Create working group opening (requires lead access)'
   static flags = {
     ...WorkingGroupsCommandBase.flags,
-    useDraft: flags.boolean({
-      char: 'd',
-      description:
-        'Whether to create the opening from existing draft.\n' +
-        'If provided without --draftName - the list of choices will be displayed.',
-    }),
-    draftName: flags.string({
-      char: 'n',
-      description: 'Name of the draft to create the opening from.',
-      dependsOn: ['useDraft'],
+    input: IOFlags.input,
+    output: flags.string({
+      char: 'o',
+      required: false,
+      description: 'Path to the file where the output JSON should be saved (this output can be then reused as input)',
     }),
-    createDraftOnly: flags.boolean({
-      char: 'c',
+    edit: flags.boolean({
+      char: 'e',
+      required: false,
       description:
-        'If provided - the extrinsic will not be executed. Use this flag if you only want to create a draft.',
+        'If provided along with --input - launches in edit mode allowing to modify the input before sending the exstinsic',
+      dependsOn: ['input'],
     }),
-    skipPrompts: flags.boolean({
-      char: 's',
-      description: 'Whether to skip all prompts when adding from draft (will use all default values)',
-      dependsOn: ['useDraft'],
-      exclusive: ['createDraftOnly'],
+    dryRun: flags.boolean({
+      required: false,
+      description:
+        'If provided along with --output - skips sending the actual extrinsic' +
+        '(can be used to generate a "draft" which can be provided as input later)',
+      dependsOn: ['output'],
     }),
   }
 
+  getHRTDefaults(memberHandle: string): HRTJson {
+    const groupName = _.startCase(this.group)
+    return {
+      version: 1,
+      headline: `Looking for ${groupName}!`,
+      job: {
+        title: groupName,
+        description: `Become part of the ${groupName} Group! This is a great opportunity to support Joystream!`,
+      },
+      application: {
+        sections: [
+          {
+            title: 'About you',
+            questions: [
+              {
+                title: 'Your name',
+                type: 'text',
+              },
+              {
+                title: 'What makes you a good fit for the job?',
+                type: 'text area',
+              },
+            ],
+          },
+        ],
+      },
+      reward: '10k JOY per 3600 blocks',
+      creator: {
+        membership: {
+          handle: memberHandle,
+        },
+      },
+    }
+  }
+
+  createTxParams(wgOpeningJson: WGOpeningJson, hrtJson: HRTJson) {
+    return [
+      wgOpeningJson.activateAt,
+      createType('WorkingGroupOpeningPolicyCommitment', {
+        max_review_period_length: wgOpeningJson.maxReviewPeriodLength,
+        application_rationing_policy: wgOpeningJson.maxActiveApplicants
+          ? { max_active_applicants: wgOpeningJson.maxActiveApplicants }
+          : null,
+        application_staking_policy: wgOpeningJson.applicationStake
+          ? {
+              amount: wgOpeningJson.applicationStake.value,
+              amount_mode: wgOpeningJson.applicationStake.mode,
+            }
+          : null,
+        role_staking_policy: wgOpeningJson.roleStake
+          ? {
+              amount: wgOpeningJson.roleStake.value,
+              amount_mode: wgOpeningJson.roleStake.mode,
+            }
+          : null,
+        terminate_role_stake_unstaking_period: wgOpeningJson.terminateRoleUnstakingPeriod,
+        exit_role_stake_unstaking_period: wgOpeningJson.leaveRoleUnstakingPeriod,
+      }),
+      JSON.stringify(hrtJson),
+      createType('OpeningType', 'Worker'),
+    ]
+  }
+
+  async promptForData(
+    lead: GroupMember,
+    rememberedInput?: [WGOpeningJson, HRTJson]
+  ): Promise<[WGOpeningJson, HRTJson]> {
+    const openingDefaults = rememberedInput?.[0]
+    const openingPrompt = new JsonSchemaPrompter<WGOpeningJson>(
+      (WGOpeningSchema as unknown) as JSONSchema,
+      openingDefaults
+    )
+    const wgOpeningJson = await openingPrompt.promptAll()
+
+    const hrtDefaults = rememberedInput?.[1] || this.getHRTDefaults(lead.profile.handle.toString())
+    this.log(`Values for ${chalk.greenBright('human_readable_text')} json:`)
+    const hrtPropmpt = new JsonSchemaPrompter<HRTJson>((HRTSchema as unknown) as JSONSchema, hrtDefaults)
+    // Prompt only for 'headline', 'job', 'application', 'reward' and 'process', leave the rest default
+    const headline = await hrtPropmpt.promptSingleProp('headline')
+    this.log('General information about the job:')
+    const job = await hrtPropmpt.promptSingleProp('job')
+    this.log('Application form sections and questions:')
+    const application = await hrtPropmpt.promptSingleProp('application')
+    this.log('Reward displayed in the opening box:')
+    const reward = await hrtPropmpt.promptSingleProp('reward')
+    this.log('Hiring process details (additional information)')
+    const process = await hrtPropmpt.promptSingleProp('process')
+
+    const hrtJson = { ...hrtDefaults, job, headline, application, reward, process }
+
+    return [wgOpeningJson, hrtJson]
+  }
+
+  async getInputFromFile(filePath: string): Promise<[WGOpeningJson, HRTJson]> {
+    const ajv = new Ajv({ allErrors: true })
+    const inputParams = await getInputJson<[WGOpeningJson, HRTJson]>(filePath)
+    if (!Array.isArray(inputParams) || inputParams.length !== 2) {
+      this.error('Invalid input file', { exit: ExitCodes.InvalidInput })
+    }
+    const [openingJson, hrtJson] = inputParams
+    if (!ajv.validate(WGOpeningSchema, openingJson)) {
+      this.error(`Invalid input file:\n${ajv.errorsText(undefined, { dataVar: 'openingJson', separator: '\n' })}`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+    if (!ajv.validate(HRTSchema, hrtJson)) {
+      this.error(`Invalid input file:\n${ajv.errorsText(undefined, { dataVar: 'hrtJson', separator: '\n' })}`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+
+    return [openingJson, hrtJson]
+  }
+
   async run() {
     const account = await this.getRequiredSelectedAccount()
     // lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLead()
+    await this.requestAccountDecoding(account) // Prompt for password
 
-    const { flags } = this.parse(WorkingGroupsCreateOpening)
+    const {
+      flags: { input, output, edit, dryRun },
+    } = this.parse(WorkingGroupsCreateOpening)
 
-    const promptOptions = new WorkerOpeningOptions()
-    let defaultValues: ApiMethodNamedArgs | undefined
-    if (flags.useDraft) {
-      const draftName = flags.draftName || (await this.promptForOpeningDraft())
-      defaultValues = await this.loadOpeningDraftParams(draftName)
-      setDefaults(promptOptions, defaultValues)
-    }
+    ensureOutputFileIsWriteable(output)
 
-    if (!flags.skipPrompts) {
-      const module = apiModuleByGroup[this.group]
-      const method = 'addOpening'
+    let tryAgain = false
+    let rememberedInput: [WGOpeningJson, HRTJson] | undefined
+    do {
+      if (edit) {
+        rememberedInput = await this.getInputFromFile(input as string)
+      }
+      // Either prompt for the data or get it from input file
+      const [openingJson, hrtJson] =
+        !input || edit || tryAgain
+          ? await this.promptForData(lead, rememberedInput)
+          : await this.getInputFromFile(input)
 
-      let saveDraft = false
-      let params: ApiMethodArg[]
-      if (flags.createDraftOnly) {
-        params = await this.promptForExtrinsicParams(module, method, promptOptions)
-        saveDraft = true
-      } else {
-        await this.requestAccountDecoding(account) // Prompt for password
-        params = await this.buildAndSendExtrinsic(account, module, method, promptOptions, true)
+      // Remember the provided/fetched data in a variable
+      rememberedInput = [openingJson, hrtJson]
 
-        saveDraft = await this.simplePrompt({
-          message: 'Do you wish to save this opening as draft?',
-          type: 'confirm',
-        })
+      // Generate and ask to confirm tx params
+      const txParams = this.createTxParams(openingJson, hrtJson)
+      this.jsonPrettyPrint(JSON.stringify(txParams))
+      const confirmed = await this.simplePrompt({
+        type: 'confirm',
+        message: 'Do you confirm these extrinsic parameters?',
+      })
+      if (!confirmed) {
+        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+        continue
       }
 
-      if (saveDraft) {
-        const draftName = await this.promptForNewOpeningDraftName()
-        this.saveOpeningDraft(draftName, params)
+      // Save output to file
+      if (output) {
+        try {
+          saveOutputJsonToFile(output, rememberedInput)
+          this.log(chalk.green(`Output succesfully saved in: ${chalk.white(output)}!`))
+        } catch (e) {
+          this.warn(`Could not save output to ${output}!`)
+        }
+      }
 
-        this.log(chalk.green(`Opening draft ${chalk.white(draftName)} succesfully saved!`))
+      if (dryRun) {
+        this.exit(ExitCodes.OK)
       }
-    } else {
-      await this.requestAccountDecoding(account) // Prompt for password
+
+      // Send the tx
       this.log(chalk.white('Sending the extrinsic...'))
-      await this.sendExtrinsic(
+      const txSuccess = await this.sendAndFollowTx(
         account,
-        apiModuleByGroup[this.group],
-        'addOpening',
-        defaultValues!.map((v) => v.value)
+        this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...txParams),
+        true // warnOnly
       )
-      this.log(chalk.green('Opening succesfully created!'))
-    }
+
+      // Display a success message on success or ask to try again on error
+      if (txSuccess) {
+        this.log(chalk.green('Opening succesfully created!'))
+        tryAgain = false
+      } else {
+        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+      }
+    } while (tryAgain)
   }
 }

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

@@ -42,7 +42,7 @@ export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsComma
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'decreaseStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'decreaseStake', [workerId, balance])
 
     this.log(
       chalk.green(

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

@@ -41,7 +41,7 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'terminateRole', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateRole', [
       workerId,
       rationale,
       shouldSlash,

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

@@ -33,7 +33,7 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'fillOpening', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'fillOpening', [
       openingId,
       applicationIds,
       rewardPolicyOpt,

+ 1 - 4
cli/src/commands/working-groups/increaseStake.ts

@@ -30,10 +30,7 @@ export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'increaseStake', [
-      worker.workerId,
-      balance,
-    ])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'increaseStake', [worker.workerId, balance])
 
     this.log(
       chalk.green(

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

@@ -21,7 +21,7 @@ export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'leaveRole', [worker.workerId, rationale])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'leaveRole', [worker.workerId, rationale])
 
     this.log(chalk.green(`Succesfully left the role! (worker id: ${chalk.white(worker.workerId.toNumber())})`))
   }

+ 22 - 0
cli/src/commands/working-groups/setDefaultGroup.ts

@@ -0,0 +1,22 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+
+export default class SetDefaultGroupCommand extends WorkingGroupsCommandBase {
+  static description = 'Change the default group context for working-groups commands.'
+  static flags = { ...WorkingGroupsCommandBase.flags }
+
+  async run() {
+    const {
+      flags: { group },
+    } = this.parse(SetDefaultGroupCommand)
+
+    if (!group) {
+      this.error('--group flag is required', { exit: ExitCodes.InvalidInput })
+    }
+
+    await this.setPreservedState({ defaultWorkingGroup: group })
+
+    this.log(chalk.green(`${chalk.white(group)} succesfully set as default working group context`))
+  }
+}

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

@@ -39,7 +39,7 @@ export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'slashStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'slashStake', [workerId, balance])
 
     this.log(
       chalk.green(

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

@@ -29,7 +29,7 @@ export default class WorkingGroupsStartAcceptingApplications extends WorkingGrou
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'acceptApplications', [openingId])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'acceptApplications', [openingId])
 
     this.log(
       chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${chalk.white('Accepting Applications')}`)

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

@@ -29,7 +29,7 @@ export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommand
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'beginApplicantReview', [openingId])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'beginApplicantReview', [openingId])
 
     this.log(chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${chalk.white('In Review')}`))
   }

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

@@ -30,7 +30,7 @@ export default class WorkingGroupsTerminateApplication extends WorkingGroupsComm
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'terminateApplication', [applicationId])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateApplication', [applicationId])
 
     this.log(chalk.green(`Application ${chalk.white(applicationId)} has been succesfully terminated!`))
   }

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

@@ -38,7 +38,7 @@ export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsComma
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
       worker.workerId,
       newRewardAccount,
     ])

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

@@ -32,7 +32,7 @@ export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommand
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
       worker.workerId,
       newRoleAccount,
     ])

+ 3 - 3
cli/src/commands/working-groups/updateWorkerReward.ts

@@ -24,8 +24,8 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
   formatReward(reward?: Reward) {
     return reward
       ? formatBalance(reward.value) +
-          (reward.interval && ` / ${reward.interval} block(s)`) +
-          (reward.nextPaymentBlock && ` (next payment: #${reward.nextPaymentBlock})`)
+          (reward.interval ? ` / ${reward.interval} block(s)` : '') +
+          (reward.nextPaymentBlock ? ` (next payment: #${reward.nextPaymentBlock})` : '')
       : 'NONE'
   }
 
@@ -55,7 +55,7 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRewardAmount', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAmount', [
       workerId,
       newRewardValue,
     ])

+ 109 - 0
cli/src/helpers/InputOutput.ts

@@ -0,0 +1,109 @@
+import { flags } from '@oclif/command'
+import { CLIError } from '@oclif/errors'
+import ExitCodes from '../ExitCodes'
+import fs from 'fs'
+import path from 'path'
+import Ajv from 'ajv'
+import $RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { getSchemasLocation } from 'cd-schemas'
+import chalk from 'chalk'
+
+// Default schema path for resolving refs
+const DEFAULT_SCHEMA_PATH = getSchemasLocation('entities') + path.sep
+
+export const IOFlags = {
+  input: flags.string({
+    char: 'i',
+    required: false,
+    description: `Path to JSON file to use as input (if not specified - the input can be provided interactively)`,
+  }),
+  output: flags.string({
+    char: 'o',
+    required: false,
+    description:
+      'Path to the directory where the output JSON file should be placed (the output file can be then reused as input)',
+  }),
+}
+
+export async function getInputJson<T>(inputPath?: string, schema?: JSONSchema, schemaPath?: string): Promise<T | null> {
+  if (inputPath) {
+    let content, jsonObj
+    try {
+      content = fs.readFileSync(inputPath).toString()
+    } catch (e) {
+      throw new CLIError(`Cannot access the input file at: ${inputPath}`, { exit: ExitCodes.FsOperationFailed })
+    }
+    try {
+      jsonObj = JSON.parse(content)
+    } catch (e) {
+      throw new CLIError(`JSON parsing failed for file: ${inputPath}`, { exit: ExitCodes.InvalidInput })
+    }
+    if (schema) {
+      const ajv = new Ajv()
+      schema = await $RefParser.dereference(schemaPath || DEFAULT_SCHEMA_PATH, schema, {})
+      const valid = ajv.validate(schema, jsonObj) as boolean
+      if (!valid) {
+        throw new CLIError(`Input JSON file is not valid: ${ajv.errorsText()}`)
+      }
+    }
+
+    return jsonObj as T
+  }
+
+  return null
+}
+
+export function saveOutputJson(outputPath: string | undefined, fileName: string, data: any): void {
+  if (outputPath) {
+    let outputFilePath = path.join(outputPath, fileName)
+    let postfix = 0
+    while (fs.existsSync(outputFilePath)) {
+      fileName = fileName.replace(/(_[0-9]+)?\.json/, `_${++postfix}.json`)
+      outputFilePath = path.join(outputPath, fileName)
+    }
+    saveOutputJsonToFile(outputFilePath, data)
+
+    console.log(`${chalk.green('Output succesfully saved to:')} ${chalk.white(outputFilePath)}`)
+  }
+}
+
+// Output as file:
+
+export function saveOutputJsonToFile(outputFilePath: string, data: any): void {
+  try {
+    fs.writeFileSync(outputFilePath, JSON.stringify(data, null, 4))
+  } catch (e) {
+    throw new CLIError(`Could not save the output to: ${outputFilePath}. Check permissions...`, {
+      exit: ExitCodes.FsOperationFailed,
+    })
+  }
+}
+
+export function ensureOutputFileIsWriteable(outputFilePath: string | undefined): void {
+  if (outputFilePath === undefined) {
+    return
+  }
+
+  if (path.extname(outputFilePath) !== '.json') {
+    throw new CLIError(`Output path ${outputFilePath} is not a JSON file!`, { exit: ExitCodes.InvalidInput })
+  }
+
+  if (fs.existsSync(outputFilePath)) {
+    // File already exists - warn the user and check it it's writeable
+    console.warn(`WARNING: ${outputFilePath} already exists and it will get overriden!`)
+    try {
+      fs.accessSync(`${outputFilePath}`, fs.constants.W_OK)
+    } catch (e) {
+      throw new CLIError(`Output path ${outputFilePath} is not writeable!`, { exit: ExitCodes.InvalidInput })
+    }
+  } else {
+    // File does not exist yet - check if the directory is writeable
+    try {
+      fs.accessSync(`${path.dirname(outputFilePath)}`, fs.constants.W_OK)
+    } catch (e) {
+      throw new CLIError(`Output directory ${path.dirname(outputFilePath)} is not writeable!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+}

+ 294 - 0
cli/src/helpers/JsonSchemaPrompt.ts

@@ -0,0 +1,294 @@
+import Ajv from 'ajv'
+import inquirer, { DistinctQuestion } from 'inquirer'
+import _ from 'lodash'
+import RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import chalk from 'chalk'
+import { BOOL_PROMPT_OPTIONS } from './prompting'
+import { getSchemasLocation } from 'cd-schemas'
+import path from 'path'
+
+type CustomPromptMethod = () => Promise<any>
+type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt } | 'skip'
+
+// For the explaination of "string & { x: never }", see: https://github.com/microsoft/TypeScript/issues/29729
+// eslint-disable-next-line @typescript-eslint/ban-types
+export type JsonSchemaCustomPrompts<T = Record<string, unknown>> = [keyof T | (string & {}) | RegExp, CustomPrompt][]
+
+// Default schema path for resolving refs
+// TODO: Would be nice to skip the filename part (but without it it doesn't work)
+const DEFAULT_SCHEMA_PATH = getSchemasLocation('entities') + path.sep
+
+export class JsonSchemaPrompter<JsonResult> {
+  schema: JSONSchema
+  schemaPath: string
+  customPropmpts?: JsonSchemaCustomPrompts
+  ajv: Ajv.Ajv
+  filledObject: Partial<JsonResult>
+
+  constructor(
+    schema: JSONSchema,
+    defaults?: Partial<JsonResult>,
+    customPrompts?: JsonSchemaCustomPrompts,
+    schemaPath: string = DEFAULT_SCHEMA_PATH
+  ) {
+    this.customPropmpts = customPrompts
+    this.schema = schema
+    this.schemaPath = schemaPath
+    // allErrors prevents .validate from setting only one error when in fact there are multiple
+    this.ajv = new Ajv({ allErrors: true })
+    this.filledObject = defaults || {}
+  }
+
+  private oneOfToOptions(oneOf: JSONSchema[], currentValue: any) {
+    let defaultValue: any
+    const choices: { name: string; value: number | string }[] = []
+
+    oneOf.forEach((pSchema, index) => {
+      if (pSchema.description) {
+        choices.push({ name: pSchema.description, value: index.toString() })
+      } else if (pSchema.type === 'object' && pSchema.properties) {
+        choices.push({ name: `{ ${Object.keys(pSchema.properties).join(', ')} }`, value: index.toString() })
+        // Supports defaults for enum variants:
+        if (
+          typeof currentValue === 'object' &&
+          currentValue !== null &&
+          Object.keys(currentValue).join(',') === Object.keys(pSchema.properties).join(',')
+        ) {
+          defaultValue = index.toString()
+        }
+      } else {
+        choices.push({ name: index.toString(), value: index.toString() })
+      }
+    })
+
+    return { choices, default: defaultValue }
+  }
+
+  private getCustomPrompt(propertyPath: string): CustomPrompt | undefined {
+    const found = this.customPropmpts?.find(([pathToMatch]) =>
+      pathToMatch instanceof RegExp ? pathToMatch.test(propertyPath) : propertyPath === pathToMatch
+    )
+
+    return found ? found[1] : undefined
+  }
+
+  private propertyDisplayName(propertyPath: string) {
+    return chalk.green(propertyPath)
+  }
+
+  private async prompt(
+    schema: JSONSchema,
+    propertyPath = '',
+    custom?: CustomPrompt,
+    allPropsRequired = false
+  ): Promise<any> {
+    const customPrompt: CustomPrompt | undefined = custom || this.getCustomPrompt(propertyPath)
+    const propDisplayName = this.propertyDisplayName(propertyPath)
+    const currentValue = _.get(this.filledObject, propertyPath)
+    const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
+
+    if (customPrompt === 'skip') {
+      return
+    }
+
+    // Automatically handle "null" values (useful for enum variants)
+    if (type === 'null') {
+      _.set(this.filledObject, propertyPath, null)
+      return null
+    }
+
+    // Custom prompt
+    if (typeof customPrompt === 'function') {
+      return await this.promptWithRetry(customPrompt, propertyPath, true)
+    }
+
+    // oneOf
+    if (schema.oneOf) {
+      const oneOf = schema.oneOf as JSONSchema[]
+      const options = this.oneOfToOptions(oneOf, currentValue)
+      const { choosen } = await inquirer.prompt({ name: 'choosen', message: propDisplayName, type: 'list', ...options })
+      if (choosen !== options.default) {
+        _.set(this.filledObject, propertyPath, undefined) // Clear any previous value if different variant selected
+      }
+      return await this.prompt(oneOf[parseInt(choosen)], propertyPath)
+    }
+
+    // object
+    if (type === 'object' && schema.properties) {
+      const value: Record<string, any> = {}
+      for (const [pName, pSchema] of Object.entries(schema.properties)) {
+        const objectPropertyPath = propertyPath ? `${propertyPath}.${pName}` : pName
+        const propertyCustomPrompt = this.getCustomPrompt(objectPropertyPath)
+
+        if (propertyCustomPrompt === 'skip') {
+          continue
+        }
+
+        let confirmed = true
+        const required = allPropsRequired || (Array.isArray(schema.required) && schema.required.includes(pName))
+
+        if (!required) {
+          confirmed = (
+            await inquirer.prompt([
+              {
+                message: `Do you want to provide optional ${chalk.greenBright(objectPropertyPath)}?`,
+                type: 'confirm',
+                name: 'confirmed',
+                default:
+                  _.get(this.filledObject, objectPropertyPath) !== undefined &&
+                  _.get(this.filledObject, objectPropertyPath) !== null,
+              },
+            ])
+          ).confirmed
+        }
+        if (confirmed) {
+          value[pName] = await this.prompt(pSchema, objectPropertyPath)
+        } else {
+          _.set(this.filledObject, objectPropertyPath, null)
+        }
+      }
+      return value
+    }
+
+    // array
+    if (type === 'array' && schema.items) {
+      return await this.promptWithRetry(() => this.promptArray(schema, propertyPath), propertyPath, true)
+    }
+
+    // "primitive" values:
+    const basicPromptOptions: DistinctQuestion = {
+      message: propDisplayName,
+      default: currentValue !== undefined ? currentValue : schema.default,
+    }
+
+    let additionalPromptOptions: DistinctQuestion | undefined
+    let normalizer: (v: any) => any = (v) => v
+
+    // Prompt options
+    if (schema.enum) {
+      additionalPromptOptions = { type: 'list', choices: schema.enum as any[] }
+    } else if (type === 'boolean') {
+      additionalPromptOptions = BOOL_PROMPT_OPTIONS
+    }
+
+    // Normalizers
+    if (type === 'integer') {
+      normalizer = (v) => (parseInt(v).toString() === v ? parseInt(v) : v)
+    }
+
+    if (type === 'number') {
+      normalizer = (v) => (Number(v).toString() === v ? Number(v) : v)
+    }
+
+    const promptOptions = { ...basicPromptOptions, ...additionalPromptOptions, ...customPrompt }
+    // Need to wrap in retry, because "validate" will not get called if "type" is "list" etc.
+    return await this.promptWithRetry(
+      async () => normalizer(await this.promptSimple(promptOptions, propertyPath, normalizer)),
+      propertyPath
+    )
+  }
+
+  private setValueAndGetError(propertyPath: string, value: any, nestedErrors = false): string | null {
+    _.set(this.filledObject as Record<string, unknown>, propertyPath, value)
+    this.ajv.validate(this.schema, this.filledObject) as boolean
+    return this.ajv.errors
+      ? this.ajv.errors
+          .filter((e) => (nestedErrors ? e.dataPath.startsWith(`.${propertyPath}`) : e.dataPath === `.${propertyPath}`))
+          .map((e) => (e.dataPath.replace(`.${propertyPath}`, '') || 'This value') + ` ${e.message}`)
+          .join(', ')
+      : null
+  }
+
+  private async promptArray(schema: JSONSchema, propertyPath: string) {
+    if (!schema.items) {
+      return []
+    }
+    const { maxItems = Number.MAX_SAFE_INTEGER } = schema
+    let currItem = 0
+    const result = []
+    while (currItem < maxItems) {
+      const { next } = await inquirer.prompt([
+        {
+          ...BOOL_PROMPT_OPTIONS,
+          name: 'next',
+          message: `Do you want to add another item to ${this.propertyDisplayName(propertyPath)} array?`,
+          default: _.get(this.filledObject, `${propertyPath}[${currItem}]`) !== undefined,
+        },
+      ])
+      if (!next) {
+        break
+      }
+      const itemSchema = Array.isArray(schema.items) ? schema.items[schema.items.length % currItem] : schema.items
+      result.push(await this.prompt(typeof itemSchema === 'boolean' ? {} : itemSchema, `${propertyPath}[${currItem}]`))
+
+      ++currItem
+    }
+
+    return result
+  }
+
+  private async promptSimple(promptOptions: DistinctQuestion, propertyPath: string, normalize?: (v: any) => any) {
+    const { result } = await inquirer.prompt([
+      {
+        ...promptOptions,
+        name: 'result',
+        validate: (v) => {
+          v = normalize ? normalize(v) : v
+          return (
+            this.setValueAndGetError(propertyPath, v) ||
+            (promptOptions.validate ? promptOptions.validate(v) : true) ||
+            true
+          )
+        },
+      },
+    ])
+
+    return result
+  }
+
+  private async promptWithRetry(customMethod: CustomPromptMethod, propertyPath: string, nestedErrors = false) {
+    let error: string | null
+    let value: any
+    do {
+      value = await customMethod()
+      error = this.setValueAndGetError(propertyPath, value, nestedErrors)
+      if (error) {
+        console.log('\n')
+        console.log('Provided value:', value)
+        console.warn(`ERROR: ${error}`)
+        console.warn(`Try providing the input for ${propertyPath} again...`)
+      }
+    } while (error)
+
+    return value
+  }
+
+  async getMainSchema() {
+    return await RefParser.dereference(this.schemaPath, this.schema, {})
+  }
+
+  async promptAll(allPropsRequired = false) {
+    await this.prompt(await this.getMainSchema(), '', undefined, allPropsRequired)
+    return this.filledObject as JsonResult
+  }
+
+  async promptMultipleProps<P extends keyof JsonResult & string, PA extends readonly P[]>(
+    props: PA
+  ): Promise<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> {
+    const result: Partial<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> = {}
+    for (const prop of props) {
+      result[prop] = await this.promptSingleProp(prop)
+    }
+
+    return result as { [K in PA[number]]: Exclude<JsonResult[K], undefined> }
+  }
+
+  async promptSingleProp<P extends keyof JsonResult & string>(
+    p: P,
+    customPrompt?: CustomPrompt
+  ): Promise<Exclude<JsonResult[P], undefined>> {
+    const mainSchema = await this.getMainSchema()
+    await this.prompt(mainSchema.properties![p] as JSONSchema, p, customPrompt)
+    return this.filledObject[p] as Exclude<JsonResult[P], undefined>
+  }
+}

+ 1 - 0
cli/src/helpers/display.ts

@@ -48,6 +48,7 @@ export function displayTable(rows: { [k: string]: string | number }[], cellHoriz
       return Math.max(maxLength, valLength)
     }, columnName.length)
   const columnDef = (columnName: string) => ({
+    header: columnName,
     get: (row: typeof rows[number]) => chalk.white(`${row[columnName]}`),
     minWidth: maxLength(columnName) + cellHorizontalPadding,
   })

+ 9 - 0
cli/src/helpers/prompting.ts

@@ -0,0 +1,9 @@
+import { DistinctQuestion } from 'inquirer'
+
+export const BOOL_PROMPT_OPTIONS: DistinctQuestion = {
+  type: 'list',
+  choices: [
+    { name: 'Yes', value: true },
+    { name: 'No', value: false },
+  ],
+}

+ 73 - 0
cli/src/json-schemas/WorkingGroupOpening.schema.json

@@ -0,0 +1,73 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "https://joystream.org/WorkingGroupOpening.schema.json",
+  "title": "WorkingGroupOpening",
+  "description": "JSON schema to describe Joystream working group opening",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["activateAt", "maxReviewPeriodLength"],
+  "properties": {
+    "activateAt": {
+      "oneOf": [
+        {
+          "type": "object",
+          "additionalProperties": false,
+          "required": ["ExactBlock"],
+          "properties": {
+            "ExactBlock": {
+              "type": "integer",
+              "minimum": 1,
+              "description": "Exact block number"
+            }
+          }
+        },
+        {
+          "type": "object",
+          "additionalProperties": false,
+          "required": ["CurrentBlock"],
+          "properties": { "CurrentBlock": { "type": "null" } }
+        }
+      ]
+    },
+    "maxActiveApplicants": {
+      "type": "integer",
+      "description": "Max. number of active applicants",
+      "minimum": 1,
+      "default": 10
+    },
+    "maxReviewPeriodLength": {
+      "type": "integer",
+      "description": "Max. review period length in blocks",
+      "minimum": 1,
+      "default": 432000
+    },
+    "applicationStake": { "$ref": "#/definitions/StakingPolicy", "description": "Application stake properties" },
+    "roleStake": { "$ref": "#/definitions/StakingPolicy", "description": "Role stake properties" },
+    "terminateRoleUnstakingPeriod": { "$ref": "#/definitions/UnstakingPeriod" },
+    "leaveRoleUnstakingPeriod": { "$ref": "#/definitions/UnstakingPeriod" }
+  },
+  "definitions": {
+    "UnstakingPeriod": {
+      "type": "integer",
+      "minimum": 1,
+      "default": 100800
+    },
+    "StakingPolicy": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["value", "mode"],
+      "properties": {
+        "mode": {
+          "type": "string",
+          "description": "Application stake mode (Exact/AtLeast)",
+          "enum": ["Exact", "AtLeast"]
+        },
+        "value": {
+          "type": "integer",
+          "description": "Required stake value in JOY",
+          "minimum": 1
+        }
+      }
+    }
+  }
+}

+ 60 - 0
cli/src/json-schemas/typings/WorkingGroupOpening.schema.d.ts

@@ -0,0 +1,60 @@
+/* tslint:disable */
+/**
+ * This file was automatically generated by json-schema-to-typescript.
+ * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
+ * and run json-schema-to-typescript to regenerate this file.
+ */
+
+export type UnstakingPeriod = number
+
+/**
+ * JSON schema to describe Joystream working group opening
+ */
+export interface WorkingGroupOpening {
+  activateAt:
+    | {
+        /**
+         * Exact block number
+         */
+        ExactBlock: number
+      }
+    | {
+        CurrentBlock: null
+      }
+  /**
+   * Max. number of active applicants
+   */
+  maxActiveApplicants?: number
+  /**
+   * Max. review period length in blocks
+   */
+  maxReviewPeriodLength: number
+  /**
+   * Application stake properties
+   */
+  applicationStake?: {
+    /**
+     * Application stake mode (Exact/AtLeast)
+     */
+    mode: 'Exact' | 'AtLeast'
+    /**
+     * Required stake value in JOY
+     */
+    value: number
+  }
+  /**
+   * Role stake properties
+   */
+  roleStake?: {
+    /**
+     * Application stake mode (Exact/AtLeast)
+     */
+    mode: 'Exact' | 'AtLeast'
+    /**
+     * Required stake value in JOY
+     */
+    value: number
+  }
+  terminateRoleUnstakingPeriod?: UnstakingPeriod
+  leaveRoleUnstakingPeriod?: UnstakingPeriod
+}

+ 0 - 59
cli/src/promptOptions/addWorkerOpening.ts

@@ -1,59 +0,0 @@
-import { ApiParamsOptions, ApiParamOptions, HRTStruct } from '../Types'
-import { OpeningType, WorkingGroupOpeningPolicyCommitment } from '@joystream/types/working-group'
-import { SlashingTerms } from '@joystream/types/common'
-import { Bytes } from '@polkadot/types'
-import { schemaValidator } from '@joystream/types/hiring'
-import { createType } from '@joystream/types'
-
-class OpeningPolicyCommitmentOptions implements ApiParamsOptions {
-  [paramName: string]: ApiParamOptions
-  public role_slashing_terms: ApiParamOptions<SlashingTerms> = {
-    value: {
-      default: createType('SlashingTerms', { Unslashable: null }),
-      locked: true,
-    },
-  }
-
-  // Rename fields containing "curator" (solivg minor UI issue related to flat namespace)
-  public terminate_curator_application_stake_unstaking_period: ApiParamOptions = {
-    forcedName: 'terminate_application_stake_unstaking_period',
-  }
-
-  public terminate_curator_role_stake_unstaking_period: ApiParamOptions = {
-    forcedName: 'terminate_role_stake_unstaking_period',
-  }
-
-  public exit_curator_role_application_stake_unstaking_period: ApiParamOptions = {
-    forcedName: 'exit_role_application_stake_unstaking_period',
-  }
-
-  public exit_curator_role_stake_unstaking_period: ApiParamOptions = {
-    forcedName: 'exit_role_stake_unstaking_period',
-  }
-}
-
-class AddWrokerOpeningOptions implements ApiParamsOptions {
-  [paramName: string]: ApiParamOptions
-  // Lock value for opening_type
-  public opening_type: ApiParamOptions<OpeningType> = {
-    value: {
-      default: createType('OpeningType', { Worker: null }),
-      locked: true,
-    },
-  }
-
-  // Json schema for human_readable_text
-  public human_readable_text: ApiParamOptions<Bytes> = {
-    jsonSchema: {
-      schemaValidator,
-      struct: HRTStruct,
-    },
-  }
-
-  // Lock value for role_slashing_terms
-  public commitment: ApiParamOptions<WorkingGroupOpeningPolicyCommitment> = {
-    nestedOptions: new OpeningPolicyCommitmentOptions(),
-  }
-}
-
-export default AddWrokerOpeningOptions

+ 3 - 1
cli/tsconfig.json

@@ -13,7 +13,9 @@
     "baseUrl": ".",
     "paths": {
       "@polkadot/types/augment": ["../types/augment-codec/augment-types.ts"],
-    }
+    },
+    "resolveJsonModule": true,
+    "skipLibCheck": true
   },
   "include": [
     "src/**/*"

+ 10 - 0
content-directory-schemas/.gitignore

@@ -0,0 +1,10 @@
+operations.json
+
+# Auto-generated
+schemas/entities
+schemas/entityBatches
+schemas/entityReferences
+types/entities
+
+# Build
+lib

+ 1 - 0
content-directory-schemas/.npmignore

@@ -0,0 +1 @@
+operations.json

+ 279 - 0
content-directory-schemas/README.md

@@ -0,0 +1,279 @@
+# Content directory tooling
+
+## Definitions
+
+In order to make this documentation as clear as possible it is important to make a strict distinction between two types of schemas:
+
+- `json-schemas` mean files with `.schema.json` extension. This is a common standard for describing how to validate other `json` files or objects (ie. a `package.json` file may be an example of a file that can be supported by a `json-schema`). A documentation of this standard can be found here: https://json-schema.org/
+- `runtime-scheams` means schemas as they are "understood" by the `content-directory` runtime module, so schemas that can be added to classes via `api.tx.contentDirectory.addClassSchema` and linked to entities via `api.tx.contentDirectory.addSchemaSupportToEntity`
+
+## Content directory input
+
+### Initializing content directory
+
+In order to intialize the content directory on a development chain based on data that is provided in form of json files inside `/inputs` directory (`classes`, `schemas` and example entities - `entityBatches`), we can run:
+
+```
+yarn workspace cd-schemas initialize:dev
+```
+
+This will handle:
+
+- Creating a membership for `ALICE` (if not already created)
+- Setting (hiring) `ALICE` as content curators lead (if not already set)
+- Creating classes in the runtime based on `inputs/classes` json inputs (if the content directory is currently empty)
+- Creating schemas in the runtime based on `inputs/schemas` and adding them to the related classes
+- Creating entities based on `inputs/entityBatches`. Those json inputs allow describing entities and relationships between them in a simplified way and are then converted into one huge `api.tx.contentDirectory.transaction` call (this is further described in _**Entity batches**_ section).
+
+### Input files naming
+
+In order to get the full benefit of the tooling, in some cases you may need to respect a specific pattern of file naming:
+
+Each input file name should end with `Class`, `Schema` or `Batch` (depending on the input type, ie. `LanguageBatch`).
+It is also recommended that each of those file names starts with a class name (currently in `entityBatches` there's no distinction between schemas and classes, as it is assumed there will be a one-to-one relationship between them)
+
+### `json-schemas` support for json inputs in `VSCode`
+
+In order to link json files inside `inputs` directory to `json-schemas` inside `schemas` and have them validated in real-time by the IDE, follow the steps below:
+
+**If you don't have `.vscode/settings.json` in the root monorepo workspace yet:**
+
+1. Create `.vscode` directory inside your monorepo workspace
+1. Copy `vscode-recommended.settings.json` into this `.vscode` directory and rename it to `settings.json`.
+
+**If you already have the `.vscode/settings.json` file in the root monorepo workspace:**
+
+1. Copy the settings from `vscode-recommended.settings.json` and merge them with the existing `.vscode/settings.json`
+
+Now all the json files matching `*Class.json`, `*Schema.json`, `*{EntityName}Batch.json` patters will be linked to the correct `json schemas`. If you edit any file inside `inputs` or add a new one that follows the naming pattern (described in _Input files naming_), you should get the benefit of autocompleted properties, validated input, on-hover tooltips with property descriptions etc.
+
+For more context, see: https://code.visualstudio.com/docs/languages/json
+
+### Validate inputs and `json-schemas` via a command
+
+All inputs inside `inputs` directory and `json-schemas` used to validate those inputs can also be validated using `yarn workspace cd-schemas validate` command. This is mainly to facilitate checking the validity of `.json` and `.schema.json` files inside `content-directory-schemas` through CI.
+
+### Entity batches
+
+The concept of entity batches (`inputs/entityBatches`) basically provides an easy way of describing complex input to content directory (ie. many entities related to each other in many ways) without the need to deal with lower-level, hard-to-validate runtime operations like `CreateEntity` and `AddSchemaSupportEntity` and trying to glue them together into a huge `api.tx.contentDirectory.transaction` call.
+
+Instead, the script that initializes the content directory (`scripts/initializeContentDir.ts`) is able to generate the complex `api.tx.contentDirectory.transaction` call based on a more human-readable input provided in `inputs/entityBatches`.
+
+This input can be provided as a simple json array of objects matching `{ [propertyName]: propertyValue}` structure.
+
+For example, in order to describe creating entities as simple as `Language`, which only has `Code` and `Name` properties, we can just create an array of objects like:
+
+```
+[
+  { "Code": "EN", "Name": "English" },
+  { "Code": "RU", "Name": "Russian" },
+  { "Code": "DE", "Name": "German" }
+]
+```
+
+_(This is the actual content of `inputs/entityBatches/LanguageBatch.json`)_
+
+#### Related entities
+
+There also exists a specific syntax for defining relations between entities in batches.
+We can do it by either using `"new"` or `"existing"` keyword.
+
+- The `"new"` keyword allows describing a scenario where related entity should be created **along with** the main entity and then referenced by it. An example of this could be `Video` and `VideoMedia` which have a one-to-one relationship and it doesn't make much sense to specify them in separate batches. Instead, we can use a syntax like:
+
+```
+{
+  "title": "Awesome video",
+  /* other Video properties... */
+  "media": { "new": {
+    "pixelWidth": 1024,
+    "pixelHeight": 764,
+    /* other VideoMedia object properties... */
+  }
+}
+```
+
+- The `"existing"` keyword allows referencing an entity created as part of any other batch inside `inputs/entityBatches`. We can do it by specifying the value of **any unique property of the referenced entity**. So, for example to reference a `Language` entity from `VideoBatch.json` file, we use this syntax:
+
+```
+{
+  "title": "Awesome video",
+  /* other Video properties... */
+  "language": { "existing": { "Code": "EN" } }
+}
+```
+
+## `json-schemas` and tooling
+
+### Entity `json-schemas`
+
+There is a script that provides an easy way of converting `runtime-schemas` (based on inputs from `inputs/schemas`) to `json-schemas` (`.schema.json` files) which allow validating the input (ie. json files) describing some specific entities. It can be run with:
+
+```
+yarn workspace cd-schemas generate:entity-schemas
+```
+
+Those `json-schemas` are currently mainly used for validating the inputs inside `inputs/entityBatches`.
+
+The generated `json-schemas` include:
+
+- `schemas/entities` - `json-schemas` that provide validation for given entity (ie. `Video`) input. They can, for example, check if the `title` property in a json object is a string that is no longer than `64` characters. They are used to validate a single entity in `inputs/entityBatches`, but can also be re-used to provide "frontend" validation of any entity input to the content directory (ie. input provided to/via `joystream-cli`).
+- `schemas/entityReferences` - `json-schemas` that describe how an entity of given class can be referenced. Currently they are used for providing an easy way of referencing entites between batches in `inputs/entityBatches`. For more details on how entities can be referenced in batches, read the _**Entity batches**_ section.
+- `schemas/entityBatches` - very simple `json-schemas` that basically just provide `array` wrappers over `schemas/entities`. Those are the actual `json-schemas` that can be linked to json input files inside `inputs/entityBatches` (ie. via `.vscode/settings.json`)
+
+### Typescript support
+
+Thanks to the `json-schema-to-typescript` library, we can very simply generate Typescript interfaces based on existing `json-schemas`. This can be done via:
+
+```
+yarn workspace cd-schemas generate:types
+```
+
+This command will generate:
+
+- `types/entities` based on `schemas/entities`, providing typescript interfaces for entities like `Video` etc. (note that this interface will include a peculiar way of describing entity relationships, further described in _**Entity batches**_ section)
+- `types/extrinsics` based on `schemas/extrinsics`, providing typescript interfaces for input to extrinsics like `AddClassSchema` and `CreateClass`
+
+The most obvious use-case of those interfaces currently is that when we're parsing any json files inside `inputs` using a Typescript code, we can assert that the resulting object will be of given type, ie.:
+
+```
+const createClassInput = JSON.parse(fs.readFileSync('/path/to/inputs/LanguageClass.json')) as CreateClass
+```
+
+Besides that, a Typescript code can be written to generate some inputs (ie. using a loop) that can then can be used to create classes/schemas or insert entities into the content directory.
+
+There are a lot of other potential use-cases, but for the purpose of this documentation it should be enough to mention there exists this very easy way of converting `.schema.json` files into Typescript interfaces.
+
+## Using as library
+
+The `content-directory-schemas` directory of the monorepo is constructed in such a way, that it should be possible to use it as library and import from it json schemas, types (mentioned in `Typescript support` section) and tools to, for example, convert entity input like this described in the `Entity batches` section into `CreateEntity`, `AddSchemaSupportToEntity` and/or `UpdateEntityPropertyValues` operations.
+
+### Examples
+
+The best way to ilustrate this would be by providing some examples:
+
+#### Creating a channel
+```
+  import { InputParser } from 'cd-schemas'
+  import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+  // Other imports...
+
+  async main() {
+    // Initialize the api, SENDER_KEYPAIR and SENDER_MEMBER_ID...
+
+    const channel: ChannelEntity = {
+      title: 'Example channel',
+      description: 'This is an example channel',
+      language: { existing: { code: 'EN' } },
+      coverPhotoUrl: '',
+      avatarPhotoURL: '',
+      isPublic: true,
+    }
+
+    const parser = InputParser.createWithKnownSchemas(api, [
+      {
+        className: 'Channel',
+        entries: [channel],
+      },
+    ])
+
+    const operations = await parser.getEntityBatchOperations()
+    await api.tx.contentDirectory
+      .transaction({ Member: SENDER_MEMBER_ID }, operations)
+      .signAndSend(SENDER_KEYPAIR)
+  }
+```
+_Full example with comments can be found in `content-directory-schemas/examples/createChannel.ts` and ran with `yarn workspace cd-schemas example:createChannel`_
+
+#### Creating a video
+```
+import { InputParser } from 'cd-schemas'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+// ...
+
+async main() {
+  // ...
+
+  const video: VideoEntity = {
+    title: 'Example video',
+    description: 'This is an example video',
+    language: { existing: { code: 'EN' } },
+    category: { existing: { name: 'Education' } },
+    channel: { existing: { title: 'Example channel' } },
+    media: {
+      new: {
+        encoding: { existing: { name: 'H.263_MP4' } },
+        pixelHeight: 600,
+        pixelWidth: 800,
+        location: {
+          new: {
+            httpMediaLocation: {
+              new: { url: 'https://testnet.joystream.org/' },
+            },
+          },
+        },
+      },
+    },
+    license: {
+      new: {
+        knownLicense: {
+          existing: { code: 'CC_BY' },
+        },
+      },
+    },
+    duration: 3600,
+    thumbnailURL: '',
+    isExplicit: false,
+    isPublic: true,
+  }
+
+  const parser = InputParser.createWithKnownSchemas(api, [
+    {
+      className: 'Video',
+      entries: [video],
+    },
+  ])
+
+  const operations = await parser.getEntityBatchOperations()
+  await api.tx.contentDirectory
+    .transaction({ Member: SENDER_MEMBER_ID }, operations)
+    .signAndSend(SENDER_KEYPAIR)
+}
+```
+_Full example with comments can be found in `content-directory-schemas/examples/createVideo.ts` and ran with `yarn workspace cd-schemas example:createChannel`_
+
+#### Update channel title
+
+```
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+// ...
+
+async function main() {
+  // ...
+
+  const channelUpdateInput: Partial<ChannelEntity> = {
+    title: 'Updated channel title',
+  }
+
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  const CHANNEL_ID = await parser.findEntityIdByUniqueQuery({ title: 'Example channel' }, 'Channel')
+
+  const updateOperations = await parser.getEntityUpdateOperations(channelUpdateInput, 'Channel', CHANNEL_ID)
+
+  await api.tx.contentDirectory
+    .transaction({ Member: SENDER_MEMBER_ID }, [updateOperation])
+    .signAndSend(SENDER_KEYPAIR)
+}
+```
+_Full example with comments can be found in `content-directory-schemas/examples/updateChannelTitle.ts` and ran with `yarn workspace cd-schemas example:updateChannelTitle`_
+
+Note: Updates can also inlucde `new` and `existing` keywords. In case `new` is specified inside the update - `CreateEntity` and `AddSchemaSupportToEntity` operations will be included as part of the operations returned by `InputParser.getEntityUpdateOperations`.
+
+## Current limitations
+
+Some limitations that should be dealt with in the nearest future:
+
+- Filename restrictions described in **_Input files naming_** section
+- Some code runs on the assumption that there is only one schema for each class, which is very limiting
+- `Vector<Reference>` property type is not yet supported when parsing entity batches

+ 52 - 0
content-directory-schemas/examples/createChannel.ts

@@ -0,0 +1,52 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities'
+
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  const channel: ChannelEntity = {
+    title: 'Example channel',
+    description: 'This is an example channel',
+    // We can use "existing" syntax to reference either an on-chain entity or other entity that's part of the same batch.
+    // Here we reference language that we assume was added by initialization script (initialize:dev), as it is part of
+    // input/entityBatches/LanguageBatch.json
+    language: { existing: { code: 'EN' } },
+    coverPhotoUrl: '',
+    avatarPhotoURL: '',
+    isPublic: true,
+  }
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(
+    api,
+    // The second argument is an array of entity batches, following standard entity batch syntax ({ className, entries }):
+    [
+      {
+        className: 'Channel',
+        entries: [channel], // We could specify multiple entries here, but in this case we only need one
+      },
+    ]
+  )
+  // We parse the input into CreateEntity and AddSchemaSupportToEntity operations
+  const operations = await parser.getEntityBatchOperations()
+  await api.tx.contentDirectory
+    .transaction(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      operations // We provide parsed operations as second argument
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 68 - 0
content-directory-schemas/examples/createChannelWithoutTransaction.ts

@@ -0,0 +1,68 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities'
+import { FlattenRelations } from 'cd-schemas/types/utility'
+import { EntityId } from '@joystream/types/content-directory'
+
+// Alternative way of creating a channel using separate extrinsics (instead of contentDirectory.transaction)
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  // In this case we need to fetch some data first (like classId and language entity id)
+  const classId = await parser.getClassIdByName('Channel')
+  const languageEntityId = await parser.findEntityIdByUniqueQuery({ code: 'EN' }, 'Language')
+
+  // We use FlattenRelations to exlude { new } and { existing } (which are not allowed if we want to parse only a single entity)
+  const channel: FlattenRelations<ChannelEntity> = {
+    title: 'Example channel 2',
+    description: 'This is an example channel',
+    language: languageEntityId,
+    coverPhotoUrl: '',
+    avatarPhotoURL: '',
+    isPublic: true,
+  }
+
+  // In this case we use some basic callback to retrieve entityId from the extrinsc event
+  const entityId = await new Promise<EntityId>((resolve, reject) => {
+    api.tx.contentDirectory.createEntity(classId, { Member: 0 }).signAndSend(ALICE, {}, (res) => {
+      if (res.isError) {
+        reject(new Error(res.status.type))
+      }
+      res.events.forEach(({ event: e }) => {
+        if (e.method === 'EntityCreated') {
+          resolve(e.data[1] as EntityId)
+        }
+        if (e.method === 'ExtrinsicFailed') {
+          reject(new Error('Extrinsic failed'))
+        }
+      })
+    })
+  })
+
+  const inputPropertyValuesMap = await parser.parseToInputEntityValuesMap({ ...channel }, 'Channel')
+  // Having entityId we can create and send addSchemaSupport tx
+  await api.tx.contentDirectory
+    .addSchemaSupportToEntity(
+      { Member: 0 }, // Context (in this case we assume it's Alice's member id)
+      entityId,
+      0, // Schema (currently we have one schema per class, so it can be just 0)
+      inputPropertyValuesMap
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 76 - 0
content-directory-schemas/examples/createVideo.ts

@@ -0,0 +1,76 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and video entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  const video: VideoEntity = {
+    title: 'Example video',
+    description: 'This is an example video',
+    // We reference existing language and category by their unique properties with "existing" syntax
+    // (those referenced here are part of inputs/entityBatches)
+    language: { existing: { code: 'EN' } },
+    category: { existing: { name: 'Education' } },
+    // We use the same "existing" syntax to reference a channel by unique property (title)
+    // In this case it's a channel that we created in createChannel example
+    channel: { existing: { title: 'Example channel' } },
+    media: {
+      // We use "new" syntax to sygnalize we want to create a new VideoMedia entity that will be related to this Video entity
+      new: {
+        // We use "exisiting" enconding from inputs/entityBatches/VideoMediaEncodingBatch.json
+        encoding: { existing: { name: 'H.263_MP4' } },
+        pixelHeight: 600,
+        pixelWidth: 800,
+        // We create nested VideoMedia->MediaLocation->HttpMediaLocation relations using the "new" syntax
+        location: { new: { httpMediaLocation: { new: { url: 'https://testnet.joystream.org/' } } } },
+      },
+    },
+    // Here we use combined "new" and "existing" syntaxes to create Video->License->KnownLicense relations
+    license: {
+      new: {
+        knownLicense: {
+          // This license can be found in inputs/entityBatches/KnownLicenseBatch.json
+          existing: { code: 'CC_BY' },
+        },
+      },
+    },
+    duration: 3600,
+    thumbnailURL: '',
+    isExplicit: false,
+    isPublic: true,
+  }
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(
+    api,
+    // The second argument is an array of entity batches, following standard entity batch syntax ({ className, entries }):
+    [
+      {
+        className: 'Video',
+        entries: [video], // We could specify multiple entries here, but in this case we only need one
+      },
+    ]
+  )
+  // We parse the input into CreateEntity and AddSchemaSupportToEntity operations
+  const operations = await parser.getEntityBatchOperations()
+  await api.tx.contentDirectory
+    .transaction(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      operations // We provide parsed operations as second argument
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 47 - 0
content-directory-schemas/examples/updateChannelTitle.ts

@@ -0,0 +1,47 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  // Create partial channel entity, only containing the fields we wish to update
+  const channelUpdateInput: Partial<ChannelEntity> = {
+    title: 'Updated channel title',
+  }
+
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  // We can reuse InputParser's `findEntityIdByUniqueQuery` method to find entityId of the channel we
+  // created in ./createChannel.ts example (normally we would probably use some other way to do it, ie.: query node)
+  const CHANNEL_ID = await parser.findEntityIdByUniqueQuery({ title: 'Example channel' }, 'Channel')
+
+  // Use getEntityUpdateOperations to parse the update input
+  const updateOperations = await parser.getEntityUpdateOperations(
+    channelUpdateInput,
+    'Channel', // Class name
+    CHANNEL_ID // Id of the entity we want to update
+  )
+
+  await api.tx.contentDirectory
+    .transaction(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      updateOperations // In this case this will be just a single UpdateEntityPropertyValues operation
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 47 - 0
content-directory-schemas/examples/updateChannelTitleWithoutTransaction.ts

@@ -0,0 +1,47 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities'
+import { FlattenRelations } from 'cd-schemas/types/utility'
+
+// Alternative way of update a channel using updateEntityPropertyValues extrinsic
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  // Create partial channel entity, only containing the fields we wish to update
+  const channelUpdateInput: Partial<FlattenRelations<ChannelEntity>> = {
+    title: 'Updated channel title 2',
+  }
+
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  // We can reuse InputParser's `findEntityIdByUniqueQuery` method to find entityId of the channel we
+  // created in ./createChannelWithoutTransaction.ts example
+  // (normally we would probably use some other way to do it, ie.: query node)
+  const CHANNEL_ID = await parser.findEntityIdByUniqueQuery({ title: 'Example channel 2' }, 'Channel')
+
+  // We use parser to create input property values map
+  const newPropertyValues = await parser.parseToInputEntityValuesMap(channelUpdateInput, 'Channel')
+
+  await api.tx.contentDirectory
+    .updateEntityPropertyValues(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      CHANNEL_ID,
+      newPropertyValues
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 7 - 0
content-directory-schemas/inputs/classes/ChannelClass.json

@@ -0,0 +1,7 @@
+{
+  "name": "Channel",
+  "description": "A channel belonging to certain member. Members can publish certain type of content (ie. videos) through channels.",
+  "maximum_entities_count": 400,
+  "default_entity_creation_voucher_upper_bound": 50,
+  "class_permissions": { "any_member": true }
+}

+ 6 - 0
content-directory-schemas/inputs/classes/ContentCategoryClass.json

@@ -0,0 +1,6 @@
+{
+  "name": "ContentCategory",
+  "description": "A category the content may be published under",
+  "maximum_entities_count": 100,
+  "default_entity_creation_voucher_upper_bound": 50
+}

+ 7 - 0
content-directory-schemas/inputs/classes/HttpMediaLocationClass.json

@@ -0,0 +1,7 @@
+{
+  "name": "HttpMediaLocation",
+  "description": "An object describing http location of media object",
+  "maximum_entities_count": 400,
+  "default_entity_creation_voucher_upper_bound": 50,
+  "class_permissions": { "any_member": true }
+}

+ 7 - 0
content-directory-schemas/inputs/classes/JoystreamMediaLocationClass.json

@@ -0,0 +1,7 @@
+{
+  "name": "JoystreamMediaLocation",
+  "description": "An object describing location of media object in a format specific to Joystream platform",
+  "maximum_entities_count": 400,
+  "default_entity_creation_voucher_upper_bound": 50,
+  "class_permissions": { "any_member": true }
+}

+ 6 - 0
content-directory-schemas/inputs/classes/KnownLicenseClass.json

@@ -0,0 +1,6 @@
+{
+  "name": "KnownLicense",
+  "description": "A commonly recognized license (ie. CC_BY_SA)",
+  "maximum_entities_count": 100,
+  "default_entity_creation_voucher_upper_bound": 50
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików