Browse Source

Merge branch 'giza-cli-channel-collab' into giza-cli

Leszek Wiesner 3 years ago
parent
commit
0c565d4c7c

+ 4 - 0
cli/src/Api.ts

@@ -557,4 +557,8 @@ export default class Api {
       value,
     ])
   }
+
+  async getMembers(ids: MemberId[]): Promise<Membership[]> {
+    return this._api.query.members.membershipById.multi(ids)
+  }
 }

+ 14 - 9
cli/src/Types.ts

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

+ 31 - 14
cli/src/base/AccountsCommandBase.ts

@@ -11,6 +11,7 @@ import { NamedKeyringPair } from '../Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { toFixedLength } from '../helpers/display'
 import { MemberId } from '@joystream/types/members'
+import _ from 'lodash'
 
 const ACCOUNTS_DIRNAME = 'accounts'
 const SPECIAL_ACCOUNT_POSTFIX = '__DEV'
@@ -53,7 +54,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
   }
 
   // Add dev "Alice" and "Bob" accounts
-  initSpecialAccounts() {
+  initSpecialAccounts(): void {
     const keyring = new Keyring({ type: 'sr25519' })
     keyring.addFromUri('//Alice', { name: 'Alice' })
     keyring.addFromUri('//Bob', { name: 'Bob' })
@@ -174,7 +175,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     await this.setPreservedState({ selectedAccountFilename: accountFilename })
   }
 
-  async promptForPassword(message = "Your account's password") {
+  async promptForPassword(message = "Your account's password"): Promise<string> {
     const { password } = await inquirer.prompt([{ name: 'password', type: 'password', message }])
 
     return password
@@ -252,7 +253,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  async getRequiredMemberId(useSelected = false): Promise<number> {
+  async getRequiredMemberId(useSelected = false, allowedIds?: MemberId[]): Promise<number> {
     if (this.selectedMemberId && useSelected) {
       return this.selectedMemberId
     }
@@ -260,33 +261,49 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     const account = await this.getRequiredSelectedAccount()
     const memberIds = await this.getApi().getMemberIdsByControllerAccount(account.address)
 
-    let memberId: number
-    if (!memberIds.length) {
+    const possibleIds = allowedIds
+      ? _.intersection(
+          memberIds.map((id) => id.toNumber()),
+          allowedIds.map((id) => id.toNumber())
+        )
+      : memberIds.map((id) => id.toNumber())
+
+    if (allowedIds && !possibleIds.length) {
+      this.error(
+        `Chosen account needs to be controller account of one of the following members: ${allowedIds?.join(', ')}`,
+        { exit: ExitCodes.AccessDenied }
+      )
+    }
+
+    if (!possibleIds.length) {
       this.error('Membership required to access this command!', { exit: ExitCodes.AccessDenied })
-    } else if (memberIds.length === 1) {
-      memberId = memberIds[0].toNumber()
+    }
+
+    let chosenId: number
+    if (possibleIds.length === 1) {
+      chosenId = possibleIds[0]
     } else {
-      memberId = await this.promptForMember(memberIds, 'Choose member context')
+      chosenId = await this.promptForMember(possibleIds, 'Choose member context')
     }
 
-    this.selectedMemberId = memberId
-    return memberId
+    this.selectedMemberId = chosenId
+    return chosenId
   }
 
-  async promptForMember(availableMembers: MemberId[], message = 'Choose a member'): Promise<number> {
+  async promptForMember(availableMembers: number[], message = 'Choose a member'): Promise<number> {
     const memberId: number = await this.simplePrompt({
       type: 'list',
       message,
       choices: availableMembers.map((memberId) => ({
-        name: `ID: ${memberId.toString()}`,
-        value: memberId.toNumber(),
+        name: `ID: ${memberId}`,
+        value: memberId,
       })),
     })
 
     return memberId
   }
 
-  async init() {
+  async init(): Promise<void> {
     await super.init()
     try {
       this.initAccountsFs()

+ 52 - 37
cli/src/base/ContentDirectoryCommandBase.ts

@@ -4,39 +4,34 @@ import { CuratorGroup, CuratorGroupId, ContentActor, Channel } from '@joystream/
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
 import { RolesCommandBase } from './WorkingGroupsCommandBase'
-import { createTypeFromConstructor } from '@joystream/types'
+import { createType, createTypeFromConstructor } from '@joystream/types'
 import { flags } from '@oclif/command'
 
-// TODO: Rework the contexts
-
-const CONTEXTS = ['Member', 'Curator', 'Lead'] as const
-const OWNER_CONTEXTS = ['Member', 'Curator'] as const
+const CHANNEL_CREATION_CONTEXTS = ['Member', 'Curator'] as const
 const CATEGORIES_CONTEXTS = ['Lead', 'Curator'] as const
+const CHANNEL_MANAGEMENT_CONTEXTS = ['Owner', 'Collaborator'] as const
 
-type Context = typeof CONTEXTS[number]
-type OwnerContext = typeof OWNER_CONTEXTS[number]
+type ChannelManagementContext = typeof CHANNEL_MANAGEMENT_CONTEXTS[number]
+type ChannelCreationContext = typeof CHANNEL_CREATION_CONTEXTS[number]
 type CategoriesContext = typeof CATEGORIES_CONTEXTS[number]
 
 /**
  * Abstract base class for commands related to content directory
  */
 export default abstract class ContentDirectoryCommandBase extends RolesCommandBase {
-  static contextFlag = flags.enum({
-    name: 'context',
+  static channelCreationContextFlag = flags.enum({
     required: false,
-    description: `Actor context to execute the command in (${CONTEXTS.join('/')})`,
-    options: [...CONTEXTS],
+    description: `Actor context to execute the command in (${CHANNEL_CREATION_CONTEXTS.join('/')})`,
+    options: [...CHANNEL_CREATION_CONTEXTS],
   })
 
-  static ownerContextFlag = flags.enum({
-    name: 'ownerContext',
+  static channelManagementContextFlag = flags.enum({
     required: false,
-    description: `Actor context to execute the command in (${OWNER_CONTEXTS.join('/')})`,
-    options: [...OWNER_CONTEXTS],
+    description: `Actor context to execute the command in (${CHANNEL_MANAGEMENT_CONTEXTS.join('/')})`,
+    options: [...CHANNEL_MANAGEMENT_CONTEXTS],
   })
 
   static categoriesContextFlag = flags.enum({
-    name: 'categoriesContext',
     required: false,
     description: `Actor context to execute the command in (${CATEGORIES_CONTEXTS.join('/')})`,
     options: [...CATEGORIES_CONTEXTS],
@@ -47,21 +42,13 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     this.group = WorkingGroups.Curators // override group for RolesCommandBase
   }
 
-  async promptForContext(message = 'Choose in which context you wish to execute the command'): Promise<Context> {
-    return this.simplePrompt({
-      message,
-      type: 'list',
-      choices: CONTEXTS.map((c) => ({ name: c, value: c })),
-    })
-  }
-
-  async promptForOwnerContext(
+  async promptForChannelCreationContext(
     message = 'Choose in which context you wish to execute the command'
-  ): Promise<OwnerContext> {
+  ): Promise<ChannelCreationContext> {
     return this.simplePrompt({
       message,
       type: 'list',
-      choices: OWNER_CONTEXTS.map((c) => ({ name: c, value: c })),
+      choices: CHANNEL_CREATION_CONTEXTS.map((c) => ({ name: c, value: c })),
     })
   }
 
@@ -92,7 +79,33 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
         return await this.getCuratorContext(channel.owner.asType('Curators'))
       }
     } else {
-      return await this.getActor('Member')
+      const memberId = await this.getRequiredMemberId(false, [channel.owner.asType('Member')])
+      return createType<ContentActor, 'ContentActor'>('ContentActor', { Member: memberId })
+    }
+  }
+
+  async getChannelCollaborator(channel: Channel): Promise<ContentActor> {
+    const memberId = await this.getRequiredMemberId(false, Array.from(channel.collaborators))
+    return createType<ContentActor, 'ContentActor'>('ContentActor', { Collaborator: memberId })
+  }
+
+  async getChannelManagementActor(channel: Channel, context: ChannelManagementContext): Promise<ContentActor> {
+    if (context && context === 'Owner') {
+      return this.getChannelOwnerActor(channel)
+    }
+    if (context && context === 'Collaborator') {
+      return this.getChannelCollaborator(channel)
+    }
+
+    // Context not set - derive
+    try {
+      const owner = await this.getChannelOwnerActor(channel)
+      this.log('Derived context: Channel owner')
+      return owner
+    } catch (e) {
+      const collaborator = await this.getChannelCollaborator(channel)
+      this.log('Derived context: Channel collaborator')
+      return collaborator
     }
   }
 
@@ -229,19 +242,21 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     return group
   }
 
-  async getActor(context: typeof CONTEXTS[number]): Promise<ContentActor> {
-    let actor: ContentActor
+  async getActor(context: Exclude<keyof typeof ContentActor.typeDefinitions, 'Collaborator'>): Promise<ContentActor> {
     if (context === 'Member') {
       const memberId = await this.getRequiredMemberId()
-      actor = this.createType('ContentActor', { Member: memberId })
-    } else if (context === 'Curator') {
-      actor = await this.getCuratorContext()
-    } else {
-      await this.getRequiredLead()
+      return this.createType('ContentActor', { Member: memberId })
+    }
 
-      actor = this.createType('ContentActor', { Lead: null })
+    if (context === 'Curator') {
+      return this.getCuratorContext()
+    }
+
+    if (context === 'Lead') {
+      await this.getRequiredLead()
+      return this.createType('ContentActor', { Lead: null })
     }
 
-    return actor
+    throw new Error(`Unrecognized context: ${context}`)
   }
 }

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

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

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

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

+ 13 - 8
cli/src/commands/content/createChannel.ts

@@ -2,7 +2,7 @@ import { getInputJson } from '../../helpers/InputOutput'
 import { ChannelInputParameters } from '../../Types'
 import { asValidatedMetadata, metadataToBytes } from '../../helpers/serialization'
 import { flags } from '@oclif/command'
-import { createTypeFromConstructor } from '@joystream/types'
+import { createType } from '@joystream/types'
 import { ChannelCreationParameters } from '@joystream/types/content'
 import { ChannelInputSchema } from '../../schemas/ContentDirectory'
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
@@ -13,7 +13,7 @@ import { ChannelMetadata } from '@joystream/metadata-protobuf'
 export default class CreateChannelCommand extends UploadCommandBase {
   static description = 'Create channel inside content directory.'
   static flags = {
-    context: ContentDirectoryCommandBase.ownerContextFlag,
+    context: ContentDirectoryCommandBase.channelCreationContextFlag,
     input: flags.string({
       char: 'i',
       required: true,
@@ -21,12 +21,12 @@ export default class CreateChannelCommand extends UploadCommandBase {
     }),
   }
 
-  async run() {
+  async run(): Promise<void> {
     let { context, input } = this.parse(CreateChannelCommand).flags
 
     // Context
     if (!context) {
-      context = await this.promptForOwnerContext()
+      context = await this.promptForChannelCreationContext()
     }
     const account = await this.getRequiredSelectedAccount()
     const actor = await this.getActor(context)
@@ -46,10 +46,15 @@ export default class CreateChannelCommand extends UploadCommandBase {
 
     // Preare and send the extrinsic
     const assets = await this.prepareAssetsForExtrinsic(resolvedAssets)
-    const channelCreationParameters = createTypeFromConstructor(ChannelCreationParameters, {
-      assets,
-      meta: metadataToBytes(ChannelMetadata, meta),
-    })
+    const channelCreationParameters = createType<ChannelCreationParameters, 'ChannelCreationParameters'>(
+      'ChannelCreationParameters',
+      {
+        assets,
+        meta: metadataToBytes(ChannelMetadata, meta),
+        collaborators: channelInput.collaborators,
+        reward_account: channelInput.rewardAccount,
+      }
+    )
 
     this.jsonPrettyPrint(JSON.stringify({ assets: assets?.toJSON(), metadata: meta }))
 

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

@@ -9,6 +9,7 @@ import { IVideoMetadata, VideoMetadata } from '@joystream/metadata-protobuf'
 import { VideoInputSchema } from '../../schemas/ContentDirectory'
 import { integrateMeta } from '@joystream/metadata-protobuf/utils'
 import chalk from 'chalk'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 
 export default class CreateVideoCommand extends UploadCommandBase {
   static description = 'Create video under specific channel inside content directory.'
@@ -23,6 +24,7 @@ export default class CreateVideoCommand extends UploadCommandBase {
       required: true,
       description: 'ID of the Channel',
     }),
+    context: ContentDirectoryCommandBase.channelManagementContextFlag,
   }
 
   setVideoMetadataDefaults(metadata: IVideoMetadata, videoFileMetadata: VideoFileMetadata): void {
@@ -40,13 +42,13 @@ export default class CreateVideoCommand extends UploadCommandBase {
     integrateMeta(metadata.mediaType || {}, mediaTypeMetaToIntegrate, ['codecName', 'container', 'mimeMediaType'])
   }
 
-  async run() {
-    const { input, channelId } = this.parse(CreateVideoCommand).flags
+  async run(): Promise<void> {
+    const { input, channelId, context } = this.parse(CreateVideoCommand).flags
 
     // Get context
     const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelOwnerActor(channel)
+    const actor = await this.getChannelManagementActor(channel, context)
     const memberId = await this.getRequiredMemberId(true)
     await this.requestAccountDecoding(account)
 

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

@@ -84,7 +84,7 @@ export default class DeleteChannelCommand extends ContentDirectoryCommandBase {
       this.log(
         `Data objects deletion prize of ${chalk.cyanBright(
           formatBalance(deletionPrize)
-        )} will be transferred to ${chalk.magentaBright(channel.deletion_prize_source_account_id.toString())}`
+        )} will be transferred to ${chalk.magentaBright(account.address)}`
       )
     }
 

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

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

+ 3 - 2
cli/src/commands/content/removeChannelAssets.ts

@@ -17,16 +17,17 @@ export default class RemoveChannelAssetsCommand extends ContentDirectoryCommandB
       multiple: true,
       description: 'ID of an object to remove',
     }),
+    context: ContentDirectoryCommandBase.channelManagementContextFlag,
   }
 
   async run(): Promise<void> {
     const {
-      flags: { channelId, objectId: objectIds },
+      flags: { channelId, objectId: objectIds, context },
     } = this.parse(RemoveChannelAssetsCommand)
     // Context
     const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelOwnerActor(channel)
+    const actor = await this.getChannelManagementActor(channel, context)
     await this.requestAccountDecoding(account)
 
     this.jsonPrettyPrint(JSON.stringify({ channelId, assetsToRemove: objectIds }))

+ 18 - 3
cli/src/commands/content/updateChannel.ts

@@ -11,9 +11,13 @@ import { DataObjectInfoFragment } from '../../graphql/generated/queries'
 import BN from 'bn.js'
 import { formatBalance } from '@polkadot/util'
 import chalk from 'chalk'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import ExitCodes from '../../ExitCodes'
+
 export default class UpdateChannelCommand extends UploadCommandBase {
   static description = 'Update existing content directory channel.'
   static flags = {
+    context: ContentDirectoryCommandBase.channelManagementContextFlag,
     input: flags.string({
       char: 'i',
       required: true,
@@ -69,22 +73,30 @@ export default class UpdateChannelCommand extends UploadCommandBase {
     return assetsToRemove.map((a) => a.id)
   }
 
-  async run() {
+  async run(): Promise<void> {
     const {
-      flags: { input },
+      flags: { input, context },
       args: { channelId },
     } = this.parse(UpdateChannelCommand)
 
     // Context
     const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelOwnerActor(channel)
+    const actor = await this.getChannelManagementActor(channel, context)
     const memberId = await this.getRequiredMemberId(true)
     await this.requestAccountDecoding(account)
 
     const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)
     const meta = asValidatedMetadata(ChannelMetadata, channelInput)
 
+    if (channelInput.rewardAccount !== undefined && actor.type === 'Collaborator') {
+      this.error("Collaborators are not allowed to update channel's reward account!", { exit: ExitCodes.AccessDenied })
+    }
+
+    if (channelInput.collaborators !== undefined && actor.type === 'Collaborator') {
+      this.error("Collaborators are not allowed to update channel's collaborators!", { exit: ExitCodes.AccessDenied })
+    }
+
     const { coverPhotoPath, avatarPhotoPath, rewardAccount } = channelInput
     const inputPaths = [coverPhotoPath, avatarPhotoPath].filter((p) => p !== undefined) as string[]
     const resolvedAssets = await this.resolveAndValidateAssets(inputPaths, input)
@@ -97,11 +109,14 @@ export default class UpdateChannelCommand extends UploadCommandBase {
     // Preare and send the extrinsic
     const assetsToUpload = await this.prepareAssetsForExtrinsic(resolvedAssets)
     const assetsToRemove = await this.getAssetsToRemove(channelId, coverPhotoIndex, avatarPhotoIndex)
+
+    const collaborators = createType('Option<BTreeSet<MemberId>>', channelInput.collaborators)
     const channelUpdateParameters: CreateInterface<ChannelUpdateParameters> = {
       assets_to_upload: assetsToUpload,
       assets_to_remove: createType('BTreeSet<DataObjectId>', assetsToRemove),
       new_meta: metadataToBytes(ChannelMetadata, meta),
       reward_account: this.parseRewardAccountInput(rewardAccount),
+      collaborators,
     }
 
     this.jsonPrettyPrint(

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

@@ -11,6 +11,7 @@ import { DataObjectInfoFragment } from '../../graphql/generated/queries'
 import BN from 'bn.js'
 import { formatBalance } from '@polkadot/util'
 import chalk from 'chalk'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 
 export default class UpdateVideoCommand extends UploadCommandBase {
   static description = 'Update video under specific id.'
@@ -20,6 +21,7 @@ export default class UpdateVideoCommand extends UploadCommandBase {
       required: true,
       description: `Path to JSON file to use as input`,
     }),
+    context: ContentDirectoryCommandBase.channelManagementContextFlag,
   }
 
   static args = [
@@ -57,9 +59,9 @@ export default class UpdateVideoCommand extends UploadCommandBase {
     return assetsToRemove.map((a) => a.id)
   }
 
-  async run() {
+  async run(): Promise<void> {
     const {
-      flags: { input },
+      flags: { input, context },
       args: { videoId },
     } = this.parse(UpdateVideoCommand)
 
@@ -67,7 +69,7 @@ export default class UpdateVideoCommand extends UploadCommandBase {
     const account = await this.getRequiredSelectedAccount()
     const video = await this.getApi().videoById(videoId)
     const channel = await this.getApi().channelById(video.in_channel.toNumber())
-    const actor = await this.getChannelOwnerActor(channel)
+    const actor = await this.getChannelManagementActor(channel, context)
     const memberId = await this.getRequiredMemberId(true)
     await this.requestAccountDecoding(account)
 

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

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

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

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