浏览代码

Merge pull request #3146 from Lezek123/olympia-cli-memberships

Olympia CLI: Memberships + base commands refactorization (scriptability improvements)
Lezek123 3 年之前
父节点
当前提交
eed6f9ae78
共有 56 个文件被更改,包括 1390 次插入443 次删除
  1. 493 135
      cli/README.md
  2. 3 3
      cli/package.json
  3. 51 42
      cli/scripts/content-test.sh
  4. 98 0
      cli/scripts/membership-test.sh
  5. 15 12
      cli/src/Api.ts
  6. 2 1
      cli/src/Types.ts
  7. 105 125
      cli/src/base/AccountsCommandBase.ts
  8. 23 7
      cli/src/base/ApiCommandBase.ts
  9. 10 6
      cli/src/base/ContentDirectoryCommandBase.ts
  10. 40 17
      cli/src/base/DefaultCommandBase.ts
  11. 86 0
      cli/src/base/MembershipsCommandBase.ts
  12. 4 0
      cli/src/base/UploadCommandBase.ts
  13. 89 0
      cli/src/base/WorkingGroupCommandBase.ts
  14. 7 65
      cli/src/base/WorkingGroupsCommandBase.ts
  15. 24 3
      cli/src/commands/account/forget.ts
  16. 4 0
      cli/src/commands/content/addCuratorToGroup.ts
  17. 4 0
      cli/src/commands/content/channel.ts
  18. 4 0
      cli/src/commands/content/channels.ts
  19. 4 2
      cli/src/commands/content/createChannel.ts
  20. 4 5
      cli/src/commands/content/createChannelCategory.ts
  21. 3 0
      cli/src/commands/content/createCuratorGroup.ts
  22. 4 5
      cli/src/commands/content/createVideo.ts
  23. 4 5
      cli/src/commands/content/createVideoCategory.ts
  24. 4 0
      cli/src/commands/content/curatorGroup.ts
  25. 4 1
      cli/src/commands/content/curatorGroups.ts
  26. 1 0
      cli/src/commands/content/deleteChannel.ts
  27. 1 0
      cli/src/commands/content/deleteChannelCategory.ts
  28. 1 0
      cli/src/commands/content/deleteVideo.ts
  29. 1 0
      cli/src/commands/content/deleteVideoCategory.ts
  30. 1 0
      cli/src/commands/content/removeChannelAssets.ts
  31. 4 0
      cli/src/commands/content/removeCuratorFromGroup.ts
  32. 1 0
      cli/src/commands/content/reuploadAssets.ts
  33. 4 0
      cli/src/commands/content/setCuratorGroupStatus.ts
  34. 4 0
      cli/src/commands/content/setFeaturedVideos.ts
  35. 1 0
      cli/src/commands/content/updateChannel.ts
  36. 1 0
      cli/src/commands/content/updateChannelCategory.ts
  37. 1 0
      cli/src/commands/content/updateChannelCensorshipStatus.ts
  38. 1 0
      cli/src/commands/content/updateVideo.ts
  39. 1 0
      cli/src/commands/content/updateVideoCategory.ts
  40. 1 0
      cli/src/commands/content/updateVideoCensorshipStatus.ts
  41. 4 0
      cli/src/commands/content/video.ts
  42. 4 0
      cli/src/commands/content/videos.ts
  43. 29 0
      cli/src/commands/membership/addStakingAccount.ts
  44. 89 0
      cli/src/commands/membership/buy.ts
  45. 42 0
      cli/src/commands/membership/details.ts
  46. 55 0
      cli/src/commands/membership/update.ts
  47. 37 0
      cli/src/commands/membership/updateAccounts.ts
  48. 1 1
      cli/src/commands/working-groups/application.ts
  49. 5 1
      cli/src/commands/working-groups/apply.ts
  50. 5 1
      cli/src/commands/working-groups/cancelOpening.ts
  51. 1 1
      cli/src/commands/working-groups/createOpening.ts
  52. 1 1
      cli/src/commands/working-groups/evictWorker.ts
  53. 1 1
      cli/src/commands/working-groups/opening.ts
  54. 1 1
      cli/src/commands/working-groups/openings.ts
  55. 1 1
      cli/src/commands/working-groups/overview.ts
  56. 1 1
      cli/src/commands/working-groups/setDefaultGroup.ts

文件差异内容过多而无法显示
+ 493 - 135
cli/README.md


+ 3 - 3
cli/package.json

@@ -108,9 +108,6 @@
       "@oclif/plugin-warn-if-update-available"
     ],
     "topics": {
-      "council": {
-        "description": "Council-related information and activities like voting, becoming part of the council etc."
-      },
       "account": {
         "description": "Accounts management - create, import or switch currently used account"
       },
@@ -122,6 +119,9 @@
       },
       "content": {
         "description": "Interactions with content directory module - managing vidoes, channels, assets, categories and curator groups"
+      },
+      "membership": {
+        "description": "Membership management - buy a new membership, update membership, manage membership keys"
       }
     }
   },

+ 51 - 42
cli/scripts/content-test.sh

@@ -4,61 +4,70 @@ set -e
 SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
 cd $SCRIPT_PATH
 
+mkdir ~/tmp || true
 echo "{}" > ~/tmp/empty.json
 
 export AUTO_CONFIRM=true
+export OCLIF_TS_NODE=0
+
+yarn workspace @joystream/cli build
+
+CLI=../bin/run
 
 # Init content lead
 GROUP=contentWorkingGroup yarn workspace api-scripts initialize-lead
 # Add integration tests lead key (in case the script is executed after ./start.sh)
-yarn joystream-cli account:import --suri //testing//worker//Content//0 --name "Test content lead key" --password "" || true
+${CLI} account:forget --name "Test content lead key" || true
+${CLI} account:import --suri //testing//worker//Content//0 --name "Test content lead key" --password "" || true
 # Test create/update/remove category
-yarn joystream-cli content:createVideoCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createVideoCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createVideoCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createChannelCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createChannelCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:createChannelCategory -i ./examples/content/CreateCategory.json
-yarn joystream-cli content:updateVideoCategory -i ./examples/content/UpdateCategory.json 2
-yarn joystream-cli content:updateChannelCategory -i ./examples/content/UpdateCategory.json 2
-yarn joystream-cli content:deleteChannelCategory 3
-yarn joystream-cli content:deleteVideoCategory 3
+${CLI} content:createVideoCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createVideoCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createVideoCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createChannelCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createChannelCategory -i ../examples/content/CreateCategory.json
+${CLI} content:createChannelCategory -i ../examples/content/CreateCategory.json
+${CLI} content:updateVideoCategory -i ../examples/content/UpdateCategory.json 2
+${CLI} content:updateChannelCategory -i ../examples/content/UpdateCategory.json 2
+${CLI} content:deleteChannelCategory 3
+${CLI} content:deleteVideoCategory 3
 # Group 1 - a valid group
-yarn joystream-cli content:createCuratorGroup
-yarn joystream-cli content:setCuratorGroupStatus 1 1
-yarn joystream-cli content:addCuratorToGroup 1 0
+${CLI} content:createCuratorGroup
+${CLI} content:setCuratorGroupStatus 1 1
+${CLI} content:addCuratorToGroup 1 0
 # Group 2 - test removeCuratorFromGroup
-yarn joystream-cli content:createCuratorGroup
-yarn joystream-cli content:addCuratorToGroup 2 0
-yarn joystream-cli content:removeCuratorFromGroup 2 0
+${CLI} content:createCuratorGroup
+${CLI} content:addCuratorToGroup 2 0
+${CLI} content:removeCuratorFromGroup 2 0
 # Create/update channel
-yarn joystream-cli content:createChannel -i ./examples/content/CreateChannel.json --context Member || true
-yarn joystream-cli content:createChannel -i ./examples/content/CreateChannel.json --context Curator || true
-yarn joystream-cli content:createChannel -i ~/tmp/empty.json --context Member || true
-yarn joystream-cli content:updateChannel -i ./examples/content/UpdateChannel.json 1 || true
+${CLI} content:createChannel -i ../examples/content/CreateChannel.json --context Member || true
+${CLI} content:createChannel -i ../examples/content/CreateChannel.json --context Curator || true
+${CLI} content:createChannel -i ~/tmp/empty.json --context Member || true
+${CLI} content:updateChannel -i ../examples/content/UpdateChannel.json 1 || true
 # Create/update video
-yarn joystream-cli content:createVideo -i ./examples/content/CreateVideo.json -c 1 || true
-yarn joystream-cli content:createVideo -i ./examples/content/CreateVideo.json -c 2 || true
-yarn joystream-cli content:createVideo -i ~/tmp/empty.json -c 2 || true
-yarn joystream-cli content:updateVideo -i ./examples/content/UpdateVideo.json 1 || true
+${CLI} content:createVideo -i ../examples/content/CreateVideo.json -c 1 || true
+${CLI} content:createVideo -i ../examples/content/CreateVideo.json -c 2 || true
+${CLI} content:createVideo -i ~/tmp/empty.json -c 2 || true
+${CLI} content:updateVideo -i ../examples/content/UpdateVideo.json 1 || true
 # Set featured videos
-yarn joystream-cli content:setFeaturedVideos 1,2
-yarn joystream-cli content:setFeaturedVideos 2,3
+${CLI} content:setFeaturedVideos 1,2
+${CLI} content:setFeaturedVideos 2,3
 # Update channel censorship status
-yarn joystream-cli content:updateChannelCensorshipStatus 1 1 --rationale "Test"
-yarn joystream-cli content:updateVideoCensorshipStatus 1 1 --rationale "Test"
+${CLI} content:updateChannelCensorshipStatus 1 1 --rationale "Test"
+${CLI} content:updateVideoCensorshipStatus 1 1 --rationale "Test"
 # Display-only commands
-yarn joystream-cli content:videos
-yarn joystream-cli content:video 1
-yarn joystream-cli content:channels
-yarn joystream-cli content:channel 1
-yarn joystream-cli content:curatorGroups
-yarn joystream-cli content:curatorGroup 1
+${CLI} content:videos
+${CLI} content:video 1
+${CLI} content:channels
+${CLI} content:channel 1
+${CLI} content:curatorGroups
+${CLI} content:curatorGroup 1
 # Remove videos/channels/assets
-yarn joystream-cli content:removeChannelAssets -c 1 -o 0
-yarn joystream-cli content:deleteVideo -v 1 -f
-yarn joystream-cli content:deleteVideo -v 2 -f
-yarn joystream-cli content:deleteVideo -v 3 -f
-yarn joystream-cli content:deleteChannel -c 1 -f
-yarn joystream-cli content:deleteChannel -c 2 -f
-yarn joystream-cli content:deleteChannel -c 3 -f
+${CLI} content:removeChannelAssets -c 1 -o 0
+${CLI} content:deleteVideo -v 1 -f
+${CLI} content:deleteVideo -v 2 -f
+${CLI} content:deleteVideo -v 3 -f
+${CLI} content:deleteChannel -c 1 -f
+${CLI} content:deleteChannel -c 2 -f
+${CLI} content:deleteChannel -c 3 -f
+# Forget test content lead account
+${CLI} account:forget --name "Test content lead key"

+ 98 - 0
cli/scripts/membership-test.sh

@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
+cd $SCRIPT_PATH
+
+export AUTO_CONFIRM=true
+export OCLIF_TS_NODE=0
+
+yarn workspace @joystream/cli build
+
+CLI=../bin/run
+
+# Remove accounts added by previous test runs if needed
+${CLI} account:forget --name test_alice_member_controller_1 || true
+${CLI} account:forget --name test_alice_member_root_1 || true
+${CLI} account:forget --name test_alice_member_controller_2 || true
+${CLI} account:forget --name test_alice_member_staking || true
+
+# Create membership (controller: //Alice//controller, root: //Alice//root, sender: //Alice)
+MEMBER_HANDLE="alice-$(date +%s)"
+MEMBER_ID=`${CLI} membership:buy\
+  --about="Test about text"\
+  --avatarUri="http://example.com/example.jpg"\
+  --controllerKey="5FnEMwYzo9PRGkGV4CtFNaCNSEZWA3AxbpbxcxamxdvMkD19"\
+  --handle="$MEMBER_HANDLE"\
+  --name="Alice"\
+  --rootKey="5CVGusS1N7brUBqfVE1XgUeowHMD8o9xpk2mMXdFrrnLmM1v"\
+  --senderKey="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"`
+
+# Import //Alice//controller key
+${CLI} account:import\
+  --suri //Alice//controller\
+  --name test_alice_member_controller_1\
+  --password=""
+
+# Transfer some funds to //Alice//controller key
+${CLI} account:transferTokens\
+  --amount 10000\
+  --from 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY\
+  --to 5FnEMwYzo9PRGkGV4CtFNaCNSEZWA3AxbpbxcxamxdvMkD19
+
+# Update membership
+${CLI} membership:update\
+  --useMemberId="$MEMBER_ID"\
+  --newHandle="$MEMBER_HANDLE-updated"\
+  --newName="Alice Updated"\
+  --newAvatarUri="http://example.com/updated.jpg"\
+  --newAbout="Test about text updated"
+
+# Import //Alice//root key
+${CLI} account:import\
+  --suri //Alice//root\
+  --name test_alice_member_root_1\
+  --password=""
+
+# Transfer some funds to //Alice//root key
+${CLI} account:transferTokens\
+  --amount 10000\
+  --from 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY\
+  --to 5CVGusS1N7brUBqfVE1XgUeowHMD8o9xpk2mMXdFrrnLmM1v
+
+# Update accounts (//Alice//controller//0, //Alice//root//0)
+${CLI} membership:updateAccounts\
+  --useMemberId="$MEMBER_ID"\
+  --newControllerAccount="5E5JemkFX48JMRFraGZrjPwKL1HnhLkPrMQxaBvoSXPmzKab"\
+  --newRootAccount="5HBBGjABKMczXYGmGZe9un3VYia1BmedLsoXJFWAtBtGVahv"
+
+# Import //Alice//controller//0 key
+${CLI} account:import\
+  --suri //Alice//controller//0\
+  --name test_alice_member_controller_2\
+  --password=""
+
+# Transfer some funds to //Alice//controller//0 key
+${CLI} account:transferTokens\
+  --amount 10000\
+  --from 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY\
+  --to 5E5JemkFX48JMRFraGZrjPwKL1HnhLkPrMQxaBvoSXPmzKab
+
+# Import //Alice//staking key
+${CLI} account:import\
+  --suri //Alice//staking\
+  --name test_alice_member_staking\
+  --password=""
+
+# Add staking account (//Alice//staking)
+${CLI} membership:addStakingAccount\
+  --useMemberId="$MEMBER_ID"\
+  --address="5EheygkSi4q4QCN12d2Vy65EnoEtdJy6yw6o7XZpPRcaVJCS"\
+  --fundsSource="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"\
+  --withBalance="10000"
+
+# Remove imported accounts
+${CLI} account:forget --name test_alice_member_controller_1
+${CLI} account:forget --name test_alice_member_root_1
+${CLI} account:forget --name test_alice_member_controller_2
+${CLI} account:forget --name test_alice_member_staking

+ 15 - 12
cli/src/Api.ts

@@ -34,6 +34,7 @@ import {
 import { BagId, DataObject, DataObjectId } from '@joystream/types/storage'
 import QueryNodeApi from './QueryNodeApi'
 import { MembershipFieldsFragment } from './graphql/generated/queries'
+import { blake2AsHex } from '@polkadot/util-crypto'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 
@@ -163,8 +164,8 @@ export default class Api {
 
     return entries.map(([memberId, membership]) => ({
       id: memberId,
-      name: memberQnDataById.get(memberId.toString())?.metadata.name,
       handle: memberQnDataById.get(memberId.toString())?.handle,
+      meta: memberQnDataById.get(memberId.toString())?.metadata,
       membership,
     }))
   }
@@ -175,13 +176,13 @@ export default class Api {
     return memberDetails
   }
 
-  protected async membershipById(memberId: MemberId): Promise<MemberDetails | null> {
+  async memberDetailsById(memberId: MemberId | number): Promise<MemberDetails | null> {
     const membership = await this._api.query.members.membershipById(memberId)
-    return membership.isEmpty ? null : await this.memberDetails(memberId, membership)
+    return membership.isEmpty ? null : await this.memberDetails(createType('MemberId', memberId), membership)
   }
 
-  protected async expectedMembershipById(memberId: MemberId): Promise<MemberDetails> {
-    const member = await this.membershipById(memberId)
+  async expectedMemberDetailsById(memberId: MemberId | number): Promise<MemberDetails> {
+    const member = await this.memberDetailsById(memberId)
     if (!member) {
       throw new CLIError(`Expected member was not found by id: ${memberId.toString()}`)
     }
@@ -230,11 +231,7 @@ export default class Api {
     const stakingAccount = worker.staking_account_id
     const memberId = worker.member_id
 
-    const profile = await this.membershipById(memberId)
-
-    if (!profile) {
-      throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`)
-    }
+    const profile = await this.expectedMemberDetailsById(memberId)
 
     const stake = await this.fetchStake(worker.staking_account_id, group)
 
@@ -318,7 +315,7 @@ export default class Api {
   ): Promise<ApplicationDetails> {
     return {
       applicationId,
-      member: await this.expectedMembershipById(application.member_id),
+      member: await this.expectedMemberDetailsById(application.member_id),
       roleAccout: application.role_account_id,
       rewardAccount: application.reward_account_id,
       stakingAccount: application.staking_account_id,
@@ -453,7 +450,13 @@ export default class Api {
   }
 
   async stakingAccountStatus(account: string): Promise<StakingAccountMemberBinding | null> {
-    const status = await this.getOriginalApi().query.members.stakingAccountIdMemberStatus(account)
+    const status = await this._api.query.members.stakingAccountIdMemberStatus(account)
     return status.isEmpty ? null : status
   }
+
+  async isHandleTaken(handle: string): Promise<boolean> {
+    const handleHash = blake2AsHex(handle)
+    const existingMeber = await this._api.query.members.memberIdByHandleHash(handleHash)
+    return !existingMeber.isEmpty
+  }
 }

+ 2 - 1
cli/src/Types.ts

@@ -17,6 +17,7 @@ import {
   IChannelCategoryMetadata,
 } from '@joystream/metadata-protobuf'
 import { DataObjectCreationParameters } from '@joystream/types/storage'
+import { MembershipFieldsFragment } from './graphql/generated/queries'
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -103,7 +104,7 @@ export type OpeningDetails = {
 // Extended membership information (including optional query node data)
 export type MemberDetails = {
   id: MemberId
-  name?: string | null
+  meta?: MembershipFieldsFragment['metadata']
   handle?: string
   membership: Membership
 }

+ 105 - 125
cli/src/base/AccountsCommandBase.ts

@@ -6,9 +6,9 @@ import { CLIError } from '@oclif/errors'
 import ApiCommandBase from './ApiCommandBase'
 import { Keyring } from '@polkadot/api'
 import { formatBalance } from '@polkadot/util'
-import { MemberDetails, NamedKeyringPair } from '../Types'
+import { NamedKeyringPair } from '../Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
-import { memberHandle, toFixedLength } from '../helpers/display'
+import { toFixedLength } from '../helpers/display'
 import { MemberId, AccountId } from '@joystream/types/common'
 import { KeyringPair, KeyringInstance, KeyringOptions } from '@polkadot/keyring/types'
 import { KeypairType } from '@polkadot/util-crypto/types'
@@ -35,7 +35,6 @@ export const STAKING_ACCOUNT_CANDIDATE_STAKE = new BN(200)
  * Where: APP_DATA_PATH is provided by StateAwareCommandBase and ACCOUNTS_DIRNAME is a const (see above).
  */
 export default abstract class AccountsCommandBase extends ApiCommandBase {
-  private selectedMember: MemberDetails | undefined
   private _keyring: KeyringInstance | undefined
 
   private get keyring(): KeyringInstance {
@@ -199,6 +198,14 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     return this.keyring.getPair(key) as NamedKeyringPair
   }
 
+  getPairByName(name: string): NamedKeyringPair {
+    const pair = this.getPairs().find((p) => this.getAccountFileName(p.meta.name) === this.getAccountFileName(name))
+    if (!pair) {
+      throw new CLIError(`Account not found by name: ${name}`)
+    }
+    return pair
+  }
+
   async getDecodedPair(key: string | AccountId): Promise<NamedKeyringPair> {
     const pair = this.getPair(key.toString())
 
@@ -278,7 +285,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
 
     const longestNameLen: number = pairs.reduce((prev, curr) => Math.max(curr.meta.name.length, prev), 0)
     const nameColLength: number = Math.min(longestNameLen + 1, 20)
-    const chosenKey = await this.simplePrompt({
+    const chosenKey = await this.simplePrompt<string>({
       message,
       type: 'list',
       choices: pairs.map((p, i) => ({
@@ -321,143 +328,116 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  async getRequiredMemberContext(useSelected = false, allowedIds?: MemberId[]): Promise<MemberDetails> {
-    if (
-      useSelected &&
-      this.selectedMember &&
-      (!allowedIds || allowedIds.some((id) => id.eq(this.selectedMember?.id)))
-    ) {
-      return this.selectedMember
+  async setupStakingAccount(
+    memberId: MemberId,
+    member: Membership,
+    address?: string,
+    requiredStake: BN = new BN(0),
+    fundsSource?: string
+  ): Promise<string> {
+    if (fundsSource && !this.isKeyAvailable(fundsSource)) {
+      throw new CLIError(`Key ${chalk.magentaBright(fundsSource)} is not available!`)
     }
 
-    const membersDetails = allowedIds
-      ? await this.getApi().membersDetailsByIds(allowedIds)
-      : await this.getApi().allMembersDetails()
-    const availableMemberships = await Promise.all(
-      membersDetails.filter((m) => this.isKeyAvailable(m.membership.controller_account.toString()))
-    )
-
-    if (!availableMemberships.length) {
-      this.error(
-        `No ${allowedIds ? 'allowed ' : ''}member controller key available!` +
-          (allowedIds ? ` Allowed members: ${allowedIds.join(', ')}.` : ''),
-        {
-          exit: ExitCodes.AccessDenied,
-        }
-      )
-    } else if (availableMemberships.length === 1) {
-      this.selectedMember = availableMemberships[0]
-    } else {
-      this.selectedMember = await this.promptForMember(availableMemberships, 'Choose member context')
+    if (!address) {
+      address = await this.promptForAnyAddress('Choose staking account')
     }
+    const { balances } = await this.getApi().getAccountSummary(address)
+    const stakingStatus = await this.getApi().stakingAccountStatus(address)
 
-    return this.selectedMember
-  }
-
-  async promptForMember(availableMemberships: MemberDetails[], message = 'Choose a member'): Promise<MemberDetails> {
-    const memberIndex = await this.simplePrompt({
-      type: 'list',
-      message,
-      choices: availableMemberships.map((m, i) => ({
-        name: `id: ${m.id}, handle: ${memberHandle(m)}`,
-        value: i,
-      })),
-    })
-
-    return availableMemberships[memberIndex]
-  }
-
-  async promptForStakingAccount(stakeValue: BN, memberId: MemberId, member: Membership): Promise<string> {
-    this.log(`Required stake: ${formatBalance(stakeValue)}`)
-    let stakingAccount: string
-    while (true) {
-      stakingAccount = await this.promptForAnyAddress('Choose staking account')
-      const { balances } = await this.getApi().getAccountSummary(stakingAccount)
-      const stakingStatus = await this.getApi().stakingAccountStatus(stakingAccount)
-
-      if (balances.lockedBalance.gtn(0)) {
-        this.warn('This account is already used for other staking purposes, choose different account...')
-        continue
-      }
-
-      if (stakingStatus && !stakingStatus.member_id.eq(memberId)) {
-        this.warn('This account is already used as staking accout by other member, choose different account...')
-        continue
-      }
+    if (balances.lockedBalance.gtn(0)) {
+      throw new CLIError('This account is already used for other staking purposes, choose a different account...')
+    }
 
-      let additionalStakingAccountCosts = new BN(0)
-      if (!stakingStatus || (stakingStatus && stakingStatus.confirmed.isFalse)) {
-        if (!this.isKeyAvailable(stakingAccount)) {
-          this.warn(
-            'Account is not a confirmed staking account and cannot be directly accessed via CLI, choose different account...'
-          )
-          continue
-        }
-        this.warn(
-          `This account is not a confirmed staking account. ` +
-            `Additional funds (fees) may be required to set it as a staking account.`
-        )
-        if (!stakingStatus) {
-          additionalStakingAccountCosts = await this.getApi().estimateFee(
-            await this.getDecodedPair(stakingAccount),
-            this.getOriginalApi().tx.members.addStakingAccountCandidate(memberId)
-          )
-          additionalStakingAccountCosts = additionalStakingAccountCosts.add(STAKING_ACCOUNT_CANDIDATE_STAKE)
-        }
-      }
+    if (stakingStatus && !stakingStatus.member_id.eq(memberId)) {
+      throw new CLIError(
+        'This account is already used as staking accout by other member, choose a different account...'
+      )
+    }
 
-      const requiredStakingAccountBalance = stakeValue.add(additionalStakingAccountCosts)
-      const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
-      if (missingStakingAccountBalance.gtn(0)) {
-        this.warn(
-          `Not enough available staking account balance! Missing: ${chalk.cyan(
-            formatBalance(missingStakingAccountBalance)
-          )}.` +
-            (additionalStakingAccountCosts.gtn(0)
-              ? ` (includes ${formatBalance(
-                  additionalStakingAccountCosts
-                )} which is a required fee and candidate stake for adding a new staking account)`
-              : '')
+    let candidateTxFee = new BN(0)
+    if (!stakingStatus || (stakingStatus && stakingStatus.confirmed.isFalse)) {
+      if (!this.isKeyAvailable(address)) {
+        throw new CLIError(
+          'Account is not a confirmed staking account and cannot be directly accessed via CLI, choose different account...'
         )
-        const transferTokens = await this.simplePrompt({
-          type: 'confirm',
-          message: `Do you want to transfer ${chalk.cyan(
-            formatBalance(missingStakingAccountBalance)
-          )} from another account?`,
-        })
-        if (transferTokens) {
-          const key = await this.promptForAccount('Choose source account')
-          await this.sendAndFollowNamedTx(await this.getDecodedPair(key), 'balances', 'transferKeepAlive', [
-            stakingAccount,
-            missingStakingAccountBalance,
-          ])
-        } else {
-          continue
-        }
       }
-
+      this.warn(
+        `This account is not a confirmed staking account. ` +
+          `Additional funds (fees) may be required to set it as a staking account.`
+      )
       if (!stakingStatus) {
-        await this.sendAndFollowNamedTx(
-          await this.getDecodedPair(stakingAccount),
-          'members',
-          'addStakingAccountCandidate',
-          [memberId]
+        candidateTxFee = await this.getApi().estimateFee(
+          await this.getDecodedPair(address),
+          this.getOriginalApi().tx.members.addStakingAccountCandidate(memberId)
         )
       }
+    }
 
-      if (!stakingStatus || stakingStatus.confirmed.isFalse) {
-        await this.sendAndFollowNamedTx(
-          await this.getDecodedPair(member.controller_account.toString()),
-          'members',
-          'confirmStakingAccount',
-          [memberId, stakingAccount]
-        )
+    const requiredStakingAccountBalance = !stakingStatus
+      ? requiredStake.add(candidateTxFee).add(STAKING_ACCOUNT_CANDIDATE_STAKE)
+      : requiredStake
+    const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
+    if (missingStakingAccountBalance.gtn(0)) {
+      this.warn(
+        `Not enough available staking account balance! Missing: ${chalk.cyanBright(
+          formatBalance(missingStakingAccountBalance)
+        )}.` +
+          (!stakingStatus
+            ? ` (required balance includes ${chalk.cyanBright(
+                formatBalance(candidateTxFee)
+              )} transaction fee and ${chalk.cyanBright(
+                formatBalance(STAKING_ACCOUNT_CANDIDATE_STAKE)
+              )} staking account candidate stake)`
+            : '')
+      )
+      const transferTokens = await this.requestConfirmation(
+        `Do you want to transfer ${chalk.cyan(formatBalance(missingStakingAccountBalance))} from another account?`
+      )
+      if (transferTokens) {
+        const key = fundsSource || (await this.promptForAccount('Choose source account'))
+        await this.sendAndFollowNamedTx(await this.getDecodedPair(key), 'balances', 'transferKeepAlive', [
+          address,
+          missingStakingAccountBalance,
+        ])
+      } else {
+        throw new CLIError('Missing amount not transferred to the staking account, aborting...')
       }
+    }
+
+    if (!stakingStatus) {
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'members', 'addStakingAccountCandidate', [
+        memberId,
+      ])
+    }
 
-      break
+    if (!stakingStatus || stakingStatus.confirmed.isFalse) {
+      await this.sendAndFollowNamedTx(
+        await this.getDecodedPair(member.controller_account.toString()),
+        'members',
+        'confirmStakingAccount',
+        [memberId, address]
+      )
     }
 
-    return stakingAccount
+    return address
+  }
+
+  async promptForStakingAccount(requiredStake: BN, memberId: MemberId, member: Membership): Promise<string> {
+    this.log(`Required stake: ${formatBalance(requiredStake)}`)
+    while (true) {
+      const stakingAccount = await this.promptForAnyAddress('Choose staking account')
+      try {
+        await this.setupStakingAccount(memberId, member, stakingAccount.toString(), requiredStake)
+        return stakingAccount
+      } catch (e) {
+        if (e instanceof CLIError) {
+          this.warn(e.message)
+        } else {
+          throw e
+        }
+      }
+    }
   }
 
   async init(): Promise<void> {

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

@@ -114,7 +114,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   async promptForApiUri(): Promise<string> {
-    let selectedNodeUri = await this.simplePrompt({
+    let selectedNodeUri = await this.simplePrompt<string>({
       type: 'list',
       message: 'Choose a node websocket api uri:',
       choices: [
@@ -502,6 +502,15 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   async sendAndFollowTx(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>): Promise<SubmittableResult> {
+    this.log(
+      chalk.magentaBright(
+        `\nSending ${tx.method.section}.${tx.method.method} extrinsic from ${
+          account.meta.name ? account.meta.name : account.address
+        }...`
+      )
+    )
+    this.log('Tx params:', this.humanize(tx.args))
+
     // Calculate fee and ask for confirmation
     const fee = await this.getApi().estimateFee(account, tx)
 
@@ -548,12 +557,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     method: Method,
     params: Submittable extends (...args: any[]) => any ? Parameters<Submittable> : []
   ): Promise<SubmittableResult> {
-    this.log(
-      chalk.magentaBright(
-        `\nSending ${module}.${method} extrinsic from ${account.meta.name ? account.meta.name : account.address}...`
-      )
-    )
-    this.log('Tx params:', this.humanize(params))
+    // TODO: Replace all usages with "sendAndFollowTx"
     const tx = await this.getUnaugmentedApi().tx[module][method](...params)
     return this.sendAndFollowTx(account, tx)
   }
@@ -566,6 +570,18 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return result.findRecord(section, method)?.event as EventType | undefined
   }
 
+  public getEvent<
+    S extends keyof AugmentedEvents<'promise'> & string,
+    M extends keyof AugmentedEvents<'promise'>[S] & string,
+    EventType = AugmentedEvents<'promise'>[S][M] extends AugmentedEvent<'promise', infer T> ? IEvent<T> : never
+  >(result: SubmittableResult, section: S, method: M): EventType {
+    const event = this.findEvent<S, M, EventType>(result, section, method)
+    if (!event) {
+      throw new Error(`Event ${section}.${method} not found in tx result: ${JSON.stringify(result.toHuman())}`)
+    }
+    return event
+  }
+
   async buildAndSendExtrinsic<
     Module extends keyof AugmentedSubmittables<'promise'>,
     Method extends keyof AugmentedSubmittables<'promise'>[Module] & string

+ 10 - 6
cli/src/base/ContentDirectoryCommandBase.ts

@@ -3,11 +3,11 @@ import { WorkingGroups } from '../Types'
 import { CuratorGroup, CuratorGroupId, ContentActor, Channel } from '@joystream/types/content'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
-import { RolesCommandBase } from './WorkingGroupsCommandBase'
 import { flags } from '@oclif/command'
 import { memberHandle } from '../helpers/display'
 import { MemberId } from '@joystream/types/common'
 import { createType } from '@joystream/types'
+import WorkingGroupCommandBase from './WorkingGroupCommandBase'
 
 const CHANNEL_CREATION_CONTEXTS = ['Member', 'Curator'] as const
 const CATEGORIES_CONTEXTS = ['Lead', 'Curator'] as const
@@ -20,7 +20,11 @@ type CategoriesContext = typeof CATEGORIES_CONTEXTS[number]
 /**
  * Abstract base class for commands related to content directory
  */
-export default abstract class ContentDirectoryCommandBase extends RolesCommandBase {
+export default abstract class ContentDirectoryCommandBase extends WorkingGroupCommandBase {
+  static flags = {
+    ...WorkingGroupCommandBase.flags,
+  }
+
   static channelCreationContextFlag = flags.enum({
     required: false,
     description: `Actor context to execute the command in (${CHANNEL_CREATION_CONTEXTS.join('/')})`,
@@ -41,7 +45,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
 
   async init(): Promise<void> {
     await super.init()
-    this.group = WorkingGroups.Curators // override group for RolesCommandBase
+    this._group = WorkingGroups.Curators // override group for RolesCommandBase
   }
 
   async promptForChannelCreationContext(
@@ -213,7 +217,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       this.warn('No Curator Groups to choose from!')
       this.exit(ExitCodes.InvalidInput)
     }
-    const selectedId = await this.simplePrompt({ message, type: 'list', choices })
+    const selectedId = await this.simplePrompt<number>({ message, type: 'list', choices })
 
     return selectedId
   }
@@ -223,7 +227,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     if (!choices.length) {
       return []
     }
-    const selectedIds = await this.simplePrompt({ message, type: 'checkbox', choices })
+    const selectedIds = await this.simplePrompt<number[]>({ message, type: 'checkbox', choices })
 
     return selectedIds
   }
@@ -242,7 +246,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       this.exit(ExitCodes.InvalidInput)
     }
 
-    const selectedCuratorId = await this.simplePrompt({
+    const selectedCuratorId = await this.simplePrompt<number>({
       message,
       type: 'list',
       choices,

+ 40 - 17
cli/src/base/DefaultCommandBase.ts

@@ -12,17 +12,29 @@ export default abstract class DefaultCommandBase extends Command {
   protected indentGroupsOpened = 0
   protected jsonPrettyIdent = ''
 
-  openIndentGroup() {
+  log(message?: unknown, ...args: unknown[]): void {
+    if (args.length) {
+      console.error(message, args)
+    } else {
+      console.error(message)
+    }
+  }
+
+  output(value: unknown): void {
+    console.log(value)
+  }
+
+  openIndentGroup(): void {
     console.group()
     ++this.indentGroupsOpened
   }
 
-  closeIndentGroup() {
+  closeIndentGroup(): void {
     console.groupEnd()
     --this.indentGroupsOpened
   }
 
-  async simplePrompt(question: DistinctQuestion) {
+  async simplePrompt<T = unknown>(question: DistinctQuestion): Promise<T> {
     const { result } = await inquirer.prompt([
       {
         ...question,
@@ -51,25 +63,36 @@ export default abstract class DefaultCommandBase extends Command {
     }
   }
 
-  private jsonPrettyIndented(line: string) {
+  async requestConfirmation(
+    message = 'Are you sure you want to execute this action?',
+    defaultVal = false
+  ): Promise<boolean> {
+    if (process.env.AUTO_CONFIRM === 'true' || parseInt(process.env.AUTO_CONFIRM || '')) {
+      return true
+    }
+    const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: defaultVal }])
+    return confirmed
+  }
+
+  private jsonPrettyIndented(line: string): string {
     return `${this.jsonPrettyIdent}${line}`
   }
 
-  private jsonPrettyOpen(char: '{' | '[') {
+  private jsonPrettyOpen(char: '{' | '['): string {
     this.jsonPrettyIdent += '    '
     return chalk.gray(char) + '\n'
   }
 
-  private jsonPrettyClose(char: '}' | ']') {
+  private jsonPrettyClose(char: '}' | ']'): string {
     this.jsonPrettyIdent = this.jsonPrettyIdent.slice(0, -4)
     return this.jsonPrettyIndented(chalk.gray(char))
   }
 
-  private jsonPrettyKeyVal(key: string, val: any): string {
+  private jsonPrettyKeyVal(key: string, val: unknown): string {
     return this.jsonPrettyIndented(chalk.magentaBright(`${key}: ${this.jsonPrettyAny(val)}`))
   }
 
-  private jsonPrettyObj(obj: { [key: string]: any }): string {
+  private jsonPrettyObj(obj: Record<string, unknown>): string {
     return (
       this.jsonPrettyOpen('{') +
       Object.keys(obj)
@@ -80,7 +103,7 @@ export default abstract class DefaultCommandBase extends Command {
     )
   }
 
-  private jsonPrettyArr(arr: any[]): string {
+  private jsonPrettyArr(arr: unknown[]): string {
     return (
       this.jsonPrettyOpen('[') +
       arr.map((v) => this.jsonPrettyIndented(this.jsonPrettyAny(v))).join(',\n') +
@@ -89,11 +112,11 @@ export default abstract class DefaultCommandBase extends Command {
     )
   }
 
-  private jsonPrettyAny(val: any): string {
+  private jsonPrettyAny(val: unknown): string {
     if (Array.isArray(val)) {
       return this.jsonPrettyArr(val)
     } else if (typeof val === 'object' && val !== null) {
-      return this.jsonPrettyObj(val)
+      return this.jsonPrettyObj(val as Record<string, unknown>)
     } else if (typeof val === 'string') {
       return chalk.green(`"${val}"`)
     }
@@ -102,26 +125,26 @@ export default abstract class DefaultCommandBase extends Command {
     return chalk.cyan(val)
   }
 
-  jsonPrettyPrint(json: string) {
+  jsonPrettyPrint(json: string): void {
     try {
       const parsed = JSON.parse(json)
-      console.log(this.jsonPrettyAny(parsed))
+      this.log(this.jsonPrettyAny(parsed))
     } catch (e) {
-      console.log(this.jsonPrettyAny(json))
+      this.log(this.jsonPrettyAny(json))
     }
   }
 
-  async finally(err: any) {
+  async finally(err: Error): Promise<void> {
     // called after run and catch regardless of whether or not the command errored
     // We'll force exit here, in case there is no error, to prevent console.log from hanging the process
     if (!err) this.exit(ExitCodes.OK)
     if (err && process.env.DEBUG === 'true') {
-      console.log(err)
+      this.log(err)
     }
     super.finally(err)
   }
 
-  async init() {
+  async init(): Promise<void> {
     inquirer.registerPrompt('datetime', inquirerDatepicker)
   }
 }

+ 86 - 0
cli/src/base/MembershipsCommandBase.ts

@@ -0,0 +1,86 @@
+import ExitCodes from '../ExitCodes'
+import { MemberDetails } from '../Types'
+import { memberHandle } from '../helpers/display'
+import { MemberId } from '@joystream/types/common'
+import { flags } from '@oclif/command'
+import AccountsCommandBase from './AccountsCommandBase'
+
+/**
+ * Abstract base class for membership-related commands / commands that require membership context.
+ */
+export default abstract class MembershipsCommandBase extends AccountsCommandBase {
+  private selectedMember: MemberDetails | undefined
+
+  static flags = {
+    useMemberId: flags.integer({
+      required: false,
+      description: 'Try using the specified member id as context whenever possible',
+    }),
+  }
+
+  async getRequiredMemberContext(
+    useSelected = false,
+    allowedIds?: MemberId[],
+    accountType: 'controller' | 'root' = 'controller'
+  ): Promise<MemberDetails> {
+    const flags = this.parse(this.constructor as typeof MembershipsCommandBase).flags
+
+    if (
+      useSelected &&
+      this.selectedMember &&
+      (!allowedIds || allowedIds.some((id) => id.eq(this.selectedMember?.id)))
+    ) {
+      return this.selectedMember
+    }
+
+    if (
+      flags.useMemberId !== undefined &&
+      (!allowedIds || allowedIds.some((id) => id.toNumber() === flags.useMemberId))
+    ) {
+      this.selectedMember = await this.getApi().expectedMemberDetailsById(flags.useMemberId)
+      return this.selectedMember
+    }
+
+    const membersDetails = allowedIds
+      ? await this.getApi().membersDetailsByIds(allowedIds)
+      : await this.getApi().allMembersDetails()
+    const availableMemberships = await Promise.all(
+      membersDetails.filter((m) =>
+        this.isKeyAvailable(
+          accountType === 'controller'
+            ? m.membership.controller_account.toString()
+            : m.membership.root_account.toString()
+        )
+      )
+    )
+
+    if (!availableMemberships.length) {
+      this.error(
+        `No ${allowedIds ? 'allowed ' : ''}member ${accountType} key available!` +
+          (allowedIds ? ` Allowed members: ${allowedIds.join(', ')}.` : ''),
+        {
+          exit: ExitCodes.AccessDenied,
+        }
+      )
+    } else if (availableMemberships.length === 1) {
+      this.selectedMember = availableMemberships[0]
+    } else {
+      this.selectedMember = await this.promptForMember(availableMemberships, 'Choose member context')
+    }
+
+    return this.selectedMember
+  }
+
+  async promptForMember(availableMemberships: MemberDetails[], message = 'Choose a member'): Promise<MemberDetails> {
+    const memberIndex = await this.simplePrompt<number>({
+      type: 'list',
+      message,
+      choices: availableMemberships.map((m, i) => ({
+        name: `id: ${m.id}, handle: ${memberHandle(m)}`,
+        value: i,
+      })),
+    })
+
+    return availableMemberships[memberIndex]
+  }
+}

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

@@ -35,6 +35,10 @@ ffmpeg.setFfprobePath(ffprobeInstaller.path)
  * Abstract base class for commands that require uploading functionality
  */
 export default abstract class UploadCommandBase extends ContentDirectoryCommandBase {
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   private fileSizeCache: Map<string, number> = new Map<string, number>()
   private maxFileSize: undefined | BN = undefined
   private progressBarOptions: Options = {

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

@@ -0,0 +1,89 @@
+import ExitCodes from '../ExitCodes'
+import { flags } from '@oclif/command'
+import { WorkingGroups, GroupMember } from '../Types'
+import _ from 'lodash'
+import MembershipsCommandBase from './MembershipsCommandBase'
+
+/**
+ * Abstract base class for commands relying on a specific working group context
+ */
+export default abstract class WorkingGroupCommandBase extends MembershipsCommandBase {
+  _group: WorkingGroups | undefined
+
+  protected get group(): WorkingGroups {
+    if (!this._group) {
+      this.error('Trying to access WorkingGroup before initialization', {
+        exit: ExitCodes.UnexpectedException,
+      })
+    }
+    return this._group
+  }
+
+  static flags = {
+    useWorkerId: flags.integer({
+      required: false,
+      description: 'Try using the specified worker id as context whenever possible',
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async init(): Promise<void> {
+    await super.init()
+    this._group = this.getPreservedState().defaultWorkingGroup
+  }
+
+  // Use when lead access is required in given command
+  async getRequiredLeadContext(): Promise<GroupMember> {
+    const lead = await this.getApi().groupLead(this.group)
+
+    if (!lead || !this.isKeyAvailable(lead.roleAccount)) {
+      this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    }
+
+    return lead
+  }
+
+  // Use when worker access is required in given command
+  async getRequiredWorkerContext(expectedKeyType: 'Role' | 'MemberController' = 'Role'): Promise<GroupMember> {
+    const flags = this.parse(this.constructor as typeof WorkingGroupCommandBase).flags
+
+    const groupMembers = await this.getApi().groupMembers(this.group)
+    const availableGroupMemberContexts = groupMembers.filter((m) =>
+      expectedKeyType === 'Role'
+        ? this.isKeyAvailable(m.roleAccount.toString())
+        : this.isKeyAvailable(m.profile.membership.controller_account.toString())
+    )
+
+    if (!availableGroupMemberContexts.length) {
+      this.error(`No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key available!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    } else if (availableGroupMemberContexts.length === 1) {
+      return availableGroupMemberContexts[0]
+    } else {
+      const matchingContext =
+        flags.useWorkerId !== undefined &&
+        (availableGroupMemberContexts.find((c) => c.workerId.toNumber() === flags.useWorkerId) ||
+          availableGroupMemberContexts.find((c) => c.memberId.toNumber() === flags.useMemberId))
+      if (matchingContext) {
+        return matchingContext
+      }
+      return await this.promptForWorker(availableGroupMemberContexts)
+    }
+  }
+
+  async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
+    const chosenWorkerIndex = await this.simplePrompt<number>({
+      message: `Choose the intended ${_.startCase(this.group)} Group Worker context:`,
+      type: 'list',
+      choices: groupMembers.map((groupMember, index) => ({
+        name: `Worker ID ${groupMember.workerId.toString()}`,
+        value: index,
+      })),
+    })
+
+    return groupMembers[chosenWorkerIndex]
+  }
+}

+ 7 - 65
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,73 +1,14 @@
 import ExitCodes from '../ExitCodes'
-import AccountsCommandBase from './AccountsCommandBase'
 import { flags } from '@oclif/command'
-import { WorkingGroups, AvailableGroups, GroupMember, OpeningDetails, ApplicationDetails } from '../Types'
-import _ from 'lodash'
+import { AvailableGroups, GroupMember, OpeningDetails, ApplicationDetails } from '../Types'
 import chalk from 'chalk'
 import { memberHandle } from '../helpers/display'
+import WorkingGroupCommandBase from './WorkingGroupCommandBase'
 
 /**
- * Abstract base class for commands that need to use gates based on user's roles
+ * Abstract base class for commands related to all working groups
  */
-export abstract class RolesCommandBase extends AccountsCommandBase {
-  group!: WorkingGroups
-
-  async init(): Promise<void> {
-    await super.init()
-    this.group = this.getPreservedState().defaultWorkingGroup
-  }
-
-  // Use when lead access is required in given command
-  async getRequiredLeadContext(): Promise<GroupMember> {
-    const lead = await this.getApi().groupLead(this.group)
-
-    if (!lead || !this.isKeyAvailable(lead.roleAccount)) {
-      this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
-        exit: ExitCodes.AccessDenied,
-      })
-    }
-
-    return lead
-  }
-
-  // Use when worker access is required in given command
-  async getRequiredWorkerContext(expectedKeyType: 'Role' | 'MemberController' = 'Role'): Promise<GroupMember> {
-    const groupMembers = await this.getApi().groupMembers(this.group)
-    const availableGroupMemberContexts = groupMembers.filter((m) =>
-      expectedKeyType === 'Role'
-        ? this.isKeyAvailable(m.roleAccount.toString())
-        : this.isKeyAvailable(m.profile.membership.controller_account.toString())
-    )
-
-    if (!availableGroupMemberContexts.length) {
-      this.error(`No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key available!`, {
-        exit: ExitCodes.AccessDenied,
-      })
-    } else if (availableGroupMemberContexts.length === 1) {
-      return availableGroupMemberContexts[0]
-    } else {
-      return await this.promptForWorker(availableGroupMemberContexts)
-    }
-  }
-
-  async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
-    const chosenWorkerIndex = await this.simplePrompt({
-      message: `Choose the intended ${_.startCase(this.group)} Group Worker context:`,
-      type: 'list',
-      choices: groupMembers.map((groupMember, index) => ({
-        name: `Worker ID ${groupMember.workerId.toString()}`,
-        value: index,
-      })),
-    })
-
-    return groupMembers[chosenWorkerIndex]
-  }
-}
-
-/**
- * Abstract base class for commands directly related to working groups
- */
-export default abstract class WorkingGroupsCommandBase extends RolesCommandBase {
+export default abstract class WorkingGroupsCommandBase extends WorkingGroupCommandBase {
   static flags = {
     group: flags.enum({
       char: 'g',
@@ -77,10 +18,11 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
       required: false,
       options: [...AvailableGroups],
     }),
+    ...WorkingGroupCommandBase.flags,
   }
 
   async promptForApplicationsToAccept(opening: OpeningDetails): Promise<number[]> {
-    const acceptedApplications = await this.simplePrompt({
+    const acceptedApplications = await this.simplePrompt<number[]>({
       message: 'Select succesful applicants',
       type: 'checkbox',
       choices: opening.applications.map((a) => ({
@@ -141,7 +83,7 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
     await super.init()
     const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase)
     if (flags.group) {
-      this.group = flags.group
+      this._group = flags.group
     }
     this.log(chalk.magentaBright('Current Group: ' + this.group))
   }

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

@@ -2,15 +2,36 @@ import fs from 'fs'
 import chalk from 'chalk'
 import ExitCodes from '../../ExitCodes'
 import AccountsCommandBase from '../../base/AccountsCommandBase'
+import { flags } from '@oclif/command'
 
-export default class AccountForget extends AccountsCommandBase {
+export default class AccountForgetCommand extends AccountsCommandBase {
   static description = 'Forget (remove) account from the list of available accounts'
 
+  static flags = {
+    address: flags.string({
+      required: false,
+      description: 'Address of the account to remove',
+      exclusive: ['name'],
+    }),
+    name: flags.string({
+      required: false,
+      description: 'Name of the account to remove',
+      exclusive: ['address'],
+    }),
+  }
+
   async run(): Promise<void> {
-    const selecteKey = await this.promptForAccount('Select an account to forget', false, false)
+    let { address, name } = this.parse(AccountForgetCommand).flags
+
+    if (!address && !name) {
+      address = await this.promptForAccount('Select an account to forget', false, false)
+    } else if (name) {
+      address = await this.getPairByName(name).address
+    }
+
     await this.requireConfirmation('Are you sure you want to PERMANENTLY FORGET this account?')
 
-    const accountFilePath = this.getAccountFilePath(this.getPair(selecteKey).meta.name)
+    const accountFilePath = this.getAccountFilePath(this.getPair(address || '').meta.name)
 
     try {
       fs.unlinkSync(accountFilePath)

+ 4 - 0
cli/src/commands/content/addCuratorToGroup.ts

@@ -16,6 +16,10 @@ export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBas
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const lead = await this.getRequiredLeadContext()
 

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

@@ -13,6 +13,10 @@ export default class ChannelCommand extends ContentDirectoryCommandBase {
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async displayMembersSet(set: BTreeSet<MemberId>): Promise<void> {
     const ids = Array.from(set)
     const members = await this.getApi().membersDetailsByIds(ids)

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

@@ -5,6 +5,10 @@ import { displayTable, shortAddress } from '../../helpers/display'
 export default class ChannelsCommand extends ContentDirectoryCommandBase {
   static description = 'List existing content directory channels.'
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const channels = await this.getApi().availableChannels()
 

+ 4 - 2
cli/src/commands/content/createChannel.ts

@@ -9,6 +9,7 @@ import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import UploadCommandBase from '../../base/UploadCommandBase'
 import chalk from 'chalk'
 import { ChannelMetadata } from '@joystream/metadata-protobuf'
+import { ChannelId } from '@joystream/types/common'
 
 export default class CreateChannelCommand extends UploadCommandBase {
   static description = 'Create channel inside content directory.'
@@ -19,6 +20,7 @@ export default class CreateChannelCommand extends UploadCommandBase {
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...UploadCommandBase.flags,
   }
 
   async run(): Promise<void> {
@@ -76,8 +78,8 @@ export default class CreateChannelCommand extends UploadCommandBase {
       channelCreationParameters,
     ])
 
-    const channelCreatedEvent = this.findEvent(result, 'content', 'ChannelCreated')
-    const channelId = channelCreatedEvent!.data[1]
+    const channelCreatedEvent = this.getEvent(result, 'content', 'ChannelCreated')
+    const channelId: ChannelId = channelCreatedEvent.data[1]
     this.log(chalk.green(`Channel with id ${chalk.cyanBright(channelId.toString())} successfully created!`))
 
     const dataObjectsUploadedEvent = this.findEvent(result, 'storage', 'DataObjectsUploaded')

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

@@ -4,7 +4,7 @@ import { ChannelCategoryInputParameters } from '../../Types'
 import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
 import { flags } from '@oclif/command'
 import { CreateInterface } from '@joystream/types'
-import { ChannelCategoryCreationParameters } from '@joystream/types/content'
+import { ChannelCategoryCreationParameters, ChannelCategoryId } from '@joystream/types/content'
 import { ChannelCategoryInputSchema } from '../../schemas/ContentDirectory'
 import chalk from 'chalk'
 import { ChannelCategoryMetadata } from '@joystream/metadata-protobuf'
@@ -18,6 +18,7 @@ export default class CreateChannelCategoryCommand extends ContentDirectoryComman
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async run(): Promise<void> {
@@ -44,10 +45,8 @@ export default class CreateChannelCategoryCommand extends ContentDirectoryComman
     )
 
     if (result) {
-      const event = this.findEvent(result, 'content', 'ChannelCategoryCreated')
-      this.log(
-        chalk.green(`ChannelCategory with id ${chalk.cyanBright(event?.data[0].toString())} successfully created!`)
-      )
+      const categoryId: ChannelCategoryId = this.getEvent(result, 'content', 'ChannelCategoryCreated').data[0]
+      this.log(chalk.green(`ChannelCategory with id ${chalk.cyanBright(categoryId.toString())} successfully created!`))
     }
   }
 }

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

@@ -4,6 +4,9 @@ import chalk from 'chalk'
 export default class CreateCuratorGroupCommand extends ContentDirectoryCommandBase {
   static description = 'Create new Curator Group.'
   static aliases = ['createCuratorGroup']
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
 
   async run(): Promise<void> {
     const lead = await this.getRequiredLeadContext()

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

@@ -4,7 +4,7 @@ import { asValidatedMetadata, metadataToBytes } from '../../helpers/serializatio
 import { VideoInputParameters, VideoFileMetadata } from '../../Types'
 import { createType } from '@joystream/types'
 import { flags } from '@oclif/command'
-import { VideoCreationParameters } from '@joystream/types/content'
+import { VideoCreationParameters, VideoId } from '@joystream/types/content'
 import { IVideoMetadata, VideoMetadata } from '@joystream/metadata-protobuf'
 import { VideoInputSchema } from '../../schemas/ContentDirectory'
 import chalk from 'chalk'
@@ -24,6 +24,7 @@ export default class CreateVideoCommand extends UploadCommandBase {
       description: 'ID of the Channel',
     }),
     context: ContentDirectoryCommandBase.channelManagementContextFlag,
+    ...UploadCommandBase.flags,
   }
 
   setVideoMetadataDefaults(metadata: IVideoMetadata, videoFileMetadata: VideoFileMetadata): IVideoMetadata {
@@ -89,10 +90,8 @@ export default class CreateVideoCommand extends UploadCommandBase {
       videoCreationParameters,
     ])
 
-    const videoCreatedEvent = this.findEvent(result, 'content', 'VideoCreated')
-    this.log(
-      chalk.green(`Video with id ${chalk.cyanBright(videoCreatedEvent?.data[2].toString())} successfully created!`)
-    )
+    const videoId: VideoId = this.getEvent(result, 'content', 'VideoCreated').data[2]
+    this.log(chalk.green(`Video with id ${chalk.cyanBright(videoId.toString())} successfully created!`))
 
     const dataObjectsUploadedEvent = this.findEvent(result, 'storage', 'DataObjectsUploaded')
     if (dataObjectsUploadedEvent) {

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

@@ -4,7 +4,7 @@ import { VideoCategoryInputParameters } from '../../Types'
 import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
 import { flags } from '@oclif/command'
 import { CreateInterface } from '@joystream/types'
-import { VideoCategoryCreationParameters } from '@joystream/types/content'
+import { VideoCategoryCreationParameters, VideoCategoryId } from '@joystream/types/content'
 import { VideoCategoryInputSchema } from '../../schemas/ContentDirectory'
 import chalk from 'chalk'
 import { VideoCategoryMetadata } from '@joystream/metadata-protobuf'
@@ -18,6 +18,7 @@ export default class CreateVideoCategoryCommand extends ContentDirectoryCommandB
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async run(): Promise<void> {
@@ -44,10 +45,8 @@ export default class CreateVideoCategoryCommand extends ContentDirectoryCommandB
     )
 
     if (result) {
-      const event = this.findEvent(result, 'content', 'VideoCategoryCreated')
-      this.log(
-        chalk.green(`VideoCategory with id ${chalk.cyanBright(event?.data[1].toString())} successfully created!`)
-      )
+      const categoryId: VideoCategoryId = this.getEvent(result, 'content', 'VideoCategoryCreated').data[1]
+      this.log(chalk.green(`VideoCategory with id ${chalk.cyanBright(categoryId.toString())} successfully created!`))
     }
   }
 }

+ 4 - 0
cli/src/commands/content/curatorGroup.ts

@@ -13,6 +13,10 @@ export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const { id } = this.parse(CuratorGroupCommand).args
     const group = await this.getCuratorGroup(id)

+ 4 - 1
cli/src/commands/content/curatorGroups.ts

@@ -4,8 +4,11 @@ import { displayTable } from '../../helpers/display'
 
 export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
   static description = 'List existing Curator Groups.'
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
 
-  async run() {
+  async run(): Promise<void> {
     const groups = await this.getApi().availableCuratorGroups()
 
     if (groups.length) {

+ 1 - 0
cli/src/commands/content/deleteChannel.ts

@@ -21,6 +21,7 @@ export default class DeleteChannelCommand extends ContentDirectoryCommandBase {
       default: false,
       description: 'Force-remove all associated channel data objects',
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async getDataObjectsInfoFromQueryNode(channelId: number): Promise<[string, BN][]> {

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

@@ -4,6 +4,7 @@ export default class DeleteChannelCategoryCommand extends ContentDirectoryComman
   static description = 'Delete channel category.'
   static flags = {
     context: ContentDirectoryCommandBase.categoriesContextFlag,
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

+ 1 - 0
cli/src/commands/content/deleteVideo.ts

@@ -23,6 +23,7 @@ export default class DeleteVideoCommand extends ContentDirectoryCommandBase {
       description: 'Force-remove all associated video data objects',
     }),
     context: ContentDirectoryCommandBase.channelManagementContextFlag,
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async getDataObjectsInfo(videoId: number): Promise<[string, BN][]> {

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

@@ -4,6 +4,7 @@ export default class DeleteVideoCategoryCommand extends ContentDirectoryCommandB
   static description = 'Delete video category.'
   static flags = {
     context: ContentDirectoryCommandBase.categoriesContextFlag,
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

+ 1 - 0
cli/src/commands/content/removeChannelAssets.ts

@@ -18,6 +18,7 @@ export default class RemoveChannelAssetsCommand extends ContentDirectoryCommandB
       description: 'ID of an object to remove',
     }),
     context: ContentDirectoryCommandBase.channelManagementContextFlag,
+    ...ContentDirectoryCommandBase.flags,
   }
 
   async run(): Promise<void> {

+ 4 - 0
cli/src/commands/content/removeCuratorFromGroup.ts

@@ -16,6 +16,10 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const lead = await this.getRequiredLeadContext()
 

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

@@ -14,6 +14,7 @@ export default class ReuploadVideoAssetsCommand extends UploadCommandBase {
       required: true,
       description: 'Path to JSON file containing array of assets to reupload (contentIds and paths)',
     }),
+    ...UploadCommandBase.flags,
   }
 
   async run(): Promise<void> {

+ 4 - 0
cli/src/commands/content/setCuratorGroupStatus.ts

@@ -17,6 +17,10 @@ export default class SetCuratorGroupStatusCommand extends ContentDirectoryComman
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const lead = await this.getRequiredLeadContext()
 

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

@@ -11,6 +11,10 @@ export default class SetFeaturedVideosCommand extends ContentDirectoryCommandBas
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const { featuredVideoIds } = this.parse(SetFeaturedVideosCommand).args
 

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

@@ -23,6 +23,7 @@ export default class UpdateChannelCommand extends UploadCommandBase {
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...UploadCommandBase.flags,
   }
 
   static args = [

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

@@ -16,6 +16,7 @@ export default class UpdateChannelCategoryCommand extends ContentDirectoryComman
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

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

@@ -11,6 +11,7 @@ export default class UpdateChannelCensorshipStatusCommand extends ContentDirecto
       required: false,
       description: 'rationale',
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

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

@@ -22,6 +22,7 @@ export default class UpdateVideoCommand extends UploadCommandBase {
       description: `Path to JSON file to use as input`,
     }),
     context: ContentDirectoryCommandBase.channelManagementContextFlag,
+    ...UploadCommandBase.flags,
   }
 
   static args = [

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

@@ -17,6 +17,7 @@ export default class UpdateVideoCategoryCommand extends ContentDirectoryCommandB
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

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

@@ -11,6 +11,7 @@ export default class UpdateVideoCensorshipStatusCommand extends ContentDirectory
       required: false,
       description: 'rationale',
     }),
+    ...ContentDirectoryCommandBase.flags,
   }
 
   static args = [

+ 4 - 0
cli/src/commands/content/video.ts

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

+ 4 - 0
cli/src/commands/content/videos.ts

@@ -13,6 +13,10 @@ export default class VideosCommand extends ContentDirectoryCommandBase {
     },
   ]
 
+  static flags = {
+    ...ContentDirectoryCommandBase.flags,
+  }
+
   async run(): Promise<void> {
     const { channelId } = this.parse(VideosCommand).args
 

+ 29 - 0
cli/src/commands/membership/addStakingAccount.ts

@@ -0,0 +1,29 @@
+import BN from 'bn.js'
+import { flags } from '@oclif/command'
+import MembershipsCommandBase from '../../base/MembershipsCommandBase'
+
+export default class MembershipAddStakingAccountCommand extends MembershipsCommandBase {
+  static description = 'Associate a new staking account with an existing membership.'
+  static flags = {
+    address: flags.string({
+      required: false,
+      description: 'Address of the staking account to be associated with the member',
+    }),
+    withBalance: flags.integer({
+      required: false,
+      description: 'Allows optionally specifying required initial balance for the staking account',
+    }),
+    fundsSource: flags.string({
+      required: false,
+      description:
+        'If provided, this account will be used as funds source for the purpose of initializing the staking accout',
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { address, withBalance, fundsSource } = this.parse(MembershipAddStakingAccountCommand).flags
+    const { id, membership } = await this.getRequiredMemberContext()
+    await this.setupStakingAccount(id, membership, address, new BN(withBalance || 0), fundsSource)
+  }
+}

+ 89 - 0
cli/src/commands/membership/buy.ts

@@ -0,0 +1,89 @@
+import AccountsCommandBase from '../../base/AccountsCommandBase'
+import { flags } from '@oclif/command'
+import { IMembershipMetadata, MembershipMetadata } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import chalk from 'chalk'
+import { formatBalance } from '@polkadot/util'
+import ExitCodes from '../../ExitCodes'
+
+export default class MembershipBuyCommand extends AccountsCommandBase {
+  static description = 'Buy / register a new membership on the Joystream platform.'
+  static aliases = ['membership:create', 'membership:register']
+  static flags = {
+    handle: flags.string({
+      required: true,
+      description: "Member's handle",
+    }),
+    name: flags.string({
+      required: false,
+      description: "Member's first name / full name",
+    }),
+    avatarUri: flags.string({
+      required: false,
+      description: "Member's avatar uri",
+    }),
+    about: flags.string({
+      required: false,
+      description: "Member's md-formatted about text (bio)",
+    }),
+    controllerKey: flags.string({
+      required: false,
+      description: "Member's controller key. Can also be provided interactively.",
+    }),
+    rootKey: flags.string({
+      required: false,
+      description: "Member's root key. Can also be provided interactively.",
+    }),
+    senderKey: flags.string({
+      required: false,
+      description: 'Tx sender key. If not provided, controllerKey will be used by default.',
+    }),
+  }
+
+  async run(): Promise<void> {
+    const api = this.getOriginalApi()
+    let { handle, name, avatarUri, about, controllerKey, rootKey, senderKey } = this.parse(MembershipBuyCommand).flags
+
+    if (await this.getApi().isHandleTaken(handle)) {
+      this.error(`Provided handle (${chalk.magentaBright(handle)}) is already taken!`, { exit: ExitCodes.InvalidInput })
+    }
+
+    if (!controllerKey) {
+      controllerKey = await this.promptForAnyAddress('Choose member controller key')
+    }
+    if (!rootKey) {
+      rootKey = await this.promptForAnyAddress('Choose member root key')
+    }
+    senderKey =
+      senderKey ??
+      (this.isKeyAvailable(controllerKey) ? controllerKey : await this.promptForAccount('Choose tx sender key'))
+
+    const metadata: IMembershipMetadata = {
+      name,
+      about,
+      avatarUri,
+    }
+    const membershipPrice = await api.query.members.membershipPrice()
+    this.warn(
+      `Buying membership will cost additional ${chalk.cyanBright(
+        formatBalance(membershipPrice)
+      )} on top of the regular transaction fee.`
+    )
+    this.jsonPrettyPrint(JSON.stringify({ rootKey, controllerKey, senderKey, handle, metadata }))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(senderKey),
+      api.tx.members.buyMembership({
+        root_account: rootKey,
+        controller_account: controllerKey,
+        handle,
+        metadata: metadataToBytes(MembershipMetadata, metadata),
+      })
+    )
+
+    const memberId = this.getEvent(result, 'members', 'MembershipBought').data[0]
+    this.log(chalk.green(`Membership with id ${chalk.cyanBright(memberId.toString())} successfully created!`))
+    this.output(memberId.toString())
+  }
+}

+ 42 - 0
cli/src/commands/membership/details.ts

@@ -0,0 +1,42 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import AccountsCommandBase from '../../base/AccountsCommandBase'
+import { displayCollapsedRow, displayHeader, memberHandle } from '../../helpers/display'
+
+export default class MembershipDetailsCommand extends AccountsCommandBase {
+  static description = 'Display membership details by specified memberId.'
+  static aliases = ['membership:info', 'membership:inspect', 'membership:show']
+  static flags = {
+    memberId: flags.integer({
+      required: true,
+      char: 'm',
+      description: 'Member id',
+    }),
+  }
+
+  async run(): Promise<void> {
+    const { memberId } = this.parse(MembershipDetailsCommand).flags
+    const details = await this.getApi().expectedMemberDetailsById(memberId)
+
+    displayCollapsedRow({
+      'ID': details.id.toString(),
+      'Handle': memberHandle(details),
+      'IsVerified': details.membership.verified.toString(),
+      'Invites': details.membership.invites.toNumber(),
+    })
+
+    if (details.meta) {
+      displayHeader(`Metadata`)
+      displayCollapsedRow({
+        'Name': details.meta.name || chalk.gray('NOT SET'),
+        'About': details.meta.about || chalk.gray('NOT SET'),
+      })
+    }
+
+    displayHeader('Keys')
+    displayCollapsedRow({
+      'Root': details.membership.root_account.toString(),
+      'Controller': details.membership.controller_account.toString(),
+    })
+  }
+}

+ 55 - 0
cli/src/commands/membership/update.ts

@@ -0,0 +1,55 @@
+import { flags } from '@oclif/command'
+import { IMembershipMetadata, MembershipMetadata } from '@joystream/metadata-protobuf'
+import chalk from 'chalk'
+import MembershipsCommandBase from '../../base/MembershipsCommandBase'
+import { metadataToBytes } from '../../helpers/serialization'
+
+export default class MembershipUpdateCommand extends MembershipsCommandBase {
+  static description = 'Update existing membership metadata and/or handle.'
+  static flags = {
+    newHandle: flags.string({
+      required: false,
+      description: "Member's new handle",
+    }),
+    newName: flags.string({
+      required: false,
+      description: "Member's new first name / full name",
+    }),
+    newAvatarUri: flags.string({
+      required: false,
+      description: "Member's new avatar uri",
+    }),
+    newAbout: flags.string({
+      required: false,
+      description: "Member's new md-formatted about text (bio)",
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = this.getOriginalApi()
+    const { newHandle, newName, newAvatarUri, newAbout } = this.parse(MembershipUpdateCommand).flags
+    const {
+      id: memberId,
+      membership: { controller_account: controllerKey },
+    } = await this.getRequiredMemberContext()
+
+    const newMetadata: IMembershipMetadata | null =
+      newName !== undefined || newAvatarUri !== undefined || newAbout !== undefined
+        ? {
+            name: newName,
+            about: newAbout,
+            avatarUri: newAvatarUri,
+          }
+        : null
+    this.jsonPrettyPrint(JSON.stringify({ memberId, newHandle, newMetadata }))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(controllerKey),
+      api.tx.members.updateProfile(memberId, newHandle ?? null, metadataToBytes(MembershipMetadata, newMetadata))
+    )
+
+    this.log(chalk.green(`Membership with id ${chalk.cyanBright(memberId.toString())} successfully updated!`))
+  }
+}

+ 37 - 0
cli/src/commands/membership/updateAccounts.ts

@@ -0,0 +1,37 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import MembershipsCommandBase from '../../base/MembershipsCommandBase'
+
+export default class MembershipUpdateAccountsCommand extends MembershipsCommandBase {
+  static description = 'Update existing membership accounts/keys (root / controller).'
+  static flags = {
+    newControllerAccount: flags.string({
+      required: false,
+      description: "Member's new controller account/key",
+    }),
+    newRootAccount: flags.string({
+      required: false,
+      description: "Member's new root account/key",
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = this.getOriginalApi()
+    const { newControllerAccount, newRootAccount } = this.parse(MembershipUpdateAccountsCommand).flags
+    const {
+      id: memberId,
+      membership: { root_account: rootKey },
+    } = await this.getRequiredMemberContext(false, undefined, 'root')
+
+    this.jsonPrettyPrint(JSON.stringify({ memberId, newControllerAccount, newRootAccount }))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(rootKey),
+      api.tx.members.updateAccounts(memberId, newRootAccount ?? null, newControllerAccount ?? null)
+    )
+
+    this.log(chalk.green(`Accounts of member ${chalk.cyanBright(memberId.toString())} successfully updated!`))
+  }
+}

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

@@ -15,7 +15,7 @@ export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsApplication)
 
     const application = await this.getApi().groupApplication(this.group, parseInt(args.wgApplicationId))

+ 5 - 1
cli/src/commands/working-groups/apply.ts

@@ -14,7 +14,11 @@ export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
     },
   ]
 
-  async run() {
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
     const { openingId } = this.parse(WorkingGroupsApply).args
     const memberContext = await this.getRequiredMemberContext()
 

+ 5 - 1
cli/src/commands/working-groups/cancelOpening.ts

@@ -12,7 +12,11 @@ export default class WorkingGroupsCancelOpening extends WorkingGroupsCommandBase
     },
   ]
 
-  async run() {
+  static flags = {
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsCancelOpening)
 
     // Lead-only gate

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

@@ -19,7 +19,6 @@ const OPENING_STAKE = new BN(2000)
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
   static description = 'Create working group opening (requires lead access)'
   static flags = {
-    ...WorkingGroupsCommandBase.flags,
     input: IOFlags.input,
     output: flags.string({
       char: 'o',
@@ -40,6 +39,7 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
         '(can be used to generate a "draft" which can be provided as input later)',
       dependsOn: ['output'],
     }),
+    ...WorkingGroupsCommandBase.flags,
   }
 
   createTxParams(

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

@@ -18,7 +18,6 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
   ]
 
   static flags = {
-    ...WorkingGroupsCommandBase.flags,
     penalty: flags.string({
       description: 'Optional penalty in JOY',
       required: false,
@@ -27,6 +26,7 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
       description: 'Optional rationale',
       required: false,
     }),
+    ...WorkingGroupsCommandBase.flags,
   }
 
   async run(): Promise<void> {

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

@@ -16,7 +16,7 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsOpening)
 
     const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId))

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

@@ -7,7 +7,7 @@ export default class WorkingGroupsOpenings extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const openings = await this.getApi().openingsByGroup(this.group)
 
     const openingsRows = openings.map((o) => ({

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

@@ -10,7 +10,7 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const lead = await this.getApi().groupLead(this.group)
     const members = await this.getApi().groupMembers(this.group)
 

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

@@ -6,7 +6,7 @@ export default class SetDefaultGroupCommand extends WorkingGroupsCommandBase {
   static description = 'Change the default group context for working-groups commands.'
   static flags = { ...WorkingGroupsCommandBase.flags }
 
-  async run() {
+  async run(): Promise<void> {
     const {
       flags: { group },
     } = this.parse(SetDefaultGroupCommand)

部分文件因为文件数量过多而无法显示