Browse Source

working-groups:createOpening initial

Leszek Wiesner 4 years ago
parent
commit
4290909f98

+ 2 - 1
cli/package.json

@@ -21,7 +21,8 @@
     "moment": "^2.24.0",
     "proper-lockfile": "^4.1.1",
     "slug": "^2.1.1",
-    "tslib": "^1.11.1"
+    "tslib": "^1.11.1",
+    "ajv": "^6.11.0"
   },
   "devDependencies": {
     "@oclif/dev-cli": "^1.22.2",

+ 191 - 1
cli/src/Types.ts

@@ -1,11 +1,28 @@
 import BN from 'bn.js';
 import { ElectionStage, Seat } from '@joystream/types/lib/council';
-import { Option } from '@polkadot/types';
+import { Option, Text } from '@polkadot/types';
+import { Constructor } 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, Lead } from '@joystream/types/lib/working-group';
 import { Profile, MemberId } from '@joystream/types/lib/members';
+import {
+    GenericJoyStreamRoleSchema,
+    JobSpecifics,
+    ApplicationDetails,
+    QuestionSections,
+    QuestionSection,
+    QuestionsFields,
+    QuestionField,
+    EntryInMembershipModuke,
+    HiringProcess,
+    AdditionalRolehiringProcessDetails,
+    CreatorDetails
+} from '@joystream/types/lib/hiring/schemas/role.schema.typings';
+import ajv from 'ajv';
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -88,3 +105,176 @@ export type GroupMember = {
     stake: Balance;
     earned: Balance;
 }
+
+// Some helper structs for generating human_readable_text in worker opening extrinsic
+// Note those types are not part of the runtime etc., we just use them to simplify prompting for values
+// (since there exists functionality that handles that for substrate types like: Struct, Vec etc.)
+interface WithJSONable<T> {
+    toJSON: () => T;
+}
+export class HRTJobSpecificsStruct extends Struct implements WithJSONable<JobSpecifics> {
+    constructor (value?: JobSpecifics) {
+        super({
+          title: "Text",
+          description: "Text",
+        }, value);
+    }
+    get title(): string {
+        return (this.get('title') as Text).toString();
+    }
+    get description(): string {
+        return (this.get('description') as Text).toString();
+    }
+    toJSON(): JobSpecifics {
+        const { title, description } = this;
+        return { title, description };
+    }
+}
+export class HRTEntryInMembershipModukeStruct extends Struct implements WithJSONable<EntryInMembershipModuke> {
+    constructor (value?: EntryInMembershipModuke) {
+        super({
+          handle: "Text",
+        }, value);
+    }
+    get handle(): string {
+        return (this.get('handle') as Text).toString();
+    }
+    toJSON(): EntryInMembershipModuke {
+        const { handle } = this;
+        return { handle };
+    }
+}
+export class HRTCreatorDetailsStruct extends Struct implements WithJSONable<CreatorDetails> {
+    constructor (value?: CreatorDetails) {
+        super({
+          membership: HRTEntryInMembershipModukeStruct,
+        }, value);
+    }
+    get membership(): EntryInMembershipModuke {
+        return (this.get('membership') as HRTEntryInMembershipModukeStruct).toJSON();
+    }
+    toJSON(): CreatorDetails {
+        const { membership } = this;
+        return { membership };
+    }
+}
+export class HRTHiringProcessStruct extends Struct implements WithJSONable<HiringProcess> {
+    constructor (value?: HiringProcess) {
+        super({
+          details: "Vec<Text>",
+        }, value);
+    }
+    get details(): AdditionalRolehiringProcessDetails {
+        return (this.get('details') as Vec<Text>).toArray().map(v => v.toString());
+    }
+    toJSON(): HiringProcess {
+        const { details } = this;
+        return { details };
+    }
+}
+export class HRTQuestionFieldStruct extends Struct implements WithJSONable<QuestionField> {
+    constructor (value?: QuestionField) {
+        super({
+            title: "Text",
+            type: "Text"
+        }, value);
+    }
+    get title(): string {
+        return (this.get('title') as Text).toString();
+    }
+    get type(): string {
+        return (this.get('type') as Text).toString();
+    }
+    toJSON(): QuestionField {
+        const { title, type } = this;
+        return { title, type };
+    }
+}
+class HRTQuestionsFieldsVec extends Vec.with(HRTQuestionFieldStruct) implements WithJSONable<QuestionsFields> {
+    toJSON(): QuestionsFields {
+        return this.toArray().map(v => v.toJSON());
+    }
+}
+export class HRTQuestionSectionStruct extends Struct implements WithJSONable<QuestionSection> {
+    constructor (value?: QuestionSection) {
+        super({
+            title: "Text",
+            questions: HRTQuestionsFieldsVec
+        }, value);
+    }
+    get title(): string {
+        return (this.get('title') as Text).toString();
+    }
+    get questions(): QuestionsFields {
+        return (this.get('questions') as HRTQuestionsFieldsVec).toJSON();
+    }
+    toJSON(): QuestionSection {
+        const { title, questions } = this;
+        return { title, questions };
+    }
+}
+export class HRTQuestionSectionsVec extends Vec.with(HRTQuestionSectionStruct) implements WithJSONable<QuestionSections> {
+    toJSON(): QuestionSections {
+        return this.toArray().map(v => v.toJSON());
+    }
+};
+export class HRTApplicationDetailsStruct extends Struct implements WithJSONable<ApplicationDetails> {
+    constructor (value?: ApplicationDetails) {
+        super({
+            sections: HRTQuestionSectionsVec
+        }, value);
+    }
+    get sections(): QuestionSections {
+        return (this.get('sections') as HRTQuestionSectionsVec).toJSON();
+    }
+    toJSON(): ApplicationDetails {
+        const { sections } = this;
+        return { sections };
+    }
+}
+export class HRTStruct extends Struct implements WithJSONable<GenericJoyStreamRoleSchema> {
+    constructor (value?: GenericJoyStreamRoleSchema) {
+        super({
+            version: "u32",
+            headline: "Text",
+            job: HRTJobSpecificsStruct,
+            application: HRTApplicationDetailsStruct,
+            reward: "Text",
+            creator: HRTCreatorDetailsStruct,
+            process: HRTHiringProcessStruct
+        }, value);
+    }
+    get version(): number {
+        return (this.get('version') as u32).toNumber();
+    }
+    get headline(): string {
+        return (this.get('headline') as Text).toString();
+    }
+    get job(): JobSpecifics {
+        return (this.get('job') as HRTJobSpecificsStruct).toJSON();
+    }
+    get application(): ApplicationDetails {
+        return (this.get('application') as HRTApplicationDetailsStruct).toJSON();
+    }
+    get reward(): string {
+        return (this.get('reward') as Text).toString();
+    }
+    get creator(): CreatorDetails {
+        return (this.get('creator') as HRTCreatorDetailsStruct).toJSON();
+    }
+    get process(): HiringProcess {
+        return (this.get('process') as HRTHiringProcessStruct).toJSON();
+    }
+    toJSON(): GenericJoyStreamRoleSchema {
+        const { version, headline, job, application, reward, creator, process } = this;
+        return { version, headline, job, application, reward, creator, process };
+    }
+};
+
+// 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
+} };

+ 318 - 1
cli/src/base/ApiCommandBase.ts

@@ -2,7 +2,19 @@ import ExitCodes from '../ExitCodes';
 import { CLIError } from '@oclif/errors';
 import StateAwareCommandBase from './StateAwareCommandBase';
 import Api from '../Api';
-import { ApiPromise } from '@polkadot/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';
+import { ApiPromise } from '@polkadot/api';
+import { KeyringPair } from '@polkadot/keyring/types';
+import chalk from 'chalk';
+import { SubmittableResultImpl } from '@polkadot/api/types';
+import ajv from 'ajv';
+
+export type ApiMethodInputArg = Codec;
+
+class ExtrinsicFailedError extends Error { };
 
 /**
  * Abstract base class for commands that require access to the API.
@@ -25,4 +37,309 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         const apiUri: string = this.getPreservedState().apiUri;
         this.api = await Api.create(apiUri);
     }
+
+    // This is needed to correctly handle some structs, enums etc.
+    // Where the main typeDef doesn't provide enough information
+    protected getRawTypeDef(type: string) {
+        const instance = createType(type as any);
+        return getTypeDef(instance.toRawType());
+    }
+
+    // Prettifier for type names which are actually JSON strings
+    protected prettifyJsonTypeName(json: string) {
+        const obj = JSON.parse(json) as { [key: string]: string };
+        return "{\n"+Object.keys(obj).map(prop => `  ${prop}${chalk.white(':'+obj[prop])}`).join("\n")+"\n}";
+    }
+
+    // Get param name based on TypeDef object
+    protected paramName(typeDef: TypeDef) {
+        return chalk.green(
+            typeDef.displayName ||
+            typeDef.name ||
+            (typeDef.type.startsWith('{') ? this.prettifyJsonTypeName(typeDef.type) : typeDef.type)
+        );
+    }
+
+    // Prompt for simple/plain value (provided as string) of given type
+    async promptForSimple(typeDef: TypeDef, defaultValue?: Codec): Promise<Codec> {
+        const providedValue = await this.simplePrompt({
+            message: `Provide value for ${ this.paramName(typeDef) }`,
+            type: 'input',
+            default: defaultValue?.toString()
+        });
+        return createType(typeDef.type as any, providedValue);
+    }
+
+    // Prompt for Option<Codec> value
+    async promptForOption(typeDef: TypeDef, defaultValue?: Option<Codec>): Promise<Option<Codec>> {
+        const subtype = <TypeDef> typeDef.sub; // We assume that Opion always has a single subtype
+
+        const confirmed = await this.simplePrompt({
+            message: `Do you want to provide the optional ${ this.paramName(typeDef) } parameter?`,
+            type: 'confirm',
+            default: defaultValue ? defaultValue.isSome : false,
+        });
+
+        if (confirmed) {
+            const value = await this.promptForParam(subtype.type, subtype.name, defaultValue?.unwrapOr(undefined));
+            return new Option(subtype.type as any, value);
+        }
+
+        return new Option(subtype.type as any, null);
+    }
+
+    // Prompt for Tuple
+    // TODO: Not well tested yet
+    async promptForTuple(typeDef: TypeDef, defaultValue: Tuple): Promise<Tuple> {
+        console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } tuple:`));
+
+        this.openIndentGroup();
+        const result: ApiMethodInputArg[] = [];
+        // 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! ];
+
+        for (const [index, subtype] of Object.entries(subtypes)) {
+            const inputParam = await this.promptForParam(subtype.type, subtype.name, defaultValue[parseInt(index)]);
+            result.push(inputParam);
+        }
+        this.closeIndentGroup();
+
+        return new Tuple((subtypes.map(subtype => subtype.type)) as any, result);
+    }
+
+    // Prompt for Struct
+    async promptForStruct(typeDef: TypeDef, defaultValue?: Struct): Promise<ApiMethodInputArg> {
+        console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } struct:`));
+
+        this.openIndentGroup();
+        const structType = typeDef.type;
+        const rawTypeDef = this.getRawTypeDef(structType);
+        // We assume struct typeDef always has array of typeDefs inside ".sub"
+        const structSubtypes = rawTypeDef.sub as TypeDef[];
+
+        const structValues: { [key: string]: ApiMethodInputArg } = {};
+        for (const subtype of structSubtypes) {
+            structValues[subtype.name!] =
+                await this.promptForParam(subtype.type, subtype.name, defaultValue && defaultValue.get(subtype.name!));
+        }
+        this.closeIndentGroup();
+
+        return createType(structType as any, structValues);
+    }
+
+    // Prompt for Vec
+    async promptForVec(typeDef: TypeDef, defaultValue?: Vec<Codec>): 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;
+        let entries: Codec[] = [];
+        let addAnother = false;
+        do {
+            addAnother = await this.simplePrompt({
+                message: `Do you want to add another entry to ${ this.paramName(typeDef) } vector (currently: ${entries.length})?`,
+                type: 'confirm',
+                default: defaultValue ? entries.length < defaultValue.length : false
+            });
+            const defaultEntryValue = defaultValue && defaultValue[entries.length];
+            if (addAnother) entries.push(await this.promptForParam(subtype.type, subtype.name, defaultEntryValue));
+        } while (addAnother);
+        this.closeIndentGroup();
+
+        return new Vec(subtype.type as any, entries);
+    }
+
+    // Prompt for Enum
+    async promptForEnum(typeDef: TypeDef, defaultValue?: Enum): 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 enumSubtypeName = await this.simplePrompt({
+            message: `Choose value for ${this.paramName(typeDef)}:`,
+            type: 'list',
+            choices: enumSubtypes.map(subtype => ({
+                name: subtype.name,
+                value: subtype.name
+            })),
+            default: defaultValue?.type
+        });
+
+        const enumSubtype = enumSubtypes.find(st => st.name === enumSubtypeName)!;
+
+        if (enumSubtype.type !== 'Null') {
+            return createType(
+                enumType as any,
+                { [enumSubtype.name!]: await this.promptForParam(enumSubtype.type, enumSubtype.name, defaultValue?.value) }
+            );
+        }
+
+        return createType(enumType as any, enumSubtype.name);
+    }
+
+    // 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> {
+        const typeDef = getTypeDef(paramType);
+        const rawTypeDef = this.getRawTypeDef(paramType);
+
+        if (forcedName) {
+            typeDef.name = forcedName;
+        }
+
+        if (rawTypeDef.info === TypeDefInfo.Option) return await this.promptForOption(typeDef, defaultValue as Option<Codec>);
+        else if (rawTypeDef.info === TypeDefInfo.Tuple) return await this.promptForTuple(typeDef, defaultValue as Tuple);
+        else if (rawTypeDef.info === TypeDefInfo.Struct) return await this.promptForStruct(typeDef, defaultValue as Struct);
+        else if (rawTypeDef.info === TypeDefInfo.Enum) return await this.promptForEnum(typeDef, defaultValue as Enum);
+        else if (rawTypeDef.info === TypeDefInfo.Vec) return await this.promptForVec(typeDef, defaultValue as Vec<Codec>);
+        else return await this.promptForSimple(typeDef, defaultValue);
+    }
+
+    async promptForJsonBytes(
+        JsonStruct: Constructor<Struct>,
+        argName?: string,
+        defaultValue?: Bytes,
+        schemaValidator?: ajv.ValidateFunction
+    ) {
+        const rawType = (new JsonStruct()).toRawType();
+        const typeDef = getTypeDef(rawType);
+
+        const defaultStruct =
+            defaultValue &&
+            new JsonStruct(JSON.parse(Buffer.from(defaultValue.toHex().replace('0x', ''), 'hex').toString()));
+
+        if (argName) {
+            typeDef.name = argName;
+        }
+
+        let isValid: boolean = true, jsonText: string;
+        do {
+            const structVal = await this.promptForStruct(typeDef, defaultStruct);
+            jsonText = JSON.stringify(structVal.toJSON());
+            if (schemaValidator) {
+                isValid = Boolean(schemaValidator(JSON.parse(jsonText)));
+                if (!isValid) {
+                    this.log("\n");
+                    this.warn(
+                        "Schema validation failed with:\n"+
+                        schemaValidator.errors?.map(e => chalk.red(`${chalk.bold(e.dataPath)}: ${e.message}`)).join("\n")+
+                        "\nTry again..."
+                    )
+                    this.log("\n");
+                }
+            }
+        } while(!isValid);
+
+        return new Bytes('0x'+Buffer.from(jsonText, 'ascii').toString('hex'));
+    }
+
+    async promptForExtrinsicParams(
+        module: string,
+        method: string,
+        jsonArgs?: JSONArgsMapping,
+        defaultValues?: ApiMethodInputArg[]
+    ): Promise<ApiMethodInputArg[]> {
+        const extrinsicMethod = this.getOriginalApi().tx[module][method];
+        let values: ApiMethodInputArg[] = [];
+
+        this.openIndentGroup();
+        for (const [index, arg] of Object.entries(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));
+            }
+        };
+        this.closeIndentGroup();
+
+        return values;
+    }
+
+    sendExtrinsic(account: KeyringPair, module: string, method: string, params: Codec[]) {
+        return new Promise((resolve, reject) => {
+            const extrinsicMethod = this.getOriginalApi().tx[module][method];
+            let unsubscribe: () => void;
+            extrinsicMethod(...params)
+                .signAndSend(account, {}, (result: SubmittableResultImpl) => {
+                    // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
+                    if (!result || !result.status) {
+                        return;
+                    }
+
+                    if (result.status.isFinalized) {
+                      unsubscribe();
+                      result.events
+                        .filter(({ event: { section } }): boolean => section === 'system')
+                        .forEach(({ event: { method } }): void => {
+                          if (method === 'ExtrinsicFailed') {
+                            reject(new ExtrinsicFailedError('Extrinsic failed!'));
+                          } else if (method === 'ExtrinsicSuccess') {
+                            resolve();
+                          }
+                        });
+                    } else if (result.isError) {
+                        reject(new ExtrinsicFailedError('Extrinsic failed!'));
+                    }
+                })
+                .then(unsubFunc => unsubscribe = unsubFunc);
+        });
+
+    }
+
+    async buildAndSendExtrinsic(
+        account: KeyringPair,
+        module: string,
+        method: string,
+        jsonArgs?: JSONArgsMapping, // Special JSON arguments (ie. human_readable_text of worker opening)
+        defaultValues?: ApiMethodInputArg[]
+    ): Promise<ApiMethodInputArg[]> {
+        const params = await this.promptForExtrinsicParams(module, method, jsonArgs, defaultValues);
+        try {
+            this.log(chalk.white(`\nSending ${ module }.${ method } extrinsic...`));
+            await this.sendExtrinsic(account, module, method, params);
+            this.log(chalk.green(`Extrinsic successful!`));
+        } catch (e) {
+            if (e instanceof ExtrinsicFailedError) {
+                throw new CLIError(`${ module }.${ method } extrinsic failed!`, { exit: ExitCodes.ApiError });
+            }
+            throw e;
+        }
+
+        return params;
+    }
+
+    extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodInputArg[] {
+        let draftJSONObj, parsedArgs: ApiMethodInputArg[] = [];
+        const extrinsicMethod = this.getOriginalApi().tx[module][method];
+        try {
+            draftJSONObj = require(draftFilePath);
+        } catch(e) {
+            throw new CLIError(`Could not load draft from: ${draftFilePath}`, { exit: ExitCodes.InvalidFile });
+        }
+        if (
+            !draftJSONObj
+            || !Array.isArray(draftJSONObj)
+            || draftJSONObj.length !== extrinsicMethod.meta.args.length
+        ) {
+            throw new CLIError(`The draft file at ${draftFilePath} is invalid!`, { exit: ExitCodes.InvalidFile });
+        }
+        for (const [index, arg] of Object.entries(extrinsicMethod.meta.args.toArray())) {
+            const argName = arg.name.toString();
+            const argType = arg.type.toString();
+            try {
+                parsedArgs.push(createType(argType as any, draftJSONObj[parseInt(index)]));
+            } catch (e) {
+                throw new CLIError(`Couldn't parse ${argName} value from draft at ${draftFilePath}!`, { exit: ExitCodes.InvalidFile });
+            }
+        }
+
+        return parsedArgs;
+    }
 }

+ 24 - 0
cli/src/base/DefaultCommandBase.ts

@@ -1,11 +1,35 @@
 import ExitCodes from '../ExitCodes';
 import Command from '@oclif/command';
+import inquirer, { DistinctQuestion } from 'inquirer';
 
 /**
  * Abstract base class for pretty much all commands
  * (prevents console.log from hanging the process and unifies the default exit code)
  */
 export default abstract class DefaultCommandBase extends Command {
+    protected indentGroupsOpened = 0;
+
+    openIndentGroup() {
+        console.group();
+        ++this.indentGroupsOpened;
+    }
+
+    closeIndentGroup() {
+        console.groupEnd();
+        --this.indentGroupsOpened;
+    }
+
+    async simplePrompt(question: DistinctQuestion) {
+        const { result } = await inquirer.prompt([{
+            ...question,
+            name: 'result',
+            // prefix = 2 spaces for each group - 1 (because 1 is always added by default)
+            prefix: Array.from(new Array(this.indentGroupsOpened)).map(() => '  ').join('').slice(1)
+        }]);
+
+        return result;
+    }
+
     async finally(err: any) {
         // called after run and catch regardless of whether or not the command errored
         // We'll force exit here, in case there is no error, to prevent console.log from hanging the process

+ 97 - 1
cli/src/base/WorkingGroupsCommandBase.ts

@@ -4,8 +4,13 @@ import { flags } from '@oclif/command';
 import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupLeadWithProfile, GroupMember } from '../Types';
 import { CLIError } from '@oclif/errors';
 import inquirer from 'inquirer';
+import { ApiMethodInputArg } from './ApiCommandBase';
+import fs from 'fs';
+import path from 'path';
+import _ from 'lodash';
 
 const DEFAULT_GROUP = WorkingGroups.StorageProviders;
+const DRAFTS_FOLDER = '/opening-drafts';
 
 /**
  * Abstract base class for commands related to working groups
@@ -67,9 +72,100 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
         return groupMembers[choosenWorkerIndex];
     }
 
+    async promptForNewOpeningDraftName() {
+        let
+            draftName: string = '',
+            fileExists: boolean = false,
+            overrideConfirmed: boolean = false;
+
+        do {
+            draftName = await this.simplePrompt({
+                type: 'input',
+                message: 'Provide the draft name',
+                validate: val => (typeof val === 'string' && val.length >= 1) || 'Draft name is required!'
+            });
+
+            fileExists = fs.existsSync(this.getOpeningDraftPath(draftName));
+            if (fileExists) {
+                overrideConfirmed = await this.simplePrompt({
+                    type: 'confirm',
+                    message: 'Such draft already exists. Do you wish to override it?',
+                    default: false
+                });
+            }
+        } while(fileExists && !overrideConfirmed);
+
+        return draftName;
+    }
+
+    async promptForOpeningDraft() {
+        let draftFiles: string[] = [];
+        try {
+            draftFiles = fs.readdirSync(this.getOpeingDraftsPath());
+        }
+        catch(e) {
+            throw this.createDataReadError(DRAFTS_FOLDER);
+        }
+        if (!draftFiles.length) {
+            throw new CLIError('No drafts available!', { exit: ExitCodes.FileNotFound });
+        }
+        const draftNames = draftFiles.map(fileName => _.startCase(fileName.replace('.json', '')));
+        const selectedDraftName = await this.simplePrompt({
+            message: 'Select a draft',
+            type: 'list',
+            choices: draftNames
+        });
+
+        return selectedDraftName;
+    }
+
+    loadOpeningDraftParams(draftName: string) {
+        const draftFilePath = this.getOpeningDraftPath(draftName);
+        const params = this.extrinsicArgsFromDraft(
+            'storageBureaucracy',
+            'addWorkerOpening',
+            draftFilePath
+        );
+
+        return params;
+    }
+
+    getOpeingDraftsPath() {
+        return path.join(this.getAppDataPath(), DRAFTS_FOLDER);
+    }
+
+    getOpeningDraftPath(draftName: string) {
+        return path.join(this.getOpeingDraftsPath(), _.snakeCase(draftName)+'.json');
+    }
+
+    saveOpeningDraft(draftName: string, params: ApiMethodInputArg[]) {
+        const paramsJson = JSON.stringify(
+            params.map(p => p.toJSON()),
+            null,
+            2
+        );
+
+        try {
+            fs.writeFileSync(this.getOpeningDraftPath(draftName), paramsJson);
+        } catch(e) {
+            throw this.createDataWriteError(DRAFTS_FOLDER);
+        }
+    }
+
+    private initOpeningDraftsDir(): void {
+        if (!fs.existsSync(this.getOpeingDraftsPath())) {
+            fs.mkdirSync(this.getOpeingDraftsPath());
+        }
+    }
+
     async init() {
         await super.init();
-        const { flags } = this.parse(WorkingGroupsCommandBase);
+        try {
+            this.initOpeningDraftsDir();
+        } catch (e) {
+            throw this.createDataDirInitError();
+        }
+        const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase);
         if (!AvailableGroups.includes(flags.group as any)) {
             throw new CLIError('Invalid group!', { exit: ExitCodes.InvalidInput });
         }

+ 11 - 61
cli/src/commands/api/inspect.ts

@@ -2,14 +2,13 @@ import { flags } from '@oclif/command';
 import { CLIError } from '@oclif/errors';
 import { displayNameValueTable } from '../../helpers/display';
 import { ApiPromise } from '@polkadot/api';
-import { getTypeDef } from '@polkadot/types';
-import { Codec, TypeDef, TypeDefInfo } from '@polkadot/types/types';
+import { Option } from '@polkadot/types';
+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 inquirer from 'inquirer';
-import ApiCommandBase from '../../base/ApiCommandBase';
+import ApiCommandBase, { ApiMethodInputArg } from '../../base/ApiCommandBase';
 
 // Command flags type
 type ApiInspectFlags = {
@@ -30,12 +29,6 @@ const TYPES_AVAILABLE = [
 // It works as if we specified: type ApiType = 'query' | 'consts'...;
 type ApiType = typeof TYPES_AVAILABLE[number];
 
-// Format of the api input args (as they are specified in the CLI)
-type ApiMethodInputSimpleArg = string;
-// This recurring type allows the correct handling of nested types like:
-// ((Type1, Type2), Option<Type3>) etc.
-type ApiMethodInputArg = ApiMethodInputSimpleArg | ApiMethodInputArg[];
-
 export default class ApiInspect extends ApiCommandBase {
     static description =
         'Lists available node API modules/methods and/or their description(s), '+
@@ -154,62 +147,19 @@ export default class ApiInspect extends ApiCommandBase {
         return { apiType, apiModule, apiMethod };
     }
 
-    // Prompt for simple value (string)
-    async promptForSimple(typeName: string): Promise<string> {
-        const userInput = await inquirer.prompt([{
-            name: 'providedValue',
-            message: `Provide value for ${ typeName }`,
-            type: 'input'
-        } ])
-        return <string> userInput.providedValue;
-    }
-
-    // Prompt for optional value (returns undefined if user refused to provide)
-    async promptForOption(typeDef: TypeDef): Promise<ApiMethodInputArg | undefined> {
-        const userInput = await inquirer.prompt([{
-            name: 'confirmed',
-            message: `Do you want to provide the optional ${ typeDef.type } parameter?`,
-            type: 'confirm'
-        } ]);
-
-        if (userInput.confirmed) {
-            const subtype = <TypeDef> typeDef.sub; // We assume that Opion always has a single subtype
-            let value = await this.promptForParam(subtype.type);
-            return value;
-        }
-    }
-
-    // Prompt for tuple - returns array of values
-    async promptForTuple(typeDef: TypeDef): Promise<(ApiMethodInputArg)[]> {
-        let result: ApiMethodInputArg[] = [];
-
-        if (!typeDef.sub) return [ await this.promptForSimple(typeDef.type) ];
-
-        const subtypes: TypeDef[] = Array.isArray(typeDef.sub) ? typeDef.sub : [ typeDef.sub ];
-
-        for (let subtype of subtypes) {
-            let inputParam = await this.promptForParam(subtype.type);
-            if (inputParam !== undefined) result.push(inputParam);
-        }
-
-        return result;
-    }
-
-    // Prompt for param based on "paramType" string (ie. Option<MemeberId>)
-    async promptForParam(paramType: string): Promise<ApiMethodInputArg | undefined> {
-        const typeDef: TypeDef = getTypeDef(paramType);
-        if (typeDef.info === TypeDefInfo.Option) return await this.promptForOption(typeDef);
-        else if (typeDef.info === TypeDefInfo.Tuple) return await this.promptForTuple(typeDef);
-        else return await this.promptForSimple(typeDef.type);
-    }
-
     // Request values for params using array of param types (strings)
     async requestParamsValues(paramTypes: string[]): Promise<ApiMethodInputArg[]> {
         let result: ApiMethodInputArg[] = [];
         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);
-            if (paramValue !== undefined) result.push(paramValue);
+            if (paramValue instanceof Option && paramValue.isSome) {
+                result.push(paramValue.unwrap());
+            }
+            else if (!(paramValue instanceof Option)) {
+                result.push(paramValue);
+            }
+            // In case of empty option we MUST NOT add anything to the array (otherwise it causes some error)
         }
 
         return result;
@@ -227,7 +177,7 @@ export default class ApiInspect extends ApiCommandBase {
 
             if (apiType === 'query') {
                 // Api query - call with (or without) arguments
-                let args: ApiMethodInputArg[] = flags.callArgs ? flags.callArgs.split(',') : [];
+                let args: (string | ApiMethodInputArg)[] = 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:');

+ 73 - 0
cli/src/commands/working-groups/createOpening.ts

@@ -0,0 +1,73 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import { HRTStruct } from '../../Types';
+import chalk from 'chalk';
+import { flags } from '@oclif/command';
+import { ApiMethodInputArg } from '../../base/ApiCommandBase';
+import { schemaValidator } from '@joystream/types/lib/hiring';
+
+export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
+    static description = 'Create working group opening (requires lead access)';
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+        useDraft: flags.boolean({
+            char: 'd',
+            description:
+                "Whether to create the opening from existing draft.\n"+
+                "If provided without --draftName - the list of choices will be displayed."
+        }),
+        draftName: flags.string({
+            char: 'n',
+            description:
+                'Name of the draft to create the opening from.',
+            dependsOn: ['useDraft']
+        }),
+        skipPrompts: flags.boolean({
+            char: 's',
+            description:
+                "Whether to skip all prompts when adding from draft (will use all default values)",
+            dependsOn: ['useDraft']
+        })
+    };
+
+    async run() {
+        const account = await this.getRequiredSelectedAccount();
+        // lead-only gate
+        await this.getRequiredLead();
+
+        const { flags } = this.parse(WorkingGroupsCreateOpening);
+
+        let defaultValues: ApiMethodInputArg[] | undefined = undefined;
+        if (flags.useDraft) {
+            const draftName = flags.draftName || await this.promptForOpeningDraft();
+            defaultValues =  await this.loadOpeningDraftParams(draftName);
+        }
+
+        await this.requestAccountDecoding(account); // Prompt for password
+        if (!flags.skipPrompts) {
+            const params = await this.buildAndSendExtrinsic(
+                account,
+                'storageBureaucracy',
+                'addWorkerOpening',
+                { 'human_readable_text': { struct: HRTStruct, schemaValidator } },
+                defaultValues
+            );
+
+            const saveDraft = await this.simplePrompt({
+                message: 'Do you wish to save this opportunity as draft?',
+                type: 'confirm'
+            });
+
+            if (saveDraft) {
+                const draftName = await this.promptForNewOpeningDraftName();
+                this.saveOpeningDraft(draftName, params);
+
+                this.log(chalk.green(`Opening draft ${ chalk.white(draftName) } succesfully saved!`));
+            }
+        }
+        else {
+            this.log(chalk.white('Sending the extrinsic...'));
+            await this.sendExtrinsic(account, 'storageBureaucracy', 'addWorkerOpening', defaultValues!);
+            this.log(chalk.green('Opening succesfully created!'));
+        }
+    }
+  }

+ 6 - 1
types/src/hiring/index.ts

@@ -345,7 +345,7 @@ export class StakingPolicy extends JoyStruct<IStakingPolicy> {
 };
 
 import * as role_schema_json from './schemas/role.schema.json'
-const schemaValidator = new ajv({ allErrors: true }).compile(role_schema_json)
+export const schemaValidator: ajv.ValidateFunction = new ajv({ allErrors: true }).compile(role_schema_json)
 
 export type IOpening = {
   created: BlockNumber,
@@ -379,11 +379,16 @@ export class Opening extends JoyStruct<IOpening> {
 
     const str = hrt.toString()
 
+    console.log('Parse hrt string:', str);
+
+
     try {
       const obj = JSON.parse(str)
       if (schemaValidator(obj) === true) {
+        console.log('HRT success', obj);
         return obj as unknown as GenericJoyStreamRoleSchema
       }
+      console.log('HRT fail', obj);
     } catch (e) {
       console.log("JSON schema validation failed:", e.toString())
     }