Browse Source

Merge branch 'feature/manage-lead-proposals' into feature/linter

Gleb Urvanov 4 years ago
parent
commit
c3cce955ca
100 changed files with 4898 additions and 3115 deletions
  1. 6 6
      .github/workflows/joystream-cli.yml
  2. 7 0
      cli/.eslintrc.js
  3. 1 1
      cli/package.json
  4. 384 355
      cli/src/Api.ts
  5. 11 11
      cli/src/ExitCodes.ts
  6. 301 248
      cli/src/Types.ts
  7. 213 224
      cli/src/base/AccountsCommandBase.ts
  8. 374 343
      cli/src/base/ApiCommandBase.ts
  9. 84 73
      cli/src/base/DefaultCommandBase.ts
  10. 94 95
      cli/src/base/StateAwareCommandBase.ts
  11. 241 160
      cli/src/base/WorkingGroupsCommandBase.ts
  12. 25 25
      cli/src/commands/account/choose.ts
  13. 35 35
      cli/src/commands/account/create.ts
  14. 34 34
      cli/src/commands/account/current.ts
  15. 62 61
      cli/src/commands/account/export.ts
  16. 23 21
      cli/src/commands/account/forget.ts
  17. 34 36
      cli/src/commands/account/import.ts
  18. 53 54
      cli/src/commands/account/transferTokens.ts
  19. 7 8
      cli/src/commands/api/getUri.ts
  20. 210 203
      cli/src/commands/api/inspect.ts
  21. 22 22
      cli/src/commands/api/setUri.ts
  22. 48 49
      cli/src/commands/council/info.ts
  23. 31 32
      cli/src/commands/working-groups/application.ts
  24. 77 84
      cli/src/commands/working-groups/createOpening.ts
  25. 56 0
      cli/src/commands/working-groups/decreaseWorkerStake.ts
  26. 56 0
      cli/src/commands/working-groups/evictWorker.ts
  27. 49 55
      cli/src/commands/working-groups/fillOpening.ts
  28. 46 0
      cli/src/commands/working-groups/increaseStake.ts
  29. 28 0
      cli/src/commands/working-groups/leaveRole.ts
  30. 67 65
      cli/src/commands/working-groups/opening.ts
  31. 18 17
      cli/src/commands/working-groups/openings.ts
  32. 34 32
      cli/src/commands/working-groups/overview.ts
  33. 55 0
      cli/src/commands/working-groups/slashWorker.ts
  34. 37 43
      cli/src/commands/working-groups/startAcceptingApplications.ts
  35. 35 43
      cli/src/commands/working-groups/startReviewPeriod.ts
  36. 35 42
      cli/src/commands/working-groups/terminateApplication.ts
  37. 48 0
      cli/src/commands/working-groups/updateRewardAccount.ts
  38. 58 0
      cli/src/commands/working-groups/updateRoleAccount.ts
  39. 67 0
      cli/src/commands/working-groups/updateWorkerReward.ts
  40. 52 47
      cli/src/helpers/display.ts
  41. 30 0
      cli/src/helpers/promptOptions.ts
  42. 14 14
      cli/src/helpers/validation.ts
  43. 1 1
      cli/src/index.ts
  44. 44 0
      cli/src/promptOptions/addWorkerOpening.ts
  45. 51 0
      cli/src/validators/common.ts
  46. 7 7
      cli/test/commands/council/info.test.ts
  47. 1 3
      cli/test/tsconfig.json
  48. 2 1
      cli/tsconfig.json
  49. 2 2
      package.json
  50. 53 3
      pioneer/packages/joy-proposals/src/Proposal/Body.tsx
  51. 1 1
      pioneer/packages/joy-proposals/src/Proposal/Votes.tsx
  52. 357 0
      pioneer/packages/joy-proposals/src/forms/AddWorkingGroupOpeningForm.tsx
  53. 48 10
      pioneer/packages/joy-proposals/src/forms/GenericProposalForm.tsx
  54. 84 0
      pioneer/packages/joy-proposals/src/forms/GenericWorkingGroupProposalForm.tsx
  55. 1 1
      pioneer/packages/joy-proposals/src/forms/errorHandling.ts
  56. 1 0
      pioneer/packages/joy-proposals/src/forms/index.ts
  57. 3 1
      pioneer/packages/joy-proposals/src/index.tsx
  58. 105 1
      pioneer/packages/joy-proposals/src/validationSchema.ts
  59. 1 1
      pioneer/packages/joy-roles/src/index.tsx
  60. 72 113
      pioneer/packages/joy-roles/src/tabs/Admin.controller.tsx
  61. 24 10
      pioneer/packages/joy-utils/src/MemberProfilePreview.tsx
  62. 2 2
      pioneer/packages/joy-utils/src/MyAccount.tsx
  63. 57 1
      pioneer/packages/joy-utils/src/consts/proposals.ts
  64. 4 0
      pioneer/packages/joy-utils/src/consts/workingGroups.ts
  65. 4 2
      pioneer/packages/joy-utils/src/react/hooks/usePromise.tsx
  66. 3 0
      pioneer/packages/joy-utils/src/transport/index.ts
  67. 5 0
      pioneer/packages/joy-utils/src/transport/members.ts
  68. 10 28
      pioneer/packages/joy-utils/src/transport/proposals.ts
  69. 47 0
      pioneer/packages/joy-utils/src/transport/workingGroups.ts
  70. 1 0
      pioneer/packages/joy-utils/src/types/common.ts
  71. 2 1
      pioneer/packages/joy-utils/src/types/proposals.ts
  72. 7 0
      pioneer/packages/joy-utils/src/types/workingGroups.ts
  73. 3 0
      runtime/src/migration.rs
  74. 4 2
      storage-node/.gitignore
  75. 4 0
      storage-node/package.json
  76. 4 0
      storage-node/packages/cli/.eslintignore
  77. 2 240
      storage-node/packages/cli/bin/cli.js
  78. 9 4
      storage-node/packages/cli/package.json
  79. 123 0
      storage-node/packages/cli/src/cli.ts
  80. 48 0
      storage-node/packages/cli/src/commands/base.ts
  81. 4 2
      storage-node/packages/cli/src/commands/dev.ts
  82. 77 0
      storage-node/packages/cli/src/commands/download.ts
  83. 50 0
      storage-node/packages/cli/src/commands/head.ts
  84. 220 0
      storage-node/packages/cli/src/commands/upload.ts
  85. 0 0
      storage-node/packages/cli/src/test/index.ts
  86. 11 0
      storage-node/packages/cli/tsconfig.json
  87. 8 8
      storage-node/packages/colossus/bin/cli.js
  88. 3 1
      storage-node/packages/colossus/lib/app.js
  89. 1 1
      storage-node/packages/colossus/lib/discovery.js
  90. 3 3
      storage-node/packages/colossus/lib/middleware/file_uploads.js
  91. 2 2
      storage-node/packages/colossus/lib/middleware/validate_responses.js
  92. 1 1
      storage-node/packages/colossus/lib/sync.js
  93. 4 4
      storage-node/packages/colossus/paths/asset/v0/{id}.js
  94. 1 1
      storage-node/packages/colossus/paths/discover/v0/{id}.js
  95. 36 36
      storage-node/packages/discovery/discover.js
  96. 1 1
      storage-node/packages/discovery/publish.js
  97. 72 72
      storage-node/packages/helios/bin/cli.js
  98. 2 2
      storage-node/packages/runtime-api/assets.js
  99. 10 8
      storage-node/packages/runtime-api/index.js
  100. 33 0
      storage-node/packages/runtime-api/system.js

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

@@ -3,7 +3,7 @@ on: [pull_request, push]
 
 jobs:
   cli_build_ubuntu:
-    name: Ubuntu Build
+    name: Ubuntu Checks
     runs-on: ubuntu-latest
     strategy:
       matrix:
@@ -14,14 +14,14 @@ jobs:
       uses: actions/setup-node@v1
       with:
         node-version: ${{ matrix.node-version }}
-    - name: build
+    - name: checks
       run: |
         yarn install --frozen-lockfile
         yarn madge --circular types/
-        yarn workspace joystream-cli build
+        yarn workspace joystream-cli checks
 
   cli_build_osx:
-    name: MacOS Build
+    name: MacOS Checks
     runs-on: macos-latest
     strategy:
       matrix:
@@ -32,8 +32,8 @@ jobs:
       uses: actions/setup-node@v1
       with:
         node-version: ${{ matrix.node-version }}
-    - name: build
+    - name: checks
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
         yarn madge --circular types/
-        yarn workspace joystream-cli build
+        yarn workspace joystream-cli checks

+ 7 - 0
cli/.eslintrc.js

@@ -1,4 +1,7 @@
 module.exports = {
+  env: {
+    mocha: true,
+  },
   extends: [
     // The oclif rules have some code-style/formatting rules which may conflict with
     // our prettier global settings. Disabling for now
@@ -7,4 +10,8 @@ module.exports = {
     // "oclif",
     // "oclif-typescript",
   ],
+  rules: {
+    "no-unused-vars": "off", // Required by the typescript rule below
+    "@typescript-eslint/no-unused-vars": ["error"]
+  }
 }

+ 1 - 1
cli/package.json

@@ -90,7 +90,7 @@
     "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"",
     "build": "tsc --build tsconfig.json",
     "version": "oclif-dev readme && git add README.md",
-    "lint": "eslint ./src/ --quiet --ext .ts",
+    "lint": "eslint ./ --quiet --ext .ts",
     "checks": "yarn lint && tsc --noEmit --pretty && prettier ./ --check",
     "format": "prettier ./ --write"
   },

+ 384 - 355
cli/src/Api.ts

@@ -1,440 +1,469 @@
-import BN from 'bn.js';
-import { registerJoystreamTypes } from '@joystream/types/';
-import { ApiPromise, WsProvider } from '@polkadot/api';
-import { QueryableStorageMultiArg } from '@polkadot/api/types';
-import { formatBalance } from '@polkadot/util';
-import { Hash, Balance } from '@polkadot/types/interfaces';
-import { KeyringPair } from '@polkadot/keyring/types';
-import { Codec } from '@polkadot/types/types';
-import { Option, Vec } from '@polkadot/types';
-import { u32 } from '@polkadot/types/primitive';
+import BN from 'bn.js'
+import { registerJoystreamTypes } from '@joystream/types/'
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { QueryableStorageMultiArg } from '@polkadot/api/types'
+import { formatBalance } from '@polkadot/util'
+import { Hash, Balance, Moment } from '@polkadot/types/interfaces'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { Codec } from '@polkadot/types/types'
+import { Option, Vec } from '@polkadot/types'
+import { u32 } from '@polkadot/types/primitive'
 import {
-    AccountSummary,
-    CouncilInfoObj, CouncilInfoTuple, createCouncilInfoObj,
-    WorkingGroups,
-    GroupMember,
-    OpeningStatus,
-    GroupOpeningStage,
-    GroupOpening,
-    GroupApplication
-} from './Types';
-import { DerivedFees, DerivedBalances } from '@polkadot/api-derive/types';
-import { CLIError } from '@oclif/errors';
-import ExitCodes from './ExitCodes';
+  AccountSummary,
+  CouncilInfoObj,
+  CouncilInfoTuple,
+  createCouncilInfoObj,
+  WorkingGroups,
+  Reward,
+  GroupMember,
+  OpeningStatus,
+  GroupOpeningStage,
+  GroupOpening,
+  GroupApplication,
+} from './Types'
+import { DerivedFees, DerivedBalances } from '@polkadot/api-derive/types'
+import { CLIError } from '@oclif/errors'
+import ExitCodes from './ExitCodes'
 import {
-    Worker, WorkerId,
-    RoleStakeProfile,
-    Opening as WGOpening,
-    Application as WGApplication
-} from '@joystream/types/working-group';
+  Worker,
+  WorkerId,
+  RoleStakeProfile,
+  Opening as WGOpening,
+  Application as WGApplication,
+} from '@joystream/types/working-group'
 import {
-    Opening,
-    Application,
-    OpeningStage,
-    ApplicationStageKeys,
-    ApplicationId,
-    OpeningId
-} from '@joystream/types/hiring';
-import { MemberId, Profile } from '@joystream/types/members';
-import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recurring-rewards';
-import { Stake, StakeId } from '@joystream/types/stake';
-import { LinkageResult } from '@polkadot/types/codec/Linkage';
-import { Moment } from '@polkadot/types/interfaces';
-
-export const DEFAULT_API_URI = 'wss://rome-rpc-endpoint.joystream.org:9944/';
-const DEFAULT_DECIMALS = new u32(12);
+  Opening,
+  Application,
+  OpeningStage,
+  ApplicationStageKeys,
+  ApplicationId,
+  OpeningId,
+} from '@joystream/types/hiring'
+import { MemberId, Profile } from '@joystream/types/members'
+import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recurring-rewards'
+import { Stake, StakeId } from '@joystream/types/stake'
+import { LinkageResult } from '@polkadot/types/codec/Linkage'
+
+import { InputValidationLengthConstraint } from '@joystream/types/common'
+
+export const DEFAULT_API_URI = 'wss://rome-rpc-endpoint.joystream.org:9944/'
+const DEFAULT_DECIMALS = new u32(12)
 
 // Mapping of working group to api module
 export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
-    [WorkingGroups.StorageProviders]: 'storageWorkingGroup'
-};
+  [WorkingGroups.StorageProviders]: 'storageWorkingGroup',
+}
 
 // Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
 export default class Api {
-    private _api: ApiPromise;
+  private _api: ApiPromise
 
-    private constructor(originalApi: ApiPromise) {
-        this._api = originalApi;
-    }
+  private constructor(originalApi: ApiPromise) {
+    this._api = originalApi
+  }
 
-    public getOriginalApi(): ApiPromise {
-        return this._api;
-    }
+  public getOriginalApi(): ApiPromise {
+    return this._api
+  }
 
-    private static async initApi(apiUri: string = DEFAULT_API_URI): Promise<ApiPromise> {
-        const wsProvider: WsProvider = new WsProvider(apiUri);
-        registerJoystreamTypes();
-        const api = await ApiPromise.create({ provider: wsProvider });
+  private static async initApi(apiUri: string = DEFAULT_API_URI): Promise<ApiPromise> {
+    const wsProvider: WsProvider = new WsProvider(apiUri)
+    registerJoystreamTypes()
+    const api = await ApiPromise.create({ provider: wsProvider })
 
-        // Initializing some api params based on pioneer/packages/react-api/Api.tsx
-        const [properties] = await Promise.all([
-            api.rpc.system.properties()
-        ]);
+    // Initializing some api params based on pioneer/packages/react-api/Api.tsx
+    const [properties] = await Promise.all([api.rpc.system.properties()])
 
-        const tokenSymbol = properties.tokenSymbol.unwrapOr('DEV').toString();
-        const tokenDecimals = properties.tokenDecimals.unwrapOr(DEFAULT_DECIMALS).toNumber();
+    const tokenSymbol = properties.tokenSymbol.unwrapOr('DEV').toString()
+    const tokenDecimals = properties.tokenDecimals.unwrapOr(DEFAULT_DECIMALS).toNumber()
 
-        // formatBlanace config
-        formatBalance.setDefaults({
-            decimals: tokenDecimals,
-            unit: tokenSymbol
-        });
+    // formatBlanace config
+    formatBalance.setDefaults({
+      decimals: tokenDecimals,
+      unit: tokenSymbol,
+    })
 
-        return api;
-    }
+    return api
+  }
 
-    static async create(apiUri: string = DEFAULT_API_URI): Promise<Api> {
-        const originalApi: ApiPromise = await Api.initApi(apiUri);
-        return new Api(originalApi);
-    }
+  static async create(apiUri: string = DEFAULT_API_URI): Promise<Api> {
+    const originalApi: ApiPromise = await Api.initApi(apiUri)
+    return new Api(originalApi)
+  }
 
-    private async queryMultiOnce(queries: Parameters<typeof ApiPromise.prototype.queryMulti>[0]): Promise<Codec[]> {
-        let results: Codec[] = [];
+  private async queryMultiOnce(queries: Parameters<typeof ApiPromise.prototype.queryMulti>[0]): Promise<Codec[]> {
+    let results: Codec[] = []
 
-        const unsub = await this._api.queryMulti(
-            queries,
-            (res) => { results = res }
-        );
-        unsub();
+    const unsub = await this._api.queryMulti(queries, (res) => {
+      results = res
+    })
+    unsub()
 
-        if (!results.length || results.length !== queries.length) {
-            throw new CLIError('API querying issue', { exit: ExitCodes.ApiError });
-        }
-
-        return results;
+    if (!results.length || results.length !== queries.length) {
+      throw new CLIError('API querying issue', { exit: ExitCodes.ApiError })
     }
 
-    async getAccountsBalancesInfo(accountAddresses: string[]): Promise<DerivedBalances[]> {
-        let accountsBalances: DerivedBalances[] = await this._api.derive.balances.votingBalances(accountAddresses);
-
-        return accountsBalances;
+    return results
+  }
+
+  async getAccountsBalancesInfo(accountAddresses: string[]): Promise<DerivedBalances[]> {
+    const accountsBalances: DerivedBalances[] = await this._api.derive.balances.votingBalances(accountAddresses)
+
+    return accountsBalances
+  }
+
+  // Get on-chain data related to given account.
+  // For now it's just account balances
+  async getAccountSummary(accountAddresses: string): Promise<AccountSummary> {
+    const balances: DerivedBalances = (await this.getAccountsBalancesInfo([accountAddresses]))[0]
+    // TODO: Some more information can be fetched here in the future
+
+    return { balances }
+  }
+
+  async getCouncilInfo(): Promise<CouncilInfoObj> {
+    const queries: { [P in keyof CouncilInfoObj]: QueryableStorageMultiArg<'promise'> } = {
+      activeCouncil: this._api.query.council.activeCouncil,
+      termEndsAt: this._api.query.council.termEndsAt,
+      autoStart: this._api.query.councilElection.autoStart,
+      newTermDuration: this._api.query.councilElection.newTermDuration,
+      candidacyLimit: this._api.query.councilElection.candidacyLimit,
+      councilSize: this._api.query.councilElection.councilSize,
+      minCouncilStake: this._api.query.councilElection.minCouncilStake,
+      minVotingStake: this._api.query.councilElection.minVotingStake,
+      announcingPeriod: this._api.query.councilElection.announcingPeriod,
+      votingPeriod: this._api.query.councilElection.votingPeriod,
+      revealingPeriod: this._api.query.councilElection.revealingPeriod,
+      round: this._api.query.councilElection.round,
+      stage: this._api.query.councilElection.stage,
     }
+    const results: CouncilInfoTuple = (await this.queryMultiOnce(Object.values(queries))) as CouncilInfoTuple
 
-    // Get on-chain data related to given account.
-    // For now it's just account balances
-    async getAccountSummary(accountAddresses: string): Promise<AccountSummary> {
-        const balances: DerivedBalances = (await this.getAccountsBalancesInfo([accountAddresses]))[0];
-        // TODO: Some more information can be fetched here in the future
+    return createCouncilInfoObj(...results)
+  }
 
-        return { balances };
-    }
+  // TODO: This formula is probably not too good, so some better implementation will be required in the future
+  async estimateFee(account: KeyringPair, recipientAddr: string, amount: BN): Promise<BN> {
+    const transfer = this._api.tx.balances.transfer(recipientAddr, amount)
+    const signature = account.sign(transfer.toU8a())
+    const transactionByteSize: BN = new BN(transfer.encodedLength + signature.length)
 
-    async getCouncilInfo(): Promise<CouncilInfoObj> {
-        const queries: { [P in keyof CouncilInfoObj]: QueryableStorageMultiArg<"promise"> } = {
-            activeCouncil: this._api.query.council.activeCouncil,
-            termEndsAt: this._api.query.council.termEndsAt,
-            autoStart: this._api.query.councilElection.autoStart,
-            newTermDuration: this._api.query.councilElection.newTermDuration,
-            candidacyLimit: this._api.query.councilElection.candidacyLimit,
-            councilSize: this._api.query.councilElection.councilSize,
-            minCouncilStake: this._api.query.councilElection.minCouncilStake,
-            minVotingStake: this._api.query.councilElection.minVotingStake,
-            announcingPeriod: this._api.query.councilElection.announcingPeriod,
-            votingPeriod: this._api.query.councilElection.votingPeriod,
-            revealingPeriod: this._api.query.councilElection.revealingPeriod,
-            round: this._api.query.councilElection.round,
-            stage: this._api.query.councilElection.stage
-        }
-        const results: CouncilInfoTuple = <CouncilInfoTuple>await this.queryMultiOnce(Object.values(queries));
-
-        return createCouncilInfoObj(...results);
-    }
+    const fees: DerivedFees = await this._api.derive.balances.fees()
 
-    // TODO: This formula is probably not too good, so some better implementation will be required in the future
-    async estimateFee(account: KeyringPair, recipientAddr: string, amount: BN): Promise<BN> {
-        const transfer = this._api.tx.balances.transfer(recipientAddr, amount);
-        const signature = account.sign(transfer.toU8a());
-        const transactionByteSize: BN = new BN(transfer.encodedLength + signature.length);
+    const estimatedFee = fees.transactionBaseFee.add(fees.transactionByteFee.mul(transactionByteSize))
 
-        const fees: DerivedFees = await this._api.derive.balances.fees();
+    return estimatedFee
+  }
 
-        const estimatedFee = fees.transactionBaseFee.add(fees.transactionByteFee.mul(transactionByteSize));
+  async transfer(account: KeyringPair, recipientAddr: string, amount: BN): Promise<Hash> {
+    const txHash = await this._api.tx.balances.transfer(recipientAddr, amount).signAndSend(account)
+    return txHash
+  }
 
-        return estimatedFee;
-    }
+  // Working groups
+  // TODO: This is a lot of repeated logic from "/pioneer/joy-roles/src/transport.substrate.ts"
+  // (although simplified a little bit)
+  // Hopefully this will be refactored to "joystream-js" soon
+  protected singleLinkageResult<T extends Codec>(result: LinkageResult) {
+    return result[0] as T
+  }
 
-    async transfer(account: KeyringPair, recipientAddr: string, amount: BN): Promise<Hash> {
-        const txHash = await this._api.tx.balances
-            .transfer(recipientAddr, amount)
-            .signAndSend(account);
-        return txHash;
-    }
+  protected multiLinkageResult<K extends Codec, V extends Codec>(result: LinkageResult): [Vec<K>, Vec<V>] {
+    return [result[0] as Vec<K>, result[1] as Vec<V>]
+  }
 
-    // Working groups
-    // TODO: This is a lot of repeated logic from "/pioneer/joy-roles/src/transport.substrate.ts"
-    // (although simplified a little bit)
-    // Hopefully this will be refactored to "joystream-js" soon
-    protected singleLinkageResult<T extends Codec>(result: LinkageResult) {
-        return result[0] as T;
-    }
+  protected async blockHash(height: number): Promise<string> {
+    const blockHash = await this._api.rpc.chain.getBlockHash(height)
 
-    protected multiLinkageResult<K extends Codec, V extends Codec>(result: LinkageResult): [Vec<K>, Vec<V>] {
-        return [result[0] as Vec<K>, result[1] as Vec<V>];
-    }
+    return blockHash.toString()
+  }
 
-    protected async blockHash(height: number): Promise<string> {
-        const blockHash = await this._api.rpc.chain.getBlockHash(height);
+  protected async blockTimestamp(height: number): Promise<Date> {
+    const blockTime = (await this._api.query.timestamp.now.at(await this.blockHash(height))) as Moment
 
-        return blockHash.toString();
-    }
+    return new Date(blockTime.toNumber())
+  }
 
-    protected async blockTimestamp(height: number): Promise<Date> {
-        const blockTime = (await this._api.query.timestamp.now.at(await this.blockHash(height))) as Moment;
+  protected workingGroupApiQuery(group: WorkingGroups) {
+    const module = apiModuleByGroup[group]
+    return this._api.query[module]
+  }
 
-        return new Date(blockTime.toNumber());
-    }
+  protected async memberProfileById(memberId: MemberId): Promise<Profile | null> {
+    const profile = (await this._api.query.members.memberProfile(memberId)) as Option<Profile>
 
-    protected workingGroupApiQuery(group: WorkingGroups) {
-        const module = apiModuleByGroup[group];
-        return this._api.query[module];
-    }
+    return profile.unwrapOr(null)
+  }
 
-    protected async memberProfileById(memberId: MemberId): Promise<Profile | null> {
-        const profile = await this._api.query.members.memberProfile(memberId) as Option<Profile>;
+  async groupLead(group: WorkingGroups): Promise<GroupMember | null> {
+    const optLeadId = (await this.workingGroupApiQuery(group).currentLead()) as Option<WorkerId>
 
-        return profile.unwrapOr(null);
+    if (!optLeadId.isSome) {
+      return null
     }
 
-    async groupLead(group: WorkingGroups): Promise<GroupMember | null> {
-        const optLeadId = (await this.workingGroupApiQuery(group).currentLead()) as Option<WorkerId>;
+    const leadWorkerId = optLeadId.unwrap()
+    const leadWorker = await this.workerByWorkerId(group, leadWorkerId.toNumber())
 
-        if (!optLeadId.isSome) {
-            return null;
-        }
+    return await this.parseGroupMember(leadWorkerId, leadWorker)
+  }
 
-        const leadWorkerId = optLeadId.unwrap();
-        const leadWorker = this.singleLinkageResult<Worker>(
-            await this.workingGroupApiQuery(group).workerById(leadWorkerId) as LinkageResult
-        );
+  protected async stakeValue(stakeId: StakeId): Promise<Balance> {
+    const stake = this.singleLinkageResult<Stake>((await this._api.query.stake.stakes(stakeId)) as LinkageResult)
+    return stake.value
+  }
 
-        if (!leadWorker.is_active) {
-            return null;
-        }
+  protected async workerStake(stakeProfile: RoleStakeProfile): Promise<Balance> {
+    return this.stakeValue(stakeProfile.stake_id)
+  }
 
-        return await this.groupMember(leadWorkerId, leadWorker);
-    }
+  protected async workerReward(relationshipId: RewardRelationshipId): Promise<Reward> {
+    const rewardRelationship = this.singleLinkageResult<RewardRelationship>(
+      (await this._api.query.recurringRewards.rewardRelationships(relationshipId)) as LinkageResult
+    )
 
-    protected async stakeValue(stakeId: StakeId): Promise<Balance> {
-        const stake = this.singleLinkageResult<Stake>(
-            await this._api.query.stake.stakes(stakeId) as LinkageResult
-        );
-        return stake.value;
+    return {
+      totalRecieved: rewardRelationship.total_reward_received,
+      value: rewardRelationship.amount_per_payout,
+      interval: rewardRelationship.payout_interval.unwrapOr(undefined)?.toNumber(),
+      nextPaymentBlock: rewardRelationship.next_payment_at_block.unwrapOr(new BN(0)).toNumber(),
     }
+  }
 
-    protected async workerStake (stakeProfile: RoleStakeProfile): Promise<Balance> {
-        return this.stakeValue(stakeProfile.stake_id);
+  protected async parseGroupMember(id: WorkerId, worker: Worker): Promise<GroupMember> {
+    const roleAccount = worker.role_account_id
+    const memberId = worker.member_id
+
+    const profile = await this.memberProfileById(memberId)
+
+    if (!profile) {
+      throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`)
     }
 
-    protected async workerTotalReward(relationshipId: RewardRelationshipId): Promise<Balance> {
-        const relationship = this.singleLinkageResult<RewardRelationship>(
-            await this._api.query.recurringRewards.rewardRelationships(relationshipId) as LinkageResult
-        );
-        return relationship.total_reward_received;
+    let stake: Balance | undefined
+    if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
+      stake = await this.workerStake(worker.role_stake_profile.unwrap())
     }
 
-    protected async groupMember(
-        id: WorkerId,
-        worker: Worker
-    ): Promise<GroupMember> {
-        const roleAccount = worker.role_account_id;
-        const memberId = worker.member_id;
-
-        const profile = await this.memberProfileById(memberId);
-
-        if (!profile) {
-            throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`);
-        }
-
-        let stakeValue: Balance = this._api.createType("Balance", 0);
-        if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
-            stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
-        }
-
-        let earnedValue: Balance = this._api.createType("Balance", 0);
-        if (worker.reward_relationship && worker.reward_relationship.isSome) {
-            earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
-        }
-
-        return ({
-            workerId: id,
-            roleAccount,
-            memberId,
-            profile,
-            stake: stakeValue,
-            earned: earnedValue
-        });
+    let reward: Reward | undefined
+    if (worker.reward_relationship && worker.reward_relationship.isSome) {
+      reward = await this.workerReward(worker.reward_relationship.unwrap())
     }
 
-    async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
-        const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId;
+    return {
+      workerId: id,
+      roleAccount,
+      memberId,
+      profile,
+      stake,
+      reward,
+    }
+  }
 
-        // This is chain specfic, but if next id is still 0, it means no curators have been added yet
-        if (nextId.eq(0)) {
-            return [];
-        }
+  async workerByWorkerId(group: WorkingGroups, workerId: number): Promise<Worker> {
+    const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId
 
-        const [workerIds, workers] = this.multiLinkageResult<WorkerId, Worker>(
-            (await this.workingGroupApiQuery(group).workerById()) as LinkageResult
-        );
+    // This is chain specfic, but if next id is still 0, it means no workers have been added yet
+    if (workerId < 0 || workerId >= nextId.toNumber()) {
+      throw new CLIError('Invalid worker id!')
+    }
 
-        let groupMembers: GroupMember[] = [];
-        for (let [index, worker] of Object.entries(workers.toArray())) {
-            const workerId = workerIds[parseInt(index)];
-            groupMembers.push(await this.groupMember(workerId, worker));
-        }
+    const worker = this.singleLinkageResult<Worker>(
+      (await this.workingGroupApiQuery(group).workerById(workerId)) as LinkageResult
+    )
 
-        return groupMembers.reverse();
+    if (!worker.is_active) {
+      throw new CLIError('This worker is not active anymore')
     }
 
-    async openingsByGroup(group: WorkingGroups): Promise<GroupOpening[]> {
-        const openings: GroupOpening[] = [];
-        const nextId = (await this.workingGroupApiQuery(group).nextOpeningId()) as OpeningId;
+    return worker
+  }
 
-        // This is chain specfic, but if next id is still 0, it means no openings have been added yet
-        if (!nextId.eq(0)) {
-            const highestId = nextId.toNumber() - 1;
-            for (let i = highestId; i >= 0; i--) {
-                openings.push(await this.groupOpening(group, i));
-            }
-        }
+  async groupMember(group: WorkingGroups, workerId: number) {
+    const worker = await this.workerByWorkerId(group, workerId)
+    return await this.parseGroupMember(new WorkerId(workerId), worker)
+  }
 
-        return openings;
-    }
+  async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
+    const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId
 
-    protected async hiringOpeningById(id: number | OpeningId): Promise<Opening> {
-        const result = await this._api.query.hiring.openingById(id) as LinkageResult;
-        return this.singleLinkageResult<Opening>(result);
+    // This is chain specfic, but if next id is still 0, it means no workers have been added yet
+    if (nextId.eq(0)) {
+      return []
     }
 
-    protected async hiringApplicationById(id: number | ApplicationId): Promise<Application> {
-        const result = await this._api.query.hiring.applicationById(id) as LinkageResult;
-        return this.singleLinkageResult<Application>(result);
+    const [workerIds, workers] = this.multiLinkageResult<WorkerId, Worker>(
+      (await this.workingGroupApiQuery(group).workerById()) as LinkageResult
+    )
+
+    const groupMembers: GroupMember[] = []
+    for (const [index, worker] of Object.entries(workers.toArray())) {
+      const workerId = workerIds[parseInt(index)]
+      if (worker.is_active) {
+        groupMembers.push(await this.parseGroupMember(workerId, worker))
+      }
     }
 
-    async wgApplicationById(group: WorkingGroups, wgApplicationId: number): Promise<WGApplication> {
-        const nextAppId = await this.workingGroupApiQuery(group).nextApplicationId() as ApplicationId;
+    return groupMembers.reverse()
+  }
 
-        if (wgApplicationId < 0 || wgApplicationId >= nextAppId.toNumber()) {
-            throw new CLIError('Invalid working group application ID!');
-        }
+  async openingsByGroup(group: WorkingGroups): Promise<GroupOpening[]> {
+    const openings: GroupOpening[] = []
+    const nextId = (await this.workingGroupApiQuery(group).nextOpeningId()) as OpeningId
 
-        return this.singleLinkageResult<WGApplication>(
-            await this.workingGroupApiQuery(group).applicationById(wgApplicationId) as LinkageResult
-        );
+    // This is chain specfic, but if next id is still 0, it means no openings have been added yet
+    if (!nextId.eq(0)) {
+      const highestId = nextId.toNumber() - 1
+      for (let i = highestId; i >= 0; i--) {
+        openings.push(await this.groupOpening(group, i))
+      }
     }
 
-    protected async parseApplication(wgApplicationId: number, wgApplication: WGApplication): Promise<GroupApplication> {
-        const appId = wgApplication.application_id;
-        const application = await this.hiringApplicationById(appId);
-
-        const { active_role_staking_id: roleStakingId, active_application_staking_id: appStakingId } = application;
-
-        return {
-            wgApplicationId,
-            applicationId: appId.toNumber(),
-            member: await this.memberProfileById(wgApplication.member_id),
-            roleAccout: wgApplication.role_account_id,
-            stakes: {
-                application: appStakingId.isSome ? (await this.stakeValue(appStakingId.unwrap())).toNumber() : 0,
-                role: roleStakingId.isSome ? (await this.stakeValue(roleStakingId.unwrap())).toNumber() : 0
-            },
-            humanReadableText: application.human_readable_text.toString(),
-            stage: application.stage.type as ApplicationStageKeys
-        };
+    return openings
+  }
+
+  protected async hiringOpeningById(id: number | OpeningId): Promise<Opening> {
+    const result = (await this._api.query.hiring.openingById(id)) as LinkageResult
+    return this.singleLinkageResult<Opening>(result)
+  }
+
+  protected async hiringApplicationById(id: number | ApplicationId): Promise<Application> {
+    const result = (await this._api.query.hiring.applicationById(id)) as LinkageResult
+    return this.singleLinkageResult<Application>(result)
+  }
+
+  async wgApplicationById(group: WorkingGroups, wgApplicationId: number): Promise<WGApplication> {
+    const nextAppId = (await this.workingGroupApiQuery(group).nextApplicationId()) as ApplicationId
+
+    if (wgApplicationId < 0 || wgApplicationId >= nextAppId.toNumber()) {
+      throw new CLIError('Invalid working group application ID!')
     }
 
-    async groupApplication(group: WorkingGroups, wgApplicationId: number): Promise<GroupApplication> {
-        const wgApplication = await this.wgApplicationById(group, wgApplicationId);
-        return await this.parseApplication(wgApplicationId, wgApplication);
+    return this.singleLinkageResult<WGApplication>(
+      (await this.workingGroupApiQuery(group).applicationById(wgApplicationId)) as LinkageResult
+    )
+  }
+
+  protected async parseApplication(wgApplicationId: number, wgApplication: WGApplication): Promise<GroupApplication> {
+    const appId = wgApplication.application_id
+    const application = await this.hiringApplicationById(appId)
+
+    const { active_role_staking_id: roleStakingId, active_application_staking_id: appStakingId } = application
+
+    return {
+      wgApplicationId,
+      applicationId: appId.toNumber(),
+      wgOpeningId: wgApplication.opening_id.toNumber(),
+      member: await this.memberProfileById(wgApplication.member_id),
+      roleAccout: wgApplication.role_account_id,
+      stakes: {
+        application: appStakingId.isSome ? (await this.stakeValue(appStakingId.unwrap())).toNumber() : 0,
+        role: roleStakingId.isSome ? (await this.stakeValue(roleStakingId.unwrap())).toNumber() : 0,
+      },
+      humanReadableText: application.human_readable_text.toString(),
+      stage: application.stage.type as ApplicationStageKeys,
+    }
+  }
+
+  async groupApplication(group: WorkingGroups, wgApplicationId: number): Promise<GroupApplication> {
+    const wgApplication = await this.wgApplicationById(group, wgApplicationId)
+    return await this.parseApplication(wgApplicationId, wgApplication)
+  }
+
+  protected async groupOpeningApplications(group: WorkingGroups, wgOpeningId: number): Promise<GroupApplication[]> {
+    const applications: GroupApplication[] = []
+
+    const nextAppId = (await this.workingGroupApiQuery(group).nextApplicationId()) as ApplicationId
+    for (let i = 0; i < nextAppId.toNumber(); i++) {
+      const wgApplication = await this.wgApplicationById(group, i)
+      if (wgApplication.opening_id.toNumber() !== wgOpeningId) {
+        continue
+      }
+      applications.push(await this.parseApplication(i, wgApplication))
     }
 
-    protected async groupOpeningApplications(group: WorkingGroups, wgOpeningId: number): Promise<GroupApplication[]> {
-        const applications: GroupApplication[] = [];
+    return applications
+  }
 
-        const nextAppId = await this.workingGroupApiQuery(group).nextApplicationId() as ApplicationId;
-        for (let i = 0; i < nextAppId.toNumber(); i++) {
-            const wgApplication = await this.wgApplicationById(group, i);
-            if (wgApplication.opening_id.toNumber() !== wgOpeningId) {
-                continue;
-            }
-            applications.push(await this.parseApplication(i, wgApplication));
-        }
+  async groupOpening(group: WorkingGroups, wgOpeningId: number): Promise<GroupOpening> {
+    const nextId = ((await this.workingGroupApiQuery(group).nextOpeningId()) as OpeningId).toNumber()
+
+    if (wgOpeningId < 0 || wgOpeningId >= nextId) {
+      throw new CLIError('Invalid working group opening ID!')
+    }
 
+    const groupOpening = this.singleLinkageResult<WGOpening>(
+      (await this.workingGroupApiQuery(group).openingById(wgOpeningId)) as LinkageResult
+    )
+
+    const openingId = groupOpening.hiring_opening_id.toNumber()
+    const opening = await this.hiringOpeningById(openingId)
+    const applications = await this.groupOpeningApplications(group, wgOpeningId)
+    const stage = await this.parseOpeningStage(opening.stage)
+    const type = groupOpening.opening_type
+    const stakes = {
+      application: opening.application_staking_policy.unwrapOr(undefined),
+      role: opening.role_staking_policy.unwrapOr(undefined),
+    }
 
-        return applications;
+    return {
+      wgOpeningId,
+      openingId,
+      opening,
+      stage,
+      stakes,
+      applications,
+      type,
+    }
+  }
+
+  async parseOpeningStage(stage: OpeningStage): Promise<GroupOpeningStage> {
+    let status: OpeningStatus | undefined, stageBlock: number | undefined, stageDate: Date | undefined
+
+    if (stage.isOfType('WaitingToBegin')) {
+      const stageData = stage.asType('WaitingToBegin')
+      const currentBlockNumber = (await this._api.derive.chain.bestNumber()).toNumber()
+      const expectedBlockTime = (this._api.consts.babe.expectedBlockTime as Moment).toNumber()
+      status = OpeningStatus.WaitingToBegin
+      stageBlock = stageData.begins_at_block.toNumber()
+      stageDate = new Date(Date.now() + (stageBlock - currentBlockNumber) * expectedBlockTime)
     }
 
-    async groupOpening(group: WorkingGroups, wgOpeningId: number): Promise<GroupOpening> {
-        const nextId = ((await this.workingGroupApiQuery(group).nextOpeningId()) as OpeningId).toNumber();
-
-        if (wgOpeningId < 0 || wgOpeningId >= nextId) {
-            throw new CLIError('Invalid working group opening ID!');
-        }
-
-        const groupOpening = this.singleLinkageResult<WGOpening>(
-            await this.workingGroupApiQuery(group).openingById(wgOpeningId) as LinkageResult
-        );
-
-        const openingId = groupOpening.hiring_opening_id.toNumber();
-        const opening = await this.hiringOpeningById(openingId);
-        const applications = await this.groupOpeningApplications(group, wgOpeningId);
-        const stage = await this.parseOpeningStage(opening.stage);
-        const stakes = {
-            application: opening.application_staking_policy.unwrapOr(undefined),
-            role: opening.role_staking_policy.unwrapOr(undefined)
-        }
-
-        return ({
-            wgOpeningId,
-            openingId,
-            opening,
-            stage,
-            stakes,
-            applications
-        });
+    if (stage.isOfType('Active')) {
+      const stageData = stage.asType('Active')
+      const substage = stageData.stage
+      if (substage.isOfType('AcceptingApplications')) {
+        status = OpeningStatus.AcceptingApplications
+        stageBlock = substage.asType('AcceptingApplications').started_accepting_applicants_at_block.toNumber()
+      }
+      if (substage.isOfType('ReviewPeriod')) {
+        status = OpeningStatus.InReview
+        stageBlock = substage.asType('ReviewPeriod').started_review_period_at_block.toNumber()
+      }
+      if (substage.isOfType('Deactivated')) {
+        status = substage.asType('Deactivated').cause.isOfType('Filled')
+          ? OpeningStatus.Complete
+          : OpeningStatus.Cancelled
+        stageBlock = substage.asType('Deactivated').deactivated_at_block.toNumber()
+      }
+      if (stageBlock) {
+        stageDate = new Date(await this.blockTimestamp(stageBlock))
+      }
     }
 
-    async parseOpeningStage(stage: OpeningStage): Promise<GroupOpeningStage> {
-        let
-            status: OpeningStatus | undefined,
-            stageBlock: number | undefined,
-            stageDate: Date | undefined;
-
-        if (stage.isOfType('WaitingToBegin')) {
-            const stageData = stage.asType('WaitingToBegin');
-            const currentBlockNumber = (await this._api.derive.chain.bestNumber()).toNumber();
-            const expectedBlockTime = (this._api.consts.babe.expectedBlockTime as Moment).toNumber();
-            status = OpeningStatus.WaitingToBegin;
-            stageBlock = stageData.begins_at_block.toNumber();
-            stageDate = new Date(Date.now() + (stageBlock - currentBlockNumber) * expectedBlockTime);
-        }
-
-        if (stage.isOfType('Active')) {
-            const stageData = stage.asType('Active');
-            const substage = stageData.stage;
-            if (substage.isOfType('AcceptingApplications')) {
-                status = OpeningStatus.AcceptingApplications;
-                stageBlock = substage.asType('AcceptingApplications').started_accepting_applicants_at_block.toNumber();
-            }
-            if (substage.isOfType('ReviewPeriod')) {
-                status = OpeningStatus.InReview;
-                stageBlock = substage.asType('ReviewPeriod').started_review_period_at_block.toNumber();
-            }
-            if (substage.isOfType('Deactivated')) {
-                status = substage.asType('Deactivated').cause.isOfType('Filled')
-                    ? OpeningStatus.Complete
-                    : OpeningStatus.Cancelled;
-                stageBlock = substage.asType('Deactivated').deactivated_at_block.toNumber();
-            }
-            if (stageBlock) {
-                stageDate = new Date(await this.blockTimestamp(stageBlock));
-            }
-        }
-
-        return {
-            status: status || OpeningStatus.Unknown,
-            block: stageBlock,
-            date: stageDate
-        };
+    return {
+      status: status || OpeningStatus.Unknown,
+      block: stageBlock,
+      date: stageDate,
     }
+  }
+
+  async getMemberIdsByControllerAccount(address: string): Promise<MemberId[]> {
+    const ids = (await this._api.query.members.memberIdsByControllerAccountId(address)) as Vec<MemberId>
+    return ids.toArray()
+  }
+
+  async workerExitRationaleConstraint(group: WorkingGroups): Promise<InputValidationLengthConstraint> {
+    return (await this.workingGroupApiQuery(group).workerExitRationaleText()) as InputValidationLengthConstraint
+  }
 }

+ 11 - 11
cli/src/ExitCodes.ts

@@ -1,15 +1,15 @@
 enum ExitCodes {
-    OK = 0,
+  OK = 0,
 
-    InvalidInput = 400,
-    FileNotFound = 401,
-    InvalidFile = 402,
-    NoAccountFound = 403,
-    NoAccountSelected = 404,
-    AccessDenied = 405,
+  InvalidInput = 400,
+  FileNotFound = 401,
+  InvalidFile = 402,
+  NoAccountFound = 403,
+  NoAccountSelected = 404,
+  AccessDenied = 405,
 
-    UnexpectedException = 500,
-    FsOperationFailed = 501,
-    ApiError = 502,
+  UnexpectedException = 500,
+  FsOperationFailed = 501,
+  ApiError = 502,
 }
-export = ExitCodes;
+export = ExitCodes

+ 301 - 248
cli/src/Types.ts

@@ -1,318 +1,371 @@
-import BN from 'bn.js';
-import { ElectionStage, Seat } from '@joystream/types/council';
-import { Option, Text } from '@polkadot/types';
-import { Constructor } from '@polkadot/types/types';
-import { Struct, Vec } from '@polkadot/types/codec';
-import { u32 } from '@polkadot/types/primitive';
-import { BlockNumber, Balance, AccountId } from '@polkadot/types/interfaces';
-import { DerivedBalances } from '@polkadot/api-derive/types';
-import { KeyringPair } from '@polkadot/keyring/types';
-import { WorkerId } from '@joystream/types/working-group';
-import { Profile, MemberId } from '@joystream/types/members';
+import BN from 'bn.js'
+import { ElectionStage, Seat } from '@joystream/types/council'
+import { Option, Text } from '@polkadot/types'
+import { Constructor, Codec } from '@polkadot/types/types'
+import { Struct, Vec } from '@polkadot/types/codec'
+import { u32 } from '@polkadot/types/primitive'
+import { BlockNumber, Balance, AccountId } from '@polkadot/types/interfaces'
+import { DerivedBalances } from '@polkadot/api-derive/types'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { WorkerId, OpeningType } from '@joystream/types/working-group'
+import { Profile, MemberId } from '@joystream/types/members'
 import {
-    GenericJoyStreamRoleSchema,
-    JobSpecifics,
-    ApplicationDetails,
-    QuestionSections,
-    QuestionSection,
-    QuestionsFields,
-    QuestionField,
-    EntryInMembershipModuke,
-    HiringProcess,
-    AdditionalRolehiringProcessDetails,
-    CreatorDetails
-} from '@joystream/types/hiring/schemas/role.schema.typings';
-import ajv from 'ajv';
-import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/hiring';
+  GenericJoyStreamRoleSchema,
+  JobSpecifics,
+  ApplicationDetails,
+  QuestionSections,
+  QuestionSection,
+  QuestionsFields,
+  QuestionField,
+  EntryInMembershipModuke,
+  HiringProcess,
+  AdditionalRolehiringProcessDetails,
+  CreatorDetails,
+} from '@joystream/types/hiring/schemas/role.schema.typings'
+import ajv from 'ajv'
+import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/hiring'
+import { Validator } from 'inquirer'
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
 // If not provided in the account json file, the meta.name value is set to "Unnamed Account"
 export type NamedKeyringPair = KeyringPair & {
-    meta: {
-        name: string
-    }
+  meta: {
+    name: string
+  }
 }
 
 // Summary of the account information fetched from the api for "account:current" purposes (currently just balances)
 export type AccountSummary = {
-    balances: DerivedBalances
+  balances: DerivedBalances
 }
 
-// Object/Tuple containing council/councilElection information (council:info).
-// The tuple is useful, because that's how api.queryMulti returns the results.
-export type CouncilInfoTuple = Parameters<typeof createCouncilInfoObj>;
-export type CouncilInfoObj = ReturnType<typeof createCouncilInfoObj>;
 // This function allows us to easily transform the tuple into the object
-// and simplifies the creation of consitent Object and Tuple types (seen above).
+// and simplifies the creation of consitent Object and Tuple types (seen below).
 export function createCouncilInfoObj(
-    activeCouncil: Seat[],
-    termEndsAt: BlockNumber,
-    autoStart: Boolean,
-    newTermDuration: BN,
-    candidacyLimit: BN,
-    councilSize: BN,
-    minCouncilStake: Balance,
-    minVotingStake: Balance,
-    announcingPeriod: BlockNumber,
-    votingPeriod: BlockNumber,
-    revealingPeriod: BlockNumber,
-    round: BN,
-    stage: Option<ElectionStage>
+  activeCouncil: Seat[],
+  termEndsAt: BlockNumber,
+  autoStart: boolean,
+  newTermDuration: BN,
+  candidacyLimit: BN,
+  councilSize: BN,
+  minCouncilStake: Balance,
+  minVotingStake: Balance,
+  announcingPeriod: BlockNumber,
+  votingPeriod: BlockNumber,
+  revealingPeriod: BlockNumber,
+  round: BN,
+  stage: Option<ElectionStage>
 ) {
-    return {
-        activeCouncil,
-        termEndsAt,
-        autoStart,
-        newTermDuration,
-        candidacyLimit,
-        councilSize,
-        minCouncilStake,
-        minVotingStake,
-        announcingPeriod,
-        votingPeriod,
-        revealingPeriod,
-        round,
-        stage
-    };
+  return {
+    activeCouncil,
+    termEndsAt,
+    autoStart,
+    newTermDuration,
+    candidacyLimit,
+    councilSize,
+    minCouncilStake,
+    minVotingStake,
+    announcingPeriod,
+    votingPeriod,
+    revealingPeriod,
+    round,
+    stage,
+  }
 }
+// Object/Tuple containing council/councilElection information (council:info).
+// The tuple is useful, because that's how api.queryMulti returns the results.
+export type CouncilInfoTuple = Parameters<typeof createCouncilInfoObj>
+export type CouncilInfoObj = ReturnType<typeof createCouncilInfoObj>
 
 // Object with "name" and "value" properties, used for rendering simple CLI tables like:
 // Total balance:   100 JOY
 // Free calance:     50 JOY
-export type NameValueObj = { name: string, value: string };
+export type NameValueObj = { name: string; value: string }
 
 // Working groups related types
 export enum WorkingGroups {
-    StorageProviders = 'storageProviders'
+  StorageProviders = 'storageProviders',
 }
 
 // In contrast to Pioneer, currently only StorageProviders group is available in CLI
-export const AvailableGroups: readonly WorkingGroups[] = [
-  WorkingGroups.StorageProviders
-] as const;
+export const AvailableGroups: readonly WorkingGroups[] = [WorkingGroups.StorageProviders] as const
+
+export type Reward = {
+  totalRecieved: Balance
+  value: Balance
+  interval?: number
+  nextPaymentBlock: number // 0 = no incoming payment
+}
 
 // Compound working group types
 export type GroupMember = {
-    workerId: WorkerId;
-    memberId: MemberId;
-    roleAccount: AccountId;
-    profile: Profile;
-    stake: Balance;
-    earned: Balance;
+  workerId: WorkerId
+  memberId: MemberId
+  roleAccount: AccountId
+  profile: Profile
+  stake?: Balance
+  reward?: Reward
 }
 
 export type GroupApplication = {
-    wgApplicationId: number;
-    applicationId: number;
-    member: Profile | null;
-    roleAccout: AccountId;
-    stakes: {
-        application: number;
-        role: number;
-    },
-    humanReadableText: string;
-    stage: ApplicationStageKeys;
+  wgApplicationId: number
+  applicationId: number
+  wgOpeningId: number
+  member: Profile | null
+  roleAccout: AccountId
+  stakes: {
+    application: number
+    role: number
+  }
+  humanReadableText: string
+  stage: ApplicationStageKeys
 }
 
 export enum OpeningStatus {
-    WaitingToBegin = 'WaitingToBegin',
-    AcceptingApplications = 'AcceptingApplications',
-    InReview = 'InReview',
-    Complete = 'Complete',
-    Cancelled = 'Cancelled',
-    Unknown = 'Unknown'
+  WaitingToBegin = 'WaitingToBegin',
+  AcceptingApplications = 'AcceptingApplications',
+  InReview = 'InReview',
+  Complete = 'Complete',
+  Cancelled = 'Cancelled',
+  Unknown = 'Unknown',
 }
 
 export type GroupOpeningStage = {
-    status: OpeningStatus;
-    block?: number;
-    date?: Date;
+  status: OpeningStatus
+  block?: number
+  date?: Date
 }
 
 export type GroupOpeningStakes = {
-    application?: StakingPolicy;
-    role?: StakingPolicy;
+  application?: StakingPolicy
+  role?: StakingPolicy
 }
 
 export type GroupOpening = {
-    wgOpeningId: number;
-    openingId: number;
-    stage: GroupOpeningStage;
-    opening: Opening;
-    stakes: GroupOpeningStakes;
-    applications: GroupApplication[];
+  wgOpeningId: number
+  openingId: number
+  stage: GroupOpeningStage
+  opening: Opening
+  stakes: GroupOpeningStakes
+  applications: GroupApplication[]
+  type: OpeningType
 }
 
 // Some helper structs for generating human_readable_text in working group opening extrinsic
 // Note those types are not part of the runtime etc., we just use them to simplify prompting for values
 // (since there exists functionality that handles that for substrate types like: Struct, Vec etc.)
 interface WithJSONable<T> {
-    toJSON: () => T;
+  toJSON: () => T
 }
 export class HRTJobSpecificsStruct extends Struct implements WithJSONable<JobSpecifics> {
-    constructor (value?: JobSpecifics) {
-        super({
-          title: "Text",
-          description: "Text",
-        }, value);
-    }
-    get title(): string {
-        return (this.get('title') as Text).toString();
-    }
-    get description(): string {
-        return (this.get('description') as Text).toString();
-    }
-    toJSON(): JobSpecifics {
-        const { title, description } = this;
-        return { title, description };
-    }
+  constructor(value?: JobSpecifics) {
+    super(
+      {
+        title: 'Text',
+        description: 'Text',
+      },
+      value
+    )
+  }
+  get title(): string {
+    return (this.get('title') as Text).toString()
+  }
+  get description(): string {
+    return (this.get('description') as Text).toString()
+  }
+  toJSON(): JobSpecifics {
+    const { title, description } = this
+    return { title, description }
+  }
 }
 export class HRTEntryInMembershipModukeStruct extends Struct implements WithJSONable<EntryInMembershipModuke> {
-    constructor (value?: EntryInMembershipModuke) {
-        super({
-          handle: "Text",
-        }, value);
-    }
-    get handle(): string {
-        return (this.get('handle') as Text).toString();
-    }
-    toJSON(): EntryInMembershipModuke {
-        const { handle } = this;
-        return { handle };
-    }
+  constructor(value?: EntryInMembershipModuke) {
+    super(
+      {
+        handle: 'Text',
+      },
+      value
+    )
+  }
+  get handle(): string {
+    return (this.get('handle') as Text).toString()
+  }
+  toJSON(): EntryInMembershipModuke {
+    const { handle } = this
+    return { handle }
+  }
 }
 export class HRTCreatorDetailsStruct extends Struct implements WithJSONable<CreatorDetails> {
-    constructor (value?: CreatorDetails) {
-        super({
-          membership: HRTEntryInMembershipModukeStruct,
-        }, value);
-    }
-    get membership(): EntryInMembershipModuke {
-        return (this.get('membership') as HRTEntryInMembershipModukeStruct).toJSON();
-    }
-    toJSON(): CreatorDetails {
-        const { membership } = this;
-        return { membership };
-    }
+  constructor(value?: CreatorDetails) {
+    super(
+      {
+        membership: HRTEntryInMembershipModukeStruct,
+      },
+      value
+    )
+  }
+  get membership(): EntryInMembershipModuke {
+    return (this.get('membership') as HRTEntryInMembershipModukeStruct).toJSON()
+  }
+  toJSON(): CreatorDetails {
+    const { membership } = this
+    return { membership }
+  }
 }
 export class HRTHiringProcessStruct extends Struct implements WithJSONable<HiringProcess> {
-    constructor (value?: HiringProcess) {
-        super({
-          details: "Vec<Text>",
-        }, value);
-    }
-    get details(): AdditionalRolehiringProcessDetails {
-        return (this.get('details') as Vec<Text>).toArray().map(v => v.toString());
-    }
-    toJSON(): HiringProcess {
-        const { details } = this;
-        return { details };
-    }
+  constructor(value?: HiringProcess) {
+    super(
+      {
+        details: 'Vec<Text>',
+      },
+      value
+    )
+  }
+  get details(): AdditionalRolehiringProcessDetails {
+    return (this.get('details') as Vec<Text>).toArray().map((v) => v.toString())
+  }
+  toJSON(): HiringProcess {
+    const { details } = this
+    return { details }
+  }
 }
 export class HRTQuestionFieldStruct extends Struct implements WithJSONable<QuestionField> {
-    constructor (value?: QuestionField) {
-        super({
-            title: "Text",
-            type: "Text"
-        }, value);
-    }
-    get title(): string {
-        return (this.get('title') as Text).toString();
-    }
-    get type(): string {
-        return (this.get('type') as Text).toString();
-    }
-    toJSON(): QuestionField {
-        const { title, type } = this;
-        return { title, type };
-    }
+  constructor(value?: QuestionField) {
+    super(
+      {
+        title: 'Text',
+        type: 'Text',
+      },
+      value
+    )
+  }
+  get title(): string {
+    return (this.get('title') as Text).toString()
+  }
+  get type(): string {
+    return (this.get('type') as Text).toString()
+  }
+  toJSON(): QuestionField {
+    const { title, type } = this
+    return { title, type }
+  }
 }
 class HRTQuestionsFieldsVec extends Vec.with(HRTQuestionFieldStruct) implements WithJSONable<QuestionsFields> {
-    toJSON(): QuestionsFields {
-        return this.toArray().map(v => v.toJSON());
-    }
+  toJSON(): QuestionsFields {
+    return this.toArray().map((v) => v.toJSON())
+  }
 }
 export class HRTQuestionSectionStruct extends Struct implements WithJSONable<QuestionSection> {
-    constructor (value?: QuestionSection) {
-        super({
-            title: "Text",
-            questions: HRTQuestionsFieldsVec
-        }, value);
-    }
-    get title(): string {
-        return (this.get('title') as Text).toString();
-    }
-    get questions(): QuestionsFields {
-        return (this.get('questions') as HRTQuestionsFieldsVec).toJSON();
-    }
-    toJSON(): QuestionSection {
-        const { title, questions } = this;
-        return { title, questions };
-    }
+  constructor(value?: QuestionSection) {
+    super(
+      {
+        title: 'Text',
+        questions: HRTQuestionsFieldsVec,
+      },
+      value
+    )
+  }
+  get title(): string {
+    return (this.get('title') as Text).toString()
+  }
+  get questions(): QuestionsFields {
+    return (this.get('questions') as HRTQuestionsFieldsVec).toJSON()
+  }
+  toJSON(): QuestionSection {
+    const { title, questions } = this
+    return { title, questions }
+  }
+}
+export class HRTQuestionSectionsVec extends Vec.with(HRTQuestionSectionStruct)
+  implements WithJSONable<QuestionSections> {
+  toJSON(): QuestionSections {
+    return this.toArray().map((v) => v.toJSON())
+  }
 }
-export class HRTQuestionSectionsVec extends Vec.with(HRTQuestionSectionStruct) implements WithJSONable<QuestionSections> {
-    toJSON(): QuestionSections {
-        return this.toArray().map(v => v.toJSON());
-    }
-};
 export class HRTApplicationDetailsStruct extends Struct implements WithJSONable<ApplicationDetails> {
-    constructor (value?: ApplicationDetails) {
-        super({
-            sections: HRTQuestionSectionsVec
-        }, value);
-    }
-    get sections(): QuestionSections {
-        return (this.get('sections') as HRTQuestionSectionsVec).toJSON();
-    }
-    toJSON(): ApplicationDetails {
-        const { sections } = this;
-        return { sections };
-    }
+  constructor(value?: ApplicationDetails) {
+    super(
+      {
+        sections: HRTQuestionSectionsVec,
+      },
+      value
+    )
+  }
+  get sections(): QuestionSections {
+    return (this.get('sections') as HRTQuestionSectionsVec).toJSON()
+  }
+  toJSON(): ApplicationDetails {
+    const { sections } = this
+    return { sections }
+  }
 }
 export class HRTStruct extends Struct implements WithJSONable<GenericJoyStreamRoleSchema> {
-    constructor (value?: GenericJoyStreamRoleSchema) {
-        super({
-            version: "u32",
-            headline: "Text",
-            job: HRTJobSpecificsStruct,
-            application: HRTApplicationDetailsStruct,
-            reward: "Text",
-            creator: HRTCreatorDetailsStruct,
-            process: HRTHiringProcessStruct
-        }, value);
-    }
-    get version(): number {
-        return (this.get('version') as u32).toNumber();
-    }
-    get headline(): string {
-        return (this.get('headline') as Text).toString();
-    }
-    get job(): JobSpecifics {
-        return (this.get('job') as HRTJobSpecificsStruct).toJSON();
-    }
-    get application(): ApplicationDetails {
-        return (this.get('application') as HRTApplicationDetailsStruct).toJSON();
-    }
-    get reward(): string {
-        return (this.get('reward') as Text).toString();
-    }
-    get creator(): CreatorDetails {
-        return (this.get('creator') as HRTCreatorDetailsStruct).toJSON();
-    }
-    get process(): HiringProcess {
-        return (this.get('process') as HRTHiringProcessStruct).toJSON();
-    }
-    toJSON(): GenericJoyStreamRoleSchema {
-        const { version, headline, job, application, reward, creator, process } = this;
-        return { version, headline, job, application, reward, creator, process };
-    }
-};
+  constructor(value?: GenericJoyStreamRoleSchema) {
+    super(
+      {
+        version: 'u32',
+        headline: 'Text',
+        job: HRTJobSpecificsStruct,
+        application: HRTApplicationDetailsStruct,
+        reward: 'Text',
+        creator: HRTCreatorDetailsStruct,
+        process: HRTHiringProcessStruct,
+      },
+      value
+    )
+  }
+  get version(): number {
+    return (this.get('version') as u32).toNumber()
+  }
+  get headline(): string {
+    return (this.get('headline') as Text).toString()
+  }
+  get job(): JobSpecifics {
+    return (this.get('job') as HRTJobSpecificsStruct).toJSON()
+  }
+  get application(): ApplicationDetails {
+    return (this.get('application') as HRTApplicationDetailsStruct).toJSON()
+  }
+  get reward(): string {
+    return (this.get('reward') as Text).toString()
+  }
+  get creator(): CreatorDetails {
+    return (this.get('creator') as HRTCreatorDetailsStruct).toJSON()
+  }
+  get process(): HiringProcess {
+    return (this.get('process') as HRTHiringProcessStruct).toJSON()
+  }
+  toJSON(): GenericJoyStreamRoleSchema {
+    const { version, headline, job, application, reward, creator, process } = this
+    return { version, headline, job, application, reward, creator, process }
+  }
+}
+
+// Api-related
 
-// A mapping of argName to json struct and schemaValidator
-// It is used to map arguments of type "Bytes" that are in fact a json string
-// (and can be validated against a schema)
-export type JSONArgsMapping = { [argName: string]: {
-    struct: Constructor<Struct>,
+// Additional options that can be passed to ApiCommandBase.promptForParam in order to override
+// its default behaviour, change param name, add validation etc.
+export type ApiParamOptions<ParamType = Codec> = {
+  forcedName?: string
+  value?: {
+    default: ParamType
+    locked?: boolean
+  }
+  jsonSchema?: {
+    struct: Constructor<Struct>
     schemaValidator: ajv.ValidateFunction
-} };
+  }
+  validator?: Validator
+  nestedOptions?: ApiParamsOptions // For more complex params, like structs
+}
+export type ApiParamsOptions = {
+  [paramName: string]: ApiParamOptions
+}
+
+export type ApiMethodArg = Codec
+export type ApiMethodNamedArg = {
+  name: string
+  value: ApiMethodArg
+}
+export type ApiMethodNamedArgs = ApiMethodNamedArg[]

+ 213 - 224
cli/src/base/AccountsCommandBase.ts

@@ -1,18 +1,18 @@
-import fs from 'fs';
-import path from 'path';
-import slug from 'slug';
-import inquirer from 'inquirer';
-import ExitCodes from '../ExitCodes';
-import { CLIError } from '@oclif/errors';
-import ApiCommandBase from './ApiCommandBase';
-import { Keyring } from '@polkadot/api';
-import { formatBalance } from '@polkadot/util';
-import { NamedKeyringPair } from '../Types';
-import { DerivedBalances } from '@polkadot/api-derive/types';
-import { toFixedLength } from '../helpers/display';
-
-const ACCOUNTS_DIRNAME = 'accounts';
-const SPECIAL_ACCOUNT_POSTFIX = '__DEV';
+import fs from 'fs'
+import path from 'path'
+import slug from 'slug'
+import inquirer from 'inquirer'
+import ExitCodes from '../ExitCodes'
+import { CLIError } from '@oclif/errors'
+import ApiCommandBase from './ApiCommandBase'
+import { Keyring } from '@polkadot/api'
+import { formatBalance } from '@polkadot/util'
+import { NamedKeyringPair } from '../Types'
+import { DerivedBalances } from '@polkadot/api-derive/types'
+import { toFixedLength } from '../helpers/display'
+
+const ACCOUNTS_DIRNAME = 'accounts'
+const SPECIAL_ACCOUNT_POSTFIX = '__DEV'
 
 /**
  * Abstract base class for account-related commands.
@@ -22,216 +22,205 @@ 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 {
-    getAccountsDirPath(): string {
-        return path.join(this.getAppDataPath(), ACCOUNTS_DIRNAME);
+  getAccountsDirPath(): string {
+    return path.join(this.getAppDataPath(), ACCOUNTS_DIRNAME)
+  }
+
+  getAccountFilePath(account: NamedKeyringPair, isSpecial = false): string {
+    return path.join(this.getAccountsDirPath(), this.generateAccountFilename(account, isSpecial))
+  }
+
+  generateAccountFilename(account: NamedKeyringPair, isSpecial = false): string {
+    return `${slug(account.meta.name, '_')}__${account.address}${isSpecial ? SPECIAL_ACCOUNT_POSTFIX : ''}.json`
+  }
+
+  private initAccountsFs(): void {
+    if (!fs.existsSync(this.getAccountsDirPath())) {
+      fs.mkdirSync(this.getAccountsDirPath())
+    }
+  }
+
+  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()
+    }
+  }
+
+  // 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 } }, '', true))
+  }
+
+  fetchAccountFromJsonFile(jsonBackupFilePath: string): NamedKeyringPair {
+    if (!fs.existsSync(jsonBackupFilePath)) {
+      throw new CLIError('Input file does not exist!', { exit: ExitCodes.FileNotFound })
     }
-
-    getAccountFilePath(account: NamedKeyringPair, isSpecial: boolean = false): string {
-        return path.join(this.getAccountsDirPath(), this.generateAccountFilename(account, isSpecial));
-    }
-
-    generateAccountFilename(account: NamedKeyringPair, isSpecial: boolean = false): string {
-        return `${ slug(account.meta.name, '_') }__${ account.address }${ isSpecial ? SPECIAL_ACCOUNT_POSTFIX : '' }.json`;
-    }
-
-    private initAccountsFs(): void {
-        if (!fs.existsSync(this.getAccountsDirPath())) {
-            fs.mkdirSync(this.getAccountsDirPath());
-        }
+    if (path.extname(jsonBackupFilePath) !== '.json') {
+      throw new CLIError('Invalid input file: File extension should be .json', { exit: ExitCodes.InvalidFile })
     }
-
-    saveAccount(account: NamedKeyringPair, password: string, isSpecial: boolean = false): void {
-        try {
-            const destPath = this.getAccountFilePath(account, isSpecial);
-            fs.writeFileSync(destPath, JSON.stringify(account.toJson(password)));
-        } catch(e) {
-            throw this.createDataWriteError();
-        }
-    }
-
-    // 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 } },
-            '',
-            true
-        ));
-    }
-
-    fetchAccountFromJsonFile(jsonBackupFilePath: string): NamedKeyringPair {
-        if (!fs.existsSync(jsonBackupFilePath)) {
-            throw new CLIError('Input file does not exist!', { exit: ExitCodes.FileNotFound });
-        }
-        if (path.extname(jsonBackupFilePath) !== '.json') {
-            throw new CLIError('Invalid input file: File extension should be .json', { exit: ExitCodes.InvalidFile });
-        }
-        let accountJsonObj: any;
-        try {
-            accountJsonObj = require(jsonBackupFilePath);
-        } catch (e) {
-            throw new CLIError('Provided backup file is not valid or cannot be accessed', { exit: ExitCodes.InvalidFile });
-        }
-        if (typeof accountJsonObj !== 'object' || accountJsonObj === null) {
-            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';
-
-        let keyring = new Keyring();
-        let account:NamedKeyringPair;
-        try {
-            // Try adding and retrieving the keys in order to validate that the backup file is correct
-            keyring.addFromJson(accountJsonObj);
-            account = <NamedKeyringPair> keyring.getPair(accountJsonObj.address); // 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 });
-        }
-
-        return account;
-    }
-
-    private fetchAccountOrNullFromFile(jsonFilePath: string): NamedKeyringPair | null {
-        try {
-            return this.fetchAccountFromJsonFile(jsonFilePath);
-        } catch (e) {
-            // Here in case of a typical CLIError we just return null (otherwise we throw)
-            if (!(e instanceof CLIError)) throw e;
-            return null;
-        }
-    }
-
-    fetchAccounts(includeSpecial: boolean = false): NamedKeyringPair[] {
-        let files: string[] = [];
-        const accountDir = this.getAccountsDirPath();
-        try {
-            files = fs.readdirSync(accountDir);
-        }
-        catch(e) {
-        }
-
-        // We have to assert the type, because TS is not aware that we're filtering out the nulls at the end
-        return <NamedKeyringPair[]> 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);
-    }
-
-    getSelectedAccountFilename(): string {
-        return this.getPreservedState().selectedAccountFilename;
-    }
-
-    getSelectedAccount(): NamedKeyringPair | null {
-        const selectedAccountFilename = this.getSelectedAccountFilename();
-
-        if (!selectedAccountFilename) {
-            return null;
-        }
-
-        const account = this.fetchAccountOrNullFromFile(
-            path.join(this.getAccountsDirPath(), selectedAccountFilename)
-        );
-
-        return account;
-    }
-
-    // Use when account usage is required in given command
-    async getRequiredSelectedAccount(promptIfMissing: boolean = true): Promise<NamedKeyringPair> {
-        let selectedAccount: NamedKeyringPair | null = this.getSelectedAccount();
-        if (!selectedAccount) {
-            this.warn('No default account selected! Use account:choose to set the default account!');
-            if (!promptIfMissing) this.exit(ExitCodes.NoAccountSelected);
-            const accounts: NamedKeyringPair[] = this.fetchAccounts();
-            if (!accounts.length) {
-                this.error('There are no accounts available!', { exit: ExitCodes.NoAccountFound });
-            }
-
-            selectedAccount = await this.promptForAccount(accounts);
-        }
-
-        return selectedAccount;
-    }
-
-    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 });
-    }
-
-    async promptForPassword(message:string = 'Your account\'s password') {
-        const { password } = await inquirer.prompt([
-            { name: 'password', type: 'password', message }
-        ]);
-
-        return password;
-    }
-
-    async requireConfirmation(message: string = 'Are you sure you want to execute this action?'): Promise<void> {
-        const { confirmed } = await inquirer.prompt([
-            { type: 'confirm', name: 'confirmed', message, default: false }
-        ]);
-        if (!confirmed) this.exit(ExitCodes.OK);
-    }
-
-    async promptForAccount(
-        accounts: NamedKeyringPair[],
-        defaultAccount: NamedKeyringPair | null = null,
-        message: string = 'Select an account',
-        showBalances: boolean = true
-    ): Promise<NamedKeyringPair> {
-        let balances: DerivedBalances[];
-        if (showBalances) {
-            balances = await this.getApi().getAccountsBalancesInfo(accounts.map(acc => acc.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 <NamedKeyringPair> accounts.find(acc => this.generateAccountFilename(acc) === chosenAccountFilename);
-    }
-
-    async requestAccountDecoding(account: NamedKeyringPair): Promise<void> {
-        const password: string = await this.promptForPassword();
-        try {
-            account.decodePkcs8(password);
-        } catch (e) {
-            this.error('Invalid password!', { exit: ExitCodes.InvalidInput });
-        }
-    }
-
-    async init() {
-        await super.init();
-        try {
-            this.initAccountsFs();
-            this.initSpecialAccounts();
-        } catch (e) {
-            throw this.createDataDirInitError();
-        }
+    let accountJsonObj: any
+    try {
+      accountJsonObj = require(jsonBackupFilePath)
+    } catch (e) {
+      throw new CLIError('Provided backup file is not valid or cannot be accessed', { exit: ExitCodes.InvalidFile })
+    }
+    if (typeof accountJsonObj !== 'object' || accountJsonObj === null) {
+      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'
+
+    const keyring = new Keyring()
+    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 })
+    }
+
+    return account
+  }
+
+  private fetchAccountOrNullFromFile(jsonFilePath: string): NamedKeyringPair | null {
+    try {
+      return this.fetchAccountFromJsonFile(jsonFilePath)
+    } catch (e) {
+      // Here in case of a typical CLIError we just return null (otherwise we throw)
+      if (!(e instanceof CLIError)) throw e
+      return null
+    }
+  }
+
+  fetchAccounts(includeSpecial = false): NamedKeyringPair[] {
+    let files: string[] = []
+    const accountDir = this.getAccountsDirPath()
+    try {
+      files = fs.readdirSync(accountDir)
+    } catch (e) {
+      // Do nothing
+    }
+
+    // We have to assert the type, because TS is not aware that we're filtering out the nulls at the end
+    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[]
+  }
+
+  getSelectedAccountFilename(): string {
+    return this.getPreservedState().selectedAccountFilename
+  }
+
+  getSelectedAccount(): NamedKeyringPair | null {
+    const selectedAccountFilename = this.getSelectedAccountFilename()
+
+    if (!selectedAccountFilename) {
+      return null
+    }
+
+    const account = this.fetchAccountOrNullFromFile(path.join(this.getAccountsDirPath(), selectedAccountFilename))
+
+    return account
+  }
+
+  // Use when account usage is required in given command
+  async getRequiredSelectedAccount(promptIfMissing = true): Promise<NamedKeyringPair> {
+    let selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
+    if (!selectedAccount) {
+      this.warn('No default account selected! Use account:choose to set the default account!')
+      if (!promptIfMissing) this.exit(ExitCodes.NoAccountSelected)
+      const accounts: NamedKeyringPair[] = this.fetchAccounts()
+      if (!accounts.length) {
+        this.error('There are no accounts available!', { exit: ExitCodes.NoAccountFound })
+      }
+
+      selectedAccount = await this.promptForAccount(accounts)
+    }
+
+    return selectedAccount
+  }
+
+  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 })
+  }
+
+  async promptForPassword(message = "Your account's password") {
+    const { password } = await inquirer.prompt([{ name: 'password', type: 'password', message }])
+
+    return password
+  }
+
+  async requireConfirmation(message = 'Are you sure you want to execute this action?'): Promise<void> {
+    const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: false }])
+    if (!confirmed) this.exit(ExitCodes.OK)
+  }
+
+  async promptForAccount(
+    accounts: NamedKeyringPair[],
+    defaultAccount: NamedKeyringPair | null = null,
+    message = 'Select an account',
+    showBalances = true
+  ): Promise<NamedKeyringPair> {
+    let balances: DerivedBalances[]
+    if (showBalances) {
+      balances = await this.getApi().getAccountsBalancesInfo(accounts.map((acc) => acc.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
+  }
+
+  async requestAccountDecoding(account: NamedKeyringPair): Promise<void> {
+    const password: string = await this.promptForPassword()
+    try {
+      account.decodePkcs8(password)
+    } catch (e) {
+      this.error('Invalid password!', { exit: ExitCodes.InvalidInput })
+    }
+  }
+
+  async init() {
+    await super.init()
+    try {
+      this.initAccountsFs()
+      this.initSpecialAccounts()
+    } catch (e) {
+      throw this.createDataDirInitError()
+    }
+  }
 }

+ 374 - 343
cli/src/base/ApiCommandBase.ts

@@ -1,376 +1,407 @@
-import ExitCodes from '../ExitCodes';
-import { CLIError } from '@oclif/errors';
-import StateAwareCommandBase from './StateAwareCommandBase';
-import Api from '../Api';
-import { JSONArgsMapping } from '../Types';
-import { getTypeDef, createType, Option, Tuple, Bytes } from '@polkadot/types';
-import { Codec, TypeDef, TypeDefInfo, Constructor } from '@polkadot/types/types';
-import { Vec, Struct, Enum } from '@polkadot/types/codec';
-import { ApiPromise } from '@polkadot/api';
-import { KeyringPair } from '@polkadot/keyring/types';
-import chalk from 'chalk';
-import { SubmittableResultImpl } from '@polkadot/api/types';
-import ajv from 'ajv';
-
-export type ApiMethodInputArg = Codec;
-
-class ExtrinsicFailedError extends Error { };
+import ExitCodes from '../ExitCodes'
+import { CLIError } from '@oclif/errors'
+import StateAwareCommandBase from './StateAwareCommandBase'
+import Api from '../Api'
+import { getTypeDef, createType, Option, Tuple, Bytes } from '@polkadot/types'
+import { Codec, TypeDef, TypeDefInfo, Constructor } from '@polkadot/types/types'
+import { Vec, Struct, Enum } from '@polkadot/types/codec'
+import { ApiPromise } from '@polkadot/api'
+import { KeyringPair } from '@polkadot/keyring/types'
+import chalk from 'chalk'
+import { SubmittableResultImpl } from '@polkadot/api/types'
+import ajv from 'ajv'
+import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions } from '../Types'
+import { createParamOptions } from '../helpers/promptOptions'
+
+class ExtrinsicFailedError extends Error {}
 
 /**
  * Abstract base class for commands that require access to the API.
  */
 export default abstract class ApiCommandBase extends StateAwareCommandBase {
-    private api: Api | null = null;
-
-    getApi(): Api {
-        if (!this.api) throw new CLIError('Tried to get API before initialization.', { exit: ExitCodes.ApiError });
-        return this.api;
+  private api: Api | null = null
+
+  getApi(): Api {
+    if (!this.api) throw new CLIError('Tried to get API before initialization.', { exit: ExitCodes.ApiError })
+    return this.api
+  }
+
+  // Get original api for lower-level api calls
+  getOriginalApi(): ApiPromise {
+    return this.getApi().getOriginalApi()
+  }
+
+  async init() {
+    await super.init()
+    const apiUri: string = this.getPreservedState().apiUri
+    this.api = await Api.create(apiUri)
+  }
+
+  // This is needed to correctly handle some structs, enums etc.
+  // Where the main typeDef doesn't provide enough information
+  protected getRawTypeDef(type: string) {
+    const instance = createType(type as any)
+    return getTypeDef(instance.toRawType())
+  }
+
+  // Prettifier for type names which are actually JSON strings
+  protected prettifyJsonTypeName(json: string) {
+    const obj = JSON.parse(json) as { [key: string]: string }
+    return (
+      '{\n' +
+      Object.keys(obj)
+        .map((prop) => `  ${prop}${chalk.white(':' + obj[prop])}`)
+        .join('\n') +
+      '\n}'
+    )
+  }
+
+  // Get param name based on TypeDef object
+  protected paramName(typeDef: TypeDef) {
+    return chalk.green(
+      typeDef.displayName ||
+        typeDef.name ||
+        (typeDef.type.startsWith('{') ? this.prettifyJsonTypeName(typeDef.type) : typeDef.type)
+    )
+  }
+
+  // Prompt for simple/plain value (provided as string) of given type
+  async promptForSimple(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Codec> {
+    const providedValue = await this.simplePrompt({
+      message: `Provide value for ${this.paramName(typeDef)}`,
+      type: 'input',
+      // If not default provided - show default value resulting from providing empty string
+      default: paramOptions?.value?.default?.toString() || createType(typeDef.type as any, '').toString(),
+      validate: paramOptions?.validator,
+    })
+    return createType(typeDef.type as any, providedValue)
+  }
+
+  // Prompt for Option<Codec> value
+  async promptForOption(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Option<Codec>> {
+    const subtype = typeDef.sub as TypeDef // We assume that Opion always has a single subtype
+    const defaultValue = paramOptions?.value?.default as Option<Codec> | undefined
+    const confirmed = await this.simplePrompt({
+      message: `Do you want to provide the optional ${this.paramName(typeDef)} parameter?`,
+      type: 'confirm',
+      default: defaultValue ? defaultValue.isSome : false,
+    })
+
+    if (confirmed) {
+      this.openIndentGroup()
+      const value = await this.promptForParam(
+        subtype.type,
+        createParamOptions(subtype.name, defaultValue?.unwrapOr(undefined))
+      )
+      this.closeIndentGroup()
+      return new Option(subtype.type as any, value)
     }
 
-    // Get original api for lower-level api calls
-    getOriginalApi(): ApiPromise {
-        return this.getApi().getOriginalApi();
-    }
+    return new Option(subtype.type as any, null)
+  }
 
-    async init() {
-        await super.init();
-        const apiUri: string = this.getPreservedState().apiUri;
-        this.api = await Api.create(apiUri);
-    }
+  // Prompt for Tuple
+  // TODO: Not well tested yet
+  async promptForTuple(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Tuple> {
+    console.log(chalk.grey(`Providing values for ${this.paramName(typeDef)} tuple:`))
 
-    // This is needed to correctly handle some structs, enums etc.
-    // Where the main typeDef doesn't provide enough information
-    protected getRawTypeDef(type: string) {
-        const instance = createType(type as any);
-        return getTypeDef(instance.toRawType());
-    }
+    this.openIndentGroup()
+    const result: ApiMethodArg[] = []
+    // We assume that for Tuple there is always at least 1 subtype (pethaps it's even always an array?)
+    const subtypes: TypeDef[] = Array.isArray(typeDef.sub) ? typeDef.sub! : [typeDef.sub!]
+    const defaultValue = paramOptions?.value?.default as Tuple | undefined
 
-    // Prettifier for type names which are actually JSON strings
-    protected prettifyJsonTypeName(json: string) {
-        const obj = JSON.parse(json) as { [key: string]: string };
-        return "{\n"+Object.keys(obj).map(prop => `  ${prop}${chalk.white(':'+obj[prop])}`).join("\n")+"\n}";
+    for (const [index, subtype] of Object.entries(subtypes)) {
+      const entryDefaultVal = defaultValue && defaultValue[parseInt(index)]
+      const inputParam = await this.promptForParam(subtype.type, createParamOptions(subtype.name, entryDefaultVal))
+      result.push(inputParam)
     }
-
-    // Get param name based on TypeDef object
-    protected paramName(typeDef: TypeDef) {
-        return chalk.green(
-            typeDef.displayName ||
-            typeDef.name ||
-            (typeDef.type.startsWith('{') ? this.prettifyJsonTypeName(typeDef.type) : typeDef.type)
-        );
+    this.closeIndentGroup()
+
+    return new Tuple(subtypes.map((subtype) => subtype.type) as any, result)
+  }
+
+  // Prompt for Struct
+  async promptForStruct(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<ApiMethodArg> {
+    console.log(chalk.grey(`Providing values for ${this.paramName(typeDef)} struct:`))
+
+    this.openIndentGroup()
+    const structType = typeDef.type
+    const rawTypeDef = this.getRawTypeDef(structType)
+    // We assume struct typeDef always has array of typeDefs inside ".sub"
+    const structSubtypes = rawTypeDef.sub as TypeDef[]
+    const structDefault = paramOptions?.value?.default as Struct | undefined
+
+    const structValues: { [key: string]: ApiMethodArg } = {}
+    for (const subtype of structSubtypes) {
+      const fieldOptions = paramOptions?.nestedOptions && paramOptions.nestedOptions[subtype.name!]
+      const fieldDefaultValue = fieldOptions?.value?.default || (structDefault && structDefault.get(subtype.name!))
+      const finalFieldOptions: ApiParamOptions = {
+        ...fieldOptions,
+        forcedName: subtype.name,
+        value: fieldDefaultValue && { ...fieldOptions?.value, default: fieldDefaultValue },
+      }
+      structValues[subtype.name!] = await this.promptForParam(subtype.type, finalFieldOptions)
     }
-
-    // Prompt for simple/plain value (provided as string) of given type
-    async promptForSimple(typeDef: TypeDef, defaultValue?: Codec): Promise<Codec> {
-        const providedValue = await this.simplePrompt({
-            message: `Provide value for ${ this.paramName(typeDef) }`,
-            type: 'input',
-            default: defaultValue?.toString()
-        });
-        return createType(typeDef.type as any, providedValue);
+    this.closeIndentGroup()
+
+    return createType(structType as any, structValues)
+  }
+
+  // Prompt for Vec
+  async promptForVec(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Vec<Codec>> {
+    console.log(chalk.grey(`Providing values for ${this.paramName(typeDef)} vector:`))
+
+    this.openIndentGroup()
+    // We assume Vec always has one TypeDef as ".sub"
+    const subtype = typeDef.sub as TypeDef
+    const defaultValue = paramOptions?.value?.default as Vec<Codec> | undefined
+    const entries: Codec[] = []
+    let addAnother = false
+    do {
+      addAnother = await this.simplePrompt({
+        message: `Do you want to add another entry to ${this.paramName(typeDef)} vector (currently: ${
+          entries.length
+        })?`,
+        type: 'confirm',
+        default: defaultValue ? entries.length < defaultValue.length : false,
+      })
+      const defaultEntryValue = defaultValue && defaultValue[entries.length]
+      if (addAnother) {
+        entries.push(await this.promptForParam(subtype.type, createParamOptions(subtype.name, defaultEntryValue)))
+      }
+    } while (addAnother)
+    this.closeIndentGroup()
+
+    return new Vec(subtype.type as any, entries)
+  }
+
+  // Prompt for Enum
+  async promptForEnum(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Enum> {
+    const enumType = typeDef.type
+    const rawTypeDef = this.getRawTypeDef(enumType)
+    // We assume enum always has array on TypeDefs inside ".sub"
+    const enumSubtypes = rawTypeDef.sub as TypeDef[]
+    const defaultValue = paramOptions?.value?.default as Enum | undefined
+
+    const enumSubtypeName = await this.simplePrompt({
+      message: `Choose value for ${this.paramName(typeDef)}:`,
+      type: 'list',
+      choices: enumSubtypes.map((subtype) => ({
+        name: subtype.name,
+        value: subtype.name,
+      })),
+      default: defaultValue?.type,
+    })
+
+    const enumSubtype = enumSubtypes.find((st) => st.name === enumSubtypeName)!
+
+    if (enumSubtype.type !== 'Null') {
+      const subtypeOptions = createParamOptions(enumSubtype.name, defaultValue?.value)
+      return createType(enumType as any, {
+        [enumSubtype.name!]: await this.promptForParam(enumSubtype.type, subtypeOptions),
+      })
     }
 
-    // Prompt for Option<Codec> value
-    async promptForOption(typeDef: TypeDef, defaultValue?: Option<Codec>): Promise<Option<Codec>> {
-        const subtype = <TypeDef> typeDef.sub; // We assume that Opion always has a single subtype
-        const confirmed = await this.simplePrompt({
-            message: `Do you want to provide the optional ${ this.paramName(typeDef) } parameter?`,
-            type: 'confirm',
-            default: defaultValue ? defaultValue.isSome : false,
-        });
-
-        if (confirmed) {
-            this.openIndentGroup();
-            const value = await this.promptForParam(subtype.type, subtype.name, defaultValue?.unwrapOr(undefined));
-            this.closeIndentGroup();
-            return new Option(subtype.type as any, value);
-        }
-
-        return new Option(subtype.type as any, null);
-    }
+    return createType(enumType as any, enumSubtype.name)
+  }
 
-    // Prompt for Tuple
-    // TODO: Not well tested yet
-    async promptForTuple(typeDef: TypeDef, defaultValue: Tuple): Promise<Tuple> {
-        console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } tuple:`));
+  // Prompt for param based on "paramType" string (ie. Option<MemeberId>)
+  // TODO: This may not yet work for all possible types
+  async promptForParam(
+    paramType: string,
+    paramOptions?: ApiParamOptions // TODO: This is not fully implemented for all types yet
+  ): Promise<ApiMethodArg> {
+    const typeDef = getTypeDef(paramType)
+    const rawTypeDef = this.getRawTypeDef(paramType)
 
-        this.openIndentGroup();
-        const result: ApiMethodInputArg[] = [];
-        // We assume that for Tuple there is always at least 1 subtype (pethaps it's even always an array?)
-        const subtypes: TypeDef[] = Array.isArray(typeDef.sub) ? typeDef.sub! : [ typeDef.sub! ];
-
-        for (const [index, subtype] of Object.entries(subtypes)) {
-            const inputParam = await this.promptForParam(subtype.type, subtype.name, defaultValue[parseInt(index)]);
-            result.push(inputParam);
-        }
-        this.closeIndentGroup();
-
-        return new Tuple((subtypes.map(subtype => subtype.type)) as any, result);
+    if (paramOptions?.forcedName) {
+      typeDef.name = paramOptions.forcedName
     }
 
-    // Prompt for Struct
-    async promptForStruct(typeDef: TypeDef, defaultValue?: Struct): Promise<ApiMethodInputArg> {
-        console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } struct:`));
-
-        this.openIndentGroup();
-        const structType = typeDef.type;
-        const rawTypeDef = this.getRawTypeDef(structType);
-        // We assume struct typeDef always has array of typeDefs inside ".sub"
-        const structSubtypes = rawTypeDef.sub as TypeDef[];
-
-        const structValues: { [key: string]: ApiMethodInputArg } = {};
-        for (const subtype of structSubtypes) {
-            structValues[subtype.name!] =
-                await this.promptForParam(subtype.type, subtype.name, defaultValue && defaultValue.get(subtype.name!));
-        }
-        this.closeIndentGroup();
-
-        return createType(structType as any, structValues);
+    if (paramOptions?.value?.locked) {
+      return paramOptions.value.default
     }
 
-    // Prompt for Vec
-    async promptForVec(typeDef: TypeDef, defaultValue?: Vec<Codec>): Promise<Vec<Codec>> {
-        console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } vector:`));
-
-        this.openIndentGroup();
-        // We assume Vec always has one TypeDef as ".sub"
-        const subtype = typeDef.sub as TypeDef;
-        let entries: Codec[] = [];
-        let addAnother = false;
-        do {
-            addAnother = await this.simplePrompt({
-                message: `Do you want to add another entry to ${ this.paramName(typeDef) } vector (currently: ${entries.length})?`,
-                type: 'confirm',
-                default: defaultValue ? entries.length < defaultValue.length : false
-            });
-            const defaultEntryValue = defaultValue && defaultValue[entries.length];
-            if (addAnother) {
-                entries.push(await this.promptForParam(subtype.type, subtype.name, defaultEntryValue));
-            }
-        } while (addAnother);
-        this.closeIndentGroup();
-
-        return new Vec(subtype.type as any, entries);
+    if (paramOptions?.jsonSchema) {
+      const { struct, schemaValidator } = paramOptions.jsonSchema
+      return await this.promptForJsonBytes(
+        struct,
+        typeDef.name,
+        paramOptions.value?.default as Bytes | undefined,
+        schemaValidator
+      )
     }
 
-    // Prompt for Enum
-    async promptForEnum(typeDef: TypeDef, defaultValue?: Enum): Promise<Enum> {
-        const enumType = typeDef.type;
-        const rawTypeDef = this.getRawTypeDef(enumType);
-        // We assume enum always has array on TypeDefs inside ".sub"
-        const enumSubtypes = rawTypeDef.sub as TypeDef[];
-
-        const enumSubtypeName = await this.simplePrompt({
-            message: `Choose value for ${this.paramName(typeDef)}:`,
-            type: 'list',
-            choices: enumSubtypes.map(subtype => ({
-                name: subtype.name,
-                value: subtype.name
-            })),
-            default: defaultValue?.type
-        });
-
-        const enumSubtype = enumSubtypes.find(st => st.name === enumSubtypeName)!;
-
-        if (enumSubtype.type !== 'Null') {
-            return createType(
-                enumType as any,
-                { [enumSubtype.name!]: await this.promptForParam(enumSubtype.type, enumSubtype.name, defaultValue?.value) }
-            );
-        }
-
-        return createType(enumType as any, enumSubtype.name);
+    if (rawTypeDef.info === TypeDefInfo.Option) {
+      return await this.promptForOption(typeDef, paramOptions)
+    } else if (rawTypeDef.info === TypeDefInfo.Tuple) {
+      return await this.promptForTuple(typeDef, paramOptions)
+    } else if (rawTypeDef.info === TypeDefInfo.Struct) {
+      return await this.promptForStruct(typeDef, paramOptions)
+    } else if (rawTypeDef.info === TypeDefInfo.Enum) {
+      return await this.promptForEnum(typeDef, paramOptions)
+    } else if (rawTypeDef.info === TypeDefInfo.Vec) {
+      return await this.promptForVec(typeDef, paramOptions)
+    } else {
+      return await this.promptForSimple(typeDef, paramOptions)
     }
-
-    // Prompt for param based on "paramType" string (ie. Option<MemeberId>)
-    // TODO: This may not yet work for all possible types
-    async promptForParam(paramType: string, forcedName?: string, defaultValue?: ApiMethodInputArg): Promise<ApiMethodInputArg> {
-        const typeDef = getTypeDef(paramType);
-        const rawTypeDef = this.getRawTypeDef(paramType);
-
-        if (forcedName) {
-            typeDef.name = forcedName;
-        }
-
-        if (rawTypeDef.info === TypeDefInfo.Option) {
-            return await this.promptForOption(typeDef, defaultValue as Option<Codec>);
-        }
-        else if (rawTypeDef.info === TypeDefInfo.Tuple) {
-            return await this.promptForTuple(typeDef, defaultValue as Tuple);
-        }
-        else if (rawTypeDef.info === TypeDefInfo.Struct) {
-            return await this.promptForStruct(typeDef, defaultValue as Struct);
-        }
-        else if (rawTypeDef.info === TypeDefInfo.Enum) {
-            return await this.promptForEnum(typeDef, defaultValue as Enum);
-        }
-        else if (rawTypeDef.info === TypeDefInfo.Vec) {
-            return await this.promptForVec(typeDef, defaultValue as Vec<Codec>);
-        }
-        else {
-            return await this.promptForSimple(typeDef, defaultValue);
-        }
+  }
+
+  async promptForJsonBytes(
+    JsonStruct: Constructor<Struct>,
+    argName?: string,
+    defaultValue?: Bytes,
+    schemaValidator?: ajv.ValidateFunction
+  ) {
+    const rawType = new JsonStruct().toRawType()
+    const typeDef = getTypeDef(rawType)
+
+    const defaultStruct =
+      defaultValue && new JsonStruct(JSON.parse(Buffer.from(defaultValue.toHex().replace('0x', ''), 'hex').toString()))
+
+    if (argName) {
+      typeDef.name = argName
     }
 
-    async promptForJsonBytes(
-        JsonStruct: Constructor<Struct>,
-        argName?: string,
-        defaultValue?: Bytes,
-        schemaValidator?: ajv.ValidateFunction
-    ) {
-        const rawType = (new JsonStruct()).toRawType();
-        const typeDef = getTypeDef(rawType);
-
-        const defaultStruct =
-            defaultValue &&
-            new JsonStruct(JSON.parse(Buffer.from(defaultValue.toHex().replace('0x', ''), 'hex').toString()));
-
-        if (argName) {
-            typeDef.name = argName;
+    let isValid = true,
+      jsonText: string
+    do {
+      const structVal = await this.promptForStruct(typeDef, createParamOptions(typeDef.name, defaultStruct))
+      jsonText = JSON.stringify(structVal.toJSON())
+      if (schemaValidator) {
+        isValid = Boolean(schemaValidator(JSON.parse(jsonText)))
+        if (!isValid) {
+          this.log('\n')
+          this.warn(
+            'Schema validation failed with:\n' +
+              schemaValidator.errors?.map((e) => chalk.red(`${chalk.bold(e.dataPath)}: ${e.message}`)).join('\n') +
+              '\nTry again...'
+          )
+          this.log('\n')
         }
-
-        let isValid: boolean = true, jsonText: string;
-        do {
-            const structVal = await this.promptForStruct(typeDef, defaultStruct);
-            jsonText = JSON.stringify(structVal.toJSON());
-            if (schemaValidator) {
-                isValid = Boolean(schemaValidator(JSON.parse(jsonText)));
-                if (!isValid) {
-                    this.log("\n");
-                    this.warn(
-                        "Schema validation failed with:\n"+
-                        schemaValidator.errors?.map(e => chalk.red(`${chalk.bold(e.dataPath)}: ${e.message}`)).join("\n")+
-                        "\nTry again..."
-                    )
-                    this.log("\n");
-                }
-            }
-        } while(!isValid);
-
-        return new Bytes('0x'+Buffer.from(jsonText, 'ascii').toString('hex'));
+      }
+    } while (!isValid)
+
+    return new Bytes('0x' + Buffer.from(jsonText, 'ascii').toString('hex'))
+  }
+
+  async promptForExtrinsicParams(
+    module: string,
+    method: string,
+    paramsOptions?: ApiParamsOptions
+  ): Promise<ApiMethodArg[]> {
+    const extrinsicMethod = this.getOriginalApi().tx[module][method]
+    const values: ApiMethodArg[] = []
+
+    this.openIndentGroup()
+    for (const arg of extrinsicMethod.meta.args.toArray()) {
+      const argName = arg.name.toString()
+      const argType = arg.type.toString()
+      let argOptions = paramsOptions && paramsOptions[argName]
+      if (!argOptions?.forcedName) {
+        argOptions = { ...argOptions, forcedName: argName }
+      }
+      values.push(await this.promptForParam(argType, argOptions))
     }
-
-    async promptForExtrinsicParams(
-        module: string,
-        method: string,
-        jsonArgs?: JSONArgsMapping,
-        defaultValues?: ApiMethodInputArg[]
-    ): Promise<ApiMethodInputArg[]> {
-        const extrinsicMethod = this.getOriginalApi().tx[module][method];
-        let values: ApiMethodInputArg[] = [];
-
-        this.openIndentGroup();
-        for (const [index, arg] of Object.entries(extrinsicMethod.meta.args.toArray())) {
-            const argName = arg.name.toString();
-            const argType = arg.type.toString();
-            const defaultValue = defaultValues && defaultValues[parseInt(index)];
-            if (jsonArgs && jsonArgs[argName]) {
-                const { struct, schemaValidator } = jsonArgs[argName];
-                values.push(await this.promptForJsonBytes(struct, argName, defaultValue as Bytes, schemaValidator));
-            }
-            else {
-                values.push(await this.promptForParam(argType, argName, defaultValue));
-            }
-        };
-        this.closeIndentGroup();
-
-        return values;
+    this.closeIndentGroup()
+
+    return values
+  }
+
+  sendExtrinsic(account: KeyringPair, module: string, method: string, params: Codec[]) {
+    return new Promise((resolve, reject) => {
+      const extrinsicMethod = this.getOriginalApi().tx[module][method]
+      let unsubscribe: () => void
+      extrinsicMethod(...params)
+        .signAndSend(account, {}, (result: SubmittableResultImpl) => {
+          // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
+          if (!result || !result.status) {
+            return
+          }
+
+          if (result.status.isFinalized) {
+            unsubscribe()
+            result.events
+              .filter(({ event: { section } }): boolean => section === 'system')
+              .forEach(({ event: { method } }): void => {
+                if (method === 'ExtrinsicFailed') {
+                  reject(new ExtrinsicFailedError('Extrinsic execution error!'))
+                } else if (method === 'ExtrinsicSuccess') {
+                  resolve()
+                }
+              })
+          } else if (result.isError) {
+            reject(new ExtrinsicFailedError('Extrinsic execution error!'))
+          }
+        })
+        .then((unsubFunc) => (unsubscribe = unsubFunc))
+        .catch((e) =>
+          reject(new ExtrinsicFailedError(`Cannot send the extrinsic: ${e.message ? e.message : JSON.stringify(e)}`))
+        )
+    })
+  }
+
+  async sendAndFollowExtrinsic(
+    account: KeyringPair,
+    module: string,
+    method: string,
+    params: Codec[],
+    warnOnly = false // If specified - only warning will be displayed (instead of error beeing thrown)
+  ) {
+    try {
+      this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
+      await this.sendExtrinsic(account, module, method, params)
+      this.log(chalk.green(`Extrinsic successful!`))
+    } catch (e) {
+      if (e instanceof ExtrinsicFailedError && warnOnly) {
+        this.warn(`${module}.${method} extrinsic failed! ${e.message}`)
+      } else if (e instanceof ExtrinsicFailedError) {
+        throw new CLIError(`${module}.${method} extrinsic failed! ${e.message}`, { exit: ExitCodes.ApiError })
+      } else {
+        throw e
+      }
     }
-
-    sendExtrinsic(account: KeyringPair, module: string, method: string, params: Codec[]) {
-        return new Promise((resolve, reject) => {
-            const extrinsicMethod = this.getOriginalApi().tx[module][method];
-            let unsubscribe: () => void;
-            extrinsicMethod(...params)
-                .signAndSend(account, {}, (result: SubmittableResultImpl) => {
-                    // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
-                    if (!result || !result.status) {
-                        return;
-                    }
-
-                    if (result.status.isFinalized) {
-                      unsubscribe();
-                      result.events
-                        .filter(({ event: { section } }): boolean => section === 'system')
-                        .forEach(({ event: { method } }): void => {
-                          if (method === 'ExtrinsicFailed') {
-                            reject(new ExtrinsicFailedError('Extrinsic execution error!'));
-                          } else if (method === 'ExtrinsicSuccess') {
-                            resolve();
-                          }
-                        });
-                    } else if (result.isError) {
-                        reject(new ExtrinsicFailedError('Extrinsic execution error!'));
-                    }
-                })
-                .then(unsubFunc => unsubscribe = unsubFunc)
-                .catch(e => reject(new ExtrinsicFailedError(`Cannot send the extrinsic: ${e.message ? e.message : JSON.stringify(e)}`)));
-        });
+  }
+
+  async buildAndSendExtrinsic(
+    account: KeyringPair,
+    module: string,
+    method: string,
+    paramsOptions: ApiParamsOptions,
+    warnOnly = false // If specified - only warning will be displayed (instead of error beeing thrown)
+  ): Promise<ApiMethodArg[]> {
+    const params = await this.promptForExtrinsicParams(module, method, paramsOptions)
+    await this.sendAndFollowExtrinsic(account, module, method, params, warnOnly)
+
+    return params
+  }
+
+  extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodNamedArgs {
+    let draftJSONObj
+    const parsedArgs: ApiMethodNamedArgs = []
+    const extrinsicMethod = this.getOriginalApi().tx[module][method]
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-var-requires
+      draftJSONObj = require(draftFilePath)
+    } catch (e) {
+      throw new CLIError(`Could not load draft from: ${draftFilePath}`, { exit: ExitCodes.InvalidFile })
     }
-
-    async sendAndFollowExtrinsic(
-        account: KeyringPair,
-        module: string,
-        method: string,
-        params: Codec[],
-        warnOnly: boolean = false // If specified - only warning will be displayed (instead of error beeing thrown)
-    ) {
-        try {
-            this.log(chalk.white(`\nSending ${ module }.${ method } extrinsic...`));
-            await this.sendExtrinsic(account, module, method, params);
-            this.log(chalk.green(`Extrinsic successful!`));
-        } catch (e) {
-            if (e instanceof ExtrinsicFailedError && warnOnly) {
-                this.warn(`${ module }.${ method } extrinsic failed! ${ e.message }`);
-            }
-            else if (e instanceof ExtrinsicFailedError) {
-                throw new CLIError(`${ module }.${ method } extrinsic failed! ${ e.message }`, { exit: ExitCodes.ApiError });
-            }
-            else {
-                throw e;
-            }
-        }
+    if (!draftJSONObj || !Array.isArray(draftJSONObj) || draftJSONObj.length !== extrinsicMethod.meta.args.length) {
+      throw new CLIError(`The draft file at ${draftFilePath} is invalid!`, { exit: ExitCodes.InvalidFile })
     }
-
-    async buildAndSendExtrinsic(
-        account: KeyringPair,
-        module: string,
-        method: string,
-        jsonArgs?: JSONArgsMapping, // Special JSON arguments (ie. human_readable_text of working group opening)
-        defaultValues?: ApiMethodInputArg[],
-        warnOnly: boolean = false // If specified - only warning will be displayed (instead of error beeing thrown)
-    ): Promise<ApiMethodInputArg[]> {
-        const params = await this.promptForExtrinsicParams(module, method, jsonArgs, defaultValues);
-        await this.sendAndFollowExtrinsic(account, module, method, params, warnOnly);
-
-        return params;
+    for (const [index, arg] of Object.entries(extrinsicMethod.meta.args.toArray())) {
+      const argName = arg.name.toString()
+      const argType = arg.type.toString()
+      try {
+        parsedArgs.push({ name: argName, value: createType(argType as any, draftJSONObj[parseInt(index)]) })
+      } catch (e) {
+        throw new CLIError(`Couldn't parse ${argName} value from draft at ${draftFilePath}!`, {
+          exit: ExitCodes.InvalidFile,
+        })
+      }
     }
 
-    extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodInputArg[] {
-        let draftJSONObj, parsedArgs: ApiMethodInputArg[] = [];
-        const extrinsicMethod = this.getOriginalApi().tx[module][method];
-        try {
-            draftJSONObj = require(draftFilePath);
-        } catch(e) {
-            throw new CLIError(`Could not load draft from: ${draftFilePath}`, { exit: ExitCodes.InvalidFile });
-        }
-        if (
-            !draftJSONObj
-            || !Array.isArray(draftJSONObj)
-            || draftJSONObj.length !== extrinsicMethod.meta.args.length
-        ) {
-            throw new CLIError(`The draft file at ${draftFilePath} is invalid!`, { exit: ExitCodes.InvalidFile });
-        }
-        for (const [index, arg] of Object.entries(extrinsicMethod.meta.args.toArray())) {
-            const argName = arg.name.toString();
-            const argType = arg.type.toString();
-            try {
-                parsedArgs.push(createType(argType as any, draftJSONObj[parseInt(index)]));
-            } catch (e) {
-                throw new CLIError(`Couldn't parse ${argName} value from draft at ${draftFilePath}!`, { exit: ExitCodes.InvalidFile });
-            }
-        }
-
-        return parsedArgs;
-    }
+    return parsedArgs
+  }
 }

+ 84 - 73
cli/src/base/DefaultCommandBase.ts

@@ -1,95 +1,106 @@
-import ExitCodes from '../ExitCodes';
-import Command from '@oclif/command';
-import inquirer, { DistinctQuestion } from 'inquirer';
-import chalk from 'chalk';
+import ExitCodes from '../ExitCodes'
+import Command from '@oclif/command'
+import inquirer, { DistinctQuestion } from 'inquirer'
+import chalk from 'chalk'
 
 /**
  * Abstract base class for pretty much all commands
  * (prevents console.log from hanging the process and unifies the default exit code)
  */
 export default abstract class DefaultCommandBase extends Command {
-    protected indentGroupsOpened = 0;
-    protected jsonPrettyIdent = '';
+  protected indentGroupsOpened = 0
+  protected jsonPrettyIdent = ''
 
-    openIndentGroup() {
-        console.group();
-        ++this.indentGroupsOpened;
-    }
-
-    closeIndentGroup() {
-        console.groupEnd();
-        --this.indentGroupsOpened;
-    }
+  openIndentGroup() {
+    console.group()
+    ++this.indentGroupsOpened
+  }
 
-    async simplePrompt(question: DistinctQuestion) {
-        const { result } = await inquirer.prompt([{
-            ...question,
-            name: 'result',
-            // prefix = 2 spaces for each group - 1 (because 1 is always added by default)
-            prefix: Array.from(new Array(this.indentGroupsOpened)).map(() => '  ').join('').slice(1)
-        }]);
+  closeIndentGroup() {
+    console.groupEnd()
+    --this.indentGroupsOpened
+  }
 
-        return result;
-    }
+  async simplePrompt(question: DistinctQuestion) {
+    const { result } = await inquirer.prompt([
+      {
+        ...question,
+        name: 'result',
+        // prefix = 2 spaces for each group - 1 (because 1 is always added by default)
+        prefix: Array.from(new Array(this.indentGroupsOpened))
+          .map(() => '  ')
+          .join('')
+          .slice(1),
+      },
+    ])
 
-    private jsonPrettyIndented(line:string) {
-        return `${this.jsonPrettyIdent}${ line }`;
-    }
+    return result
+  }
 
-    private jsonPrettyOpen(char: '{' | '[') {
-        this.jsonPrettyIdent += '    ';
-        return chalk.gray(char)+"\n";
-    }
+  private jsonPrettyIndented(line: string) {
+    return `${this.jsonPrettyIdent}${line}`
+  }
 
-    private jsonPrettyClose(char: '}' | ']') {
-        this.jsonPrettyIdent = this.jsonPrettyIdent.slice(0, -4);
-        return this.jsonPrettyIndented(chalk.gray(char));
-    }
+  private jsonPrettyOpen(char: '{' | '[') {
+    this.jsonPrettyIdent += '    '
+    return chalk.gray(char) + '\n'
+  }
 
-    private jsonPrettyKeyVal(key:string, val:any): string {
-        return this.jsonPrettyIndented(chalk.white(`${key}: ${this.jsonPrettyAny(val)}`));
-    }
+  private jsonPrettyClose(char: '}' | ']') {
+    this.jsonPrettyIdent = this.jsonPrettyIdent.slice(0, -4)
+    return this.jsonPrettyIndented(chalk.gray(char))
+  }
 
-    private jsonPrettyObj(obj: { [key: string]: any }): string {
-        return this.jsonPrettyOpen('{')
-            + Object.keys(obj).map(k => this.jsonPrettyKeyVal(k, obj[k])).join(',\n') + "\n"
-            + this.jsonPrettyClose('}');
-    }
+  private jsonPrettyKeyVal(key: string, val: any): string {
+    return this.jsonPrettyIndented(chalk.white(`${key}: ${this.jsonPrettyAny(val)}`))
+  }
 
-    private jsonPrettyArr(arr: any[]): string {
-        return this.jsonPrettyOpen('[')
-            + arr.map(v => this.jsonPrettyIndented(this.jsonPrettyAny(v))).join(',\n') + "\n"
-            + this.jsonPrettyClose(']');
-    }
+  private jsonPrettyObj(obj: { [key: string]: any }): string {
+    return (
+      this.jsonPrettyOpen('{') +
+      Object.keys(obj)
+        .map((k) => this.jsonPrettyKeyVal(k, obj[k]))
+        .join(',\n') +
+      '\n' +
+      this.jsonPrettyClose('}')
+    )
+  }
 
-    private jsonPrettyAny(val: any): string {
-        if (Array.isArray(val)) {
-            return this.jsonPrettyArr(val);
-        }
-        else if (typeof val === 'object' && val !== null) {
-            return this.jsonPrettyObj(val);
-        }
-        else if (typeof val === 'string') {
-            return chalk.green(`"${val}"`);
-        }
+  private jsonPrettyArr(arr: any[]): string {
+    return (
+      this.jsonPrettyOpen('[') +
+      arr.map((v) => this.jsonPrettyIndented(this.jsonPrettyAny(v))).join(',\n') +
+      '\n' +
+      this.jsonPrettyClose(']')
+    )
+  }
 
-        // Number, boolean etc.
-        return chalk.cyan(val);
+  private jsonPrettyAny(val: any): string {
+    if (Array.isArray(val)) {
+      return this.jsonPrettyArr(val)
+    } else if (typeof val === 'object' && val !== null) {
+      return this.jsonPrettyObj(val)
+    } else if (typeof val === 'string') {
+      return chalk.green(`"${val}"`)
     }
 
-    jsonPrettyPrint(json: string) {
-        try {
-            const parsed = JSON.parse(json);
-            console.log(this.jsonPrettyAny(parsed));
-        } catch(e) {
-            console.log(this.jsonPrettyAny(json));
-        }
-    }
+    // Number, boolean etc.
+    return chalk.cyan(val)
+  }
 
-    async finally(err: any) {
-        // called after run and catch regardless of whether or not the command errored
-        // We'll force exit here, in case there is no error, to prevent console.log from hanging the process
-        if (!err) this.exit(ExitCodes.OK);
-        super.finally(err);
+  jsonPrettyPrint(json: string) {
+    try {
+      const parsed = JSON.parse(json)
+      console.log(this.jsonPrettyAny(parsed))
+    } catch (e) {
+      console.log(this.jsonPrettyAny(json))
     }
+  }
+
+  async finally(err: any) {
+    // called after run and catch regardless of whether or not the command errored
+    // We'll force exit here, in case there is no error, to prevent console.log from hanging the process
+    if (!err) this.exit(ExitCodes.OK)
+    super.finally(err)
+  }
 }

+ 94 - 95
cli/src/base/StateAwareCommandBase.ts

@@ -1,32 +1,32 @@
-import fs from 'fs';
-import path from 'path';
-import ExitCodes from '../ExitCodes';
-import { CLIError } from '@oclif/errors';
-import { DEFAULT_API_URI } from '../Api';
-import lockFile from 'proper-lockfile';
-import DefaultCommandBase from './DefaultCommandBase';
-import os from 'os';
+import fs from 'fs'
+import path from 'path'
+import ExitCodes from '../ExitCodes'
+import { CLIError } from '@oclif/errors'
+import { DEFAULT_API_URI } from '../Api'
+import lockFile from 'proper-lockfile'
+import DefaultCommandBase from './DefaultCommandBase'
+import os from 'os'
 
 // Type for the state object (which is preserved as json in the state file)
 type StateObject = {
-    selectedAccountFilename: string,
-    apiUri: string
-};
+  selectedAccountFilename: string
+  apiUri: string
+}
 
 // State object default values
 const DEFAULT_STATE: StateObject = {
-    selectedAccountFilename: '',
-    apiUri: DEFAULT_API_URI
+  selectedAccountFilename: '',
+  apiUri: DEFAULT_API_URI,
 }
 
 // State file path (relative to getAppDataPath())
-const STATE_FILE = '/state.json';
+const STATE_FILE = '/state.json'
 
 // Possible data directory access errors
 enum DataDirErrorType {
-    Init = 0,
-    Read = 1,
-    Write = 2,
+  Init = 0,
+  Read = 1,
+  Write = 2,
 }
 
 /**
@@ -37,95 +37,94 @@ enum DataDirErrorType {
  * choosen by the user after executing account:choose command etc. (see "StateObject" type above).
  */
 export default abstract class StateAwareCommandBase extends DefaultCommandBase {
-    getAppDataPath(): string {
-        const systemAppDataPath =
-            process.env.APPDATA ||
-            (
-                process.platform === 'darwin'
-                    ? path.join(os.homedir(), '/Library/Application Support')
-                    : path.join(os.homedir(), '/.local/share')
-            );
-        const packageJson: { name?: string } = require('../../package.json');
-        if (!packageJson || !packageJson.name) {
-            throw new CLIError('Cannot get package name from package.json!');
-        }
-        return path.join(systemAppDataPath, packageJson.name);
+  getAppDataPath(): string {
+    const systemAppDataPath =
+      process.env.APPDATA ||
+      (process.platform === 'darwin'
+        ? path.join(os.homedir(), '/Library/Application Support')
+        : path.join(os.homedir(), '/.local/share'))
+    // eslint-disable-next-line @typescript-eslint/no-var-requires
+    const packageJson: { name?: string } = require('../../package.json')
+    if (!packageJson || !packageJson.name) {
+      throw new CLIError('Cannot get package name from package.json!')
     }
-
-    getStateFilePath(): string {
-        return path.join(this.getAppDataPath(), STATE_FILE);
+    return path.join(systemAppDataPath, packageJson.name)
+  }
+
+  getStateFilePath(): string {
+    return path.join(this.getAppDataPath(), STATE_FILE)
+  }
+
+  private createDataDirFsError(errorType: DataDirErrorType, specificPath = '') {
+    const actionStrs: { [x in DataDirErrorType]: string } = {
+      [DataDirErrorType.Init]: 'initialize',
+      [DataDirErrorType.Read]: 'read from',
+      [DataDirErrorType.Write]: 'write into',
     }
 
-    private createDataDirFsError(errorType: DataDirErrorType, specificPath: string = '') {
-        const actionStrs: { [x in DataDirErrorType]: string } = {
-            [DataDirErrorType.Init]: 'initialize',
-            [DataDirErrorType.Read]: 'read from',
-            [DataDirErrorType.Write]: 'write into'
-        };
+    const errorMsg =
+      `Unexpected error while trying to ${actionStrs[errorType]} the data directory.` +
+      `(${path.join(this.getAppDataPath(), specificPath)})! Permissions issue?`
 
-        const errorMsg =
-            `Unexpected error while trying to ${ actionStrs[errorType] } the data directory.`+
-            `(${ path.join(this.getAppDataPath(), specificPath) })! Permissions issue?`;
+    return new CLIError(errorMsg, { exit: ExitCodes.FsOperationFailed })
+  }
 
-        return new CLIError(errorMsg, { exit: ExitCodes.FsOperationFailed });
-    }
+  createDataReadError(specificPath = ''): CLIError {
+    return this.createDataDirFsError(DataDirErrorType.Read, specificPath)
+  }
 
-    createDataReadError(specificPath: string = ''): CLIError {
-        return this.createDataDirFsError(DataDirErrorType.Read, specificPath);
-    }
+  createDataWriteError(specificPath = ''): CLIError {
+    return this.createDataDirFsError(DataDirErrorType.Write, specificPath)
+  }
 
-    createDataWriteError(specificPath: string = ''): CLIError {
-        return this.createDataDirFsError(DataDirErrorType.Write, specificPath);
-    }
+  createDataDirInitError(specificPath = ''): CLIError {
+    return this.createDataDirFsError(DataDirErrorType.Init, specificPath)
+  }
 
-    createDataDirInitError(specificPath: string = ''): CLIError {
-        return this.createDataDirFsError(DataDirErrorType.Init, specificPath);
+  private initStateFs(): void {
+    if (!fs.existsSync(this.getAppDataPath())) {
+      fs.mkdirSync(this.getAppDataPath())
     }
-
-    private initStateFs(): void {
-        if (!fs.existsSync(this.getAppDataPath())) {
-            fs.mkdirSync(this.getAppDataPath());
-        }
-        if (!fs.existsSync(this.getStateFilePath())) {
-            fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE));
-        }
+    if (!fs.existsSync(this.getStateFilePath())) {
+      fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE))
     }
-
-    getPreservedState(): StateObject {
-        let preservedState: StateObject;
-        try {
-            preservedState = <StateObject> require(this.getStateFilePath());
-        } catch(e) {
-            throw this.createDataReadError();
-        }
-        // The state preserved in a file may be missing some required values ie.
-        // if the user previously used the older version of the software.
-        // That's why we combine it with default state before returing.
-        return { ...DEFAULT_STATE, ...preservedState };
+  }
+
+  getPreservedState(): StateObject {
+    let preservedState: StateObject
+    try {
+      preservedState = require(this.getStateFilePath()) as StateObject
+    } catch (e) {
+      throw this.createDataReadError()
     }
-
-    // Modifies preserved state. Uses file lock in order to avoid updating an older state.
-    // (which could potentialy change between read and write operation)
-    async setPreservedState(modifiedState: Partial<StateObject>): Promise<void> {
-        const stateFilePath = this.getStateFilePath();
-        const unlock = await lockFile.lock(stateFilePath);
-        let oldState: StateObject = this.getPreservedState();
-        let newState: StateObject = { ...oldState, ...modifiedState };
-        try {
-            fs.writeFileSync(stateFilePath, JSON.stringify(newState));
-        } catch(e) {
-            await unlock();
-            throw this.createDataWriteError();
-        }
-        await unlock();
+    // The state preserved in a file may be missing some required values ie.
+    // if the user previously used the older version of the software.
+    // That's why we combine it with default state before returing.
+    return { ...DEFAULT_STATE, ...preservedState }
+  }
+
+  // Modifies preserved state. Uses file lock in order to avoid updating an older state.
+  // (which could potentialy change between read and write operation)
+  async setPreservedState(modifiedState: Partial<StateObject>): Promise<void> {
+    const stateFilePath = this.getStateFilePath()
+    const unlock = await lockFile.lock(stateFilePath)
+    const oldState: StateObject = this.getPreservedState()
+    const newState: StateObject = { ...oldState, ...modifiedState }
+    try {
+      fs.writeFileSync(stateFilePath, JSON.stringify(newState))
+    } catch (e) {
+      await unlock()
+      throw this.createDataWriteError()
     }
-
-    async init() {
-        await super.init();
-        try {
-            await this.initStateFs();
-        } catch (e) {
-            throw this.createDataDirInitError();
-        }
+    await unlock()
+  }
+
+  async init() {
+    await super.init()
+    try {
+      await this.initStateFs()
+    } catch (e) {
+      throw this.createDataDirInitError()
     }
+  }
 }

+ 241 - 160
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,190 +1,271 @@
-import ExitCodes from '../ExitCodes';
-import AccountsCommandBase from './AccountsCommandBase';
-import { flags } from '@oclif/command';
-import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupMember, GroupOpening } from '../Types';
-import { apiModuleByGroup } from '../Api';
-import { CLIError } from '@oclif/errors';
-import inquirer from 'inquirer';
-import { ApiMethodInputArg } from './ApiCommandBase';
-import fs from 'fs';
-import path from 'path';
-import _ from 'lodash';
-import { ApplicationStageKeys } from '@joystream/types/hiring';
-
-const DEFAULT_GROUP = WorkingGroups.StorageProviders;
-const DRAFTS_FOLDER = 'opening-drafts';
+import ExitCodes from '../ExitCodes'
+import AccountsCommandBase from './AccountsCommandBase'
+import { flags } from '@oclif/command'
+import {
+  WorkingGroups,
+  AvailableGroups,
+  NamedKeyringPair,
+  GroupMember,
+  GroupOpening,
+  ApiMethodArg,
+  ApiMethodNamedArgs,
+  OpeningStatus,
+  GroupApplication,
+} from '../Types'
+import { apiModuleByGroup } from '../Api'
+import { CLIError } from '@oclif/errors'
+import fs from 'fs'
+import path from 'path'
+import _ from 'lodash'
+import { ApplicationStageKeys } from '@joystream/types/hiring'
+
+const DEFAULT_GROUP = WorkingGroups.StorageProviders
+const DRAFTS_FOLDER = 'opening-drafts'
 
 /**
  * Abstract base class for commands related to working groups
  */
 export default abstract class WorkingGroupsCommandBase extends AccountsCommandBase {
-    group: WorkingGroups = DEFAULT_GROUP;
-
-    static flags = {
-        group: flags.string({
-            char: 'g',
-            description:
-                "The working group context in which the command should be executed\n" +
-                `Available values are: ${AvailableGroups.join(', ')}.`,
-            required: true,
-            default: DEFAULT_GROUP
-        }),
-    };
-
-    // Use when lead access is required in given command
-    async getRequiredLead(): Promise<GroupMember> {
-        let selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount();
-        let lead = await this.getApi().groupLead(this.group);
-
-        if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
-            this.error('Lead access required for this command!', { exit: ExitCodes.AccessDenied });
-        }
-
-        return lead;
+  group: WorkingGroups = DEFAULT_GROUP
+
+  static flags = {
+    group: flags.string({
+      char: 'g',
+      description:
+        'The working group context in which the command should be executed\n' +
+        `Available values are: ${AvailableGroups.join(', ')}.`,
+      required: true,
+      default: DEFAULT_GROUP,
+    }),
+  }
+
+  // Use when lead access is required in given command
+  async getRequiredLead(): Promise<GroupMember> {
+    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+    const lead = await this.getApi().groupLead(this.group)
+
+    if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
+      this.error('Lead access required for this command!', { exit: ExitCodes.AccessDenied })
     }
 
-    // Use when worker access is required in given command
-    async getRequiredWorker(): Promise<GroupMember> {
-        let selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount();
-        let groupMembers = await this.getApi().groupMembers(this.group);
-        let groupMembersByAccount = groupMembers.filter(m => m.roleAccount.toString() === selectedAccount.address);
-
-        if (!groupMembersByAccount.length) {
-            this.error('Worker access required for this command!', { exit: ExitCodes.AccessDenied });
-        }
-        else if (groupMembersByAccount.length === 1) {
-            return groupMembersByAccount[0];
-        }
-        else {
-            return await this.promptForWorker(groupMembersByAccount);
-        }
+    return lead
+  }
+
+  // Use when worker access is required in given command
+  async getRequiredWorker(): Promise<GroupMember> {
+    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+    const groupMembers = await this.getApi().groupMembers(this.group)
+    const groupMembersByAccount = groupMembers.filter((m) => m.roleAccount.toString() === selectedAccount.address)
+
+    if (!groupMembersByAccount.length) {
+      this.error('Worker access required for this command!', { exit: ExitCodes.AccessDenied })
+    } else if (groupMembersByAccount.length === 1) {
+      return groupMembersByAccount[0]
+    } else {
+      return await this.promptForWorker(groupMembersByAccount)
     }
+  }
 
-    async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
-        const { choosenWorkerIndex } = await inquirer.prompt([{
-            name: 'chosenWorkerIndex',
-            message: 'Choose the worker to execute the command as',
-            type: 'list',
-            choices: groupMembers.map((groupMember, index) => ({
-                name: `Worker ID ${ groupMember.workerId.toString() }`,
-                value: index
-            }))
-        }]);
-
-        return groupMembers[choosenWorkerIndex];
+  // 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))
+    )
+
+    if (!controlledWorkers.length) {
+      this.error(`Member controller account with some associated ${this.group} group roles needs to be selected!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    } else if (controlledWorkers.length === 1) {
+      return controlledWorkers[0]
+    } else {
+      return await this.promptForWorker(controlledWorkers)
     }
+  }
+
+  async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
+    const chosenWorkerIndex = await this.simplePrompt({
+      message: 'Choose the intended worker context:',
+      type: 'list',
+      choices: groupMembers.map((groupMember, index) => ({
+        name: `Worker ID ${groupMember.workerId.toString()}`,
+        value: index,
+      })),
+    })
 
-    async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
-        const acceptableApplications = opening.applications.filter(a => a.stage === ApplicationStageKeys.Active);
-        const acceptedApplications = await this.simplePrompt({
-            message: 'Select succesful applicants',
-            type: 'checkbox',
-            choices: acceptableApplications.map(a => ({
-                name: ` ${a.wgApplicationId}: ${a.member?.handle.toString()}`,
-                value: a.wgApplicationId,
-            }))
-        });
-
-        return acceptedApplications;
+    return groupMembers[chosenWorkerIndex]
+  }
+
+  async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
+    const acceptableApplications = opening.applications.filter((a) => a.stage === ApplicationStageKeys.Active)
+    const acceptedApplications = await this.simplePrompt({
+      message: 'Select succesful applicants',
+      type: 'checkbox',
+      choices: acceptableApplications.map((a) => ({
+        name: ` ${a.wgApplicationId}: ${a.member?.handle.toString()}`,
+        value: a.wgApplicationId,
+      })),
+    })
+
+    return acceptedApplications
+  }
+
+  async promptForNewOpeningDraftName() {
+    let draftName = '',
+      fileExists = false,
+      overrideConfirmed = false
+
+    do {
+      draftName = await this.simplePrompt({
+        type: 'input',
+        message: 'Provide the draft name',
+        validate: (val) => (typeof val === 'string' && val.length >= 1) || 'Draft name is required!',
+      })
+
+      fileExists = fs.existsSync(this.getOpeningDraftPath(draftName))
+      if (fileExists) {
+        overrideConfirmed = await this.simplePrompt({
+          type: 'confirm',
+          message: 'Such draft already exists. Do you wish to override it?',
+          default: false,
+        })
+      }
+    } while (fileExists && !overrideConfirmed)
+
+    return draftName
+  }
+
+  async promptForOpeningDraft() {
+    let draftFiles: string[] = []
+    try {
+      draftFiles = fs.readdirSync(this.getOpeingDraftsPath())
+    } catch (e) {
+      throw this.createDataReadError(DRAFTS_FOLDER)
+    }
+    if (!draftFiles.length) {
+      throw new CLIError('No drafts available!', { exit: ExitCodes.FileNotFound })
     }
+    const draftNames = draftFiles.map((fileName) => _.startCase(fileName.replace('.json', '')))
+    const selectedDraftName = await this.simplePrompt({
+      message: 'Select a draft',
+      type: 'list',
+      choices: draftNames,
+    })
 
-    async promptForNewOpeningDraftName() {
-        let
-            draftName: string = '',
-            fileExists: boolean = false,
-            overrideConfirmed: boolean = false;
-
-        do {
-            draftName = await this.simplePrompt({
-                type: 'input',
-                message: 'Provide the draft name',
-                validate: val => (typeof val === 'string' && val.length >= 1) || 'Draft name is required!'
-            });
-
-            fileExists = fs.existsSync(this.getOpeningDraftPath(draftName));
-            if (fileExists) {
-                overrideConfirmed = await this.simplePrompt({
-                    type: 'confirm',
-                    message: 'Such draft already exists. Do you wish to override it?',
-                    default: false
-                });
-            }
-        } while(fileExists && !overrideConfirmed);
-
-        return draftName;
+    return selectedDraftName
+  }
+
+  async getOpeningForLeadAction(id: number, requiredStatus?: OpeningStatus): Promise<GroupOpening> {
+    const opening = await this.getApi().groupOpening(this.group, id)
+
+    if (!opening.type.isOfType('Worker')) {
+      this.error('A lead can only manage Worker openings!', { exit: ExitCodes.AccessDenied })
     }
 
-    async promptForOpeningDraft() {
-        let draftFiles: string[] = [];
-        try {
-            draftFiles = fs.readdirSync(this.getOpeingDraftsPath());
-        }
-        catch(e) {
-            throw this.createDataReadError(DRAFTS_FOLDER);
-        }
-        if (!draftFiles.length) {
-            throw new CLIError('No drafts available!', { exit: ExitCodes.FileNotFound });
-        }
-        const draftNames = draftFiles.map(fileName => _.startCase(fileName.replace('.json', '')));
-        const selectedDraftName = await this.simplePrompt({
-            message: 'Select a draft',
-            type: 'list',
-            choices: draftNames
-        });
-
-        return selectedDraftName;
+    if (requiredStatus && opening.stage.status !== requiredStatus) {
+      this.error(
+        `The opening needs to be in "${_.startCase(requiredStatus)}" stage! ` +
+          `This one is: "${_.startCase(opening.stage.status)}"`,
+        { exit: ExitCodes.InvalidInput }
+      )
     }
 
-    loadOpeningDraftParams(draftName: string) {
-        const draftFilePath = this.getOpeningDraftPath(draftName);
-        const params = this.extrinsicArgsFromDraft(
-            apiModuleByGroup[this.group],
-            'addOpening',
-            draftFilePath
-        );
+    return opening
+  }
+
+  // An alias for better code readibility in case we don't need the actual return value
+  validateOpeningForLeadAction = this.getOpeningForLeadAction
+
+  async getApplicationForLeadAction(id: number, requiredStatus?: ApplicationStageKeys): Promise<GroupApplication> {
+    const application = await this.getApi().groupApplication(this.group, id)
+    const opening = await this.getApi().groupOpening(this.group, application.wgOpeningId)
 
-        return params;
+    if (!opening.type.isOfType('Worker')) {
+      this.error('A lead can only manage Worker opening applications!', { exit: ExitCodes.AccessDenied })
     }
 
-    getOpeingDraftsPath() {
-        return path.join(this.getAppDataPath(), DRAFTS_FOLDER);
+    if (requiredStatus && application.stage !== requiredStatus) {
+      this.error(
+        `The application needs to have "${_.startCase(requiredStatus)}" status! ` +
+          `This one has: "${_.startCase(application.stage)}"`,
+        { exit: ExitCodes.InvalidInput }
+      )
+    }
+
+    return application
+  }
+
+  async getWorkerForLeadAction(id: number, requireStakeProfile = false) {
+    const groupMember = await this.getApi().groupMember(this.group, id)
+    const groupLead = await this.getApi().groupLead(this.group)
+
+    if (groupLead?.workerId.eq(groupMember.workerId)) {
+      this.error('A lead cannot manage his own role this way!', { exit: ExitCodes.AccessDenied })
     }
 
-    getOpeningDraftPath(draftName: string) {
-        return path.join(this.getOpeingDraftsPath(), _.snakeCase(draftName)+'.json');
+    if (requireStakeProfile && !groupMember.stake) {
+      this.error('This worker has no associated role stake profile!', { exit: ExitCodes.InvalidInput })
     }
 
-    saveOpeningDraft(draftName: string, params: ApiMethodInputArg[]) {
-        const paramsJson = JSON.stringify(
-            params.map(p => p.toJSON()),
-            null,
-            2
-        );
-
-        try {
-            fs.writeFileSync(this.getOpeningDraftPath(draftName), paramsJson);
-        } catch(e) {
-            throw this.createDataWriteError(DRAFTS_FOLDER);
-        }
+    return groupMember
+  }
+
+  // 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) {
+    return (await this.getWorkerForLeadAction(id, true)) as GroupMember & Required<Pick<GroupMember, 'stake'>>
+  }
+
+  loadOpeningDraftParams(draftName: string): ApiMethodNamedArgs {
+    const draftFilePath = this.getOpeningDraftPath(draftName)
+    const params = this.extrinsicArgsFromDraft(apiModuleByGroup[this.group], 'addOpening', draftFilePath)
+
+    return params
+  }
+
+  getOpeingDraftsPath() {
+    return path.join(this.getAppDataPath(), DRAFTS_FOLDER)
+  }
+
+  getOpeningDraftPath(draftName: string) {
+    return path.join(this.getOpeingDraftsPath(), _.snakeCase(draftName) + '.json')
+  }
+
+  saveOpeningDraft(draftName: string, params: ApiMethodArg[]) {
+    const paramsJson = JSON.stringify(
+      params.map((p) => p.toJSON()),
+      null,
+      2
+    )
+
+    try {
+      fs.writeFileSync(this.getOpeningDraftPath(draftName), paramsJson)
+    } catch (e) {
+      throw this.createDataWriteError(DRAFTS_FOLDER)
     }
+  }
 
-    private initOpeningDraftsDir(): void {
-        if (!fs.existsSync(this.getOpeingDraftsPath())) {
-            fs.mkdirSync(this.getOpeingDraftsPath());
-        }
+  private initOpeningDraftsDir(): void {
+    if (!fs.existsSync(this.getOpeingDraftsPath())) {
+      fs.mkdirSync(this.getOpeingDraftsPath())
     }
+  }
 
-    async init() {
-        await super.init();
-        try {
-            this.initOpeningDraftsDir();
-        } catch (e) {
-            throw this.createDataDirInitError();
-        }
-        const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase);
-        if (!AvailableGroups.includes(flags.group as any)) {
-            throw new CLIError(`Invalid group! Available values are: ${AvailableGroups.join(', ')}`, { exit: ExitCodes.InvalidInput });
-        }
-        this.group = flags.group as WorkingGroups;
+  async init() {
+    await super.init()
+    try {
+      this.initOpeningDraftsDir()
+    } catch (e) {
+      throw this.createDataDirInitError()
+    }
+    const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase)
+    if (!AvailableGroups.includes(flags.group as any)) {
+      throw new CLIError(`Invalid group! Available values are: ${AvailableGroups.join(', ')}`, {
+        exit: ExitCodes.InvalidInput,
+      })
     }
+    this.group = flags.group as WorkingGroups
+  }
 }

+ 25 - 25
cli/src/commands/account/choose.ts

@@ -1,33 +1,33 @@
-import AccountsCommandBase from '../../base/AccountsCommandBase';
-import chalk from 'chalk';
-import ExitCodes from '../../ExitCodes';
-import { NamedKeyringPair } from '../../Types';
-import { flags } from '@oclif/command';
+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',
-            required: false
-        }),
-    };
+  static description = 'Choose default account to use in the CLI'
+  static flags = {
+    showSpecial: flags.boolean({
+      description: 'Whether to show special (DEV chain) accounts',
+      required: false,
+    }),
+  }
 
-    async run() {
-        const { showSpecial } = this.parse(AccountChoose).flags;
-        const accounts: NamedKeyringPair[] = this.fetchAccounts(showSpecial);
-        const selectedAccount: NamedKeyringPair | null = this.getSelectedAccount();
+  async run() {
+    const { showSpecial } = this.parse(AccountChoose).flags
+    const accounts: NamedKeyringPair[] = this.fetchAccounts(showSpecial)
+    const selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
 
-        this.log(chalk.white(`Found ${ accounts.length } existing accounts...\n`));
+    this.log(chalk.white(`Found ${accounts.length} existing accounts...\n`))
 
-        if (accounts.length === 0) {
-            this.warn('No account to choose from. Add accont using account:import or account:create.');
-            this.exit(ExitCodes.NoAccountFound);
-        }
+    if (accounts.length === 0) {
+      this.warn('No account to choose from. Add accont using account:import or account:create.')
+      this.exit(ExitCodes.NoAccountFound)
+    }
 
-        const choosenAccount: NamedKeyringPair = await this.promptForAccount(accounts, selectedAccount);
+    const choosenAccount: NamedKeyringPair = await this.promptForAccount(accounts, selectedAccount)
 
-        await this.setSelectedAccount(choosenAccount);
-        this.log(chalk.greenBright("\nAccount switched!"));
-    }
+    await this.setSelectedAccount(choosenAccount)
+    this.log(chalk.greenBright('\nAccount switched!'))
   }
+}

+ 35 - 35
cli/src/commands/account/create.ts

@@ -1,47 +1,47 @@
-import chalk from 'chalk';
-import ExitCodes from '../../ExitCodes';
-import AccountsCommandBase from '../../base/AccountsCommandBase';
-import { Keyring } from '@polkadot/api';
+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';
+import { NamedKeyringPair } from '../../Types'
 
 type AccountCreateArgs = {
-    name: string
-};
+  name: string
+}
 
 export default class AccountCreate extends AccountsCommandBase {
-    static description = 'Create new account';
-
-    static args = [
-        {
-            name: 'name',
-            required: true,
-            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 });
-    }
+  static description = 'Create new account'
+
+  static args = [
+    {
+      name: 'name',
+      required: true,
+      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 })
+  }
 
-    async run() {
-        const args: AccountCreateArgs = <AccountCreateArgs> this.parse(AccountCreate).args;
-        const keyring: Keyring = new Keyring();
-        const mnemonic: string = mnemonicGenerate();
+  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 = <NamedKeyringPair> keyring.pairs[0]; // We assigned the name above
+    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');
+    const password = await this.promptForPassword("Set your account's password")
+    const password2 = await this.promptForPassword('Confirm your password')
 
-        this.validatePass(password, password2);
+    this.validatePass(password, password2)
 
-        this.saveAccount(keys, password);
+    this.saveAccount(keys, password)
 
-        this.log(chalk.greenBright(`\nAccount succesfully created!`));
-        this.log(chalk.white(`${chalk.bold('Name:    ') }${ args.name }`));
-        this.log(chalk.white(`${chalk.bold('Address: ') }${ keys.address }`));
-    }
+    this.log(chalk.greenBright(`\nAccount succesfully created!`))
+    this.log(chalk.white(`${chalk.bold('Name:    ')}${args.name}`))
+    this.log(chalk.white(`${chalk.bold('Address: ')}${keys.address}`))
   }
+}

+ 34 - 34
cli/src/commands/account/current.ts

@@ -1,41 +1,41 @@
-import AccountsCommandBase from '../../base/AccountsCommandBase';
-import { AccountSummary, NameValueObj, NamedKeyringPair } from '../../Types';
-import { DerivedBalances } from '@polkadot/api-derive/types';
-import { displayHeader, displayNameValueTable } from '../../helpers/display';
-import { formatBalance } from '@polkadot/util';
-import moment from 'moment';
+import AccountsCommandBase from '../../base/AccountsCommandBase'
+import { AccountSummary, NameValueObj, NamedKeyringPair } from '../../Types'
+import { DerivedBalances } from '@polkadot/api-derive/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'];
+  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);
+  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).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('Account information')
+    const creationDate: string = currentAccount.meta.whenCreated
+      ? moment(currentAccount.meta.whenCreated).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: DerivedBalances = summary.balances;
-        let 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);
+    displayHeader('Balances')
+    const balances: DerivedBalances = 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)
   }
+}

+ 62 - 61
cli/src/commands/account/export.ts

@@ -1,73 +1,74 @@
-import fs from 'fs';
-import chalk from 'chalk';
-import path from 'path';
-import ExitCodes from '../../ExitCodes';
-import AccountsCommandBase from '../../base/AccountsCommandBase';
-import { flags } from '@oclif/command';
-import { NamedKeyringPair } from '../../Types';
+import fs from 'fs'
+import chalk from 'chalk'
+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 AccountExportFlags = { all: boolean }
+type AccountExportArgs = { path: string }
 
 export default class AccountExport extends AccountsCommandBase {
-    static description = 'Export account(s) to given location';
-    static MULTI_EXPORT_FOLDER_NAME = 'exported_accounts';
+  static description = 'Export account(s) to given location'
+  static MULTI_EXPORT_FOLDER_NAME = 'exported_accounts'
 
-    static args = [
-        {
-            name: 'path',
-            required: true,
-            description: 'Path where the exported files should be placed'
-        }
-    ];
+  static args = [
+    {
+      name: 'path',
+      required: true,
+      description: 'Path where the exported files should be placed',
+    },
+  ]
 
-    static flags = {
-        all: flags.boolean({
-            char: 'a',
-            description: `If provided, exports all existing accounts into "${ AccountExport.MULTI_EXPORT_FOLDER_NAME }" folder inside given path`,
-        }),
-    };
-
-    exportAccount(account: NamedKeyringPair, destPath: string): string {
-        const sourceFilePath: string = this.getAccountFilePath(account);
-        const destFilePath: string = path.join(destPath, this.generateAccountFilename(account));
-        try {
-            fs.copyFileSync(sourceFilePath, destFilePath);
-        }
-        catch (e) {
-            this.error(
-                `Error while trying to copy into the export file: (${ destFilePath }). Permissions issue?`,
-                { exit: ExitCodes.FsOperationFailed }
-            );
-        }
+  static flags = {
+    all: flags.boolean({
+      char: 'a',
+      description: `If provided, exports all existing accounts into "${AccountExport.MULTI_EXPORT_FOLDER_NAME}" folder inside given path`,
+    }),
+  }
 
-        return destFilePath;
+  exportAccount(account: NamedKeyringPair, destPath: string): string {
+    const sourceFilePath: string = this.getAccountFilePath(account)
+    const destFilePath: string = path.join(destPath, this.generateAccountFilename(account))
+    try {
+      fs.copyFileSync(sourceFilePath, destFilePath)
+    } catch (e) {
+      this.error(`Error while trying to copy into the export file: (${destFilePath}). Permissions issue?`, {
+        exit: ExitCodes.FsOperationFailed,
+      })
     }
 
-    async run() {
-        const args: AccountExportArgs = <AccountExportArgs> this.parse(AccountExport).args;
-        const flags: AccountExportFlags = <AccountExportFlags> this.parse(AccountExport).flags;
-        const accounts: NamedKeyringPair[] = this.fetchAccounts();
+    return destFilePath
+  }
 
-        if (!accounts.length) {
-            this.error('No accounts found!', { exit: ExitCodes.NoAccountFound });
-        }
+  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 })
+    }
 
-        if (flags.all) {
-            const destPath: string = path.join(args.path, AccountExport.MULTI_EXPORT_FOLDER_NAME);
-            try {
-                if (!fs.existsSync(destPath)) fs.mkdirSync(destPath);
-            } catch(e) {
-                this.error(`Failed to create the export folder (${ destPath })`, { exit: ExitCodes.FsOperationFailed });
-            }
-            for (let account of accounts) this.exportAccount(account, destPath);
-            this.log(chalk.greenBright(`All accounts succesfully exported succesfully to: ${ chalk.white(destPath) }!`));
-        }
-        else {
-            const destPath: string = args.path;
-            const choosenAccount: NamedKeyringPair = await this.promptForAccount(accounts, null, 'Select an account to export');
-            const exportedFilePath: string = this.exportAccount(choosenAccount, destPath);
-            this.log(chalk.greenBright(`Account succesfully exported to: ${ chalk.white(exportedFilePath) }`));
-        }
+    if (flags.all) {
+      const destPath: string = path.join(args.path, AccountExport.MULTI_EXPORT_FOLDER_NAME)
+      try {
+        if (!fs.existsSync(destPath)) fs.mkdirSync(destPath)
+      } catch (e) {
+        this.error(`Failed to create the export folder (${destPath})`, { exit: ExitCodes.FsOperationFailed })
+      }
+      for (const account of accounts) this.exportAccount(account, destPath)
+      this.log(chalk.greenBright(`All accounts succesfully exported succesfully to: ${chalk.white(destPath)}!`))
+    } else {
+      const destPath: string = args.path
+      const choosenAccount: NamedKeyringPair = await this.promptForAccount(
+        accounts,
+        null,
+        'Select an account to export'
+      )
+      const exportedFilePath: string = this.exportAccount(choosenAccount, destPath)
+      this.log(chalk.greenBright(`Account succesfully exported to: ${chalk.white(exportedFilePath)}`))
     }
   }
+}

+ 23 - 21
cli/src/commands/account/forget.ts

@@ -1,29 +1,31 @@
-import fs from 'fs';
-import chalk from 'chalk';
-import ExitCodes from '../../ExitCodes';
-import AccountsCommandBase from '../../base/AccountsCommandBase';
-import { NamedKeyringPair } from '../../Types';
+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';
+  static description = 'Forget (remove) account from the list of available accounts'
 
-    async run() {
-        const accounts: NamedKeyringPair[] = this.fetchAccounts();
+  async run() {
+    const accounts: NamedKeyringPair[] = this.fetchAccounts()
 
-        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?');
+    if (!accounts.length) {
+      this.error('No accounts found!', { exit: ExitCodes.NoAccountFound })
+    }
 
-        const accountFilePath: string = this.getAccountFilePath(choosenAccount);
-        try {
-            fs.unlinkSync(accountFilePath);
-        } catch (e) {
-            this.error(`Could not remove account file (${ accountFilePath }). Permissions issue?`, { exit: ExitCodes.FsOperationFailed });
-        }
+    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?')
 
-        this.log(chalk.greenBright(`\nAccount has been forgotten!`))
+    const accountFilePath: string = this.getAccountFilePath(choosenAccount)
+    try {
+      fs.unlinkSync(accountFilePath)
+    } catch (e) {
+      this.error(`Could not remove account file (${accountFilePath}). Permissions issue?`, {
+        exit: ExitCodes.FsOperationFailed,
+      })
     }
+
+    this.log(chalk.greenBright(`\nAccount has been forgotten!`))
   }
+}

+ 34 - 36
cli/src/commands/account/import.ts

@@ -1,46 +1,44 @@
-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';
+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
-};
+  backupFilePath: string
+}
 
 export default class AccountImport extends AccountsCommandBase {
-    static description = 'Import account using JSON backup file';
+  static description = 'Import account using JSON backup file'
 
-    static args = [
-        {
-            name: 'backupFilePath',
-            required: true,
-            description: 'Path to account backup JSON file'
-        },
-    ];
+  static args = [
+    {
+      name: 'backupFilePath',
+      required: true,
+      description: 'Path to account backup JSON file',
+    },
+  ]
 
-    async run() {
-        const args: AccountImportArgs = <AccountImportArgs> this.parse(AccountImport).args;
-        const backupAcc: NamedKeyringPair = this.fetchAccountFromJsonFile(args.backupFilePath);
-        const accountName: string = backupAcc.meta.name;
-        const accountAddress: string = backupAcc.address;
+  async run() {
+    const args: AccountImportArgs = this.parse(AccountImport).args as AccountImportArgs
+    const backupAcc: NamedKeyringPair = this.fetchAccountFromJsonFile(args.backupFilePath)
+    const accountName: string = backupAcc.meta.name
+    const accountAddress: string = backupAcc.address
 
-        const sourcePath: string = args.backupFilePath;
-        const destPath: string = path.join(this.getAccountsDirPath(), this.generateAccountFilename(backupAcc));
+    const sourcePath: string = args.backupFilePath
+    const destPath: string = path.join(this.getAccountsDirPath(), this.generateAccountFilename(backupAcc))
 
-        try {
-            fs.copyFileSync(sourcePath, destPath);
-        }
-        catch (e) {
-            this.error(
-                'Unexpected error while trying to copy input file! Permissions issue?',
-                { exit: ExitCodes.FsOperationFailed }
-            );
-        }
-
-        this.log(chalk.bold.greenBright(`ACCOUNT IMPORTED SUCCESFULLY!`));
-        this.log(chalk.bold.white(`NAME:    `), accountName);
-        this.log(chalk.bold.white(`ADDRESS: `), accountAddress);
+    try {
+      fs.copyFileSync(sourcePath, destPath)
+    } catch (e) {
+      this.error('Unexpected error while trying to copy input file! Permissions issue?', {
+        exit: ExitCodes.FsOperationFailed,
+      })
     }
+
+    this.log(chalk.bold.greenBright(`ACCOUNT IMPORTED SUCCESFULLY!`))
+    this.log(chalk.bold.white(`NAME:    `), accountName)
+    this.log(chalk.bold.white(`ADDRESS: `), accountAddress)
   }
+}

+ 53 - 54
cli/src/commands/account/transferTokens.ts

@@ -1,68 +1,67 @@
-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';
-import { DerivedBalances } from '@polkadot/api-derive/types';
+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'
+import { DerivedBalances } from '@polkadot/api-derive/types'
 
 type AccountTransferArgs = {
-    recipient: string,
-    amount: string
-};
+  recipient: string
+  amount: string
+}
 
 export default class AccountTransferTokens extends AccountsCommandBase {
-    static description = 'Transfer tokens from currently choosen account';
+  static description = 'Transfer tokens from currently choosen account'
 
-    static args = [
-        {
-            name: 'recipient',
-            required: true,
-            description: 'Address of the transfer recipient'
-        },
-        {
-            name: 'amount',
-            required: true,
-            description: 'Amount of tokens to transfer'
-        },
-    ];
+  static args = [
+    {
+      name: 'recipient',
+      required: true,
+      description: 'Address of the transfer recipient',
+    },
+    {
+      name: 'amount',
+      required: true,
+      description: 'Amount of tokens to transfer',
+    },
+  ]
 
-    async run() {
-        const args: AccountTransferArgs = <AccountTransferArgs> this.parse(AccountTransferTokens).args;
-        const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount();
-        const amountBN: BN = new BN(args.amount);
+  async run() {
+    const args: AccountTransferArgs = this.parse(AccountTransferTokens).args as AccountTransferArgs
+    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+    const amountBN: BN = new BN(args.amount)
 
-        // Initial validation
-        validateAddress(args.recipient, 'Invalid recipient address');
-        const accBalances: DerivedBalances = (await this.getApi().getAccountsBalancesInfo([ selectedAccount.address ]))[0];
-        checkBalance(accBalances, amountBN);
+    // Initial validation
+    validateAddress(args.recipient, 'Invalid recipient address')
+    const accBalances: DerivedBalances = (await this.getApi().getAccountsBalancesInfo([selectedAccount.address]))[0]
+    checkBalance(accBalances, amountBN)
 
-        await this.requestAccountDecoding(selectedAccount);
+    await this.requestAccountDecoding(selectedAccount)
 
-        this.log(chalk.white('Estimating fee...'));
-        let estimatedFee: BN;
-        try {
-            estimatedFee = await this.getApi().estimateFee(selectedAccount, args.recipient, amountBN);
-        }
-        catch (e) {
-            this.error('Could not estimate the fee.', { exit: ExitCodes.UnexpectedException });
-        }
-        const totalAmount: BN = amountBN.add(estimatedFee);
-        this.log(chalk.white('Estimated fee:', formatBalance(estimatedFee)));
-        this.log(chalk.white('Total transfer amount:', formatBalance(totalAmount)));
+    this.log(chalk.white('Estimating fee...'))
+    let estimatedFee: BN
+    try {
+      estimatedFee = await this.getApi().estimateFee(selectedAccount, args.recipient, amountBN)
+    } catch (e) {
+      this.error('Could not estimate the fee.', { exit: ExitCodes.UnexpectedException })
+    }
+    const totalAmount: BN = amountBN.add(estimatedFee)
+    this.log(chalk.white('Estimated fee:', formatBalance(estimatedFee)))
+    this.log(chalk.white('Total transfer amount:', formatBalance(totalAmount)))
 
-        checkBalance(accBalances, totalAmount);
+    checkBalance(accBalances, totalAmount)
 
-        await this.requireConfirmation('Do you confirm the transfer?');
+    await this.requireConfirmation('Do you confirm the transfer?')
 
-        try {
-            const txHash: Hash = await this.getApi().transfer(selectedAccount, args.recipient, amountBN);
-            this.log(chalk.greenBright('Transaction succesfully sent!'));
-            this.log(chalk.white('Hash:', txHash.toString()));
-        } catch (e) {
-            this.error('Could not send the transaction.', { exit: ExitCodes.UnexpectedException });
-        }
+    try {
+      const txHash: Hash = await this.getApi().transfer(selectedAccount, args.recipient, amountBN)
+      this.log(chalk.greenBright('Transaction succesfully sent!'))
+      this.log(chalk.white('Hash:', txHash.toString()))
+    } catch (e) {
+      this.error('Could not send the transaction.', { exit: ExitCodes.UnexpectedException })
     }
   }
+}

+ 7 - 8
cli/src/commands/api/getUri.ts

@@ -1,12 +1,11 @@
-import StateAwareCommandBase from '../../base/StateAwareCommandBase';
-import chalk from 'chalk';
-
+import StateAwareCommandBase from '../../base/StateAwareCommandBase'
+import chalk from 'chalk'
 
 export default class ApiGetUri extends StateAwareCommandBase {
-    static description = 'Get current api WS provider uri';
+  static description = 'Get current api WS provider uri'
 
-    async run() {
-        const currentUri:string = this.getPreservedState().apiUri;
-        this.log(chalk.green(currentUri));
-    }
+  async run() {
+    const currentUri: string = this.getPreservedState().apiUri
+    this.log(chalk.green(currentUri))
   }
+}

+ 210 - 203
cli/src/commands/api/inspect.ts

@@ -1,227 +1,234 @@
-import { flags } from '@oclif/command';
-import { CLIError } from '@oclif/errors';
-import { displayNameValueTable } from '../../helpers/display';
-import { ApiPromise } from '@polkadot/api';
-import { Option } from '@polkadot/types';
-import { Codec } from '@polkadot/types/types';
-import { ConstantCodec } from '@polkadot/api-metadata/consts/types';
-import ExitCodes from '../../ExitCodes';
-import chalk from 'chalk';
-import { NameValueObj } from '../../Types';
-import ApiCommandBase, { ApiMethodInputArg } from '../../base/ApiCommandBase';
+import { flags } from '@oclif/command'
+import { CLIError } from '@oclif/errors'
+import { displayNameValueTable } from '../../helpers/display'
+import { ApiPromise } from '@polkadot/api'
+import { Option } from '@polkadot/types'
+import { Codec } from '@polkadot/types/types'
+import { ConstantCodec } from '@polkadot/api-metadata/consts/types'
+import ExitCodes from '../../ExitCodes'
+import chalk from 'chalk'
+import { NameValueObj, ApiMethodArg } from '../../Types'
+import ApiCommandBase from '../../base/ApiCommandBase'
 
 // Command flags type
 type ApiInspectFlags = {
-    type: string,
-    module: string,
-    method: string,
-    exec: boolean,
-    callArgs: string
-};
+  type: string
+  module: string
+  method: string
+  exec: boolean
+  callArgs: string
+}
 
 // Currently "inspectable" api types
-const TYPES_AVAILABLE = [
-    'query',
-    'consts',
-] as const;
+const TYPES_AVAILABLE = ['query', 'consts'] as const
 
 // String literals type based on TYPES_AVAILABLE const.
 // It works as if we specified: type ApiType = 'query' | 'consts'...;
-type ApiType = typeof TYPES_AVAILABLE[number];
+type ApiType = typeof TYPES_AVAILABLE[number]
 
 export default class ApiInspect extends ApiCommandBase {
-    static description =
-        'Lists available node API modules/methods and/or their description(s), '+
-        'or calls one of the API methods (depending on provided arguments and flags)';
-
-    static examples = [
-        '$ api:inspect',
-        '$ api:inspect -t=query',
-        '$ api:inspect -t=query -M=members',
-        '$ api:inspect -t=query -M=members -m=memberProfile',
-        '$ api:inspect -t=query -M=members -m=memberProfile -e',
-        '$ api:inspect -t=query -M=members -m=memberProfile -e -a=1',
-    ];
-
-    static flags = {
-        type: flags.string({
-            char: 't',
-            description:
-                'Specifies the type/category of the inspected request (ie. "query", "consts" etc.).\n'+
-                'If no "--module" flag is provided then all available modules in that type will be listed.\n'+
-                'If this flag is not provided then all available types will be listed.',
-        }),
-        module: flags.string({
-            char: 'M',
-            description:
-                'Specifies the api module, ie. "system", "staking" etc.\n'+
-                'If no "--method" flag is provided then all methods in that module will be listed along with the descriptions.',
-            dependsOn: ['type'],
-        }),
-        method: flags.string({
-            char: 'm',
-            description: 'Specifies the api method to call/describe.',
-            dependsOn: ['module'],
-        }),
-        exec: flags.boolean({
-            char: 'e',
-            description: 'Provide this flag if you want to execute the actual call, instead of displaying the method description (which is default)',
-            dependsOn: ['method'],
-        }),
-        callArgs: flags.string({
-            char: 'a',
-            description:
-                'Specifies the arguments to use when calling a method. Multiple arguments can be separated with a comma, ie. "-a=arg1,arg2".\n'+
-                'You can omit this flag even if the method requires some aguments.\n'+
-                'In that case you will be promted to provide value for each required argument.\n' +
-                'Ommiting this flag is recommended when input parameters are of more complex types (and it\'s hard to specify them as just simple comma-separated strings)',
-            dependsOn: ['exec'],
-        })
-    };
-
-    getMethodMeta(apiType: ApiType, apiModule: string, apiMethod: string) {
-        if (apiType === 'query') {
-            return this.getOriginalApi().query[apiModule][apiMethod].creator.meta;
-        }
-        else {
-            // Currently the only other optoin is api.consts
-            const method:ConstantCodec = <ConstantCodec> this.getOriginalApi().consts[apiModule][apiMethod];
-            return method.meta;
-        }
+  static description =
+    'Lists available node API modules/methods and/or their description(s), ' +
+    'or calls one of the API methods (depending on provided arguments and flags)'
+
+  static examples = [
+    '$ api:inspect',
+    '$ api:inspect -t=query',
+    '$ api:inspect -t=query -M=members',
+    '$ api:inspect -t=query -M=members -m=memberProfile',
+    '$ api:inspect -t=query -M=members -m=memberProfile -e',
+    '$ api:inspect -t=query -M=members -m=memberProfile -e -a=1',
+  ]
+
+  static flags = {
+    type: flags.string({
+      char: 't',
+      description:
+        'Specifies the type/category of the inspected request (ie. "query", "consts" etc.).\n' +
+        'If no "--module" flag is provided then all available modules in that type will be listed.\n' +
+        'If this flag is not provided then all available types will be listed.',
+    }),
+    module: flags.string({
+      char: 'M',
+      description:
+        'Specifies the api module, ie. "system", "staking" etc.\n' +
+        'If no "--method" flag is provided then all methods in that module will be listed along with the descriptions.',
+      dependsOn: ['type'],
+    }),
+    method: flags.string({
+      char: 'm',
+      description: 'Specifies the api method to call/describe.',
+      dependsOn: ['module'],
+    }),
+    exec: flags.boolean({
+      char: 'e',
+      description:
+        'Provide this flag if you want to execute the actual call, instead of displaying the method description (which is default)',
+      dependsOn: ['method'],
+    }),
+    callArgs: flags.string({
+      char: 'a',
+      description:
+        'Specifies the arguments to use when calling a method. Multiple arguments can be separated with a comma, ie. "-a=arg1,arg2".\n' +
+        'You can omit this flag even if the method requires some aguments.\n' +
+        'In that case you will be promted to provide value for each required argument.\n' +
+        "Ommiting this flag is recommended when input parameters are of more complex types (and it's hard to specify them as just simple comma-separated strings)",
+      dependsOn: ['exec'],
+    }),
+  }
+
+  getMethodMeta(apiType: ApiType, apiModule: string, apiMethod: string) {
+    if (apiType === 'query') {
+      return this.getOriginalApi().query[apiModule][apiMethod].creator.meta
+    } else {
+      // Currently the only other optoin is api.consts
+      const method: ConstantCodec = this.getOriginalApi().consts[apiModule][apiMethod] as ConstantCodec
+      return method.meta
     }
-
-    getMethodDescription(apiType: ApiType, apiModule: string, apiMethod: string): string {
-        let description:string = this.getMethodMeta(apiType, apiModule, apiMethod).documentation.join(' ');
-        return description || 'No description available.';
+  }
+
+  getMethodDescription(apiType: ApiType, apiModule: string, apiMethod: string): string {
+    const description: string = this.getMethodMeta(apiType, apiModule, apiMethod).documentation.join(' ')
+    return description || 'No description available.'
+  }
+
+  getQueryMethodParamsTypes(apiModule: string, apiMethod: string): string[] {
+    const method = this.getOriginalApi().query[apiModule][apiMethod]
+    const { type } = method.creator.meta
+    if (type.isDoubleMap) {
+      return [type.asDoubleMap.key1.toString(), type.asDoubleMap.key2.toString()]
     }
-
-    getQueryMethodParamsTypes(apiModule: string, apiMethod: string): string[] {
-        const method = this.getOriginalApi().query[apiModule][apiMethod];
-        const { type } = method.creator.meta;
-        if (type.isDoubleMap) {
-            return [ type.asDoubleMap.key1.toString(), type.asDoubleMap.key2.toString() ];
+    if (type.isMap) {
+      return type.asMap.linked.isTrue ? [`Option<${type.asMap.key.toString()}>`] : [type.asMap.key.toString()]
+    }
+    return []
+  }
+
+  getMethodReturnType(apiType: ApiType, apiModule: string, apiMethod: string): string {
+    if (apiType === 'query') {
+      const method = this.getOriginalApi().query[apiModule][apiMethod]
+      const {
+        meta: { type, modifier },
+      } = method.creator
+      if (type.isDoubleMap) {
+        return type.asDoubleMap.value.toString()
+      }
+      if (modifier.isOptional) {
+        return `Option<${type.toString()}>`
+      }
+    }
+    // Fallback for "query" and default for "consts"
+    return this.getMethodMeta(apiType, apiModule, apiMethod).type.toString()
+  }
+
+  // Validate the flags - throws an error if flags.type, flags.module or flags.method is invalid / does not exist in the api.
+  // Returns type, module and method which validity we can be sure about (notice they may still be "undefined" if weren't provided).
+  validateFlags(
+    api: ApiPromise,
+    flags: ApiInspectFlags
+  ): { apiType: ApiType | undefined; apiModule: string | undefined; apiMethod: string | undefined } {
+    let apiType: ApiType | undefined = undefined
+    const { module: apiModule, method: apiMethod } = flags
+
+    if (flags.type !== undefined) {
+      const availableTypes: readonly string[] = TYPES_AVAILABLE
+      if (!availableTypes.includes(flags.type)) {
+        throw new CLIError('Such type is not available', { exit: ExitCodes.InvalidInput })
+      }
+      apiType = flags.type as ApiType
+      if (apiModule !== undefined) {
+        if (!api[apiType][apiModule]) {
+          throw new CLIError('Such module was not found', { exit: ExitCodes.InvalidInput })
         }
-        if (type.isMap) {
-            return type.asMap.linked.isTrue ? [ `Option<${type.asMap.key.toString()}>` ] : [ type.asMap.key.toString() ];
+        if (apiMethod !== undefined && !api[apiType][apiModule][apiMethod]) {
+          throw new CLIError('Such method was not found', { exit: ExitCodes.InvalidInput })
         }
-        return [];
+      }
     }
 
-    getMethodReturnType(apiType: ApiType, apiModule: string, apiMethod: string): string {
-        if (apiType === 'query') {
-            const method = this.getOriginalApi().query[apiModule][apiMethod];
-            const { meta: { type, modifier } } = method.creator;
-            if (type.isDoubleMap) {
-                return type.asDoubleMap.value.toString();
-            }
-            if (modifier.isOptional) {
-                return `Option<${type.toString()}>`;
-            }
-        }
-        // Fallback for "query" and default for "consts"
-        return this.getMethodMeta(apiType, apiModule, apiMethod).type.toString();
+    return { apiType, apiModule, apiMethod }
+  }
+
+  // Request values for params using array of param types (strings)
+  async requestParamsValues(paramTypes: string[]): Promise<ApiMethodArg[]> {
+    const result: ApiMethodArg[] = []
+    for (const [key, paramType] of Object.entries(paramTypes)) {
+      this.log(chalk.bold.white(`Parameter no. ${parseInt(key) + 1} (${paramType}):`))
+      const paramValue = await this.promptForParam(paramType)
+      if (paramValue instanceof Option && paramValue.isSome) {
+        result.push(paramValue.unwrap())
+      } else if (!(paramValue instanceof Option)) {
+        result.push(paramValue)
+      }
+      // In case of empty option we MUST NOT add anything to the array (otherwise it causes some error)
     }
 
-    // Validate the flags - throws an error if flags.type, flags.module or flags.method is invalid / does not exist in the api.
-    // Returns type, module and method which validity we can be sure about (notice they may still be "undefined" if weren't provided).
-    validateFlags(api: ApiPromise, flags: ApiInspectFlags): { apiType: ApiType | undefined, apiModule: string | undefined, apiMethod: string | undefined } {
-        let apiType: ApiType | undefined = undefined;
-        const { module: apiModule, method: apiMethod } = flags;
-
-        if (flags.type !== undefined) {
-            const availableTypes: readonly string[] = TYPES_AVAILABLE;
-            if (!availableTypes.includes(flags.type)) {
-                throw new CLIError('Such type is not available', { exit: ExitCodes.InvalidInput });
-            }
-            apiType = <ApiType> flags.type;
-            if (apiModule !== undefined) {
-                if (!api[apiType][apiModule]) {
-                    throw new CLIError('Such module was not found', { exit: ExitCodes.InvalidInput });
-                }
-                if (apiMethod !== undefined && !api[apiType][apiModule][apiMethod]) {
-                    throw new CLIError('Such method was not found', { exit: ExitCodes.InvalidInput });
-                }
-            }
+    return result
+  }
+
+  async run() {
+    const api: ApiPromise = this.getOriginalApi()
+    const flags: ApiInspectFlags = this.parse(ApiInspect).flags as ApiInspectFlags
+    const availableTypes: readonly string[] = TYPES_AVAILABLE
+    const { apiType, apiModule, apiMethod } = this.validateFlags(api, flags)
+
+    // Executing a call
+    if (apiType && apiModule && apiMethod && flags.exec) {
+      let result: Codec
+
+      if (apiType === 'query') {
+        // Api query - call with (or without) arguments
+        let args: (string | ApiMethodArg)[] = flags.callArgs ? flags.callArgs.split(',') : []
+        const paramsTypes: string[] = this.getQueryMethodParamsTypes(apiModule, apiMethod)
+        if (args.length < paramsTypes.length) {
+          this.warn('Some parameters are missing! Please, provide the missing parameters:')
+          const missingParamsValues = await this.requestParamsValues(paramsTypes.slice(args.length))
+          args = args.concat(missingParamsValues)
         }
+        result = await api.query[apiModule][apiMethod](...args)
+      } else {
+        // Api consts - just assign the value
+        result = api.consts[apiModule][apiMethod]
+      }
 
-        return { apiType, apiModule, apiMethod };
+      this.log(chalk.green(result.toString()))
     }
-
-    // Request values for params using array of param types (strings)
-    async requestParamsValues(paramTypes: string[]): Promise<ApiMethodInputArg[]> {
-        let result: ApiMethodInputArg[] = [];
-        for (let [key, paramType] of Object.entries(paramTypes)) {
-            this.log(chalk.bold.white(`Parameter no. ${ parseInt(key)+1 } (${ paramType }):`));
-            let paramValue = await this.promptForParam(paramType);
-            if (paramValue instanceof Option && paramValue.isSome) {
-                result.push(paramValue.unwrap());
-            }
-            else if (!(paramValue instanceof Option)) {
-                result.push(paramValue);
-            }
-            // In case of empty option we MUST NOT add anything to the array (otherwise it causes some error)
-        }
-
-        return result;
+    // Describing a method
+    else if (apiType && apiModule && apiMethod) {
+      this.log(chalk.bold.white(`${apiType}.${apiModule}.${apiMethod}`))
+      const description: string = this.getMethodDescription(apiType, apiModule, apiMethod)
+      this.log(`\n${description}\n`)
+      const typesRows: NameValueObj[] = []
+      if (apiType === 'query') {
+        typesRows.push({
+          name: 'Params:',
+          value: this.getQueryMethodParamsTypes(apiModule, apiMethod).join(', ') || '-',
+        })
+      }
+      typesRows.push({ name: 'Returns:', value: this.getMethodReturnType(apiType, apiModule, apiMethod) })
+      displayNameValueTable(typesRows)
     }
-
-    async run() {
-        const api: ApiPromise = this.getOriginalApi();
-        const flags: ApiInspectFlags = <ApiInspectFlags> this.parse(ApiInspect).flags;
-        const availableTypes: readonly string[] = TYPES_AVAILABLE;
-        const { apiType, apiModule, apiMethod } = this.validateFlags(api, flags);
-
-        // Executing a call
-        if (apiType && apiModule && apiMethod && flags.exec) {
-            let result: Codec;
-
-            if (apiType === 'query') {
-                // Api query - call with (or without) arguments
-                let args: (string | ApiMethodInputArg)[] = flags.callArgs ? flags.callArgs.split(',') : [];
-                const paramsTypes: string[] = this.getQueryMethodParamsTypes(apiModule, apiMethod);
-                if (args.length < paramsTypes.length) {
-                    this.warn('Some parameters are missing! Please, provide the missing parameters:');
-                    let missingParamsValues = await this.requestParamsValues(paramsTypes.slice(args.length));
-                    args = args.concat(missingParamsValues);
-                }
-                result = await api.query[apiModule][apiMethod](...args);
-            }
-            else {
-                // Api consts - just assign the value
-                result = api.consts[apiModule][apiMethod];
-            }
-
-            this.log(chalk.green(result.toString()));
-        }
-        // Describing a method
-        else if (apiType && apiModule && apiMethod) {
-            this.log(chalk.bold.white(`${ apiType }.${ apiModule }.${ apiMethod }`));
-            const description: string = this.getMethodDescription(apiType, apiModule, apiMethod);
-            this.log(`\n${ description }\n`);
-            let typesRows: NameValueObj[] = [];
-            if (apiType === 'query') {
-                typesRows.push({ name: 'Params:', value: this.getQueryMethodParamsTypes(apiModule, apiMethod).join(', ') || '-' });
-            }
-            typesRows.push({ name: 'Returns:', value: this.getMethodReturnType(apiType, apiModule, apiMethod) });
-            displayNameValueTable(typesRows);
-        }
-        // Displaying all available methods
-        else if (apiType && apiModule) {
-            const module = api[apiType][apiModule];
-            const rows: NameValueObj[] = Object.keys(module).map((key: string) => {
-                return { name: key, value: this.getMethodDescription(apiType, apiModule, key) };
-            });
-            displayNameValueTable(rows);
-        }
-        // Displaying all available modules
-        else if (apiType) {
-            this.log(chalk.bold.white('Available modules:'));
-            this.log(Object.keys(api[apiType]).map(key => chalk.white(key)).join('\n'));
-        }
-        // Displaying all available types
-        else {
-            this.log(chalk.bold.white('Available types:'));
-            this.log(availableTypes.map(type => chalk.white(type)).join('\n'));
-        }
+    // Displaying all available methods
+    else if (apiType && apiModule) {
+      const module = api[apiType][apiModule]
+      const rows: NameValueObj[] = Object.keys(module).map((key: string) => {
+        return { name: key, value: this.getMethodDescription(apiType, apiModule, key) }
+      })
+      displayNameValueTable(rows)
+    }
+    // Displaying all available modules
+    else if (apiType) {
+      this.log(chalk.bold.white('Available modules:'))
+      this.log(
+        Object.keys(api[apiType])
+          .map((key) => chalk.white(key))
+          .join('\n')
+      )
+    }
+    // Displaying all available types
+    else {
+      this.log(chalk.bold.white('Available types:'))
+      this.log(availableTypes.map((type) => chalk.white(type)).join('\n'))
     }
+  }
 }

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

@@ -1,28 +1,28 @@
-import StateAwareCommandBase from '../../base/StateAwareCommandBase';
-import chalk from 'chalk';
-import { WsProvider } from '@polkadot/api';
-import ExitCodes from '../../ExitCodes';
+import StateAwareCommandBase from '../../base/StateAwareCommandBase'
+import chalk from 'chalk'
+import { WsProvider } from '@polkadot/api'
+import ExitCodes from '../../ExitCodes'
 
-type ApiSetUriArgs = { uri: string };
+type ApiSetUriArgs = { uri: string }
 
 export default class ApiSetUri extends StateAwareCommandBase {
-    static description = 'Set api WS provider uri';
-    static args = [
-        {
-            name: 'uri',
-            required: true,
-            description: 'Uri of the node api WS provider'
-        }
-    ];
+  static description = 'Set api WS provider uri'
+  static args = [
+    {
+      name: 'uri',
+      required: true,
+      description: 'Uri of the node api WS provider',
+    },
+  ]
 
-    async run() {
-        const args: ApiSetUriArgs = <ApiSetUriArgs> this.parse(ApiSetUri).args;
-        try {
-            new WsProvider(args.uri);
-        } catch(e) {
-            this.error('The WS provider uri seems to be incorrect', { exit: ExitCodes.InvalidInput });
-        }
-        await this.setPreservedState({ apiUri: args.uri });
-        this.log(chalk.greenBright('Api uri successfuly changed! New uri: ') + chalk.white(args.uri))
+  async run() {
+    const args: ApiSetUriArgs = this.parse(ApiSetUri).args as ApiSetUriArgs
+    try {
+      new WsProvider(args.uri)
+    } catch (e) {
+      this.error('The WS provider uri seems to be incorrect', { exit: ExitCodes.InvalidInput })
     }
+    await this.setPreservedState({ apiUri: args.uri })
+    this.log(chalk.greenBright('Api uri successfuly changed! New uri: ') + chalk.white(args.uri))
   }
+}

+ 48 - 49
cli/src/commands/council/info.ts

@@ -1,57 +1,56 @@
-import { ElectionStage } from '@joystream/types/council';
-import { formatNumber, formatBalance } from '@polkadot/util';
-import { BlockNumber } from '@polkadot/types/interfaces';
-import { CouncilInfoObj, NameValueObj } from '../../Types';
-import { displayHeader, displayNameValueTable } from '../../helpers/display';
-import ApiCommandBase from '../../base/ApiCommandBase';
+import { ElectionStage } from '@joystream/types/council'
+import { formatNumber, formatBalance } from '@polkadot/util'
+import { BlockNumber } from '@polkadot/types/interfaces'
+import { CouncilInfoObj, NameValueObj } from '../../Types'
+import { displayHeader, displayNameValueTable } from '../../helpers/display'
+import ApiCommandBase from '../../base/ApiCommandBase'
 
 export default class CouncilInfo extends ApiCommandBase {
-    static description = 'Get current council and council elections information';
+  static description = 'Get current council and council elections information'
 
-    displayInfo(infoObj: CouncilInfoObj) {
-        const { activeCouncil = [], round, stage } = infoObj;
+  displayInfo(infoObj: CouncilInfoObj) {
+    const { activeCouncil = [], round, stage } = infoObj
 
-        displayHeader('Council');
-        const councilRows: NameValueObj[] = [
-            { name: 'Elected:', value: activeCouncil.length ? 'YES' : 'NO' },
-            { name: 'Members:', value: activeCouncil.length.toString() },
-            { name: 'Term ends at block:', value: `#${formatNumber(infoObj.termEndsAt) }` },
-        ];
-        displayNameValueTable(councilRows);
+    displayHeader('Council')
+    const councilRows: NameValueObj[] = [
+      { name: 'Elected:', value: activeCouncil.length ? 'YES' : 'NO' },
+      { name: 'Members:', value: activeCouncil.length.toString() },
+      { name: 'Term ends at block:', value: `#${formatNumber(infoObj.termEndsAt)}` },
+    ]
+    displayNameValueTable(councilRows)
 
-
-        displayHeader('Election');
-        let electionTableRows: NameValueObj[] = [
-            { name: 'Running:', value: stage && stage.isSome ? 'YES' : 'NO' },
-            { name: 'Election round:', value: formatNumber(round) }
-        ];
-        if (stage && stage.isSome) {
-            const stageValue = <ElectionStage> stage.value;
-            const stageName: string = stageValue.type;
-            const stageEndsAt = <BlockNumber> stageValue.value;
-            electionTableRows.push({ name: 'Stage:', value: stageName });
-            electionTableRows.push({ name: 'Stage ends at block:', value: `#${stageEndsAt}` });
-        }
-        displayNameValueTable(electionTableRows);
-
-        displayHeader('Configuration');
-        const isAutoStart = (infoObj.autoStart || false).valueOf();
-        const configTableRows: NameValueObj[] = [
-            { name: 'Auto-start elections:', value: isAutoStart ? 'YES' : 'NO' },
-            { name: 'New term duration:', value: formatNumber(infoObj.newTermDuration) },
-            { name: 'Candidacy limit:', value: formatNumber(infoObj.candidacyLimit) },
-            { name: 'Council size:', value: formatNumber(infoObj.councilSize) },
-            { name: 'Min. council stake:', value: formatBalance(infoObj.minCouncilStake) },
-            { name: 'Min. voting stake:', value: formatBalance(infoObj.minVotingStake) },
-            { name: 'Announcing period:', value: `${ formatNumber(infoObj.announcingPeriod) } blocks` },
-            { name: 'Voting period:', value: `${ formatNumber(infoObj.votingPeriod) } blocks` },
-            { name: 'Revealing period:', value: `${ formatNumber(infoObj.revealingPeriod) } blocks` }
-        ];
-        displayNameValueTable(configTableRows);
+    displayHeader('Election')
+    const electionTableRows: NameValueObj[] = [
+      { name: 'Running:', value: stage && stage.isSome ? 'YES' : 'NO' },
+      { name: 'Election round:', value: formatNumber(round) },
+    ]
+    if (stage && stage.isSome) {
+      const stageValue = stage.value as ElectionStage
+      const stageName: string = stageValue.type
+      const stageEndsAt = stageValue.value as BlockNumber
+      electionTableRows.push({ name: 'Stage:', value: stageName })
+      electionTableRows.push({ name: 'Stage ends at block:', value: `#${stageEndsAt}` })
     }
+    displayNameValueTable(electionTableRows)
 
-    async run() {
-        const infoObj = await this.getApi().getCouncilInfo();
-        this.displayInfo(infoObj);
-    }
+    displayHeader('Configuration')
+    const isAutoStart = (infoObj.autoStart || false).valueOf()
+    const configTableRows: NameValueObj[] = [
+      { name: 'Auto-start elections:', value: isAutoStart ? 'YES' : 'NO' },
+      { name: 'New term duration:', value: formatNumber(infoObj.newTermDuration) },
+      { name: 'Candidacy limit:', value: formatNumber(infoObj.candidacyLimit) },
+      { name: 'Council size:', value: formatNumber(infoObj.councilSize) },
+      { name: 'Min. council stake:', value: formatBalance(infoObj.minCouncilStake) },
+      { name: 'Min. voting stake:', value: formatBalance(infoObj.minVotingStake) },
+      { name: 'Announcing period:', value: `${formatNumber(infoObj.announcingPeriod)} blocks` },
+      { name: 'Voting period:', value: `${formatNumber(infoObj.votingPeriod)} blocks` },
+      { name: 'Revealing period:', value: `${formatNumber(infoObj.revealingPeriod)} blocks` },
+    ]
+    displayNameValueTable(configTableRows)
+  }
+
+  async run() {
+    const infoObj = await this.getApi().getCouncilInfo()
+    this.displayInfo(infoObj)
   }
+}

+ 31 - 32
cli/src/commands/working-groups/application.ts

@@ -1,40 +1,39 @@
-import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import { displayCollapsedRow, displayHeader } from '../../helpers/display';
-import _ from 'lodash';
-import chalk from 'chalk';
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+import chalk from 'chalk'
 
 export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
-    static description = 'Shows an overview of given application by Working Group Application ID';
-    static args = [
-        {
-            name: 'wgApplicationId',
-            required: true,
-            description: 'Working Group Application ID'
-        },
-    ]
-    static flags = {
-        ...WorkingGroupsCommandBase.flags,
-    };
+  static description = 'Shows an overview of given application by Working Group Application ID'
+  static args = [
+    {
+      name: 'wgApplicationId',
+      required: true,
+      description: 'Working Group Application ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
 
-    async run() {
-        const { args } = this.parse(WorkingGroupsApplication);
+  async run() {
+    const { args } = this.parse(WorkingGroupsApplication)
 
-        const application = await this.getApi().groupApplication(this.group, parseInt(args.wgApplicationId));
+    const application = await this.getApi().groupApplication(this.group, parseInt(args.wgApplicationId))
 
-        displayHeader('Human readable text');
-        this.jsonPrettyPrint(application.humanReadableText);
+    displayHeader('Human readable text')
+    this.jsonPrettyPrint(application.humanReadableText)
 
-        displayHeader(`Details`);
-        const applicationRow = {
-            'WG application ID': application.wgApplicationId,
-            'Application ID': application.applicationId,
-            'Member handle': application.member?.handle.toString() || chalk.red('NONE'),
-            'Role account': application.roleAccout.toString(),
-            'Stage': application.stage,
-            'Application stake': application.stakes.application,
-            'Role stake': application.stakes.role,
-            'Total stake': Object.values(application.stakes).reduce((a, b) => a + b)
-        };
-        displayCollapsedRow(applicationRow);
+    displayHeader(`Details`)
+    const applicationRow = {
+      'WG application ID': application.wgApplicationId,
+      'Application ID': application.applicationId,
+      'Member handle': application.member?.handle.toString() || chalk.red('NONE'),
+      'Role account': application.roleAccout.toString(),
+      Stage: application.stage,
+      'Application stake': application.stakes.application,
+      'Role stake': application.stakes.role,
+      'Total stake': Object.values(application.stakes).reduce((a, b) => a + b),
     }
+    displayCollapsedRow(applicationRow)
+  }
 }

+ 77 - 84
cli/src/commands/working-groups/createOpening.ts

@@ -1,96 +1,89 @@
-import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import { HRTStruct } from '../../Types';
-import chalk from 'chalk';
-import { flags } from '@oclif/command';
-import { ApiMethodInputArg } from '../../base/ApiCommandBase';
-import { schemaValidator } from '@joystream/types/hiring';
-import { apiModuleByGroup } from '../../Api';
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { ApiMethodArg, ApiMethodNamedArgs } from '../../Types'
+import chalk from 'chalk'
+import { flags } from '@oclif/command'
+import { apiModuleByGroup } from '../../Api'
+import WorkerOpeningOptions from '../../promptOptions/addWorkerOpening'
+import { setDefaults } from '../../helpers/promptOptions'
 
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
-    static description = 'Create working group opening (requires lead access)';
-    static flags = {
-        ...WorkingGroupsCommandBase.flags,
-        useDraft: flags.boolean({
-            char: 'd',
-            description:
-                "Whether to create the opening from existing draft.\n"+
-                "If provided without --draftName - the list of choices will be displayed."
-        }),
-        draftName: flags.string({
-            char: 'n',
-            description:
-                'Name of the draft to create the opening from.',
-            dependsOn: ['useDraft']
-        }),
-        createDraftOnly: flags.boolean({
-            char: 'c',
-            description:
-                'If provided - the extrinsic will not be executed. Use this flag if you only want to create a draft.'
-        }),
-        skipPrompts: flags.boolean({
-            char: 's',
-            description:
-                "Whether to skip all prompts when adding from draft (will use all default values)",
-            dependsOn: ['useDraft'],
-            exclusive: ['createDraftOnly']
-        })
-    };
-
-    async run() {
-        const account = await this.getRequiredSelectedAccount();
-        // lead-only gate
-        await this.getRequiredLead();
-
-        const { flags } = this.parse(WorkingGroupsCreateOpening);
+  static description = 'Create working group opening (requires lead access)'
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+    useDraft: flags.boolean({
+      char: 'd',
+      description:
+        'Whether to create the opening from existing draft.\n' +
+        'If provided without --draftName - the list of choices will be displayed.',
+    }),
+    draftName: flags.string({
+      char: 'n',
+      description: 'Name of the draft to create the opening from.',
+      dependsOn: ['useDraft'],
+    }),
+    createDraftOnly: flags.boolean({
+      char: 'c',
+      description:
+        'If provided - the extrinsic will not be executed. Use this flag if you only want to create a draft.',
+    }),
+    skipPrompts: flags.boolean({
+      char: 's',
+      description: 'Whether to skip all prompts when adding from draft (will use all default values)',
+      dependsOn: ['useDraft'],
+      exclusive: ['createDraftOnly'],
+    }),
+  }
 
-        let defaultValues: ApiMethodInputArg[] | undefined = undefined;
-        if (flags.useDraft) {
-            const draftName = flags.draftName || await this.promptForOpeningDraft();
-            defaultValues =  await this.loadOpeningDraftParams(draftName);
-        }
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    // lead-only gate
+    await this.getRequiredLead()
 
-        if (!flags.skipPrompts) {
-            const module = apiModuleByGroup[this.group];
-            const method = 'addOpening';
-            const jsonArgsMapping = { 'human_readable_text': { struct: HRTStruct, schemaValidator } };
+    const { flags } = this.parse(WorkingGroupsCreateOpening)
 
-            let saveDraft = false, params: ApiMethodInputArg[];
-            if (flags.createDraftOnly) {
-                params = await this.promptForExtrinsicParams(module, method, jsonArgsMapping, defaultValues);
-                saveDraft = true;
-            }
-            else {
-                await this.requestAccountDecoding(account); // Prompt for password
+    const promptOptions = new WorkerOpeningOptions()
+    let defaultValues: ApiMethodNamedArgs | undefined
+    if (flags.useDraft) {
+      const draftName = flags.draftName || (await this.promptForOpeningDraft())
+      defaultValues = await this.loadOpeningDraftParams(draftName)
+      setDefaults(promptOptions, defaultValues)
+    }
 
-                params = await this.buildAndSendExtrinsic(
-                    account,
-                    module,
-                    method,
-                    jsonArgsMapping,
-                    defaultValues,
-                    true
-                );
+    if (!flags.skipPrompts) {
+      const module = apiModuleByGroup[this.group]
+      const method = 'addOpening'
 
-                this.log(chalk.green('Opening succesfully created!'));
+      let saveDraft = false,
+        params: ApiMethodArg[]
+      if (flags.createDraftOnly) {
+        params = await this.promptForExtrinsicParams(module, method, promptOptions)
+        saveDraft = true
+      } else {
+        await this.requestAccountDecoding(account) // Prompt for password
+        params = await this.buildAndSendExtrinsic(account, module, method, promptOptions, true)
 
-                saveDraft = await this.simplePrompt({
-                    message: 'Do you wish to save this opening as draft?',
-                    type: 'confirm'
-                });
-            }
+        saveDraft = await this.simplePrompt({
+          message: 'Do you wish to save this opening as draft?',
+          type: 'confirm',
+        })
+      }
 
-            if (saveDraft) {
-                const draftName = await this.promptForNewOpeningDraftName();
-                this.saveOpeningDraft(draftName, params);
+      if (saveDraft) {
+        const draftName = await this.promptForNewOpeningDraftName()
+        this.saveOpeningDraft(draftName, params)
 
-                this.log(chalk.green(`Opening draft ${ chalk.white(draftName) } succesfully saved!`));
-            }
-        }
-        else {
-            await this.requestAccountDecoding(account); // Prompt for password
-            this.log(chalk.white('Sending the extrinsic...'));
-            await this.sendExtrinsic(account, apiModuleByGroup[this.group], 'addOpening', defaultValues!);
-            this.log(chalk.green('Opening succesfully created!'));
-        }
+        this.log(chalk.green(`Opening draft ${chalk.white(draftName)} succesfully saved!`))
+      }
+    } else {
+      await this.requestAccountDecoding(account) // Prompt for password
+      this.log(chalk.white('Sending the extrinsic...'))
+      await this.sendExtrinsic(
+        account,
+        apiModuleByGroup[this.group],
+        'addOpening',
+        defaultValues!.map((v) => v.value)
+      )
+      this.log(chalk.green('Opening succesfully created!'))
     }
+  }
 }

+ 56 - 0
cli/src/commands/working-groups/decreaseWorkerStake.ts

@@ -0,0 +1,56 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import { WorkerId } from '@joystream/types/working-group'
+import { Balance } from '@polkadot/types/interfaces'
+import { formatBalance } from '@polkadot/util'
+import { minMaxInt } from '../../validators/common'
+import chalk from 'chalk'
+import { createParamOptions } from '../../helpers/promptOptions'
+
+export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsCommandBase {
+  static description =
+    'Decreases given worker stake by an amount that will be returned to the worker role account. ' +
+    'Requires lead access.'
+  static args = [
+    {
+      name: 'workerId',
+      required: true,
+      description: 'Worker ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsDecreaseWorkerStake)
+
+    const account = await this.getRequiredSelectedAccount()
+    // Lead-only gate
+    await this.getRequiredLead()
+
+    const workerId = parseInt(args.workerId)
+    const groupMember = await this.getWorkerWithStakeForLeadAction(workerId)
+
+    this.log(chalk.white('Current worker stake: ', formatBalance(groupMember.stake)))
+    const balanceValidator = minMaxInt(1, groupMember.stake.toNumber())
+    const balance = (await this.promptForParam(
+      'Balance',
+      createParamOptions('amount', undefined, balanceValidator)
+    )) as Balance
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'decreaseStake', [
+      new WorkerId(workerId),
+      balance,
+    ])
+
+    this.log(
+      chalk.green(
+        `${chalk.white(formatBalance(balance))} from worker ${chalk.white(workerId)} stake ` +
+          `has been returned to worker's role account (${chalk.white(groupMember.roleAccount.toString())})!`
+      )
+    )
+  }
+}

+ 56 - 0
cli/src/commands/working-groups/evictWorker.ts

@@ -0,0 +1,56 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import { WorkerId } from '@joystream/types/working-group'
+import { bool } from '@polkadot/types/primitive'
+import { formatBalance } from '@polkadot/util'
+import chalk from 'chalk'
+import { createParamOptions } from '../../helpers/promptOptions'
+
+export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
+  static description = 'Evicts given worker. Requires lead access.'
+  static args = [
+    {
+      name: 'workerId',
+      required: true,
+      description: 'Worker ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsEvictWorker)
+
+    const account = await this.getRequiredSelectedAccount()
+    // Lead-only gate
+    await this.getRequiredLead()
+
+    const workerId = parseInt(args.workerId)
+    // This will also make sure the worker is valid
+    const groupMember = await this.getWorkerForLeadAction(workerId)
+
+    // TODO: Terminate worker text limits? (minMaxStr)
+    const rationale = await this.promptForParam('Bytes', createParamOptions('rationale'))
+    const shouldSlash = groupMember.stake
+      ? await this.simplePrompt({
+          message: `Should the worker stake (${formatBalance(groupMember.stake)}) be slashed?`,
+          type: 'confirm',
+          default: false,
+        })
+      : false
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'terminateRole', [
+      new WorkerId(workerId),
+      rationale,
+      new bool(shouldSlash),
+    ])
+
+    this.log(chalk.green(`Worker ${chalk.white(workerId)} has been evicted!`))
+    if (shouldSlash) {
+      this.log(chalk.green(`Worker stake totalling ${chalk.white(formatBalance(groupMember.stake))} has been slashed!`))
+    }
+  }
+}

+ 49 - 55
cli/src/commands/working-groups/fillOpening.ts

@@ -1,58 +1,52 @@
-import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import _ from 'lodash';
-import { OpeningStatus } from '../../Types';
-import ExitCodes from '../../ExitCodes';
-import { apiModuleByGroup } from '../../Api';
-import { OpeningId } from '@joystream/types/hiring';
-import { ApplicationIdSet, RewardPolicy } from '@joystream/types/working-group';
-import chalk from 'chalk';
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { OpeningStatus } from '../../Types'
+import { apiModuleByGroup } from '../../Api'
+import { OpeningId } from '@joystream/types/hiring'
+import { ApplicationIdSet, RewardPolicy } from '@joystream/types/working-group'
+import chalk from 'chalk'
+import { createParamOptions } from '../../helpers/promptOptions'
 
 export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
-    static description = 'Allows filling working group opening that\'s currently in review. Requires lead access.';
-    static args = [
-        {
-            name: 'wgOpeningId',
-            required: true,
-            description: 'Working Group Opening ID'
-        },
-    ]
-    static flags = {
-        ...WorkingGroupsCommandBase.flags,
-    };
-
-    async run() {
-        const { args } = this.parse(WorkingGroupsFillOpening);
-
-        const account = await this.getRequiredSelectedAccount();
-        // Lead-only gate
-        await this.getRequiredLead();
-
-        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
-
-        if (opening.stage.status !== OpeningStatus.InReview) {
-            this.error('This opening is not in the Review stage!', { exit: ExitCodes.InvalidInput });
-        }
-
-        const applicationIds = await this.promptForApplicationsToAccept(opening);
-        const rewardPolicyOpt = await this.promptForParam(`Option<${RewardPolicy.name}>`, 'RewardPolicy');
-
-        await this.requestAccountDecoding(account);
-
-        await this.sendAndFollowExtrinsic(
-            account,
-            apiModuleByGroup[this.group],
-            'fillOpening',
-            [
-                new OpeningId(opening.wgOpeningId),
-                new ApplicationIdSet(applicationIds),
-                rewardPolicyOpt
-            ]
-        );
-
-        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} succesfully filled!`));
-        this.log(
-            chalk.green('Accepted working group application IDs: ') +
-            chalk.white(applicationIds.length ? applicationIds.join(chalk.green(', ')) : 'NONE')
-        );
-    }
+  static description = "Allows filling working group opening that's currently in review. Requires lead access."
+  static args = [
+    {
+      name: 'wgOpeningId',
+      required: true,
+      description: 'Working Group Opening ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsFillOpening)
+
+    const account = await this.getRequiredSelectedAccount()
+    // Lead-only gate
+    await this.getRequiredLead()
+
+    const openingId = parseInt(args.wgOpeningId)
+    const opening = await this.getOpeningForLeadAction(openingId, OpeningStatus.InReview)
+
+    const applicationIds = await this.promptForApplicationsToAccept(opening)
+    const rewardPolicyOpt = await this.promptForParam(
+      `Option<${RewardPolicy.name}>`,
+      createParamOptions('RewardPolicy')
+    )
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'fillOpening', [
+      new OpeningId(openingId),
+      new ApplicationIdSet(applicationIds),
+      rewardPolicyOpt,
+    ])
+
+    this.log(chalk.green(`Opening ${chalk.white(openingId)} succesfully filled!`))
+    this.log(
+      chalk.green('Accepted working group application IDs: ') +
+        chalk.white(applicationIds.length ? applicationIds.join(chalk.green(', ')) : 'NONE')
+    )
+  }
 }

+ 46 - 0
cli/src/commands/working-groups/increaseStake.ts

@@ -0,0 +1,46 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import { Balance } from '@polkadot/types/interfaces'
+import { formatBalance } from '@polkadot/util'
+import { positiveInt } from '../../validators/common'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+import { createParamOptions } from '../../helpers/promptOptions'
+
+export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase {
+  static description = 'Increases current role (lead/worker) stake. Requires active role account to be selected.'
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    // Worker-only gate
+    const worker = await this.getRequiredWorker()
+
+    if (!worker.stake) {
+      this.error('Cannot increase stake. No associated role stake profile found!', { exit: ExitCodes.InvalidInput })
+    }
+
+    this.log(chalk.white('Current stake: ', formatBalance(worker.stake)))
+    const balance = (await this.promptForParam(
+      'Balance',
+      createParamOptions('amount', undefined, positiveInt())
+    )) as Balance
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'increaseStake', [
+      worker.workerId,
+      balance,
+    ])
+
+    this.log(
+      chalk.green(
+        `Worker ${chalk.white(worker.workerId.toNumber())} stake has been increased by ${chalk.white(
+          formatBalance(balance)
+        )}`
+      )
+    )
+  }
+}

+ 28 - 0
cli/src/commands/working-groups/leaveRole.ts

@@ -0,0 +1,28 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import { minMaxStr } from '../../validators/common'
+import chalk from 'chalk'
+import { createParamOptions } from '../../helpers/promptOptions'
+
+export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
+  static description = 'Leave the worker or lead role associated with currently selected account.'
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    // Worker-only gate
+    const worker = await this.getRequiredWorker()
+
+    const constraint = await this.getApi().workerExitRationaleConstraint(this.group)
+    const rationaleValidator = minMaxStr(constraint.min.toNumber(), constraint.max.toNumber())
+    const rationale = await this.promptForParam('Bytes', createParamOptions('rationale', undefined, rationaleValidator))
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'leaveRole', [worker.workerId, rationale])
+
+    this.log(chalk.green(`Succesfully left the role! (worker id: ${chalk.white(worker.workerId.toNumber())})`))
+  }
+}

+ 67 - 65
cli/src/commands/working-groups/opening.ts

@@ -1,78 +1,80 @@
-import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import { displayTable, displayCollapsedRow, displayHeader } from '../../helpers/display';
-import _ from 'lodash';
-import { OpeningStatus, GroupOpeningStage, GroupOpeningStakes } from '../../Types';
-import { StakingAmountLimitModeKeys, StakingPolicy } from '@joystream/types/hiring';
-import { formatBalance } from '@polkadot/util';
-import chalk from 'chalk';
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { displayTable, displayCollapsedRow, displayHeader } from '../../helpers/display'
+import _ from 'lodash'
+import { OpeningStatus, GroupOpeningStage, GroupOpeningStakes } from '../../Types'
+import { StakingAmountLimitModeKeys, StakingPolicy } from '@joystream/types/hiring'
+import { formatBalance } from '@polkadot/util'
+import chalk from 'chalk'
 
 export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
-    static description = 'Shows an overview of given working group opening by Working Group Opening ID';
-    static args = [
-        {
-            name: 'wgOpeningId',
-            required: true,
-            description: 'Working Group Opening ID'
-        },
-    ]
-    static flags = {
-        ...WorkingGroupsCommandBase.flags,
-    };
+  static description = 'Shows an overview of given working group opening by Working Group Opening ID'
+  static args = [
+    {
+      name: 'wgOpeningId',
+      required: true,
+      description: 'Working Group Opening ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
 
-    stageColumns(stage: GroupOpeningStage) {
-        const { status, date, block } = stage;
-        const statusTimeHeader = status === OpeningStatus.WaitingToBegin ? 'Starts at' : 'Last status change';
-        return {
-            'Stage': _.startCase(status),
-            [statusTimeHeader]: (date && block)
-                ? `~ ${date.toLocaleTimeString()} ${ date.toLocaleDateString()} (#${block})`
-                : (block && `#${block}` || '?')
-        };
+  stageColumns(stage: GroupOpeningStage) {
+    const { status, date, block } = stage
+    const statusTimeHeader = status === OpeningStatus.WaitingToBegin ? 'Starts at' : 'Last status change'
+    return {
+      Stage: _.startCase(status),
+      [statusTimeHeader]:
+        date && block
+          ? `~ ${date.toLocaleTimeString()} ${date.toLocaleDateString()} (#${block})`
+          : (block && `#${block}`) || '?',
     }
+  }
 
-    formatStake(stake: StakingPolicy | undefined) {
-        if (!stake) return 'NONE';
-        const { amount, amount_mode } = stake;
-        return amount_mode.type === StakingAmountLimitModeKeys.AtLeast
-            ? `>= ${ formatBalance(amount) }`
-            : `== ${ formatBalance(amount) }`;
-    }
+  formatStake(stake: StakingPolicy | undefined) {
+    if (!stake) return 'NONE'
+    const { amount, amount_mode: amountMode } = stake
+    return amountMode.type === StakingAmountLimitModeKeys.AtLeast
+      ? `>= ${formatBalance(amount)}`
+      : `== ${formatBalance(amount)}`
+  }
 
-    stakeColumns(stakes: GroupOpeningStakes) {
-        const { role, application } = stakes;
-        return {
-            'Application stake': this.formatStake(application),
-            'Role stake': this.formatStake(role),
-        }
+  stakeColumns(stakes: GroupOpeningStakes) {
+    const { role, application } = stakes
+    return {
+      'Application stake': this.formatStake(application),
+      'Role stake': this.formatStake(role),
     }
+  }
 
-    async run() {
-        const { args } = this.parse(WorkingGroupsOpening);
-
-        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
+  async run() {
+    const { args } = this.parse(WorkingGroupsOpening)
 
-        displayHeader('Human readable text');
-        this.jsonPrettyPrint(opening.opening.human_readable_text.toString());
+    const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId))
 
-        displayHeader('Opening details');
-        const openingRow = {
-            'WG Opening ID': opening.wgOpeningId,
-            'Opening ID': opening.openingId,
-            ...this.stageColumns(opening.stage),
-            ...this.stakeColumns(opening.stakes)
-        };
-        displayCollapsedRow(openingRow);
+    displayHeader('Human readable text')
+    this.jsonPrettyPrint(opening.opening.human_readable_text.toString())
 
-        displayHeader(`Applications (${opening.applications.length})`);
-        const applicationsRows = opening.applications.map(a => ({
-            'WG appl. ID': a.wgApplicationId,
-            'Appl. ID': a.applicationId,
-            'Member': a.member?.handle.toString() || chalk.red('NONE'),
-            'Stage': a.stage,
-            'Appl. stake': a.stakes.application,
-            'Role stake': a.stakes.role,
-            'Total stake': Object.values(a.stakes).reduce((a, b) => a + b)
-        }));
-        displayTable(applicationsRows, 5);
+    displayHeader('Opening details')
+    const openingRow = {
+      'WG Opening ID': opening.wgOpeningId,
+      'Opening ID': opening.openingId,
+      Type: opening.type.type,
+      ...this.stageColumns(opening.stage),
+      ...this.stakeColumns(opening.stakes),
     }
+    displayCollapsedRow(openingRow)
+
+    displayHeader(`Applications (${opening.applications.length})`)
+    const applicationsRows = opening.applications.map((a) => ({
+      'WG appl. ID': a.wgApplicationId,
+      'Appl. ID': a.applicationId,
+      Member: a.member?.handle.toString() || chalk.red('NONE'),
+      Stage: a.stage,
+      'Appl. stake': a.stakes.application,
+      'Role stake': a.stakes.role,
+      'Total stake': Object.values(a.stakes).reduce((a, b) => a + b),
+    }))
+    displayTable(applicationsRows, 5)
   }
+}

+ 18 - 17
cli/src/commands/working-groups/openings.ts

@@ -1,22 +1,23 @@
-import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import { displayTable } from '../../helpers/display';
-import _ from 'lodash';
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { displayTable } from '../../helpers/display'
+import _ from 'lodash'
 
 export default class WorkingGroupsOpenings extends WorkingGroupsCommandBase {
-    static description = 'Shows an overview of given working group openings';
-    static flags = {
-        ...WorkingGroupsCommandBase.flags,
-    };
+  static description = 'Shows an overview of given working group openings'
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
 
-    async run() {
-        const openings = await this.getApi().openingsByGroup(this.group);
+  async run() {
+    const openings = await this.getApi().openingsByGroup(this.group)
 
-        const openingsRows = openings.map(o => ({
-            'WG Opening ID': o.wgOpeningId,
-            'Opening ID': o.openingId,
-            'Stage': `${_.startCase(o.stage.status)}${o.stage.block ? ` (#${o.stage.block})` : ''}`,
-            'Applications': o.applications.length
-        }));
-        displayTable(openingsRows, 5);
-    }
+    const openingsRows = openings.map((o) => ({
+      'WG Opening ID': o.wgOpeningId,
+      'Opening ID': o.openingId,
+      Type: o.type.type,
+      Stage: `${_.startCase(o.stage.status)}${o.stage.block ? ` (#${o.stage.block})` : ''}`,
+      Applications: o.applications.length,
+    }))
+    displayTable(openingsRows, 5)
+  }
 }

+ 34 - 32
cli/src/commands/working-groups/overview.ts

@@ -1,38 +1,40 @@
-import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import { displayHeader, displayNameValueTable, displayTable } from '../../helpers/display';
-import { formatBalance } from '@polkadot/util';
-import chalk from 'chalk';
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { displayHeader, displayNameValueTable, displayTable, shortAddress } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
 
-export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
-    static description = 'Shows an overview of given working group (current lead and workers)';
-    static flags = {
-        ...WorkingGroupsCommandBase.flags,
-    };
+import chalk from 'chalk'
 
-    async run() {
-        const lead = await this.getApi().groupLead(this.group);
-        const members = await this.getApi().groupMembers(this.group);
+export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
+  static description = 'Shows an overview of given working group (current lead and workers)'
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
 
-        displayHeader('Group lead');
-        if (lead) {
-            displayNameValueTable([
-                { name: 'Member id:', value: lead.memberId.toString() },
-                { name: 'Member handle:', value: lead.profile.handle.toString() },
-                { name: 'Role account:', value: lead.roleAccount.toString() },
-            ]);
-        }
-        else {
-            this.log(chalk.yellow('No lead assigned!'));
-        }
+  async run() {
+    const lead = await this.getApi().groupLead(this.group)
+    const members = await this.getApi().groupMembers(this.group)
 
-        displayHeader('Members');
-        const membersRows = members.map(m => ({
-            'Worker id': m.workerId.toString(),
-            'Member id': m.memberId.toString(),
-            'Member handle': m.profile.handle.toString(),
-            'Stake': formatBalance(m.stake),
-            'Earned': formatBalance(m.earned)
-        }));
-        displayTable(membersRows, 5);
+    displayHeader('Group lead')
+    if (lead) {
+      displayNameValueTable([
+        { name: 'Member id:', value: lead.memberId.toString() },
+        { name: 'Member handle:', value: lead.profile.handle.toString() },
+        { name: 'Role account:', value: lead.roleAccount.toString() },
+      ])
+    } else {
+      this.log(chalk.yellow('No lead assigned!'))
     }
+
+    displayHeader('Members')
+    const membersRows = members.map((m) => ({
+      '': lead?.workerId.eq(m.workerId) ? '\u{2B50}' : '', // A nice star for the lead
+      'Worker id': m.workerId.toString(),
+      'Member id': m.memberId.toString(),
+      'Member handle': m.profile.handle.toString(),
+      Stake: formatBalance(m.stake),
+      Earned: formatBalance(m.reward?.totalRecieved),
+      'Role account': shortAddress(m.roleAccount),
+    }))
+    displayTable(membersRows, 5)
   }
+}

+ 55 - 0
cli/src/commands/working-groups/slashWorker.ts

@@ -0,0 +1,55 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import { WorkerId } from '@joystream/types/working-group'
+import { Balance } from '@polkadot/types/interfaces'
+import { formatBalance } from '@polkadot/util'
+import { minMaxInt } from '../../validators/common'
+import chalk from 'chalk'
+import { createParamOptions } from '../../helpers/promptOptions'
+
+export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
+  static description = 'Slashes given worker stake. Requires lead access.'
+  static args = [
+    {
+      name: 'workerId',
+      required: true,
+      description: 'Worker ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsSlashWorker)
+
+    const account = await this.getRequiredSelectedAccount()
+    // Lead-only gate
+    await this.getRequiredLead()
+
+    const workerId = parseInt(args.workerId)
+    const groupMember = await this.getWorkerWithStakeForLeadAction(workerId)
+
+    this.log(chalk.white('Current worker stake: ', formatBalance(groupMember.stake)))
+    const balanceValidator = minMaxInt(1, groupMember.stake.toNumber())
+    const balance = (await this.promptForParam(
+      'Balance',
+      createParamOptions('amount', undefined, balanceValidator)
+    )) as Balance
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'slashStake', [
+      new WorkerId(workerId),
+      balance,
+    ])
+
+    this.log(
+      chalk.green(
+        `${chalk.white(formatBalance(balance))} from worker ${chalk.white(
+          workerId
+        )} stake has been succesfully slashed!`
+      )
+    )
+  }
+}

+ 37 - 43
cli/src/commands/working-groups/startAcceptingApplications.ts

@@ -1,46 +1,40 @@
-import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import _ from 'lodash';
-import { OpeningStatus } from '../../Types';
-import ExitCodes from '../../ExitCodes';
-import { apiModuleByGroup } from '../../Api';
-import { OpeningId } from '@joystream/types/hiring';
-import chalk from 'chalk';
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { OpeningStatus } from '../../Types'
+import { apiModuleByGroup } from '../../Api'
+import { OpeningId } from '@joystream/types/hiring'
+import chalk from 'chalk'
 
 export default class WorkingGroupsStartAcceptingApplications extends WorkingGroupsCommandBase {
-    static description = 'Changes the status of pending opening to "Accepting applications". Requires lead access.';
-    static args = [
-        {
-            name: 'wgOpeningId',
-            required: true,
-            description: 'Working Group Opening ID'
-        },
-    ]
-    static flags = {
-        ...WorkingGroupsCommandBase.flags,
-    };
-
-    async run() {
-        const { args } = this.parse(WorkingGroupsStartAcceptingApplications);
-
-        const account = await this.getRequiredSelectedAccount();
-        // Lead-only gate
-        await this.getRequiredLead();
-
-        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
-
-        if (opening.stage.status !== OpeningStatus.WaitingToBegin) {
-            this.error('This opening is not in "Waiting To Begin" stage!', { exit: ExitCodes.InvalidInput });
-        }
-
-        await this.requestAccountDecoding(account);
-
-        await this.sendAndFollowExtrinsic(
-            account,
-            apiModuleByGroup[this.group],
-            'acceptApplications',
-            [ new OpeningId(opening.wgOpeningId) ]
-        );
-
-        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} status changed to: ${ chalk.white('Accepting Applications') }`));
-    }
+  static description = 'Changes the status of pending opening to "Accepting applications". Requires lead access.'
+  static args = [
+    {
+      name: 'wgOpeningId',
+      required: true,
+      description: 'Working Group Opening ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsStartAcceptingApplications)
+
+    const account = await this.getRequiredSelectedAccount()
+    // Lead-only gate
+    await this.getRequiredLead()
+
+    const openingId = parseInt(args.wgOpeningId)
+    await this.validateOpeningForLeadAction(openingId, OpeningStatus.WaitingToBegin)
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'acceptApplications', [
+      new OpeningId(openingId),
+    ])
+
+    this.log(
+      chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${chalk.white('Accepting Applications')}`)
+    )
+  }
 }

+ 35 - 43
cli/src/commands/working-groups/startReviewPeriod.ts

@@ -1,46 +1,38 @@
-import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import _ from 'lodash';
-import { OpeningStatus } from '../../Types';
-import ExitCodes from '../../ExitCodes';
-import { apiModuleByGroup } from '../../Api';
-import { OpeningId } from '@joystream/types/hiring';
-import chalk from 'chalk';
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { OpeningStatus } from '../../Types'
+import { apiModuleByGroup } from '../../Api'
+import { OpeningId } from '@joystream/types/hiring'
+import chalk from 'chalk'
 
 export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommandBase {
-    static description = 'Changes the status of active opening to "In review". Requires lead access.';
-    static args = [
-        {
-            name: 'wgOpeningId',
-            required: true,
-            description: 'Working Group Opening ID'
-        },
-    ]
-    static flags = {
-        ...WorkingGroupsCommandBase.flags,
-    };
-
-    async run() {
-        const { args } = this.parse(WorkingGroupsStartReviewPeriod);
-
-        const account = await this.getRequiredSelectedAccount();
-        // Lead-only gate
-        await this.getRequiredLead();
-
-        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
-
-        if (opening.stage.status !== OpeningStatus.AcceptingApplications) {
-            this.error('This opening is not in "Accepting Applications" stage!', { exit: ExitCodes.InvalidInput });
-        }
-
-        await this.requestAccountDecoding(account);
-
-        await this.sendAndFollowExtrinsic(
-            account,
-            apiModuleByGroup[this.group],
-            'beginApplicantReview',
-            [ new OpeningId(opening.wgOpeningId) ]
-        );
-
-        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} status changed to: ${ chalk.white('In Review') }`));
-    }
+  static description = 'Changes the status of active opening to "In review". Requires lead access.'
+  static args = [
+    {
+      name: 'wgOpeningId',
+      required: true,
+      description: 'Working Group Opening ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsStartReviewPeriod)
+
+    const account = await this.getRequiredSelectedAccount()
+    // Lead-only gate
+    await this.getRequiredLead()
+
+    const openingId = parseInt(args.wgOpeningId)
+    await this.validateOpeningForLeadAction(openingId, OpeningStatus.AcceptingApplications)
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'beginApplicantReview', [
+      new OpeningId(openingId),
+    ])
+
+    this.log(chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${chalk.white('In Review')}`))
+  }
 }

+ 35 - 42
cli/src/commands/working-groups/terminateApplication.ts

@@ -1,45 +1,38 @@
-import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import _ from 'lodash';
-import ExitCodes from '../../ExitCodes';
-import { apiModuleByGroup } from '../../Api';
-import { ApplicationStageKeys, ApplicationId } from '@joystream/types/hiring';
-import chalk from 'chalk';
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import { ApplicationStageKeys, ApplicationId } from '@joystream/types/hiring'
+import chalk from 'chalk'
 
 export default class WorkingGroupsTerminateApplication extends WorkingGroupsCommandBase {
-    static description = 'Terminates given working group application. Requires lead access.';
-    static args = [
-        {
-            name: 'wgApplicationId',
-            required: true,
-            description: 'Working Group Application ID'
-        },
-    ]
-    static flags = {
-        ...WorkingGroupsCommandBase.flags,
-    };
-
-    async run() {
-        const { args } = this.parse(WorkingGroupsTerminateApplication);
-
-        const account = await this.getRequiredSelectedAccount();
-        // Lead-only gate
-        await this.getRequiredLead();
-
-        const application = await this.getApi().groupApplication(this.group, parseInt(args.wgApplicationId));
-
-        if (application.stage !== ApplicationStageKeys.Active) {
-            this.error('This application is not active!', { exit: ExitCodes.InvalidInput });
-        }
-
-        await this.requestAccountDecoding(account);
-
-        await this.sendAndFollowExtrinsic(
-            account,
-            apiModuleByGroup[this.group],
-            'terminateApplication',
-            [new ApplicationId(application.wgApplicationId)]
-        );
-
-        this.log(chalk.green(`Application ${chalk.white(application.wgApplicationId)} has been succesfully terminated!`));
-    }
+  static description = 'Terminates given working group application. Requires lead access.'
+  static args = [
+    {
+      name: 'wgApplicationId',
+      required: true,
+      description: 'Working Group Application ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsTerminateApplication)
+
+    const account = await this.getRequiredSelectedAccount()
+    // Lead-only gate
+    await this.getRequiredLead()
+
+    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.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'terminateApplication', [
+      new ApplicationId(applicationId),
+    ])
+
+    this.log(chalk.green(`Application ${chalk.white(applicationId)} has been succesfully terminated!`))
+  }
 }

+ 48 - 0
cli/src/commands/working-groups/updateRewardAccount.ts

@@ -0,0 +1,48 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import { validateAddress } from '../../helpers/validation'
+import { GenericAccountId } from '@polkadot/types'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+
+export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsCommandBase {
+  static description = 'Updates the worker/lead reward account (requires current role account to be selected)'
+  static args = [
+    {
+      name: 'accountAddress',
+      required: false,
+      description: 'New reward account address (if omitted, one of the existing CLI accounts can be selected)',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsUpdateRewardAccount)
+
+    const account = await this.getRequiredSelectedAccount()
+    // Worker-only gate
+    const worker = await this.getRequiredWorker()
+
+    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
+    }
+    validateAddress(newRewardAccount)
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
+      worker.workerId,
+      new GenericAccountId(newRewardAccount),
+    ])
+
+    this.log(chalk.green(`Succesfully updated the reward account to: ${chalk.white(newRewardAccount)})`))
+  }
+}

+ 58 - 0
cli/src/commands/working-groups/updateRoleAccount.ts

@@ -0,0 +1,58 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import { validateAddress } from '../../helpers/validation'
+import { GenericAccountId } from '@polkadot/types'
+import chalk from 'chalk'
+
+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',
+      required: false,
+      description: 'New role account address (if omitted, one of the existing CLI accounts can be selected)',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsUpdateRoleAccount)
+
+    const account = await this.getRequiredSelectedAccount()
+    const worker = await this.getRequiredWorkerByMemberController()
+
+    const cliAccounts = await this.fetchAccounts()
+    let newRoleAccount: string = args.accountAddress
+    if (!newRoleAccount) {
+      newRoleAccount = (await this.promptForAccount(cliAccounts, undefined, 'Choose the new role account')).address
+    }
+    validateAddress(newRoleAccount)
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
+      worker.workerId,
+      new GenericAccountId(newRoleAccount),
+    ])
+
+    this.log(chalk.green(`Succesfully updated the role account to: ${chalk.white(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.white(`${matchingAccount.meta.name} (${matchingAccount.address})`)
+        )
+      }
+    }
+  }
+}

+ 67 - 0
cli/src/commands/working-groups/updateWorkerReward.ts

@@ -0,0 +1,67 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import { WorkerId } from '@joystream/types/working-group'
+import { formatBalance } from '@polkadot/util'
+import chalk from 'chalk'
+import { Reward } from '../../Types'
+import { positiveInt } from '../../validators/common'
+import { createParamOptions } from '../../helpers/promptOptions'
+import ExitCodes from '../../ExitCodes'
+
+export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsCommandBase {
+  static description = "Change given worker's reward (amount only). Requires lead access."
+  static args = [
+    {
+      name: 'workerId',
+      required: true,
+      description: 'Worker ID',
+    },
+  ]
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  formatReward(reward?: Reward) {
+    return reward
+      ? formatBalance(reward.value) +
+          (reward.interval && ` / ${reward.interval} block(s)`) +
+          (reward.nextPaymentBlock && ` (next payment: #${reward.nextPaymentBlock})`)
+      : 'NONE'
+  }
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsUpdateWorkerReward)
+
+    const account = await this.getRequiredSelectedAccount()
+    // Lead-only gate
+    await this.getRequiredLead()
+
+    const workerId = parseInt(args.workerId)
+    // This will also make sure the worker is valid
+    const groupMember = await this.getWorkerForLeadAction(workerId)
+
+    const { reward } = groupMember
+
+    if (!reward) {
+      this.error('There is no reward relationship associated with this worker!', { exit: ExitCodes.InvalidInput })
+    }
+
+    console.log(chalk.white(`Current worker reward: ${this.formatReward(reward)}`))
+
+    const newRewardValue = await this.promptForParam(
+      'BalanceOfMint',
+      createParamOptions('new_amount', undefined, positiveInt())
+    )
+
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRewardAmount', [
+      new WorkerId(workerId),
+      newRewardValue,
+    ])
+
+    const updatedGroupMember = await this.getApi().groupMember(this.group, workerId)
+    this.log(chalk.green(`Worker ${chalk.white(workerId)} reward has been updated!`))
+    this.log(chalk.green(`New worker reward: ${chalk.white(this.formatReward(updatedGroupMember.reward))}`))
+  }
+}

+ 52 - 47
cli/src/helpers/display.ts

@@ -1,67 +1,72 @@
-import { cli, Table } from 'cli-ux';
-import chalk from 'chalk';
-import { NameValueObj } from '../Types';
+import { cli, Table } from 'cli-ux'
+import chalk from 'chalk'
+import { NameValueObj } from '../Types'
+import { AccountId } from '@polkadot/types/interfaces'
 
-export function displayHeader(caption: string, placeholderSign: string = '_', size: number = 50) {
-    let singsPerSide: number = Math.floor((size - (caption.length + 2)) / 2);
-    let finalStr: string = '';
-    for (let i = 0; i < singsPerSide; ++i) finalStr += placeholderSign;
-    finalStr += ` ${ caption} `;
-    while (finalStr.length < size) finalStr += placeholderSign;
+export function displayHeader(caption: string, placeholderSign = '_', size = 50) {
+  const singsPerSide: number = Math.floor((size - (caption.length + 2)) / 2)
+  let finalStr = ''
+  for (let i = 0; i < singsPerSide; ++i) finalStr += placeholderSign
+  finalStr += ` ${caption} `
+  while (finalStr.length < size) finalStr += placeholderSign
 
-    process.stdout.write("\n" + chalk.bold.blueBright(finalStr) + "\n\n");
+  process.stdout.write('\n' + chalk.bold.blueBright(finalStr) + '\n\n')
 }
 
 export function displayNameValueTable(rows: NameValueObj[]) {
-    cli.table(
-        rows,
-        {
-            name: { minWidth: 30, get: row => chalk.bold.white(row.name) },
-            value: { get: row => chalk.white(row.value) }
-        },
-        { 'no-header': true }
-    );
+  cli.table(
+    rows,
+    {
+      name: { minWidth: 30, get: (row) => chalk.bold.white(row.name) },
+      value: { get: (row) => chalk.white(row.value) },
+    },
+    { 'no-header': true }
+  )
 }
 
 export function displayCollapsedRow(row: { [k: string]: string | number }) {
-    const collapsedRow: NameValueObj[] = Object.keys(row).map(name => ({
-        name,
-        value: typeof row[name] === 'string' ? row[name] as string : row[name].toString()
-    }));
+  const collapsedRow: NameValueObj[] = Object.keys(row).map((name) => ({
+    name,
+    value: typeof row[name] === 'string' ? (row[name] as string) : row[name].toString(),
+  }))
 
-    displayNameValueTable(collapsedRow);
+  displayNameValueTable(collapsedRow)
 }
 
 export function displayCollapsedTable(rows: { [k: string]: string | number }[]) {
-    for (const row of rows) displayCollapsedRow(row);
+  for (const row of rows) displayCollapsedRow(row)
 }
 
 export function displayTable(rows: { [k: string]: string | number }[], cellHorizontalPadding = 0) {
-    if (!rows.length) {
-        return;
-    }
-    const maxLength = (columnName: string) => rows.reduce(
-        (maxLength, row) => {
-            const val = row[columnName];
-            const valLength = typeof val === 'string' ? val.length : val.toString().length;
-            return Math.max(maxLength, valLength);
-        },
-        columnName.length
-    )
-    const columnDef = (columnName: string) => ({
-        get: (row: typeof rows[number])  => chalk.white(`${row[columnName]}`),
-        minWidth: maxLength(columnName) + cellHorizontalPadding
-    });
-    let columns: Table.table.Columns<{ [k: string]: string }> = {};
-    Object.keys(rows[0]).forEach(columnName => columns[columnName] = columnDef(columnName))
-    cli.table(rows, columns);
+  if (!rows.length) {
+    return
+  }
+  const maxLength = (columnName: string) =>
+    rows.reduce((maxLength, row) => {
+      const val = row[columnName]
+      const valLength = typeof val === 'string' ? val.length : val.toString().length
+      return Math.max(maxLength, valLength)
+    }, columnName.length)
+  const columnDef = (columnName: string) => ({
+    get: (row: typeof rows[number]) => chalk.white(`${row[columnName]}`),
+    minWidth: maxLength(columnName) + cellHorizontalPadding,
+  })
+  const columns: Table.table.Columns<{ [k: string]: string }> = {}
+  Object.keys(rows[0]).forEach((columnName) => (columns[columnName] = columnDef(columnName)))
+  cli.table(rows, columns)
 }
 
 export function toFixedLength(text: string, length: number, spacesOnLeft = false): string {
-    if (text.length > length && length > 3) {
-        return text.slice(0, length-3) + '...';
-    }
-    while(text.length < length) { spacesOnLeft ? text = ' '+text : text += ' ' };
+  if (text.length > length && length > 3) {
+    return text.slice(0, length - 3) + '...'
+  }
+  while (text.length < length) {
+    spacesOnLeft ? (text = ' ' + text) : (text += ' ')
+  }
 
-    return text;
+  return text
+}
+
+export function shortAddress(address: AccountId | string): string {
+  return address.toString().substr(0, 6) + '...' + address.toString().substr(-6)
 }

+ 30 - 0
cli/src/helpers/promptOptions.ts

@@ -0,0 +1,30 @@
+import { ApiParamsOptions, ApiMethodNamedArgs, ApiParamOptions, ApiMethodArg } from '../Types'
+import { Validator } from 'inquirer'
+
+export function setDefaults(promptOptions: ApiParamsOptions, defaultValues: ApiMethodNamedArgs) {
+  for (const defaultValue of defaultValues) {
+    const { name: paramName, value: paramValue } = defaultValue
+    const paramOptions = promptOptions[paramName]
+    if (paramOptions && paramOptions.value) {
+      paramOptions.value.default = paramValue
+    } else if (paramOptions) {
+      promptOptions[paramName].value = { default: paramValue }
+    } else {
+      promptOptions[paramName] = { value: { default: paramValue } }
+    }
+  }
+}
+
+// Temporary(?) helper for easier creation of common ApiParamOptions
+export function createParamOptions(
+  forcedName?: string,
+  defaultValue?: ApiMethodArg | undefined,
+  validator?: Validator
+): ApiParamOptions {
+  const paramOptions: ApiParamOptions = { forcedName, validator }
+  if (defaultValue) {
+    paramOptions.value = { default: defaultValue }
+  }
+
+  return paramOptions
+}

+ 14 - 14
cli/src/helpers/validation.ts

@@ -1,19 +1,19 @@
-import BN from 'bn.js';
-import ExitCodes from '../ExitCodes';
-import { decodeAddress } from '@polkadot/util-crypto';
-import { DerivedBalances } from '@polkadot/api-derive/types';
-import { CLIError } from '@oclif/errors';
+import BN from 'bn.js'
+import ExitCodes from '../ExitCodes'
+import { decodeAddress } from '@polkadot/util-crypto'
+import { DerivedBalances } from '@polkadot/api-derive/types'
+import { CLIError } from '@oclif/errors'
 
-export function validateAddress(address: string, errorMessage: string = 'Invalid address'): void {
-    try {
-        decodeAddress(address);
-    } catch (e) {
-        throw new CLIError(errorMessage, { exit: ExitCodes.InvalidInput });
-    }
+export function validateAddress(address: string, errorMessage = 'Invalid address'): void {
+  try {
+    decodeAddress(address)
+  } catch (e) {
+    throw new CLIError(errorMessage, { exit: ExitCodes.InvalidInput })
+  }
 }
 
 export function checkBalance(accBalances: DerivedBalances, requiredBalance: BN): void {
-    if (requiredBalance.gt(accBalances.availableBalance)) {
-        throw new CLIError('Not enough balance available', { exit: ExitCodes.InvalidInput });
-    }
+  if (requiredBalance.gt(accBalances.availableBalance)) {
+    throw new CLIError('Not enough balance available', { exit: ExitCodes.InvalidInput })
+  }
 }

+ 1 - 1
cli/src/index.ts

@@ -1 +1 @@
-export {run} from '@oclif/command'
+export { run } from '@oclif/command'

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

@@ -0,0 +1,44 @@
+import { ApiParamsOptions, ApiParamOptions, HRTStruct } from '../Types'
+import {
+  OpeningType,
+  SlashingTerms,
+  UnslashableTerms,
+  OpeningType_Worker as OpeningTypeWorker,
+  WorkingGroupOpeningPolicyCommitment,
+} from '@joystream/types/working-group'
+import { Bytes } from '@polkadot/types'
+import { schemaValidator } from '@joystream/types/hiring'
+
+class OpeningPolicyCommitmentOptions implements ApiParamsOptions {
+  [paramName: string]: ApiParamOptions
+  public role_slashing_terms: ApiParamOptions<SlashingTerms> = {
+    value: {
+      default: SlashingTerms.create('Unslashable', new UnslashableTerms()),
+      locked: true,
+    },
+  }
+}
+
+class AddWrokerOpeningOptions implements ApiParamsOptions {
+  [paramName: string]: ApiParamOptions
+  // Lock value for opening_type
+  public opening_type: ApiParamOptions<OpeningType> = {
+    value: {
+      default: OpeningType.create('Worker', new OpeningTypeWorker()),
+      locked: true,
+    },
+  }
+  // Json schema for human_readable_text
+  public human_readable_text: ApiParamOptions<Bytes> = {
+    jsonSchema: {
+      schemaValidator,
+      struct: HRTStruct,
+    },
+  }
+  // Lock value for role_slashing_terms
+  public commitment: ApiParamOptions<WorkingGroupOpeningPolicyCommitment> = {
+    nestedOptions: new OpeningPolicyCommitmentOptions(),
+  }
+}
+
+export default AddWrokerOpeningOptions

+ 51 - 0
cli/src/validators/common.ts

@@ -0,0 +1,51 @@
+//
+// Validators for console input
+// (usable with inquirer package)
+//
+
+type Validator = (value: any) => boolean | string
+
+export const isInt = (message?: string) => (value: any) =>
+  (typeof value === 'number' && Math.floor(value) === value) ||
+  (typeof value === 'string' && parseInt(value).toString() === value)
+    ? true
+    : message || 'The value must be an integer!'
+
+export const gte = (min: number, message?: string) => (value: any) =>
+  parseFloat(value) >= min
+    ? true
+    : message?.replace('{min}', min.toString()) || `The value must be a number greater than or equal ${min}`
+
+export const lte = (max: number, message?: string) => (value: any) =>
+  parseFloat(value) <= max
+    ? true
+    : message?.replace('{max}', max.toString()) || `The value must be less than or equal ${max}`
+
+export const minLen = (min: number, message?: string) => (value: any) =>
+  typeof value === 'string' && value.length >= min
+    ? true
+    : message?.replace('{min}', min.toString()) || `The value should be at least ${min} character(s) long`
+
+export const maxLen = (max: number, message?: string) => (value: any) =>
+  typeof value === 'string' && value.length <= max
+    ? true
+    : message?.replace('{max}', max.toString()) || `The value cannot be more than ${max} character(s) long`
+
+export const combined = (validators: Validator[], message?: string) => (value: any) => {
+  for (const validator of validators) {
+    const result = validator(value)
+    if (result !== true) {
+      return message || result
+    }
+  }
+
+  return true
+}
+
+export const positiveInt = (message?: string) => combined([isInt(), gte(0)], message)
+
+export const minMaxInt = (min: number, max: number, message?: string) =>
+  combined([isInt(), gte(min), lte(max)], message?.replace('{min}', min.toString()).replace('{max}', max.toString()))
+
+export const minMaxStr = (min: number, max: number, message?: string) =>
+  combined([minLen(min), maxLen(max)], message?.replace('{min}', min.toString()).replace('{max}', max.toString()))

+ 7 - 7
cli/test/commands/council/info.test.ts

@@ -1,11 +1,11 @@
-import {expect, test} from '@oclif/test'
+import { expect, test } from '@oclif/test'
 
 describe('info', () => {
   test
-  .stdout()
-  .command(['council:info'])
-  .exit(0)
-  .it('displays "Council" string', ctx => {
-    expect(ctx.stdout).to.contain('Council')
-  })
+    .stdout()
+    .command(['council:info'])
+    .exit(0)
+    .it('displays "Council" string', (ctx) => {
+      expect(ctx.stdout).to.contain('Council')
+    })
 })

+ 1 - 3
cli/test/tsconfig.json

@@ -3,7 +3,5 @@
   "compilerOptions": {
     "noEmit": true
   },
-  "references": [
-    {"path": ".."}
-  ]
+  "references": [{ "path": ".." }]
 }

+ 2 - 1
cli/tsconfig.json

@@ -8,7 +8,8 @@
     "strict": true,
     "target": "es2017",
     "esModuleInterop": true,
-	"types" : [ "node" ]
+    "types" : [ "node" ],
+    "noUnusedLocals": true
   },
   "include": [
     "src/**/*"

+ 2 - 2
package.json

@@ -6,8 +6,8 @@
   "scripts": {
     "test": "yarn && yarn workspaces run test",
     "test-migration": "yarn && yarn workspaces run test-migration",
-    "postinstall": "yarn workspace @joystream/types build",
-    "cargo-checks": "devops/git-hooks/pre-commit && devops/git-hooks/pre-push",
+    "postinstall": "yarn workspace @joystream/types build && yarn workspace storage-node run build",
+	"cargo-checks": "devops/git-hooks/pre-commit && devops/git-hooks/pre-push",
     "cargo-build": "scripts/cargo-build.sh",
     "lint": "yarn workspaces run lint"
   },

+ 53 - 3
pioneer/packages/joy-proposals/src/Proposal/Body.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import { Card, Header, Button, Icon, Message } from 'semantic-ui-react';
 import { ProposalType } from '@polkadot/joy-utils/types/proposals';
+import { bytesToString } from '@polkadot/joy-utils/functions/misc';
 import { blake2AsHex } from '@polkadot/util-crypto';
 import styled from 'styled-components';
 import AddressMini from '@polkadot/react-components/AddressMiniJoy';
@@ -9,11 +10,16 @@ import { ProposalId } from '@joystream/types/proposals';
 import { MemberId, Profile } from '@joystream/types/members';
 import ProfilePreview from '@polkadot/joy-utils/MemberProfilePreview';
 import { useTransport, usePromise } from '@polkadot/joy-utils/react/hooks';
-
-import { Option } from '@polkadot/types/';
+import { Option, Bytes } from '@polkadot/types/';
 import { formatBalance } from '@polkadot/util';
 import { PromiseComponent } from '@polkadot/joy-utils/react/components';
 import ReactMarkdown from 'react-markdown';
+import { WorkingGroupOpeningPolicyCommitment } from '@joystream/types/working-group';
+import {
+  ActivateOpeningAt,
+  ActivateOpeningAtKeys
+} from '@joystream/types/hiring';
+import { WorkingGroup } from '@joystream/types/common';
 
 type BodyProps = {
   title: string;
@@ -65,6 +71,16 @@ function ProposedMember (props: { memberId?: MemberId | number | null }) {
   );
 }
 
+const ParsedHRT = styled.pre`
+  font-size: 14px;
+  font-weight: normal;
+  background: #eee;
+  border-radius: 0.5rem;
+  padding: 1rem;
+  margin: 0;
+  white-space: pre-wrap;
+`;
+
 // The methods for parsing params by Proposal type.
 // They take the params as array and return { LABEL: VALUE } object.
 const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: string | number | JSX.Element } } = {
@@ -116,7 +132,41 @@ const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: s
     // "Min. service period": params.min_service_period + " blocks",
     // "Startup grace period": params.startup_grace_period + " blocks",
     'Entry request fee': formatBalance(params.entry_request_fee)
-  })
+  }),
+  AddWorkingGroupLeaderOpening: ([{ activate_at, commitment, human_readable_text, working_group }]) => {
+    const workingGroup = new WorkingGroup(working_group);
+    const activateAt = new ActivateOpeningAt(activate_at);
+    const activateAtBlock = activateAt.type === ActivateOpeningAtKeys.ExactBlock ? activateAt.value : null;
+    const OPCommitment = new WorkingGroupOpeningPolicyCommitment(commitment);
+    const {
+      application_staking_policy: aSP,
+      role_staking_policy: rSP,
+      application_rationing_policy: rationingPolicy
+    } = OPCommitment;
+    let HRT = bytesToString(new Bytes(human_readable_text));
+    try { HRT = JSON.stringify(JSON.parse(HRT), undefined, 4); } catch (e) { /* Do nothing */ }
+    return {
+      'Working group': workingGroup.type,
+      'Activate at': `${activateAt.type}${activateAtBlock ? `(${activateAtBlock.toString()})` : ''}`,
+      'Application stake': aSP.isSome ? aSP.unwrap().amount_mode.type + `(${aSP.unwrap().amount})` : 'NONE',
+      'Role stake': rSP.isSome ? rSP.unwrap().amount_mode.type + `(${rSP.unwrap().amount})` : 'NONE',
+      'Max. applications': rationingPolicy.isSome ? rationingPolicy.unwrap().max_active_applicants.toNumber() : 'UNLIMITED',
+      'Terminate unstaking period (role stake)': OPCommitment.terminate_role_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Exit unstaking period (role stake)': OPCommitment.exit_role_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      // <required_to_prevent_sneaking>
+      'Terminate unstaking period (appl. stake)': OPCommitment.terminate_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Exit unstaking period (appl. stake)': OPCommitment.exit_role_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Appl. accepted unstaking period (appl. stake)': OPCommitment.fill_opening_successful_applicant_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Appl. failed unstaking period (role stake)': OPCommitment.fill_opening_failed_applicant_role_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Appl. failed unstaking period (appl. stake)': OPCommitment.fill_opening_failed_applicant_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Crowded out unstaking period (role stake)': ((rSP.isSome && rSP.unwrap().crowded_out_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      'Review period expierd unstaking period (role stake)': ((rSP.isSome && rSP.unwrap().review_period_expired_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      'Crowded out unstaking period (appl. stake)': ((aSP.isSome && aSP.unwrap().crowded_out_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      'Review period expierd unstaking period (appl. stake)': ((aSP.isSome && aSP.unwrap().review_period_expired_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      // </required_to_prevent_sneaking>
+      'Human readable text': <ParsedHRT>{ HRT }</ParsedHRT>
+    };
+  }
 };
 
 const StyledProposalDescription = styled(Card.Description)`

+ 1 - 1
pioneer/packages/joy-proposals/src/Proposal/Votes.tsx

@@ -12,7 +12,7 @@ type VotesProps = {
 
 export default function Votes ({ votes }: VotesProps) {
   if (!votes.votes.length) {
-    return <Header as="h4">No votes has been submitted!</Header>;
+    return <Header as="h4">No votes have been submitted!</Header>;
   }
 
   return (

+ 357 - 0
pioneer/packages/joy-proposals/src/forms/AddWorkingGroupOpeningForm.tsx

@@ -0,0 +1,357 @@
+import React, { useEffect } from 'react';
+import { getFormErrorLabelsProps, FormErrorLabelsProps } from './errorHandling';
+import * as Yup from 'yup';
+import {
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps,
+  genericFormDefaultOptions
+} from './GenericProposalForm';
+import {
+  GenericWorkingGroupProposalForm,
+  FormValues as WGFormValues,
+  defaultValues as wgFromDefaultValues
+} from './GenericWorkingGroupProposalForm';
+import { FormField, InputFormField, TextareaFormField } from './FormFields';
+import { withFormContainer } from './FormContainer';
+import './forms.css';
+import { ActivateOpeningAtKey, ActivateOpeningAtDef, StakingAmountLimitModeKeys, IApplicationRationingPolicy, IStakingPolicy } from '@joystream/types/hiring';
+import { GenericJoyStreamRoleSchema } from '@joystream/types/hiring/schemas/role.schema.typings';
+import { Dropdown, Grid, Message, Checkbox } from 'semantic-ui-react';
+import { formatBalance } from '@polkadot/util';
+import _ from 'lodash';
+import { IWorkingGroupOpeningPolicyCommitment } from '@joystream/types/working-group';
+import { IAddOpeningParameters } from '@joystream/types/proposals';
+import { WorkingGroupKeys } from '@joystream/types/common';
+import { BlockNumber } from '@polkadot/types/interfaces';
+import { withCalls } from '@polkadot/react-api';
+import { SimplifiedTypeInterface } from '@polkadot/joy-utils/types/common';
+import Validation from '../validationSchema';
+
+type FormValues = WGFormValues & {
+  activateAt: ActivateOpeningAtKey;
+  activateAtBlock: string;
+  maxReviewPeriodLength: string;
+  applicationsLimited: boolean;
+  maxApplications: string;
+  applicationStakeRequired: boolean;
+  applicationStakeMode: StakingAmountLimitModeKeys;
+  applicationStakeValue: string;
+  roleStakeRequired: boolean;
+  roleStakeMode: StakingAmountLimitModeKeys;
+  roleStakeValue: string;
+  terminateRoleUnstakingPeriod: string;
+  leaveRoleUnstakingPeriod: string;
+  humanReadableText: string;
+};
+
+const defaultValues: FormValues = {
+  ...wgFromDefaultValues,
+  activateAt: 'CurrentBlock',
+  activateAtBlock: '',
+  maxReviewPeriodLength: (14400 * 30).toString(), // 30 days
+  applicationsLimited: false,
+  maxApplications: '',
+  applicationStakeRequired: false,
+  applicationStakeMode: StakingAmountLimitModeKeys.Exact,
+  applicationStakeValue: '',
+  roleStakeRequired: false,
+  roleStakeMode: StakingAmountLimitModeKeys.Exact,
+  roleStakeValue: '',
+  terminateRoleUnstakingPeriod: (14400 * 7).toString(), // 7 days
+  leaveRoleUnstakingPeriod: (14400 * 7).toString(), // 7 days
+  humanReadableText: ''
+};
+
+const HRTDefault: (memberHandle: string, group: WorkingGroupKeys) => GenericJoyStreamRoleSchema =
+  (memberHandle, group) => ({
+    version: 1,
+    headline: `Looking for ${group} Working Group Leader!`,
+    job: {
+      title: `${group} Working Group Leader`,
+      description: `Become ${group} Working Group Leader! This is a great opportunity to support Joystream!`
+    },
+    application: {
+      sections: [
+        {
+          title: 'About you',
+          questions: [
+            {
+              title: 'Your name',
+              type: 'text'
+            },
+            {
+              title: 'What makes you a good fit for the job?',
+              type: 'text area'
+            }
+          ]
+        }
+      ]
+    },
+    reward: '100 JOY per block',
+    creator: {
+      membership: {
+        handle: memberHandle
+      }
+    }
+  });
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export component into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps> & {
+  currentBlock?: BlockNumber;
+};
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+type StakeFieldsProps = Pick<FormInnerProps, 'values' | 'handleChange' | 'setFieldValue'> & {
+  errorLabelsProps: FormErrorLabelsProps<FormValues>;
+  stakeType: 'role' | 'application';
+};
+const StakeFields: React.FunctionComponent<StakeFieldsProps> = ({
+  values,
+  errorLabelsProps,
+  handleChange,
+  stakeType,
+  setFieldValue
+}) => {
+  return (
+  <>
+    <FormField label={`${_.startCase(stakeType)} stake` }>
+      <Checkbox
+        toggle
+        onChange={(e, data) => { setFieldValue(`${stakeType}StakeRequired`, data.checked); }}
+        label={ `Require ${stakeType} stake` }
+        checked={ stakeType === 'role' ? values.roleStakeRequired : values.applicationStakeRequired }/>
+    </FormField>
+    { (stakeType === 'role' ? values.roleStakeRequired : values.applicationStakeRequired) && (<>
+      <FormField label="Stake mode">
+        <Dropdown
+          onChange={handleChange}
+          name={ `${stakeType}StakeMode` }
+          selection
+          options={[StakingAmountLimitModeKeys.Exact, StakingAmountLimitModeKeys.AtLeast].map(mode => ({ text: mode, value: mode }))}
+          value={ stakeType === 'role' ? values.roleStakeMode : values.applicationStakeMode }
+        />
+      </FormField>
+      <InputFormField
+        label="Stake value"
+        unit={formatBalance.getDefaults().unit}
+        onChange={handleChange}
+        name={ `${stakeType}StakeValue` }
+        error={ stakeType === 'role' ? errorLabelsProps.roleStakeValue : errorLabelsProps.applicationStakeValue}
+        value={ stakeType === 'role' ? values.roleStakeValue : values.applicationStakeValue}
+        placeholder={'ie. 100'}
+      />
+    </>) }
+  </>
+  );
+};
+
+const valuesToAddOpeningParams = (values: FormValues): SimplifiedTypeInterface<IAddOpeningParameters> => {
+  const commitment: SimplifiedTypeInterface<IWorkingGroupOpeningPolicyCommitment> = {
+    max_review_period_length: parseInt(values.maxReviewPeriodLength)
+  };
+  if (parseInt(values.terminateRoleUnstakingPeriod) > 0) {
+    commitment.terminate_role_stake_unstaking_period = parseInt(values.terminateRoleUnstakingPeriod);
+  }
+  if (parseInt(values.leaveRoleUnstakingPeriod) > 0) {
+    commitment.exit_role_stake_unstaking_period = parseInt(values.leaveRoleUnstakingPeriod);
+  }
+  if (values.applicationsLimited) {
+    const rationingPolicy: SimplifiedTypeInterface<IApplicationRationingPolicy> = {
+      max_active_applicants: parseInt(values.maxApplications)
+    };
+    commitment.application_rationing_policy = rationingPolicy;
+  }
+  if (values.applicationStakeRequired) {
+    const applicationStakingPolicy: SimplifiedTypeInterface<IStakingPolicy> = {
+      amount: parseInt(values.applicationStakeValue),
+      amount_mode: values.applicationStakeMode
+    };
+    commitment.application_staking_policy = applicationStakingPolicy;
+  }
+  if (values.roleStakeRequired) {
+    const roleStakingPolicy: SimplifiedTypeInterface<IStakingPolicy> = {
+      amount: parseInt(values.roleStakeValue),
+      amount_mode: values.roleStakeMode
+    };
+    commitment.role_staking_policy = roleStakingPolicy;
+  }
+  return {
+    activate_at: { [values.activateAt]: values.activateAt === 'ExactBlock' ? parseInt(values.activateAtBlock) : null },
+    commitment: commitment,
+    human_readable_text: values.humanReadableText,
+    working_group: values.workingGroup
+  };
+};
+
+const AddWorkingGroupOpeningForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values, setFieldValue, myMemberId, memberProfile } = props;
+  useEffect(() => {
+    if (memberProfile?.isSome && !touched.humanReadableText) {
+      setFieldValue(
+        'humanReadableText',
+        JSON.stringify(HRTDefault(memberProfile.unwrap().handle.toString(), values.workingGroup), undefined, 4)
+      );
+    }
+  }, [values.workingGroup, memberProfile]);
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+
+  return (
+    <GenericWorkingGroupProposalForm
+      {...props}
+      txMethod="createAddWorkingGroupLeaderOpeningProposal"
+      proposalType="AddWorkingGroupLeaderOpening"
+      submitParams={[
+        myMemberId,
+        values.title,
+        values.rationale,
+        '{STAKE}',
+        valuesToAddOpeningParams(values)
+      ]}
+    >
+      <Grid columns="4" doubling stackable verticalAlign="bottom">
+        <Grid.Row>
+          <Grid.Column>
+            <FormField label="Activate opening at">
+              <Dropdown
+                onChange={handleChange}
+                name="activateAt"
+                selection
+                options={Object.keys(ActivateOpeningAtDef).map(wgKey => ({ text: wgKey, value: wgKey }))}
+                value={values.activateAt}
+              />
+            </FormField>
+          </Grid.Column>
+          <Grid.Column>
+            { values.activateAt === 'ExactBlock' && (
+              <InputFormField
+                onChange={handleChange}
+                name="activateAtBlock"
+                error={errorLabelsProps.activateAtBlock}
+                value={values.activateAtBlock}
+                placeholder={'Provide the block number'}
+              />
+            ) }
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      { values.activateAt === 'ExactBlock' && (
+        <Message info>
+          In case <b>ExactBlock</b> is specified, the opening will remain in <i>Waiting to Begin</i> stage (which means it will be visible,
+          but no applicants will be able to apply yet) until current block number will equal the specified number.
+        </Message>
+      ) }
+      <Grid columns="4" doubling stackable verticalAlign="bottom">
+        <Grid.Row>
+          <Grid.Column>
+            <InputFormField
+              label="Max. review period"
+              onChange={handleChange}
+              name="maxReviewPeriodLength"
+              error={errorLabelsProps.maxReviewPeriodLength}
+              value={values.maxReviewPeriodLength}
+              placeholder={'ie. 72000'}
+              unit="blocks"
+            />
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <Grid columns="4" doubling stackable verticalAlign="bottom">
+        <Grid.Row>
+          <Grid.Column>
+            <FormField label="Applications limit">
+              <Checkbox
+                toggle
+                onChange={(e, data) => { setFieldValue('applicationsLimited', data.checked); }}
+                label="Limit applications"
+                checked={values.applicationsLimited}/>
+            </FormField>
+            { values.applicationsLimited && (
+              <InputFormField
+                onChange={handleChange}
+                name="maxApplications"
+                error={errorLabelsProps.maxApplications}
+                value={values.maxApplications}
+                placeholder={'Max. number of applications'}
+              />
+            ) }
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <Grid columns="2" stackable style={{ marginBottom: 0 }}>
+        <Grid.Row>
+          <Grid.Column>
+            <StakeFields stakeType="application" {...{ errorLabelsProps, values, handleChange, setFieldValue }}/>
+          </Grid.Column>
+          <Grid.Column>
+            <StakeFields stakeType="role" {...{ errorLabelsProps, values, handleChange, setFieldValue }}/>
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <Grid columns="2" stackable style={{ marginBottom: 0 }}>
+        <Grid.Row>
+          <Grid.Column>
+            <InputFormField
+              onChange={handleChange}
+              name="terminateRoleUnstakingPeriod"
+              error={errorLabelsProps.terminateRoleUnstakingPeriod}
+              value={values.terminateRoleUnstakingPeriod}
+              label={'Terminate role unstaking period'}
+              placeholder={'ie. 14400'}
+              unit="blocks"
+              help={
+                'In case leader role or application is terminated - this will be the unstaking period for the role stake (in blocks).'
+              }
+            />
+          </Grid.Column>
+          <Grid.Column>
+            <InputFormField
+              onChange={handleChange}
+              name="leaveRoleUnstakingPeriod"
+              error={errorLabelsProps.leaveRoleUnstakingPeriod}
+              value={values.leaveRoleUnstakingPeriod}
+              label={'Leave role unstaking period'}
+              placeholder={'ie. 14400'}
+              unit="blocks"
+              help={
+                'In case leader leaves/exits his role - this will be the unstaking period for his role stake (in blocks). ' +
+                'It also applies when user is withdrawing an active leader application.'
+              }
+            />
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <TextareaFormField
+        label="Opening schema (human_readable_text)"
+        help="JSON schema that describes some characteristics of the opening presented in the UI (headers, content, application form etc.)"
+        onChange={handleChange}
+        name="humanReadableText"
+        placeholder="Paste the JSON schema here..."
+        error={errorLabelsProps.humanReadableText}
+        value={values.humanReadableText}
+        rows={20}
+      />
+    </GenericWorkingGroupProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: (props: FormContainerProps) => Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    ...Validation.AddWorkingGroupLeaderOpening(props.currentBlock?.toNumber() || 0)
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: 'AddWorkingGroupOpeningForm'
+})(AddWorkingGroupOpeningForm);
+
+export default withCalls<ExportComponentProps>(
+  ['derive.chain.bestNumber', { propName: 'currentBlock' }]
+)(
+  withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer)
+);

+ 48 - 10
pioneer/packages/joy-proposals/src/forms/GenericProposalForm.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useState, useRef } from 'react';
 import { FormikProps, WithFormikConfig } from 'formik';
 import { Form, Icon, Button, Message } from 'semantic-ui-react';
 import { getFormErrorLabelsProps } from './errorHandling';
@@ -81,25 +81,58 @@ export const genericFormDefaultOptions: GenericFormDefaultOptions = {
 export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps> = props => {
   const {
     handleChange,
+    handleSubmit,
     errors,
     isSubmitting,
+    isValidating,
+    isValid,
     touched,
-    handleSubmit,
+    submitForm,
     children,
     handleReset,
     values,
     txMethod,
     submitParams,
-    isValid,
     setSubmitting,
     history,
     balances_totalIssuance,
     proposalType
   } = props;
   const errorLabelsProps = getFormErrorLabelsProps<GenericFormValues>(errors, touched);
+  const [afterSubmit, setAfterSubmit] = useState(null as (() => () => void) | null);
+  const formContainerRef = useRef<HTMLDivElement>(null);
+
+  // After-submit effect
+  // With current version of Formik, there seems to be no other viable way to handle this (ie. for sendTx)
+  useEffect(() => {
+    if (!isValidating && afterSubmit) {
+      if (isValid) {
+        afterSubmit();
+      }
+      setAfterSubmit(null);
+      setSubmitting(false);
+    }
+  }, [isValidating, isValid, afterSubmit]);
+
+  // Focus first error field when isValidating changes to false (which happens after form is validated)
+  // (operates directly on DOM)
+  useEffect(() => {
+    if (!isValidating && formContainerRef.current !== null) {
+      const [errorField] = formContainerRef.current.getElementsByClassName('error field');
+      if (errorField) {
+        errorField.scrollIntoView({ behavior: 'smooth' });
+        const [errorInput] = errorField.querySelectorAll('input,textarea');
+        if (errorInput) {
+          (errorInput as (HTMLInputElement | HTMLTextAreaElement)).focus();
+        }
+      }
+    }
+  }, [isValidating]);
 
-  const onSubmit = (sendTx: () => void) => {
-    if (isValid) sendTx();
+  // Replaces standard submit handler (in order to work with TxButton)
+  const onTxButtonClick = (sendTx: () => void) => {
+    submitForm();
+    setAfterSubmit(() => sendTx);
   };
 
   const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => {
@@ -127,8 +160,13 @@ export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps>
     proposalsConsts[proposalType].stake;
 
   return (
-    <div className="Forms">
-      <Form className="proposal-form" onSubmit={handleSubmit}>
+    <div className="Forms" ref={formContainerRef}>
+      <Form
+        className="proposal-form"
+        onSubmit={txMethod
+          ? () => { /* Do nothing. Tx button uses custom submit handler - "onTxButtonClick" */ }
+          : handleSubmit
+        }>
         <InputFormField
           label="Title"
           help="The title of your proposal"
@@ -157,15 +195,15 @@ export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps>
         <div className="form-buttons">
           {txMethod ? (
             <TxButton
-              type="submit"
+              type="button" // Tx button uses custom submit handler - "onTxButtonClick"
               label="Submit proposal"
               icon="paper plane"
-              isDisabled={isSubmitting || !isValid}
+              isDisabled={isSubmitting}
               params={(submitParams || []).map(p => (p === '{STAKE}' ? requiredStake : p))}
               tx={`proposalsCodex.${txMethod}`}
-              onClick={onSubmit}
               txFailedCb={onTxFailed}
               txSuccessCb={onTxSuccess}
+              onClick={onTxButtonClick} // This replaces standard submit
             />
           ) : (
             <Button type="submit" color="blue" loading={isSubmitting}>

+ 84 - 0
pioneer/packages/joy-proposals/src/forms/GenericWorkingGroupProposalForm.tsx

@@ -0,0 +1,84 @@
+import React from 'react';
+import { getFormErrorLabelsProps } from './errorHandling';
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultValues,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from './GenericProposalForm';
+import { FormField } from './FormFields';
+import { ProposalType } from '@polkadot/joy-utils/types/proposals';
+import { WorkingGroupKeys, WorkingGroupDef } from '@joystream/types/common';
+import './forms.css';
+import { Dropdown, Message } from 'semantic-ui-react';
+import { usePromise, useTransport } from '@polkadot/joy-utils/react/hooks';
+import { PromiseComponent } from '@polkadot/joy-utils/react/components';
+import { ProfilePreviewFromStruct as MemberPreview } from '@polkadot/joy-utils/MemberProfilePreview';
+
+export type FormValues = GenericFormValues & {
+  workingGroup: WorkingGroupKeys;
+};
+
+export const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  workingGroup: 'Storage'
+};
+
+// Aditional props coming all the way from export comonent into the inner form.
+type FormAdditionalProps = {
+  txMethod: string;
+  submitParams: any[];
+  proposalType: ProposalType;
+  showLead?: boolean;
+};
+
+// We don't exactly use "container" and "export" components here, but those types are useful for
+// generiting the right "FormInnerProps"
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+export type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+export const GenericWorkingGroupProposalForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values, showLead = true } = props;
+  const transport = useTransport();
+  const [lead, error, loading] = usePromise(
+    () => transport.workingGroups.currentLead(values.workingGroup),
+    null,
+    [values.workingGroup]
+  );
+  const leadRes = { lead, error, loading };
+
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+  return (
+    <GenericProposalForm {...props}>
+      <FormField
+        error={errorLabelsProps.workingGroup}
+        label="Working group"
+      >
+        <Dropdown
+          name="workingGroup"
+          placeholder="Select the working group"
+          selection
+          options={Object.keys(WorkingGroupDef).map(wgKey => ({ text: wgKey + ' Wroking Group', value: wgKey }))}
+          value={values.workingGroup}
+          onChange={ handleChange }
+        />
+      </FormField>
+      { showLead && (
+        <PromiseComponent message={'Fetching current lead...'} {...leadRes}>
+          <Message info>
+            <Message.Content>
+              <Message.Header>Current {values.workingGroup} Working Group lead:</Message.Header>
+              <div style={{ padding: '0.5rem 0' }}>
+                { leadRes.lead ? <MemberPreview profile={leadRes.lead.profile} /> : 'NONE' }
+              </div>
+            </Message.Content>
+          </Message>
+        </PromiseComponent>
+      ) }
+      { props.children }
+    </GenericProposalForm>
+  );
+};

+ 1 - 1
pioneer/packages/joy-proposals/src/forms/errorHandling.ts

@@ -2,7 +2,7 @@ import { FormikErrors, FormikTouched } from 'formik';
 import { LabelProps } from 'semantic-ui-react';
 
 type FieldErrorLabelProps = LabelProps | null; // This is used for displaying semantic-ui errors
-type FormErrorLabelsProps<ValuesT> = { [T in keyof ValuesT]: FieldErrorLabelProps };
+export type FormErrorLabelsProps<ValuesT> = { [T in keyof ValuesT]: FieldErrorLabelProps };
 
 // Single form field error state.
 // Takes formik "errors" and "touched" objects and the field name as arguments.

+ 1 - 0
pioneer/packages/joy-proposals/src/forms/index.ts

@@ -9,3 +9,4 @@ export { default as RuntimeUpgradeForm } from './RuntimeUpgradeForm';
 export { default as SetContentWorkingGroupMintCapForm } from './SetContentWorkingGroupMintCapForm';
 export { default as SetCouncilMintCapForm } from './SetCouncilMintCapForm';
 export { default as SetMaxValidatorCountForm } from './SetMaxValidatorCountForm';
+export { default as AddWorkingGroupOpeningForm } from './AddWorkingGroupOpeningForm';

+ 3 - 1
pioneer/packages/joy-proposals/src/index.tsx

@@ -21,7 +21,8 @@ import {
   SetCouncilParamsForm,
   SetStorageRoleParamsForm,
   SetMaxValidatorCountForm,
-  RuntimeUpgradeForm
+  RuntimeUpgradeForm,
+  AddWorkingGroupOpeningForm
 } from './forms';
 
 interface Props extends AppProps, I18nProps {}
@@ -70,6 +71,7 @@ function App (props: Props): React.ReactElement<Props> {
           <Route exact path={`${basePath}/new/evict-storage-provider`} component={EvictStorageProviderForm} />
           <Route exact path={`${basePath}/new/set-validator-count`} component={SetMaxValidatorCountForm} />
           <Route exact path={`${basePath}/new/set-storage-role-parameters`} component={SetStorageRoleParamsForm} />
+          <Route exact path={`${basePath}/new/add-working-group-leader-opening`} component={AddWorkingGroupOpeningForm} />
           <Route exact path={`${basePath}/active`} component={NotDone} />
           <Route exact path={`${basePath}/finalized`} component={NotDone} />
           <Route exact path={`${basePath}/:id`} component={ProposalFromId} />

+ 105 - 1
pioneer/packages/joy-proposals/src/validationSchema.ts

@@ -1,4 +1,5 @@
 import * as Yup from 'yup';
+import { schemaValidator, ActivateOpeningAtKeys } from '@joystream/types/hiring';
 
 // TODO: If we really need this (currency unit) we can we make "Validation" a functiction that returns an object.
 // We could then "instantialize" it in "withFormContainer" where instead of passing
@@ -70,6 +71,23 @@ const STARTUP_GRACE_PERIOD_MAX = 28800;
 const ENTRY_REQUEST_FEE_MIN = 1;
 const ENTRY_REQUEST_FEE_MAX = 100000;
 
+// Add Working Group Leader Opening Parameters
+// TODO: Discuss the actual values
+const MIN_EXACT_BLOCK_MINUS_CURRENT = 14400 * 5; // ~5 days
+const MAX_EXACT_BLOCK_MINUS_CURRENT = 14400 * 60; // 2 months
+const MAX_REVIEW_PERIOD_LENGTH_MIN = 14400 * 5; // ~5 days
+const MAX_REVIEW_PERIOD_LENGTH_MAX = 14400 * 60; // 2 months
+const MAX_APPLICATIONS_MIN = 1;
+const MAX_APPLICATIONS_MAX = 1000;
+const APPLICATION_STAKE_VALUE_MIN = 1;
+const APPLICATION_STAKE_VALUE_MAX = 1000000;
+const ROLE_STAKE_VALUE_MIN = 1;
+const ROLE_STAKE_VALUE_MAX = 1000000;
+const TERMINATE_ROLE_UNSTAKING_MIN = 0;
+const TERMINATE_ROLE_UNSTAKING_MAX = 14 * 14400; // 14 days
+const LEAVE_ROLE_UNSTAKING_MIN = 0;
+const LEAVE_ROLE_UNSTAKING_MAX = 14 * 14400; // 14 days
+
 function errorMessage (name: string, min?: number | string, max?: number | string, unit?: string): string {
   return `${name} should be at least ${min} and no more than ${max}${unit ? ` ${unit}.` : '.'}`;
 }
@@ -139,8 +157,31 @@ type ValidationType = {
     startup_grace_period: Yup.NumberSchema<number>;
     entry_request_fee: Yup.NumberSchema<number>;
   };
+  AddWorkingGroupLeaderOpening: (currentBlock: number) => {
+    applicationsLimited: Yup.BooleanSchema<boolean>;
+    activateAt: Yup.StringSchema<string>;
+    activateAtBlock: Yup.NumberSchema<number>;
+    maxReviewPeriodLength: Yup.NumberSchema<number>;
+    maxApplications: Yup.NumberSchema<number>;
+    applicationStakeRequired: Yup.BooleanSchema<boolean>;
+    applicationStakeValue: Yup.NumberSchema<number>;
+    roleStakeRequired: Yup.BooleanSchema<boolean>;
+    roleStakeValue: Yup.NumberSchema<number>;
+    terminateRoleUnstakingPeriod: Yup.NumberSchema<number>;
+    leaveRoleUnstakingPeriod: Yup.NumberSchema<number>;
+    humanReadableText: Yup.StringSchema<string>;
+  };
 };
 
+// Helpers for common validation
+function minMaxInt (min: number, max: number, fieldName: string) {
+  return Yup.number()
+    .required(`${fieldName} is required!`)
+    .integer(`${fieldName} must be an integer!`)
+    .min(min, errorMessage(fieldName, min, max))
+    .max(max, errorMessage(fieldName, min, max));
+}
+
 const Validation: ValidationType = {
   All: {
     title: Yup.string()
@@ -346,7 +387,70 @@ const Validation: ValidationType = {
         STARTUP_GRACE_PERIOD_MAX,
         errorMessage('The entry request fee', ENTRY_REQUEST_FEE_MIN, ENTRY_REQUEST_FEE_MAX, CURRENCY_UNIT)
       )
-  }
+  },
+  AddWorkingGroupLeaderOpening: (currentBlock: number) => ({
+    activateAt: Yup.string().required(),
+    activateAtBlock: Yup.number()
+      .when('activateAt', {
+        is: ActivateOpeningAtKeys.ExactBlock,
+        then: minMaxInt(
+          MIN_EXACT_BLOCK_MINUS_CURRENT + currentBlock,
+          MAX_EXACT_BLOCK_MINUS_CURRENT + currentBlock,
+          'Exact block'
+        )
+      }),
+    maxReviewPeriodLength: minMaxInt(MAX_REVIEW_PERIOD_LENGTH_MIN, MAX_REVIEW_PERIOD_LENGTH_MAX, 'Max. review period length'),
+    applicationsLimited: Yup.boolean(),
+    maxApplications: Yup.number()
+      .when('applicationsLimited', {
+        is: true,
+        then: minMaxInt(MAX_APPLICATIONS_MIN, MAX_APPLICATIONS_MAX, 'Max. number of applications')
+      }),
+    applicationStakeRequired: Yup.boolean(),
+    applicationStakeValue: Yup.number()
+      .when('applicationStakeRequired', {
+        is: true,
+        then: minMaxInt(APPLICATION_STAKE_VALUE_MIN, APPLICATION_STAKE_VALUE_MAX, 'Application stake value')
+      }),
+    roleStakeRequired: Yup.boolean(),
+    roleStakeValue: Yup.number()
+      .when('roleStakeRequired', {
+        is: true,
+        then: minMaxInt(ROLE_STAKE_VALUE_MIN, ROLE_STAKE_VALUE_MAX, 'Role stake value')
+      }),
+    terminateRoleUnstakingPeriod: minMaxInt(
+      TERMINATE_ROLE_UNSTAKING_MIN,
+      TERMINATE_ROLE_UNSTAKING_MAX,
+      'Terminate role unstaking period'
+    ),
+    leaveRoleUnstakingPeriod: minMaxInt(
+      LEAVE_ROLE_UNSTAKING_MIN,
+      LEAVE_ROLE_UNSTAKING_MAX,
+      'Leave role unstaking period'
+    ),
+    humanReadableText: Yup.string()
+      .required()
+      .test(
+        'schemaIsValid',
+        'Schema validation failed!',
+        function (val) {
+          let schemaObj: any;
+          try {
+            schemaObj = JSON.parse(val);
+          } catch (e) {
+            return this.createError({ message: 'Schema validation failed: Invalid JSON' });
+          }
+          const isValid = schemaValidator(schemaObj);
+          const errors = schemaValidator.errors || [];
+          if (!isValid) {
+            return this.createError({
+              message: 'Schema validation failed: ' + errors.map(e => `${e.message}${e.dataPath && ` (${e.dataPath})`}`).join(', ')
+            });
+          }
+          return true;
+        }
+      )
+  })
 };
 
 export default Validation;

+ 1 - 1
pioneer/packages/joy-roles/src/index.tsx

@@ -60,7 +60,7 @@ export const App: React.FC<Props> = (props: Props) => {
   const oppsCtrl = new OpportunitiesController(transport, props.myMemberId);
   const [applyCtrl] = useState(new ApplyController(transport));
   const myRolesCtrl = new MyRolesController(transport, props.myAddress);
-  const [adminCtrl] = useState(new AdminController(transport, api));
+  const [adminCtrl] = useState(new AdminController(transport, api, queueExtrinsic));
 
   useEffect(() => {
     return () => {

+ 72 - 113
pioneer/packages/joy-roles/src/tabs/Admin.controller.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useRef } from 'react';
 import { Link } from 'react-router-dom';
 import { formatBalance } from '@polkadot/util';
 
@@ -7,8 +7,9 @@ import { GenericAccountId, Option, Text, Vec, u32, u128 } from '@polkadot/types'
 import { Balance } from '@polkadot/types/interfaces';
 
 import { SingleLinkedMapEntry, Controller, View } from '@polkadot/joy-utils/index';
-import { MyAccountProvider, useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
+import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
 import { SubmittableExtrinsic } from '@polkadot/api/promise/types';
+import { QueueTxExtrinsicAdd } from '@polkadot/react-components/Status/types';
 
 import {
   Accordion,
@@ -367,53 +368,35 @@ const newEmptyState = (): State => {
 };
 
 export class AdminController extends Controller<State, ITransport> {
-  api: ApiPromise
-  constructor (transport: ITransport, api: ApiPromise, initialState: State = newEmptyState()) {
+  api: ApiPromise;
+  queueExtrinsic: QueueTxExtrinsicAdd;
+
+  constructor (transport: ITransport, api: ApiPromise, queueExtrinsic: QueueTxExtrinsicAdd, initialState: State = newEmptyState()) {
     super(transport, initialState);
     this.api = api;
+    this.queueExtrinsic = queueExtrinsic;
     this.state.currentDescriptor = stockOpenings[0];
     this.updateState();
   }
 
-  newOpening (creatorAddress: string, desc: openingDescriptor) {
+  onTxSuccess = () => { this.updateState(); }
+
+  newOpening (accountId: string, desc: openingDescriptor) {
     const tx = this.api.tx.contentWorkingGroup.addCuratorOpening(
       desc.start,
       desc.policy,
       desc.text
     ) as unknown as SubmittableExtrinsic;
 
-    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
-    // and probably the reason why it always appears as succesful
-    tx.signAndSend(creatorAddress, ({ events = [], status }) => {
-      if (status.isFinalized) {
-        this.updateState();
-        console.log('Successful transfer with hash ' + status.asFinalized.toHex());
-      } else {
-        console.log('Status of transfer: ' + status.type);
-      }
-
-      events.forEach(({ phase, event: { data, method, section } }) => {
-        console.log(phase.toString() + ' : ' + section + '.' + method + ' ' + data.toString());
-      });
-    });
+    // FIXME: Normally we would keep it open in case of errror, but due to bad design
+    // the values in the form are reset at this point anyway, so there is no point
+    this.closeModal();
+    this.queueExtrinsic({ extrinsic: tx, txSuccessCb: this.onTxSuccess, accountId });
   }
 
-  startAcceptingApplications (creatorAddress: string, id = 0) {
+  startAcceptingApplications (accountId: string, id = 0) {
     const tx = this.api.tx.contentWorkingGroup.acceptCuratorApplications(id);
-    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
-    // and probably the reason why it always appears as succesful
-    tx.signAndSend(creatorAddress, ({ events = [], status }) => {
-      if (status.isFinalized) {
-        this.updateState();
-        console.log('Successful transfer with hash ' + status.asFinalized.toHex());
-      } else {
-        console.log('Status of transfer: ' + status.type);
-      }
-
-      events.forEach(({ phase, event: { data, method, section } }) => {
-        console.log(phase.toString() + ' : ' + section + '.' + method + ' ' + data.toString());
-      });
-    });
+    this.queueExtrinsic({ extrinsic: tx, txSuccessCb: this.onTxSuccess, accountId });
   }
 
   async applyAsACurator (creatorAddress: string, openingId: number) {
@@ -430,60 +413,21 @@ export class AdminController extends Controller<State, ITransport> {
       new Option(u128, 400),
       new Text('This is my application')
     ) as unknown as SubmittableExtrinsic;
-    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
-    // and probably the reason why it always appears as succesful
-    tx.signAndSend(creatorAddress, ({ events = [], status }) => {
-      if (status.isFinalized) {
-        this.updateState();
-        console.log('Successful transfer with hash ' + status.asFinalized.toHex());
-      } else {
-        console.log('Status of transfer: ' + status.type);
-      }
-
-      events.forEach(({ phase, event: { data, method, section } }) => {
-        console.log(phase.toString() + ' : ' + section + '.' + method + ' ' + data.toString());
-      });
-    });
+    this.queueExtrinsic({ extrinsic: tx, txSuccessCb: this.onTxSuccess, accountId: creatorAddress });
   }
 
-  beginApplicantReview (creatorAddress: string, openingId: number) {
+  beginApplicantReview (accountId: string, openingId: number) {
     const tx = this.api.tx.contentWorkingGroup.beginCuratorApplicantReview(openingId);
-    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
-    // and probably the reason why it always appears as succesful
-    tx.signAndSend(creatorAddress, ({ events = [], status }) => {
-      if (status.isFinalized) {
-        this.updateState();
-        console.log('Successful transfer with hash ' + status.asFinalized.toHex());
-      } else {
-        console.log('Status of transfer: ' + status.type);
-      }
-
-      events.forEach(({ phase, event: { data, method, section } }) => {
-        console.log(phase.toString() + ' : ' + section + '.' + method + ' ' + data.toString());
-      });
-    });
+    this.queueExtrinsic({ extrinsic: tx, txSuccessCb: this.onTxSuccess, accountId });
   }
 
-  acceptCuratorApplications (creatorAddress: string, openingId: number, applications: Array<number>) {
+  acceptCuratorApplications (accountId: string, openingId: number, applications: Array<number>) {
     const tx = this.api.tx.contentWorkingGroup.fillCuratorOpening(
       openingId,
       applications,
       null
     ) as unknown as SubmittableExtrinsic;
-    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
-    // and probably the reason why it always appears as succesful
-    tx.signAndSend(creatorAddress, ({ events = [], status }) => {
-      if (status.isFinalized) {
-        this.updateState();
-        console.log('Successful transfer with hash ' + status.asFinalized.toHex());
-      } else {
-        console.log('Status of transfer: ' + status.type);
-      }
-
-      events.forEach(({ phase, event: { data, method, section } }) => {
-        console.log(phase.toString() + ' : ' + section + '.' + method + ' ' + data.toString());
-      });
-    });
+    this.queueExtrinsic({ extrinsic: tx, txSuccessCb: this.onTxSuccess, accountId });
   }
 
   protected async profile (id: MemberId): Promise<Option<Profile>> {
@@ -595,44 +539,59 @@ export class AdminController extends Controller<State, ITransport> {
   }
 }
 
+type AdminContainerProps = {
+  state: State;
+  controller: AdminController;
+}
+const AdminContainer = ({ state, controller }: AdminContainerProps) => {
+  const address = useMyAccount().state.address;
+  const containerRef = useRef<HTMLDivElement>(null);
+  return (
+    <div ref={containerRef}>
+      <Container className="admin">
+        <Card fluid color='orange'>
+          <Card.Content>
+            <Dropdown text='Create new opening...'>
+              <Dropdown.Menu>
+                {
+                  stockOpenings.map((value, key) => {
+                    return (
+                      <Dropdown.Item
+                        key={value.title}
+                        text={value.title}
+                        onClick={() => controller.showNewOpeningModal(value)}
+                      />
+                    );
+                  })
+                }
+              </Dropdown.Menu>
+            </Dropdown>
+            <Modal
+              open={state.modalOpen}
+              onClose={() => controller.closeModal()}
+              mountNode={containerRef.current} // Prevent conflicts with tx-modal (after form values reset issue is fixed, see FIXME: above)
+            >
+              <Modal.Content image>
+                <Modal.Description>
+                  <NewOpening desc={state.currentDescriptor} fn={(desc) => address && controller.newOpening(address, desc)} />
+                </Modal.Description>
+              </Modal.Content>
+            </Modal>
+          </Card.Content>
+        </Card>
+        {
+          [...state.openings.keys()].map(key => <OpeningView key={key} opening={state.openings.get(key) as opening} controller={controller} />)
+        }
+        <br />
+      </Container>
+    </div>
+  );
+};
+
 export const AdminView = View<AdminController, State>(
   (state, controller) => {
-    const address = useMyAccount().state.address as string;
     return (
-      <MyAccountProvider>
-        <Container className="admin">
-          <Card fluid color='orange'>
-            <Card.Content>
-              <Dropdown text='Create new opening...'>
-                <Dropdown.Menu>
-                  {
-                    stockOpenings.map((value, key) => {
-                      return (
-                        <Dropdown.Item
-                          key={value.title}
-                          text={value.title}
-                          onClick={() => controller.showNewOpeningModal(value)}
-                        />
-                      );
-                    })
-                  }
-                </Dropdown.Menu>
-              </Dropdown>
-              <Modal open={state.modalOpen} onClose={() => controller.closeModal()}>
-                <Modal.Content image>
-                  <Modal.Description>
-                    <NewOpening desc={state.currentDescriptor} fn={(desc) => controller.newOpening(address, desc)} />
-                  </Modal.Description>
-                </Modal.Content>
-              </Modal>
-            </Card.Content>
-          </Card>
-          {
-            [...state.openings.keys()].map(key => <OpeningView key={key} opening={state.openings.get(key) as opening} controller={controller} />)
-          }
-          <br />
-        </Container>
-      </MyAccountProvider>
+      <AdminContainer state={state} controller={controller} />
     );
   }
 );

+ 24 - 10
pioneer/packages/joy-utils/src/MemberProfilePreview.tsx

@@ -2,14 +2,17 @@ import React from 'react';
 import { Image } from 'semantic-ui-react';
 import { IdentityIcon } from '@polkadot/react-components';
 import { Link } from 'react-router-dom';
+import { Text } from '@polkadot/types';
+import { AccountId } from '@polkadot/types/interfaces';
+import { MemberId, Profile } from '@joystream/types/members';
 import styled from 'styled-components';
 
 type ProfileItemProps = {
-  avatar_uri: string;
-  root_account: string;
-  handle: string;
+  avatar_uri: string | Text;
+  root_account: string | AccountId;
+  handle: string | Text;
   link?: boolean;
-  id?: number;
+  id?: number | MemberId;
 };
 
 const StyledProfilePreview = styled.div`
@@ -41,21 +44,32 @@ const DetailsID = styled.div`
 export default function ProfilePreview ({ id, avatar_uri, root_account, handle, link = false }: ProfileItemProps) {
   const Preview = (
     <StyledProfilePreview>
-      {avatar_uri ? (
-        <Image src={avatar_uri} avatar floated="left" />
+      {avatar_uri.toString() ? (
+        <Image src={avatar_uri.toString()} avatar floated="left" />
       ) : (
-        <IdentityIcon className="image" value={root_account} size={40} />
+        <IdentityIcon className="image" value={root_account.toString()} size={40} />
       )}
       <Details>
-        <DetailsHandle>{handle}</DetailsHandle>
-        { id !== undefined && <DetailsID>ID: {id}</DetailsID> }
+        <DetailsHandle>{handle.toString()}</DetailsHandle>
+        { id !== undefined && <DetailsID>ID: {id.toString()}</DetailsID> }
       </Details>
     </StyledProfilePreview>
   );
 
   if (link) {
-    return <Link to={ `/members/${handle}` }>{ Preview }</Link>;
+    return <Link to={ `/members/${handle.toString()}` }>{ Preview }</Link>;
   }
 
   return Preview;
 }
+
+type ProfilePreviewFromStructProps = {
+  profile: Profile;
+  link?: boolean;
+  id?: number | MemberId;
+};
+
+export function ProfilePreviewFromStruct ({ profile, link, id }: ProfilePreviewFromStructProps) {
+  const { avatar_uri, root_account, handle } = profile;
+  return <ProfilePreview {...{ avatar_uri, root_account, handle, link, id }} />;
+}

+ 2 - 2
pioneer/packages/joy-utils/src/MyAccount.tsx

@@ -29,7 +29,7 @@ export type MyAccountProps = MyAddressProps & {
   memberIdsByControllerAccountId?: Vec<MemberId>;
   myMemberIdChecked?: boolean;
   iAmMember?: boolean;
-  memberProfile?: Option<any>;
+  memberProfile?: Option<Profile>;
 
   // Content Working Group
   curatorEntries?: any; // entire linked_map: CuratorId => Curator
@@ -134,7 +134,7 @@ function withMyRoles<P extends MyAccountProps> (Component: React.ComponentType<P
     const myCuratorIds: Array<CuratorId> = [];
 
     if (iAmMember && memberProfile && memberProfile.isSome) {
-      const profile = memberProfile.unwrap() as Profile;
+      const profile = memberProfile.unwrap();
       profile.roles.forEach(role => {
         if (role.isContentLead) {
           myContentLeadId = role.actor_id;

+ 57 - 1
pioneer/packages/joy-utils/src/consts/proposals.ts

@@ -1,6 +1,6 @@
 import { ProposalType, ProposalMeta } from '../types/proposals';
 
-const metadata: { [k in ProposalType]: ProposalMeta } = {
+export const metadata: { [k in ProposalType]: ProposalMeta } = {
   EvictStorageProvider: {
     description: 'Evicting Storage Provider Proposal',
     category: 'Storage',
@@ -81,7 +81,63 @@ const metadata: { [k in ProposalType]: ProposalMeta } = {
     approvalThreshold: 100,
     slashingQuorum: 60,
     slashingThreshold: 80
+  },
+  AddWorkingGroupLeaderOpening: {
+    description: 'Add Working Group Leader Opening Proposal',
+    category: 'Other',
+    stake: 100000,
+    approvalQuorum: 60,
+    approvalThreshold: 80,
+    slashingQuorum: 60,
+    slashingThreshold: 80
   }
 };
 
+type ProposalsApiMethodNames = {
+  votingPeriod: string;
+  gracePeriod: string;
+}
+export const apiMethods: { [k in ProposalType]: ProposalsApiMethodNames } = {
+  EvictStorageProvider: {
+    votingPeriod: 'evictStorageProviderProposalVotingPeriod',
+    gracePeriod: 'evictStorageProviderProposalPeriod'
+  },
+  Text: {
+    votingPeriod: 'textProposalVotingPeriod',
+    gracePeriod: 'textProposalGracePeriod'
+  },
+  SetStorageRoleParameters: {
+    votingPeriod: 'setStorageRoleParametersProposalVotingPeriod',
+    gracePeriod: 'setStorageRoleParametersProposalGracePeriod'
+  },
+  SetValidatorCount: {
+    votingPeriod: 'setValidatorCountProposalVotingPeriod',
+    gracePeriod: 'setValidatorCountProposalGracePeriod'
+  },
+  SetLead: {
+    votingPeriod: 'setLeadProposalVotingPeriod',
+    gracePeriod: 'setLeadProposalGracePeriod'
+  },
+  SetContentWorkingGroupMintCapacity: {
+    votingPeriod: 'setContentWorkingGroupMintCapacityProposalVotingPeriod',
+    gracePeriod: 'setContentWorkingGroupMintCapacityProposalGracePeriod'
+  },
+  Spending: {
+    votingPeriod: 'spendingProposalVotingPeriod',
+    gracePeriod: 'spendingProposalGracePeriod'
+  },
+  SetElectionParameters: {
+    votingPeriod: 'setElectionParametersProposalVotingPeriod',
+    gracePeriod: 'setElectionParametersProposalGracePeriod'
+  },
+  RuntimeUpgrade: {
+    votingPeriod: 'runtimeUpgradeProposalVotingPeriod',
+    gracePeriod: 'runtimeUpgradeProposalGracePeriod'
+  },
+  AddWorkingGroupLeaderOpening: {
+    votingPeriod: 'addWorkingGroupOpeningProposalVotingPeriod',
+    gracePeriod: 'addWorkingGroupOpeningProposalGracePeriod'
+  }
+} as const;
+
 export default metadata;

+ 4 - 0
pioneer/packages/joy-utils/src/consts/workingGroups.ts

@@ -0,0 +1,4 @@
+import { WorkingGroupKeys } from '@joystream/types/common';
+export const apiModuleByGroup: { [k in WorkingGroupKeys]: string } = {
+  Storage: 'storageWorkingGroup'
+};

+ 4 - 2
pioneer/packages/joy-utils/src/react/hooks/usePromise.tsx

@@ -1,6 +1,8 @@
 import { useState, useEffect, useCallback } from 'react';
 
-export default function usePromise<T> (promise: () => Promise<T>, defaultValue: T): [T, any, boolean, () => Promise<void|null>] {
+export type UsePromiseReturnValues<T> = [T, any, boolean, () => Promise<void|null>];
+
+export default function usePromise<T> (promise: () => Promise<T>, defaultValue: T, dependsOn: any[] = []): UsePromiseReturnValues<T> {
   const [state, setState] = useState<{
     value: T;
     error: any;
@@ -19,7 +21,7 @@ export default function usePromise<T> (promise: () => Promise<T>, defaultValue:
     return () => {
       isSubscribed = false;
     };
-  }, []);
+  }, dependsOn);
 
   const { value, error, isPending } = state;
   return [value, error, isPending, execute];

+ 3 - 0
pioneer/packages/joy-utils/src/transport/index.ts

@@ -6,6 +6,7 @@ import MembersTransport from './members';
 import CouncilTransport from './council';
 import StorageProvidersTransport from './storageProviders';
 import ValidatorsTransport from './validators';
+import WorkingGroupsTransport from './workingGroups';
 
 export default class Transport {
   protected api: ApiPromise;
@@ -17,6 +18,7 @@ export default class Transport {
   public contentWorkingGroup: ContentWorkingGroupTransport;
   public storageProviders: StorageProvidersTransport;
   public validators: ValidatorsTransport;
+  public workingGroups: WorkingGroupsTransport;
 
   constructor (api: ApiPromise) {
     this.api = api;
@@ -27,5 +29,6 @@ export default class Transport {
     this.council = new CouncilTransport(api, this.members, this.chain);
     this.contentWorkingGroup = new ContentWorkingGroupTransport(api, this.members);
     this.proposals = new ProposalsTransport(api, this.members, this.chain, this.council);
+    this.workingGroups = new WorkingGroupsTransport(api, this.members);
   }
 }

+ 5 - 0
pioneer/packages/joy-utils/src/transport/members.ts

@@ -7,6 +7,11 @@ export default class MembersTransport extends BaseTransport {
     return this.members.memberProfile(id) as Promise<Option<Profile>>;
   }
 
+  // Throws if profile not found
+  async expectedMemberProfile (id: MemberId | number): Promise<Profile> {
+    return (await this.memberProfile(id)).unwrap();
+  }
+
   async membersCreated (): Promise<number> {
     return (await this.members.membersCreated() as MemberId).toNumber();
   }

+ 10 - 28
pioneer/packages/joy-utils/src/transport/proposals.ts

@@ -18,9 +18,9 @@ import { MemberId } from '@joystream/types/members';
 import { u32, u64 } from '@polkadot/types/';
 import { BalanceOf } from '@polkadot/types/interfaces';
 
-import { includeKeys, bytesToString } from '../functions/misc';
+import { bytesToString } from '../functions/misc';
 import _ from 'lodash';
-import proposalsConsts from '../consts/proposals';
+import { metadata as proposalsConsts, apiMethods as proposalsApiMethods } from '../consts/proposals';
 import { FIRST_MEMBER_ID } from '../consts/members';
 
 import { ApiPromise } from '@polkadot/api';
@@ -153,33 +153,15 @@ export default class ProposalsTransport extends BaseTransport {
     };
   }
 
-  async fetchProposalMethodsFromCodex (includeKey: string) {
-    const methods = includeKeys(this.proposalsCodex, includeKey);
-    // methods = [proposalTypeVotingPeriod...]
-    return methods.reduce(async (prevProm, method) => {
-      const obj = await prevProm;
-      const period = (await this.proposalsCodex[method]()) as u32;
-      // setValidatorCountProposalVotingPeriod to SetValidatorCount
-      const key = _.words(_.startCase(method))
-        .slice(0, -3)
-        .map((w, i) => (i === 0 ? w.slice(0, 1).toUpperCase() + w.slice(1) : w))
-        .join('') as ProposalType;
-
-      return { ...obj, [`${key}`]: period.toNumber() };
-    }, Promise.resolve({}) as Promise<{ [k in ProposalType]: number }>);
-  }
-
-  async proposalTypesGracePeriod (): Promise<{ [k in ProposalType]: number }> {
-    return this.fetchProposalMethodsFromCodex('GracePeriod');
-  }
-
-  async proposalTypesVotingPeriod (): Promise<{ [k in ProposalType]: number }> {
-    return this.fetchProposalMethodsFromCodex('VotingPeriod');
-  }
-
   async parametersFromProposalType (type: ProposalType) {
-    const votingPeriod = (await this.proposalTypesVotingPeriod())[type];
-    const gracePeriod = (await this.proposalTypesGracePeriod())[type];
+    const { votingPeriod: votingPeriodMethod, gracePeriod: gracePeriodMethod } = proposalsApiMethods[type];
+    // TODO: Remove the fallback after outdated proposals are removed
+    const votingPeriod = this.proposalsCodex[votingPeriodMethod]
+      ? ((await this.proposalsCodex[votingPeriodMethod]()) as u32).toNumber()
+      : 0;
+    const gracePeriod = this.proposalsCodex[gracePeriodMethod]
+      ? ((await this.proposalsCodex[gracePeriodMethod]()) as u32).toNumber()
+      : 0;
     // Currently it's same for all types, but this will change soon
     const cancellationFee = this.cancellationFee();
     return {

+ 47 - 0
pioneer/packages/joy-utils/src/transport/workingGroups.ts

@@ -0,0 +1,47 @@
+import { Option } from '@polkadot/types/';
+import BaseTransport from './base';
+import { ApiPromise } from '@polkadot/api';
+import MembersTransport from './members';
+import { SingleLinkedMapEntry } from '../index';
+import { Worker, WorkerId } from '@joystream/types/working-group';
+import { apiModuleByGroup } from '../consts/workingGroups';
+import { WorkingGroupKeys } from '@joystream/types/common';
+import { LeadWithProfile } from '../types/workingGroups';
+
+export default class WorkingGroupsTransport extends BaseTransport {
+  private membersT: MembersTransport;
+
+  constructor (api: ApiPromise, membersTransport: MembersTransport) {
+    super(api);
+    this.membersT = membersTransport;
+  }
+
+  protected queryByGroup (group: WorkingGroupKeys) {
+    const module = apiModuleByGroup[group];
+    return this.api.query[module];
+  }
+
+  public async currentLead (group: WorkingGroupKeys): Promise <LeadWithProfile | null> {
+    const optLeadId = (await this.queryByGroup(group).currentLead()) as Option<WorkerId>;
+
+    if (!optLeadId.isSome) {
+      return null;
+    }
+
+    const leadWorkerId = optLeadId.unwrap();
+    const leadWorkerLink = new SingleLinkedMapEntry(
+      Worker,
+      await this.queryByGroup(group).workerById(leadWorkerId)
+    );
+    const leadWorker = leadWorkerLink.value;
+
+    if (!leadWorker.is_active) {
+      return null;
+    }
+
+    return {
+      worker: leadWorker,
+      profile: await this.membersT.expectedMemberProfile(leadWorker.member_id)
+    };
+  }
+}

+ 1 - 0
pioneer/packages/joy-utils/src/types/common.ts

@@ -0,0 +1 @@
+export type SimplifiedTypeInterface<I> = Partial<{ [k in keyof I]: any }>;

+ 2 - 1
pioneer/packages/joy-utils/src/types/proposals.ts

@@ -12,7 +12,8 @@ export const ProposalTypes = [
   'SetContentWorkingGroupMintCapacity',
   'EvictStorageProvider',
   'SetValidatorCount',
-  'SetStorageRoleParameters'
+  'SetStorageRoleParameters',
+  'AddWorkingGroupLeaderOpening'
 ] as const;
 
 export type ProposalType = typeof ProposalTypes[number];

+ 7 - 0
pioneer/packages/joy-utils/src/types/workingGroups.ts

@@ -0,0 +1,7 @@
+import { Worker } from '@joystream/types/working-group';
+import { Profile } from '@joystream/types/members';
+
+export type LeadWithProfile = {
+  worker: Worker;
+  profile: Profile;
+};

+ 3 - 0
runtime/src/migration.rs

@@ -25,12 +25,15 @@ impl<T: Trait> Module<T> {
         Self::initialize_storage_working_group_mint();
         Self::initialize_storage_working_group_text_constraints();
         Self::clear_storage_data();
+
+        proposals_codex::Module::<T>::set_default_config_values();
     }
 }
 
 pub trait Trait:
     system::Trait
     + minting::Trait
+    + proposals_codex::Trait
     + working_group::Trait<working_group::Instance2>
     + storage::data_directory::Trait
     + storage::data_object_storage_registry::Trait

+ 4 - 2
storage-node/.gitignore

@@ -1,6 +1,6 @@
 build/
 coverage/
-dist
+dist/
 tmp/
 .DS_Store
 
@@ -26,4 +26,6 @@ node_modules/
 # Ignore nvm config file
 .nvmrc
 
-yarn.lock
+yarn.lock
+
+*.tsbuildinfo

+ 4 - 0
storage-node/package.json

@@ -33,16 +33,20 @@
   "scripts": {
     "test": "wsrun --serial test",
     "lint": "eslint --ignore-path .gitignore .",
+    "build": "yarn workspace @joystream/storage-cli run build",
     "checks": "yarn lint && prettier . --check",
     "format": "prettier ./ --write"
   },
   "devDependencies": {
+    "@types/chai": "^4.2.11",
+    "@types/mocha": "^7.0.2",
     "eslint": "^5.16.0",
     "eslint-config-esnext": "^4.1.0",
     "eslint-config-prettier": "^6.11.0",
     "eslint-plugin-babel": "^5.3.1",
     "eslint-plugin-prettier": "^3.1.4",
     "prettier": "^2.0.5",
+    "typescript": "^3.9.6",
     "wsrun": "^3.6.5"
   }
 }

+ 4 - 0
storage-node/packages/cli/.eslintignore

@@ -0,0 +1,4 @@
+**/build/*
+**/dist/*
+**/coverage/*
+**/node_modules/*

+ 2 - 240
storage-node/packages/cli/bin/cli.js

@@ -1,251 +1,13 @@
 #!/usr/bin/env node
-/*
- * This file is part of the storage node for the Joystream project.
- * Copyright (C) 2019 Joystream Contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
 
-'use strict'
-
-const fs = require('fs')
-const assert = require('assert')
-const { RuntimeApi } = require('@joystream/storage-runtime-api')
-const meow = require('meow')
 const chalk = require('chalk')
-const _ = require('lodash')
-const debug = require('debug')('joystream:storage-cli')
-const dev = require('./dev')
-
-// Parse CLI
-const FLAG_DEFINITIONS = {
-  // TODO
-}
-
-const cli = meow(
-  `
-  Usage:
-    $ storage-cli command [arguments..] [key_file] [passphrase]
-
-  Some commands require a key file as the last option holding the identity for
-  interacting with the runtime API.
-
-  Commands:
-    upload            Upload a file to a Colossus storage node. Requires a
-                      storage node URL, and a local file name to upload. As
-                      an optional third parameter, you can provide a Data
-                      Object Type ID - this defaults to "1" if not provided.
-    download          Retrieve a file. Requires a storage node URL and a content
-                      ID, as well as an output filename.
-    head              Send a HEAD request for a file, and print headers.
-                      Requires a storage node URL and a content ID.
-
-  Dev Commands:       Commands to run on a development chain.
-    dev-init          Setup chain with Alice as lead and storage provider.
-    dev-check         Check the chain is setup with Alice as lead and storage provider.
-  `,
-  { flags: FLAG_DEFINITIONS }
-)
-
-function assertFile(name, filename) {
-  assert(filename, `Need a ${name} parameter to proceed!`)
-  assert(fs.statSync(filename).isFile(), `Path "${filename}" is not a file, aborting!`)
-}
-
-function loadIdentity(api, filename, passphrase) {
-  if (filename) {
-    assertFile('keyfile', filename)
-    api.identities.loadUnlock(filename, passphrase)
-  } else {
-    debug('Loading Alice as identity')
-    api.identities.useKeyPair(dev.aliceKeyPair(api))
-  }
-}
-
-const commands = {
-  // add Alice well known account as storage provider
-  'dev-init': async api => {
-    // dev accounts are automatically loaded, no need to add explicitly to keyring using loadIdentity(api)
-    const dev = require('./dev')
-    return dev.init(api)
-  },
-  // Checks that the setup done by dev-init command was successful.
-  'dev-check': async api => {
-    // dev accounts are automatically loaded, no need to add explicitly to keyring using loadIdentity(api)
-    const dev = require('./dev')
-    return dev.check(api)
-  },
-  // The upload method is not correctly implemented
-  // needs to get the liaison after creating a data object,
-  // resolve the ipns id to the asset put api url of the storage-node
-  // before uploading..
-  upload: async (api, url, filename, doTypeId, keyfile, passphrase) => {
-    loadIdentity(keyfile, passphrase)
-    // Check parameters
-    assertFile('file', filename)
-
-    const size = fs.statSync(filename).size
-    debug(`File "${filename}" is ${chalk.green(size)} Bytes.`)
-
-    if (!doTypeId) {
-      doTypeId = 1
-    }
-
-    debug('Data Object Type ID is: ' + chalk.green(doTypeId))
-
-    // Generate content ID
-    // FIXME this require path is like this because of
-    // https://github.com/Joystream/apps/issues/207
-    const { ContentId } = require('@joystream/types/media')
-    let cid = ContentId.generate()
-    cid = cid.encode().toString()
-    debug('Generated content ID: ' + chalk.green(cid))
-
-    // Create Data Object
-    await api.assets.createDataObject(api.identities.key.address, cid, doTypeId, size)
-    debug('Data object created.')
-
-    // TODO in future, optionally contact liaison here?
-    const request = require('request')
-    url = `${url}asset/v0/${cid}`
-    debug('Uploading to URL', chalk.green(url))
-
-    const f = fs.createReadStream(filename)
-    const opts = {
-      url,
-      headers: {
-        'content-type': '',
-        'content-length': `${size}`,
-      },
-      json: true,
-    }
-    return new Promise((resolve, reject) => {
-      const r = request.put(opts, (error, response, body) => {
-        if (error) {
-          reject(error)
-          return
-        }
-
-        if (response.statusCode / 100 !== 2) {
-          reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`))
-          return
-        }
-        debug('Upload successful:', body.message)
-        resolve()
-      })
-      f.pipe(r)
-    })
-  },
-  // needs to be updated to take a content id and resolve it a potential set
-  // of providers that has it, and select one (possibly try more than one provider)
-  // to fetch it from the get api url of a provider..
-  download: async (api, url, contentId, filename) => {
-    const request = require('request')
-    url = `${url}asset/v0/${contentId}`
-    debug('Downloading URL', chalk.green(url), 'to', chalk.green(filename))
-
-    const f = fs.createWriteStream(filename)
-    const opts = {
-      url,
-      json: true,
-    }
-    return new Promise((resolve, reject) => {
-      const r = request.get(opts, (error, response, body) => {
-        if (error) {
-          reject(error)
-          return
-        }
-
-        debug(
-          'Downloading',
-          chalk.green(response.headers['content-type']),
-          'of size',
-          chalk.green(response.headers['content-length']),
-          '...'
-        )
-
-        f.on('error', err => {
-          reject(err)
-        })
-
-        f.on('finish', () => {
-          if (response.statusCode / 100 !== 2) {
-            reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`))
-            return
-          }
-          debug('Download completed.')
-          resolve()
-        })
-      })
-      r.pipe(f)
-    })
-  },
-  // similar to 'download' function
-  head: async (api, url, contentId) => {
-    const request = require('request')
-    url = `${url}asset/v0/${contentId}`
-    debug('Checking URL', chalk.green(url), '...')
-
-    const opts = {
-      url,
-      json: true,
-    }
-    return new Promise((resolve, reject) => {
-      request.head(opts, (error, response, body) => {
-        if (error) {
-          reject(error)
-          return
-        }
-
-        if (response.statusCode / 100 !== 2) {
-          reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`))
-          return
-        }
-
-        for (const propname in response.headers) {
-          debug(`  ${chalk.yellow(propname)}: ${response.headers[propname]}`)
-        }
-
-        resolve()
-      })
-    })
-  },
-}
-
-async function main() {
-  const api = await RuntimeApi.create()
-
-  // Simple CLI commands
-  const command = cli.input[0]
-  if (!command) {
-    throw new Error('Need a command to run!')
-  }
-
-  if (Object.prototype.hasOwnProperty.call(commands, command)) {
-    // Command recognized
-    const args = _.clone(cli.input).slice(1)
-    await commands[command](api, ...args)
-  } else {
-    throw new Error(`Command "${command}" not recognized, aborting!`)
-  }
-}
+const { main } = require('../dist/cli')
 
 main()
   .then(() => {
     process.exit(0)
   })
-  .catch(err => {
+  .catch((err) => {
     console.error(chalk.red(err.stack))
     process.exit(-1)
   })

+ 9 - 4
storage-node/packages/cli/package.json

@@ -27,11 +27,12 @@
     "node": ">=10.15.3"
   },
   "scripts": {
-    "test": "mocha 'test/**/*.js'",
-    "lint": "eslint 'paths/**/*.js' 'lib/**/*.js'"
+    "test": "mocha 'dist/test/**/*.js'",
+    "lint": "eslint --ext .ts,.tsx . && tsc --noEmit --pretty",
+    "build": "tsc --build"
   },
   "bin": {
-    "storage-cli": "bin/cli.js"
+    "storage-cli": "./bin/cli.js"
   },
   "devDependencies": {
     "chai": "^4.2.0",
@@ -41,9 +42,13 @@
   },
   "dependencies": {
     "@joystream/storage-runtime-api": "^0.1.0",
+    "@joystream/service-discovery": "^0.1.0",
+    "@joystream/storage-utils": "^0.1.0",
+    "@joystream/types": "^0.11.0",
+    "axios": "^0.19.2",
     "chalk": "^2.4.2",
     "lodash": "^4.17.11",
     "meow": "^5.0.0",
-    "request": "^2.88.0"
+    "ipfs-only-hash": "^1.0.2"
   }
 }

+ 123 - 0
storage-node/packages/cli/src/cli.ts

@@ -0,0 +1,123 @@
+/*
+ * This file is part of the storage node for the Joystream project.
+ * Copyright (C) 2019 Joystream Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+'use strict'
+
+import { RuntimeApi } from '@joystream/storage-runtime-api'
+import meow from 'meow'
+import _ from 'lodash'
+
+// Commands
+import * as dev from './commands/dev'
+import { HeadCommand } from './commands/head'
+import { DownloadCommand } from './commands/download'
+import { UploadCommand } from './commands/upload'
+
+// Parse CLI
+const FLAG_DEFINITIONS = {
+  // TODO: current version of meow doesn't support subcommands. We should consider a migration to yargs or oclif.
+}
+
+const usage = `
+  Usage:
+    $ storage-cli command [arguments..]
+
+  Commands:
+    upload            Upload a file to the Joystream Network. Requires a
+                      source file path to upload, data object ID, member ID and account key file with
+                      pass phrase to unlock it.
+    download          Retrieve a file. Requires a storage node URL and a content
+                      ID, as well as an output filename.
+    head              Send a HEAD request for a file, and print headers.
+                      Requires a storage node URL and a content ID.
+
+  Dev Commands:       Commands to run on a development chain.
+    dev-init          Setup chain with Alice as lead and storage provider.
+    dev-check         Check the chain is setup with Alice as lead and storage provider.
+    
+  Type 'storage-cli command' for the exact command usage examples.
+  `
+
+const cli = meow(usage, { flags: FLAG_DEFINITIONS })
+
+// Shows a message, CLI general usage and exits.
+function showUsageAndExit(message: string) {
+  console.log(message)
+  console.log(usage)
+  process.exit(1)
+}
+
+const commands = {
+  // add Alice well known account as storage provider
+  'dev-init': async (api) => {
+    // dev accounts are automatically loaded, no need to add explicitly to keyring using loadIdentity(api)
+    return dev.init(api)
+  },
+  // Checks that the setup done by dev-init command was successful.
+  'dev-check': async (api) => {
+    // dev accounts are automatically loaded, no need to add explicitly to keyring using loadIdentity(api)
+    return dev.check(api)
+  },
+  // Uploads the file to the system. Registers new data object in the runtime, obtains proper colossus instance URL.
+  upload: async (
+    api: any,
+    filePath: string,
+    dataObjectTypeId: string,
+    keyFile: string,
+    passPhrase: string,
+    memberId: string
+  ) => {
+    const uploadCmd = new UploadCommand(api, filePath, dataObjectTypeId, keyFile, passPhrase, memberId)
+
+    await uploadCmd.run()
+  },
+  // needs to be updated to take a content id and resolve it a potential set
+  // of providers that has it, and select one (possibly try more than one provider)
+  // to fetch it from the get api url of a provider..
+  download: async (api: any, url: string, contentId: string, filePath: string) => {
+    const downloadCmd = new DownloadCommand(api, url, contentId, filePath)
+
+    await downloadCmd.run()
+  },
+  // Shows asset information derived from response headers.
+  // Accepts colossus URL and content ID.
+  head: async (api: any, storageNodeUrl: string, contentId: string) => {
+    const headCmd = new HeadCommand(api, storageNodeUrl, contentId)
+
+    await headCmd.run()
+  },
+}
+
+// Entry point.
+export async function main() {
+  const api = await RuntimeApi.create()
+
+  // Simple CLI commands
+  const command = cli.input[0]
+  if (!command) {
+    showUsageAndExit('Enter the command, please.')
+  }
+
+  if (Object.prototype.hasOwnProperty.call(commands, command)) {
+    // Command recognized
+    const args = _.clone(cli.input).slice(1)
+    await commands[command](api, ...args)
+  } else {
+    showUsageAndExit(`Command "${command}" not recognized.`)
+  }
+}

+ 48 - 0
storage-node/packages/cli/src/commands/base.ts

@@ -0,0 +1,48 @@
+import chalk from 'chalk'
+import removeEndingForwardSlash from '@joystream/storage-utils/stripEndingSlash'
+import { ContentId } from '@joystream/types/media'
+
+// Commands base abstract class. Contains reusable methods.
+export abstract class BaseCommand {
+  // Creates the Colossus asset URL and logs it.
+  protected createAndLogAssetUrl(url: string, contentId: string | ContentId): string {
+    let normalizedContentId: string
+
+    if (typeof contentId === 'string') {
+      normalizedContentId = contentId
+    } else {
+      normalizedContentId = contentId.encode()
+    }
+
+    const normalizedUrl = removeEndingForwardSlash(url)
+    const assetUrl = `${normalizedUrl}/asset/v0/${normalizedContentId}`
+    console.log(chalk.yellow('Generated asset URL:', assetUrl))
+
+    return assetUrl
+  }
+
+  // Abstract method to provide parameter validation.
+  protected abstract validateParameters(): boolean
+
+  // Abstract method to show command usage.
+  protected abstract showUsage()
+
+  // Checks command parameters and shows the usage if necessary.
+  protected assertParameters(): boolean {
+    // Create, validate and show parameters.
+    if (!this.validateParameters()) {
+      console.log(chalk.yellow(`Invalid parameters for the command:`))
+      this.showUsage()
+
+      return false
+    }
+
+    return true
+  }
+
+  // Shows the error message and ends the process with error code.
+  protected fail(message: string) {
+    console.log(chalk.red(message))
+    process.exit(1)
+  }
+}

+ 4 - 2
storage-node/packages/cli/bin/dev.js → storage-node/packages/cli/src/commands/dev.ts

@@ -19,7 +19,7 @@ function developmentPort() {
   return 3001
 }
 
-const check = async api => {
+const check = async (api) => {
   const roleAccountId = roleKeyPair(api).address
   const providerId = await api.workers.findProviderIdByRoleAccount(roleAccountId)
 
@@ -40,7 +40,7 @@ const check = async api => {
 // Setup Alice account on a developement chain as
 // a member, storage lead, and a storage provider using a deterministic
 // development key for the role account
-const init = async api => {
+const init = async (api) => {
   try {
     await check(api)
     return
@@ -123,3 +123,5 @@ module.exports = {
   roleKeyPair,
   developmentPort,
 }
+
+export { init, check, aliceKeyPair, roleKeyPair, developmentPort }

+ 77 - 0
storage-node/packages/cli/src/commands/download.ts

@@ -0,0 +1,77 @@
+import axios from 'axios'
+import chalk from 'chalk'
+import fs from 'fs'
+import { BaseCommand } from './base'
+
+// Download command class. Validates input parameters and execute the logic for asset downloading.
+export class DownloadCommand extends BaseCommand {
+  private readonly api: any
+  private readonly storageNodeUrl: string
+  private readonly contentId: string
+  private readonly outputFilePath: string
+
+  constructor(api: any, storageNodeUrl: string, contentId: string, outputFilePath: string) {
+    super()
+
+    this.api = api
+    this.storageNodeUrl = storageNodeUrl
+    this.contentId = contentId
+    this.outputFilePath = outputFilePath
+  }
+
+  // Provides parameter validation. Overrides the abstract method from the base class.
+  protected validateParameters(): boolean {
+    return (
+      this.storageNodeUrl &&
+      this.storageNodeUrl !== '' &&
+      this.contentId &&
+      this.contentId !== '' &&
+      this.outputFilePath &&
+      this.outputFilePath !== ''
+    )
+  }
+
+  // Shows command usage. Overrides the abstract method from the base class.
+  protected showUsage() {
+    console.log(
+      chalk.yellow(`
+        Usage:   storage-cli download colossusURL contentID filePath
+        Example: storage-cli download http://localhost:3001 0x7a6ba7e9157e5fba190dc146fe1baa8180e29728a5c76779ed99655500cff795 ./movie.mp4
+      `)
+    )
+  }
+
+  // Command executor.
+  async run() {
+    // Checks for input parameters, shows usage if they are invalid.
+    if (!this.assertParameters()) return
+
+    const assetUrl = this.createAndLogAssetUrl(this.storageNodeUrl, this.contentId)
+    console.log(chalk.yellow('File path:', this.outputFilePath))
+
+    // Create file write stream and set error handler.
+    const writer = fs.createWriteStream(this.outputFilePath).on('error', (err) => {
+      this.fail(`File write failed: ${err}`)
+    })
+
+    // Request file download.
+    try {
+      const response = await axios({
+        url: assetUrl,
+        method: 'GET',
+        responseType: 'stream',
+      })
+
+      response.data.pipe(writer)
+
+      return new Promise((resolve) => {
+        writer.on('finish', () => {
+          console.log('File downloaded.')
+          resolve()
+        })
+      })
+    } catch (err) {
+      this.fail(`Colossus request failed: ${err.message}`)
+    }
+  }
+}

+ 50 - 0
storage-node/packages/cli/src/commands/head.ts

@@ -0,0 +1,50 @@
+import axios from 'axios'
+import chalk from 'chalk'
+import { BaseCommand } from './base'
+
+// Head command class. Validates input parameters and obtains the asset headers.
+export class HeadCommand extends BaseCommand {
+  private readonly api: any
+  private readonly storageNodeUrl: string
+  private readonly contentId: string
+
+  constructor(api: any, storageNodeUrl: string, contentId: string) {
+    super()
+
+    this.api = api
+    this.storageNodeUrl = storageNodeUrl
+    this.contentId = contentId
+  }
+
+  // Provides parameter validation. Overrides the abstract method from the base class.
+  protected validateParameters(): boolean {
+    return this.storageNodeUrl && this.storageNodeUrl !== '' && this.contentId && this.contentId !== ''
+  }
+
+  // Shows command usage. Overrides the abstract method from the base class.
+  protected showUsage() {
+    console.log(
+      chalk.yellow(`
+        Usage:   storage-cli head colossusURL contentID
+        Example: storage-cli head http://localhost:3001 0x7a6ba7e9157e5fba190dc146fe1baa8180e29728a5c76779ed99655500cff795
+      `)
+    )
+  }
+
+  // Command executor.
+  async run() {
+    // Checks for input parameters, shows usage if they are invalid.
+    if (!this.assertParameters()) return
+
+    const assetUrl = this.createAndLogAssetUrl(this.storageNodeUrl, this.contentId)
+
+    try {
+      const response = await axios.head(assetUrl)
+
+      console.log(chalk.green(`Content type: ${response.headers['content-type']}`))
+      console.log(chalk.green(`Content length: ${response.headers['content-length']}`))
+    } catch (err) {
+      this.fail(`Colossus request failed: ${err.message}`)
+    }
+  }
+}

+ 220 - 0
storage-node/packages/cli/src/commands/upload.ts

@@ -0,0 +1,220 @@
+import axios, { AxiosRequestConfig } from 'axios'
+import fs from 'fs'
+import ipfsHash from 'ipfs-only-hash'
+import { ContentId, DataObject } from '@joystream/types/media'
+import BN from 'bn.js'
+import { Option } from '@polkadot/types/codec'
+import { BaseCommand } from './base'
+import { discover } from '@joystream/service-discovery/discover'
+import Debug from 'debug'
+import chalk from 'chalk'
+import { aliceKeyPair } from './dev'
+const debug = Debug('joystream:storage-cli:upload')
+
+// Defines maximum content length for the assets (files). Limits the upload.
+const MAX_CONTENT_LENGTH = 500 * 1024 * 1024 // 500Mb
+
+// Defines the necessary parameters for the AddContent runtime tx.
+interface AddContentParams {
+  accountId: string
+  ipfsCid: string
+  contentId: ContentId
+  fileSize: BN
+  dataObjectTypeId: number
+  memberId: number
+}
+
+// Upload command class. Validates input parameters and uploads the asset to the storage node and runtime.
+export class UploadCommand extends BaseCommand {
+  private readonly api: any
+  private readonly mediaSourceFilePath: string
+  private readonly dataObjectTypeId: string
+  private readonly keyFile: string
+  private readonly passPhrase: string
+  private readonly memberId: string
+
+  constructor(
+    api: any,
+    mediaSourceFilePath: string,
+    dataObjectTypeId: string,
+    memberId: string,
+    keyFile: string,
+    passPhrase: string
+  ) {
+    super()
+
+    this.api = api
+    this.mediaSourceFilePath = mediaSourceFilePath
+    this.dataObjectTypeId = dataObjectTypeId
+    this.memberId = memberId
+    this.keyFile = keyFile
+    this.passPhrase = passPhrase
+  }
+
+  // Provides parameter validation. Overrides the abstract method from the base class.
+  protected validateParameters(): boolean {
+    return (
+      this.mediaSourceFilePath &&
+      this.mediaSourceFilePath !== '' &&
+      this.dataObjectTypeId &&
+      this.dataObjectTypeId !== '' &&
+      this.memberId &&
+      this.memberId !== ''
+    )
+  }
+
+  // Reads the file from the filesystem and computes IPFS hash.
+  private async computeIpfsHash(): Promise<string> {
+    const file = fs.createReadStream(this.mediaSourceFilePath).on('error', (err) => {
+      this.fail(`File read failed: ${err}`)
+    })
+
+    return await ipfsHash.of(file)
+  }
+
+  // Read the file size from the file system.
+  private getFileSize(): number {
+    const stats = fs.statSync(this.mediaSourceFilePath)
+    return stats.size
+  }
+
+  // Creates parameters for the AddContent runtime tx.
+  private async getAddContentParams(): Promise<AddContentParams> {
+    const identity = await this.loadIdentity()
+    const accountId = identity.address
+
+    const dataObjectTypeId: number = parseInt(this.dataObjectTypeId)
+    if (isNaN(dataObjectTypeId)) {
+      this.fail(`Cannot parse dataObjectTypeId: ${this.dataObjectTypeId}`)
+    }
+
+    const memberId: number = parseInt(this.memberId)
+    if (isNaN(dataObjectTypeId)) {
+      this.fail(`Cannot parse memberIdString: ${this.memberId}`)
+    }
+
+    return {
+      accountId,
+      ipfsCid: await this.computeIpfsHash(),
+      contentId: ContentId.generate(),
+      fileSize: new BN(this.getFileSize()),
+      dataObjectTypeId,
+      memberId,
+    }
+  }
+
+  // Creates the DataObject in the runtime.
+  private async createContent(p: AddContentParams): Promise<DataObject> {
+    try {
+      const dataObject: Option<DataObject> = await this.api.assets.createDataObject(
+        p.accountId,
+        p.memberId,
+        p.contentId,
+        p.dataObjectTypeId,
+        p.fileSize,
+        p.ipfsCid
+      )
+
+      if (dataObject.isNone) {
+        this.fail('Cannot create data object: got None object')
+      }
+
+      return dataObject.unwrap()
+    } catch (err) {
+      this.fail(`Cannot create data object: ${err}`)
+    }
+  }
+
+  // Uploads file to given asset URL.
+  private async uploadFile(assetUrl: string) {
+    // Create file read stream and set error handler.
+    const file = fs.createReadStream(this.mediaSourceFilePath).on('error', (err) => {
+      this.fail(`File read failed: ${err}`)
+    })
+
+    // Upload file from the stream.
+    try {
+      const fileSize = this.getFileSize()
+      const config: AxiosRequestConfig = {
+        headers: {
+          'Content-Type': '', // https://github.com/Joystream/storage-node-joystream/issues/16
+          'Content-Length': fileSize.toString(),
+        },
+        maxContentLength: MAX_CONTENT_LENGTH,
+      }
+      await axios.put(assetUrl, file, config)
+
+      console.log('File uploaded.')
+    } catch (err) {
+      this.fail(err.toString())
+    }
+  }
+
+  // Requests the runtime and obtains the storage node endpoint URL.
+  private async discoverStorageProviderEndpoint(storageProviderId: string): Promise<string> {
+    try {
+      const serviceInfo = await discover(storageProviderId, this.api)
+
+      if (serviceInfo === null) {
+        this.fail('Storage node discovery failed.')
+      }
+      debug(`Discovered service info object: ${serviceInfo}`)
+
+      const dataWrapper = JSON.parse(serviceInfo)
+      const assetWrapper = JSON.parse(dataWrapper.serialized)
+
+      return assetWrapper.asset.endpoint
+    } catch (err) {
+      this.fail(`Could not get asset endpoint: ${err}`)
+    }
+  }
+
+  // Loads and unlocks the runtime identity using the key file and pass phrase.
+  private async loadIdentity(): Promise<any> {
+    const noKeyFileProvided = !this.keyFile || this.keyFile === ''
+    const useAlice = noKeyFileProvided && (await this.api.system.isDevelopmentChain())
+
+    if (useAlice) {
+      debug("Discovered 'development' chain.")
+      return aliceKeyPair(this.api)
+    }
+
+    try {
+      await fs.promises.access(this.keyFile)
+    } catch (error) {
+      this.fail(`Cannot read file "${this.keyFile}".`)
+    }
+
+    return this.api.identities.loadUnlock(this.keyFile, this.passPhrase)
+  }
+
+  // Shows command usage. Overrides the abstract method from the base class.
+  protected showUsage() {
+    console.log(
+      chalk.yellow(`
+        Usage:       storage-cli upload mediaSourceFilePath dataObjectTypeId memberId [keyFilePath] [passPhrase]
+        Example:     storage-cli upload ./movie.mp4 1 1 ./keyFile.json secretPhrase
+        Development: storage-cli upload ./movie.mp4 1 0
+      `)
+    )
+  }
+
+  // Command executor.
+  async run() {
+    // Checks for input parameters, shows usage if they are invalid.
+    if (!this.assertParameters()) return
+
+    const addContentParams = await this.getAddContentParams()
+    debug(`AddContent Tx params: ${JSON.stringify(addContentParams)}`)
+    debug(`Decoded CID: ${addContentParams.contentId.toString()}`)
+
+    const dataObject = await this.createContent(addContentParams)
+    debug(`Received data object: ${dataObject.toString()}`)
+
+    const colossusEndpoint = await this.discoverStorageProviderEndpoint(dataObject.liaison.toString())
+    debug(`Discovered storage node endpoint: ${colossusEndpoint}`)
+
+    const assetUrl = this.createAndLogAssetUrl(colossusEndpoint, addContentParams.contentId)
+    await this.uploadFile(assetUrl)
+  }
+}

+ 0 - 0
storage-node/packages/cli/test/index.js → storage-node/packages/cli/src/test/index.ts


+ 11 - 0
storage-node/packages/cli/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "include": [
+    "src"
+  ],
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src",
+    "baseUrl": "."
+  }
+}

+ 8 - 8
storage-node/packages/colossus/bin/cli.js

@@ -29,14 +29,14 @@ const FLAG_DEFINITIONS = {
   },
   keyFile: {
     type: 'string',
-    isRequired: flags => {
+    isRequired: (flags) => {
       return !flags.dev
     },
   },
   publicUrl: {
     type: 'string',
     alias: 'u',
-    isRequired: flags => {
+    isRequired: (flags) => {
       return !flags.dev
     },
   },
@@ -50,7 +50,7 @@ const FLAG_DEFINITIONS = {
   providerId: {
     type: 'number',
     alias: 'i',
-    isRequired: flags => {
+    isRequired: (flags) => {
       return !flags.dev
     },
   },
@@ -122,7 +122,7 @@ function getStorage(runtimeApi) {
   const { Storage } = require('@joystream/storage-node-backend')
 
   const options = {
-    resolve_content_id: async contentId => {
+    resolve_content_id: async (contentId) => {
       // Resolve via API
       const obj = await runtimeApi.assets.getDataObject(contentId)
       if (!obj || obj.isNone) {
@@ -176,7 +176,7 @@ async function initApiDevelopment() {
     provider_url: wsProvider,
   })
 
-  const dev = require('../../cli/bin/dev')
+  const dev = require('../../cli/dist/commands/dev')
 
   api.identities.useKeyPair(dev.roleKeyPair(api))
 
@@ -201,7 +201,7 @@ function getServiceInformation(publicUrl) {
 
 async function announcePublicUrl(api, publicUrl) {
   // re-announce in future
-  const reannounce = function(timeoutMs) {
+  const reannounce = function (timeoutMs) {
     setTimeout(announcePublicUrl, timeoutMs, api, publicUrl)
   }
 
@@ -253,7 +253,7 @@ const commands = {
     let publicUrl, port, api
 
     if (cli.flags.dev) {
-      const dev = require('../../cli/bin/dev')
+      const dev = require('../../cli/dist/commands/dev')
       api = await initApiDevelopment()
       port = dev.developmentPort()
       publicUrl = `http://localhost:${port}/`
@@ -295,7 +295,7 @@ main()
   .then(() => {
     process.exit(0)
   })
-  .catch(err => {
+  .catch((err) => {
     console.error(chalk.red(err.stack))
     process.exit(-1)
   })

+ 3 - 1
storage-node/packages/colossus/lib/app.js

@@ -64,7 +64,9 @@ function createApp(projectRoot, storage, runtime) {
 
   // If no other handler gets triggered (errors), respond with the
   // error serialized to JSON.
-  app.use(function(err, req, res) {
+  // Disable lint because we need such function signature.
+  // eslint-disable-next-line no-unused-vars
+  app.use(function (err, req, res, next) {
     res.status(err.status).json(err)
   })
 

+ 1 - 1
storage-node/packages/colossus/lib/discovery.js

@@ -60,7 +60,7 @@ function createApp(projectRoot, runtime) {
 
   // If no other handler gets triggered (errors), respond with the
   // error serialized to JSON.
-  app.use(function(err, req, res) {
+  app.use(function (err, req, res) {
     res.status(err.status).json(err)
   })
 

+ 3 - 3
storage-node/packages/colossus/lib/middleware/file_uploads.js

@@ -21,8 +21,8 @@
 const multer = require('multer')
 
 // Taken from express-openapi examples
-module.exports = function(req, res, next) {
-  multer().any()(req, res, function(err) {
+module.exports = function (req, res, next) {
+  multer().any()(req, res, function (err) {
     if (err) {
       return next(err)
     }
@@ -34,7 +34,7 @@ module.exports = function(req, res, next) {
         }),
       {}
     )
-    Object.keys(filesMap).forEach(fieldname => {
+    Object.keys(filesMap).forEach((fieldname) => {
       const files = filesMap[fieldname]
       req.body[fieldname] = files.length > 1 ? files.map(() => '') : ''
     })

+ 2 - 2
storage-node/packages/colossus/lib/middleware/validate_responses.js

@@ -21,7 +21,7 @@
 const debug = require('debug')('joystream:middleware:validate')
 
 // Function taken directly from https://github.com/kogosoftwarellc/open-api/tree/master/packages/express-openapi
-module.exports = function(req, res, next) {
+module.exports = function (req, res, next) {
   const strictValidation = !!req.apiDoc['x-express-openapi-validation-strict']
   if (typeof res.validateResponse === 'function') {
     const send = res.send
@@ -42,7 +42,7 @@ module.exports = function(req, res, next) {
       }
       if (validation.errors) {
         const errorList = Array.from(validation.errors)
-          .map(_ => _.message)
+          .map((_) => _.message)
           .join(',')
         validationMessage = `Invalid response for status code ${res.statusCode}: ${errorList}`
         debug(validationMessage)

+ 1 - 1
storage-node/packages/colossus/lib/sync.js

@@ -30,7 +30,7 @@ async function syncCallback(api, storage) {
   const providerId = api.storageProviderId
 
   // Iterate over all sync objects, and ensure they're synced.
-  const allChecks = knownContentIds.map(async contentId => {
+  const allChecks = knownContentIds.map(async (contentId) => {
     // eslint-disable-next-line prefer-const
     let { relationship, relationshipId } = await api.assets.getStorageRelationshipAndId(providerId, contentId)
 

+ 4 - 4
storage-node/packages/colossus/paths/asset/v0/{id}.js

@@ -30,7 +30,7 @@ function errorHandler(response, err, code) {
   response.status(err.code || code || 500).send({ message: err.toString() })
 }
 
-module.exports = function(storage, runtime) {
+module.exports = function (storage, runtime) {
   const doc = {
     // parameters for all operations in this path
     parameters: [
@@ -108,7 +108,7 @@ module.exports = function(storage, runtime) {
           }
         }
 
-        stream.on('fileInfo', async info => {
+        stream.on('fileInfo', async (info) => {
           try {
             debug('Detected file info:', info)
 
@@ -142,7 +142,7 @@ module.exports = function(storage, runtime) {
           }
         })
 
-        stream.on('committed', async hash => {
+        stream.on('committed', async (hash) => {
           console.log('commited', dataObject)
           try {
             if (hash !== dataObject.ipfs_content_id.toString()) {
@@ -170,7 +170,7 @@ module.exports = function(storage, runtime) {
           }
         })
 
-        stream.on('error', err => errorHandler(res, err))
+        stream.on('error', (err) => errorHandler(res, err))
         req.pipe(stream)
       } catch (err) {
         errorHandler(res, err)

+ 1 - 1
storage-node/packages/colossus/paths/discover/v0/{id}.js

@@ -4,7 +4,7 @@ const debug = require('debug')('joystream:colossus:api:discovery')
 const MAX_CACHE_AGE = 30 * 60 * 1000
 const USE_CACHE = true
 
-module.exports = function(runtime) {
+module.exports = function (runtime) {
   const doc = {
     // parameters for all operations in this path
     parameters: [

+ 36 - 36
storage-node/packages/discovery/discover.js

@@ -171,42 +171,6 @@ async function discoverOverLocalIpfsNode(storageProviderId, runtimeApi) {
   return JSON.parse(content)
 }
 
-/**
- * Cached discovery of storage provider service information. If useCachedValue is
- * set to true, will always return the cached result if found. New discovery will be triggered
- * if record is found to be stale. If a stale record is not desired (CACHE_TTL old) pass a non zero
- * value for maxCacheAge, which will force a new discovery and return the new resolved value.
- * This method in turn calls _discovery which handles concurrent discoveries and selects the appropriate
- * protocol to perform the query.
- * If the storage provider is not registered it will resolve to null
- * @param {number | BN | u64} storageProviderId - provider to discover
- * @param {RuntimeApi} runtimeApi - api instance to query the chain
- * @param {bool} useCachedValue - optionaly use chached queries
- * @param {number} maxCacheAge - maximum age of a cached query that triggers automatic re-discovery
- * @returns { Promise<object | null> } - the published service information
- */
-async function discover(storageProviderId, runtimeApi, useCachedValue = false, maxCacheAge = 0) {
-  storageProviderId = new BN(storageProviderId)
-  const id = storageProviderId.toNumber()
-  const cached = accountInfoCache[id]
-
-  if (cached && useCachedValue) {
-    if (maxCacheAge > 0) {
-      // get latest value
-      if (Date.now() > cached.updated + maxCacheAge) {
-        return _discover(storageProviderId, runtimeApi)
-      }
-    }
-    // refresh if cache if stale, new value returned on next cached query
-    if (Date.now() > cached.updated + CACHE_TTL) {
-      _discover(storageProviderId, runtimeApi)
-    }
-    // return best known value
-    return cached.value
-  }
-  return _discover(storageProviderId, runtimeApi)
-}
-
 /**
  * Internal method that handles concurrent discoveries and caching of results. Will
  * select the appropriate discovery protocol based on whether we are in a browser environment or not.
@@ -264,6 +228,42 @@ async function _discover(storageProviderId, runtimeApi) {
   }
 }
 
+/**
+ * Cached discovery of storage provider service information. If useCachedValue is
+ * set to true, will always return the cached result if found. New discovery will be triggered
+ * if record is found to be stale. If a stale record is not desired (CACHE_TTL old) pass a non zero
+ * value for maxCacheAge, which will force a new discovery and return the new resolved value.
+ * This method in turn calls _discovery which handles concurrent discoveries and selects the appropriate
+ * protocol to perform the query.
+ * If the storage provider is not registered it will resolve to null
+ * @param {number | BN | u64} storageProviderId - provider to discover
+ * @param {RuntimeApi} runtimeApi - api instance to query the chain
+ * @param {bool} useCachedValue - optionaly use chached queries
+ * @param {number} maxCacheAge - maximum age of a cached query that triggers automatic re-discovery
+ * @returns { Promise<object | null> } - the published service information
+ */
+async function discover(storageProviderId, runtimeApi, useCachedValue = false, maxCacheAge = 0) {
+  storageProviderId = new BN(storageProviderId)
+  const id = storageProviderId.toNumber()
+  const cached = accountInfoCache[id]
+
+  if (cached && useCachedValue) {
+    if (maxCacheAge > 0) {
+      // get latest value
+      if (Date.now() > cached.updated + maxCacheAge) {
+        return _discover(storageProviderId, runtimeApi)
+      }
+    }
+    // refresh if cache if stale, new value returned on next cached query
+    if (Date.now() > cached.updated + CACHE_TTL) {
+      _discover(storageProviderId, runtimeApi)
+    }
+    // return best known value
+    return cached.value
+  }
+  return _discover(storageProviderId, runtimeApi)
+}
+
 module.exports = {
   discover,
   discoverOverJoystreamDiscoveryService,

+ 1 - 1
storage-node/packages/discovery/publish.js

@@ -42,7 +42,7 @@ function encodeServiceInfo(info) {
  */
 async function publish(serviceInfo) {
   const keys = await ipfs.key.list()
-  let servicesKey = keys.find(key => key.name === PUBLISH_KEY)
+  let servicesKey = keys.find((key) => key.name === PUBLISH_KEY)
 
   // An ipfs node will always have the self key.
   // If the publish key is specified as anything else and it doesn't exist

+ 72 - 72
storage-node/packages/helios/bin/cli.js

@@ -6,6 +6,74 @@ const { discover } = require('@joystream/service-discovery')
 const axios = require('axios')
 const stripEndingSlash = require('@joystream/storage-utils/stripEndingSlash')
 
+function mapInfoToStatus(providers, currentHeight) {
+  return providers.map(({ providerId, info }) => {
+    if (info) {
+      return {
+        providerId,
+        identity: info.identity.toString(),
+        expiresIn: info.expires_at.sub(currentHeight).toNumber(),
+        expired: currentHeight.gte(info.expires_at),
+      }
+    }
+    return {
+      providerId,
+      identity: null,
+      status: 'down',
+    }
+  })
+}
+
+function makeAssetUrl(contentId, source) {
+  source = stripEndingSlash(source)
+  return `${source}/asset/v0/${encodeAddress(contentId)}`
+}
+
+async function assetRelationshipState(api, contentId, providers) {
+  const dataObject = await api.query.dataDirectory.dataObjectByContentId(contentId)
+
+  const relationshipIds = await api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId)
+
+  // how many relationships associated with active providers and in ready state
+  const activeRelationships = await Promise.all(
+    relationshipIds.map(async (id) => {
+      let relationship = await api.query.dataObjectStorageRegistry.relationships(id)
+      relationship = relationship.unwrap()
+      // only interested in ready relationships
+      if (!relationship.ready) {
+        return undefined
+      }
+      // Does the relationship belong to an active provider ?
+      return providers.find((provider) => relationship.storage_provider.eq(provider))
+    })
+  )
+
+  return [activeRelationships.filter((active) => active).length, dataObject.unwrap().liaison_judgement]
+}
+
+// HTTP HEAD with axios all known content ids on each provider
+async function countContentAvailability(contentIds, source) {
+  const content = {}
+  let found = 0
+  let missing = 0
+  for (let i = 0; i < contentIds.length; i++) {
+    const assetUrl = makeAssetUrl(contentIds[i], source)
+    try {
+      const info = await axios.head(assetUrl)
+      content[encodeAddress(contentIds[i])] = {
+        type: info.headers['content-type'],
+        bytes: info.headers['content-length'],
+      }
+      // TODO: cross check against dataobject size
+      found++
+    } catch (err) {
+      missing++
+    }
+  }
+
+  return { found, missing, content }
+}
+
 async function main() {
   const runtime = await RuntimeApi.create()
   const { api } = runtime
@@ -19,7 +87,7 @@ async function main() {
   console.log(`Found ${storageProviders.length} staked providers`)
 
   const storageProviderAccountInfos = await Promise.all(
-    storageProviders.map(async providerId => {
+    storageProviders.map(async (providerId) => {
       return {
         providerId,
         info: await runtime.discovery.getAccountInfo(providerId),
@@ -49,7 +117,7 @@ async function main() {
 
   console.log(
     '\n== Down Providers!\n',
-    downProviders.map(provider => {
+    downProviders.map((provider) => {
       return {
         providerId: provider.providerId,
       }
@@ -80,7 +148,7 @@ async function main() {
 
   console.log('\nChecking API Endpoints are online')
   await Promise.all(
-    endpoints.map(async provider => {
+    endpoints.map(async (provider) => {
       if (!provider.endpoint) {
         console.log('skipping', provider.address)
         return
@@ -103,7 +171,7 @@ async function main() {
 
   // Check which providers are reporting a ready relationship for each asset
   await Promise.all(
-    knownContentIds.map(async contentId => {
+    knownContentIds.map(async (contentId) => {
       const [relationshipsCount, judgement] = await assetRelationshipState(api, contentId, storageProviders)
       console.log(
         `${encodeAddress(contentId)} replication ${relationshipsCount}/${storageProviders.length} - ${judgement}`
@@ -127,72 +195,4 @@ async function main() {
   })
 }
 
-function mapInfoToStatus(providers, currentHeight) {
-  return providers.map(({ providerId, info }) => {
-    if (info) {
-      return {
-        providerId,
-        identity: info.identity.toString(),
-        expiresIn: info.expires_at.sub(currentHeight).toNumber(),
-        expired: currentHeight.gte(info.expires_at),
-      }
-    }
-    return {
-      providerId,
-      identity: null,
-      status: 'down',
-    }
-  })
-}
-
-// HTTP HEAD with axios all known content ids on each provider
-async function countContentAvailability(contentIds, source) {
-  const content = {}
-  let found = 0
-  let missing = 0
-  for (let i = 0; i < contentIds.length; i++) {
-    const assetUrl = makeAssetUrl(contentIds[i], source)
-    try {
-      const info = await axios.head(assetUrl)
-      content[encodeAddress(contentIds[i])] = {
-        type: info.headers['content-type'],
-        bytes: info.headers['content-length'],
-      }
-      // TODO: cross check against dataobject size
-      found++
-    } catch (err) {
-      missing++
-    }
-  }
-
-  return { found, missing, content }
-}
-
-function makeAssetUrl(contentId, source) {
-  source = stripEndingSlash(source)
-  return `${source}/asset/v0/${encodeAddress(contentId)}`
-}
-
-async function assetRelationshipState(api, contentId, providers) {
-  const dataObject = await api.query.dataDirectory.dataObjectByContentId(contentId)
-
-  const relationshipIds = await api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId)
-
-  // how many relationships associated with active providers and in ready state
-  const activeRelationships = await Promise.all(
-    relationshipIds.map(async id => {
-      let relationship = await api.query.dataObjectStorageRegistry.relationships(id)
-      relationship = relationship.unwrap()
-      // only interested in ready relationships
-      if (!relationship.ready) {
-        return undefined
-      }
-      // Does the relationship belong to an active provider ?
-      return providers.find(provider => relationship.storage_provider.eq(provider))
-    })
-  )
-
-  return [activeRelationships.filter(active => active).length, dataObject.unwrap().liaison_judgement]
-}
-
 main()

+ 2 - 2
storage-node/packages/runtime-api/assets.js

@@ -134,8 +134,8 @@ class AssetsApi {
     // eslint-disable-next-line  no-async-promise-executor
     return new Promise(async (resolve, reject) => {
       try {
-        await this.createStorageRelationship(providerAccountId, storageProviderId, contentId, events => {
-          events.forEach(event => {
+        await this.createStorageRelationship(providerAccountId, storageProviderId, contentId, (events) => {
+          events.forEach((event) => {
             resolve(event[1].DataObjectStorageRelationshipId)
           })
         })

+ 10 - 8
storage-node/packages/runtime-api/index.js

@@ -28,6 +28,7 @@ const { BalancesApi } = require('@joystream/storage-runtime-api/balances')
 const { WorkersApi } = require('@joystream/storage-runtime-api/workers')
 const { AssetsApi } = require('@joystream/storage-runtime-api/assets')
 const { DiscoveryApi } = require('@joystream/storage-runtime-api/discovery')
+const { SystemApi } = require('@joystream/storage-runtime-api/system')
 const AsyncLock = require('async-lock')
 const { newExternallyControlledPromise } = require('@joystream/storage-utils/externalPromise')
 
@@ -72,6 +73,7 @@ class RuntimeApi {
     this.workers = await WorkersApi.create(this)
     this.assets = await AssetsApi.create(this)
     this.discovery = await DiscoveryApi.create(this)
+    this.system = await SystemApi.create(this)
   }
 
   disconnect() {
@@ -96,7 +98,7 @@ class RuntimeApi {
   static matchingEvents(subscribed, events) {
     debug(`Number of events: ${events.length} subscribed to ${subscribed}`)
 
-    const filtered = events.filter(record => {
+    const filtered = events.filter((record) => {
       const { event, phase } = record
 
       // Show what we are busy with
@@ -104,14 +106,14 @@ class RuntimeApi {
       debug(`\t\t${event.meta.documentation.toString()}`)
 
       // Skip events we're not interested in.
-      const matching = subscribed.filter(value => {
+      const matching = subscribed.filter((value) => {
         return event.section === value[0] && event.method === value[1]
       })
       return matching.length > 0
     })
     debug(`Filtered: ${filtered.length}`)
 
-    const mapped = filtered.map(record => {
+    const mapped = filtered.map((record) => {
       const { event } = record
       const types = event.typeDef
 
@@ -138,8 +140,8 @@ class RuntimeApi {
    * Returns the first matched event *only*.
    */
   async waitForEvents(subscribed) {
-    return new Promise(resolve => {
-      this.api.query.system.events(events => {
+    return new Promise((resolve) => {
+      this.api.query.system.events((events) => {
         const matches = RuntimeApi.matchingEvents(subscribed, events)
         if (matches && matches.length) {
           resolve(matches)
@@ -243,7 +245,7 @@ class RuntimeApi {
             isInvalid
             */
           })
-          .catch(err => {
+          .catch((err) => {
             // 1014 error: Most likely you are sending transaction with the same nonce,
             // so it assumes you want to replace existing one, but the priority is too low to replace it (priority = fee = len(encoded_transaction) currently)
             // Remember this can also happen if in the past we sent a tx with a future nonce, and the current nonce
@@ -290,8 +292,8 @@ class RuntimeApi {
     // eslint-disable-next-line  no-async-promise-executor
     return new Promise(async (resolve, reject) => {
       try {
-        await this.signAndSend(senderAccountId, tx, 1, subscribed, events => {
-          events.forEach(event => {
+        await this.signAndSend(senderAccountId, tx, 1, subscribed, (events) => {
+          events.forEach((event) => {
             // fix - we may not necessarily want the first event
             // if there are multiple events emitted,
             resolve(event[1][eventProperty])

+ 33 - 0
storage-node/packages/runtime-api/system.js

@@ -0,0 +1,33 @@
+'use strict'
+
+const debug = require('debug')('joystream:runtime:system')
+
+/*
+ * Add system functionality to the substrate API.
+ */
+class SystemApi {
+  static async create(base) {
+    const ret = new SystemApi()
+    ret.base = base
+    await SystemApi.init()
+    return ret
+  }
+
+  static async init() {
+    debug('Init')
+  }
+
+  /*
+   * Check the running chain for the development setup.
+   */
+  async isDevelopmentChain() {
+    const developmentChainName = 'Development'
+    const runningChainName = await this.base.api.rpc.system.chain()
+
+    return runningChainName.toString() === developmentChainName
+  }
+}
+
+module.exports = {
+  SystemApi,
+}

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