Browse Source

CLI: membership commands and test script

Leszek Wiesner 3 years ago
parent
commit
99b88c8643

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

@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
+cd $SCRIPT_PATH
+
+export AUTO_CONFIRM=true
+
+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
+
+# 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
+
+# Add staking account
+${CLI} membership:addStakingAccount\
+  --useMemberId="$MEMBER_ID"\
+  --address="5EHDeBnBEyNB2aCFEhcxiEdcQLLnyP96t8ghoxBXUmK2itQp"\
+  --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

+ 9 - 2
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/'
 
@@ -166,8 +167,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,
     }))
   }
@@ -452,7 +453,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.
@@ -104,7 +105,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
 }

+ 98 - 78
cli/src/base/AccountsCommandBase.ts

@@ -198,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())
 
@@ -277,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) => ({
@@ -320,97 +328,109 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  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)
+  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!`)
+    }
 
-      if (balances.lockedBalance.gtn(0)) {
-        this.warn('This account is already used for other staking purposes, choose different account...')
-        continue
-      }
+    if (!address) {
+      address = await this.promptForAnyAddress('Choose staking account')
+    }
+    const { balances } = await this.getApi().getAccountSummary(address)
+    const stakingStatus = await this.getApi().stakingAccountStatus(address)
 
-      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 = requiredStake.add(candidateTxFee).add(STAKING_ACCOUNT_CANDIDATE_STAKE)
+    const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
+    if (missingStakingAccountBalance.gtn(0)) {
+      this.warn(
+        `Not enough available staking account balance! Missing: ${chalk.cyanBright(
+          formatBalance(candidateTxFee.add(STAKING_ACCOUNT_CANDIDATE_STAKE))
+        )}. (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> {

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

@@ -503,6 +503,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)
 
@@ -549,12 +558,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)
   }

+ 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)
   }
 }

+ 18 - 5
cli/src/base/MembershipsCommandBase.ts

@@ -18,7 +18,11 @@ export default abstract class MembershipsCommandBase extends AccountsCommandBase
     }),
   }
 
-  async getRequiredMemberContext(useSelected = false, allowedIds?: MemberId[]): Promise<MemberDetails> {
+  async getRequiredMemberContext(
+    useSelected = false,
+    allowedIds?: MemberId[],
+    accountType: 'controller' | 'root' = 'controller'
+  ): Promise<MemberDetails> {
     const flags = this.parse(this.constructor as typeof MembershipsCommandBase).flags
 
     if (
@@ -29,7 +33,10 @@ export default abstract class MembershipsCommandBase extends AccountsCommandBase
       return this.selectedMember
     }
 
-    if (flags.useMemberId && (!allowedIds || allowedIds.some((id) => id.toNumber() === flags.useMemberId))) {
+    if (
+      flags.useMemberId !== undefined &&
+      (!allowedIds || allowedIds.some((id) => id.toNumber() === flags.useMemberId))
+    ) {
       this.selectedMember = await this.getApi().expectedMemberDetailsById(flags.useMemberId)
       return this.selectedMember
     }
@@ -38,12 +45,18 @@ export default abstract class MembershipsCommandBase extends AccountsCommandBase
       ? await this.getApi().membersDetailsByIds(allowedIds)
       : await this.getApi().allMembersDetails()
     const availableMemberships = await Promise.all(
-      membersDetails.filter((m) => this.isKeyAvailable(m.membership.controller_account.toString()))
+      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 controller key available!` +
+        `No ${allowedIds ? 'allowed ' : ''}member ${accountType} key available!` +
           (allowedIds ? ` Allowed members: ${allowedIds.join(', ')}.` : ''),
         {
           exit: ExitCodes.AccessDenied,
@@ -59,7 +72,7 @@ export default abstract class MembershipsCommandBase extends AccountsCommandBase
   }
 
   async promptForMember(availableMemberships: MemberDetails[], message = 'Choose a member'): Promise<MemberDetails> {
-    const memberIndex = await this.simplePrompt({
+    const memberIndex = await this.simplePrompt<number>({
       type: 'list',
       message,
       choices: availableMemberships.map((m, i) => ({

+ 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)

+ 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)
+  }
+}

+ 21 - 7
cli/src/commands/membership/buy.ts

@@ -3,8 +3,10 @@ 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 MembershipBuy extends AccountsCommandBase {
+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 = {
@@ -40,22 +42,33 @@ export default class MembershipBuy extends AccountsCommandBase {
 
   async run(): Promise<void> {
     const api = this.getOriginalApi()
-    let { handle, name, avatarUri, about, controllerKey, rootKey } = this.parse(MembershipBuy).flags
+    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')
     }
-    const senderKey = this.isKeyAvailable(controllerKey)
-      ? controllerKey
-      : await this.promptForAccount('Choose tx sender 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?')
 
@@ -69,7 +82,8 @@ export default class MembershipBuy extends AccountsCommandBase {
       })
     )
 
-    const membeId = this.getEvent(result, 'members', 'MembershipBought').data[0]
-    this.log(chalk.green(`Membership with id ${chalk.cyanBright(membeId.toString())} successfully created!`))
+    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!`))
+  }
+}