Ver código fonte

Manage groups & class permissions, list classes, entities and groups

Leszek Wiesner 4 anos atrás
pai
commit
7874a52628

+ 31 - 3
cli/src/Api.ts

@@ -47,7 +47,7 @@ import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recur
 import { Stake, StakeId } from '@joystream/types/stake'
 
 import { InputValidationLengthConstraint } from '@joystream/types/common'
-import { Class, ClassId, CuratorGroup, CuratorGroupId } from '@joystream/types/content-directory'
+import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity, EntityId } from '@joystream/types/content-directory'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 const DEFAULT_DECIMALS = new BN(12)
@@ -286,7 +286,7 @@ export default class Api {
   }
 
   async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
-    const workerEntries = await this.entriesByIds<WorkerId, Worker>(this.workingGroupApiQuery(group).workerById)
+    const workerEntries = await this.groupWorkers(group)
 
     const groupMembers: GroupMember[] = await Promise.all(
       workerEntries.map(([id, worker]) => this.parseGroupMember(id, worker))
@@ -295,6 +295,10 @@ export default class Api {
     return groupMembers.reverse() // Sort by newest
   }
 
+  groupWorkers(group: WorkingGroups): Promise<[WorkerId, Worker][]> {
+    return this.entriesByIds<WorkerId, Worker>(this.workingGroupApiQuery(group).workerById)
+  }
+
   async openingsByGroup(group: WorkingGroups): Promise<GroupOpening[]> {
     let openings: GroupOpening[] = []
     const nextId = await this.workingGroupApiQuery(group).nextOpeningId<OpeningId>()
@@ -481,7 +485,31 @@ export default class Api {
     return this.entriesByIds<ClassId, Class>(this._api.query.contentDirectory.classById)
   }
 
-  availableGroups(): Promise<[CuratorGroupId, CuratorGroup][]> {
+  availableCuratorGroups(): Promise<[CuratorGroupId, CuratorGroup][]> {
     return this.entriesByIds<CuratorGroupId, CuratorGroup>(this._api.query.contentDirectory.curatorGroupById)
   }
+
+  async curatorGroupById(id: number): Promise<CuratorGroup | null> {
+    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id))
+    return exists ? await this._api.query.contentDirectory.curatorGroupById<CuratorGroup>(id) : null
+  }
+
+  async nextCuratorGroupId(): Promise<number> {
+    return (await this._api.query.contentDirectory.nextCuratorGroupId<CuratorGroupId>()).toNumber()
+  }
+
+  async classById(id: number): Promise<Class | null> {
+    const c = await this._api.query.contentDirectory.classById<Class>(id)
+    return c.isEmpty ? null : c
+  }
+
+  async entitiesByClassId(classId: number): Promise<[EntityId, Entity][]> {
+    const entityEntries = await this.entriesByIds<EntityId, Entity>(this._api.query.contentDirectory.entityById)
+    return entityEntries.filter(([, entity]) => entity.class_id.toNumber() === classId)
+  }
+
+  async entityById(id: number): Promise<Entity | null> {
+    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id))
+    return exists ? await this._api.query.contentDirectory.entityById<Entity>(id) : null
+  }
 }

+ 11 - 3
cli/src/base/ApiCommandBase.ts

@@ -14,6 +14,8 @@ import ajv from 'ajv'
 import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions } from '../Types'
 import { createParamOptions } from '../helpers/promptOptions'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { DistinctQuestion } from 'inquirer'
+import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 
 class ExtrinsicFailedError extends Error {}
 
@@ -132,9 +134,15 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     // If no default provided - get default value resulting from providing empty string
     const defaultValueString =
       paramOptions?.value?.default?.toString() || this.createType(typeDef.type as any, '').toString()
+
+    let typeSpecificOptions: DistinctQuestion = { type: 'input' }
+    if (typeDef.type === 'bool') {
+      typeSpecificOptions = BOOL_PROMPT_OPTIONS
+    }
+
     const providedValue = await this.simplePrompt({
       message: `Provide value for ${this.paramName(typeDef)}`,
-      type: 'input',
+      ...typeSpecificOptions,
       // We want to avoid showing default value like '0x', because it falsely suggests
       // that user needs to provide the value as hex
       default: (defaultValueString === '0x' ? '' : defaultValueString) || undefined,
@@ -315,8 +323,8 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   // More typesafe version
-  async promptForType(type: keyof InterfaceTypes) {
-    return await this.promptForParam(type)
+  async promptForType(type: keyof InterfaceTypes, options?: ApiParamOptions) {
+    return await this.promptForParam(type, options)
   }
 
   async promptForJsonBytes(

+ 126 - 8
cli/src/base/ContentDirectoryCommandBase.ts

@@ -2,8 +2,11 @@ import ExitCodes from '../ExitCodes'
 import AccountsCommandBase from './AccountsCommandBase'
 import { WorkingGroups, NamedKeyringPair } from '../Types'
 import { ReferenceProperty } from 'cd-schemas/types/extrinsics/AddClassSchema'
-import { BOOL_PROMPT_OPTIONS } from '../helpers/JsonSchemaPrompt'
-import { Class } from '@joystream/types/content-directory'
+import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
+import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity } from '@joystream/types/content-directory'
+import { Worker } from '@joystream/types/working-group'
+import { CLIError } from '@oclif/errors'
+import { Codec } from '@polkadot/types/types'
 
 /**
  * Abstract base class for commands related to working groups
@@ -22,19 +25,53 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
   async promptForClass(message = 'Select a class'): Promise<Class> {
     const classes = await this.getApi().availableClasses()
     const choices = classes.map(([, c]) => ({ name: c.name.toString(), value: c }))
+    if (!choices.length) {
+      this.warn('No classes exist to choose from!')
+      this.exit(ExitCodes.InvalidInput)
+    }
 
     const selectedClass = await this.simplePrompt({ message, type: 'list', choices })
 
     return selectedClass
   }
 
-  async promptForCuratorGroups(message = 'Select a curator group'): Promise<number> {
-    const groups = await this.getApi().availableGroups()
-    const choices = groups.map(([id]) => ({
-      name: `Group ${id.toString()}`,
-      value: id.toNumber(),
-    }))
+  async classEntryByNameOrId(classNameOrId: string): Promise<[ClassId, Class]> {
+    const classes = await this.getApi().availableClasses()
+    const foundClass = classes.find(([id, c]) => id.toString() === classNameOrId || c.name.toString() === classNameOrId)
+    if (!foundClass) {
+      this.error(`Class id not found by class name or id: "${classNameOrId}"!`)
+    }
+
+    return foundClass
+  }
+
+  private async curatorGroupChoices(ids?: CuratorGroupId[]) {
+    const groups = await this.getApi().availableCuratorGroups()
+    return groups
+      .filter(([id]) => (ids ? ids.some((allowedId) => allowedId.eq(id)) : true))
+      .map(([id, group]) => ({
+        name:
+          `Group ${id.toString()} (` +
+          `${group.active.valueOf() ? 'Active' : 'Inactive'}, ` +
+          `${group.curators.toArray().length} member(s), ` +
+          `${group.number_of_classes_maintained.toNumber()} classes maintained)`,
+        value: id.toNumber(),
+      }))
+  }
 
+  async promptForCuratorGroup(message = 'Select a Curator Group', ids?: CuratorGroupId[]): Promise<number> {
+    const choices = await this.curatorGroupChoices(ids)
+    if (!choices.length) {
+      this.warn('No Curator Groups to choose from!')
+      this.exit(ExitCodes.InvalidInput)
+    }
+    const selectedId = await this.simplePrompt({ message, type: 'list', choices })
+
+    return selectedId
+  }
+
+  async promptForCuratorGroups(message = 'Select Curator Groups'): Promise<number[]> {
+    const choices = await this.curatorGroupChoices()
     const selectedIds = await this.simplePrompt({ message, type: 'checkbox', choices })
 
     return selectedIds
@@ -45,4 +82,85 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
     const sameOwner = await this.simplePrompt({ message: 'Same owner required?', ...BOOL_PROMPT_OPTIONS })
     return { className: selectedClass.name.toString(), sameOwner }
   }
+
+  async promptForCurator(message = 'Choose a Curator'): Promise<number> {
+    const curators = await this.getApi().groupMembers(WorkingGroups.Curators)
+    const selectedCuratorId = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices: curators.map((c) => ({
+        name: `${c.profile.handle.toString()} (Worker ID: ${c.workerId})`,
+        value: c.workerId,
+      })),
+    })
+
+    return selectedCuratorId
+  }
+
+  async getCurator(id: string | number): Promise<Worker> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    let curator
+    try {
+      curator = await this.getApi().workerByWorkerId(WorkingGroups.Curators, id)
+    } catch (e) {
+      if (e instanceof CLIError) {
+        throw new CLIError('Invalid Curator id!')
+      }
+      throw e
+    }
+
+    return curator
+  }
+
+  async getCuratorGroup(id: string | number): Promise<CuratorGroup> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    const group = await this.getApi().curatorGroupById(id)
+
+    if (!group) {
+      this.error('Invalid Curator Group id!', { exit: ExitCodes.InvalidInput })
+    }
+
+    return group
+  }
+
+  async getEntity(id: string | number): Promise<Entity> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    const entity = await this.getApi().entityById(id)
+
+    if (!entity) {
+      this.error('Invalid entity id!', { exit: ExitCodes.InvalidInput })
+    }
+
+    return entity
+  }
+
+  parseEntityPropertyValues(
+    entity: Entity,
+    entityClass: Class,
+    includedProperties?: string[]
+  ): Record<string, { value: Codec; type: string }> {
+    const { properties } = entityClass
+    return Array.from(entity.getField('values').entries()).reduce((columns, [propId, propValue]) => {
+      const prop = properties[propId.toNumber()]
+      const propName = prop.name.toString()
+      const included = !includedProperties || includedProperties.some((p) => p.toLowerCase() === propName.toLowerCase())
+
+      if (included) {
+        columns[propName] = {
+          value: propValue.getValue(),
+          type: `${prop.property_type.type}<${prop.property_type.subtype}>`,
+        }
+      }
+      return columns
+    }, {} as Record<string, { value: Codec; type: string }>)
+  }
 }

+ 42 - 0
cli/src/commands/content-directory/addCuratorToGroup.ts

@@ -0,0 +1,42 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Add Curator to existing Curator Group.'
+  static args = [
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'curatorId',
+      required: false,
+      description: 'ID of the curator',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, curatorId } = this.parse(AddCuratorToGroupCommand).args
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    if (curatorId === undefined) {
+      curatorId = await this.promptForCurator()
+    } else {
+      await this.getCurator(curatorId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addCuratorToGroup', [groupId, curatorId])
+
+    console.log(chalk.green(`Curator ${chalk.white(curatorId)} succesfully added to group ${chalk.white(groupId)}!`))
+  }
+}

+ 44 - 0
cli/src/commands/content-directory/addMaintainerToClass.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Add maintainer (Curator Group) to a class.'
+  static args = [
+    {
+      name: 'className',
+      required: false,
+      description: 'Name or ID of the class (ie. Video)',
+    },
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group to add as class maintainer',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
+
+    if (className === undefined) {
+      className = (await this.promptForClass()).name.toString()
+    }
+
+    const classId = (await this.classEntryByNameOrId(className))[0].toNumber()
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addMaintainerToClass', [classId, groupId])
+
+    console.log(
+      chalk.green(`Curator Group ${chalk.white(groupId)} added as maintainer to ${chalk.white(className)} class!`)
+    )
+  }
+}

+ 47 - 0
cli/src/commands/content-directory/class.ts

@@ -0,0 +1,47 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader, displayTable } from '../../helpers/display'
+
+export default class ClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Class details by id or name.'
+  static args = [
+    {
+      name: 'className',
+      required: true,
+      description: 'Name or ID of the Class',
+    },
+  ]
+
+  async run() {
+    const { className } = this.parse(ClassCommand).args
+    const [id, aClass] = await this.classEntryByNameOrId(className)
+    const permissions = aClass.class_permissions
+    const maintainers = permissions.maintainers.toArray()
+
+    displayCollapsedRow({
+      'Name': aClass.name.toString(),
+      'ID': id.toString(),
+      'Any member': permissions.any_member.toString(),
+      'Entity creation blocked': permissions.entity_creation_blocked.toString(),
+      'All property values locked': permissions.all_entity_property_values_locked.toString(),
+    })
+
+    displayHeader(`Maintainers`)
+    this.log(
+      maintainers.length ? maintainers.map((groupId) => chalk.white(`Group ${groupId.toString()}`)).join(', ') : 'NONE'
+    )
+
+    displayHeader(`Properties`)
+    displayTable(
+      aClass.properties.map((p) => ({
+        'Name': p.name.toString(),
+        'Type': JSON.stringify(p.property_type.toJSON()),
+        'Required': p.required.toString(),
+        'Unique': p.unique.toString(),
+        'Controller lock': p.locking_policy.is_locked_from_controller.toString(),
+        'Maintainer lock': p.locking_policy.is_locked_from_maintainer.toString(),
+      })),
+      3
+    )
+  }
+}

+ 24 - 0
cli/src/commands/content-directory/classes.ts

@@ -0,0 +1,24 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+// import chalk from 'chalk'
+import { displayTable } from '../../helpers/display'
+
+export default class ClassesCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing content directory classes.'
+
+  async run() {
+    const classes = await this.getApi().availableClasses()
+
+    displayTable(
+      classes.map(([id, c]) => ({
+        'ID': id.toString(),
+        'Name': c.name.toString(),
+        'Any member': c.class_permissions.any_member.toString(),
+        'Entities': c.current_number_of_entities.toNumber(),
+        'Schemas': c.schemas.length,
+        'Maintainers': c.class_permissions.maintainers.toArray().length,
+        'Properties': c.properties.length,
+      })),
+      3
+    )
+  }
+}

+ 18 - 0
cli/src/commands/content-directory/createCuratorGroup.ts

@@ -0,0 +1,18 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Create new Curator Group.'
+  static aliases = ['addCuratorGroup']
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    await this.requestAccountDecoding(account)
+    await this.buildAndSendExtrinsic(account, 'contentDirectory', 'addCuratorGroup')
+
+    const newGroupId = (await this.getApi().nextCuratorGroupId()) - 1
+    console.log(chalk.green(`New group succesfully created! (ID: ${chalk.white(newGroupId)})`))
+  }
+}

+ 39 - 0
cli/src/commands/content-directory/curatorGroup.ts

@@ -0,0 +1,39 @@
+import { WorkingGroups } from '../../Types'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+
+export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Curator Group details by ID.'
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Curator Group',
+    },
+  ]
+
+  async run() {
+    const { id } = this.parse(CuratorGroupCommand).args
+    const group = await this.getCuratorGroup(id)
+    const classesMaintained = (await this.getApi().availableClasses()).filter(([, c]) =>
+      c.class_permissions.maintainers.toArray().some((gId) => gId.toNumber() === parseInt(id))
+    )
+    const members = (await this.getApi().groupMembers(WorkingGroups.Curators)).filter((curator) =>
+      group.curators.toArray().some((groupCurator) => groupCurator.eq(curator.workerId))
+    )
+
+    displayCollapsedRow({
+      'ID': id,
+      'Status': group.active.valueOf() ? 'Active' : 'Inactive',
+    })
+    displayHeader(`Classes maintained (${classesMaintained.length})`)
+    this.log(classesMaintained.map(([, c]) => chalk.white(c.name.toString())).join(', '))
+    displayHeader(`Group Members (${members.length})`)
+    this.log(
+      members
+        .map((curator) => chalk.white(`${curator.profile.handle} (WorkerID: ${curator.workerId.toString()})`))
+        .join(', ')
+    )
+  }
+}

+ 21 - 0
cli/src/commands/content-directory/curatorGroups.ts

@@ -0,0 +1,21 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+// import chalk from 'chalk'
+import { displayTable } from '../../helpers/display'
+
+export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing Curator Groups.'
+
+  async run() {
+    const groups = await this.getApi().availableCuratorGroups()
+
+    displayTable(
+      groups.map(([id, group]) => ({
+        'ID': id.toString(),
+        'Status': group.active.valueOf() ? 'Active' : 'Inactive',
+        'Classes maintained': group.number_of_classes_maintained.toNumber(),
+        'Members': group.curators.toArray().length,
+      })),
+      5
+    )
+  }
+}

+ 40 - 0
cli/src/commands/content-directory/entities.ts

@@ -0,0 +1,40 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { displayTable } from '../../helpers/display'
+import _ from 'lodash'
+
+export default class ClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Show entities list by class id or name.'
+  static args = [
+    {
+      name: 'className',
+      required: true,
+      description: 'Name or ID of the Class',
+    },
+    {
+      name: 'properties',
+      required: false,
+      description:
+        'Comma-separated properties to include in the results table (ie. code,name). ' +
+        'By default all property values will be included.',
+    },
+  ]
+
+  async run() {
+    const { className, properties } = this.parse(ClassCommand).args
+    const [classId, entityClass] = await this.classEntryByNameOrId(className)
+    const entityEntries = await this.getApi().entitiesByClassId(classId.toNumber())
+    const propertiesToInclude = properties && (properties as string).split(',')
+
+    displayTable(
+      await Promise.all(
+        entityEntries.map(([id, entity]) => ({
+          'ID': id.toString(),
+          ..._.mapValues(this.parseEntityPropertyValues(entity, entityClass, propertiesToInclude), (v) =>
+            v.value.toString()
+          ),
+        }))
+      ),
+      3
+    )
+  }
+}

+ 37 - 0
cli/src/commands/content-directory/entity.ts

@@ -0,0 +1,37 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+import _ from 'lodash'
+
+export default class EntityCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Entity details by id.'
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Entity',
+    },
+  ]
+
+  async run() {
+    const { id } = this.parse(EntityCommand).args
+    const entity = await this.getEntity(id)
+    const { controller, frozen, referenceable } = entity.entity_permissions
+    const [classId, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
+    const propertyValues = this.parseEntityPropertyValues(entity, entityClass)
+
+    displayCollapsedRow({
+      'ID': id,
+      'Class name': entityClass.name.toString(),
+      'Class ID': classId.toNumber(),
+      'Supported schemas': JSON.stringify(entity.supported_schemas.toJSON()),
+      'Controller': controller.type + (controller.isOfType('Member') ? `(${controller.asType('Member')})` : ''),
+      'Frozen': frozen.toString(),
+      'Refrecencable': referenceable.toString(),
+      'Same owner references': entity.reference_counter.same_owner.toNumber(),
+      'Total references': entity.reference_counter.total.toNumber(),
+    })
+    displayHeader('Property values')
+    displayCollapsedRow(_.mapValues(propertyValues, (v) => `${v.value.toString()} ${chalk.green(`${v.type}`)}`))
+  }
+}

+ 30 - 0
cli/src/commands/content-directory/removeCuratorGroup.ts

@@ -0,0 +1,30 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove existing Curator Group.'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Curator Group to remove',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { id } = this.parse(AddCuratorGroupCommand).args
+    if (id === undefined) {
+      id = await this.promptForCuratorGroup('Select Curator Group to remove')
+    } else {
+      await this.getCuratorGroup(id)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeCuratorGroup', [id])
+
+    console.log(chalk.green(`Curator Group ${chalk.white(id)} succesfully removed!`))
+  }
+}

+ 44 - 0
cli/src/commands/content-directory/removeMaintainerFromClass.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove maintainer (Curator Group) from class.'
+  static args = [
+    {
+      name: 'className',
+      required: false,
+      description: 'Name or ID of the class (ie. Video)',
+    },
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group to remove from maintainers',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
+
+    if (className === undefined) {
+      className = (await this.promptForClass()).name.toString()
+    }
+
+    const [classId, aClass] = await this.classEntryByNameOrId(className)
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup('Select a maintainer', aClass.class_permissions.maintainers.toArray())
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeMaintainerFromClass', [classId, groupId])
+
+    console.log(
+      chalk.green(`Curator Group ${chalk.white(groupId)} removed as maintainer of ${chalk.white(className)} class!`)
+    )
+  }
+}

+ 61 - 0
cli/src/commands/content-directory/setCuratorGroupStatus.ts

@@ -0,0 +1,61 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+
+export default class SetCuratorGroupStatusCommand extends ContentDirectoryCommandBase {
+  static description = 'Set Curator Group status (Active/Inactive).'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'status',
+      required: false,
+      description: 'New status of the group (1 - active, 0 - inactive)',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { id, status } = this.parse(SetCuratorGroupStatusCommand).args
+
+    if (id === undefined) {
+      id = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(id)
+    }
+
+    if (status === undefined) {
+      status = await this.simplePrompt({
+        type: 'list',
+        message: 'Select new status',
+        choices: [
+          { name: 'Active', value: true },
+          { name: 'Inactive', value: false },
+        ],
+      })
+    } else {
+      if (status !== '0' && status !== '1') {
+        this.error('Invalid status provided. Use "1" for Active and "0" for Inactive.', {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      status = !!parseInt(status)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'setCuratorGroupStatus', [id, status])
+
+    console.log(
+      chalk.green(
+        `Curator Group ${chalk.white(id)} status succesfully changed to: ${chalk.white(
+          status ? 'Active' : 'Inactive'
+        )}!`
+      )
+    )
+  }
+}

+ 55 - 0
cli/src/commands/content-directory/updateClassPermissions.ts

@@ -0,0 +1,55 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import CreateClassSchema from 'cd-schemas/schemas/extrinsics/CreateClass.schema.json'
+import chalk from 'chalk'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { CreateClass } from 'cd-schemas/types/extrinsics/CreateClass'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+
+export default class UpdateClassPermissionsCommand extends ContentDirectoryCommandBase {
+  static description = 'Update permissions in given class.'
+  static args = [
+    {
+      name: 'className',
+      required: false,
+      description: 'Name or ID of the class (ie. Video)',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { className } = this.parse(UpdateClassPermissionsCommand).args
+
+    if (className === undefined) {
+      className = (await this.promptForClass()).name.toString()
+    }
+
+    const [classId, aClass] = await this.classEntryByNameOrId(className)
+    const currentPermissions = aClass.class_permissions
+
+    const customPrompts: JsonSchemaCustomPrompts = [
+      ['class_permissions.maintainers', () => this.promptForCuratorGroups('Select class maintainers')],
+    ]
+
+    const prompter = new JsonSchemaPrompter<CreateClass>(
+      CreateClassSchema as JSONSchema,
+      { class_permissions: currentPermissions.toJSON() as CreateClass['class_permissions'] },
+      customPrompts
+    )
+
+    const newPermissions = await prompter.promptSingleProp('class_permissions')
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'updateClassPermissions', [
+      classId,
+      newPermissions.any_member,
+      newPermissions.entity_creation_blocked,
+      newPermissions.all_entity_property_values_locked,
+      newPermissions.maintainers,
+    ])
+
+    console.log(chalk.green(`${chalk.white(className)} class permissions updated to:`))
+    this.jsonPrettyPrint(JSON.stringify(newPermissions))
+  }
+}

+ 13 - 16
cli/src/helpers/JsonSchemaPrompt.ts

@@ -3,27 +3,20 @@ import inquirer, { DistinctQuestion } from 'inquirer'
 import _ from 'lodash'
 import RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'
 import chalk from 'chalk'
+import { BOOL_PROMPT_OPTIONS } from './prompting'
 
 type CustomPromptMethod = () => Promise<any>
 type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt }
 
 export type JsonSchemaCustomPrompts = [string | RegExp, CustomPrompt][]
 
-export const BOOL_PROMPT_OPTIONS: DistinctQuestion = {
-  type: 'list',
-  choices: [
-    { name: 'Yes', value: true },
-    { name: 'No', value: false },
-  ],
-}
-
 export class JsonSchemaPrompter<JsonResult> {
   schema: JSONSchema
   customPropmpts?: JsonSchemaCustomPrompts
   ajv: Ajv.Ajv
   filledObject: Partial<JsonResult>
 
-  constructor(schema: JSONSchema, defaults?: JsonResult, customPrompts?: JsonSchemaCustomPrompts) {
+  constructor(schema: JSONSchema, defaults?: Partial<JsonResult>, customPrompts?: JsonSchemaCustomPrompts) {
     this.customPropmpts = customPrompts
     this.schema = schema
     this.ajv = new Ajv()
@@ -58,7 +51,7 @@ export class JsonSchemaPrompter<JsonResult> {
     return chalk.green(propertyPath)
   }
 
-  private async promptRecursive(schema: JSONSchema, propertyPath = ''): Promise<any> {
+  private async prompt(schema: JSONSchema, propertyPath = ''): Promise<any> {
     const customPrompt: CustomPrompt | undefined = this.getCustomPrompt(propertyPath)
     const propDisplayName = this.propertyDisplayName(propertyPath)
 
@@ -72,14 +65,14 @@ export class JsonSchemaPrompter<JsonResult> {
       const oneOf = schema.oneOf as JSONSchema[]
       const choices = this.oneOfToChoices(oneOf)
       const { choosen } = await inquirer.prompt({ name: 'choosen', message: propDisplayName, type: 'list', choices })
-      return await this.promptRecursive(oneOf[choosen], propertyPath)
+      return await this.prompt(oneOf[choosen], propertyPath)
     }
 
     // object
     if (schema.type === 'object' && schema.properties) {
       const value: Record<string, any> = {}
       for (const [pName, pSchema] of Object.entries(schema.properties)) {
-        value[pName] = await this.promptRecursive(pSchema, propertyPath ? `${propertyPath}.${pName}` : pName)
+        value[pName] = await this.prompt(pSchema, propertyPath ? `${propertyPath}.${pName}` : pName)
       }
       return value
     }
@@ -152,9 +145,7 @@ export class JsonSchemaPrompter<JsonResult> {
         break
       }
       const itemSchema = Array.isArray(schema.items) ? schema.items[schema.items.length % currItem] : schema.items
-      result.push(
-        await this.promptRecursive(typeof itemSchema === 'boolean' ? {} : itemSchema, `${propertyPath}[${currItem}]`)
-      )
+      result.push(await this.prompt(typeof itemSchema === 'boolean' ? {} : itemSchema, `${propertyPath}[${currItem}]`))
 
       ++currItem
     }
@@ -203,7 +194,13 @@ export class JsonSchemaPrompter<JsonResult> {
   }
 
   async promptAll() {
-    await this.promptRecursive(await RefParser.dereference(this.schema))
+    await this.prompt(await RefParser.dereference(this.schema))
     return this.filledObject as JsonResult
   }
+
+  async promptSingleProp<P extends keyof JsonResult & string>(p: P): Promise<Exclude<JsonResult[P], undefined>> {
+    const dereferenced = await RefParser.dereference(this.schema)
+    await this.prompt(dereferenced.properties![p] as JSONSchema, p)
+    return this.filledObject[p] as Exclude<JsonResult[P], undefined>
+  }
 }

+ 1 - 0
cli/src/helpers/display.ts

@@ -48,6 +48,7 @@ export function displayTable(rows: { [k: string]: string | number }[], cellHoriz
       return Math.max(maxLength, valLength)
     }, columnName.length)
   const columnDef = (columnName: string) => ({
+    header: columnName,
     get: (row: typeof rows[number]) => chalk.white(`${row[columnName]}`),
     minWidth: maxLength(columnName) + cellHorizontalPadding,
   })

+ 9 - 0
cli/src/helpers/prompting.ts

@@ -0,0 +1,9 @@
+import { DistinctQuestion } from 'inquirer'
+
+export const BOOL_PROMPT_OPTIONS: DistinctQuestion = {
+  type: 'list',
+  choices: [
+    { name: 'Yes', value: true },
+    { name: 'No', value: false },
+  ],
+}

+ 9 - 1
types/src/common.ts

@@ -13,8 +13,15 @@ export { JoyEnum, JoyStructCustom, JoyStructDecorated }
 // Adds sorting during BTreeSet toU8a encoding (required by the runtime)
 // Currently only supports values that extend UInt
 // FIXME: Will not cover cases where BTreeSet is part of extrinsic args metadata
-export function JoyBTreeSet<V extends UInt>(valType: Constructor<V>): Constructor<BTreeSet<V>> {
+export interface ExtendedBTreeSet<V extends UInt> extends BTreeSet<V> {
+  toArray(): V[]
+}
+export function JoyBTreeSet<V extends UInt>(valType: Constructor<V>): Constructor<ExtendedBTreeSet<V>> {
   return class extends BTreeSet.with(valType) {
+    public toArray(): V[] {
+      return Array.from(this)
+    }
+
     public toU8a(isBare?: boolean): Uint8Array {
       const encoded = new Array<Uint8Array>()
 
@@ -98,6 +105,7 @@ export class InputValidationLengthConstraint extends JoyStructDecorated({ min: u
 
 export const WorkingGroupDef = {
   Storage: Null,
+  Content: Null,
 } as const
 export type WorkingGroupKey = keyof typeof WorkingGroupDef
 export class WorkingGroup extends JoyEnum(WorkingGroupDef) {}

+ 14 - 2
types/src/content-directory/index.ts

@@ -45,9 +45,13 @@ export class PropertyType extends JoyEnum({
   Single: PropertyTypeSingle,
   Vector: PropertyTypeVector,
 }) {
+  get subtype() {
+    return this.isOfType('Single') ? this.asType('Single').type : this.asType('Vector').vec_type.type
+  }
+
   toInputPropertyValue(value: any): InputPropertyValue {
     const inputPwType: keyof typeof InputPropertyValue['typeDefinitions'] = this.type
-    const subtype = this.isOfType('Single') ? this.asType('Single').type : this.asType('Vector').vec_type.type
+    const subtype = this.subtype
 
     if (inputPwType === 'Single') {
       const inputPwSubtype: keyof typeof InputValue['typeDefinitions'] = subtype === 'Hash' ? 'TextToHash' : subtype
@@ -137,7 +141,15 @@ export class VecStoredPropertyValue extends JoyStructDecorated({
 export class StoredPropertyValue extends JoyEnum({
   Single: StoredValue,
   Vector: VecStoredPropertyValue,
-}) {}
+}) {
+  get subtype() {
+    return this.isOfType('Single') ? this.asType('Single').type : this.asType('Vector').vec_value.type
+  }
+
+  public getValue() {
+    return this.isOfType('Single') ? this.asType('Single').value : this.asType('Vector').vec_value.value
+  }
+}
 
 export class InboundReferenceCounter extends JoyStructDecorated({
   total: u32,