Browse Source

Merge pull request #286 from Lezek123/api_viewer

CLI: "api:" commands family (inspecting api and specifying custom api url)
Bedeho Mender 4 years ago
parent
commit
2661f3d5c5

+ 72 - 0
cli/README.md

@@ -61,6 +61,9 @@ USAGE
 * [`joystream-cli account:forget`](#joystream-cli-accountforget)
 * [`joystream-cli account:import BACKUPFILEPATH`](#joystream-cli-accountimport-backupfilepath)
 * [`joystream-cli account:transferTokens RECIPIENT AMOUNT`](#joystream-cli-accounttransfertokens-recipient-amount)
+* [`joystream-cli api:getUri`](#joystream-cli-apigeturi)
+* [`joystream-cli api:inspect`](#joystream-cli-apiinspect)
+* [`joystream-cli api:setUri URI`](#joystream-cli-apiseturi-uri)
 * [`joystream-cli council:info`](#joystream-cli-councilinfo)
 * [`joystream-cli help [COMMAND]`](#joystream-cli-help-command)
 
@@ -161,6 +164,75 @@ ARGUMENTS
 
 _See code: [src/commands/account/transferTokens.ts](https://github.com/Joystream/substrate-runtime-joystream/blob/master/cli/src/commands/account/transferTokens.ts)_
 
+## `joystream-cli api:getUri`
+
+Get current api WS provider uri
+
+```
+USAGE
+  $ joystream-cli api:getUri
+```
+
+_See code: [src/commands/api/getUri.ts](https://github.com/Joystream/substrate-runtime-joystream/blob/master/cli/src/commands/api/getUri.ts)_
+
+## `joystream-cli api:inspect`
+
+Lists available node API modules/methods and/or their description(s), or calls one of the API methods (depending on provided arguments and flags)
+
+```
+USAGE
+  $ joystream-cli api:inspect
+
+OPTIONS
+  -M, --module=module
+      Specifies the api module, ie. "system", "staking" etc.
+      If no "--method" flag is provided then all methods in that module will be listed along with the descriptions.
+
+  -a, --callArgs=callArgs
+      Specifies the arguments to use when calling a method. Multiple arguments can be separated with a comma, ie. 
+      "-a=arg1,arg2".
+      You can omit this flag even if the method requires some aguments.
+      In that case you will be promted to provide value for each required argument.
+      Ommiting this flag is recommended when input parameters are of more complex types (and it's hard to specify them as 
+      just simple comma-separated strings)
+
+  -e, --exec
+      Provide this flag if you want to execute the actual call, instead of displaying the method description (which is 
+      default)
+
+  -m, --method=method
+      Specifies the api method to call/describe.
+
+  -t, --type=type
+      Specifies the type/category of the inspected request (ie. "query", "consts" etc.).
+      If no "--module" flag is provided then all available modules in that type will be listed.
+      If this flag is not provided then all available types will be listed.
+
+EXAMPLES
+  $ api:inspect
+  $ api:inspect -t=query
+  $ api:inspect -t=query -M=members
+  $ api:inspect -t=query -M=members -m=memberProfile
+  $ api:inspect -t=query -M=members -m=memberProfile -e
+  $ api:inspect -t=query -M=members -m=memberProfile -e -a=1
+```
+
+_See code: [src/commands/api/inspect.ts](https://github.com/Joystream/substrate-runtime-joystream/blob/master/cli/src/commands/api/inspect.ts)_
+
+## `joystream-cli api:setUri URI`
+
+Set api WS provider uri
+
+```
+USAGE
+  $ joystream-cli api:setUri URI
+
+ARGUMENTS
+  URI  Uri of the node api WS provider
+```
+
+_See code: [src/commands/api/setUri.ts](https://github.com/Joystream/substrate-runtime-joystream/blob/master/cli/src/commands/api/setUri.ts)_
+
 ## `joystream-cli council:info`
 
 Get current council and council elections information

+ 28 - 0
cli/package-lock.json

@@ -610,6 +610,19 @@
         "@types/node": "*"
       }
     },
+    "@types/proper-lockfile": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.1.tgz",
+      "integrity": "sha512-HAjVfDa73pFgivViHyDu8HHHcds+W4MgOuZZAdyFJrHS8ngtCXmhl4hc2YXqSOwO6Bsa+iF2Sgxb2+gv874VOQ==",
+      "requires": {
+        "@types/retry": "*"
+      }
+    },
+    "@types/retry": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
+      "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="
+    },
     "@types/secp256k1": {
       "version": "3.5.3",
       "resolved": "https://registry.npmjs.org/@types/secp256k1/-/secp256k1-3.5.3.tgz",
@@ -3549,6 +3562,16 @@
       "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
       "dev": true
     },
+    "proper-lockfile": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.1.tgz",
+      "integrity": "sha512-1w6rxXodisVpn7QYvLk706mzprPTAPCYAqxMvctmPN3ekuRk/kuGkGc82pangZiAt4R3lwSuUzheTTn0/Yb7Zg==",
+      "requires": {
+        "graceful-fs": "^4.1.11",
+        "retry": "^0.12.0",
+        "signal-exit": "^3.0.2"
+      }
+    },
     "pseudomap": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
@@ -3796,6 +3819,11 @@
       "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
       "dev": true
     },
+    "retry": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+      "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs="
+    },
     "reusify": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",

+ 7 - 2
cli/package.json

@@ -14,10 +14,12 @@
     "@oclif/plugin-help": "^2.2.3",
     "@polkadot/api": "^0.96.1",
     "@types/inquirer": "^6.5.0",
+    "@types/proper-lockfile": "^4.1.1",
     "@types/slug": "^0.9.1",
     "cli-ux": "^5.4.5",
     "inquirer": "^7.1.0",
     "moment": "^2.24.0",
+    "proper-lockfile": "^4.1.1",
     "slug": "^2.1.1",
     "tslib": "^1.11.1"
   },
@@ -66,12 +68,15 @@
       },
       "account": {
         "description": "Accounts management - create, import or switch currently used account"
+      },
+      "api": {
+        "description": "Inspect the substrate node api, perform lower-level api calls or change the current api provider uri"
       }
     }
   },
   "repository": {
-    "type" : "git",
-    "url" : "https://github.com/Joystream/substrate-runtime-joystream",
+    "type": "git",
+    "url": "https://github.com/Joystream/substrate-runtime-joystream",
     "directory": "cli"
   },
   "scripts": {

+ 12 - 11
cli/src/Api.ts

@@ -11,12 +11,8 @@ import { DerivedFees, DerivedBalances } from '@polkadot/api-derive/types';
 import { CLIError } from '@oclif/errors';
 import ExitCodes from './ExitCodes';
 
-const API_URL = process.env.WS_URL || (
-    process.env.MAIN_TESTNET ?
-        'wss://rome-rpc-endpoint.joystream.org:9944/'
-        : 'wss://rome-staging-2.joystream.org/staging/rpc/'
-);
-const TOKEN_SYMBOL = 'JOY';
+export const DEFAULT_API_URI = 'wss://rome-rpc-endpoint.joystream.org:9944/';
+export const TOKEN_SYMBOL = 'JOY';
 
 // Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
 
@@ -27,16 +23,20 @@ export default class Api {
         this._api = originalApi;
     }
 
-    private static async initApi(): Promise<ApiPromise> {
+    public getOriginalApi(): ApiPromise {
+        return this._api;
+    }
+
+    private static async initApi(apiUri: string = DEFAULT_API_URI): Promise<ApiPromise> {
         formatBalance.setDefaults({ unit: TOKEN_SYMBOL });
-        const wsProvider:WsProvider = new WsProvider(API_URL);
+        const wsProvider:WsProvider = new WsProvider(apiUri);
         registerJoystreamTypes();
 
         return await ApiPromise.create({ provider: wsProvider });
     }
 
-    static async create(): Promise<Api> {
-        const originalApi: ApiPromise = await Api.initApi();
+    static async create(apiUri: string = DEFAULT_API_URI): Promise<Api> {
+        const originalApi: ApiPromise = await Api.initApi(apiUri);
         return new Api(originalApi);
     }
 
@@ -52,7 +52,8 @@ export default class Api {
         if (!results.length || results.length !== queries.length) {
             throw new CLIError('API querying issue', { exit: ExitCodes.ApiError });
         }
-            return results;
+
+        return results;
     }
 
     async getAccountsBalancesInfo(accountAddresses:string[]): Promise<AccountBalances[]> {

+ 12 - 83
cli/src/base/AccountsCommandBase.ts

@@ -4,38 +4,23 @@ import slug from 'slug';
 import inquirer from 'inquirer';
 import ExitCodes from '../ExitCodes';
 import { CLIError } from '@oclif/errors';
-import { Command } from '@oclif/command';
+import ApiCommandBase from './ApiCommandBase';
 import { Keyring } from '@polkadot/api';
-import { KeyringPair$Json, KeyringPair } from '@polkadot/keyring/types';
-import Api from '../Api';
 import { formatBalance } from '@polkadot/util';
 import { AccountBalances, NamedKeyringPair } from '../Types';
 
-type StateObject = { selectedAccountFilename: string };
+const ACCOUNTS_DIRNAME = '/accounts';
 
 /**
  * Abstract base class for account-related commands.
  *
  * All the accounts available in the CLI are stored in the form of json backup files inside:
  * { this.config.dataDir }/{ ACCOUNTS_DIRNAME } (ie. ~/.local/share/joystream-cli/accounts on Ubuntu)
- * Where: this.config.dataDir is provided by oclif and ACCOUNTS_DIRNAME is a static of AccountsCommandBase.
- *
- * Additionally, there is a state.json file kept inside the data directory (this.config.dataDir).
- * Currently it just stores information about the default account that can be choosen by the user
- * by executing account:choose command (see "StateObject" type above).
+ * Where: this.config.dataDir is provided by oclif and ACCOUNTS_DIRNAME is a const (see above).
  */
-export default abstract class AccountsCommandBase extends Command {
-    static ACCOUNTS_DIRNAME = '/accounts';
-    static STATE_FILE = '/state.json';
-    private api: Api | null = null;
-
-    getApi(): Api {
-        if (!this.api) throw new CLIError('Tried to get API before initialization.', { exit: ExitCodes.ApiError });
-        return this.api;
-    }
-
+export default abstract class AccountsCommandBase extends ApiCommandBase {
     getAccountsDirPath(): string {
-        return path.join(this.config.dataDir, AccountsCommandBase.ACCOUNTS_DIRNAME);
+        return path.join(this.config.dataDir, ACCOUNTS_DIRNAME);
     }
 
     getAccountFilePath(account: NamedKeyringPair): string {
@@ -46,35 +31,10 @@ export default abstract class AccountsCommandBase extends Command {
         return `${ slug(account.meta.name, '_') }__${ account.address }.json`;
     }
 
-    getStateFilePath(): string {
-        return path.join(this.config.dataDir, AccountsCommandBase.STATE_FILE);
-    }
-
-    private createDataReadError(): CLIError {
-        return new CLIError(
-            `Unexpected error while trying to read from the data directory (${this.config.dataDir})! Permissions issue?`,
-            { exit: ExitCodes.FsOperationFailed }
-        );
-    }
-
-    private createDataWriteError(): CLIError {
-        return new CLIError(
-            `Unexpected error while trying to write into the data directory (${this.config.dataDir})! Permissions issue?`,
-            { exit: ExitCodes.FsOperationFailed }
-        );
-    }
-
-    private initDataDir(): void {
-        const initialState: StateObject = { selectedAccountFilename: '' };
-        if (!fs.existsSync(this.config.dataDir)) {
-            fs.mkdirSync(this.config.dataDir);
-        }
+    private initAccountsFs(): void {
         if (!fs.existsSync(this.getAccountsDirPath())) {
             fs.mkdirSync(this.getAccountsDirPath());
         }
-        if (!fs.existsSync(this.getStateFilePath())) {
-            fs.writeFileSync(this.getStateFilePath(), JSON.stringify(initialState));
-        }
     }
 
     saveAccount(account: NamedKeyringPair, password: string): void {
@@ -147,16 +107,8 @@ export default abstract class AccountsCommandBase extends Command {
             .filter(accObj => accObj !== null);
     }
 
-    // TODO: Probably some better way to handle state will be required later
     getSelectedAccountFilename(): string {
-        let state: StateObject;
-        try {
-            state = <StateObject> require(this.getStateFilePath());
-        } catch(e) {
-            throw this.createDataReadError();
-        }
-
-        return state.selectedAccountFilename;
+        return this.getPreservedState().selectedAccountFilename;
     }
 
     getSelectedAccount(): NamedKeyringPair | null {
@@ -190,21 +142,8 @@ export default abstract class AccountsCommandBase extends Command {
         return selectedAccount;
     }
 
-    setSelectedAccount(account: NamedKeyringPair): void {
-        let state: StateObject;
-        try {
-            state = <StateObject> require(this.getStateFilePath());
-        } catch(e) {
-            throw this.createDataReadError();
-        }
-
-        state.selectedAccountFilename = this.generateAccountFilename(account);
-
-        try {
-            fs.writeFileSync(this.getStateFilePath(), JSON.stringify(state));
-        } catch(e) {
-            throw this.createDataWriteError();
-        }
+    async setSelectedAccount(account: NamedKeyringPair): Promise<void> {
+        await this.setPreservedState({ selectedAccountFilename: this.generateAccountFilename(account) });
     }
 
     async promptForPassword(message:string = 'Your account\'s password') {
@@ -259,21 +198,11 @@ export default abstract class AccountsCommandBase extends Command {
     }
 
     async init() {
-        this.api = await Api.create();
+        await super.init();
         try {
-            this.initDataDir();
+            this.initAccountsFs();
         } catch (e) {
-            this.error(
-                'Unexpected error while trying to initialize the data directory! Permissions issue?',
-                { exit: ExitCodes.FsOperationFailed }
-            );
+            throw this.createDataDirInitError();
         }
     }
-
-    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
-        if (!err) this.exit(ExitCodes.OK);
-        super.finally(err);
-    }
 }

+ 28 - 0
cli/src/base/ApiCommandBase.ts

@@ -0,0 +1,28 @@
+import ExitCodes from '../ExitCodes';
+import { CLIError } from '@oclif/errors';
+import StateAwareCommandBase from './StateAwareCommandBase';
+import Api from '../Api';
+import { ApiPromise } from '@polkadot/api'
+
+/**
+ * Abstract base class for commands that require access to the API.
+ */
+export default abstract class ApiCommandBase extends StateAwareCommandBase {
+    private api: Api | null = null;
+
+    getApi(): Api {
+        if (!this.api) throw new CLIError('Tried to get API before initialization.', { exit: ExitCodes.ApiError });
+        return this.api;
+    }
+
+    // Get original api for lower-level api calls
+    getOriginalApi(): ApiPromise {
+        return this.getApi().getOriginalApi();
+    }
+
+    async init() {
+        await super.init();
+        const apiUri: string = this.getPreservedState().apiUri;
+        this.api = await Api.create(apiUri);
+    }
+}

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

@@ -0,0 +1,15 @@
+import ExitCodes from '../ExitCodes';
+import Command from '@oclif/command';
+
+/**
+ * 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 {
+    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
+        if (!err) this.exit(ExitCodes.OK);
+        super.finally(err);
+    }
+}

+ 115 - 0
cli/src/base/StateAwareCommandBase.ts

@@ -0,0 +1,115 @@
+import fs from 'fs';
+import path from 'path';
+import ExitCodes from '../ExitCodes';
+import { CLIError } from '@oclif/errors';
+import { DEFAULT_API_URI } from '../Api';
+import lockFile from 'proper-lockfile';
+import DefaultCommandBase from './DefaultCommandBase';
+
+// Type for the state object (which is preserved as json in the state file)
+type StateObject = {
+    selectedAccountFilename: string,
+    apiUri: string
+};
+
+// State object default values
+const DEFAULT_STATE: StateObject = {
+    selectedAccountFilename: '',
+    apiUri: DEFAULT_API_URI
+}
+
+// State file path (relative to this.config.dataDir)
+const STATE_FILE = '/state.json';
+
+// Possible data directory access errors
+enum DataDirErrorType {
+    Init = 0,
+    Read = 1,
+    Write = 2,
+}
+
+/**
+ * Abstract base class for commands that need to work with the preserved state.
+ *
+ * The preserved state is kept in a json file inside the data directory (this.config.dataDir, supplied by oclif).
+ * The state object contains all the information that needs to be preserved across sessions, ie. the default account
+ * choosen by the user after executing account:choose command etc. (see "StateObject" type above).
+ */
+export default abstract class StateAwareCommandBase extends DefaultCommandBase {
+    getStateFilePath(): string {
+        return path.join(this.config.dataDir, STATE_FILE);
+    }
+
+    private createDataDirFsError(errorType: DataDirErrorType, specificPath: string = '') {
+        const actionStrs: { [x in DataDirErrorType]: string } = {
+            [DataDirErrorType.Init]: 'initialize',
+            [DataDirErrorType.Read]: 'read from',
+            [DataDirErrorType.Write]: 'write into'
+        };
+
+        const errorMsg =
+            `Unexpected error while trying to ${ actionStrs[errorType] } the data directory.`+
+            `(${ path.join(this.config.dataDir, specificPath) })! Permissions issue?`;
+
+        return new CLIError(errorMsg, { exit: ExitCodes.FsOperationFailed });
+    }
+
+    createDataReadError(specificPath: string = ''): CLIError {
+        return this.createDataDirFsError(DataDirErrorType.Read, specificPath);
+    }
+
+    createDataWriteError(specificPath: string = ''): CLIError {
+        return this.createDataDirFsError(DataDirErrorType.Write, specificPath);
+    }
+
+    createDataDirInitError(specificPath: string = ''): CLIError {
+        return this.createDataDirFsError(DataDirErrorType.Init, specificPath);
+    }
+
+    private initStateFs(): void {
+        if (!fs.existsSync(this.config.dataDir)) {
+            fs.mkdirSync(this.config.dataDir);
+        }
+        if (!fs.existsSync(this.getStateFilePath())) {
+            fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE));
+        }
+    }
+
+    getPreservedState(): StateObject {
+        let preservedState: StateObject;
+        try {
+            preservedState = <StateObject> require(this.getStateFilePath());
+        } catch(e) {
+            throw this.createDataReadError();
+        }
+        // The state preserved in a file may be missing some required values ie.
+        // if the user previously used the older version of the software.
+        // That's why we combine it with default state before returing.
+        return { ...DEFAULT_STATE, ...preservedState };
+    }
+
+    // Modifies preserved state. Uses file lock in order to avoid updating an older state.
+    // (which could potentialy change between read and write operation)
+    async setPreservedState(modifiedState: Partial<StateObject>): Promise<void> {
+        const stateFilePath = this.getStateFilePath();
+        const unlock = await lockFile.lock(stateFilePath);
+        let oldState: StateObject = this.getPreservedState();
+        let newState: StateObject = { ...oldState, ...modifiedState };
+        try {
+            fs.writeFileSync(stateFilePath, JSON.stringify(newState));
+        } catch(e) {
+            await unlock();
+            throw this.createDataWriteError();
+        }
+        await unlock();
+    }
+
+    async init() {
+        await super.init();
+        try {
+            await this.initStateFs();
+        } catch (e) {
+            throw this.createDataDirInitError();
+        }
+    }
+}

+ 1 - 1
cli/src/commands/account/choose.ts

@@ -19,7 +19,7 @@ export default class AccountChoose extends AccountsCommandBase {
 
         const choosenAccount: NamedKeyringPair = await this.promptForAccount(accounts, selectedAccount);
 
-        this.setSelectedAccount(choosenAccount);
+        await this.setSelectedAccount(choosenAccount);
         this.log(chalk.greenBright("\nAccount switched!"));
     }
   }

+ 0 - 1
cli/src/commands/account/current.ts

@@ -1,5 +1,4 @@
 import AccountsCommandBase from '../../base/AccountsCommandBase';
-import Api from '../../Api';
 import { AccountSummary, NameValueObj, NamedKeyringPair } from '../../Types';
 import { displayHeader, displayNameValueTable } from '../../helpers/display';
 import { formatBalance } from '@polkadot/util';

+ 12 - 0
cli/src/commands/api/getUri.ts

@@ -0,0 +1,12 @@
+import StateAwareCommandBase from '../../base/StateAwareCommandBase';
+import chalk from 'chalk';
+
+
+export default class ApiGetUri extends StateAwareCommandBase {
+    static description = 'Get current api WS provider uri';
+
+    async run() {
+        const currentUri:string = this.getPreservedState().apiUri;
+        this.log(chalk.green(currentUri));
+    }
+  }

+ 277 - 0
cli/src/commands/api/inspect.ts

@@ -0,0 +1,277 @@
+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 { 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';
+
+// Command flags type
+type ApiInspectFlags = {
+    type: string,
+    module: string,
+    method: string,
+    exec: boolean,
+    callArgs: string
+};
+
+// Currently "inspectable" api types
+const TYPES_AVAILABLE = [
+    'query',
+    'consts',
+] as const;
+
+// String literals type based on TYPES_AVAILABLE const.
+// 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), '+
+        'or calls one of the API methods (depending on provided arguments and flags)';
+
+    static examples = [
+        '$ api:inspect',
+        '$ api:inspect -t=query',
+        '$ api:inspect -t=query -M=members',
+        '$ api:inspect -t=query -M=members -m=memberProfile',
+        '$ api:inspect -t=query -M=members -m=memberProfile -e',
+        '$ api:inspect -t=query -M=members -m=memberProfile -e -a=1',
+    ];
+
+    static flags = {
+        type: flags.string({
+            char: 't',
+            description:
+                'Specifies the type/category of the inspected request (ie. "query", "consts" etc.).\n'+
+                'If no "--module" flag is provided then all available modules in that type will be listed.\n'+
+                'If this flag is not provided then all available types will be listed.',
+        }),
+        module: flags.string({
+            char: 'M',
+            description:
+                'Specifies the api module, ie. "system", "staking" etc.\n'+
+                'If no "--method" flag is provided then all methods in that module will be listed along with the descriptions.',
+            dependsOn: ['type'],
+        }),
+        method: flags.string({
+            char: 'm',
+            description: 'Specifies the api method to call/describe.',
+            dependsOn: ['module'],
+        }),
+        exec: flags.boolean({
+            char: 'e',
+            description: 'Provide this flag if you want to execute the actual call, instead of displaying the method description (which is default)',
+            dependsOn: ['method'],
+        }),
+        callArgs: flags.string({
+            char: 'a',
+            description:
+                'Specifies the arguments to use when calling a method. Multiple arguments can be separated with a comma, ie. "-a=arg1,arg2".\n'+
+                'You can omit this flag even if the method requires some aguments.\n'+
+                'In that case you will be promted to provide value for each required argument.\n' +
+                'Ommiting this flag is recommended when input parameters are of more complex types (and it\'s hard to specify them as just simple comma-separated strings)',
+            dependsOn: ['exec'],
+        })
+    };
+
+    getMethodMeta(apiType: ApiType, apiModule: string, apiMethod: string) {
+        if (apiType === 'query') {
+            return this.getOriginalApi().query[apiModule][apiMethod].creator.meta;
+        }
+        else {
+            // Currently the only other optoin is api.consts
+            const method:ConstantCodec = <ConstantCodec> this.getOriginalApi().consts[apiModule][apiMethod];
+            return method.meta;
+        }
+    }
+
+    getMethodDescription(apiType: ApiType, apiModule: string, apiMethod: string): string {
+        let description:string = this.getMethodMeta(apiType, apiModule, apiMethod).documentation.join(' ');
+        return description || 'No description available.';
+    }
+
+    getQueryMethodParamsTypes(apiModule: string, apiMethod: string): string[] {
+        const method = this.getOriginalApi().query[apiModule][apiMethod];
+        const { type } = method.creator.meta;
+        if (type.isDoubleMap) {
+            return [ type.asDoubleMap.key1.toString(), type.asDoubleMap.key2.toString() ];
+        }
+        if (type.isMap) {
+            return type.asMap.linked.isTrue ? [ `Option<${type.asMap.key.toString()}>` ] : [ type.asMap.key.toString() ];
+        }
+        return [];
+    }
+
+    getMethodReturnType(apiType: ApiType, apiModule: string, apiMethod: string): string {
+        if (apiType === 'query') {
+            const method = this.getOriginalApi().query[apiModule][apiMethod];
+            const { meta: { type, modifier } } = method.creator;
+            if (type.isDoubleMap) {
+                return type.asDoubleMap.value.toString();
+            }
+            if (modifier.isOptional) {
+                return `Option<${type.toString()}>`;
+            }
+        }
+        // Fallback for "query" and default for "consts"
+        return this.getMethodMeta(apiType, apiModule, apiMethod).type.toString();
+    }
+
+    // Validate the flags - throws an error if flags.type, flags.module or flags.method is invalid / does not exist in the api.
+    // Returns type, module and method which validity we can be sure about (notice they may still be "undefined" if weren't provided).
+    validateFlags(api: ApiPromise, flags: ApiInspectFlags): { apiType: ApiType | undefined, apiModule: string | undefined, apiMethod: string | undefined } {
+        let apiType: ApiType | undefined = undefined;
+        const { module: apiModule, method: apiMethod } = flags;
+
+        if (flags.type !== undefined) {
+            const availableTypes: readonly string[] = TYPES_AVAILABLE;
+            if (!availableTypes.includes(flags.type)) {
+                throw new CLIError('Such type is not available', { exit: ExitCodes.InvalidInput });
+            }
+            apiType = <ApiType> flags.type;
+            if (apiModule !== undefined) {
+                if (!api[apiType][apiModule]) {
+                    throw new CLIError('Such module was not found', { exit: ExitCodes.InvalidInput });
+                }
+                if (apiMethod !== undefined && !api[apiType][apiModule][apiMethod]) {
+                    throw new CLIError('Such method was not found', { exit: ExitCodes.InvalidInput });
+                }
+            }
+        }
+
+        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);
+        }
+
+        return result;
+    }
+
+    async run() {
+        const api: ApiPromise = this.getOriginalApi();
+        const flags: ApiInspectFlags = <ApiInspectFlags> this.parse(ApiInspect).flags;
+        const availableTypes: readonly string[] = TYPES_AVAILABLE;
+        const { apiType, apiModule, apiMethod } = this.validateFlags(api, flags);
+
+        // Executing a call
+        if (apiType && apiModule && apiMethod && flags.exec) {
+            let result: Codec;
+
+            if (apiType === 'query') {
+                // Api query - call with (or without) arguments
+                let args: 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:');
+                    let missingParamsValues = await this.requestParamsValues(paramsTypes.slice(args.length));
+                    args = args.concat(missingParamsValues);
+                }
+                result = await api.query[apiModule][apiMethod](...args);
+            }
+            else {
+                // Api consts - just assign the value
+                result = api.consts[apiModule][apiMethod];
+            }
+
+            this.log(chalk.green(result.toString()));
+        }
+        // Describing a method
+        else if (apiType && apiModule && apiMethod) {
+            this.log(chalk.bold.white(`${ apiType }.${ apiModule }.${ apiMethod }`));
+            const description: string = this.getMethodDescription(apiType, apiModule, apiMethod);
+            this.log(`\n${ description }\n`);
+            let typesRows: NameValueObj[] = [];
+            if (apiType === 'query') {
+                typesRows.push({ name: 'Params:', value: this.getQueryMethodParamsTypes(apiModule, apiMethod).join(', ') || '-' });
+            }
+            typesRows.push({ name: 'Returns:', value: this.getMethodReturnType(apiType, apiModule, apiMethod) });
+            displayNameValueTable(typesRows);
+        }
+        // Displaying all available methods
+        else if (apiType && apiModule) {
+            const module = api[apiType][apiModule];
+            const rows: NameValueObj[] = Object.keys(module).map((key: string) => {
+                return { name: key, value: this.getMethodDescription(apiType, apiModule, key) };
+            });
+            displayNameValueTable(rows);
+        }
+        // Displaying all available modules
+        else if (apiType) {
+            this.log(chalk.bold.white('Available modules:'));
+            this.log(Object.keys(api[apiType]).map(key => chalk.white(key)).join('\n'));
+        }
+        // Displaying all available types
+        else {
+            this.log(chalk.bold.white('Available types:'));
+            this.log(availableTypes.map(type => chalk.white(type)).join('\n'));
+        }
+    }
+}

+ 28 - 0
cli/src/commands/api/setUri.ts

@@ -0,0 +1,28 @@
+import StateAwareCommandBase from '../../base/StateAwareCommandBase';
+import chalk from 'chalk';
+import { WsProvider } from '@polkadot/api';
+import ExitCodes from '../../ExitCodes';
+
+type ApiSetUriArgs = { uri: string };
+
+export default class ApiSetUri extends StateAwareCommandBase {
+    static description = 'Set api WS provider uri';
+    static args = [
+        {
+            name: 'uri',
+            required: true,
+            description: 'Uri of the node api WS provider'
+        }
+    ];
+
+    async run() {
+        const args: ApiSetUriArgs = <ApiSetUriArgs> this.parse(ApiSetUri).args;
+        try {
+            new WsProvider(args.uri);
+        } catch(e) {
+            this.error('The WS provider uri seems to be incorrect', { exit: ExitCodes.InvalidInput });
+        }
+        await this.setPreservedState({ apiUri: args.uri });
+        this.log(chalk.greenBright('Api uri successfuly changed! New uri: ') + chalk.white(args.uri))
+    }
+  }

+ 3 - 6
cli/src/commands/council/info.ts

@@ -1,12 +1,11 @@
 import { ElectionStage } from '@joystream/types';
 import { formatNumber, formatBalance } from '@polkadot/util';
 import { BlockNumber } from '@polkadot/types/interfaces';
-import Command from '@oclif/command';
 import { CouncilInfoObj, NameValueObj } from '../../Types';
 import { displayHeader, displayNameValueTable } from '../../helpers/display';
-import Api from '../../Api';
+import ApiCommandBase from '../../base/ApiCommandBase';
 
-export default class CouncilInfo extends Command {
+export default class CouncilInfo extends ApiCommandBase {
     static description = 'Get current council and council elections information';
 
     displayInfo(infoObj: CouncilInfoObj) {
@@ -52,9 +51,7 @@ export default class CouncilInfo extends Command {
     }
 
     async run() {
-        const api = await Api.create();
-        const infoObj = await api.getCouncilInfo();
+        const infoObj = await this.getApi().getCouncilInfo();
         this.displayInfo(infoObj);
-        this.exit();
     }
   }