Ver código fonte

Merge pull request #908 from Lezek123/cli-working-groups-final

CLI: Worker management, worker actions and updates following hireable lead upgrade
Mokhtar Naamani 4 anos atrás
pai
commit
361250de16

+ 61 - 21
cli/src/Api.ts

@@ -12,6 +12,7 @@ import {
     AccountSummary,
     CouncilInfoObj, CouncilInfoTuple, createCouncilInfoObj,
     WorkingGroups,
+    Reward,
     GroupMember,
     OpeningStatus,
     GroupOpeningStage,
@@ -40,6 +41,7 @@ import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recur
 import { Stake, StakeId } from '@joystream/types/stake';
 import { LinkageResult } from '@polkadot/types/codec/Linkage';
 import { Moment } from '@polkadot/types/interfaces';
+import { InputValidationLengthConstraint } from '@joystream/types/common';
 
 export const DEFAULT_API_URI = 'wss://rome-rpc-endpoint.joystream.org:9944/';
 const DEFAULT_DECIMALS = new u32(12);
@@ -203,15 +205,9 @@ export default class Api {
         }
 
         const leadWorkerId = optLeadId.unwrap();
-        const leadWorker = this.singleLinkageResult<Worker>(
-            await this.workingGroupApiQuery(group).workerById(leadWorkerId) as LinkageResult
-        );
-
-        if (!leadWorker.is_active) {
-            return null;
-        }
+        const leadWorker = await this.workerByWorkerId(group, leadWorkerId.toNumber());
 
-        return await this.groupMember(leadWorkerId, leadWorker);
+        return await this.parseGroupMember(leadWorkerId, leadWorker);
     }
 
     protected async stakeValue(stakeId: StakeId): Promise<Balance> {
@@ -225,14 +221,20 @@ export default class Api {
         return this.stakeValue(stakeProfile.stake_id);
     }
 
-    protected async workerTotalReward(relationshipId: RewardRelationshipId): Promise<Balance> {
-        const relationship = this.singleLinkageResult<RewardRelationship>(
+    protected async workerReward(relationshipId: RewardRelationshipId): Promise<Reward> {
+        const rewardRelationship = this.singleLinkageResult<RewardRelationship>(
             await this._api.query.recurringRewards.rewardRelationships(relationshipId) as LinkageResult
         );
-        return relationship.total_reward_received;
+
+        return {
+            totalRecieved: rewardRelationship.total_reward_received,
+            value: rewardRelationship.amount_per_payout,
+            interval: rewardRelationship.payout_interval.unwrapOr(undefined)?.toNumber(),
+            nextPaymentBlock: rewardRelationship.next_payment_at_block.unwrapOr(new BN(0)).toNumber()
+        };
     }
 
-    protected async groupMember(
+    protected async parseGroupMember(
         id: WorkerId,
         worker: Worker
     ): Promise<GroupMember> {
@@ -245,14 +247,14 @@ export default class Api {
             throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`);
         }
 
-        let stakeValue: Balance = this._api.createType("Balance", 0);
+        let stake: Balance | undefined;
         if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
-            stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
+            stake = await this.workerStake(worker.role_stake_profile.unwrap());
         }
 
-        let earnedValue: Balance = this._api.createType("Balance", 0);
+        let reward: Reward | undefined;
         if (worker.reward_relationship && worker.reward_relationship.isSome) {
-            earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
+            reward = await this.workerReward(worker.reward_relationship.unwrap());
         }
 
         return ({
@@ -260,15 +262,39 @@ export default class Api {
             roleAccount,
             memberId,
             profile,
-            stake: stakeValue,
-            earned: earnedValue
+            stake,
+            reward
         });
     }
 
+    async workerByWorkerId(group: WorkingGroups, workerId: number): Promise<Worker> {
+        const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId;
+
+        // This is chain specfic, but if next id is still 0, it means no workers have been added yet
+        if (workerId < 0 || workerId >= nextId.toNumber()) {
+            throw new CLIError('Invalid worker id!');
+        }
+
+        const worker = this.singleLinkageResult<Worker>(
+            (await this.workingGroupApiQuery(group).workerById(workerId)) as LinkageResult
+        );
+
+        if (!worker.is_active) {
+            throw new CLIError('This worker is not active anymore');
+        }
+
+        return worker;
+    }
+
+    async groupMember(group: WorkingGroups, workerId: number) {
+        const worker = await this.workerByWorkerId(group, workerId);
+        return await this.parseGroupMember(new WorkerId(workerId), worker);
+    }
+
     async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
         const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId;
 
-        // This is chain specfic, but if next id is still 0, it means no curators have been added yet
+        // This is chain specfic, but if next id is still 0, it means no workers have been added yet
         if (nextId.eq(0)) {
             return [];
         }
@@ -280,7 +306,9 @@ export default class Api {
         let groupMembers: GroupMember[] = [];
         for (let [index, worker] of Object.entries(workers.toArray())) {
             const workerId = workerIds[parseInt(index)];
-            groupMembers.push(await this.groupMember(workerId, worker));
+            if (worker.is_active) {
+                groupMembers.push(await this.parseGroupMember(workerId, worker));
+            }
         }
 
         return groupMembers.reverse();
@@ -332,6 +360,7 @@ export default class Api {
         return {
             wgApplicationId,
             applicationId: appId.toNumber(),
+            wgOpeningId: wgApplication.opening_id.toNumber(),
             member: await this.memberProfileById(wgApplication.member_id),
             roleAccout: wgApplication.role_account_id,
             stakes: {
@@ -379,6 +408,7 @@ export default class Api {
         const opening = await this.hiringOpeningById(openingId);
         const applications = await this.groupOpeningApplications(group, wgOpeningId);
         const stage = await this.parseOpeningStage(opening.stage);
+        const type = groupOpening.opening_type;
         const stakes = {
             application: opening.application_staking_policy.unwrapOr(undefined),
             role: opening.role_staking_policy.unwrapOr(undefined)
@@ -390,7 +420,8 @@ export default class Api {
             opening,
             stage,
             stakes,
-            applications
+            applications,
+            type
         });
     }
 
@@ -437,4 +468,13 @@ export default class Api {
             date: stageDate
         };
     }
+
+    async getMemberIdsByControllerAccount(address: string): Promise<MemberId[]> {
+        const ids = await this._api.query.members.memberIdsByControllerAccountId(address) as Vec<MemberId>;
+        return ids.toArray();
+    }
+
+    async workerExitRationaleConstraint(group: WorkingGroups): Promise<InputValidationLengthConstraint> {
+        return await this.workingGroupApiQuery(group).workerExitRationaleText() as InputValidationLengthConstraint;
+    }
 }

+ 41 - 11
cli/src/Types.ts

@@ -1,13 +1,13 @@
 import BN from 'bn.js';
 import { ElectionStage, Seat } from '@joystream/types/council';
 import { Option, Text } from '@polkadot/types';
-import { Constructor } from '@polkadot/types/types';
+import { Constructor, Codec } from '@polkadot/types/types';
 import { Struct, Vec } from '@polkadot/types/codec';
 import { u32 } from '@polkadot/types/primitive';
 import { BlockNumber, Balance, AccountId } from '@polkadot/types/interfaces';
 import { DerivedBalances } from '@polkadot/api-derive/types';
 import { KeyringPair } from '@polkadot/keyring/types';
-import { WorkerId } from '@joystream/types/working-group';
+import { WorkerId, OpeningType } from '@joystream/types/working-group';
 import { Profile, MemberId } from '@joystream/types/members';
 import {
     GenericJoyStreamRoleSchema,
@@ -24,6 +24,7 @@ import {
 } from '@joystream/types/hiring/schemas/role.schema.typings';
 import ajv from 'ajv';
 import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/hiring';
+import { Validator } from 'inquirer';
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -92,19 +93,27 @@ export const AvailableGroups: readonly WorkingGroups[] = [
   WorkingGroups.StorageProviders
 ] as const;
 
+export type Reward = {
+    totalRecieved: Balance;
+    value: Balance;
+    interval?: number;
+    nextPaymentBlock: number; // 0 = no incoming payment
+}
+
 // Compound working group types
 export type GroupMember = {
     workerId: WorkerId;
     memberId: MemberId;
     roleAccount: AccountId;
     profile: Profile;
-    stake: Balance;
-    earned: Balance;
+    stake?: Balance;
+    reward?: Reward;
 }
 
 export type GroupApplication = {
     wgApplicationId: number;
     applicationId: number;
+    wgOpeningId: number;
     member: Profile | null;
     roleAccout: AccountId;
     stakes: {
@@ -142,6 +151,7 @@ export type GroupOpening = {
     opening: Opening;
     stakes: GroupOpeningStakes;
     applications: GroupApplication[];
+    type: OpeningType;
 }
 
 // Some helper structs for generating human_readable_text in working group opening extrinsic
@@ -309,10 +319,30 @@ export class HRTStruct extends Struct implements WithJSONable<GenericJoyStreamRo
     }
 };
 
-// A mapping of argName to json struct and schemaValidator
-// It is used to map arguments of type "Bytes" that are in fact a json string
-// (and can be validated against a schema)
-export type JSONArgsMapping = { [argName: string]: {
-    struct: Constructor<Struct>,
-    schemaValidator: ajv.ValidateFunction
-} };
+// Api-related
+
+// Additional options that can be passed to ApiCommandBase.promptForParam in order to override
+// its default behaviour, change param name, add validation etc.
+export type ApiParamOptions<ParamType = Codec> = {
+    forcedName?: string,
+    value?: {
+        default: ParamType;
+        locked?: boolean;
+    }
+    jsonSchema?: {
+        struct: Constructor<Struct>;
+        schemaValidator: ajv.ValidateFunction;
+    }
+    validator?: Validator,
+    nestedOptions?: ApiParamsOptions // For more complex params, like structs
+};
+export type ApiParamsOptions = {
+    [paramName: string]: ApiParamOptions;
+}
+
+export type ApiMethodArg = Codec;
+export type ApiMethodNamedArg = {
+    name: string;
+    value: ApiMethodArg;
+};
+export type ApiMethodNamedArgs = ApiMethodNamedArg[];

+ 76 - 47
cli/src/base/ApiCommandBase.ts

@@ -2,7 +2,6 @@ import ExitCodes from '../ExitCodes';
 import { CLIError } from '@oclif/errors';
 import StateAwareCommandBase from './StateAwareCommandBase';
 import Api from '../Api';
-import { JSONArgsMapping } from '../Types';
 import { getTypeDef, createType, Option, Tuple, Bytes } from '@polkadot/types';
 import { Codec, TypeDef, TypeDefInfo, Constructor } from '@polkadot/types/types';
 import { Vec, Struct, Enum } from '@polkadot/types/codec';
@@ -11,8 +10,8 @@ import { KeyringPair } from '@polkadot/keyring/types';
 import chalk from 'chalk';
 import { SubmittableResultImpl } from '@polkadot/api/types';
 import ajv from 'ajv';
-
-export type ApiMethodInputArg = Codec;
+import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions } from '../Types';
+import { createParamOptions } from '../helpers/promptOptions';
 
 class ExtrinsicFailedError extends Error { };
 
@@ -61,18 +60,24 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
 
     // Prompt for simple/plain value (provided as string) of given type
-    async promptForSimple(typeDef: TypeDef, defaultValue?: Codec): Promise<Codec> {
+    async promptForSimple(
+        typeDef: TypeDef,
+        paramOptions?: ApiParamOptions
+    ): Promise<Codec> {
         const providedValue = await this.simplePrompt({
             message: `Provide value for ${ this.paramName(typeDef) }`,
             type: 'input',
-            default: defaultValue?.toString()
+            // If not default provided - show default value resulting from providing empty string
+            default: paramOptions?.value?.default?.toString() || createType(typeDef.type as any, '').toString(),
+            validate: paramOptions?.validator
         });
         return createType(typeDef.type as any, providedValue);
     }
 
     // Prompt for Option<Codec> value
-    async promptForOption(typeDef: TypeDef, defaultValue?: Option<Codec>): Promise<Option<Codec>> {
+    async promptForOption(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Option<Codec>> {
         const subtype = <TypeDef> typeDef.sub; // We assume that Opion always has a single subtype
+        const defaultValue = paramOptions?.value?.default as Option<Codec> | undefined;
         const confirmed = await this.simplePrompt({
             message: `Do you want to provide the optional ${ this.paramName(typeDef) } parameter?`,
             type: 'confirm',
@@ -81,7 +86,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
         if (confirmed) {
             this.openIndentGroup();
-            const value = await this.promptForParam(subtype.type, subtype.name, defaultValue?.unwrapOr(undefined));
+            const value = await this.promptForParam(subtype.type, createParamOptions(subtype.name, defaultValue?.unwrapOr(undefined)));
             this.closeIndentGroup();
             return new Option(subtype.type as any, value);
         }
@@ -91,16 +96,18 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
     // Prompt for Tuple
     // TODO: Not well tested yet
-    async promptForTuple(typeDef: TypeDef, defaultValue: Tuple): Promise<Tuple> {
+    async promptForTuple(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Tuple> {
         console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } tuple:`));
 
         this.openIndentGroup();
-        const result: ApiMethodInputArg[] = [];
+        const result: ApiMethodArg[] = [];
         // We assume that for Tuple there is always at least 1 subtype (pethaps it's even always an array?)
         const subtypes: TypeDef[] = Array.isArray(typeDef.sub) ? typeDef.sub! : [ typeDef.sub! ];
+        const defaultValue = paramOptions?.value?.default as Tuple | undefined;
 
         for (const [index, subtype] of Object.entries(subtypes)) {
-            const inputParam = await this.promptForParam(subtype.type, subtype.name, defaultValue[parseInt(index)]);
+            const entryDefaultVal = defaultValue && defaultValue[parseInt(index)];
+            const inputParam = await this.promptForParam(subtype.type, createParamOptions(subtype.name, entryDefaultVal));
             result.push(inputParam);
         }
         this.closeIndentGroup();
@@ -109,7 +116,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
 
     // Prompt for Struct
-    async promptForStruct(typeDef: TypeDef, defaultValue?: Struct): Promise<ApiMethodInputArg> {
+    async promptForStruct(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<ApiMethodArg> {
         console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } struct:`));
 
         this.openIndentGroup();
@@ -117,11 +124,18 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         const rawTypeDef = this.getRawTypeDef(structType);
         // We assume struct typeDef always has array of typeDefs inside ".sub"
         const structSubtypes = rawTypeDef.sub as TypeDef[];
+        const structDefault = paramOptions?.value?.default as Struct | undefined;
 
-        const structValues: { [key: string]: ApiMethodInputArg } = {};
+        const structValues: { [key: string]: ApiMethodArg } = {};
         for (const subtype of structSubtypes) {
-            structValues[subtype.name!] =
-                await this.promptForParam(subtype.type, subtype.name, defaultValue && defaultValue.get(subtype.name!));
+            const fieldOptions = paramOptions?.nestedOptions && paramOptions.nestedOptions[subtype.name!];
+            const fieldDefaultValue = fieldOptions?.value?.default || (structDefault && structDefault.get(subtype.name!));
+            const finalFieldOptions: ApiParamOptions = {
+                ...fieldOptions,
+                forcedName: subtype.name,
+                value: fieldDefaultValue && { ...fieldOptions?.value, default: fieldDefaultValue }
+            }
+            structValues[subtype.name!] = await this.promptForParam(subtype.type, finalFieldOptions);
         }
         this.closeIndentGroup();
 
@@ -129,12 +143,13 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
 
     // Prompt for Vec
-    async promptForVec(typeDef: TypeDef, defaultValue?: Vec<Codec>): Promise<Vec<Codec>> {
+    async promptForVec(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Vec<Codec>> {
         console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } vector:`));
 
         this.openIndentGroup();
         // We assume Vec always has one TypeDef as ".sub"
         const subtype = typeDef.sub as TypeDef;
+        const defaultValue = paramOptions?.value?.default as Vec<Codec> | undefined;
         let entries: Codec[] = [];
         let addAnother = false;
         do {
@@ -145,7 +160,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
             });
             const defaultEntryValue = defaultValue && defaultValue[entries.length];
             if (addAnother) {
-                entries.push(await this.promptForParam(subtype.type, subtype.name, defaultEntryValue));
+                entries.push(await this.promptForParam(subtype.type, createParamOptions(subtype.name, defaultEntryValue)));
             }
         } while (addAnother);
         this.closeIndentGroup();
@@ -154,11 +169,12 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
 
     // Prompt for Enum
-    async promptForEnum(typeDef: TypeDef, defaultValue?: Enum): Promise<Enum> {
+    async promptForEnum(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Enum> {
         const enumType = typeDef.type;
         const rawTypeDef = this.getRawTypeDef(enumType);
         // We assume enum always has array on TypeDefs inside ".sub"
         const enumSubtypes = rawTypeDef.sub as TypeDef[];
+        const defaultValue = paramOptions?.value?.default as Enum | undefined;
 
         const enumSubtypeName = await this.simplePrompt({
             message: `Choose value for ${this.paramName(typeDef)}:`,
@@ -173,9 +189,10 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         const enumSubtype = enumSubtypes.find(st => st.name === enumSubtypeName)!;
 
         if (enumSubtype.type !== 'Null') {
+            const subtypeOptions = createParamOptions(enumSubtype.name, defaultValue?.value);
             return createType(
                 enumType as any,
-                { [enumSubtype.name!]: await this.promptForParam(enumSubtype.type, enumSubtype.name, defaultValue?.value) }
+                { [enumSubtype.name!]: await this.promptForParam(enumSubtype.type, subtypeOptions) }
             );
         }
 
@@ -184,31 +201,48 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
     // Prompt for param based on "paramType" string (ie. Option<MemeberId>)
     // TODO: This may not yet work for all possible types
-    async promptForParam(paramType: string, forcedName?: string, defaultValue?: ApiMethodInputArg): Promise<ApiMethodInputArg> {
+    async promptForParam(
+        paramType: string,
+        paramOptions?: ApiParamOptions // TODO: This is not fully implemented for all types yet
+    ): Promise<ApiMethodArg> {
         const typeDef = getTypeDef(paramType);
         const rawTypeDef = this.getRawTypeDef(paramType);
 
-        if (forcedName) {
-            typeDef.name = forcedName;
+        if (paramOptions?.forcedName) {
+            typeDef.name = paramOptions.forcedName;
+        }
+
+        if (paramOptions?.value?.locked) {
+            return paramOptions.value.default;
+        }
+
+        if (paramOptions?.jsonSchema) {
+            const { struct, schemaValidator } = paramOptions.jsonSchema;
+            return await this.promptForJsonBytes(
+                struct,
+                typeDef.name,
+                paramOptions.value?.default as Bytes | undefined,
+                schemaValidator
+            );
         }
 
         if (rawTypeDef.info === TypeDefInfo.Option) {
-            return await this.promptForOption(typeDef, defaultValue as Option<Codec>);
+            return await this.promptForOption(typeDef, paramOptions);
         }
         else if (rawTypeDef.info === TypeDefInfo.Tuple) {
-            return await this.promptForTuple(typeDef, defaultValue as Tuple);
+            return await this.promptForTuple(typeDef, paramOptions);
         }
         else if (rawTypeDef.info === TypeDefInfo.Struct) {
-            return await this.promptForStruct(typeDef, defaultValue as Struct);
+            return await this.promptForStruct(typeDef, paramOptions);
         }
         else if (rawTypeDef.info === TypeDefInfo.Enum) {
-            return await this.promptForEnum(typeDef, defaultValue as Enum);
+            return await this.promptForEnum(typeDef, paramOptions);
         }
         else if (rawTypeDef.info === TypeDefInfo.Vec) {
-            return await this.promptForVec(typeDef, defaultValue as Vec<Codec>);
+            return await this.promptForVec(typeDef, paramOptions);
         }
         else {
-            return await this.promptForSimple(typeDef, defaultValue);
+            return await this.promptForSimple(typeDef, paramOptions);
         }
     }
 
@@ -231,7 +265,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
         let isValid: boolean = true, jsonText: string;
         do {
-            const structVal = await this.promptForStruct(typeDef, defaultStruct);
+            const structVal = await this.promptForStruct(typeDef, createParamOptions(typeDef.name, defaultStruct));
             jsonText = JSON.stringify(structVal.toJSON());
             if (schemaValidator) {
                 isValid = Boolean(schemaValidator(JSON.parse(jsonText)));
@@ -253,24 +287,20 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     async promptForExtrinsicParams(
         module: string,
         method: string,
-        jsonArgs?: JSONArgsMapping,
-        defaultValues?: ApiMethodInputArg[]
-    ): Promise<ApiMethodInputArg[]> {
+        paramsOptions?: ApiParamsOptions
+    ): Promise<ApiMethodArg[]> {
         const extrinsicMethod = this.getOriginalApi().tx[module][method];
-        let values: ApiMethodInputArg[] = [];
+        let values: ApiMethodArg[] = [];
 
         this.openIndentGroup();
-        for (const [index, arg] of Object.entries(extrinsicMethod.meta.args.toArray())) {
+        for (const arg of extrinsicMethod.meta.args.toArray()) {
             const argName = arg.name.toString();
             const argType = arg.type.toString();
-            const defaultValue = defaultValues && defaultValues[parseInt(index)];
-            if (jsonArgs && jsonArgs[argName]) {
-                const { struct, schemaValidator } = jsonArgs[argName];
-                values.push(await this.promptForJsonBytes(struct, argName, defaultValue as Bytes, schemaValidator));
-            }
-            else {
-                values.push(await this.promptForParam(argType, argName, defaultValue));
+            let argOptions = paramsOptions && paramsOptions[argName];
+            if (!argOptions?.forcedName) {
+                argOptions = { ...argOptions, forcedName: argName };
             }
+            values.push(await this.promptForParam(argType, argOptions));
         };
         this.closeIndentGroup();
 
@@ -336,18 +366,17 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         account: KeyringPair,
         module: string,
         method: string,
-        jsonArgs?: JSONArgsMapping, // Special JSON arguments (ie. human_readable_text of working group opening)
-        defaultValues?: ApiMethodInputArg[],
+        paramsOptions: ApiParamsOptions,
         warnOnly: boolean = false // If specified - only warning will be displayed (instead of error beeing thrown)
-    ): Promise<ApiMethodInputArg[]> {
-        const params = await this.promptForExtrinsicParams(module, method, jsonArgs, defaultValues);
+    ): Promise<ApiMethodArg[]> {
+        const params = await this.promptForExtrinsicParams(module, method, paramsOptions);
         await this.sendAndFollowExtrinsic(account, module, method, params, warnOnly);
 
         return params;
     }
 
-    extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodInputArg[] {
-        let draftJSONObj, parsedArgs: ApiMethodInputArg[] = [];
+    extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodNamedArgs {
+        let draftJSONObj, parsedArgs: ApiMethodNamedArgs = [];
         const extrinsicMethod = this.getOriginalApi().tx[module][method];
         try {
             draftJSONObj = require(draftFilePath);
@@ -365,7 +394,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
             const argName = arg.name.toString();
             const argType = arg.type.toString();
             try {
-                parsedArgs.push(createType(argType as any, draftJSONObj[parseInt(index)]));
+                parsedArgs.push({ name: argName, value: createType(argType as any, draftJSONObj[parseInt(index)]) });
             } catch (e) {
                 throw new CLIError(`Couldn't parse ${argName} value from draft at ${draftFilePath}!`, { exit: ExitCodes.InvalidFile });
             }

+ 89 - 10
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,11 +1,9 @@
 import ExitCodes from '../ExitCodes';
 import AccountsCommandBase from './AccountsCommandBase';
 import { flags } from '@oclif/command';
-import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupMember, GroupOpening } from '../Types';
+import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupMember, GroupOpening, ApiMethodArg, ApiMethodNamedArgs, OpeningStatus, GroupApplication } from '../Types';
 import { apiModuleByGroup } from '../Api';
 import { CLIError } from '@oclif/errors';
-import inquirer from 'inquirer';
-import { ApiMethodInputArg } from './ApiCommandBase';
 import fs from 'fs';
 import path from 'path';
 import _ from 'lodash';
@@ -60,18 +58,38 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
         }
     }
 
+    // Use when member controller access is required, but one of the associated roles is expected to be selected
+    async getRequiredWorkerByMemberController(): Promise<GroupMember> {
+        const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount();
+        const memberIds = await this.getApi().getMemberIdsByControllerAccount(selectedAccount.address);
+        const controlledWorkers = (await this.getApi().groupMembers(this.group))
+            .filter(groupMember => memberIds.some(memberId => groupMember.memberId.eq(memberId)));
+
+        if (!controlledWorkers.length) {
+            this.error(
+                `Member controller account with some associated ${this.group} group roles needs to be selected!`,
+                { exit: ExitCodes.AccessDenied }
+            );
+        }
+        else if (controlledWorkers.length === 1) {
+            return controlledWorkers[0];
+        }
+        else {
+            return await this.promptForWorker(controlledWorkers);
+        }
+    }
+
     async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
-        const { choosenWorkerIndex } = await inquirer.prompt([{
-            name: 'chosenWorkerIndex',
-            message: 'Choose the worker to execute the command as',
+        const chosenWorkerIndex = await this.simplePrompt({
+            message: 'Choose the intended worker context:',
             type: 'list',
             choices: groupMembers.map((groupMember, index) => ({
                 name: `Worker ID ${ groupMember.workerId.toString() }`,
                 value: index
             }))
-        }]);
+        });
 
-        return groupMembers[choosenWorkerIndex];
+        return groupMembers[chosenWorkerIndex];
     }
 
     async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
@@ -135,7 +153,68 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
         return selectedDraftName;
     }
 
-    loadOpeningDraftParams(draftName: string) {
+    async getOpeningForLeadAction(id: number, requiredStatus?: OpeningStatus): Promise<GroupOpening> {
+        const opening = await this.getApi().groupOpening(this.group, id);
+
+        if (!opening.type.isOfType('Worker')) {
+            this.error('A lead can only manage Worker openings!',  { exit: ExitCodes.AccessDenied });
+        }
+
+        if (requiredStatus && opening.stage.status !== requiredStatus) {
+            this.error(
+                `The opening needs to be in "${_.startCase(requiredStatus)}" stage! ` +
+                `This one is: "${_.startCase(opening.stage.status)}"`,
+                { exit: ExitCodes.InvalidInput }
+            );
+        }
+
+        return opening;
+    }
+
+    // An alias for better code readibility in case we don't need the actual return value
+    validateOpeningForLeadAction = this.getOpeningForLeadAction
+
+    async getApplicationForLeadAction(id: number, requiredStatus?: ApplicationStageKeys): Promise<GroupApplication> {
+        const application = await this.getApi().groupApplication(this.group, id);
+        const opening = await this.getApi().groupOpening(this.group, application.wgOpeningId);
+
+        if (!opening.type.isOfType('Worker')) {
+            this.error('A lead can only manage Worker opening applications!',  { exit: ExitCodes.AccessDenied });
+        }
+
+        if (requiredStatus && application.stage !== requiredStatus) {
+            this.error(
+                `The application needs to have "${_.startCase(requiredStatus)}" status! ` +
+                `This one has: "${_.startCase(application.stage)}"`,
+                { exit: ExitCodes.InvalidInput }
+            );
+        }
+
+        return application;
+    }
+
+    async getWorkerForLeadAction(id: number, requireStakeProfile: boolean = false) {
+        const groupMember = await this.getApi().groupMember(this.group, id);
+        const groupLead = await this.getApi().groupLead(this.group);
+
+        if (groupLead?.workerId.eq(groupMember.workerId)) {
+            this.error('A lead cannot manage his own role this way!', { exit: ExitCodes.AccessDenied });
+        }
+
+        if (requireStakeProfile && !groupMember.stake) {
+            this.error('This worker has no associated role stake profile!', { exit: ExitCodes.InvalidInput });
+        }
+
+        return groupMember;
+    }
+
+    // Helper for better TS handling.
+    // We could also use some magic with conditional types instead, but those don't seem be very well supported yet.
+    async getWorkerWithStakeForLeadAction(id: number) {
+        return (await this.getWorkerForLeadAction(id, true)) as (GroupMember & Required<Pick<GroupMember, 'stake'>>);
+    }
+
+    loadOpeningDraftParams(draftName: string): ApiMethodNamedArgs {
         const draftFilePath = this.getOpeningDraftPath(draftName);
         const params = this.extrinsicArgsFromDraft(
             apiModuleByGroup[this.group],
@@ -154,7 +233,7 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
         return path.join(this.getOpeingDraftsPath(), _.snakeCase(draftName)+'.json');
     }
 
-    saveOpeningDraft(draftName: string, params: ApiMethodInputArg[]) {
+    saveOpeningDraft(draftName: string, params: ApiMethodArg[]) {
         const paramsJson = JSON.stringify(
             params.map(p => p.toJSON()),
             null,

+ 5 - 5
cli/src/commands/api/inspect.ts

@@ -7,8 +7,8 @@ import { Codec } from '@polkadot/types/types';
 import { ConstantCodec } from '@polkadot/api-metadata/consts/types';
 import ExitCodes from '../../ExitCodes';
 import chalk from 'chalk';
-import { NameValueObj } from '../../Types';
-import ApiCommandBase, { ApiMethodInputArg } from '../../base/ApiCommandBase';
+import { NameValueObj, ApiMethodArg } from '../../Types';
+import ApiCommandBase from '../../base/ApiCommandBase';
 
 // Command flags type
 type ApiInspectFlags = {
@@ -148,8 +148,8 @@ export default class ApiInspect extends ApiCommandBase {
     }
 
     // Request values for params using array of param types (strings)
-    async requestParamsValues(paramTypes: string[]): Promise<ApiMethodInputArg[]> {
-        let result: ApiMethodInputArg[] = [];
+    async requestParamsValues(paramTypes: string[]): Promise<ApiMethodArg[]> {
+        let result: ApiMethodArg[] = [];
         for (let [key, paramType] of Object.entries(paramTypes)) {
             this.log(chalk.bold.white(`Parameter no. ${ parseInt(key)+1 } (${ paramType }):`));
             let paramValue = await this.promptForParam(paramType);
@@ -177,7 +177,7 @@ export default class ApiInspect extends ApiCommandBase {
 
             if (apiType === 'query') {
                 // Api query - call with (or without) arguments
-                let args: (string | ApiMethodInputArg)[] = flags.callArgs ? flags.callArgs.split(',') : [];
+                let args: (string | ApiMethodArg)[] = flags.callArgs ? flags.callArgs.split(',') : [];
                 const paramsTypes: string[] = this.getQueryMethodParamsTypes(apiModule, apiMethod);
                 if (args.length < paramsTypes.length) {
                     this.warn('Some parameters are missing! Please, provide the missing parameters:');

+ 10 - 20
cli/src/commands/working-groups/createOpening.ts

@@ -1,10 +1,10 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import { HRTStruct } from '../../Types';
+import { ApiMethodArg, ApiMethodNamedArgs } from '../../Types';
 import chalk from 'chalk';
 import { flags } from '@oclif/command';
-import { ApiMethodInputArg } from '../../base/ApiCommandBase';
-import { schemaValidator } from '@joystream/types/hiring';
 import { apiModuleByGroup } from '../../Api';
+import WorkerOpeningOptions from '../../promptOptions/addWorkerOpening';
+import { setDefaults } from '../../helpers/promptOptions';
 
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
     static description = 'Create working group opening (requires lead access)';
@@ -43,35 +43,25 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
 
         const { flags } = this.parse(WorkingGroupsCreateOpening);
 
-        let defaultValues: ApiMethodInputArg[] | undefined = undefined;
+        let promptOptions = new WorkerOpeningOptions(), defaultValues: ApiMethodNamedArgs | undefined;
         if (flags.useDraft) {
             const draftName = flags.draftName || await this.promptForOpeningDraft();
-            defaultValues =  await this.loadOpeningDraftParams(draftName);
+            defaultValues = await this.loadOpeningDraftParams(draftName);
+            setDefaults(promptOptions, defaultValues);
         }
 
         if (!flags.skipPrompts) {
             const module = apiModuleByGroup[this.group];
             const method = 'addOpening';
-            const jsonArgsMapping = { 'human_readable_text': { struct: HRTStruct, schemaValidator } };
 
-            let saveDraft = false, params: ApiMethodInputArg[];
+            let saveDraft = false, params: ApiMethodArg[];
             if (flags.createDraftOnly) {
-                params = await this.promptForExtrinsicParams(module, method, jsonArgsMapping, defaultValues);
+                params = await this.promptForExtrinsicParams(module, method, promptOptions);
                 saveDraft = true;
             }
             else {
                 await this.requestAccountDecoding(account); // Prompt for password
-
-                params = await this.buildAndSendExtrinsic(
-                    account,
-                    module,
-                    method,
-                    jsonArgsMapping,
-                    defaultValues,
-                    true
-                );
-
-                this.log(chalk.green('Opening succesfully created!'));
+                params = await this.buildAndSendExtrinsic(account, module, method, promptOptions, true);
 
                 saveDraft = await this.simplePrompt({
                     message: 'Do you wish to save this opening as draft?',
@@ -89,7 +79,7 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
         else {
             await this.requestAccountDecoding(account); // Prompt for password
             this.log(chalk.white('Sending the extrinsic...'));
-            await this.sendExtrinsic(account, apiModuleByGroup[this.group], 'addOpening', defaultValues!);
+            await this.sendExtrinsic(account, apiModuleByGroup[this.group], 'addOpening', defaultValues!.map(v => v.value));
             this.log(chalk.green('Opening succesfully created!'));
         }
     }

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

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

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

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

+ 6 - 9
cli/src/commands/working-groups/fillOpening.ts

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

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

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

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

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

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

@@ -58,6 +58,7 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
         const openingRow = {
             'WG Opening ID': opening.wgOpeningId,
             'Opening ID': opening.openingId,
+            'Type': opening.type.type,
             ...this.stageColumns(opening.stage),
             ...this.stakeColumns(opening.stakes)
         };

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

@@ -14,6 +14,7 @@ export default class WorkingGroupsOpenings extends WorkingGroupsCommandBase {
         const openingsRows = openings.map(o => ({
             'WG Opening ID': o.wgOpeningId,
             'Opening ID': o.openingId,
+            'Type': o.type.type,
             'Stage': `${_.startCase(o.stage.status)}${o.stage.block ? ` (#${o.stage.block})` : ''}`,
             'Applications': o.applications.length
         }));

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

@@ -1,6 +1,7 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
 import { displayHeader, displayNameValueTable, displayTable } from '../../helpers/display';
 import { formatBalance } from '@polkadot/util';
+import { shortAddress } from '../../helpers/display';
 import chalk from 'chalk';
 
 export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
@@ -27,11 +28,13 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
 
         displayHeader('Members');
         const membersRows = members.map(m => ({
+            '': lead?.workerId.eq(m.workerId) ? "\u{2B50}" : '', // A nice star for the lead
             'Worker id': m.workerId.toString(),
             'Member id': m.memberId.toString(),
             'Member handle': m.profile.handle.toString(),
             'Stake': formatBalance(m.stake),
-            'Earned': formatBalance(m.earned)
+            'Earned': formatBalance(m.reward?.totalRecieved),
+            'Role account': shortAddress(m.roleAccount)
         }));
         displayTable(membersRows, 5);
     }

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

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

+ 4 - 8
cli/src/commands/working-groups/startAcceptingApplications.ts

@@ -1,7 +1,6 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
 import _ from 'lodash';
 import { OpeningStatus } from '../../Types';
-import ExitCodes from '../../ExitCodes';
 import { apiModuleByGroup } from '../../Api';
 import { OpeningId } from '@joystream/types/hiring';
 import chalk from 'chalk';
@@ -26,11 +25,8 @@ export default class WorkingGroupsStartAcceptingApplications extends WorkingGrou
         // Lead-only gate
         await this.getRequiredLead();
 
-        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
-
-        if (opening.stage.status !== OpeningStatus.WaitingToBegin) {
-            this.error('This opening is not in "Waiting To Begin" stage!', { exit: ExitCodes.InvalidInput });
-        }
+        const openingId = parseInt(args.wgOpeningId);
+        await this.validateOpeningForLeadAction(openingId, OpeningStatus.WaitingToBegin);
 
         await this.requestAccountDecoding(account);
 
@@ -38,9 +34,9 @@ export default class WorkingGroupsStartAcceptingApplications extends WorkingGrou
             account,
             apiModuleByGroup[this.group],
             'acceptApplications',
-            [ new OpeningId(opening.wgOpeningId) ]
+            [ new OpeningId(openingId) ]
         );
 
-        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} status changed to: ${ chalk.white('Accepting Applications') }`));
+        this.log(chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${ chalk.white('Accepting Applications') }`));
     }
 }

+ 4 - 8
cli/src/commands/working-groups/startReviewPeriod.ts

@@ -1,7 +1,6 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
 import _ from 'lodash';
 import { OpeningStatus } from '../../Types';
-import ExitCodes from '../../ExitCodes';
 import { apiModuleByGroup } from '../../Api';
 import { OpeningId } from '@joystream/types/hiring';
 import chalk from 'chalk';
@@ -26,11 +25,8 @@ export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommand
         // Lead-only gate
         await this.getRequiredLead();
 
-        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
-
-        if (opening.stage.status !== OpeningStatus.AcceptingApplications) {
-            this.error('This opening is not in "Accepting Applications" stage!', { exit: ExitCodes.InvalidInput });
-        }
+        const openingId = parseInt(args.wgOpeningId);
+        await this.validateOpeningForLeadAction(openingId, OpeningStatus.AcceptingApplications);
 
         await this.requestAccountDecoding(account);
 
@@ -38,9 +34,9 @@ export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommand
             account,
             apiModuleByGroup[this.group],
             'beginApplicantReview',
-            [ new OpeningId(opening.wgOpeningId) ]
+            [ new OpeningId(openingId) ]
         );
 
-        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} status changed to: ${ chalk.white('In Review') }`));
+        this.log(chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${ chalk.white('In Review') }`));
     }
 }

+ 5 - 7
cli/src/commands/working-groups/terminateApplication.ts

@@ -25,11 +25,9 @@ export default class WorkingGroupsTerminateApplication extends WorkingGroupsComm
         // Lead-only gate
         await this.getRequiredLead();
 
-        const application = await this.getApi().groupApplication(this.group, parseInt(args.wgApplicationId));
-
-        if (application.stage !== ApplicationStageKeys.Active) {
-            this.error('This application is not active!', { exit: ExitCodes.InvalidInput });
-        }
+        const applicationId = parseInt(args.wgApplicationId);
+        // We don't really need the application itself here, so this one is just for validation purposes
+        await this.getApplicationForLeadAction(applicationId, ApplicationStageKeys.Active);
 
         await this.requestAccountDecoding(account);
 
@@ -37,9 +35,9 @@ export default class WorkingGroupsTerminateApplication extends WorkingGroupsComm
             account,
             apiModuleByGroup[this.group],
             'terminateApplication',
-            [new ApplicationId(application.wgApplicationId)]
+            [new ApplicationId(applicationId)]
         );
 
-        this.log(chalk.green(`Application ${chalk.white(application.wgApplicationId)} has been succesfully terminated!`));
+        this.log(chalk.green(`Application ${chalk.white(applicationId)} has been succesfully terminated!`));
     }
 }

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

@@ -0,0 +1,54 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { validateAddress } from '../../helpers/validation';
+import { GenericAccountId } from '@polkadot/types';
+import chalk from 'chalk';
+import ExitCodes from '../../ExitCodes';
+
+export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsCommandBase {
+    static description = 'Updates the worker/lead reward account (requires current role account to be selected)';
+    static args = [
+        {
+            name: 'accountAddress',
+            required: false,
+            description: 'New reward account address (if omitted, one of the existing CLI accounts can be selected)'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsUpdateRewardAccount);
+
+        const account = await this.getRequiredSelectedAccount();
+        // Worker-only gate
+        const worker = await this.getRequiredWorker();
+
+        if (!worker.reward) {
+            this.error('There is no reward relationship associated with this role!', { exit: ExitCodes.InvalidInput });
+        }
+
+        let newRewardAccount: string = args.accountAddress;
+        if (!newRewardAccount) {
+            const accounts = await this.fetchAccounts();
+            newRewardAccount = (await this.promptForAccount(accounts, undefined, 'Choose the new reward account')).address;
+        }
+        validateAddress(newRewardAccount);
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'updateRewardAccount',
+            [
+                worker.workerId,
+                new GenericAccountId(newRewardAccount)
+            ]
+        );
+
+        this.log(chalk.green(`Succesfully updated the reward account to: ${chalk.white(newRewardAccount)})`));
+    }
+}

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

@@ -0,0 +1,64 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { validateAddress } from '../../helpers/validation';
+import { GenericAccountId } from '@polkadot/types';
+import chalk from 'chalk';
+
+export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommandBase {
+    static description = 'Updates the worker/lead role account. Requires member controller account to be selected';
+    static args = [
+        {
+            name: 'accountAddress',
+            required: false,
+            description: 'New role account address (if omitted, one of the existing CLI accounts can be selected)'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsUpdateRoleAccount);
+
+        const account = await this.getRequiredSelectedAccount();
+        const worker = await this.getRequiredWorkerByMemberController();
+
+        const cliAccounts = await this.fetchAccounts();
+        let newRoleAccount: string = args.accountAddress;
+        if (!newRoleAccount) {
+            newRoleAccount = (await this.promptForAccount(cliAccounts, undefined, 'Choose the new role account')).address;
+        }
+        validateAddress(newRoleAccount);
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'updateRoleAccount',
+            [
+                worker.workerId,
+                new GenericAccountId(newRoleAccount)
+            ]
+        );
+
+        this.log(chalk.green(`Succesfully updated the role account to: ${chalk.white(newRoleAccount)})`));
+
+        const matchingAccount = cliAccounts.find(account => account.address === newRoleAccount);
+        if (matchingAccount) {
+            const switchAccount = await this.simplePrompt({
+                type: 'confirm',
+                message: 'Do you want to switch the currenly selected CLI account to the new role account?',
+                default: false
+            });
+            if (switchAccount) {
+                await this.setSelectedAccount(matchingAccount);
+                this.log(
+                    chalk.green('Account switched to: ') +
+                    chalk.white(`${matchingAccount.meta.name} (${matchingAccount.address})`)
+                );
+            }
+        }
+    }
+}

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

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

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

@@ -1,6 +1,7 @@
 import { cli, Table } from 'cli-ux';
 import chalk from 'chalk';
 import { NameValueObj } from '../Types';
+import { AccountId } from '@polkadot/types/interfaces';
 
 export function displayHeader(caption: string, placeholderSign: string = '_', size: number = 50) {
     let singsPerSide: number = Math.floor((size - (caption.length + 2)) / 2);
@@ -65,3 +66,7 @@ export function toFixedLength(text: string, length: number, spacesOnLeft = false
 
     return text;
 }
+
+export function shortAddress(address: AccountId | string): string {
+    return address.toString().substr(0, 6) + '...' + address.toString().substr(-6);
+}

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

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

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

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

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

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

+ 9 - 2
types/src/JoyEnum.ts

@@ -7,15 +7,22 @@ export interface ExtendedEnum<Types extends Record<string, Constructor>> extends
   asType<TypeKey extends keyof Types>(type: TypeKey): InstanceType<Types[TypeKey]>;
 };
 
+export interface ExtendedEnumConstructor<Types extends Record<string, Constructor>> extends EnumConstructor<ExtendedEnum<Types>> {
+  create<TypeKey extends keyof Types>(typeKey: TypeKey, value: InstanceType<Types[TypeKey]>): ExtendedEnum<Types>;
+}
+
 // Helper for creating extended Enum type with TS-compatible isOfType and asType helpers
-export function JoyEnum<Types extends Record<string, Constructor>>(types: Types): EnumConstructor<ExtendedEnum<Types>>
+export function JoyEnum<Types extends Record<string, Constructor>>(types: Types): ExtendedEnumConstructor<Types>
 {
   // Unique values check
   if (Object.values(types).some((val, i) => Object.values(types).indexOf(val, i + 1) !== -1)) {
     throw new Error('Values passed to JoyEnum are not unique. Create an individual class for each value.');
   }
 
-  return class extends Enum {
+  return class JoyEnumObject extends Enum {
+    public static create<TypeKey extends keyof Types>(typeKey: TypeKey, value: InstanceType<Types[TypeKey]>) {
+      return new JoyEnumObject({ [typeKey]: value });
+    }
     constructor(value?: any, index?: number) {
       super(types, value, index);
     }

+ 25 - 9
types/src/recurring-rewards/index.ts

@@ -1,4 +1,4 @@
-import { getTypeRegistry, u32, u64, u128, Option, GenericAccountId } from '@polkadot/types';
+import { getTypeRegistry, u64, u128, Option } from '@polkadot/types';
 import { AccountId, Balance, BlockNumber } from '@polkadot/types/interfaces';
 import { JoyStruct } from '../common';
 import { MintId } from '../mint';
@@ -42,12 +42,12 @@ export class RewardRelationship extends JoyStruct<IRewardRelationship> {
     super({
       recipient: RecipientId,
       mint_id: MintId,
-      account: GenericAccountId,
-      amount_per_payout: u128,
-      next_payment_at_block: Option.with(u32),
-      payout_interval: Option.with(u32),
-      total_reward_received: u128,
-      total_reward_missed: u128,
+      account: 'AccountId',
+      amount_per_payout: 'Balance',
+      next_payment_at_block: Option.with('BlockNumber'),
+      payout_interval: Option.with('BlockNumber'),
+      total_reward_received: 'Balance',
+      total_reward_missed: 'Balance',
     }, value);
   }
 
@@ -55,8 +55,24 @@ export class RewardRelationship extends JoyStruct<IRewardRelationship> {
     return this.getField<RecipientId>('recipient')
   }
 
-  get total_reward_received(): u128 {
-    return this.getField<u128>('total_reward_received');
+  get total_reward_received(): Balance {
+    return this.getField<Balance>('total_reward_received');
+  }
+
+  get total_reward_missed(): Balance {
+    return this.getField<Balance>('total_reward_missed');
+  }
+
+  get amount_per_payout(): Balance {
+    return this.getField<Balance>('amount_per_payout');
+  }
+
+  get payout_interval(): Option<BlockNumber> {
+    return this.getField<Option<BlockNumber>>('payout_interval');
+  }
+
+  get next_payment_at_block(): Option<BlockNumber> {
+    return this.getField<Option<BlockNumber>>('next_payment_at_block');
   }
 };
 

+ 7 - 15
types/src/working-group/index.ts

@@ -1,4 +1,4 @@
-import { getTypeRegistry, Bytes, BTreeMap, Option, Enum } from '@polkadot/types';
+import { getTypeRegistry, Bytes, BTreeMap, Option} from '@polkadot/types';
 import { u16, Null } from '@polkadot/types/primitive';
 import { AccountId, BlockNumber, Balance } from '@polkadot/types/interfaces';
 import { BTreeSet, JoyStruct } from '../common';
@@ -139,17 +139,13 @@ export class SlashableTerms extends JoyStruct<ISlashableTerms> {
   }
 };
 
+export class UnslashableTerms extends Null { };
+
 // This type is also defined in /content-working-group (as above)
-export class SlashingTerms extends Enum {
-  constructor (value?: any, index?: number) {
-    super(
-      {
-        Unslashable: Null,
-        Slashable: SlashableTerms,
-      },
-      value, index);
-  }
-};
+export class SlashingTerms extends JoyEnum({
+  Unslashable: UnslashableTerms,
+  Slashable: SlashableTerms
+} as const) { };
 
 export type IWorkingGroupOpeningPolicyCommitment = {
   application_rationing_policy: Option<ApplicationRationingPolicy>,
@@ -244,10 +240,6 @@ export class WorkingGroupOpeningPolicyCommitment extends JoyStruct<IWorkingGroup
   }
 };
 
-export enum OpeningTypeKeys {
-  Leader = 'Leader',
-  Worker = 'Worker'
-};
 
 export class OpeningType_Leader extends Null { };
 export class OpeningType_Worker extends Null { };