Przeglądaj źródła

Merge branch 'giza_staging' into giza-integration-tests

Leszek Wiesner 3 lat temu
rodzic
commit
c8adad1db5
100 zmienionych plików z 2170 dodań i 1348 usunięć
  1. 6 2
      .env
  2. 3 3
      Cargo.lock
  3. 0 0
      chain-metadata.json
  4. 267 144
      cli/README.md
  5. 1 1
      cli/package.json
  6. 14 1
      cli/src/Api.ts
  7. 5 5
      cli/src/QueryNodeApi.ts
  8. 14 9
      cli/src/Types.ts
  9. 230 148
      cli/src/base/AccountsCommandBase.ts
  10. 36 7
      cli/src/base/ApiCommandBase.ts
  11. 115 50
      cli/src/base/ContentDirectoryCommandBase.ts
  12. 13 0
      cli/src/base/DefaultCommandBase.ts
  13. 2 4
      cli/src/base/StateAwareCommandBase.ts
  14. 23 24
      cli/src/base/UploadCommandBase.ts
  15. 19 53
      cli/src/base/WorkingGroupsCommandBase.ts
  16. 0 48
      cli/src/commands/account/choose.ts
  17. 16 39
      cli/src/commands/account/create.ts
  18. 0 40
      cli/src/commands/account/current.ts
  19. 34 31
      cli/src/commands/account/export.ts
  20. 4 10
      cli/src/commands/account/forget.ts
  21. 56 33
      cli/src/commands/account/import.ts
  22. 56 0
      cli/src/commands/account/info.ts
  23. 26 0
      cli/src/commands/account/list.ts
  24. 34 51
      cli/src/commands/account/transferTokens.ts
  25. 6 5
      cli/src/commands/content/addCuratorToGroup.ts
  26. 6 3
      cli/src/commands/content/channel.ts
  27. 4 3
      cli/src/commands/content/channels.ts
  28. 29 22
      cli/src/commands/content/createChannel.ts
  29. 8 9
      cli/src/commands/content/createChannelCategory.ts
  30. 3 5
      cli/src/commands/content/createCuratorGroup.ts
  31. 26 29
      cli/src/commands/content/createVideo.ts
  32. 8 9
      cli/src/commands/content/createVideoCategory.ts
  33. 1 1
      cli/src/commands/content/curatorGroup.ts
  34. 3 5
      cli/src/commands/content/deleteChannel.ts
  35. 6 6
      cli/src/commands/content/deleteChannelCategory.ts
  36. 5 6
      cli/src/commands/content/deleteVideo.ts
  37. 6 6
      cli/src/commands/content/deleteVideoCategory.ts
  38. 4 5
      cli/src/commands/content/removeChannelAssets.ts
  39. 6 5
      cli/src/commands/content/removeCuratorFromGroup.ts
  40. 10 5
      cli/src/commands/content/reuploadAssets.ts
  41. 6 5
      cli/src/commands/content/setCuratorGroupStatus.ts
  42. 3 6
      cli/src/commands/content/setFeaturedVideos.ts
  43. 39 16
      cli/src/commands/content/updateChannel.ts
  44. 3 6
      cli/src/commands/content/updateChannelCategory.ts
  45. 3 7
      cli/src/commands/content/updateChannelCensorshipStatus.ts
  46. 19 16
      cli/src/commands/content/updateVideo.ts
  47. 3 6
      cli/src/commands/content/updateVideoCategory.ts
  48. 3 7
      cli/src/commands/content/updateVideoCensorshipStatus.ts
  49. 1 1
      cli/src/commands/content/video.ts
  50. 1 1
      cli/src/commands/content/videos.ts
  51. 3 5
      cli/src/commands/working-groups/createOpening.ts
  52. 8 6
      cli/src/commands/working-groups/decreaseWorkerStake.ts
  53. 8 11
      cli/src/commands/working-groups/evictWorker.ts
  54. 8 10
      cli/src/commands/working-groups/fillOpening.ts
  55. 8 6
      cli/src/commands/working-groups/increaseStake.ts
  56. 8 6
      cli/src/commands/working-groups/leaveRole.ts
  57. 8 6
      cli/src/commands/working-groups/slashWorker.ts
  58. 8 6
      cli/src/commands/working-groups/startAcceptingApplications.ts
  59. 8 6
      cli/src/commands/working-groups/startReviewPeriod.ts
  60. 8 6
      cli/src/commands/working-groups/terminateApplication.ts
  61. 16 18
      cli/src/commands/working-groups/updateRewardAccount.ts
  62. 17 34
      cli/src/commands/working-groups/updateRoleAccount.ts
  63. 8 11
      cli/src/commands/working-groups/updateRoleStorage.ts
  64. 9 11
      cli/src/commands/working-groups/updateWorkerReward.ts
  65. 4 4
      cli/src/graphql/generated/queries.ts
  66. 21 11
      cli/src/graphql/generated/schema.ts
  67. 1 1
      cli/src/graphql/queries/storage.graphql
  68. 2 4
      cli/src/helpers/JsonSchemaPrompt.ts
  69. 5 5
      cli/src/helpers/display.ts
  70. 2 2
      cli/src/helpers/serialization.ts
  71. 9 2
      cli/src/helpers/validation.ts
  72. 7 0
      cli/src/schemas/ContentDirectory.ts
  73. 5 0
      devops/kubernetes/argus/.gitignore
  74. 35 0
      devops/kubernetes/argus/Pulumi.yaml
  75. 123 0
      devops/kubernetes/argus/README.md
  76. 5 0
      devops/kubernetes/argus/docker_dummy/Dockerfile
  77. 229 0
      devops/kubernetes/argus/index.ts
  78. 15 0
      devops/kubernetes/argus/package.json
  79. 18 0
      devops/kubernetes/argus/tsconfig.json
  80. 29 0
      devops/kubernetes/pulumi-common/configMap.ts
  81. 2 0
      devops/kubernetes/pulumi-common/index.ts
  82. 43 0
      devops/kubernetes/pulumi-common/volume.ts
  83. 9 0
      devops/kubernetes/query-node/Pulumi.yaml
  84. 2 4
      devops/kubernetes/query-node/README.md
  85. 1 1
      devops/kubernetes/query-node/configMap.ts
  86. 22 18
      devops/kubernetes/query-node/index.ts
  87. 32 61
      devops/kubernetes/query-node/indexerDeployment.ts
  88. 46 19
      devops/kubernetes/query-node/processorDeployment.ts
  89. 16 12
      devops/kubernetes/storage-node/Pulumi.yaml
  90. 11 9
      devops/kubernetes/storage-node/README.md
  91. 5 0
      devops/kubernetes/storage-node/docker_dummy/Dockerfile
  92. 119 110
      devops/kubernetes/storage-node/index.ts
  93. 4 4
      distributor-node/docs/api/operator/index.md
  94. 3 3
      distributor-node/docs/api/public/index.md
  95. 11 23
      distributor-node/docs/commands/leader.md
  96. 7 1
      distributor-node/docs/commands/node.md
  97. 2 6
      distributor-node/docs/commands/operator.md
  98. 0 7
      distributor-node/docs/schema/definition-properties-bucket-ids-items.md
  99. 13 0
      distributor-node/docs/schema/definition-properties-distributed-buckets-ids-items.md
  100. 1 1
      distributor-node/docs/schema/definition-properties-distributed-buckets-ids.md

+ 6 - 2
.env

@@ -44,13 +44,17 @@ PROCESSOR_INDEXER_GATEWAY=http://hydra-indexer-gateway:${HYDRA_INDEXER_GATEWAY_P
 
 # Colossus services identities
 COLOSSUS_1_WORKER_ID=0
-COLOSSUS_1_ACCOUNT_URI=//testing//worker//Storage//${COLOSSUS_1_WORKER_ID}
+COLOSSUS_1_WORKER_URI=//testing//worker//Storage//${COLOSSUS_1_WORKER_ID}
+COLOSSUS_1_TRANSACTOR_URI=//Colossus1
+
 COLOSSUS_2_WORKER_ID=1
-COLOSSUS_2_ACCOUNT_URI=//testing//worker//Storage//${COLOSSUS_2_WORKER_ID}
+COLOSSUS_2_WORKER_URI=//testing//worker//Storage//${COLOSSUS_2_WORKER_ID}
+COLOSSUS_2_TRANSACTOR_URI=//Colossus2
 
 # Distributor node services identities
 DISTRIBUTOR_1_WORKER_ID=0
 DISTRIBUTOR_1_ACCOUNT_URI=//testing//worker//Distribution//${DISTRIBUTOR_1_WORKER_ID}
+
 DISTRIBUTOR_2_WORKER_ID=1
 DISTRIBUTOR_2_ACCOUNT_URI=//testing//worker//Distribution//${DISTRIBUTOR_2_WORKER_ID}
 

+ 3 - 3
Cargo.lock

@@ -731,7 +731,7 @@ dependencies = [
 
 [[package]]
 name = "chain-spec-builder"
-version = "3.1.1"
+version = "3.2.0"
 dependencies = [
  "ansi_term 0.12.1",
  "enum-utils",
@@ -2332,7 +2332,7 @@ dependencies = [
 
 [[package]]
 name = "joystream-node"
-version = "5.9.0"
+version = "5.12.0"
 dependencies = [
  "frame-benchmarking",
  "frame-benchmarking-cli",
@@ -2393,7 +2393,7 @@ dependencies = [
 
 [[package]]
 name = "joystream-node-runtime"
-version = "9.11.0"
+version = "9.12.0"
 dependencies = [
  "frame-benchmarking",
  "frame-executive",

Plik diff jest za duży
+ 0 - 0
chain-metadata.json


+ 267 - 144
cli/README.md

@@ -9,35 +9,13 @@ Command Line Interface for Joystream community and governance activities
 [![License](https://img.shields.io/npm/l/@joystream/cli.svg)](https://github.com/Joystream/joystream/blob/master/cli/package.json)
 
 <!-- toc -->
-* [Development](#development)
 * [Usage](#usage)
+* [Development](#development)
 * [First steps](#first-steps)
+* [Useful environment settings](#useful-environment-settings)
 * [Commands](#commands)
-* [Environment variables](#environment-variables)
 <!-- tocstop -->
 
-# Development
-<!-- development -->
-To run a command in developemnt environment (without installing the package):
-
-1. Navigate into the CLI root directory
-1. Execute any command like this:
-
-    ```
-        $ ./bin/run COMMAND
-    ```
-
-Alternatively:
-
-1. Navigate into the CLI root directory
-1. Execute `yarn link` (if that doesn't work, consider `sudo yarn link`)
-1. Execute command from any location like this:
-
-    ```
-        $ joystream-cli COMMAND
-    ```
-<!-- developmentstop -->
-
 # Usage
 <!-- usage -->
 ```sh-session
@@ -45,7 +23,7 @@ $ npm install -g @joystream/cli
 $ joystream-cli COMMAND
 running command...
 $ joystream-cli (-v|--version|version)
-@joystream/cli/0.5.1 linux-x64 node-v14.16.1
+@joystream/cli/0.6.0 linux-x64 node-v14.18.0
 $ joystream-cli --help [COMMAND]
 USAGE
   $ joystream-cli COMMAND
@@ -53,28 +31,56 @@ USAGE
 ```
 <!-- usagestop -->
 
+# Development
+<!-- development -->
+To run a command in developemnt environment (from the root of [Joystream monorepo](https://github.com/Joystream/joystream), without installing the package):
+
+```shell
+  $ yarn && yarn workspace @joystream/types build && yarn workspace @joystream/metadata-protobuf build
+  $ ./cli/bin/run COMMAND # OR:
+  $ yarn joystream-cli COMMAND
+```
+
+Alternatively:
+
+```shell
+  $ yarn workspace @joystream/cli link
+  $ joystream-cli COMMAND
+```
+<!-- developmentstop -->
+
+
 # First steps
 <!-- first-steps -->
 When using the CLI for the first time there are a few common steps you might want to take in order to configure the CLI:
 
-1. Set the correct node endpoint. You can do this by executing `api:setUri` or any command that requires an api connection. To verify the current endpoint you can execute `api:getUri`.
-1. In order to use the accounts/keys that you may already have access to within Pioneer, you need to dowload the backup json file(s) ([https://testnet.joystream.org/#/accounts](https://testnet.joystream.org/#/accounts)) and import them into the CLI by executing `account:import /path/to/backup.json`.
-1. By executing `account:choose` you can choose one of the imported accounts, that will then serve as context for the next commands (you can check currently selected account using `account:info`). If you just want to use the development _Alice_ or _Bob_ account, you can access them without importing by providing an additional flag: `account:choose --showSpecial`.
-1. The context should now be fully set up! Feel free to use the `--help` flag to investigate the available commands or take a look at the sections below.
-1. You may also find it useful to get the first part of the command (before the colon) autocompleted when you press `[Tab]` while typing the name in the console. Executing `autocomplete` command will provide the instructions on how to set this up (see documentation below).
+1. Set the correct Joystream node websocket endpoint. You can do this by executing [`api:setUri`](#joystream-cli-apiseturi-uri) and choosing one of the suggested endpoints of providing your own url. To verify the currently used Joystream node websocket endpoint you can execute [`api:getUri`](#joystream-cli-apigeturi).
+2. Set the Joystream query node endpoint. This is optional, but some commands (for example: [`content:createChannel`](#joystream-cli-contentcreatechannel)) will require a connection to the query node in order to fetch the data they need complete the requested operations (ie. [`content:createChannel`](#joystream-cli-contentcreatechannel) will need to fetch the available storage node endnpoints in order to upload the channel assets). In order to do that, execute [`api:setQueryNodeEndpoint`](#joystream-cli-apisetquerynodeendpoint-endpoint) and choose one of the suggested endpoints or provide your own url. You can use [`api:getQueryNodeEndpoint`](#joystream-cli-apigetquerynodeendpoint) any time to verify the currently set endpoint.
+3. In order to use your existing keys within the CLI, you can import them using [`account:import`](#joystream-cli-accountimport) command. You can provide json backup files exported from Pioneer or Polkadot{.js} extension as an input. You can also use raw mnemonic or seed phrases. See the [`account:import` command documentation](#joystream-cli-accountimport) for the full list of supported inputs.
+  The key to sign the transaction(s) with will be determined based on the required permissions, depending on the command you execute. For example, if you execute [`working-groups:updateRewardAccount --group storageProviders`](#joystream-cli-working-groupsupdaterewardaccount-address), the CLI will look for a storage provider role key among your available keys. If multiple execution contexts are available, the CLI will prompt you to choose the desired one.
+4. **Optionally:** You may also find it useful to get the first part of the command (before the colon) autocompleted when you press `[Tab]` while typing the command name in the console. Executing [`autocomplete`](#joystream-cli-autocomplete-shell) command will provide you the instructions on how to set this up.
+5. That's it! The CLI is now be fully set up! Feel free to use the `--help` flag to investigate the available commands or take a look at the commands documentation below.
 <!-- first-steps -->
 
+# Useful environment settings
+<!-- env -->
+- `FORCE_COLOR=0` - disables output coloring. This will make the output easier to parse in case it's redirected to a file or used within a script.
+- `AUTO_CONFIRM=true` - this will make the CLI skip asking for any confirmations (can be useful when creating bash scripts).
+<!-- envstop -->
+
 # Commands
 <!-- commands -->
-* [`joystream-cli account:choose`](#joystream-cli-accountchoose)
-* [`joystream-cli account:create NAME`](#joystream-cli-accountcreate-name)
-* [`joystream-cli account:current`](#joystream-cli-accountcurrent)
-* [`joystream-cli account:export PATH`](#joystream-cli-accountexport-path)
+* [`joystream-cli account:create`](#joystream-cli-accountcreate)
+* [`joystream-cli account:export DESTPATH`](#joystream-cli-accountexport-destpath)
 * [`joystream-cli account:forget`](#joystream-cli-accountforget)
-* [`joystream-cli account:import BACKUPFILEPATH`](#joystream-cli-accountimport-backupfilepath)
-* [`joystream-cli account:transferTokens RECIPIENT AMOUNT`](#joystream-cli-accounttransfertokens-recipient-amount)
+* [`joystream-cli account:import`](#joystream-cli-accountimport)
+* [`joystream-cli account:info [ADDRESS]`](#joystream-cli-accountinfo-address)
+* [`joystream-cli account:list`](#joystream-cli-accountlist)
+* [`joystream-cli account:transferTokens`](#joystream-cli-accounttransfertokens)
+* [`joystream-cli api:getQueryNodeEndpoint`](#joystream-cli-apigetquerynodeendpoint)
 * [`joystream-cli api:getUri`](#joystream-cli-apigeturi)
 * [`joystream-cli api:inspect`](#joystream-cli-apiinspect)
+* [`joystream-cli api:setQueryNodeEndpoint [ENDPOINT]`](#joystream-cli-apisetquerynodeendpoint-endpoint)
 * [`joystream-cli api:setUri [URI]`](#joystream-cli-apiseturi-uri)
 * [`joystream-cli autocomplete [SHELL]`](#joystream-cli-autocomplete-shell)
 * [`joystream-cli content:addCuratorToGroup [GROUPID] [CURATORID]`](#joystream-cli-contentaddcuratortogroup-groupid-curatorid)
@@ -87,8 +93,11 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli content:createVideoCategory`](#joystream-cli-contentcreatevideocategory)
 * [`joystream-cli content:curatorGroup ID`](#joystream-cli-contentcuratorgroup-id)
 * [`joystream-cli content:curatorGroups`](#joystream-cli-contentcuratorgroups)
+* [`joystream-cli content:deleteChannel`](#joystream-cli-contentdeletechannel)
 * [`joystream-cli content:deleteChannelCategory CHANNELCATEGORYID`](#joystream-cli-contentdeletechannelcategory-channelcategoryid)
+* [`joystream-cli content:deleteVideo`](#joystream-cli-contentdeletevideo)
 * [`joystream-cli content:deleteVideoCategory VIDEOCATEGORYID`](#joystream-cli-contentdeletevideocategory-videocategoryid)
+* [`joystream-cli content:removeChannelAssets`](#joystream-cli-contentremovechannelassets)
 * [`joystream-cli content:removeCuratorFromGroup [GROUPID] [CURATORID]`](#joystream-cli-contentremovecuratorfromgroup-groupid-curatorid)
 * [`joystream-cli content:reuploadAssets`](#joystream-cli-contentreuploadassets)
 * [`joystream-cli content:setCuratorGroupStatus [ID] [STATUS]`](#joystream-cli-contentsetcuratorgroupstatus-id-status)
@@ -118,111 +127,129 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli working-groups:startAcceptingApplications WGOPENINGID`](#joystream-cli-working-groupsstartacceptingapplications-wgopeningid)
 * [`joystream-cli working-groups:startReviewPeriod WGOPENINGID`](#joystream-cli-working-groupsstartreviewperiod-wgopeningid)
 * [`joystream-cli working-groups:terminateApplication WGAPPLICATIONID`](#joystream-cli-working-groupsterminateapplication-wgapplicationid)
-* [`joystream-cli working-groups:updateRewardAccount [ACCOUNTADDRESS]`](#joystream-cli-working-groupsupdaterewardaccount-accountaddress)
-* [`joystream-cli working-groups:updateRoleAccount [ACCOUNTADDRESS]`](#joystream-cli-working-groupsupdateroleaccount-accountaddress)
+* [`joystream-cli working-groups:updateRewardAccount [ADDRESS]`](#joystream-cli-working-groupsupdaterewardaccount-address)
+* [`joystream-cli working-groups:updateRoleAccount [ADDRESS]`](#joystream-cli-working-groupsupdateroleaccount-address)
 * [`joystream-cli working-groups:updateRoleStorage STORAGE`](#joystream-cli-working-groupsupdaterolestorage-storage)
 * [`joystream-cli working-groups:updateWorkerReward WORKERID`](#joystream-cli-working-groupsupdateworkerreward-workerid)
 
-## `joystream-cli account:choose`
+## `joystream-cli account:create`
 
-Choose default account to use in the CLI
+Create a new account
 
 ```
 USAGE
-  $ joystream-cli account:choose
+  $ joystream-cli account:create
 
 OPTIONS
-  -S, --showSpecial      Whether to show special (DEV chain) accounts
-  -a, --address=address  Select account by address (if available)
+  --name=name               Account name
+  --type=(sr25519|ed25519)  Account type (defaults to sr25519)
 ```
 
-_See code: [src/commands/account/choose.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/choose.ts)_
+_See code: [src/commands/account/create.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/create.ts)_
 
-## `joystream-cli account:create NAME`
+## `joystream-cli account:export DESTPATH`
 
-Create new account
+Export account(s) to given location
 
 ```
 USAGE
-  $ joystream-cli account:create NAME
+  $ joystream-cli account:export DESTPATH
 
 ARGUMENTS
-  NAME  Account name
+  DESTPATH  Path where the exported files should be placed
+
+OPTIONS
+  -a, --all        If provided, exports all existing accounts into "exported_accounts" folder inside given path
+  -n, --name=name  Name of the account to export
 ```
 
-_See code: [src/commands/account/create.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/create.ts)_
+_See code: [src/commands/account/export.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/export.ts)_
 
-## `joystream-cli account:current`
+## `joystream-cli account:forget`
 
-Display information about currently choosen default account
+Forget (remove) account from the list of available accounts
 
 ```
 USAGE
-  $ joystream-cli account:current
+  $ joystream-cli account:forget
+```
+
+_See code: [src/commands/account/forget.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/forget.ts)_
+
+## `joystream-cli account:import`
+
+Import account using mnemonic phrase, seed, suri or json backup file
 
-ALIASES
-  $ joystream-cli account:info
-  $ joystream-cli account:default
 ```
+USAGE
+  $ joystream-cli account:import
 
-_See code: [src/commands/account/current.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/current.ts)_
+OPTIONS
+  --backupFilePath=backupFilePath  Path to account backup JSON file
+  --mnemonic=mnemonic              Mnemonic phrase
+  --name=name                      Account name
+  --password=password              Account password
+  --seed=seed                      Secret seed
+  --suri=suri                      Substrate uri
+  --type=(sr25519|ed25519)         Account type (defaults to sr25519)
+```
 
-## `joystream-cli account:export PATH`
+_See code: [src/commands/account/import.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/import.ts)_
 
-Export account(s) to given location
+## `joystream-cli account:info [ADDRESS]`
+
+Display detailed information about specified account
 
 ```
 USAGE
-  $ joystream-cli account:export PATH
+  $ joystream-cli account:info [ADDRESS]
 
 ARGUMENTS
-  PATH  Path where the exported files should be placed
+  ADDRESS  An address to inspect (can also be provided interavtively)
 
-OPTIONS
-  -a, --all  If provided, exports all existing accounts into "exported_accounts" folder inside given path
+ALIASES
+  $ joystream-cli account:inspect
 ```
 
-_See code: [src/commands/account/export.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/export.ts)_
+_See code: [src/commands/account/info.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/info.ts)_
 
-## `joystream-cli account:forget`
+## `joystream-cli account:list`
 
-Forget (remove) account from the list of available accounts
+List all available accounts
 
 ```
 USAGE
-  $ joystream-cli account:forget
+  $ joystream-cli account:list
 ```
 
-_See code: [src/commands/account/forget.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/forget.ts)_
+_See code: [src/commands/account/list.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/list.ts)_
 
-## `joystream-cli account:import BACKUPFILEPATH`
+## `joystream-cli account:transferTokens`
 
-Import account using JSON backup file
+Transfer tokens from any of the available accounts
 
 ```
 USAGE
-  $ joystream-cli account:import BACKUPFILEPATH
+  $ joystream-cli account:transferTokens
 
-ARGUMENTS
-  BACKUPFILEPATH  Path to account backup JSON file
+OPTIONS
+  --amount=amount  (required) Amount of tokens to transfer
+  --from=from      Address of the sender (can also be provided interactively)
+  --to=to          Address of the recipient (can also be provided interactively)
 ```
 
-_See code: [src/commands/account/import.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/import.ts)_
+_See code: [src/commands/account/transferTokens.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/transferTokens.ts)_
 
-## `joystream-cli account:transferTokens RECIPIENT AMOUNT`
+## `joystream-cli api:getQueryNodeEndpoint`
 
-Transfer tokens from currently choosen account
+Get current query node endpoint
 
 ```
 USAGE
-  $ joystream-cli account:transferTokens RECIPIENT AMOUNT
-
-ARGUMENTS
-  RECIPIENT  Address of the transfer recipient
-  AMOUNT     Amount of tokens to transfer
+  $ joystream-cli api:getQueryNodeEndpoint
 ```
 
-_See code: [src/commands/account/transferTokens.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/transferTokens.ts)_
+_See code: [src/commands/api/getQueryNodeEndpoint.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/api/getQueryNodeEndpoint.ts)_
 
 ## `joystream-cli api:getUri`
 
@@ -279,6 +306,20 @@ EXAMPLES
 
 _See code: [src/commands/api/inspect.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/api/inspect.ts)_
 
+## `joystream-cli api:setQueryNodeEndpoint [ENDPOINT]`
+
+Set query node endpoint
+
+```
+USAGE
+  $ joystream-cli api:setQueryNodeEndpoint [ENDPOINT]
+
+ARGUMENTS
+  ENDPOINT  Query node endpoint for the CLI to use
+```
+
+_See code: [src/commands/api/setQueryNodeEndpoint.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/api/setQueryNodeEndpoint.ts)_
+
 ## `joystream-cli api:setUri [URI]`
 
 Set api WS provider uri
@@ -409,8 +450,9 @@ USAGE
   $ joystream-cli content:createVideo
 
 OPTIONS
-  -c, --channelId=channelId  (required) ID of the Channel
-  -i, --input=input          (required) Path to JSON file to use as input
+  -c, --channelId=channelId       (required) ID of the Channel
+  -i, --input=input               (required) Path to JSON file to use as input
+  --context=(Owner|Collaborator)  Actor context to execute the command in (Owner/Collaborator)
 ```
 
 _See code: [src/commands/content/createVideo.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/createVideo.ts)_
@@ -455,6 +497,21 @@ USAGE
 
 _See code: [src/commands/content/curatorGroups.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/curatorGroups.ts)_
 
+## `joystream-cli content:deleteChannel`
+
+Delete the channel and optionally all associated data objects.
+
+```
+USAGE
+  $ joystream-cli content:deleteChannel
+
+OPTIONS
+  -c, --channelId=channelId  (required) ID of the Channel
+  -f, --force                Force-remove all associated channel data objects
+```
+
+_See code: [src/commands/content/deleteChannel.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/deleteChannel.ts)_
+
 ## `joystream-cli content:deleteChannelCategory CHANNELCATEGORYID`
 
 Delete channel category.
@@ -472,6 +529,22 @@ OPTIONS
 
 _See code: [src/commands/content/deleteChannelCategory.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/deleteChannelCategory.ts)_
 
+## `joystream-cli content:deleteVideo`
+
+Delete the video and optionally all associated data objects.
+
+```
+USAGE
+  $ joystream-cli content:deleteVideo
+
+OPTIONS
+  -f, --force                     Force-remove all associated video data objects
+  -v, --videoId=videoId           (required) ID of the Video
+  --context=(Owner|Collaborator)  Actor context to execute the command in (Owner/Collaborator)
+```
+
+_See code: [src/commands/content/deleteVideo.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/deleteVideo.ts)_
+
 ## `joystream-cli content:deleteVideoCategory VIDEOCATEGORYID`
 
 Delete video category.
@@ -489,6 +562,22 @@ OPTIONS
 
 _See code: [src/commands/content/deleteVideoCategory.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/deleteVideoCategory.ts)_
 
+## `joystream-cli content:removeChannelAssets`
+
+Remove data objects associated with the channel or any of its videos.
+
+```
+USAGE
+  $ joystream-cli content:removeChannelAssets
+
+OPTIONS
+  -c, --channelId=channelId       (required) ID of the Channel
+  -o, --objectId=objectId         (required) ID of an object to remove
+  --context=(Owner|Collaborator)  Actor context to execute the command in (Owner/Collaborator)
+```
+
+_See code: [src/commands/content/removeChannelAssets.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/removeChannelAssets.ts)_
+
 ## `joystream-cli content:removeCuratorFromGroup [GROUPID] [CURATORID]`
 
 Remove Curator from Curator Group.
@@ -559,7 +648,8 @@ ARGUMENTS
   CHANNELID  ID of the Channel
 
 OPTIONS
-  -i, --input=input  (required) Path to JSON file to use as input
+  -i, --input=input               (required) Path to JSON file to use as input
+  --context=(Owner|Collaborator)  Actor context to execute the command in (Owner/Collaborator)
 ```
 
 _See code: [src/commands/content/updateChannel.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/updateChannel.ts)_
@@ -612,7 +702,8 @@ ARGUMENTS
   VIDEOID  ID of the Video
 
 OPTIONS
-  -i, --input=input  (required) Path to JSON file to use as input
+  -i, --input=input               (required) Path to JSON file to use as input
+  --context=(Owner|Collaborator)  Actor context to execute the command in (Owner/Collaborator)
 ```
 
 _See code: [src/commands/content/updateVideo.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/updateVideo.ts)_
@@ -721,8 +812,10 @@ ARGUMENTS
   WGAPPLICATIONID  Working Group Application ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/application.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/application.ts)_
@@ -736,21 +829,23 @@ USAGE
   $ joystream-cli working-groups:createOpening
 
 OPTIONS
-  -e, --edit                                          If provided along with --input - launches in edit mode allowing to
-                                                      modify the input before sending the exstinsic
+  -e, --edit
+      If provided along with --input - launches in edit mode allowing to modify the input before sending the exstinsic
 
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 
-  -i, --input=input                                   Path to JSON file to use as input (if not specified - the input
-                                                      can be provided interactively)
+  -i, --input=input
+      Path to JSON file to use as input (if not specified - the input can be provided interactively)
 
-  -o, --output=output                                 Path to the file where the output JSON should be saved (this
-                                                      output can be then reused as input)
+  -o, --output=output
+      Path to the file where the output JSON should be saved (this output can be then reused as input)
 
-  --dryRun                                            If provided along with --output - skips sending the actual
-                                                      extrinsic(can be used to generate a "draft" which can be provided
-                                                      as input later)
+  --dryRun
+      If provided along with --output - skips sending the actual extrinsic(can be used to generate a "draft" which can be 
+      provided as input later)
 ```
 
 _See code: [src/commands/working-groups/createOpening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/createOpening.ts)_
@@ -767,8 +862,10 @@ ARGUMENTS
   WORKERID  Worker ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/decreaseWorkerStake.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/decreaseWorkerStake.ts)_
@@ -785,8 +882,10 @@ ARGUMENTS
   WORKERID  Worker ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/evictWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/evictWorker.ts)_
@@ -803,8 +902,10 @@ ARGUMENTS
   WGOPENINGID  Working Group Opening ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/fillOpening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/fillOpening.ts)_
@@ -818,8 +919,10 @@ USAGE
   $ joystream-cli working-groups:increaseStake
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/increaseStake.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/increaseStake.ts)_
@@ -833,8 +936,10 @@ USAGE
   $ joystream-cli working-groups:leaveRole
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/leaveRole.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/leaveRole.ts)_
@@ -851,8 +956,10 @@ ARGUMENTS
   WGOPENINGID  Working Group Opening ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/opening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/opening.ts)_
@@ -866,8 +973,10 @@ USAGE
   $ joystream-cli working-groups:openings
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/openings.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/openings.ts)_
@@ -881,8 +990,10 @@ USAGE
   $ joystream-cli working-groups:overview
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/overview.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/overview.ts)_
@@ -896,8 +1007,10 @@ USAGE
   $ joystream-cli working-groups:setDefaultGroup
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/setDefaultGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/setDefaultGroup.ts)_
@@ -914,8 +1027,10 @@ ARGUMENTS
   WORKERID  Worker ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/slashWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/slashWorker.ts)_
@@ -932,8 +1047,10 @@ ARGUMENTS
   WGOPENINGID  Working Group Opening ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/startAcceptingApplications.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/startAcceptingApplications.ts)_
@@ -950,8 +1067,10 @@ ARGUMENTS
   WGOPENINGID  Working Group Opening ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/startReviewPeriod.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/startReviewPeriod.ts)_
@@ -968,44 +1087,50 @@ ARGUMENTS
   WGAPPLICATIONID  Working Group Application ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/terminateApplication.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/terminateApplication.ts)_
 
-## `joystream-cli working-groups:updateRewardAccount [ACCOUNTADDRESS]`
+## `joystream-cli working-groups:updateRewardAccount [ADDRESS]`
 
 Updates the worker/lead reward account (requires current role account to be selected)
 
 ```
 USAGE
-  $ joystream-cli working-groups:updateRewardAccount [ACCOUNTADDRESS]
+  $ joystream-cli working-groups:updateRewardAccount [ADDRESS]
 
 ARGUMENTS
-  ACCOUNTADDRESS  New reward account address (if omitted, one of the existing CLI accounts can be selected)
+  ADDRESS  New reward account address (if omitted, can be provided interactivel)
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/updateRewardAccount.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateRewardAccount.ts)_
 
-## `joystream-cli working-groups:updateRoleAccount [ACCOUNTADDRESS]`
+## `joystream-cli working-groups:updateRoleAccount [ADDRESS]`
 
 Updates the worker/lead role account. Requires member controller account to be selected
 
 ```
 USAGE
-  $ joystream-cli working-groups:updateRoleAccount [ACCOUNTADDRESS]
+  $ joystream-cli working-groups:updateRoleAccount [ADDRESS]
 
 ARGUMENTS
-  ACCOUNTADDRESS  New role account address (if omitted, one of the existing CLI accounts can be selected)
+  ADDRESS  New role account address (if omitted, can be provided interactively)
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/updateRoleAccount.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateRoleAccount.ts)_
@@ -1022,8 +1147,10 @@ ARGUMENTS
   STORAGE  Worker storage
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/updateRoleStorage.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateRoleStorage.ts)_
@@ -1040,15 +1167,11 @@ ARGUMENTS
   WORKERID  Worker ID
 
 OPTIONS
-  -g, --group=(storageProviders|curators|operations)  The working group context in which the command should be executed
-                                                      Available values are: storageProviders, curators, operations.
+  -g, --group=(storageProviders|curators|operationsAlpha|operationsBeta|operationsGamma|gateway|distributors)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, operationsAlpha, operationsBeta, operationsGamma, gateway, 
+      distributors.
 ```
 
 _See code: [src/commands/working-groups/updateWorkerReward.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateWorkerReward.ts)_
 <!-- commandsstop -->
-
-# Environment variables
-<!-- env -->
-- `FORCE_COLOR` - can be set to `0` to disable output coloring
-- `AUTO_CONFIRM` - can be set to `1` or `true` to skip any required confirmations (can be useful for creating bash scripts)
-<!-- envstop -->

+ 1 - 1
cli/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@joystream/cli",
   "description": "Command Line Interface for Joystream community and governance activities",
-  "version": "0.5.1",
+  "version": "0.6.0",
   "author": "Leszek Wiesner",
   "bin": {
     "joystream-cli": "./bin/run"

+ 14 - 1
cli/src/Api.ts

@@ -1,5 +1,5 @@
 import BN from 'bn.js'
-import { types } from '@joystream/types/'
+import { createType, types } from '@joystream/types/'
 import { ApiPromise, WsProvider } from '@polkadot/api'
 import { AugmentedQuery, SubmittableExtrinsic } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
@@ -557,4 +557,17 @@ export default class Api {
       value,
     ])
   }
+
+  async getMembers(ids: MemberId[] | number[]): Promise<Membership[]> {
+    return this._api.query.members.membershipById.multi(ids)
+  }
+
+  async memberEntriesByIds(ids: MemberId[] | number[]): Promise<[MemberId, Membership][]> {
+    const memberships = await this._api.query.members.membershipById.multi<Membership>(ids)
+    return ids.map((id, i) => [createType('MemberId', id), memberships[i]])
+  }
+
+  allMemberEntries(): Promise<[MemberId, Membership][]> {
+    return this.entriesByIds(this._api.query.members.membershipById)
+  }
 }

+ 5 - 5
cli/src/QueryNodeApi.ts

@@ -21,9 +21,9 @@ import {
   GetDataObjectsByVideoId,
   GetDataObjectsByVideoIdQuery,
   GetDataObjectsByVideoIdQueryVariables,
-  GetDataObjectsChannelId,
-  GetDataObjectsChannelIdQuery,
-  GetDataObjectsChannelIdQueryVariables,
+  GetDataObjectsByChannelId,
+  GetDataObjectsByChannelIdQuery,
+  GetDataObjectsByChannelIdQueryVariables,
 } from './graphql/generated/queries'
 import { URL } from 'url'
 import fetch from 'cross-fetch'
@@ -89,8 +89,8 @@ export default class QueryNodeApi {
   }
 
   async dataObjectsByChannelId(channelId: string): Promise<DataObjectInfoFragment[]> {
-    return this.multipleEntitiesQuery<GetDataObjectsChannelIdQuery, GetDataObjectsChannelIdQueryVariables>(
-      GetDataObjectsChannelId,
+    return this.multipleEntitiesQuery<GetDataObjectsByChannelIdQuery, GetDataObjectsByChannelIdQueryVariables>(
+      GetDataObjectsByChannelId,
       { channelId },
       'storageDataObjects'
     )

+ 14 - 9
cli/src/Types.ts

@@ -11,7 +11,7 @@ import { Opening, StakingPolicy, ApplicationStage } from '@joystream/types/hirin
 import { Validator } from 'inquirer'
 import { ApiPromise } from '@polkadot/api'
 import { SubmittableModuleExtrinsics, QueryableModuleStorage, QueryableModuleConsts } from '@polkadot/api/types'
-import { JSONSchema7, JSONSchema7Definition } from 'json-schema'
+import { JSONSchema4 } from 'json-schema'
 import {
   IChannelCategoryMetadata,
   IChannelMetadata,
@@ -232,6 +232,7 @@ export type ChannelInputParameters = Omit<IChannelMetadata, 'coverPhoto' | 'avat
   coverPhotoPath?: string
   avatarPhotoPath?: string
   rewardAccount?: string
+  collaborators?: number[]
 }
 
 export type ChannelCategoryInputParameters = IChannelCategoryMetadata
@@ -241,6 +242,14 @@ export type VideoCategoryInputParameters = IVideoCategoryMetadata
 type AnyNonObject = string | number | boolean | any[] | Long
 
 // JSONSchema utility types
+
+// Based on: https://stackoverflow.com/questions/51465182/how-to-remove-index-signature-using-mapped-types
+type RemoveIndex<T> = {
+  [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]
+}
+
+type AnyJSONSchema = RemoveIndex<JSONSchema4>
+
 export type JSONTypeName<T> = T extends string
   ? 'string' | ['string', 'null']
   : T extends number
@@ -253,19 +262,15 @@ export type JSONTypeName<T> = T extends string
   ? 'number' | ['number', 'null']
   : 'object' | ['object', 'null']
 
-export type PropertySchema<P> = Omit<
-  JSONSchema7Definition & {
-    type: JSONTypeName<P>
-    properties: P extends AnyNonObject ? never : JsonSchemaProperties<P>
-  },
-  P extends AnyNonObject ? 'properties' : ''
->
+export type PropertySchema<P> = Omit<AnyJSONSchema, 'type' | 'properties'> & {
+  type: JSONTypeName<P>
+} & (P extends AnyNonObject ? { properties?: never } : { properties: JsonSchemaProperties<P> })
 
 export type JsonSchemaProperties<T> = {
   [K in keyof Required<T>]: PropertySchema<Required<T>[K]>
 }
 
-export type JsonSchema<T> = JSONSchema7 & {
+export type JsonSchema<T> = Omit<AnyJSONSchema, 'type' | 'properties'> & {
   type: 'object'
   properties: JsonSchemaProperties<T>
 }

+ 230 - 148
cli/src/base/AccountsCommandBase.ts

@@ -1,4 +1,4 @@
-import fs from 'fs'
+import fs, { readdirSync } from 'fs'
 import path from 'path'
 import slug from 'slug'
 import inquirer from 'inquirer'
@@ -10,10 +10,20 @@ import { formatBalance } from '@polkadot/util'
 import { NamedKeyringPair } from '../Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { toFixedLength } from '../helpers/display'
-import { MemberId } from '@joystream/types/members'
+import { MemberId, Membership } from '@joystream/types/members'
+import { AccountId } from '@polkadot/types/interfaces'
+import { KeyringPair, KeyringInstance, KeyringOptions } from '@polkadot/keyring/types'
+import { KeypairType } from '@polkadot/util-crypto/types'
+import { createTestKeyring } from '@polkadot/keyring/testing'
+import chalk from 'chalk'
+import { mnemonicGenerate } from '@polkadot/util-crypto'
+import { validateAddress } from '../helpers/validation'
 
 const ACCOUNTS_DIRNAME = 'accounts'
-const SPECIAL_ACCOUNT_POSTFIX = '__DEV'
+export const DEFAULT_ACCOUNT_TYPE = 'sr25519'
+export const KEYRING_OPTIONS: KeyringOptions = {
+  type: DEFAULT_ACCOUNT_TYPE,
+}
 
 /**
  * Abstract base class for account-related commands.
@@ -23,18 +33,36 @@ const SPECIAL_ACCOUNT_POSTFIX = '__DEV'
  * Where: APP_DATA_PATH is provided by StateAwareCommandBase and ACCOUNTS_DIRNAME is a const (see above).
  */
 export default abstract class AccountsCommandBase extends ApiCommandBase {
-  private selectedMemberId: number | undefined
+  private selectedMember: [MemberId, Membership] | undefined
+  private _keyring: KeyringInstance | undefined
+
+  private get keyring(): KeyringInstance {
+    if (!this._keyring) {
+      this.error('Trying to access Keyring before AccountsCommandBase initialization', {
+        exit: ExitCodes.UnexpectedException,
+      })
+    }
+    return this._keyring
+  }
+
+  isKeyAvailable(key: AccountId | string): boolean {
+    return this.keyring.getPairs().some((p) => p.address === key.toString())
+  }
 
   getAccountsDirPath(): string {
     return path.join(this.getAppDataPath(), ACCOUNTS_DIRNAME)
   }
 
-  getAccountFilePath(account: NamedKeyringPair, isSpecial = false): string {
-    return path.join(this.getAccountsDirPath(), this.generateAccountFilename(account, isSpecial))
+  getAccountFileName(accountName: string): string {
+    return `${slug(accountName)}.json`
+  }
+
+  getAccountFilePath(accountName: string): string {
+    return path.join(this.getAccountsDirPath(), this.getAccountFileName(accountName))
   }
 
-  generateAccountFilename(account: NamedKeyringPair, isSpecial = false): string {
-    return `${slug(account.meta.name, '_')}__${account.address}${isSpecial ? SPECIAL_ACCOUNT_POSTFIX : ''}.json`
+  isAccountNameTaken(accountName: string): boolean {
+    return readdirSync(this.getAccountsDirPath()).some((filename) => filename === this.getAccountFileName(accountName))
   }
 
   private initAccountsFs(): void {
@@ -43,23 +71,58 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  saveAccount(account: NamedKeyringPair, password: string, isSpecial = false): void {
-    try {
-      const destPath = this.getAccountFilePath(account, isSpecial)
-      fs.writeFileSync(destPath, JSON.stringify(account.toJson(password)))
-    } catch (e) {
-      throw this.createDataWriteError()
+  async createAccount(
+    name?: string,
+    masterKey?: KeyringPair,
+    password?: string,
+    type?: KeypairType
+  ): Promise<NamedKeyringPair> {
+    while (!name || this.isAccountNameTaken(name)) {
+      if (name) {
+        this.warn(`Account ${chalk.magentaBright(name)} already exists... Try different name`)
+      }
+      name = await this.simplePrompt({ message: 'New account name' })
     }
-  }
 
-  // Add dev "Alice" and "Bob" accounts
-  initSpecialAccounts() {
-    const keyring = new Keyring({ type: 'sr25519' })
-    keyring.addFromUri('//Alice', { name: 'Alice' })
-    keyring.addFromUri('//Bob', { name: 'Bob' })
-    keyring
-      .getPairs()
-      .forEach((pair) => this.saveAccount({ ...pair, meta: { name: pair.meta.name as string } }, '', true))
+    if (!masterKey) {
+      const keyring = new Keyring(KEYRING_OPTIONS)
+      const mnemonic = mnemonicGenerate()
+      keyring.addFromMnemonic(mnemonic, { name, whenCreated: Date.now() }, type)
+      masterKey = keyring.getPairs()[0]
+      this.log(chalk.magentaBright(`${chalk.bold('New account memonic: ')}${mnemonic}`))
+    } else {
+      const { address } = masterKey
+      const existingAcc = this.getPairs().find((p) => p.address === address)
+      if (existingAcc) {
+        this.error(`Account with this key already exists (${chalk.magentaBright(existingAcc.meta.name)})`, {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      await this.requestPairDecoding(masterKey, 'Current account password')
+      masterKey.meta.name = name
+    }
+
+    while (password === undefined) {
+      password = await this.promptForPassword("Set new account's password")
+      const password2 = await this.promptForPassword("Confirm new account's password")
+
+      if (password !== password2) {
+        this.warn('Passwords are not the same!')
+        password = undefined
+      }
+    }
+    if (!password) {
+      this.warn('Using empty password is not recommended!')
+    }
+
+    const destPath = this.getAccountFilePath(name)
+    fs.writeFileSync(destPath, JSON.stringify(masterKey.toJson(password)))
+
+    this.keyring.addPair(masterKey)
+
+    this.log(chalk.greenBright(`\nNew account succesfully created!`))
+
+    return masterKey as NamedKeyringPair
   }
 
   fetchAccountFromJsonFile(jsonBackupFilePath: string): NamedKeyringPair {
@@ -79,18 +142,20 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
       throw new CLIError('Provided backup file is not valid', { exit: ExitCodes.InvalidFile })
     }
 
-    // Force some default account name if none is provided in the original backup
     if (!accountJsonObj.meta) accountJsonObj.meta = {}
-    if (!accountJsonObj.meta.name) accountJsonObj.meta.name = 'Unnamed Account'
+    // Normalize the CLI account name based on file name
+    // (makes sure getAccountFilePath(name) will always point to the correct file, preserving backward-compatibility
+    // with older CLI versions)
+    accountJsonObj.meta.name = path.basename(jsonBackupFilePath, '.json')
 
-    const keyring = new Keyring()
+    const keyring = new Keyring(KEYRING_OPTIONS)
     let account: NamedKeyringPair
     try {
       // Try adding and retrieving the keys in order to validate that the backup file is correct
       keyring.addFromJson(accountJsonObj)
       account = keyring.getPair(accountJsonObj.address) as NamedKeyringPair // We can be sure it's named, because we forced it before
     } catch (e) {
-      throw new CLIError('Provided backup file is not valid', { exit: ExitCodes.InvalidFile })
+      throw new CLIError(`Provided backup file is not valid (${(e as Error).message})`, { exit: ExitCodes.InvalidFile })
     }
 
     return account
@@ -106,7 +171,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  fetchAccounts(includeSpecial = false): NamedKeyringPair[] {
+  fetchAccounts(): NamedKeyringPair[] {
     let files: string[] = []
     const accountDir = this.getAccountsDirPath()
     try {
@@ -119,180 +184,197 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     return files
       .map((fileName) => {
         const filePath = path.join(accountDir, fileName)
-        if (!includeSpecial && filePath.includes(SPECIAL_ACCOUNT_POSTFIX + '.')) return null
         return this.fetchAccountOrNullFromFile(filePath)
       })
-      .filter((accObj) => accObj !== null) as NamedKeyringPair[]
+      .filter((account) => account !== null) as NamedKeyringPair[]
   }
 
-  getSelectedAccountFilename(): string {
-    return this.getPreservedState().selectedAccountFilename
+  getPairs(includeDevAccounts = true): NamedKeyringPair[] {
+    return this.keyring.getPairs().filter((p) => includeDevAccounts || !p.meta.isTesting) as NamedKeyringPair[]
   }
 
-  getSelectedAccount(): NamedKeyringPair | null {
-    const selectedAccountFilename = this.getSelectedAccountFilename()
-
-    if (!selectedAccountFilename) {
-      return null
-    }
+  getPair(key: string): NamedKeyringPair {
+    return this.keyring.getPair(key) as NamedKeyringPair
+  }
 
-    const account = this.fetchAccountOrNullFromFile(path.join(this.getAccountsDirPath(), selectedAccountFilename))
+  async getDecodedPair(key: string | AccountId): Promise<NamedKeyringPair> {
+    const pair = this.getPair(key.toString())
 
-    return account
+    return (await this.requestPairDecoding(pair)) as NamedKeyringPair
   }
 
-  // Use when account usage is required in given command
-  async getRequiredSelectedAccount(promptIfMissing = true): Promise<NamedKeyringPair> {
-    let selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
-    if (!selectedAccount) {
-      if (!promptIfMissing) {
-        this.error('No default account selected! Use account:choose to set the default account.', {
-          exit: ExitCodes.NoAccountSelected,
-        })
-      }
+  async requestPairDecoding(pair: KeyringPair, message?: string): Promise<KeyringPair> {
+    // Skip if pair already unlocked
+    if (!pair.isLocked) {
+      return pair
+    }
 
-      const accounts: NamedKeyringPair[] = this.fetchAccounts()
-      if (!accounts.length) {
-        this.error('No accounts available! Use account:import in order to import accounts into the CLI.', {
-          exit: ExitCodes.NoAccountFound,
-        })
-      }
+    // First - try decoding using empty string
+    try {
+      pair.decodePkcs8('')
+      return pair
+    } catch (e) {
+      // Continue...
+    }
 
-      this.warn('No default account selected!')
-      selectedAccount = await this.promptForAccount(accounts)
-      await this.setSelectedAccount(selectedAccount)
+    let isPassValid = false
+    while (!isPassValid) {
+      try {
+        const password = await this.promptForPassword(
+          message || `Enter ${pair.meta.name ? pair.meta.name : pair.address} account password`
+        )
+        pair.decodePkcs8(password)
+        isPassValid = true
+      } catch (e) {
+        this.warn('Invalid password... Try again.')
+      }
     }
 
-    return selectedAccount
+    return pair
   }
 
-  async setSelectedAccount(account: NamedKeyringPair): Promise<void> {
-    const accountFilename = fs.existsSync(this.getAccountFilePath(account, true))
-      ? this.generateAccountFilename(account, true)
-      : this.generateAccountFilename(account)
-
-    await this.setPreservedState({ selectedAccountFilename: accountFilename })
+  initKeyring(): void {
+    this._keyring = this.getApi().isDevelopment ? createTestKeyring(KEYRING_OPTIONS) : new Keyring(KEYRING_OPTIONS)
+    const accounts = this.fetchAccounts()
+    accounts.forEach((a) => this.keyring.addPair(a))
   }
 
-  async promptForPassword(message = "Your account's password") {
-    const { password } = await inquirer.prompt([{ name: 'password', type: 'password', message }])
+  async promptForPassword(message = "Your account's password"): Promise<string> {
+    const { password } = await inquirer.prompt([
+      {
+        name: 'password',
+        type: 'password',
+        message,
+      },
+    ])
 
     return password
   }
 
-  async requireConfirmation(
-    message = 'Are you sure you want to execute this action?',
-    defaultVal = false
-  ): Promise<void> {
-    if (process.env.AUTO_CONFIRM === 'true' || parseInt(process.env.AUTO_CONFIRM || '')) {
-      return
-    }
-    const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: defaultVal }])
-    if (!confirmed) {
-      this.exit(ExitCodes.OK)
-    }
-  }
-
   async promptForAccount(
-    accounts: NamedKeyringPair[],
-    defaultAccount: NamedKeyringPair | null = null,
     message = 'Select an account',
+    createIfUnavailable = true,
+    includeDevAccounts = true,
     showBalances = true
-  ): Promise<NamedKeyringPair> {
-    let balances: DeriveBalancesAll[]
+  ): Promise<string> {
+    const pairs = this.getPairs(includeDevAccounts)
+
+    if (!pairs.length) {
+      this.warn('No accounts available!')
+      if (createIfUnavailable) {
+        await this.requireConfirmation('Do you want to create a new account?', true)
+        pairs.push(await this.createAccount())
+      } else {
+        this.exit()
+      }
+    }
+
+    let balances: DeriveBalancesAll[] = []
     if (showBalances) {
-      balances = await this.getApi().getAccountsBalancesInfo(accounts.map((acc) => acc.address))
+      balances = await this.getApi().getAccountsBalancesInfo(pairs.map((p) => p.address))
     }
-    const longestAccNameLength: number = accounts.reduce((prev, curr) => Math.max(curr.meta.name.length, prev), 0)
-    const accNameColLength: number = Math.min(longestAccNameLength + 1, 20)
-    const { chosenAccountFilename } = await inquirer.prompt([
-      {
-        name: 'chosenAccountFilename',
-        message,
-        type: 'list',
-        choices: accounts.map((account: NamedKeyringPair, i) => ({
-          name:
-            `${toFixedLength(account.meta.name, accNameColLength)} | ` +
-            `${account.address} | ` +
-            ((showBalances || '') &&
-              `${formatBalance(balances[i].availableBalance)} / ` + `${formatBalance(balances[i].votingBalance)}`),
-          value: this.generateAccountFilename(account),
-          short: `${account.meta.name} (${account.address})`,
-        })),
-        default: defaultAccount && this.generateAccountFilename(defaultAccount),
-      },
-    ])
 
-    return accounts.find((acc) => this.generateAccountFilename(acc) === chosenAccountFilename) as NamedKeyringPair
+    const longestNameLen: number = pairs.reduce((prev, curr) => Math.max(curr.meta.name.length, prev), 0)
+    const nameColLength: number = Math.min(longestNameLen + 1, 20)
+    const chosenKey = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices: pairs.map((p, i) => ({
+        name:
+          `${toFixedLength(p.meta.name, nameColLength)} | ` +
+          `${p.address} | ` +
+          ((showBalances || '') &&
+            `${formatBalance(balances[i].availableBalance)} / ` + `${formatBalance(balances[i].votingBalance)}`),
+        value: p.address,
+      })),
+    })
+
+    return chosenKey
   }
 
-  async requestAccountDecoding(account: NamedKeyringPair): Promise<void> {
-    // Skip if account already unlocked
-    if (!account.isLocked) {
-      return
-    }
+  promptForCustomAddress(): Promise<string> {
+    return this.simplePrompt({
+      message: 'Provide custom address',
+      validate: (a) => validateAddress(a),
+    })
+  }
 
-    // First - try decoding using empty string
-    try {
-      account.decodePkcs8('')
-      return
-    } catch (e) {
-      // Continue...
-    }
+  async promptForAnyAddress(message = 'Select an address'): Promise<string> {
+    const type: 'available' | 'new' | 'custom' = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices: [
+        { name: 'Available account', value: 'available' },
+        { name: 'New account', value: 'new' },
+        { name: 'Custom address', value: 'custom' },
+      ],
+    })
 
-    let isPassValid = false
-    while (!isPassValid) {
-      try {
-        const password = await this.promptForPassword()
-        account.decodePkcs8(password)
-        isPassValid = true
-      } catch (e) {
-        this.warn('Invalid password... Try again.')
-      }
+    if (type === 'available') {
+      return this.promptForAccount()
+    } else if (type === 'new') {
+      return (await this.createAccount()).address
+    } else {
+      return this.promptForCustomAddress()
     }
   }
 
-  async getRequiredMemberId(useSelected = false): Promise<number> {
-    if (this.selectedMemberId && useSelected) {
-      return this.selectedMemberId
+  async getRequiredMemberContext(useSelected = false, allowedIds?: MemberId[]): Promise<[MemberId, Membership]> {
+    if (
+      useSelected &&
+      this.selectedMember &&
+      (!allowedIds || allowedIds.some((id) => id.eq(this.selectedMember?.[0])))
+    ) {
+      return this.selectedMember
     }
 
-    const account = await this.getRequiredSelectedAccount()
-    const memberIds = await this.getApi().getMemberIdsByControllerAccount(account.address)
-
-    let memberId: number
-    if (!memberIds.length) {
-      this.error('Membership required to access this command!', { exit: ExitCodes.AccessDenied })
-    } else if (memberIds.length === 1) {
-      memberId = memberIds[0].toNumber()
+    const membersEntries = allowedIds
+      ? await this.getApi().memberEntriesByIds(allowedIds)
+      : await this.getApi().allMemberEntries()
+    const availableMemberships = await Promise.all(
+      membersEntries.filter(([, m]) => this.isKeyAvailable(m.controller_account.toString()))
+    )
+
+    if (!availableMemberships.length) {
+      this.error(
+        `No ${allowedIds ? 'allowed ' : ''}member controller key available!` +
+          (allowedIds ? ` Allowed members: ${allowedIds.join(', ')}.` : ''),
+        {
+          exit: ExitCodes.AccessDenied,
+        }
+      )
+    } else if (availableMemberships.length === 1) {
+      this.selectedMember = availableMemberships[0]
     } else {
-      memberId = await this.promptForMember(memberIds, 'Choose member context')
+      this.selectedMember = await this.promptForMember(availableMemberships, 'Choose member context')
     }
 
-    this.selectedMemberId = memberId
-    return memberId
+    return this.selectedMember
   }
 
-  async promptForMember(availableMembers: MemberId[], message = 'Choose a member'): Promise<number> {
-    const memberId: number = await this.simplePrompt({
+  async promptForMember(
+    availableMemberships: [MemberId, Membership][],
+    message = 'Choose a member'
+  ): Promise<[MemberId, Membership]> {
+    const memberIndex = await this.simplePrompt({
       type: 'list',
       message,
-      choices: availableMembers.map((memberId) => ({
-        name: `ID: ${memberId.toString()}`,
-        value: memberId.toNumber(),
+      choices: availableMemberships.map(([, membership], i) => ({
+        name: membership.handle.toString(),
+        value: i,
       })),
     })
 
-    return memberId
+    return availableMemberships[memberIndex]
   }
 
-  async init() {
+  async init(): Promise<void> {
     await super.init()
     try {
       this.initAccountsFs()
-      this.initSpecialAccounts()
     } catch (e) {
       throw this.createDataDirInitError()
     }
+    await this.initKeyring()
   }
 }

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

@@ -16,6 +16,9 @@ import { DistinctQuestion } from 'inquirer'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 import { DispatchError } from '@polkadot/types/interfaces/system'
 import QueryNodeApi from '../QueryNodeApi'
+import { formatBalance } from '@polkadot/util'
+import BN from 'bn.js'
+import _ from 'lodash'
 
 export class ExtrinsicFailedError extends Error {}
 
@@ -186,9 +189,11 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
       } while (!this.isQueryNodeUriValid(selectedUri))
     }
 
-    await this.setPreservedState({ queryNodeUri: selectedUri })
+    const queryNodeUri = selectedUri === 'none' ? null : selectedUri
 
-    return selectedUri === 'none' ? null : selectedUri
+    await this.setPreservedState({ queryNodeUri })
+
+    return queryNodeUri
   }
 
   isApiUriValid(uri: string): boolean {
@@ -214,13 +219,13 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
   // This is needed to correctly handle some structs, enums etc.
   // Where the main typeDef doesn't provide enough information
-  protected getRawTypeDef(type: keyof InterfaceTypes) {
+  protected getRawTypeDef(type: keyof InterfaceTypes): TypeDef {
     const instance = this.createType(type)
     return getTypeDef(instance.toRawType())
   }
 
   // Prettifier for type names which are actually JSON strings
-  protected prettifyJsonTypeName(json: string) {
+  protected prettifyJsonTypeName(json: string): string {
     const obj = JSON.parse(json) as { [key: string]: string }
     return (
       '{\n' +
@@ -232,7 +237,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   // Get param name based on TypeDef object
-  protected paramName(typeDef: TypeDef) {
+  protected paramName(typeDef: TypeDef): string {
     return chalk.green(
       typeDef.displayName ||
         typeDef.name ||
@@ -428,7 +433,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   // More typesafe version
-  async promptForType(type: keyof InterfaceTypes, options?: ApiParamOptions) {
+  async promptForType(type: keyof InterfaceTypes, options?: ApiParamOptions): Promise<Codec> {
     return await this.promptForParam(type, options)
   }
 
@@ -498,6 +503,13 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   async sendAndFollowTx(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>): Promise<SubmittableResult> {
+    // Calculate fee and ask for confirmation
+    const fee = await this.getApi().estimateFee(account, tx)
+
+    await this.requireConfirmation(
+      `Tx fee of ${chalk.cyan(formatBalance(fee))} will be deduced from you account, do you confirm the transfer?`
+    )
+
     try {
       const res = await this.sendExtrinsic(account, tx)
       this.log(chalk.green(`Extrinsic successful!`))
@@ -511,6 +523,22 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
   }
 
+  private humanize(p: unknown): any {
+    if (Array.isArray(p)) {
+      return p.map((v) => this.humanize(v))
+    } else if (typeof p === 'object' && p !== null) {
+      if ((p as any).toHuman) {
+        return (p as Codec).toHuman()
+      } else if (p instanceof BN) {
+        return p.toString()
+      } else {
+        return _.mapValues(p, this.humanize.bind(this))
+      }
+    }
+
+    return p
+  }
+
   async sendAndFollowNamedTx<
     Module extends keyof AugmentedSubmittables<'promise'>,
     Method extends keyof AugmentedSubmittables<'promise'>[Module] & string,
@@ -526,8 +554,9 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         `\nSending ${module}.${method} extrinsic from ${account.meta.name ? account.meta.name : account.address}...`
       )
     )
+    this.log('Tx params:', this.humanize(params))
     const tx = await this.getUnaugmentedApi().tx[module][method](...params)
-    return await this.sendAndFollowTx(account, tx) //, warnOnly)
+    return this.sendAndFollowTx(account, tx)
   }
 
   public findEvent<

+ 115 - 50
cli/src/base/ContentDirectoryCommandBase.ts

@@ -4,61 +4,52 @@ import { CuratorGroup, CuratorGroupId, ContentActor, Channel } from '@joystream/
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
 import { RolesCommandBase } from './WorkingGroupsCommandBase'
-import { createTypeFromConstructor } from '@joystream/types'
+import { createType } from '@joystream/types'
 import { flags } from '@oclif/command'
+import { MemberId } from '@joystream/types/members'
 
-// TODO: Rework the contexts
-
-const CONTEXTS = ['Member', 'Curator', 'Lead'] as const
-const OWNER_CONTEXTS = ['Member', 'Curator'] as const
+const CHANNEL_CREATION_CONTEXTS = ['Member', 'Curator'] as const
 const CATEGORIES_CONTEXTS = ['Lead', 'Curator'] as const
+const CHANNEL_MANAGEMENT_CONTEXTS = ['Owner', 'Collaborator'] as const
 
-type Context = typeof CONTEXTS[number]
-type OwnerContext = typeof OWNER_CONTEXTS[number]
+type ChannelManagementContext = typeof CHANNEL_MANAGEMENT_CONTEXTS[number]
+type ChannelCreationContext = typeof CHANNEL_CREATION_CONTEXTS[number]
 type CategoriesContext = typeof CATEGORIES_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',
+  static channelCreationContextFlag = flags.enum({
     required: false,
-    description: `Actor context to execute the command in (${CONTEXTS.join('/')})`,
-    options: [...CONTEXTS],
+    description: `Actor context to execute the command in (${CHANNEL_CREATION_CONTEXTS.join('/')})`,
+    options: [...CHANNEL_CREATION_CONTEXTS],
   })
 
-  static ownerContextFlag = flags.enum({
-    name: 'ownerContext',
+  static channelManagementContextFlag = flags.enum({
     required: false,
-    description: `Actor context to execute the command in (${OWNER_CONTEXTS.join('/')})`,
-    options: [...OWNER_CONTEXTS],
+    description: `Actor context to execute the command in (${CHANNEL_MANAGEMENT_CONTEXTS.join('/')})`,
+    options: [...CHANNEL_MANAGEMENT_CONTEXTS],
   })
 
   static categoriesContextFlag = flags.enum({
-    name: 'categoriesContext',
     required: false,
     description: `Actor context to execute the command in (${CATEGORIES_CONTEXTS.join('/')})`,
     options: [...CATEGORIES_CONTEXTS],
   })
 
-  async promptForContext(message = 'Choose in which context you wish to execute the command'): Promise<Context> {
-    return this.simplePrompt({
-      message,
-      type: 'list',
-      choices: CONTEXTS.map((c) => ({ name: c, value: c })),
-    })
+  async init(): Promise<void> {
+    await super.init()
+    this.group = WorkingGroups.Curators // override group for RolesCommandBase
   }
 
-  async promptForOwnerContext(
+  async promptForChannelCreationContext(
     message = 'Choose in which context you wish to execute the command'
-  ): Promise<OwnerContext> {
+  ): Promise<ChannelCreationContext> {
     return this.simplePrompt({
       message,
       type: 'list',
-      choices: OWNER_CONTEXTS.map((c) => ({ name: c, value: c })),
+      choices: CHANNEL_CREATION_CONTEXTS.map((c) => ({ name: c, value: c })),
     })
   }
 
@@ -74,35 +65,90 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
 
   // Use when lead access is required in given command
   async requireLead(): Promise<void> {
-    await this.getRequiredLead()
+    await this.getRequiredLeadContext()
   }
 
-  async getCurationActorByChannel(channel: Channel): Promise<ContentActor> {
-    return channel.owner.isOfType('Curators') ? await this.getActor('Lead') : await this.getActor('Curator')
+  getCurationActorByChannel(channel: Channel): Promise<[ContentActor, string]> {
+    return channel.owner.isOfType('Curators') ? this.getContentActor('Lead') : this.getContentActor('Curator')
   }
 
-  async getChannelOwnerActor(channel: Channel): Promise<ContentActor> {
+  async getChannelOwnerActor(channel: Channel): Promise<[ContentActor, string]> {
     if (channel.owner.isOfType('Curators')) {
       try {
-        return await this.getActor('Lead')
+        return this.getContentActor('Lead')
       } catch (e) {
-        return await this.getCuratorContext(channel.owner.asType('Curators'))
+        return this.getCuratorContext(channel.owner.asType('Curators'))
       }
     } else {
-      return await this.getActor('Member')
+      const [id, membership] = await this.getRequiredMemberContext(false, [channel.owner.asType('Member')])
+      return [
+        createType<ContentActor, 'ContentActor'>('ContentActor', { Member: id }),
+        membership.controller_account.toString(),
+      ]
     }
   }
 
-  async getCategoryManagementActor(): Promise<ContentActor> {
+  async getChannelCollaboratorActor(channel: Channel): Promise<[ContentActor, string]> {
+    const [id, membership] = await this.getRequiredMemberContext(false, Array.from(channel.collaborators))
+    return [
+      createType<ContentActor, 'ContentActor'>('ContentActor', { Collaborator: id }),
+      membership.controller_account.toString(),
+    ]
+  }
+
+  async getChannelManagementActor(
+    channel: Channel,
+    context: ChannelManagementContext
+  ): Promise<[ContentActor, string]> {
+    if (context && context === 'Owner') {
+      return this.getChannelOwnerActor(channel)
+    }
+    if (context && context === 'Collaborator') {
+      return this.getChannelCollaboratorActor(channel)
+    }
+
+    // Context not set - derive
+
+    try {
+      const owner = await this.getChannelOwnerActor(channel)
+      this.log('Derived context: Channel owner')
+      return owner
+    } catch (e) {
+      // continue
+    }
+
     try {
-      return await this.getActor('Lead')
+      const collaborator = await this.getChannelCollaboratorActor(channel)
+      this.log('Derived context: Channel collaborator')
+      return collaborator
     } catch (e) {
-      return await this.getActor('Curator')
+      // continue
     }
+
+    this.error('No account found with access to manage the provided channel', { exit: ExitCodes.AccessDenied })
   }
 
-  async getCuratorContext(requiredGroupId?: CuratorGroupId): Promise<ContentActor> {
-    const curator = await this.getRequiredWorker()
+  async getCategoryManagementActor(): Promise<[ContentActor, string]> {
+    try {
+      const lead = await this.getContentActor('Lead')
+      this.log('Derived context: Lead')
+      return lead
+    } catch (e) {
+      // continue
+    }
+    try {
+      const curator = await this.getContentActor('Curator')
+      this.log('Derived context: Curator')
+      return curator
+    } catch (e) {
+      // continue
+    }
+
+    this.error('Lead / Curator Group member permissions are required for this action', { exit: ExitCodes.AccessDenied })
+  }
+
+  async getCuratorContext(requiredGroupId?: CuratorGroupId): Promise<[ContentActor, string]> {
+    const curator = await this.getRequiredWorkerContext()
 
     let groupId: number
     if (requiredGroupId) {
@@ -134,7 +180,10 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       }
     }
 
-    return createTypeFromConstructor(ContentActor, { Curator: [groupId, curator.workerId.toNumber()] })
+    return [
+      createType<ContentActor, 'ContentActor'>('ContentActor', { Curator: [groupId, curator.workerId.toNumber()] }),
+      curator.roleAccount.toString(),
+    ]
   }
 
   private async curatorGroupChoices(ids?: CuratorGroupId[]) {
@@ -226,19 +275,35 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     return group
   }
 
-  async getActor(context: typeof CONTEXTS[number]) {
-    let actor: ContentActor
+  async getContentActor(
+    context: Exclude<keyof typeof ContentActor.typeDefinitions, 'Collaborator'>
+  ): Promise<[ContentActor, string]> {
     if (context === 'Member') {
-      const memberId = await this.getRequiredMemberId()
-      actor = this.createType('ContentActor', { Member: memberId })
-    } else if (context === 'Curator') {
-      actor = await this.getCuratorContext()
-    } else {
-      await this.getRequiredLead()
+      const [id, membership] = await this.getRequiredMemberContext()
+      return [
+        createType<ContentActor, 'ContentActor'>('ContentActor', { Member: id }),
+        membership.controller_account.toString(),
+      ]
+    }
+
+    if (context === 'Curator') {
+      return this.getCuratorContext()
+    }
 
-      actor = this.createType('ContentActor', { Lead: null })
+    if (context === 'Lead') {
+      const lead = await this.getRequiredLeadContext()
+      return [createType<ContentActor, 'ContentActor'>('ContentActor', { Lead: null }), lead.roleAccount.toString()]
     }
 
-    return actor
+    throw new Error(`Unrecognized context: ${context}`)
+  }
+
+  async validateCollaborators(collaborators: number[] | MemberId[]): Promise<void> {
+    const collaboratorMembers = await this.getApi().getMembers(collaborators)
+    if (collaboratorMembers.length < collaborators.length || collaboratorMembers.some((m) => m.isEmpty)) {
+      this.error(`Invalid collaborator set! All collaborators must be existing members.`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
   }
 }

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

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

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

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

+ 23 - 24
cli/src/base/UploadCommandBase.ts

@@ -193,24 +193,30 @@ export default abstract class UploadCommandBase extends ContentDirectoryCommandB
     })
   }
 
-  async resolveAndValidateAssets(paths: string[], basePath: string): Promise<ResolvedAsset[]> {
-    // Resolve assets
-    if (basePath) {
-      paths = paths.map((p) => basePath && path.resolve(path.dirname(basePath), p))
-    }
-    // Validate assets
-    await Promise.all(paths.map((p) => this.validateFile(p)))
-
-    // Return data
-    return await Promise.all(
-      paths.map(async (path) => {
-        const parameters = await this.generateDataObjectParameters(path)
-        return {
-          path,
-          parameters,
-        }
+  async resolveAndValidateAssets<T extends Record<string, string | null | undefined>>(
+    paths: T,
+    basePath: string
+  ): Promise<[ResolvedAsset[], { [K in keyof T]?: number }]> {
+    const assetIndices: { [K in keyof T]?: number } = {}
+    const resolvedAssets: ResolvedAsset[] = []
+    for (let [assetKey, assetPath] of Object.entries(paths)) {
+      const assetType = assetKey as keyof T
+      if (!assetPath) {
+        assetIndices[assetType] = undefined
+        continue
+      }
+      if (basePath) {
+        assetPath = path.resolve(path.dirname(basePath), assetPath)
+      }
+      await this.validateFile(assetPath)
+      const parameters = await this.generateDataObjectParameters(assetPath)
+      assetIndices[assetType] = resolvedAssets.length
+      resolvedAssets.push({
+        path: assetPath,
+        parameters,
       })
-    )
+    }
+    return [resolvedAssets, assetIndices]
   }
 
   async getStorageNodeUploadToken(
@@ -254,7 +260,6 @@ export default abstract class UploadCommandBase extends ContentDirectoryCommandB
       this.error('No active storage node found!', { exit: ExitCodes.ActionCurrentlyUnavailable })
     }
     this.log(`Chosen storage node endpoint: ${storageNodeInfo.apiEndpoint}`)
-    const token = await this.getStorageNodeUploadToken(storageNodeInfo, account, memberId, objectId, bagId)
     const { fileStream, progressBar } = this.createReadStreamWithProgressBar(
       filePath,
       `Uploading ${filePath}`,
@@ -279,7 +284,6 @@ export default abstract class UploadCommandBase extends ContentDirectoryCommandB
         maxBodyLength: Infinity,
         maxContentLength: Infinity,
         headers: {
-          'x-api-key': token,
           'content-type': 'multipart/form-data',
           ...formData.getHeaders(),
         },
@@ -336,11 +340,6 @@ export default abstract class UploadCommandBase extends ContentDirectoryCommandB
     multiBar.stop()
   }
 
-  public assetsIndexes(originalPaths: (string | undefined)[], filteredPaths: string[]): (number | undefined)[] {
-    let lastIndex = -1
-    return originalPaths.map((path) => (filteredPaths.includes(path as string) ? ++lastIndex : undefined))
-  }
-
   async prepareAssetsForExtrinsic(resolvedAssets: ResolvedAsset[]): Promise<StorageAssets | undefined> {
     const feePerMB = await this.getOriginalApi().query.storage.dataObjectPerMegabyteFee()
     const { dataObjectDeletionPrize } = this.getOriginalApi().consts.storage

+ 19 - 53
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,38 +1,27 @@
 import ExitCodes from '../ExitCodes'
 import AccountsCommandBase from './AccountsCommandBase'
 import { flags } from '@oclif/command'
-import {
-  WorkingGroups,
-  AvailableGroups,
-  NamedKeyringPair,
-  GroupMember,
-  GroupOpening,
-  OpeningStatus,
-  GroupApplication,
-} from '../Types'
+import { WorkingGroups, AvailableGroups, GroupMember, GroupOpening, OpeningStatus, GroupApplication } from '../Types'
 import _ from 'lodash'
 import { ApplicationStageKeys } from '@joystream/types/hiring'
 import chalk from 'chalk'
-import { IConfig } from '@oclif/config'
 
 /**
  * Abstract base class for commands that need to use gates based on user's roles
  */
 export abstract class RolesCommandBase extends AccountsCommandBase {
-  group: WorkingGroups
+  group!: WorkingGroups
 
-  constructor(argv: string[], config: IConfig) {
-    super(argv, config)
-    // Can be modified by child class constructor
+  async init(): Promise<void> {
+    await super.init()
     this.group = this.getPreservedState().defaultWorkingGroup
   }
 
   // Use when lead access is required in given command
-  async getRequiredLead(): Promise<GroupMember> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+  async getRequiredLeadContext(): Promise<GroupMember> {
     const lead = await this.getApi().groupLead(this.group)
 
-    if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
+    if (!lead || !this.isKeyAvailable(lead.roleAccount)) {
       this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
         exit: ExitCodes.AccessDenied,
       })
@@ -42,38 +31,22 @@ export abstract class RolesCommandBase extends AccountsCommandBase {
   }
 
   // Use when worker access is required in given command
-  async getRequiredWorker(): Promise<GroupMember> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+  async getRequiredWorkerContext(expectedKeyType: 'Role' | 'MemberController' = 'Role'): Promise<GroupMember> {
     const groupMembers = await this.getApi().groupMembers(this.group)
-    const groupMembersByAccount = groupMembers.filter((m) => m.roleAccount.toString() === selectedAccount.address)
-
-    if (!groupMembersByAccount.length) {
-      this.error(`${_.startCase(this.group)} Group Worker access required for this command!`, {
-        exit: ExitCodes.AccessDenied,
-      })
-    } else if (groupMembersByAccount.length === 1) {
-      return groupMembersByAccount[0]
-    } else {
-      return await this.promptForWorker(groupMembersByAccount)
-    }
-  }
-
-  // Use when member controller access is required, but one of the associated roles is expected to be selected
-  async getRequiredWorkerByMemberController(): Promise<GroupMember> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
-    const memberIds = await this.getApi().getMemberIdsByControllerAccount(selectedAccount.address)
-    const controlledWorkers = (await this.getApi().groupMembers(this.group)).filter((groupMember) =>
-      memberIds.some((memberId) => groupMember.memberId.eq(memberId))
+    const availableGroupMemberContexts = groupMembers.filter((m) =>
+      expectedKeyType === 'Role'
+        ? this.isKeyAvailable(m.roleAccount.toString())
+        : this.isKeyAvailable(m.profile.controller_account.toString())
     )
 
-    if (!controlledWorkers.length) {
-      this.error(`Member controller account with some associated ${this.group} group roles needs to be selected!`, {
+    if (!availableGroupMemberContexts.length) {
+      this.error(`No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key available!`, {
         exit: ExitCodes.AccessDenied,
       })
-    } else if (controlledWorkers.length === 1) {
-      return controlledWorkers[0]
+    } else if (availableGroupMemberContexts.length === 1) {
+      return availableGroupMemberContexts[0]
     } else {
-      return await this.promptForWorker(controlledWorkers)
+      return await this.promptForWorker(availableGroupMemberContexts)
     }
   }
 
@@ -95,13 +68,6 @@ export abstract class RolesCommandBase extends AccountsCommandBase {
  * 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',
@@ -167,7 +133,7 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
     return application
   }
 
-  async getWorkerForLeadAction(id: number, requireStakeProfile = false) {
+  async getWorkerForLeadAction(id: number, requireStakeProfile = false): Promise<GroupMember> {
     const groupMember = await this.getApi().groupMember(this.group, id)
     const groupLead = await this.getApi().groupLead(this.group)
 
@@ -184,11 +150,11 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
 
   // Helper for better TS handling.
   // We could also use some magic with conditional types instead, but those don't seem be very well supported yet.
-  async getWorkerWithStakeForLeadAction(id: number) {
+  async getWorkerWithStakeForLeadAction(id: number): Promise<GroupMember & Required<Pick<GroupMember, 'stake'>>> {
     return (await this.getWorkerForLeadAction(id, true)) as GroupMember & Required<Pick<GroupMember, 'stake'>>
   }
 
-  async init() {
+  async init(): Promise<void> {
     await super.init()
     const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase)
     if (flags.group) {

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

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

+ 16 - 39
cli/src/commands/account/create.ts

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

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

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

+ 34 - 31
cli/src/commands/account/export.ts

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

+ 4 - 10
cli/src/commands/account/forget.ts

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

+ 56 - 33
cli/src/commands/account/import.ts

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

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

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

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

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

+ 34 - 51
cli/src/commands/account/transferTokens.ts

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

+ 6 - 5
cli/src/commands/content/addCuratorToGroup.ts

@@ -16,9 +16,8 @@ export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBas
     },
   ]
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+  async run(): Promise<void> {
+    const lead = await this.getRequiredLeadContext()
 
     let { groupId, curatorId } = this.parse(AddCuratorToGroupCommand).args
 
@@ -34,8 +33,10 @@ export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBas
       await this.getCurator(curatorId)
     }
 
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'content', 'addCuratorToGroup', [groupId, curatorId])
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(lead.roleAccount), 'content', 'addCuratorToGroup', [
+      groupId,
+      curatorId,
+    ])
 
     console.log(
       chalk.green(

+ 6 - 3
cli/src/commands/content/channel.ts

@@ -11,7 +11,7 @@ export default class ChannelCommand extends ContentDirectoryCommandBase {
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { channelId } = this.parse(ChannelCommand).args
     const channel = await this.getApi().channelById(channelId)
     if (channel) {
@@ -20,14 +20,17 @@ export default class ChannelCommand extends ContentDirectoryCommandBase {
         'Owner': JSON.stringify(channel.owner.toJSON()),
         'IsCensored': channel.is_censored.toString(),
         'RewardAccount': channel.reward_account.unwrapOr('NONE').toString(),
-        'DeletionPrizeAccount': channel.deletion_prize_source_account_id.toString(),
       })
 
       displayHeader(`Media`)
-
       displayCollapsedRow({
         'NumberOfVideos': channel.num_videos.toNumber(),
       })
+
+      displayHeader(`Collaborators`)
+      const collaboratorIds = Array.from(channel.collaborators)
+      const collaborators = await this.getApi().getMembers(collaboratorIds)
+      this.log(collaborators.map((c, i) => `${collaboratorIds[i].toString()} (${c.handle.toString()})`).join(', '))
     } else {
       this.error(`Channel not found by channel id: "${channelId}"!`)
     }

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

@@ -1,11 +1,11 @@
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 // import chalk from 'chalk'
-import { displayTable } from '../../helpers/display'
+import { displayTable, shortAddress } from '../../helpers/display'
 
 export default class ChannelsCommand extends ContentDirectoryCommandBase {
   static description = 'List existing content directory channels.'
 
-  async run() {
+  async run(): Promise<void> {
     const channels = await this.getApi().availableChannels()
 
     if (channels.length > 0) {
@@ -14,7 +14,8 @@ export default class ChannelsCommand extends ContentDirectoryCommandBase {
           'ID': id.toString(),
           'Owner': JSON.stringify(c.owner.toJSON()),
           'IsCensored': c.is_censored.toString(),
-          'RewardAccount': c.reward_account ? c.reward_account.toString() : 'NONE',
+          'RewardAccount': c.reward_account ? shortAddress(c.reward_account.toString()) : 'NONE',
+          'Collaborators': c.collaborators.size,
         })),
         3
       )

+ 29 - 22
cli/src/commands/content/createChannel.ts

@@ -2,7 +2,7 @@ import { getInputJson } from '../../helpers/InputOutput'
 import { ChannelInputParameters } from '../../Types'
 import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
 import { flags } from '@oclif/command'
-import { createTypeFromConstructor } from '@joystream/types'
+import { createType } from '@joystream/types'
 import { ChannelCreationParameters } from '@joystream/types/content'
 import { ChannelInputSchema } from '../../schemas/ContentDirectory'
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
@@ -13,7 +13,7 @@ import { ChannelMetadata } from '@joystream/metadata-protobuf'
 export default class CreateChannelCommand extends UploadCommandBase {
   static description = 'Create channel inside content directory.'
   static flags = {
-    context: ContentDirectoryCommandBase.ownerContextFlag,
+    context: ContentDirectoryCommandBase.channelCreationContextFlag,
     input: flags.string({
       char: 'i',
       required: true,
@@ -21,42 +21,49 @@ export default class CreateChannelCommand extends UploadCommandBase {
     }),
   }
 
-  async run() {
+  async run(): Promise<void> {
     let { context, input } = this.parse(CreateChannelCommand).flags
 
     // Context
     if (!context) {
-      context = await this.promptForOwnerContext()
+      context = await this.promptForChannelCreationContext()
     }
-    const account = await this.getRequiredSelectedAccount()
-    const actor = await this.getActor(context)
-    const memberId = await this.getRequiredMemberId(true)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getContentActor(context)
+    const [memberId] = await this.getRequiredMemberContext(true)
+    const keypair = await this.getDecodedPair(address)
 
     const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)
     const meta = asValidatedMetadata(ChannelMetadata, channelInput)
 
+    if (channelInput.collaborators) {
+      await this.validateCollaborators(channelInput.collaborators)
+    }
+
     const { coverPhotoPath, avatarPhotoPath } = channelInput
-    const assetsPaths = [coverPhotoPath, avatarPhotoPath].filter((v) => v !== undefined) as string[]
-    const resolvedAssets = await this.resolveAndValidateAssets(assetsPaths, input)
-    // Set assets indexes in the metadata
-    const [coverPhotoIndex, avatarPhotoIndex] = this.assetsIndexes([coverPhotoPath, avatarPhotoPath], assetsPaths)
-    meta.coverPhoto = coverPhotoIndex
-    meta.avatarPhoto = avatarPhotoIndex
+    const [resolvedAssets, assetIndices] = await this.resolveAndValidateAssets(
+      { coverPhotoPath, avatarPhotoPath },
+      input
+    )
+    meta.coverPhoto = assetIndices.coverPhotoPath
+    meta.avatarPhoto = assetIndices.avatarPhotoPath
 
     // Preare and send the extrinsic
     const assets = await this.prepareAssetsForExtrinsic(resolvedAssets)
-    const channelCreationParameters = createTypeFromConstructor(ChannelCreationParameters, {
-      assets,
-      meta: metadataToBytes(ChannelMetadata, meta),
-      reward_account: channelInput.rewardAccount,
-    })
+    const channelCreationParameters = createType<ChannelCreationParameters, 'ChannelCreationParameters'>(
+      'ChannelCreationParameters',
+      {
+        assets,
+        meta: metadataToBytes(ChannelMetadata, meta),
+        collaborators: channelInput.collaborators,
+        reward_account: channelInput.rewardAccount,
+      }
+    )
 
     this.jsonPrettyPrint(JSON.stringify({ assets: assets?.toJSON(), metadata: meta }))
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(account, 'content', 'createChannel', [
+    const result = await this.sendAndFollowNamedTx(keypair, 'content', 'createChannel', [
       actor,
       channelCreationParameters,
     ])
@@ -69,8 +76,8 @@ export default class CreateChannelCommand extends UploadCommandBase {
     if (dataObjectsUploadedEvent) {
       const [objectIds] = dataObjectsUploadedEvent.data
       await this.uploadAssets(
-        account,
-        memberId,
+        keypair,
+        memberId.toNumber(),
         `dynamic:channel:${channelId.toString()}`,
         objectIds.map((id, index) => ({ dataObjectId: id, path: resolvedAssets[index].path })),
         input

+ 8 - 9
cli/src/commands/content/createChannelCategory.ts

@@ -20,13 +20,10 @@ export default class CreateChannelCategoryCommand extends ContentDirectoryComman
     }),
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { context, input } = this.parse(CreateChannelCategoryCommand).flags
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
     const channelCategoryInput = await getInputJson<ChannelCategoryInputParameters>(input, ChannelCategoryInputSchema)
     const meta = asValidatedMetadata(ChannelCategoryMetadata, channelCategoryInput)
@@ -39,10 +36,12 @@ export default class CreateChannelCategoryCommand extends ContentDirectoryComman
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(currentAccount, 'content', 'createChannelCategory', [
-      actor,
-      channelCategoryCreationParameters,
-    ])
+    const result = await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(address),
+      'content',
+      'createChannelCategory',
+      [actor, channelCategoryCreationParameters]
+    )
 
     if (result) {
       const event = this.findEvent(result, 'content', 'ChannelCategoryCreated')

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

@@ -5,12 +5,10 @@ export default class CreateCuratorGroupCommand extends ContentDirectoryCommandBa
   static description = 'Create new Curator Group.'
   static aliases = ['createCuratorGroup']
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+  async run(): Promise<void> {
+    const lead = await this.getRequiredLeadContext()
 
-    await this.requestAccountDecoding(account)
-    await this.buildAndSendExtrinsic(account, 'content', 'createCuratorGroup')
+    await this.buildAndSendExtrinsic(await this.getDecodedPair(lead.roleAccount), 'content', 'createCuratorGroup')
 
     const newGroupId = (await this.getApi().nextCuratorGroupId()) - 1
     console.log(chalk.green(`New group successfully created! (ID: ${chalk.magentaBright(newGroupId)})`))

+ 26 - 29
cli/src/commands/content/createVideo.ts

@@ -7,8 +7,8 @@ import { flags } from '@oclif/command'
 import { VideoCreationParameters } from '@joystream/types/content'
 import { IVideoMetadata, VideoMetadata } from '@joystream/metadata-protobuf'
 import { VideoInputSchema } from '../../schemas/ContentDirectory'
-import { integrateMeta } from '@joystream/metadata-protobuf/utils'
 import chalk from 'chalk'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 
 export default class CreateVideoCommand extends UploadCommandBase {
   static description = 'Create video under specific channel inside content directory.'
@@ -23,51 +23,48 @@ export default class CreateVideoCommand extends UploadCommandBase {
       required: true,
       description: 'ID of the Channel',
     }),
+    context: ContentDirectoryCommandBase.channelManagementContextFlag,
   }
 
-  setVideoMetadataDefaults(metadata: IVideoMetadata, videoFileMetadata: VideoFileMetadata): void {
-    const videoMetaToIntegrate = {
+  setVideoMetadataDefaults(metadata: IVideoMetadata, videoFileMetadata: VideoFileMetadata): IVideoMetadata {
+    return {
       duration: videoFileMetadata.duration,
       mediaPixelWidth: videoFileMetadata.width,
       mediaPixelHeight: videoFileMetadata.height,
+      mediaType: {
+        codecName: videoFileMetadata.codecName,
+        container: videoFileMetadata.container,
+        mimeMediaType: videoFileMetadata.mimeType,
+      },
+      ...metadata,
     }
-    const mediaTypeMetaToIntegrate = {
-      codecName: videoFileMetadata.codecName,
-      container: videoFileMetadata.container,
-      mimeMediaType: videoFileMetadata.mimeType,
-    }
-    integrateMeta(metadata, videoMetaToIntegrate, ['duration', 'mediaPixelWidth', 'mediaPixelHeight'])
-    integrateMeta(metadata.mediaType || {}, mediaTypeMetaToIntegrate, ['codecName', 'container', 'mimeMediaType'])
   }
 
-  async run() {
-    const { input, channelId } = this.parse(CreateVideoCommand).flags
+  async run(): Promise<void> {
+    const { input, channelId, context } = this.parse(CreateVideoCommand).flags
 
     // Get context
-    const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelOwnerActor(channel)
-    const memberId = await this.getRequiredMemberId(true)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
+    const [memberId] = await this.getRequiredMemberContext(true)
+    const keypair = await this.getDecodedPair(address)
 
     // Get input from file
     const videoCreationParametersInput = await getInputJson<VideoInputParameters>(input, VideoInputSchema)
-    const meta = asValidatedMetadata(VideoMetadata, videoCreationParametersInput)
+    let meta = asValidatedMetadata(VideoMetadata, videoCreationParametersInput)
 
     // Assets
     const { videoPath, thumbnailPhotoPath } = videoCreationParametersInput
-    const assetsPaths = [videoPath, thumbnailPhotoPath].filter((a) => a !== undefined) as string[]
-    const resolvedAssets = await this.resolveAndValidateAssets(assetsPaths, input)
-    // Set assets indexes in the metadata
-    const [videoIndex, thumbnailPhotoIndex] = this.assetsIndexes([videoPath, thumbnailPhotoPath], assetsPaths)
-    meta.video = videoIndex
-    meta.thumbnailPhoto = thumbnailPhotoIndex
+    const [resolvedAssets, assetIndices] = await this.resolveAndValidateAssets({ videoPath, thumbnailPhotoPath }, input)
+    // Set assets indices in the metadata
+    meta.video = assetIndices.videoPath
+    meta.thumbnailPhoto = assetIndices.thumbnailPhotoPath
 
     // Try to get video file metadata
-    if (videoIndex !== undefined) {
-      const videoFileMetadata = await this.getVideoFileMetadata(resolvedAssets[videoIndex].path)
+    if (assetIndices.videoPath !== undefined) {
+      const videoFileMetadata = await this.getVideoFileMetadata(resolvedAssets[assetIndices.videoPath].path)
       this.log('Video media file parameters established:', videoFileMetadata)
-      this.setVideoMetadataDefaults(meta, videoFileMetadata)
+      meta = this.setVideoMetadataDefaults(meta, videoFileMetadata)
     }
 
     // Preare and send the extrinsic
@@ -81,7 +78,7 @@ export default class CreateVideoCommand extends UploadCommandBase {
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(account, 'content', 'createVideo', [
+    const result = await this.sendAndFollowNamedTx(keypair, 'content', 'createVideo', [
       actor,
       channelId,
       videoCreationParameters,
@@ -96,8 +93,8 @@ export default class CreateVideoCommand extends UploadCommandBase {
     if (dataObjectsUploadedEvent) {
       const [objectIds] = dataObjectsUploadedEvent.data
       await this.uploadAssets(
-        account,
-        memberId,
+        keypair,
+        memberId.toNumber(),
         `dynamic:channel:${channelId.toString()}`,
         objectIds.map((id, index) => ({ dataObjectId: id, path: resolvedAssets[index].path })),
         input

+ 8 - 9
cli/src/commands/content/createVideoCategory.ts

@@ -20,13 +20,10 @@ export default class CreateVideoCategoryCommand extends ContentDirectoryCommandB
     }),
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { context, input } = this.parse(CreateVideoCategoryCommand).flags
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
     const videoCategoryInput = await getInputJson<VideoCategoryInputParameters>(input, VideoCategoryInputSchema)
     const meta = asValidatedMetadata(VideoCategoryMetadata, videoCategoryInput)
@@ -39,10 +36,12 @@ export default class CreateVideoCategoryCommand extends ContentDirectoryCommandB
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(currentAccount, 'content', 'createVideoCategory', [
-      actor,
-      videoCategoryCreationParameters,
-    ])
+    const result = await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(address),
+      'content',
+      'createVideoCategory',
+      [actor, videoCategoryCreationParameters]
+    )
 
     if (result) {
       const event = this.findEvent(result, 'content', 'VideoCategoryCreated')

+ 1 - 1
cli/src/commands/content/curatorGroup.ts

@@ -13,7 +13,7 @@ export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { id } = this.parse(CuratorGroupCommand).args
     const group = await this.getCuratorGroup(id)
     const members = (await this.getApi().groupMembers(WorkingGroups.Curators)).filter((curator) =>

+ 3 - 5
cli/src/commands/content/deleteChannel.ts

@@ -58,10 +58,8 @@ export default class DeleteChannelCommand extends ContentDirectoryCommandBase {
       flags: { channelId, force },
     } = this.parse(DeleteChannelCommand)
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelOwnerActor(channel)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelOwnerActor(channel)
 
     if (channel.num_videos.toNumber()) {
       this.error(
@@ -84,7 +82,7 @@ export default class DeleteChannelCommand extends ContentDirectoryCommandBase {
       this.log(
         `Data objects deletion prize of ${chalk.cyanBright(
           formatBalance(deletionPrize)
-        )} will be transferred to ${chalk.magentaBright(channel.deletion_prize_source_account_id.toString())}`
+        )} will be transferred to ${chalk.magentaBright(address)}`
       )
     }
 
@@ -94,7 +92,7 @@ export default class DeleteChannelCommand extends ContentDirectoryCommandBase {
       }?`
     )
 
-    await this.sendAndFollowNamedTx(account, 'content', 'deleteChannel', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteChannel', [
       actor,
       channelId,
       force ? dataObjectsInfo.length : 0,

+ 6 - 6
cli/src/commands/content/deleteChannelCategory.ts

@@ -14,7 +14,7 @@ export default class DeleteChannelCategoryCommand extends ContentDirectoryComman
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { context } = this.parse(DeleteChannelCategoryCommand).flags
 
     const { channelCategoryId } = this.parse(DeleteChannelCategoryCommand).args
@@ -22,12 +22,12 @@ export default class DeleteChannelCategoryCommand extends ContentDirectoryComman
     const channelCategoryIds = await this.getApi().channelCategoryIds()
 
     if (channelCategoryIds.some((id) => id.toString() === channelCategoryId)) {
-      const currentAccount = await this.getRequiredSelectedAccount()
-      await this.requestAccountDecoding(currentAccount)
+      const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
-      const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
-
-      await this.sendAndFollowNamedTx(currentAccount, 'content', 'deleteChannelCategory', [actor, channelCategoryId])
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteChannelCategory', [
+        actor,
+        channelCategoryId,
+      ])
     } else {
       this.error('Channel category under given id does not exist...')
     }

+ 5 - 6
cli/src/commands/content/deleteVideo.ts

@@ -22,6 +22,7 @@ export default class DeleteVideoCommand extends ContentDirectoryCommandBase {
       default: false,
       description: 'Force-remove all associated video data objects',
     }),
+    context: ContentDirectoryCommandBase.channelManagementContextFlag,
   }
 
   async getDataObjectsInfo(videoId: number): Promise<[string, BN][]> {
@@ -39,14 +40,12 @@ export default class DeleteVideoCommand extends ContentDirectoryCommandBase {
 
   async run(): Promise<void> {
     const {
-      flags: { videoId, force },
+      flags: { videoId, force, context },
     } = this.parse(DeleteVideoCommand)
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const video = await this.getApi().videoById(videoId)
     const channel = await this.getApi().channelById(video.in_channel.toNumber())
-    const actor = await this.getChannelOwnerActor(channel)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
 
     const dataObjectsInfo = await this.getDataObjectsInfo(videoId)
     if (dataObjectsInfo.length) {
@@ -59,7 +58,7 @@ export default class DeleteVideoCommand extends ContentDirectoryCommandBase {
       this.log(
         `Data objects deletion prize of ${chalk.cyanBright(
           formatBalance(deletionPrize)
-        )} will be transferred to ${chalk.magentaBright(channel.deletion_prize_source_account_id.toString())}`
+        )} will be transferred to ${chalk.magentaBright(address)}`
       )
     }
 
@@ -69,7 +68,7 @@ export default class DeleteVideoCommand extends ContentDirectoryCommandBase {
       }?`
     )
 
-    await this.sendAndFollowNamedTx(account, 'content', 'deleteVideo', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteVideo', [
       actor,
       videoId,
       createType(

+ 6 - 6
cli/src/commands/content/deleteVideoCategory.ts

@@ -14,7 +14,7 @@ export default class DeleteVideoCategoryCommand extends ContentDirectoryCommandB
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { context } = this.parse(DeleteVideoCategoryCommand).flags
 
     const { videoCategoryId } = this.parse(DeleteVideoCategoryCommand).args
@@ -22,12 +22,12 @@ export default class DeleteVideoCategoryCommand extends ContentDirectoryCommandB
     const videoCategoryIds = await this.getApi().videoCategoryIds()
 
     if (videoCategoryIds.some((id) => id.toString() === videoCategoryId)) {
-      const currentAccount = await this.getRequiredSelectedAccount()
-      await this.requestAccountDecoding(currentAccount)
+      const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
-      const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
-
-      await this.sendAndFollowNamedTx(currentAccount, 'content', 'deleteVideoCategory', [actor, videoCategoryId])
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteVideoCategory', [
+        actor,
+        videoCategoryId,
+      ])
     } else {
       this.error('Video category under given id does not exist...')
     }

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

@@ -17,22 +17,21 @@ export default class RemoveChannelAssetsCommand extends ContentDirectoryCommandB
       multiple: true,
       description: 'ID of an object to remove',
     }),
+    context: ContentDirectoryCommandBase.channelManagementContextFlag,
   }
 
   async run(): Promise<void> {
     const {
-      flags: { channelId, objectId: objectIds },
+      flags: { channelId, objectId: objectIds, context },
     } = this.parse(RemoveChannelAssetsCommand)
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelOwnerActor(channel)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
 
     this.jsonPrettyPrint(JSON.stringify({ channelId, assetsToRemove: objectIds }))
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    await this.sendAndFollowNamedTx(account, 'content', 'updateChannel', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateChannel', [
       actor,
       channelId,
       { assets_to_remove: createType('BTreeSet<DataObjectId>', objectIds) },

+ 6 - 5
cli/src/commands/content/removeCuratorFromGroup.ts

@@ -16,9 +16,8 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
     },
   ]
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+  async run(): Promise<void> {
+    const lead = await this.getRequiredLeadContext()
 
     let { groupId, curatorId } = this.parse(RemoveCuratorFromGroupCommand).args
 
@@ -38,8 +37,10 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
       await this.getCurator(curatorId)
     }
 
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'content', 'removeCuratorFromGroup', [groupId, curatorId])
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(lead.roleAccount), 'content', 'removeCuratorFromGroup', [
+      groupId,
+      curatorId,
+    ])
 
     this.log(
       chalk.green(

+ 10 - 5
cli/src/commands/content/reuploadAssets.ts

@@ -16,13 +16,11 @@ export default class ReuploadVideoAssetsCommand extends UploadCommandBase {
     }),
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { input } = this.parse(ReuploadVideoAssetsCommand).flags
 
     // Get context
-    const account = await this.getRequiredSelectedAccount()
-    const memberId = await this.getRequiredMemberId()
-    await this.requestAccountDecoding(account)
+    const [memberId, membership] = await this.getRequiredMemberContext()
 
     // Get input from file
     const inputData = await getInputJson<AssetsInput>(input, AssetsSchema)
@@ -33,6 +31,13 @@ export default class ReuploadVideoAssetsCommand extends UploadCommandBase {
     }))
 
     // Upload assets
-    await this.uploadAssets(account, memberId, bagId, inputAssets, input, '')
+    await this.uploadAssets(
+      await this.getDecodedPair(membership.controller_account),
+      memberId.toNumber(),
+      bagId,
+      inputAssets,
+      input,
+      ''
+    )
   }
 }

+ 6 - 5
cli/src/commands/content/setCuratorGroupStatus.ts

@@ -17,9 +17,8 @@ export default class SetCuratorGroupStatusCommand extends ContentDirectoryComman
     },
   ]
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+  async run(): Promise<void> {
+    const lead = await this.getRequiredLeadContext()
 
     let { id, status } = this.parse(SetCuratorGroupStatusCommand).args
 
@@ -47,8 +46,10 @@ export default class SetCuratorGroupStatusCommand extends ContentDirectoryComman
       status = !!parseInt(status)
     }
 
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'content', 'setCuratorGroupStatus', [id, status])
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(lead.roleAccount), 'content', 'setCuratorGroupStatus', [
+      id,
+      status,
+    ])
 
     console.log(
       chalk.green(

+ 3 - 6
cli/src/commands/content/setFeaturedVideos.ts

@@ -11,15 +11,12 @@ export default class SetFeaturedVideosCommand extends ContentDirectoryCommandBas
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { featuredVideoIds } = this.parse(SetFeaturedVideosCommand).args
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
+    const [actor, address] = await this.getContentActor('Lead')
 
-    const actor = await this.getActor('Lead')
-
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'setFeaturedVideos', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'setFeaturedVideos', [
       actor,
       (featuredVideoIds as string).split(','),
     ])

+ 39 - 16
cli/src/commands/content/updateChannel.ts

@@ -11,9 +11,13 @@ import { DataObjectInfoFragment } from '../../graphql/generated/queries'
 import BN from 'bn.js'
 import { formatBalance } from '@polkadot/util'
 import chalk from 'chalk'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import ExitCodes from '../../ExitCodes'
+
 export default class UpdateChannelCommand extends UploadCommandBase {
   static description = 'Update existing content directory channel.'
   static flags = {
+    context: ContentDirectoryCommandBase.channelManagementContextFlag,
     input: flags.string({
       char: 'i',
       required: true,
@@ -69,39 +73,58 @@ export default class UpdateChannelCommand extends UploadCommandBase {
     return assetsToRemove.map((a) => a.id)
   }
 
-  async run() {
+  async run(): Promise<void> {
     const {
-      flags: { input },
+      flags: { input, context },
       args: { channelId },
     } = this.parse(UpdateChannelCommand)
 
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelOwnerActor(channel)
-    const memberId = await this.getRequiredMemberId(true)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
+    const [memberId] = await this.getRequiredMemberContext(true)
+    const keypair = await this.getDecodedPair(address)
 
     const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)
     const meta = asValidatedMetadata(ChannelMetadata, channelInput)
 
+    if (channelInput.rewardAccount !== undefined && actor.type === 'Collaborator') {
+      this.error("Collaborators are not allowed to update channel's reward account!", { exit: ExitCodes.AccessDenied })
+    }
+
+    if (channelInput.collaborators !== undefined && actor.type === 'Collaborator') {
+      this.error("Collaborators are not allowed to update channel's collaborators!", { exit: ExitCodes.AccessDenied })
+    }
+
+    if (channelInput.collaborators) {
+      await this.validateCollaborators(channelInput.collaborators)
+    }
+
     const { coverPhotoPath, avatarPhotoPath, rewardAccount } = channelInput
-    const inputPaths = [coverPhotoPath, avatarPhotoPath].filter((p) => p !== undefined) as string[]
-    const resolvedAssets = await this.resolveAndValidateAssets(inputPaths, input)
-    // Set assets indexes in the metadata
-    const [coverPhotoIndex, avatarPhotoIndex] = this.assetsIndexes([coverPhotoPath, avatarPhotoPath], inputPaths)
+    const [resolvedAssets, assetIndices] = await this.resolveAndValidateAssets(
+      { coverPhotoPath, avatarPhotoPath },
+      input
+    )
+    // Set assets indices in the metadata
     // "undefined" values will be omitted when the metadata is encoded. It's not possible to "unset" an asset this way.
-    meta.coverPhoto = coverPhotoIndex
-    meta.avatarPhoto = avatarPhotoIndex
+    meta.coverPhoto = assetIndices.coverPhotoPath
+    meta.avatarPhoto = assetIndices.avatarPhotoPath
 
     // Preare and send the extrinsic
     const assetsToUpload = await this.prepareAssetsForExtrinsic(resolvedAssets)
-    const assetsToRemove = await this.getAssetsToRemove(channelId, coverPhotoIndex, avatarPhotoIndex)
+    const assetsToRemove = await this.getAssetsToRemove(
+      channelId,
+      assetIndices.coverPhotoPath,
+      assetIndices.avatarPhotoPath
+    )
+
+    const collaborators = createType('Option<BTreeSet<MemberId>>', channelInput.collaborators)
     const channelUpdateParameters: CreateInterface<ChannelUpdateParameters> = {
       assets_to_upload: assetsToUpload,
       assets_to_remove: createType('BTreeSet<DataObjectId>', assetsToRemove),
       new_meta: metadataToBytes(ChannelMetadata, meta),
       reward_account: this.parseRewardAccountInput(rewardAccount),
+      collaborators,
     }
 
     this.jsonPrettyPrint(
@@ -110,7 +133,7 @@ export default class UpdateChannelCommand extends UploadCommandBase {
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(account, 'content', 'updateChannel', [
+    const result = await this.sendAndFollowNamedTx(keypair, 'content', 'updateChannel', [
       actor,
       channelId,
       channelUpdateParameters,
@@ -119,8 +142,8 @@ export default class UpdateChannelCommand extends UploadCommandBase {
     if (dataObjectsUploadedEvent) {
       const [objectIds] = dataObjectsUploadedEvent.data
       await this.uploadAssets(
-        account,
-        memberId,
+        keypair,
+        memberId.toNumber(),
         `dynamic:channel:${channelId.toString()}`,
         objectIds.map((id, index) => ({ dataObjectId: id, path: resolvedAssets[index].path })),
         input

+ 3 - 6
cli/src/commands/content/updateChannelCategory.ts

@@ -26,15 +26,12 @@ export default class UpdateChannelCategoryCommand extends ContentDirectoryComman
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { context, input } = this.parse(UpdateChannelCategoryCommand).flags
 
     const { channelCategoryId } = this.parse(UpdateChannelCategoryCommand).args
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
     const channelCategoryInput = await getInputJson<ChannelCategoryInputParameters>(input, ChannelCategoryInputSchema)
     const meta = asValidatedMetadata(ChannelCategoryMetadata, channelCategoryInput)
@@ -47,7 +44,7 @@ export default class UpdateChannelCategoryCommand extends ContentDirectoryComman
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateChannelCategory', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateChannelCategory', [
       actor,
       channelCategoryId,
       channelCategoryUpdateParameters,

+ 3 - 7
cli/src/commands/content/updateChannelCensorshipStatus.ts

@@ -26,18 +26,14 @@ export default class UpdateChannelCensorshipStatusCommand extends ContentDirecto
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     let {
       args: { id, status },
       flags: { rationale },
     } = this.parse(UpdateChannelCensorshipStatusCommand)
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-
     const channel = await this.getApi().channelById(id)
-    const actor = await this.getCurationActorByChannel(channel)
-
-    await this.requestAccountDecoding(currentAccount)
+    const [actor, address] = await this.getCurationActorByChannel(channel)
 
     if (status === undefined) {
       status = await this.simplePrompt({
@@ -63,7 +59,7 @@ export default class UpdateChannelCensorshipStatusCommand extends ContentDirecto
       })) as string
     }
 
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateChannelCensorshipStatus', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateChannelCensorshipStatus', [
       actor,
       id,
       status,

+ 19 - 16
cli/src/commands/content/updateVideo.ts

@@ -11,6 +11,7 @@ import { DataObjectInfoFragment } from '../../graphql/generated/queries'
 import BN from 'bn.js'
 import { formatBalance } from '@polkadot/util'
 import chalk from 'chalk'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 
 export default class UpdateVideoCommand extends UploadCommandBase {
   static description = 'Update video under specific id.'
@@ -20,6 +21,7 @@ export default class UpdateVideoCommand extends UploadCommandBase {
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    context: ContentDirectoryCommandBase.channelManagementContextFlag,
   }
 
   static args = [
@@ -57,35 +59,36 @@ export default class UpdateVideoCommand extends UploadCommandBase {
     return assetsToRemove.map((a) => a.id)
   }
 
-  async run() {
+  async run(): Promise<void> {
     const {
-      flags: { input },
+      flags: { input, context },
       args: { videoId },
     } = this.parse(UpdateVideoCommand)
 
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const video = await this.getApi().videoById(videoId)
     const channel = await this.getApi().channelById(video.in_channel.toNumber())
-    const actor = await this.getChannelOwnerActor(channel)
-    const memberId = await this.getRequiredMemberId(true)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
+    const [memberId] = await this.getRequiredMemberContext(true)
+    const keypair = await this.getDecodedPair(address)
 
     const videoInput = await getInputJson<VideoInputParameters>(input, VideoInputSchema)
     const meta = asValidatedMetadata(VideoMetadata, videoInput)
 
     const { videoPath, thumbnailPhotoPath } = videoInput
-    const inputPaths = [videoPath, thumbnailPhotoPath].filter((p) => p !== undefined) as string[]
-    const resolvedAssets = await this.resolveAndValidateAssets(inputPaths, input)
-    // Set assets indexes in the metadata
-    const [videoIndex, thumbnailPhotoIndex] = this.assetsIndexes([videoPath, thumbnailPhotoPath], inputPaths)
+    const [resolvedAssets, assetIndices] = await this.resolveAndValidateAssets({ videoPath, thumbnailPhotoPath }, input)
+    // Set assets indices in the metadata
     // "undefined" values will be omitted when the metadata is encoded. It's not possible to "unset" an asset this way.
-    meta.video = videoIndex
-    meta.thumbnailPhoto = thumbnailPhotoIndex
+    meta.video = assetIndices.videoPath
+    meta.thumbnailPhoto = assetIndices.thumbnailPhotoPath
 
     // Preare and send the extrinsic
     const assetsToUpload = await this.prepareAssetsForExtrinsic(resolvedAssets)
-    const assetsToRemove = await this.getAssetsToRemove(videoId, videoIndex, thumbnailPhotoIndex)
+    const assetsToRemove = await this.getAssetsToRemove(
+      videoId,
+      assetIndices.videoPath,
+      assetIndices.thumbnailPhotoPath
+    )
     const videoUpdateParameters: CreateInterface<VideoUpdateParameters> = {
       assets_to_upload: assetsToUpload,
       new_meta: metadataToBytes(VideoMetadata, meta),
@@ -98,7 +101,7 @@ export default class UpdateVideoCommand extends UploadCommandBase {
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(account, 'content', 'updateVideo', [
+    const result = await this.sendAndFollowNamedTx(keypair, 'content', 'updateVideo', [
       actor,
       videoId,
       videoUpdateParameters,
@@ -107,8 +110,8 @@ export default class UpdateVideoCommand extends UploadCommandBase {
     if (dataObjectsUploadedEvent) {
       const [objectIds] = dataObjectsUploadedEvent.data
       await this.uploadAssets(
-        account,
-        memberId,
+        keypair,
+        memberId.toNumber(),
         `dynamic:channel:${video.in_channel.toString()}`,
         objectIds.map((id, index) => ({ dataObjectId: id, path: resolvedAssets[index].path })),
         input

+ 3 - 6
cli/src/commands/content/updateVideoCategory.ts

@@ -27,15 +27,12 @@ export default class UpdateVideoCategoryCommand extends ContentDirectoryCommandB
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { context, input } = this.parse(UpdateVideoCategoryCommand).flags
 
     const { videoCategoryId } = this.parse(UpdateVideoCategoryCommand).args
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
     const videoCategoryInput = await getInputJson<VideoCategoryInputParameters>(input, VideoCategoryInputSchema)
     const meta = asValidatedMetadata(VideoCategoryMetadata, videoCategoryInput)
@@ -48,7 +45,7 @@ export default class UpdateVideoCategoryCommand extends ContentDirectoryCommandB
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateVideoCategory', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateVideoCategory', [
       actor,
       videoCategoryId,
       videoCategoryUpdateParameters,

+ 3 - 7
cli/src/commands/content/updateVideoCensorshipStatus.ts

@@ -26,19 +26,15 @@ export default class UpdateVideoCensorshipStatusCommand extends ContentDirectory
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     let {
       args: { id, status },
       flags: { rationale },
     } = this.parse(UpdateVideoCensorshipStatusCommand)
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-
     const video = await this.getApi().videoById(id)
     const channel = await this.getApi().channelById(video.in_channel.toNumber())
-    const actor = await this.getCurationActorByChannel(channel)
-
-    await this.requestAccountDecoding(currentAccount)
+    const [actor, address] = await this.getCurationActorByChannel(channel)
 
     if (status === undefined) {
       status = await this.simplePrompt({
@@ -64,7 +60,7 @@ export default class UpdateVideoCensorshipStatusCommand extends ContentDirectory
       })) as string
     }
 
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateVideoCensorshipStatus', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateVideoCensorshipStatus', [
       actor,
       id,
       status,

+ 1 - 1
cli/src/commands/content/video.ts

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

+ 1 - 1
cli/src/commands/content/videos.ts

@@ -13,7 +13,7 @@ export default class VideosCommand extends ContentDirectoryCommandBase {
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { channelId } = this.parse(VideosCommand).args
 
     let videos: [VideoId, Video][] = await this.getApi().availableVideos()

+ 3 - 5
cli/src/commands/working-groups/createOpening.ts

@@ -161,11 +161,9 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     return [openingJson, hrtJson]
   }
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
+  async run(): Promise<void> {
     // lead-only gate
-    const lead = await this.getRequiredLead()
-    await this.requestAccountDecoding(account) // Prompt for password
+    const lead = await this.getRequiredLeadContext()
 
     const {
       flags: { input, output, edit, dryRun },
@@ -218,7 +216,7 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
       this.log(chalk.magentaBright('Sending the extrinsic...'))
       try {
         await this.sendAndFollowTx(
-          account,
+          await this.getDecodedPair(lead.roleAccount),
           this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...txParams)
         )
         this.log(chalk.green('Opening successfully created!'))

+ 8 - 6
cli/src/commands/working-groups/decreaseWorkerStake.ts

@@ -23,12 +23,11 @@ export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsComma
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsDecreaseWorkerStake)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const workerId = parseInt(args.workerId)
     const groupMember = await this.getWorkerWithStakeForLeadAction(workerId)
@@ -40,9 +39,12 @@ export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsComma
       createParamOptions('amount', undefined, balanceValidator)
     )) as Balance
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'decreaseStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'decreaseStake',
+      [workerId, balance]
+    )
 
     this.log(
       chalk.green(

+ 8 - 11
cli/src/commands/working-groups/evictWorker.ts

@@ -19,12 +19,10 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsEvictWorker)
 
-    const account = await this.getRequiredSelectedAccount()
-    // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const workerId = parseInt(args.workerId)
     // This will also make sure the worker is valid
@@ -40,13 +38,12 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
         })
       : false
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateRole', [
-      workerId,
-      rationale,
-      shouldSlash,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'terminateRole',
+      [workerId, rationale, shouldSlash]
+    )
 
     this.log(chalk.green(`Worker ${chalk.magentaBright(workerId)} has been evicted!`))
     if (shouldSlash) {

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

@@ -18,12 +18,11 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsFillOpening)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const openingId = parseInt(args.wgOpeningId)
     const opening = await this.getOpeningForLeadAction(openingId, OpeningStatus.InReview)
@@ -31,13 +30,12 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
     const applicationIds = await this.promptForApplicationsToAccept(opening)
     const rewardPolicyOpt = await this.promptForParam(`Option<RewardPolicy>`, createParamOptions('RewardPolicy'))
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'fillOpening', [
-      openingId,
-      createType('BTreeSet<ApplicationId>', applicationIds),
-      rewardPolicyOpt,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'fillOpening',
+      [openingId, createType('BTreeSet<ApplicationId>', applicationIds), rewardPolicyOpt]
+    )
 
     this.log(chalk.green(`Opening ${chalk.magentaBright(openingId)} successfully filled!`))
     this.log(

+ 8 - 6
cli/src/commands/working-groups/increaseStake.ts

@@ -13,10 +13,9 @@ export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
+  async run(): Promise<void> {
     // Worker-only gate
-    const worker = await this.getRequiredWorker()
+    const worker = await this.getRequiredWorkerContext()
 
     if (!worker.stake) {
       this.error('Cannot increase stake. No associated role stake profile found!', { exit: ExitCodes.InvalidInput })
@@ -28,9 +27,12 @@ export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase
       createParamOptions('amount', undefined, positiveInt())
     )) as Balance
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'increaseStake', [worker.workerId, balance])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount),
+      apiModuleByGroup[this.group],
+      'increaseStake',
+      [worker.workerId, balance]
+    )
 
     this.log(
       chalk.green(

+ 8 - 6
cli/src/commands/working-groups/leaveRole.ts

@@ -11,10 +11,9 @@ export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
+  async run(): Promise<void> {
     // Worker-only gate
-    const worker = await this.getRequiredWorker()
+    const worker = await this.getRequiredWorkerContext()
 
     const constraint = await this.getApi().workerExitRationaleConstraint(this.group)
     const rationaleValidator = minMaxStr(constraint.min.toNumber(), constraint.max.toNumber())
@@ -23,9 +22,12 @@ export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
       createParamOptions('rationale', undefined, rationaleValidator)
     )) as Bytes
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'leaveRole', [worker.workerId, rationale])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount),
+      apiModuleByGroup[this.group],
+      'leaveRole',
+      [worker.workerId, rationale]
+    )
 
     this.log(chalk.green(`Successfully left the role! (worker id: ${chalk.magentaBright(worker.workerId.toNumber())})`))
   }

+ 8 - 6
cli/src/commands/working-groups/slashWorker.ts

@@ -20,12 +20,11 @@ export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsSlashWorker)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const workerId = parseInt(args.workerId)
     const groupMember = await this.getWorkerWithStakeForLeadAction(workerId)
@@ -37,9 +36,12 @@ export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
       createParamOptions('amount', undefined, balanceValidator)
     )) as Balance
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'slashStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'slashStake',
+      [workerId, balance]
+    )
 
     this.log(
       chalk.green(

+ 8 - 6
cli/src/commands/working-groups/startAcceptingApplications.ts

@@ -17,19 +17,21 @@ export default class WorkingGroupsStartAcceptingApplications extends WorkingGrou
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsStartAcceptingApplications)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const openingId = parseInt(args.wgOpeningId)
     await this.validateOpeningForLeadAction(openingId, OpeningStatus.WaitingToBegin)
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'acceptApplications', [openingId])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'acceptApplications',
+      [openingId]
+    )
 
     this.log(
       chalk.green(

+ 8 - 6
cli/src/commands/working-groups/startReviewPeriod.ts

@@ -17,19 +17,21 @@ export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommand
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsStartReviewPeriod)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const openingId = parseInt(args.wgOpeningId)
     await this.validateOpeningForLeadAction(openingId, OpeningStatus.AcceptingApplications)
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'beginApplicantReview', [openingId])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'beginApplicantReview',
+      [openingId]
+    )
 
     this.log(
       chalk.green(`Opening ${chalk.magentaBright(openingId)} status changed to: ${chalk.magentaBright('In Review')}`)

+ 8 - 6
cli/src/commands/working-groups/terminateApplication.ts

@@ -17,20 +17,22 @@ export default class WorkingGroupsTerminateApplication extends WorkingGroupsComm
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsTerminateApplication)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const applicationId = parseInt(args.wgApplicationId)
     // We don't really need the application itself here, so this one is just for validation purposes
     await this.getApplicationForLeadAction(applicationId, ApplicationStageKeys.Active)
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateApplication', [applicationId])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'terminateApplication',
+      [applicationId]
+    )
 
     this.log(chalk.green(`Application ${chalk.magentaBright(applicationId)} has been successfully terminated!`))
   }

+ 16 - 18
cli/src/commands/working-groups/updateRewardAccount.ts

@@ -8,9 +8,9 @@ export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsComma
   static description = 'Updates the worker/lead reward account (requires current role account to be selected)'
   static args = [
     {
-      name: 'accountAddress',
+      name: 'address',
       required: false,
-      description: 'New reward account address (if omitted, one of the existing CLI accounts can be selected)',
+      description: 'New reward account address (if omitted, can be provided interactivel)',
     },
   ]
 
@@ -18,31 +18,29 @@ export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsComma
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const { args } = this.parse(WorkingGroupsUpdateRewardAccount)
+  async run(): Promise<void> {
+    let { address } = this.parse(WorkingGroupsUpdateRewardAccount).args
 
-    const account = await this.getRequiredSelectedAccount()
     // Worker-only gate
-    const worker = await this.getRequiredWorker()
+    const worker = await this.getRequiredWorkerContext()
 
     if (!worker.reward) {
       this.error('There is no reward relationship associated with this role!', { exit: ExitCodes.InvalidInput })
     }
 
-    let newRewardAccount: string = args.accountAddress
-    if (!newRewardAccount) {
-      const accounts = await this.fetchAccounts()
-      newRewardAccount = (await this.promptForAccount(accounts, undefined, 'Choose the new reward account')).address
+    if (!address) {
+      address = await this.promptForAnyAddress('Select new reward account')
+    } else if (validateAddress(address) !== true) {
+      this.error('Invalid address', { exit: ExitCodes.InvalidInput })
     }
-    validateAddress(newRewardAccount)
 
-    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount),
+      apiModuleByGroup[this.group],
+      'updateRewardAccount',
+      [worker.workerId, address]
+    )
 
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
-      worker.workerId,
-      newRewardAccount,
-    ])
-
-    this.log(chalk.green(`Successfully updated the reward account to: ${chalk.magentaBright(newRewardAccount)})`))
+    this.log(chalk.green(`Successfully updated the reward account to: ${chalk.magentaBright(address)})`))
   }
 }

+ 17 - 34
cli/src/commands/working-groups/updateRoleAccount.ts

@@ -2,14 +2,15 @@ import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { apiModuleByGroup } from '../../Api'
 import { validateAddress } from '../../helpers/validation'
 import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
 
 export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommandBase {
   static description = 'Updates the worker/lead role account. Requires member controller account to be selected'
   static args = [
     {
-      name: 'accountAddress',
+      name: 'address',
       required: false,
-      description: 'New role account address (if omitted, one of the existing CLI accounts can be selected)',
+      description: 'New role account address (if omitted, can be provided interactively)',
     },
   ]
 
@@ -17,42 +18,24 @@ export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommand
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const { args } = this.parse(WorkingGroupsUpdateRoleAccount)
+  async run(): Promise<void> {
+    let { address } = this.parse(WorkingGroupsUpdateRoleAccount).args
 
-    const account = await this.getRequiredSelectedAccount()
-    const worker = await this.getRequiredWorkerByMemberController()
+    const worker = await this.getRequiredWorkerContext('MemberController')
 
-    const cliAccounts = await this.fetchAccounts()
-    let newRoleAccount: string = args.accountAddress
-    if (!newRoleAccount) {
-      newRoleAccount = (await this.promptForAccount(cliAccounts, undefined, 'Choose the new role account')).address
+    if (!address) {
+      address = await this.promptForAnyAddress('Select new role account')
+    } else if (validateAddress(address) !== true) {
+      this.error('Invalid address', { exit: ExitCodes.InvalidInput })
     }
-    validateAddress(newRoleAccount)
 
-    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.profile.controller_account),
+      apiModuleByGroup[this.group],
+      'updateRoleAccount',
+      [worker.workerId, address]
+    )
 
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
-      worker.workerId,
-      newRoleAccount,
-    ])
-
-    this.log(chalk.green(`Successfully updated the role account to: ${chalk.magentaBright(newRoleAccount)})`))
-
-    const matchingAccount = cliAccounts.find((account) => account.address === newRoleAccount)
-    if (matchingAccount) {
-      const switchAccount = await this.simplePrompt({
-        type: 'confirm',
-        message: 'Do you want to switch the currenly selected CLI account to the new role account?',
-        default: false,
-      })
-      if (switchAccount) {
-        await this.setSelectedAccount(matchingAccount)
-        this.log(
-          chalk.green('Account switched to: ') +
-            chalk.magentaBright(`${matchingAccount.meta.name} (${matchingAccount.address})`)
-        )
-      }
-    }
+    this.log(chalk.green(`Successfully updated the role account to: ${chalk.magentaBright(address)})`))
   }
 }

+ 8 - 11
cli/src/commands/working-groups/updateRoleStorage.ts

@@ -16,20 +16,17 @@ export default class WorkingGroupsUpdateRoleStorage extends WorkingGroupsCommand
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { storage } = this.parse(WorkingGroupsUpdateRoleStorage).args
 
-    const account = await this.getRequiredSelectedAccount()
+    const worker = await this.getRequiredWorkerContext()
 
-    // Worker-only gate
-    const worker = await this.getRequiredWorker()
-
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRoleStorage', [
-      worker.workerId,
-      storage,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount),
+      apiModuleByGroup[this.group],
+      'updateRoleStorage',
+      [worker.workerId, storage]
+    )
 
     this.log(chalk.green(`Successfully updated the associated worker storage to: ${chalk.magentaBright(storage)})`))
   }

+ 9 - 11
cli/src/commands/working-groups/updateWorkerReward.ts

@@ -22,7 +22,7 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
     ...WorkingGroupsCommandBase.flags,
   }
 
-  formatReward(reward?: Reward) {
+  formatReward(reward?: Reward): string {
     return reward
       ? formatBalance(reward.value) +
           (reward.interval ? ` / ${reward.interval} block(s)` : '') +
@@ -30,12 +30,10 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
       : 'NONE'
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsUpdateWorkerReward)
 
-    const account = await this.getRequiredSelectedAccount()
-    // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const workerId = parseInt(args.workerId)
     // This will also make sure the worker is valid
@@ -54,12 +52,12 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
       createParamOptions('new_amount', undefined, positiveInt())
     )) as BalanceOfMint
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAmount', [
-      workerId,
-      newRewardValue,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'updateRewardAmount',
+      [workerId, newRewardValue]
+    )
 
     const updatedGroupMember = await this.getApi().groupMember(this.group, workerId)
     this.log(chalk.green(`Worker ${chalk.magentaBright(workerId)} reward has been updated!`))

+ 4 - 4
cli/src/graphql/generated/queries.ts

@@ -30,11 +30,11 @@ export type GetDataObjectsByBagIdQueryVariables = Types.Exact<{
 
 export type GetDataObjectsByBagIdQuery = { storageDataObjects: Array<DataObjectInfoFragment> }
 
-export type GetDataObjectsChannelIdQueryVariables = Types.Exact<{
+export type GetDataObjectsByChannelIdQueryVariables = Types.Exact<{
   channelId?: Types.Maybe<Types.Scalars['ID']>
 }>
 
-export type GetDataObjectsChannelIdQuery = { storageDataObjects: Array<DataObjectInfoFragment> }
+export type GetDataObjectsByChannelIdQuery = { storageDataObjects: Array<DataObjectInfoFragment> }
 
 export type GetDataObjectsByVideoIdQueryVariables = Types.Exact<{
   videoId?: Types.Maybe<Types.Scalars['ID']>
@@ -102,8 +102,8 @@ export const GetDataObjectsByBagId = gql`
   }
   ${DataObjectInfo}
 `
-export const GetDataObjectsChannelId = gql`
-  query getDataObjectsChannelId($channelId: ID) {
+export const GetDataObjectsByChannelId = gql`
+  query getDataObjectsByChannelId($channelId: ID) {
     storageDataObjects(where: { type_json: { channelId_eq: $channelId } }) {
       ...DataObjectInfo
     }

+ 21 - 11
cli/src/graphql/generated/schema.ts

@@ -91,8 +91,6 @@ export type Channel = BaseGraphQlObject & {
   categoryId?: Maybe<Scalars['String']>
   /** Reward account where revenue is sent if set. */
   rewardAccount?: Maybe<Scalars['String']>
-  /** Destination account for the prize associated with channel deletion */
-  deletionPrizeDestAccount: Scalars['String']
   /** The title of the Channel */
   title?: Maybe<Scalars['String']>
   /** The description of a Channel */
@@ -108,7 +106,9 @@ export type Channel = BaseGraphQlObject & {
   language?: Maybe<Language>
   languageId?: Maybe<Scalars['String']>
   videos: Array<Video>
+  /** Number of the block the channel was created in */
   createdInBlock: Scalars['Int']
+  collaborators: Array<Membership>
 }
 
 export type ChannelCategoriesByNameFtsOutput = {
@@ -228,7 +228,6 @@ export type ChannelCreateInput = {
   ownerCuratorGroup?: Maybe<Scalars['ID']>
   category?: Maybe<Scalars['ID']>
   rewardAccount?: Maybe<Scalars['String']>
-  deletionPrizeDestAccount: Scalars['String']
   title?: Maybe<Scalars['String']>
   description?: Maybe<Scalars['String']>
   coverPhoto?: Maybe<Scalars['ID']>
@@ -259,8 +258,6 @@ export enum ChannelOrderByInput {
   CategoryDesc = 'category_DESC',
   RewardAccountAsc = 'rewardAccount_ASC',
   RewardAccountDesc = 'rewardAccount_DESC',
-  DeletionPrizeDestAccountAsc = 'deletionPrizeDestAccount_ASC',
-  DeletionPrizeDestAccountDesc = 'deletionPrizeDestAccount_DESC',
   TitleAsc = 'title_ASC',
   TitleDesc = 'title_DESC',
   DescriptionAsc = 'description_ASC',
@@ -284,7 +281,6 @@ export type ChannelUpdateInput = {
   ownerCuratorGroup?: Maybe<Scalars['ID']>
   category?: Maybe<Scalars['ID']>
   rewardAccount?: Maybe<Scalars['String']>
-  deletionPrizeDestAccount?: Maybe<Scalars['String']>
   title?: Maybe<Scalars['String']>
   description?: Maybe<Scalars['String']>
   coverPhoto?: Maybe<Scalars['ID']>
@@ -325,11 +321,6 @@ export type ChannelWhereInput = {
   rewardAccount_startsWith?: Maybe<Scalars['String']>
   rewardAccount_endsWith?: Maybe<Scalars['String']>
   rewardAccount_in?: Maybe<Array<Scalars['String']>>
-  deletionPrizeDestAccount_eq?: Maybe<Scalars['String']>
-  deletionPrizeDestAccount_contains?: Maybe<Scalars['String']>
-  deletionPrizeDestAccount_startsWith?: Maybe<Scalars['String']>
-  deletionPrizeDestAccount_endsWith?: Maybe<Scalars['String']>
-  deletionPrizeDestAccount_in?: Maybe<Array<Scalars['String']>>
   title_eq?: Maybe<Scalars['String']>
   title_contains?: Maybe<Scalars['String']>
   title_startsWith?: Maybe<Scalars['String']>
@@ -359,6 +350,9 @@ export type ChannelWhereInput = {
   videos_none?: Maybe<VideoWhereInput>
   videos_some?: Maybe<VideoWhereInput>
   videos_every?: Maybe<VideoWhereInput>
+  collaborators_none?: Maybe<MembershipWhereInput>
+  collaborators_some?: Maybe<MembershipWhereInput>
+  collaborators_every?: Maybe<MembershipWhereInput>
   AND?: Maybe<Array<ChannelWhereInput>>
   OR?: Maybe<Array<ChannelWhereInput>>
 }
@@ -512,6 +506,8 @@ export type DistributionBucket = BaseGraphQlObject & {
   version: Scalars['Int']
   family: DistributionBucketFamily
   familyId: Scalars['String']
+  /** Bucket index within the family */
+  bucketIndex: Scalars['Int']
   operators: Array<DistributionBucketOperator>
   /** Whether the bucket is accepting any new bags */
   acceptingNewBags: Scalars['Boolean']
@@ -528,6 +524,7 @@ export type DistributionBucketConnection = {
 
 export type DistributionBucketCreateInput = {
   family: Scalars['ID']
+  bucketIndex: Scalars['Float']
   acceptingNewBags: Scalars['Boolean']
   distributing: Scalars['Boolean']
 }
@@ -1028,6 +1025,8 @@ export enum DistributionBucketOrderByInput {
   DeletedAtDesc = 'deletedAt_DESC',
   FamilyAsc = 'family_ASC',
   FamilyDesc = 'family_DESC',
+  BucketIndexAsc = 'bucketIndex_ASC',
+  BucketIndexDesc = 'bucketIndex_DESC',
   AcceptingNewBagsAsc = 'acceptingNewBags_ASC',
   AcceptingNewBagsDesc = 'acceptingNewBags_DESC',
   DistributingAsc = 'distributing_ASC',
@@ -1036,6 +1035,7 @@ export enum DistributionBucketOrderByInput {
 
 export type DistributionBucketUpdateInput = {
   family?: Maybe<Scalars['ID']>
+  bucketIndex?: Maybe<Scalars['Float']>
   acceptingNewBags?: Maybe<Scalars['Boolean']>
   distributing?: Maybe<Scalars['Boolean']>
 }
@@ -1065,6 +1065,12 @@ export type DistributionBucketWhereInput = {
   deletedAt_gte?: Maybe<Scalars['DateTime']>
   deletedById_eq?: Maybe<Scalars['ID']>
   deletedById_in?: Maybe<Array<Scalars['ID']>>
+  bucketIndex_eq?: Maybe<Scalars['Int']>
+  bucketIndex_gt?: Maybe<Scalars['Int']>
+  bucketIndex_gte?: Maybe<Scalars['Int']>
+  bucketIndex_lt?: Maybe<Scalars['Int']>
+  bucketIndex_lte?: Maybe<Scalars['Int']>
+  bucketIndex_in?: Maybe<Array<Scalars['Int']>>
   acceptingNewBags_eq?: Maybe<Scalars['Boolean']>
   acceptingNewBags_in?: Maybe<Array<Scalars['Boolean']>>
   distributing_eq?: Maybe<Scalars['Boolean']>
@@ -1483,6 +1489,7 @@ export type Membership = BaseGraphQlObject & {
   /** The type of subscription the member has purchased if any. */
   subscription?: Maybe<Scalars['Int']>
   channels: Array<Channel>
+  collaboratorInChannels: Array<Channel>
 }
 
 export type MembershipConnection = {
@@ -1616,6 +1623,9 @@ export type MembershipWhereInput = {
   channels_none?: Maybe<ChannelWhereInput>
   channels_some?: Maybe<ChannelWhereInput>
   channels_every?: Maybe<ChannelWhereInput>
+  collaboratorInChannels_none?: Maybe<ChannelWhereInput>
+  collaboratorInChannels_some?: Maybe<ChannelWhereInput>
+  collaboratorInChannels_every?: Maybe<ChannelWhereInput>
   AND?: Maybe<Array<MembershipWhereInput>>
   OR?: Maybe<Array<MembershipWhereInput>>
 }

+ 1 - 1
cli/src/graphql/queries/storage.graphql

@@ -52,7 +52,7 @@ query getDataObjectsByBagId($bagId: ID) {
   }
 }
 
-query getDataObjectsChannelId($channelId: ID) {
+query getDataObjectsByChannelId($channelId: ID) {
   storageDataObjects(where: { type_json: { channelId_eq: $channelId } }) {
     ...DataObjectInfo
   }

+ 2 - 4
cli/src/helpers/JsonSchemaPrompt.ts

@@ -129,15 +129,13 @@ export class JsonSchemaPrompter<JsonResult> {
           confirmed = await this.inquirerSinglePrompt({
             message: `Do you want to provide optional ${chalk.greenBright(objectPropertyPath)}?`,
             type: 'confirm',
-            default:
-              _.get(this.filledObject, objectPropertyPath) !== undefined &&
-              _.get(this.filledObject, objectPropertyPath) !== null,
+            default: _.get(this.filledObject, objectPropertyPath) !== undefined,
           })
         }
         if (confirmed) {
           value[pName] = await this.prompt(pSchema, objectPropertyPath)
         } else {
-          _.set(this.filledObject, objectPropertyPath, null)
+          _.set(this.filledObject, objectPropertyPath, undefined)
         }
       }
       return value

+ 5 - 5
cli/src/helpers/display.ts

@@ -3,7 +3,7 @@ import chalk from 'chalk'
 import { NameValueObj } from '../Types'
 import { AccountId } from '@polkadot/types/interfaces'
 
-export function displayHeader(caption: string, placeholderSign = '_', size = 50) {
+export function displayHeader(caption: string, placeholderSign = '_', size = 50): void {
   const singsPerSide: number = Math.floor((size - (caption.length + 2)) / 2)
   let finalStr = ''
   for (let i = 0; i < singsPerSide; ++i) finalStr += placeholderSign
@@ -13,7 +13,7 @@ export function displayHeader(caption: string, placeholderSign = '_', size = 50)
   process.stdout.write('\n' + chalk.bold.blueBright(finalStr) + '\n\n')
 }
 
-export function displayNameValueTable(rows: NameValueObj[]) {
+export function displayNameValueTable(rows: NameValueObj[]): void {
   cli.table(
     rows,
     {
@@ -24,7 +24,7 @@ export function displayNameValueTable(rows: NameValueObj[]) {
   )
 }
 
-export function displayCollapsedRow(row: { [k: string]: string | number }) {
+export function displayCollapsedRow(row: { [k: string]: string | number }): void {
   const collapsedRow: NameValueObj[] = Object.keys(row).map((name) => ({
     name,
     value: typeof row[name] === 'string' ? (row[name] as string) : row[name].toString(),
@@ -33,11 +33,11 @@ export function displayCollapsedRow(row: { [k: string]: string | number }) {
   displayNameValueTable(collapsedRow)
 }
 
-export function displayCollapsedTable(rows: { [k: string]: string | number }[]) {
+export function displayCollapsedTable(rows: { [k: string]: string | number }[]): void {
   for (const row of rows) displayCollapsedRow(row)
 }
 
-export function displayTable(rows: { [k: string]: string | number }[], cellHorizontalPadding = 0) {
+export function displayTable(rows: { [k: string]: string | number }[], cellHorizontalPadding = 0): void {
   if (!rows.length) {
     return
   }

+ 2 - 2
cli/src/helpers/serialization.ts

@@ -1,12 +1,12 @@
 import { AnyMetadataClass, DecodedMetadataObject } from '@joystream/metadata-protobuf/types'
 import { Bytes } from '@polkadot/types/primitive'
-import { createTypeFromConstructor } from '@joystream/types'
+import { createType } from '@joystream/types'
 import { CLIError } from '@oclif/errors'
 import ExitCodes from '../ExitCodes'
 import { metaToObject } from '@joystream/metadata-protobuf/utils'
 
 export function metadataToBytes<T>(metaClass: AnyMetadataClass<T>, obj: T): Bytes {
-  return createTypeFromConstructor(Bytes, '0x' + Buffer.from(metaClass.encode(obj).finish()).toString('hex'))
+  return createType('Bytes', '0x' + Buffer.from(metaClass.encode(obj).finish()).toString('hex'))
 }
 
 export function metadataFromBytes<T>(metaClass: AnyMetadataClass<T>, bytes: Bytes): DecodedMetadataObject<T> {

+ 9 - 2
cli/src/helpers/validation.ts

@@ -4,12 +4,14 @@ import { decodeAddress } from '@polkadot/util-crypto'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { CLIError } from '@oclif/errors'
 
-export function validateAddress(address: string, errorMessage = 'Invalid address'): void {
+export function validateAddress(address: string, errorMessage = 'Invalid address'): string | true {
   try {
     decodeAddress(address)
   } catch (e) {
-    throw new CLIError(errorMessage, { exit: ExitCodes.InvalidInput })
+    return errorMessage
   }
+
+  return true
 }
 
 export function checkBalance(accBalances: DeriveBalancesAll, requiredBalance: BN): void {
@@ -17,3 +19,8 @@ export function checkBalance(accBalances: DeriveBalancesAll, requiredBalance: BN
     throw new CLIError('Not enough balance available', { exit: ExitCodes.InvalidInput })
   }
 }
+
+// We assume balance to be u128, which is bigger than JavaScript integer
+export function isValidBalance(balance: string): boolean {
+  return /^[1-9][0-9]{0,37}$/.test(balance)
+}

+ 7 - 0
cli/src/schemas/ContentDirectory.ts

@@ -30,6 +30,13 @@ export const ChannelInputSchema: JsonSchema<ChannelInputParameters> = {
     coverPhotoPath: { type: 'string' },
     avatarPhotoPath: { type: 'string' },
     rewardAccount: { type: ['string', 'null'] },
+    collaborators: {
+      type: ['array', 'null'],
+      items: {
+        type: 'integer',
+        min: 0,
+      },
+    },
   },
 }
 

+ 5 - 0
devops/kubernetes/argus/.gitignore

@@ -0,0 +1,5 @@
+/bin/
+/node_modules/
+kubeconfig*
+package-lock.json
+Pulumi.*.yaml

+ 35 - 0
devops/kubernetes/argus/Pulumi.yaml

@@ -0,0 +1,35 @@
+name: argus
+runtime: nodejs
+description: A Pulumi program to deploy Argus node
+template:
+  config:
+    aws:profile:
+      default: joystream-user
+    aws:region:
+      default: us-east-1
+    isLoadBalancerReady:
+      description: Whether the load balancer service is ready and has been assigned an IP
+      default: false
+    queryNodeHost:
+      description: Query node GraphQL endpoint
+      default: 'https://hydra.joystream.org/graphql'
+    wsProviderEndpointURI:
+      description: Chain RPC endpoint
+      default: 'wss://rome-rpc-endpoint.joystream.org:9944/'
+    argusImage:
+      description: The distributor node image to use for running the node
+    keys:
+      description: Specifies the keys available within distributor node CLI
+    buckets:
+      description: Specifies the buckets distributed by the node
+    workerId:
+      description: ID of the node operator (distribution working group worker)
+    dataStorage:
+      description: Amount of storage (in Gi) assigned for the data directory
+      default: 10
+    logStorage:
+      description: Amount of storage (in Gi) assigned for the logs directory
+      default: 2
+    cacheStorage:
+      description: Amount of storage (in Gi) assigned for the cache directory
+      default: 10

+ 123 - 0
devops/kubernetes/argus/README.md

@@ -0,0 +1,123 @@
+# Argus deployment on Minikube or EKS
+
+This project deploys an Argus node on an EKS or a minikube cluster
+
+## Deploying the App
+
+To deploy your infrastructure, follow the below steps.
+
+### Prerequisites
+
+1. [Install Pulumi](https://www.pulumi.com/docs/get-started/install/)
+1. [Install Node.js](https://nodejs.org/en/download/)
+1. Install a package manager for Node.js, such as [npm](https://www.npmjs.com/get-npm) or [Yarn](https://yarnpkg.com/en/docs/install).
+1. [Configure AWS Credentials](https://www.pulumi.com/docs/intro/cloud-providers/aws/setup/)
+1. Optional (for debugging): [Install kubectl](https://kubernetes.io/docs/tasks/tools/)
+
+### Steps
+
+After cloning this repo, from this working directory, run these commands:
+
+1. Install the required Node.js packages:
+
+   This installs the dependent packages [needed](https://www.pulumi.com/docs/intro/concepts/how-pulumi-works/) for our Pulumi program.
+
+   ```bash
+   $ npm install
+   ```
+
+1. Create a new stack, which is an isolated deployment target for this example:
+
+   This will initialize the Pulumi program in TypeScript.
+
+   ```bash
+   $ pulumi stack init
+   ```
+
+1. Set the required configuration variables in `Pulumi.<stack>.yaml`
+
+   ```bash
+   $ pulumi config set-all --plaintext aws:region=us-east-1 --plaintext aws:profile=joystream-user \
+    --plaintext queryNodeHost='https://34.197.252.42.nip.io/server/graphql' --plaintext isMinikube=true \
+    --plaintext wsProviderEndpointURI='wss://rome-rpc-endpoint.joystream.org:9944/' \
+    --plaintext argusImage='joystream/distributor-node:latest' \
+    --plaintext keys='[{ "suri": "//Alice" }]' --plaintext buckets='["1:0","1:1"]' --plaintext workerId=0
+   ```
+
+   If you want to build the stack on AWS set the `isMinikube` config to `false`
+
+   ```bash
+   $ pulumi config set isMinikube false
+   ```
+
+1. Stand up the EKS cluster:
+
+   Running `pulumi up -y` will deploy the EKS cluster. Note, provisioning a
+   new EKS cluster takes between 10-15 minutes.
+
+1. If you are using Minikube, run `minikube service argus-node -n $(pulumi stack output namespaceName)`
+
+   This will setup a proxy for your `argus-node` service, which can then be accessed at
+   the URL given in the output
+
+1. Once the stack if up and running, we will modify the Caddy config to get SSL certificate for the load balancer
+
+   Modify the config variable `isLoadBalancerReady`
+
+   ```bash
+   $ pulumi config set isLoadBalancerReady true
+   ```
+
+   Run `pulumi up -y` to update the Caddy config
+
+1. Access the Kubernetes Cluster using `kubectl`
+
+   To access your new Kubernetes cluster using `kubectl`, we need to set up the
+   `kubeconfig` file and download `kubectl`. We can leverage the Pulumi
+   stack output in the CLI, as Pulumi facilitates exporting these objects for us.
+
+   ```bash
+   $ pulumi stack output kubeconfig --show-secrets > kubeconfig
+   $ export KUBECONFIG=$PWD/kubeconfig
+   $ kubectl get nodes
+   ```
+
+   We can also use the stack output to query the cluster for our newly created Deployment:
+
+   ```bash
+   $ kubectl get deployment $(pulumi stack output deploymentName) --namespace=$(pulumi stack output namespaceName)
+   $ kubectl get service $(pulumi stack output serviceName) --namespace=$(pulumi stack output namespaceName)
+   ```
+
+   To get logs
+
+   ```bash
+   $ kubectl config set-context --current --namespace=$(pulumi stack output namespaceName)
+   $ kubectl get pods
+   $ kubectl logs <PODNAME> --all-containers
+   ```
+
+   To run a command on a pod
+
+   ```bash
+   $ kubectl exec ${POD_NAME} -c ${CONTAINER_NAME} -- ${CMD} ${ARG1}
+   ```
+
+   To see complete pulumi stack output
+
+   ```bash
+   $ pulumi stack output
+   ```
+
+   To execute a command
+
+   ```bash
+   $ kubectl exec --stdin --tty <PODNAME> -c colossus -- /bin/bash
+   ```
+
+1. Once you've finished experimenting, tear down your stack's resources by destroying and removing it:
+
+   ```bash
+   $ pulumi destroy --yes
+   $ pulumi stack rm --yes
+   ```

+ 5 - 0
devops/kubernetes/argus/docker_dummy/Dockerfile

@@ -0,0 +1,5 @@
+# Since Pulumi does not support push without a build
+# we build an image from an existing local image
+ARG SOURCE_IMAGE
+
+FROM --platform=linux/amd64 ${SOURCE_IMAGE}

+ 229 - 0
devops/kubernetes/argus/index.ts

@@ -0,0 +1,229 @@
+import * as awsx from '@pulumi/awsx'
+import * as aws from '@pulumi/aws'
+import * as eks from '@pulumi/eks'
+import * as docker from '@pulumi/docker'
+import * as k8s from '@pulumi/kubernetes'
+import * as pulumi from '@pulumi/pulumi'
+import { CaddyServiceDeployment, CustomPersistentVolume } from 'pulumi-common'
+
+const awsConfig = new pulumi.Config('aws')
+const config = new pulumi.Config()
+
+const queryNodeHost = config.require('queryNodeHost')
+const wsProviderEndpointURI = config.require('wsProviderEndpointURI')
+const configArgusImage = config.require('argusImage')
+const lbReady = config.get('isLoadBalancerReady') === 'true'
+const keys = config.require('keys')
+const buckets = config.require('buckets')
+const workerId = config.require('workerId')
+const name = 'argus-node'
+const isMinikube = config.getBoolean('isMinikube')
+const dataStorage = config.getNumber('dataStorage') || 10
+const logStorage = config.getNumber('logStorage') || 2
+const cacheStorage = config.getNumber('cacheStorage') || 10
+
+export let kubeconfig: pulumi.Output<any>
+export let argusImage: pulumi.Output<string> = pulumi.interpolate`${configArgusImage}`
+let provider: k8s.Provider
+
+if (isMinikube) {
+  provider = new k8s.Provider('local', {})
+} else {
+  // Create a VPC for our cluster.
+  const vpc = new awsx.ec2.Vpc('argus-vpc', { numberOfAvailabilityZones: 2, numberOfNatGateways: 1 })
+
+  // Create an EKS cluster with the default configuration.
+  const cluster = new eks.Cluster('eksctl-argus-node', {
+    vpcId: vpc.id,
+    subnetIds: vpc.publicSubnetIds,
+    desiredCapacity: 2,
+    maxSize: 2,
+    instanceType: 't2.medium',
+    providerCredentialOpts: {
+      profileName: awsConfig.get('profile'),
+    },
+  })
+  provider = cluster.provider
+
+  // Export the cluster's kubeconfig.
+  kubeconfig = cluster.kubeconfig
+
+  // Create a repository
+  const repo = new awsx.ecr.Repository('distributor-node')
+
+  // Build an image and publish it to our ECR repository.
+  argusImage = repo.buildAndPushImage({
+    context: './docker_dummy',
+    dockerfile: './docker_dummy/Dockerfile',
+    args: { SOURCE_IMAGE: argusImage! },
+  })
+
+  // Uncomment the below line to use an existing image
+  // argusImage = pulumi.interpolate`ahhda/distributor-node:latest`
+}
+
+const resourceOptions = { provider: provider }
+
+// Create a Kubernetes Namespace
+const ns = new k8s.core.v1.Namespace(name, {}, resourceOptions)
+
+// Export the Namespace name
+export const namespaceName = ns.metadata.name
+
+const appLabels = { appClass: name }
+
+const dataPVC = new CustomPersistentVolume(
+  'data',
+  { namespaceName: namespaceName, storage: dataStorage },
+  resourceOptions
+)
+const logsPVC = new CustomPersistentVolume(
+  'logs',
+  { namespaceName: namespaceName, storage: logStorage },
+  resourceOptions
+)
+const cachePVC = new CustomPersistentVolume(
+  'cache',
+  { namespaceName: namespaceName, storage: cacheStorage },
+  resourceOptions
+)
+
+// Create a Deployment
+const deployment = new k8s.apps.v1.Deployment(
+  name,
+  {
+    metadata: {
+      namespace: namespaceName,
+      labels: appLabels,
+    },
+    spec: {
+      replicas: 1,
+      selector: { matchLabels: appLabels },
+      template: {
+        metadata: {
+          labels: appLabels,
+        },
+        spec: {
+          containers: [
+            {
+              name: 'argus',
+              image: argusImage,
+              imagePullPolicy: 'IfNotPresent',
+              workingDir: '/joystream/distributor-node',
+              env: [
+                {
+                  name: 'JOYSTREAM_DISTRIBUTOR__ENDPOINTS__QUERY_NODE',
+                  value: queryNodeHost,
+                },
+                {
+                  name: 'JOYSTREAM_DISTRIBUTOR__ENDPOINTS__JOYSTREAM_NODE_WS',
+                  value: wsProviderEndpointURI,
+                },
+                {
+                  name: 'JOYSTREAM_DISTRIBUTOR__KEYS',
+                  value: keys,
+                },
+                {
+                  name: 'JOYSTREAM_DISTRIBUTOR__BUCKETS',
+                  value: buckets,
+                },
+                {
+                  name: 'JOYSTREAM_DISTRIBUTOR__WORKER_ID',
+                  value: workerId,
+                },
+                {
+                  name: 'JOYSTREAM_DISTRIBUTOR__PORT',
+                  value: '3334',
+                },
+              ],
+              args: ['start'],
+              ports: [{ containerPort: 3334 }],
+              volumeMounts: [
+                {
+                  name: 'data',
+                  mountPath: '/data',
+                  subPath: 'data',
+                },
+                {
+                  name: 'logs',
+                  mountPath: '/logs',
+                  subPath: 'logs',
+                },
+                {
+                  name: 'cache',
+                  mountPath: '/cache',
+                  subPath: 'cache',
+                },
+              ],
+            },
+          ],
+          volumes: [
+            {
+              name: 'data',
+              persistentVolumeClaim: {
+                claimName: dataPVC.pvc.metadata.name,
+              },
+            },
+            {
+              name: 'logs',
+              persistentVolumeClaim: {
+                claimName: logsPVC.pvc.metadata.name,
+              },
+            },
+            {
+              name: 'cache',
+              persistentVolumeClaim: {
+                claimName: cachePVC.pvc.metadata.name,
+              },
+            },
+          ],
+        },
+      },
+    },
+  },
+  resourceOptions
+)
+
+// Create a LoadBalancer Service for the Deployment
+const service = new k8s.core.v1.Service(
+  name,
+  {
+    metadata: {
+      labels: appLabels,
+      namespace: namespaceName,
+      name: name,
+    },
+    spec: {
+      type: isMinikube ? 'NodePort' : 'ClusterIP',
+      ports: [{ name: 'port-1', port: 3334 }],
+      selector: appLabels,
+    },
+  },
+  resourceOptions
+)
+
+// Export the Service name
+export const serviceName = service.metadata.name
+
+// Export the Deployment name
+export const deploymentName = deployment.metadata.name
+
+export let endpoint1: pulumi.Output<string> = pulumi.interpolate``
+export let endpoint2: pulumi.Output<string> = pulumi.interpolate``
+
+const caddyEndpoints = [
+  ` {
+    reverse_proxy ${name}:3334
+}`,
+]
+
+if (!isMinikube) {
+  const caddy = new CaddyServiceDeployment(
+    'caddy-proxy',
+    { lbReady, namespaceName: namespaceName, caddyEndpoints },
+    resourceOptions
+  )
+
+  endpoint1 = pulumi.interpolate`${caddy.primaryEndpoint}`
+  endpoint2 = pulumi.interpolate`${caddy.secondaryEndpoint}`
+}

+ 15 - 0
devops/kubernetes/argus/package.json

@@ -0,0 +1,15 @@
+{
+  "name": "eks-cluster",
+  "devDependencies": {
+    "@types/node": "^10.0.0"
+  },
+  "dependencies": {
+    "@pulumi/aws": "^4.0.0",
+    "@pulumi/awsx": "^0.30.0",
+    "@pulumi/eks": "^0.31.0",
+    "@pulumi/kubernetes": "^3.0.0",
+    "@pulumi/pulumi": "^3.0.0",
+    "@pulumi/docker": "^3.0.0",
+    "pulumi-common": "file:../pulumi-common"
+  }
+}

+ 18 - 0
devops/kubernetes/argus/tsconfig.json

@@ -0,0 +1,18 @@
+{
+    "compilerOptions": {
+        "strict": true,
+        "outDir": "bin",
+        "target": "es2016",
+        "module": "commonjs",
+        "moduleResolution": "node",
+        "sourceMap": true,
+        "experimentalDecorators": true,
+        "pretty": true,
+        "noFallthroughCasesInSwitch": true,
+        "noImplicitReturns": true,
+        "forceConsistentCasingInFileNames": true
+    },
+    "files": [
+        "index.ts"
+    ]
+}

+ 29 - 0
devops/kubernetes/pulumi-common/configMap.ts

@@ -0,0 +1,29 @@
+import * as pulumi from '@pulumi/pulumi'
+import * as k8s from '@pulumi/kubernetes'
+import * as fs from 'fs'
+
+export class configMapFromFile extends pulumi.ComponentResource {
+  public readonly configName?: pulumi.Output<string>
+
+  constructor(name: string, args: ConfigMapArgs, opts: pulumi.ComponentResourceOptions = {}) {
+    super('pkg:query-node:configMap', name, {}, opts)
+
+    this.configName = new k8s.core.v1.ConfigMap(
+      name,
+      {
+        metadata: {
+          namespace: args.namespaceName,
+        },
+        data: {
+          'fileData': fs.readFileSync(args.filePath).toString(),
+        },
+      },
+      opts
+    ).metadata.apply((m) => m.name)
+  }
+}
+
+export interface ConfigMapArgs {
+  filePath: string
+  namespaceName: pulumi.Output<string>
+}

+ 2 - 0
devops/kubernetes/pulumi-common/index.ts

@@ -1,2 +1,4 @@
 export { CaddyServiceDeployment } from './caddy'
 export { PostgresServiceDeployment } from './database'
+export { configMapFromFile } from './configMap'
+export { CustomPersistentVolume } from './volume'

+ 43 - 0
devops/kubernetes/pulumi-common/volume.ts

@@ -0,0 +1,43 @@
+import * as k8s from '@pulumi/kubernetes'
+import * as pulumi from '@pulumi/pulumi'
+
+/**
+ * This is an abstraction that uses a class to fold together the common pattern of a
+ * Kubernetes Deployment and its associated Service object.
+ * This class creates a Persistent Volume
+ */
+export class CustomPersistentVolume extends pulumi.ComponentResource {
+  public readonly pvc: k8s.core.v1.PersistentVolumeClaim
+
+  constructor(name: string, args: ServiceDeploymentArgs, opts?: pulumi.ComponentResourceOptions) {
+    super('volume:service:CustomPersistentVolume', name, {}, opts)
+
+    const volumeLabels = { app: name }
+    const pvcName = `${name}-pvc`
+
+    this.pvc = new k8s.core.v1.PersistentVolumeClaim(
+      pvcName,
+      {
+        metadata: {
+          labels: volumeLabels,
+          namespace: args.namespaceName,
+          name: pvcName,
+        },
+        spec: {
+          accessModes: ['ReadWriteOnce'],
+          resources: {
+            requests: {
+              storage: `${args.storage}Gi`,
+            },
+          },
+        },
+      },
+      { parent: this }
+    )
+  }
+}
+
+export interface ServiceDeploymentArgs {
+  namespaceName: pulumi.Output<string>
+  storage: Number
+}

+ 9 - 0
devops/kubernetes/query-node/Pulumi.yaml

@@ -25,3 +25,12 @@ template:
     appsImage:
       description: The joystream image to use for running GraphQL servers
       default: joystream/apps:latest
+    dbPassword:
+      description: database password for indexer and processor databases  
+      required: true
+    blockHeight:
+      descroption: Block height to start indexing at
+      default: 0
+    joystreamWsEndpoint:
+      description: Joystream-node websocket endpoint used by indexer
+      required: true

+ 2 - 4
devops/kubernetes/query-node/README.md

@@ -38,6 +38,8 @@ After cloning this repo, from this working directory, run these commands:
 
    ```bash
    $ pulumi config set-all --plaintext aws:region=us-east-1 --plaintext aws:profile=joystream-user \
+    --plaintext dbPassword=password --plaintext blockHeight=0 \
+    --plaintext joystreamWsEndpoint=ws://endpoint.somewhere.net:9944 \
     --plaintext isMinikube=true --plaintext skipProcessor=false
    ```
 
@@ -66,10 +68,6 @@ After cloning this repo, from this working directory, run these commands:
 
    If not using minikube, just specify the `appsImage` config.
 
-1. Create a `.env` file in this directory (`cp ../../../.env ./.env`) and set the database and other variables in it
-
-   Make sure to set `GRAPHQL_SERVER_PORT=4001`
-
 1. Stand up the Kubernetes cluster:
 
    Running `pulumi up -y` will deploy the EKS cluster. Note, provisioning a

+ 1 - 1
devops/kubernetes/query-node/configMap.ts

@@ -2,7 +2,7 @@ import * as pulumi from '@pulumi/pulumi'
 import * as k8s from '@pulumi/kubernetes'
 import * as fs from 'fs'
 
-export class configMapFromFile extends pulumi.ComponentResource {
+export class ConfigMapFromFile extends pulumi.ComponentResource {
   public readonly configName?: pulumi.Output<string>
 
   constructor(name: string, args: ConfigMapArgs, opts: pulumi.ComponentResourceOptions = {}) {

+ 22 - 18
devops/kubernetes/query-node/index.ts

@@ -2,14 +2,12 @@ import * as awsx from '@pulumi/awsx'
 import * as eks from '@pulumi/eks'
 import * as docker from '@pulumi/docker'
 import * as pulumi from '@pulumi/pulumi'
-import { configMapFromFile } from './configMap'
+import { ConfigMapFromFile } from './configMap'
 import * as k8s from '@pulumi/kubernetes'
 import { IndexerServiceDeployment } from './indexerDeployment'
 import { ProcessorServiceDeployment } from './processorDeployment'
 import { CaddyServiceDeployment } from 'pulumi-common'
 
-require('dotenv').config()
-
 const config = new pulumi.Config()
 const awsConfig = new pulumi.Config('aws')
 const isMinikube = config.getBoolean('isMinikube')
@@ -19,12 +17,11 @@ const skipProcessor = config.getBoolean('skipProcessor')
 const useLocalRepo = config.getBoolean('useLocalRepo')
 
 export let kubeconfig: pulumi.Output<any>
-export let joystreamAppsImage: pulumi.Output<string>
+export let joystreamAppsImage: pulumi.Output<string> = pulumi.interpolate`${appsImage}`
 let provider: k8s.Provider
 
 if (skipProcessor && externalIndexerUrl) {
-  pulumi.log.error('Need to deploy atleast one component, Indexer or Processor')
-  throw new Error(`Please check the config settings for skipProcessor and externalIndexerUrl`)
+  pulumi.log.info('No Indexer or Processor will be deployed only the cluster')
 }
 
 if (isMinikube) {
@@ -59,15 +56,19 @@ if (isMinikube) {
   // Export the cluster's kubeconfig.
   kubeconfig = cluster.kubeconfig
 
-  // Create a repository
-  const repo = new awsx.ecr.Repository('joystream/apps')
-
-  // Build an image from an existing local/docker hub image and push to ECR
-  joystreamAppsImage = repo.buildAndPushImage({
-    context: './docker_dummy',
-    dockerfile: './docker_dummy/Dockerfile',
-    args: { SOURCE_IMAGE: appsImage! },
-  })
+  // Only deploy ECR and push image if we need to deploy processor from
+  // local image build.
+  if (!skipProcessor && useLocalRepo) {
+    // Create a repository
+    const repo = new awsx.ecr.Repository('joystream/apps')
+
+    // Build an image from an existing local/docker hub image and push to ECR
+    joystreamAppsImage = repo.buildAndPushImage({
+      context: './docker_dummy',
+      dockerfile: './docker_dummy/Dockerfile',
+      args: { SOURCE_IMAGE: appsImage },
+    })
+  }
 }
 
 const resourceOptions = { provider: provider }
@@ -80,7 +81,7 @@ const ns = new k8s.core.v1.Namespace(name, {}, resourceOptions)
 // Export the Namespace name
 export const namespaceName = ns.metadata.name
 
-const defsConfig = new configMapFromFile(
+const defsConfig = new ConfigMapFromFile(
   'defs-config',
   {
     filePath: '../../../types/augment/all/defs.json',
@@ -109,11 +110,14 @@ const caddyEndpoints = [
   `/indexer* {
     uri strip_prefix /indexer
     reverse_proxy indexer:4000
-}`,
+  }`,
   `/server* {
     uri strip_prefix /server
     reverse_proxy graphql-server:8081
-}`,
+  }`,
+  `/@apollographql/* {
+    reverse_proxy graphql-server:8081
+  }`,
 ]
 
 const lbReady = config.get('isLoadBalancerReady') === 'true'

+ 32 - 61
devops/kubernetes/query-node/indexerDeployment.ts

@@ -14,9 +14,18 @@ export class IndexerServiceDeployment extends pulumi.ComponentResource {
   constructor(name: string, args: ServiceDeploymentArgs, opts?: pulumi.ComponentResourceOptions) {
     super('indexer:service:IndexerServiceDeployment', name, {}, opts)
 
+    const config = new pulumi.Config()
+    const DB_PASS = config.require('dbPassword')
+    const BLOCK_HEIGHT = config.require('blockHeight') || '0'
+    const WS_PROVIDER_ENDPOINT_URI = config.require('joystreamWsEndpoint')
+
+    const DB_USERNAME = 'postgres'
+    const INDEXER_DATABASE_NAME = 'indexer'
+    const DB_PORT = '5432'
+
     // Name passed in the constructor will be the endpoint for accessing the service
     const serviceName = name
-    let appLabels = { appClass: 'indexer' }
+    const appLabels = { appClass: 'indexer' }
 
     const indexerDbName = 'indexer-db'
     const indexerDb = new PostgresServiceDeployment(
@@ -24,56 +33,16 @@ export class IndexerServiceDeployment extends pulumi.ComponentResource {
       {
         namespaceName: args.namespaceName,
         env: [
-          { name: 'POSTGRES_USER', value: process.env.DB_USER! },
-          { name: 'POSTGRES_PASSWORD', value: process.env.DB_PASS! },
-          { name: 'POSTGRES_DB', value: process.env.INDEXER_DB_NAME! },
+          { name: 'POSTGRES_USER', value: DB_USERNAME },
+          { name: 'POSTGRES_PASSWORD', value: DB_PASS },
+          { name: 'POSTGRES_DB', value: INDEXER_DATABASE_NAME },
+          { name: 'PGPORT', value: DB_PORT },
         ],
         storage: args.storage,
       },
       { parent: this }
     )
 
-    const indexerMigrationJob = new k8s.batch.v1.Job(
-      'indexer-db-migration',
-      {
-        metadata: {
-          namespace: args.namespaceName,
-        },
-        spec: {
-          backoffLimit: 0,
-          template: {
-            spec: {
-              containers: [
-                {
-                  name: 'db-migration',
-                  image: args.joystreamAppsImage,
-                  imagePullPolicy: 'IfNotPresent',
-                  resources: { requests: { cpu: '100m', memory: '100Mi' } },
-                  env: [
-                    {
-                      name: 'WARTHOG_DB_HOST',
-                      value: indexerDbName,
-                    },
-                    {
-                      name: 'DB_HOST',
-                      value: indexerDbName,
-                    },
-                    { name: 'WARTHOG_DB_DATABASE', value: process.env.INDEXER_DB_NAME! },
-                    { name: 'DB_NAME', value: process.env.INDEXER_DB_NAME! },
-                    { name: 'DB_PASS', value: process.env.DB_PASS! },
-                  ],
-                  command: ['/bin/sh', '-c'],
-                  args: ['yarn workspace query-node-root db:prepare; yarn workspace query-node-root db:migrate'],
-                },
-              ],
-              restartPolicy: 'Never',
-            },
-          },
-        },
-      },
-      { parent: this, dependsOn: indexerDb.service }
-    )
-
     this.deployment = new k8s.apps.v1.Deployment(
       'indexer',
       {
@@ -100,17 +69,18 @@ export class IndexerServiceDeployment extends pulumi.ComponentResource {
                   image: 'joystream/hydra-indexer:3.0.0',
                   env: [
                     { name: 'DB_HOST', value: indexerDbName },
-                    { name: 'DB_NAME', value: process.env.INDEXER_DB_NAME! },
-                    { name: 'DB_PASS', value: process.env.DB_PASS! },
-                    { name: 'DB_USER', value: process.env.DB_USER! },
-                    { name: 'DB_PORT', value: process.env.DB_PORT! },
+                    { name: 'DB_NAME', value: INDEXER_DATABASE_NAME },
+                    { name: 'DB_PASS', value: DB_PASS },
+                    { name: 'DB_USER', value: DB_USERNAME },
+                    { name: 'DB_PORT', value: DB_PORT },
                     { name: 'INDEXER_WORKERS', value: '5' },
+                    // localhost for redis should work since it is in the same deployment
                     { name: 'REDIS_URI', value: 'redis://localhost:6379/0' },
                     { name: 'DEBUG', value: 'index-builder:*' },
-                    { name: 'WS_PROVIDER_ENDPOINT_URI', value: process.env.WS_PROVIDER_ENDPOINT_URI! },
+                    { name: 'WS_PROVIDER_ENDPOINT_URI', value: WS_PROVIDER_ENDPOINT_URI },
                     { name: 'TYPES_JSON', value: 'types.json' },
-                    { name: 'PGUSER', value: process.env.DB_USER! },
-                    { name: 'BLOCK_HEIGHT', value: process.env.BLOCK_HEIGHT! },
+                    { name: 'PGUSER', value: DB_USERNAME },
+                    { name: 'BLOCK_HEIGHT', value: BLOCK_HEIGHT },
                   ],
                   volumeMounts: [
                     {
@@ -126,17 +96,18 @@ export class IndexerServiceDeployment extends pulumi.ComponentResource {
                   name: 'hydra-indexer-gateway',
                   image: 'joystream/hydra-indexer-gateway:3.0.0',
                   env: [
-                    { name: 'WARTHOG_STARTER_DB_DATABASE', value: process.env.INDEXER_DB_NAME! },
+                    { name: 'WARTHOG_STARTER_DB_DATABASE', value: INDEXER_DATABASE_NAME },
                     { name: 'WARTHOG_STARTER_DB_HOST', value: indexerDbName },
-                    { name: 'WARTHOG_STARTER_DB_PASSWORD', value: process.env.DB_PASS! },
-                    { name: 'WARTHOG_STARTER_DB_PORT', value: process.env.DB_PORT! },
-                    { name: 'WARTHOG_STARTER_DB_USERNAME', value: process.env.DB_USER! },
+                    { name: 'WARTHOG_STARTER_DB_PASSWORD', value: DB_PASS },
+                    { name: 'WARTHOG_STARTER_DB_PORT', value: DB_PORT },
+                    { name: 'WARTHOG_STARTER_DB_USERNAME', value: DB_USERNAME },
+                    // localhost for redis should work since it is in the same deployment
                     { name: 'WARTHOG_STARTER_REDIS_URI', value: 'redis://localhost:6379/0' },
-                    { name: 'WARTHOG_APP_PORT', value: process.env.WARTHOG_APP_PORT! },
-                    { name: 'PORT', value: process.env.WARTHOG_APP_PORT! },
+                    { name: 'WARTHOG_APP_PORT', value: '4001' },
+                    { name: 'PORT', value: '4001' },
                     { name: 'DEBUG', value: '*' },
                   ],
-                  ports: [{ name: 'hydra-port', containerPort: Number(process.env.WARTHOG_APP_PORT!) }],
+                  ports: [{ name: 'hydra-port', containerPort: 4001 }],
                 },
               ],
               volumes: [
@@ -151,7 +122,7 @@ export class IndexerServiceDeployment extends pulumi.ComponentResource {
           },
         },
       },
-      { parent: this, dependsOn: indexerMigrationJob }
+      { parent: this, dependsOn: indexerDb.service }
     )
 
     // Create a Service for the Indexer
@@ -183,5 +154,5 @@ export interface ServiceDeploymentArgs {
   joystreamAppsImage: pulumi.Output<string>
   defsConfig: pulumi.Output<string> | undefined
   env?: Environment[]
-  storage: Number
+  storage: number
 }

+ 46 - 19
devops/kubernetes/query-node/processorDeployment.ts

@@ -15,6 +15,12 @@ export class ProcessorServiceDeployment extends pulumi.ComponentResource {
   constructor(name: string, args: ServiceDeploymentArgs, opts?: pulumi.ComponentResourceOptions) {
     super('processor:service:ProcessorServiceDeployment', name, {}, opts)
 
+    const config = new pulumi.Config()
+    const DB_PASS = config.require('dbPassword')
+    const DB_USERNAME = 'postgres'
+    const PROCESSOR_DATABASE_NAME = 'processor'
+    const DB_PORT = '5432'
+
     // Name passed in the constructor will be the endpoint for accessing the service
     this.endpoint = 'graphql-server'
 
@@ -24,9 +30,10 @@ export class ProcessorServiceDeployment extends pulumi.ComponentResource {
       {
         namespaceName: args.namespaceName,
         env: [
-          { name: 'POSTGRES_USER', value: process.env.DB_USER! },
-          { name: 'POSTGRES_PASSWORD', value: process.env.DB_PASS! },
-          { name: 'POSTGRES_DB', value: process.env.DB_NAME! },
+          { name: 'POSTGRES_USER', value: DB_USERNAME },
+          { name: 'POSTGRES_PASSWORD', value: DB_PASS },
+          { name: 'POSTGRES_DB', value: PROCESSOR_DATABASE_NAME },
+          { name: 'PGPORT', value: DB_PORT },
         ],
         storage: args.storage,
       },
@@ -58,12 +65,20 @@ export class ProcessorServiceDeployment extends pulumi.ComponentResource {
                       name: 'DB_HOST',
                       value: processorDbName,
                     },
-                    { name: 'WARTHOG_DB_DATABASE', value: process.env.DB_NAME! },
-                    { name: 'DB_NAME', value: process.env.DB_NAME! },
-                    { name: 'DB_PASS', value: process.env.DB_PASS! },
+                    { name: 'WARTHOG_DB_DATABASE', value: PROCESSOR_DATABASE_NAME },
+                    { name: 'WARTHOG_DB_USERNAME', value: DB_USERNAME },
+                    { name: 'WARTHOG_DB_PASSWORD', value: DB_PASS },
+                    { name: 'WARTHOG_DB_PORT', value: DB_PORT },
+                    { name: 'DB_NAME', value: PROCESSOR_DATABASE_NAME },
+                    { name: 'DB_PASS', value: DB_PASS },
+                    { name: 'DB_USER', value: DB_USERNAME },
+                    { name: 'DB_PORT', value: DB_PORT },
                   ],
                   command: ['/bin/sh', '-c'],
-                  args: ['yarn workspace query-node-root db:prepare; yarn workspace query-node-root db:migrate'],
+                  args: [
+                    // 'yarn workspace query-node config:dev;',
+                    'yarn workspace query-node-root db:prepare; yarn workspace query-node-root db:migrate',
+                  ],
                 },
               ],
               restartPolicy: 'Never',
@@ -98,15 +113,18 @@ export class ProcessorServiceDeployment extends pulumi.ComponentResource {
                   imagePullPolicy: 'IfNotPresent',
                   env: [
                     { name: 'DB_HOST', value: processorDbName },
-                    { name: 'DB_PASS', value: process.env.DB_PASS! },
-                    { name: 'DB_USER', value: process.env.DB_USER! },
-                    { name: 'DB_PORT', value: process.env.DB_PORT! },
-                    { name: 'DB_NAME', value: process.env.DB_NAME! },
-                    { name: 'GRAPHQL_SERVER_HOST', value: process.env.GRAPHQL_SERVER_HOST! },
-                    { name: 'GRAPHQL_SERVER_PORT', value: process.env.GRAPHQL_SERVER_PORT! },
-                    { name: 'WS_PROVIDER_ENDPOINT_URI', value: process.env.WS_PROVIDER_ENDPOINT_URI! },
+                    { name: 'DB_PASS', value: DB_PASS },
+                    { name: 'DB_USER', value: DB_USERNAME },
+                    { name: 'DB_PORT', value: DB_PORT },
+                    { name: 'DB_NAME', value: PROCESSOR_DATABASE_NAME },
+                    { name: 'WARTHOG_DB_DATABASE', value: PROCESSOR_DATABASE_NAME },
+                    { name: 'WARTHOG_DB_USERNAME', value: DB_USERNAME },
+                    { name: 'WARTHOG_DB_PASSWORD', value: DB_PASS },
+                    { name: 'WARTHOG_APP_PORT', value: '4002' },
+                    // Why do we need this anyway?
+                    { name: 'GRAPHQL_SERVER_HOST', value: 'graphql-server' },
                   ],
-                  ports: [{ name: 'graph-ql-port', containerPort: Number(process.env.GRAPHQL_SERVER_PORT!) }],
+                  ports: [{ name: 'graph-ql-port', containerPort: 4002 }],
                   args: ['workspace', 'query-node-root', 'query-node:start:prod'],
                 },
               ],
@@ -163,9 +181,19 @@ export class ProcessorServiceDeployment extends pulumi.ComponentResource {
                       value: indexerURL,
                     },
                     { name: 'TYPEORM_HOST', value: processorDbName },
-                    { name: 'TYPEORM_DATABASE', value: process.env.DB_NAME! },
+                    { name: 'TYPEORM_DATABASE', value: PROCESSOR_DATABASE_NAME },
                     { name: 'DEBUG', value: 'index-builder:*' },
                     { name: 'PROCESSOR_POLL_INTERVAL', value: '1000' },
+                    { name: 'DB_PASS', value: DB_PASS },
+                    { name: 'DB_USER', value: DB_USERNAME },
+                    { name: 'DB_PORT', value: DB_PORT },
+                    { name: 'WARTHOG_DB_DATABASE', value: PROCESSOR_DATABASE_NAME },
+                    { name: 'WARTHOG_DB_USERNAME', value: DB_USERNAME },
+                    { name: 'WARTHOG_DB_PASSWORD', value: DB_PASS },
+                    { name: 'WARTHOG_DB_PORT', value: DB_PORT },
+                    // These are note required but must be defined or processor will not startup
+                    { name: 'WARTHOG_APP_HOST', value: 'graphql-server' },
+                    { name: 'WARTHOG_APP_PORT', value: '4002' },
                   ],
                   volumeMounts: [
                     {
@@ -174,8 +202,7 @@ export class ProcessorServiceDeployment extends pulumi.ComponentResource {
                       subPath: 'fileData',
                     },
                   ],
-                  command: ['/bin/sh', '-c'],
-                  args: ['cd query-node && yarn hydra-processor run -e ../.env'],
+                  args: ['workspace', 'query-node-root', 'processor:start'],
                 },
               ],
               volumes: [
@@ -206,5 +233,5 @@ export interface ServiceDeploymentArgs {
   defsConfig: pulumi.Output<string> | undefined
   externalIndexerUrl: string | undefined
   env?: Environment[]
-  storage: Number
+  storage: number
 }

+ 16 - 12
devops/kubernetes/storage-node/Pulumi.yaml

@@ -1,33 +1,37 @@
-name: eks-cluster
+name: storage-node
 runtime: nodejs
-description: A Pulumi program to deploy storage node to cloud environment
+description: A Pulumi program to deploy storage node to Kubernetes
 template:
   config:
     aws:profile:
       default: joystream-user
     aws:region:
       default: us-east-1
+    isMinikube:
+      description: Whether you are deploying to minikube
+      default: false
     wsProviderEndpointURI:
       description: Chain RPC endpoint
-      default: 'wss://rome-rpc-endpoint.joystream.org:9944/'
-    isAnonymous:
-      description: Whether you are deploying an anonymous storage node
-      default: true
     isLoadBalancerReady:
       description: Whether the load balancer service is ready and has been assigned an IP
       default: false
     colossusPort:
       description: Port that is exposed for the colossus container
-      default: 3000
+      default: 3333
     storage:
       description: Amount of storage in gigabytes for ipfs volume
       default: 40
-    providerId:
-      description: StorageProviderId assigned to you in working group
     keyFile:
-      description: Path to JSON key export file to use as the storage provider (role account)
-    publicURL:
-      description: API Public URL to announce
+      description: Key file for the account
     passphrase:
       description: Optional passphrase to use to decrypt the key-file
       secret: true
+    colossusImage:
+      description: The colossus image to use for running the storage node
+      default: joystream/colossus:latest
+    queryNodeEndpoint:
+      description: Full URL for Query node endpoint
+    workerId:
+      description: ID of the node operator (distribution working group worker)
+    accountURI:
+      description: Account URI

+ 11 - 9
devops/kubernetes/storage-node/README.md

@@ -1,6 +1,6 @@
 # Amazon EKS Cluster: Hello World!
 
-This example deploys an EKS Kubernetes cluster with custom ipfs image
+Deploy storage-node to a Kubernetes cluster
 
 ## Deploying the App
 
@@ -37,20 +37,22 @@ After cloning this repo, from this working directory, run these commands:
 1. Set the required configuration variables in `Pulumi.<stack>.yaml`
 
    ```bash
-   $ pulumi config set-all --plaintext aws:region=us-east-1 --plaintext aws:profile=joystream-user \
-    --plaintext wsProviderEndpointURI='wss://rome-rpc-endpoint.joystream.org:9944/' \
-    --plaintext isMinikube=true --plaintext isAnonymous=true
+   $ pulumi config set-all --plaintext wsProviderEndpointURI='wss://rome-rpc-endpoint.joystream.org:9944/' \
+    --plaintext queryNodeEndpoint='http://graphql-server.query-node-yszsbs2i:8081' \
+    --plaintext keyFile='../../../keyfile.json' --secret passphrase='' \
+    --plaintext accountURI='//Alice' workerId=0 \
+    --plaintext isMinikube=true --plaintext colossusImage='joystream/colossus:latest' \
+    --plaintext aws:region=us-east-1 --plaintext aws:profile=joystream-user
    ```
 
-   If running for production use the below mentioned config
+   If you want to build the stack on AWS set the `isMinikube` config to `false`
 
    ```bash
-   $ pulumi config set-all --plaintext aws:region=us-east-1 --plaintext aws:profile=joystream-user \
-    --plaintext wsProviderEndpointURI='wss://rome-rpc-endpoint.joystream.org:9944/' --plaintext isAnonymous=false --plaintext isMinikube=false \
-    --plaintext providerId=<ID> --plaintext keyFile=<PATH> --plaintext publicURL=<DOMAIN> --secret passphrase=<PASSPHRASE>
+   $ pulumi config set isMinikube false
    ```
 
-   You can also set the `storage` and the `colossusPort` config parameters if required
+   You can also set the `storage` and the `colossusPort` config parameters if required. Check `Pulumi.yaml` file
+   for additional parameters.
 
 1. Stand up the EKS cluster:
 

+ 5 - 0
devops/kubernetes/storage-node/docker_dummy/Dockerfile

@@ -0,0 +1,5 @@
+# Since Pulumi does not support push without a build
+# we build an image from an existing local image
+ARG SOURCE_IMAGE
+
+FROM --platform=linux/amd64 ${SOURCE_IMAGE}

+ 119 - 110
devops/kubernetes/storage-node/index.ts

@@ -4,40 +4,40 @@ import * as eks from '@pulumi/eks'
 import * as docker from '@pulumi/docker'
 import * as k8s from '@pulumi/kubernetes'
 import * as pulumi from '@pulumi/pulumi'
-import { CaddyServiceDeployment } from 'pulumi-common'
+import { CaddyServiceDeployment, configMapFromFile } from 'pulumi-common'
 import * as fs from 'fs'
 
 const awsConfig = new pulumi.Config('aws')
 const config = new pulumi.Config()
 
+const name = 'storage-node'
+
 const wsProviderEndpointURI = config.require('wsProviderEndpointURI')
-const isAnonymous = config.require('isAnonymous') === 'true'
+const queryNodeHost = config.require('queryNodeEndpoint')
+const workerId = config.require('workerId')
+const accountURI = config.get('accountURI')
+const keyFile = config.get('keyFile')
 const lbReady = config.get('isLoadBalancerReady') === 'true'
-const name = 'storage-node'
-const colossusPort = parseInt(config.get('colossusPort') || '3000')
+const configColossusImage = config.get('colossusImage') || `joystream/colossus:latest`
+const colossusPort = parseInt(config.get('colossusPort') || '3333')
 const storage = parseInt(config.get('storage') || '40')
 const isMinikube = config.getBoolean('isMinikube')
 
+let additionalVolumes: pulumi.Input<pulumi.Input<k8s.types.input.core.v1.Volume>[]> = []
+let additionalVolumeMounts: pulumi.Input<pulumi.Input<k8s.types.input.core.v1.VolumeMount>[]> = []
+
+if (!accountURI && !keyFile) {
+  throw new Error('Must specify either Key file or Account URI')
+}
+
 let additionalParams: string[] | pulumi.Input<string>[] = []
-let volumeMounts: pulumi.Input<pulumi.Input<k8s.types.input.core.v1.VolumeMount>[]> = []
-let volumes: pulumi.Input<pulumi.Input<k8s.types.input.core.v1.Volume>[]> = []
 
 export let kubeconfig: pulumi.Output<any>
-export let colossusImage: pulumi.Output<string>
+export let colossusImage: pulumi.Output<string> = pulumi.interpolate`${configColossusImage}`
 let provider: k8s.Provider
 
 if (isMinikube) {
   provider = new k8s.Provider('local', {})
-  // Create image from local app
-  colossusImage = new docker.Image('joystream/colossus', {
-    build: {
-      context: '../../../',
-      dockerfile: '../../../colossus.Dockerfile',
-    },
-    imageName: 'joystream/colossus:latest',
-    skipPush: true,
-  }).baseImageName
-  // colossusImage = pulumi.interpolate`joystream/colossus:latest`
 } else {
   // Create a VPC for our cluster.
   const vpc = new awsx.ec2.Vpc('storage-node-vpc', { numberOfAvailabilityZones: 2, numberOfNatGateways: 1 })
@@ -61,8 +61,9 @@ if (isMinikube) {
 
   // Build an image and publish it to our ECR repository.
   colossusImage = repo.buildAndPushImage({
-    dockerfile: '../../../colossus.Dockerfile',
-    context: '../../../',
+    context: './docker_dummy',
+    dockerfile: './docker_dummy/Dockerfile',
+    args: { SOURCE_IMAGE: colossusImage! },
   })
 }
 
@@ -96,74 +97,36 @@ const pvc = new k8s.core.v1.PersistentVolumeClaim(
   resourceOptions
 )
 
-volumes.push({
-  name: 'ipfs-data',
-  persistentVolumeClaim: {
-    claimName: `${name}-pvc`,
-  },
-})
-
-const caddyEndpoints = [
-  ` {
-    reverse_proxy storage-node:${colossusPort}
-}`,
-]
-
-export let endpoint1: pulumi.Output<string> = pulumi.interpolate``
-export let endpoint2: pulumi.Output<string> = pulumi.interpolate``
-
-if (!isMinikube) {
-  const caddy = new CaddyServiceDeployment(
-    'caddy-proxy',
-    { lbReady, namespaceName: namespaceName, caddyEndpoints },
+if (keyFile) {
+  const keyConfigName = new configMapFromFile(
+    'key-config',
+    {
+      filePath: keyFile,
+      namespaceName: namespaceName,
+    },
     resourceOptions
-  )
-
-  endpoint1 = pulumi.interpolate`${caddy.primaryEndpoint}`
-  endpoint2 = pulumi.interpolate`${caddy.secondaryEndpoint}`
-}
-
-export let appLink: pulumi.Output<string>
-
-if (lbReady) {
-  appLink = pulumi.interpolate`https://${endpoint1}`
-
-  if (!isAnonymous) {
-    const remoteKeyFilePath = '/joystream/key-file.json'
-    const providerId = config.require('providerId')
-    const keyFile = config.require('keyFile')
-    const publicUrl = config.get('publicURL') ? config.get('publicURL')! : appLink
-
-    const keyConfig = new k8s.core.v1.ConfigMap('key-config', {
-      metadata: { namespace: namespaceName, labels: appLabels },
-      data: { 'fileData': fs.readFileSync(keyFile).toString() },
-    })
-    const keyConfigName = keyConfig.metadata.apply((m) => m.name)
-
-    additionalParams = ['--provider-id', providerId, '--key-file', remoteKeyFilePath, '--public-url', publicUrl]
+  ).configName
 
-    volumeMounts.push({
-      mountPath: remoteKeyFilePath,
-      name: 'keyfile-volume',
-      subPath: 'fileData',
-    })
+  const remoteKeyFilePath = '/joystream/key-file.json'
+  additionalParams.push(`--keyFile=${remoteKeyFilePath}`)
 
-    volumes.push({
-      name: 'keyfile-volume',
-      configMap: {
-        name: keyConfigName,
-      },
-    })
-
-    const passphrase = config.get('passphrase')
-    if (passphrase) {
-      additionalParams.push('--passphrase', passphrase)
-    }
+  const passphrase = config.get('passphrase')
+  if (passphrase) {
+    additionalParams.push(`--password=${passphrase}`)
   }
-}
 
-if (isAnonymous) {
-  additionalParams.push('--anonymous')
+  additionalVolumes.push({
+    name: 'keyfile-volume',
+    configMap: {
+      name: keyConfigName,
+    },
+  })
+
+  additionalVolumeMounts.push({
+    mountPath: remoteKeyFilePath,
+    name: 'keyfile-volume',
+    subPath: 'fileData',
+  })
 }
 
 // Create a Deployment
@@ -182,31 +145,12 @@ const deployment = new k8s.apps.v1.Deployment(
           labels: appLabels,
         },
         spec: {
-          hostname: 'ipfs',
           containers: [
-            {
-              name: 'ipfs',
-              image: 'ipfs/go-ipfs:latest',
-              ports: [{ containerPort: 5001 }, { containerPort: 8080 }],
-              command: ['/bin/sh', '-c'],
-              args: [
-                'set -e; \
-                /usr/local/bin/start_ipfs config profile apply lowpower; \
-                /usr/local/bin/start_ipfs config --json Gateway.PublicGateways \'{"localhost": null }\'; \
-                /usr/local/bin/start_ipfs config Datastore.StorageMax 200GB; \
-                /sbin/tini -- /usr/local/bin/start_ipfs daemon --migrate=true',
-              ],
-              volumeMounts: [
-                {
-                  name: 'ipfs-data',
-                  mountPath: '/data/ipfs',
-                },
-              ],
-            },
             {
               name: 'colossus',
               image: colossusImage,
               imagePullPolicy: 'IfNotPresent',
+              workingDir: '/joystream/storage-node-v2',
               env: [
                 {
                   name: 'WS_PROVIDER_ENDPOINT_URI',
@@ -217,21 +161,66 @@ const deployment = new k8s.apps.v1.Deployment(
                   name: 'DEBUG',
                   value: 'joystream:*',
                 },
+                {
+                  name: 'COLOSSUS_PORT',
+                  value: `${colossusPort}`,
+                },
+                {
+                  name: 'QUERY_NODE_ENDPOINT',
+                  value: queryNodeHost,
+                },
+                {
+                  name: 'WORKER_ID',
+                  value: workerId,
+                },
+                // ACCOUNT_URI takes precedence over keyFile
+                {
+                  name: 'ACCOUNT_URI',
+                  value: accountURI,
+                },
               ],
-              volumeMounts,
-              command: [
-                'yarn',
-                'colossus',
-                '--ws-provider',
+              volumeMounts: [
+                {
+                  name: 'colossus-data',
+                  mountPath: '/data',
+                  subPath: 'data',
+                },
+                {
+                  name: 'colossus-data',
+                  mountPath: '/keystore',
+                  subPath: 'keystore',
+                },
+                ...additionalVolumeMounts,
+              ],
+              command: ['yarn'],
+              args: [
+                'storage-node',
+                'server',
+                '--worker',
+                workerId,
+                '--port',
+                `${colossusPort}`,
+                '--uploads=/data',
+                '--sync',
+                '--syncInterval=1',
+                '--queryNodeEndpoint',
+                queryNodeHost,
+                '--apiUrl',
                 wsProviderEndpointURI,
-                '--ipfs-host',
-                'ipfs',
                 ...additionalParams,
               ],
               ports: [{ containerPort: colossusPort }],
             },
           ],
-          volumes,
+          volumes: [
+            {
+              name: 'colossus-data',
+              persistentVolumeClaim: {
+                claimName: `${name}-pvc`,
+              },
+            },
+            ...additionalVolumes,
+          ],
         },
       },
     },
@@ -262,3 +251,23 @@ export const serviceName = service.metadata.name
 
 // Export the Deployment name
 export const deploymentName = deployment.metadata.name
+
+const caddyEndpoints = [
+  ` {
+    reverse_proxy storage-node:${colossusPort}
+}`,
+]
+
+export let endpoint1: pulumi.Output<string> = pulumi.interpolate``
+export let endpoint2: pulumi.Output<string> = pulumi.interpolate``
+
+if (!isMinikube) {
+  const caddy = new CaddyServiceDeployment(
+    'caddy-proxy',
+    { lbReady, namespaceName: namespaceName, caddyEndpoints },
+    resourceOptions
+  )
+
+  endpoint1 = pulumi.interpolate`${caddy.primaryEndpoint}`
+  endpoint2 = pulumi.interpolate`${caddy.secondaryEndpoint}`
+}

+ 4 - 4
distributor-node/docs/api/operator/index.md

@@ -264,7 +264,7 @@ OperatorAuth
 ```javascript
 const inputBody = '{
   "buckets": [
-    0
+    "string"
   ]
 }';
 const headers = {
@@ -303,7 +303,7 @@ Updates buckets supported by the node.
 ```json
 {
   "buckets": [
-    0
+    "string"
   ]
 }
 ```
@@ -359,7 +359,7 @@ OperatorAuth
 ```json
 {
   "buckets": [
-    0
+    "string"
   ]
 }
 
@@ -369,7 +369,7 @@ OperatorAuth
 
 |Name|Type|Required|Restrictions|Description|
 |---|---|---|---|---|
-|buckets|[integer]|false|none|Set of bucket ids to be distributed by the node. If not provided - all buckets assigned to currently configured worker will be distributed.|
+|buckets|[string]|false|none|Set of bucket ids to be distributed by the node. If not provided - all buckets assigned to currently configured worker will be distributed.|
 
 undefined
 

+ 3 - 3
distributor-node/docs/api/public/index.md

@@ -141,7 +141,7 @@ Returns list of distributed buckets
 ```json
 {
   "bucketIds": [
-    0
+    "string"
   ]
 }
 ```
@@ -356,7 +356,7 @@ This operation does not require authentication
 ```json
 {
   "bucketIds": [
-    0
+    "string"
   ]
 }
 
@@ -369,7 +369,7 @@ oneOf
 |Name|Type|Required|Restrictions|Description|
 |---|---|---|---|---|
 |*anonymous*|object|false|none|none|
-|» bucketIds|[integer]|true|none|none|
+|» bucketIds|[string]|true|none|none|
 
 xor
 

+ 11 - 23
distributor-node/docs/commands/leader.md

@@ -26,13 +26,11 @@ USAGE
   $ joystream-distributor leader:cancel-invitation
 
 OPTIONS
-  -B, --bucketId=bucketId      (required) Distribution bucket id
+  -B, --bucketId=bucketId      (required) Distribution bucket ID in {familyId}:{bucketIndex} format.
 
   -c, --configPath=configPath  [default: ./config.yml] Path to config JSON/YAML file (relative to current working
                                directory)
 
-  -f, --familyId=familyId      (required) Distribution bucket family id
-
   -w, --workerId=workerId      (required) ID of the invited operator (distribution group worker)
 
   -y, --yes                    Answer "yes" to any prompt, skipping any manual confirmations
@@ -90,13 +88,11 @@ USAGE
   $ joystream-distributor leader:delete-bucket
 
 OPTIONS
-  -B, --bucketId=bucketId      (required) Distribution bucket id
+  -B, --bucketId=bucketId      (required) Distribution bucket ID in {familyId}:{bucketIndex} format.
 
   -c, --configPath=configPath  [default: ./config.yml] Path to config JSON/YAML file (relative to current working
                                directory)
 
-  -f, --familyId=familyId      (required) Distribution bucket family id
-
   -y, --yes                    Answer "yes" to any prompt, skipping any manual confirmations
 ```
 
@@ -130,13 +126,11 @@ USAGE
   $ joystream-distributor leader:invite-bucket-operator
 
 OPTIONS
-  -B, --bucketId=bucketId      (required) Distribution bucket id
+  -B, --bucketId=bucketId      (required) Distribution bucket ID in {familyId}:{bucketIndex} format.
 
   -c, --configPath=configPath  [default: ./config.yml] Path to config JSON/YAML file (relative to current working
                                directory)
 
-  -f, --familyId=familyId      (required) Distribution bucket family id
-
   -w, --workerId=workerId      (required) ID of the distribution group worker to invite as bucket operator
 
   -y, --yes                    Answer "yes" to any prompt, skipping any manual confirmations
@@ -157,13 +151,11 @@ USAGE
   $ joystream-distributor leader:remove-bucket-operator
 
 OPTIONS
-  -B, --bucketId=bucketId      (required) Distribution bucket id
+  -B, --bucketId=bucketId      (required) Distribution bucket ID in {familyId}:{bucketIndex} format.
 
   -c, --configPath=configPath  [default: ./config.yml] Path to config JSON/YAML file (relative to current working
                                directory)
 
-  -f, --familyId=familyId      (required) Distribution bucket family id
-
   -w, --workerId=workerId      (required) ID of the operator (distribution working group worker) to remove from the
                                bucket
 
@@ -228,7 +220,7 @@ USAGE
 
 OPTIONS
   -a, --add=add
-      [default: ] ID of a bucket to add to bag
+      [default: ] Index(es) (within the family) of bucket(s) to add to the bag
 
   -b, --bagId=bagId
       (required) Bag ID. Format: {bag_type}:{sub_type}:{id}.
@@ -250,13 +242,13 @@ OPTIONS
       (required) ID of the distribution bucket family
 
   -r, --remove=remove
-      [default: ] ID of a bucket to remove from bag
+      [default: ] Index(es) (within the family) of bucket(s) to remove from the bag
 
   -y, --yes
       Answer "yes" to any prompt, skipping any manual confirmations
 
 EXAMPLE
-  $ joystream-distributor leader:update-bag -b 1 -f 1 -a 1 -a 2 -a 3 -r 4 -r 5
+  $ joystream-distributor leader:update-bag -b 1 -f 1 -a 1 2 3 -r 4 5
 ```
 
 _See code: [src/commands/leader/update-bag.ts](https://github.com/Joystream/joystream/blob/v0.1.0/src/commands/leader/update-bag.ts)_
@@ -270,15 +262,13 @@ USAGE
   $ joystream-distributor leader:update-bucket-mode
 
 OPTIONS
-  -B, --bucketId=bucketId      (required) Distribution bucket id
+  -B, --bucketId=bucketId      (required) Distribution bucket ID in {familyId}:{bucketIndex} format.
 
   -c, --configPath=configPath  [default: ./config.yml] Path to config JSON/YAML file (relative to current working
                                directory)
 
   -d, --mode=(on|off)          (required) Whether the bucket should be "on" (distributing) or "off" (not distributing)
 
-  -f, --familyId=familyId      (required) Distribution bucket family id
-
   -y, --yes                    Answer "yes" to any prompt, skipping any manual confirmations
 ```
 
@@ -293,14 +283,12 @@ USAGE
   $ joystream-distributor leader:update-bucket-status
 
 OPTIONS
-  -B, --bucketId=bucketId       (required) Distribution bucket id
+  -B, --bucketId=bucketId       (required) Distribution bucket ID in {familyId}:{bucketIndex} format.
   -a, --acceptingBags=(yes|no)  (required) Whether the bucket should accept new bags
 
   -c, --configPath=configPath   [default: ./config.yml] Path to config JSON/YAML file (relative to current working
                                 directory)
 
-  -f, --familyId=familyId       (required) Distribution bucket family id
-
   -y, --yes                     Answer "yes" to any prompt, skipping any manual confirmations
 ```
 
@@ -318,7 +306,7 @@ OPTIONS
   -c, --configPath=configPath  [default: ./config.yml] Path to config JSON/YAML file (relative to current working
                                directory)
 
-  -p, --policy=policy          Key-value pair of {familyId}:{numberOfBuckets}
+  -p, --policy=policy          [default: ] Key-value pair of {familyId}:{numberOfBuckets}
 
   -t, --type=(Member|Channel)  (required) Dynamic bag type
 
@@ -328,7 +316,7 @@ DESCRIPTION
   Requires distribution working group leader permissions.
 
 EXAMPLE
-  $ joystream-distributor leader:update-dynamic-bag-policy -t Member -p 1:5 -p 2:10 -p 3:5
+  $ joystream-distributor leader:update-dynamic-bag-policy -t Member -p 1:5 2:10 3:5
 ```
 
 _See code: [src/commands/leader/update-dynamic-bag-policy.ts](https://github.com/Joystream/joystream/blob/v0.1.0/src/commands/leader/update-dynamic-bag-policy.ts)_

+ 7 - 1
distributor-node/docs/commands/node.md

@@ -18,7 +18,9 @@ USAGE
   $ joystream-distributor node:set-buckets
 
 OPTIONS
-  -B, --bucketIds=bucketIds    Set of bucket ids to distribute
+  -B, --bucketIds=bucketIds    Set of bucket ids to distribute. Each bucket id should be in {familyId}:{bucketIndex}
+                               format. Multiple ids can be provided, separated by space.
+
   -a, --all                    Distribute all buckets belonging to configured worker
 
   -c, --configPath=configPath  [default: ./config.yml] Path to config JSON/YAML file (relative to current working
@@ -29,6 +31,10 @@ OPTIONS
   -u, --url=url                (required) Distributor node operator api base url (ie. http://localhost:3335)
 
   -y, --yes                    Answer "yes" to any prompt, skipping any manual confirmations
+
+EXAMPLES
+  $ joystream-distributor node:set-buckets --bucketIds 1:1 1:2 1:3 2:1 2:2
+  $ joystream-distributor node:set-buckets --all
 ```
 
 _See code: [src/commands/node/set-buckets.ts](https://github.com/Joystream/joystream/blob/v0.1.0/src/commands/node/set-buckets.ts)_

+ 2 - 6
distributor-node/docs/commands/operator.md

@@ -15,13 +15,11 @@ USAGE
   $ joystream-distributor operator:accept-invitation
 
 OPTIONS
-  -B, --bucketId=bucketId      (required) Distribution bucket id
+  -B, --bucketId=bucketId      (required) Distribution bucket ID in {familyId}:{bucketIndex} format.
 
   -c, --configPath=configPath  [default: ./config.yml] Path to config JSON/YAML file (relative to current working
                                directory)
 
-  -f, --familyId=familyId      (required) Distribution bucket family id
-
   -w, --workerId=workerId      (required) ID of the invited operator (distribution group worker)
 
   -y, --yes                    Answer "yes" to any prompt, skipping any manual confirmations
@@ -41,15 +39,13 @@ USAGE
   $ joystream-distributor operator:set-metadata
 
 OPTIONS
-  -B, --bucketId=bucketId      (required) Distribution bucket id
+  -B, --bucketId=bucketId      (required) Distribution bucket ID in {familyId}:{bucketIndex} format.
 
   -c, --configPath=configPath  [default: ./config.yml] Path to config JSON/YAML file (relative to current working
                                directory)
 
   -e, --endpoint=endpoint      Root distribution node endpoint
 
-  -f, --familyId=familyId      (required) Distribution bucket family id
-
   -i, --input=input            Path to JSON metadata file
 
   -w, --workerId=workerId      (required) ID of the operator (distribution group worker)

+ 0 - 7
distributor-node/docs/schema/definition-properties-bucket-ids-items.md

@@ -1,7 +0,0 @@
-## items Type
-
-`integer`
-
-## items Constraints
-
-**minimum**: the value of this number must greater than or equal to: `0`

+ 13 - 0
distributor-node/docs/schema/definition-properties-distributed-buckets-ids-items.md

@@ -0,0 +1,13 @@
+## items Type
+
+`string`
+
+## items Constraints
+
+**pattern**: the string must match the following regular expression: 
+
+```regexp
+^[0-9]+:[0-9]+$
+```
+
+[try pattern](https://regexr.com/?expression=%5E%5B0-9%5D%2B%3A%5B0-9%5D%2B%24 "try regular expression with regexr.com")

+ 1 - 1
distributor-node/docs/schema/definition-properties-bucket-ids.md → distributor-node/docs/schema/definition-properties-distributed-buckets-ids.md

@@ -1,6 +1,6 @@
 ## buckets Type
 
-`integer[]`
+`string[]`
 
 ## buckets Constraints
 

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