Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds support for lazy commands #154

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@
],
"eslint.nodePath": ".yarn/sdks",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
}
}
7 changes: 7 additions & 0 deletions demos/yarn/DemoCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {Command} from "../../sources/advanced";

export class DemoCommand extends Command {
async execute() {
console.log(`Executing`, this.path);
}
}
12 changes: 12 additions & 0 deletions demos/yarn/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {Cli, runExit} from "../../sources/advanced";

const commands = Cli.lazyFileSystem({
cwd: `${__dirname}/commands`,
pattern: `{}.ts`,
});

runExit({
binaryLabel: `Fake Yarn`,
binaryName: `fake-yarn`,
binaryVersion: `0.0.0`,
}, commands);
5 changes: 5 additions & 0 deletions demos/yarn/commands/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../DemoCommand";

export class AddCommand extends DemoCommand {
static paths = [[`add`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../DemoCommand";

export class ConfigCommand extends DemoCommand {
static paths = [[`config`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/config/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../../DemoCommand";

export class ConfigGetCommand extends DemoCommand {
static paths = [[`config`, `get`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/config/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../../DemoCommand";

export class ConfigSetCommand extends DemoCommand {
static paths = [[`config`, `set`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../DemoCommand";

export class InstallCommand extends DemoCommand {
static paths = [[`install`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../DemoCommand";

export class RemoveCommand extends DemoCommand {
static paths = [[`remove`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../DemoCommand";

export class RunCommand extends DemoCommand {
static paths = [[`run`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/set/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../../DemoCommand";

export class SetVersionCommand extends DemoCommand {
static paths = [[`set`, `version`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/set/version/from/sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../../../../DemoCommand";

export class SetVersionFromSourcesCommand extends DemoCommand {
static paths = [[`set`, `version`, `from`, `sources`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/workspaces/focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../../DemoCommand";

export class WorkspacesFocusCommand extends DemoCommand {
static paths = [[`workspaces`, `focus`]];
}
5 changes: 5 additions & 0 deletions demos/yarn/commands/workspaces/foreach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DemoCommand} from "../../DemoCommand";

export class WorkspaceForeachCommand extends DemoCommand {
static paths = [[`workspaces`, `foreach`]];
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"postpack": "rm -rf lib",
"lint": "eslint --max-warnings 0 .",
"test": "jest",
"demo-yarn": "node --require ts-node/register demos/yarn/cli.ts",
"demo": "node --require ts-node/register demos/advanced.ts"
},
"publishConfig": {
Expand Down
95 changes: 84 additions & 11 deletions sources/advanced/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {HELP_COMMAND_INDEX} from '../constan
import {CliBuilder, CommandBuilder} from '../core';
import {ErrorMeta} from '../errors';
import {formatMarkdownish, ColorFormat, richFormat, textFormat} from '../format';
import {lazyTree, LazyTree} from '../lazy';
import * as platform from '../platform/node';

import {CommandClass, Command, Definition} from './Command';
Expand Down Expand Up @@ -75,11 +76,13 @@ export type RunContext<Context extends BaseContext = BaseContext> =
& UserContext<Context>;

export type RunCommand<Context extends BaseContext = BaseContext> =
| ((args: Array<string>) => RunCommand<Context>)
| Promise<CommandClass<Context> | Array<CommandClass<Context>>>
| Array<CommandClass<Context>>
| CommandClass<Context>;

export type RunCommandNoContext<Context extends BaseContext = BaseContext> =
UserContextKeys<Context> extends never
[UserContextKeys<Context>] extends [never]
? RunCommand<Context>
: never;

Expand Down Expand Up @@ -229,9 +232,10 @@ export async function runExit(...args: Array<any>) {
resolvedCommandClasses,
resolvedArgv,
resolvedContext,
} = resolveRunParameters(args);
} = await resolveRunParameters(args);

const cli = Cli.from(resolvedCommandClasses, resolvedOptions);

return cli.runExit(resolvedArgv, resolvedContext);
}

Expand Down Expand Up @@ -262,13 +266,13 @@ export async function run(...args: Array<any>) {
resolvedCommandClasses,
resolvedArgv,
resolvedContext,
} = resolveRunParameters(args);
} = await resolveRunParameters(args);

const cli = Cli.from(resolvedCommandClasses, resolvedOptions);
return cli.run(resolvedArgv, resolvedContext);
}

function resolveRunParameters(args: Array<any>) {
async function resolveRunParameters(args: Array<any>) {
let resolvedOptions: any;
let resolvedCommandClasses: any;
let resolvedArgv: any;
Expand All @@ -277,13 +281,16 @@ function resolveRunParameters(args: Array<any>) {
if (typeof process !== `undefined` && typeof process.argv !== `undefined`)
resolvedArgv = process.argv.slice(2);

const isCommandArg = (arg: any) =>
arg && (Command.isCommandClass(arg) || Array.isArray(arg) || typeof arg === `function` || `then` in arg);

switch (args.length) {
case 1: {
resolvedCommandClasses = args[0];
} break;

case 2: {
if (args[0] && (args[0].prototype instanceof Command) || Array.isArray(args[0])) {
if (isCommandArg(args[0])) {
resolvedCommandClasses = args[0];
if (Array.isArray(args[1])) {
resolvedArgv = args[1];
Expand All @@ -301,7 +308,7 @@ function resolveRunParameters(args: Array<any>) {
resolvedOptions = args[0];
resolvedCommandClasses = args[1];
resolvedArgv = args[2];
} else if (args[0] && (args[0].prototype instanceof Command) || Array.isArray(args[0])) {
} else if (isCommandArg(args[0])) {
resolvedCommandClasses = args[0];
resolvedArgv = args[1];
resolvedContext = args[2];
Expand All @@ -323,6 +330,11 @@ function resolveRunParameters(args: Array<any>) {
if (typeof resolvedArgv === `undefined`)
throw new Error(`The argv parameter must be provided when running Clipanion outside of a Node context`);

if (typeof resolvedCommandClasses === `function` && !Command.isCommandClass(resolvedCommandClasses))
resolvedCommandClasses = resolvedCommandClasses(resolvedArgv);

resolvedCommandClasses = await resolvedCommandClasses;

return {
resolvedOptions,
resolvedCommandClasses,
Expand Down Expand Up @@ -369,7 +381,7 @@ export class Cli<Context extends BaseContext = BaseContext> implements Omit<Mini
* @param commandClasses The Commands to register
* @returns The created `Cli` instance
*/
static from<Context extends BaseContext = BaseContext>(commandClasses: RunCommand<Context>, options: Partial<CliOptions> = {}) {
static from<Context extends BaseContext = BaseContext>(commandClasses: Exclude<RunCommand<Context>, Promise<any> | Function>, options: Partial<CliOptions> = {}) {
const cli = new Cli<Context>(options);

const resolvedCommandClasses = Array.isArray(commandClasses)
Expand All @@ -382,6 +394,69 @@ export class Cli<Context extends BaseContext = BaseContext> implements Omit<Mini
return cli;
}

/**
* Return a function that can lazily load commands from the filesystem.
*
* This function is intended to be used conjointly with `runExit`, to avoid
* loading ALL the commands from an application when you only need one.
*
* For this function to work, it's assumed that the provided directory
* structure has a 1:1 mapping to the command paths. For instance, if you
* have a command whose path is `foo bar`, it should be located at
* `<cwd>/foo/bar.ts`.
*
* Since `lazyFileSystem` relies on the filesystem, it won't work in runtimes
* that don't implement the Node.js `fs` module, or where the filesystem 1:1
* mapping doesn't apply (for example if you bundle your CLI). In those cases,
* you should use `lazyTree` instead.
*
* @param cwd The directory from which the commands will be loaded
* @returns A function that will load the commands when called
*/
static lazyFileSystem<Context extends BaseContext = BaseContext>(opts: {cwd: string, pattern: string, fallback?: () => Promise<Array<CommandClass<Context>>>}) {
return async (args: Array<string>) => {
const commands = await platform.lazyFileSystem(args, opts);

const flatCommands = commands.flatMap((val: unknown): Array<CommandClass<Context>> => {
if (Command.isCommandClass<Context>(val))
return [val];

if (typeof val === `object` && val !== null)
return Object.values(val).filter((val: unknown) => Command.isCommandClass(val));

return [];
});

if (flatCommands.length === 0 && typeof opts.fallback !== `undefined`)
return await opts.fallback();

return flatCommands;
};
}

/**
* Return a function that can lazily load commands from the provided tree.
*
* This function is intended to be used conjointly with `runExit`, to avoid
* loading ALL the commands from an application when you only need one.
*
* Unlike `lazyFileSystem`, this function doesn't rely on the filesystem and
* can be used in any runtime. It's also suitable for CLI distributed bundled.
*
* @param tree The tree from which the commands will be loaded
* @returns A function that will load the commands when called
*/
static lazyTree<TNode, Context extends BaseContext = BaseContext>(tree: LazyTree<TNode>, {fallback, mapper}: {fallback?: () => Promise<Array<CommandClass<Context>>>, mapper: (value: TNode) => Promise<Array<CommandClass<Context>>>}) {
return async (args: Array<string>) => {
const commands = await lazyTree(args, tree, mapper);

if (commands.length === 0 && typeof fallback !== `undefined`)
return await fallback();

return commands;
};
}

constructor({binaryLabel, binaryName: binaryNameOpt = `...`, binaryVersion, enableCapture = false, enableColors}: Partial<CliOptions> = {}) {
this.builder = new CliBuilder({binaryName: binaryNameOpt});

Expand Down Expand Up @@ -448,8 +523,7 @@ export class Cli<Context extends BaseContext = BaseContext> implements Omit<Mini
command.tokens = state.tokens;

return command;
} break;

}
default: {
const {commandClass} = contexts[state.selectedIndex!];

Expand All @@ -471,8 +545,7 @@ export class Cli<Context extends BaseContext = BaseContext> implements Omit<Mini
error[errorCommandSymbol] = command;
throw error;
}
} break;
}
} }
}

async run(input: Command<Context> | Array<string>, context: VoidIfEmpty<Omit<Context, keyof BaseContext>>): Promise<number>;
Expand Down
20 changes: 20 additions & 0 deletions sources/advanced/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,26 @@ export type CommandClass<Context extends BaseContext = BaseContext> = {
export abstract class Command<Context extends BaseContext = BaseContext> {
declare [`constructor`]: CommandClass<Context>;

/**
* Return true if the given parameter is a command class.
*/
static isCommandClass<Context extends BaseContext = BaseContext>(value: unknown): value is CommandClass<Context> {
return typeof value === `function` && typeof value.prototype === `object` && value.prototype instanceof Command;
}

/**
* Return all exported command definitions from a module exports object.
*/
static extractFromModuleExports<Context extends BaseContext>(exports: any): Array<CommandClass<Context>> {
if (Command.isCommandClass<Context>(exports))
return [exports];

if (typeof exports === `object` && exports !== null)
return Object.values(exports).filter((exportedValue: unknown): exportedValue is CommandClass<Context> => Command.isCommandClass(exportedValue));

return [];
}

/**
* @deprecated Do not use this; prefer the static `paths` property instead.
*/
Expand Down
1 change: 1 addition & 0 deletions sources/advanced/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {CommandClass, Usage, Definition} from './Command';
export {Token} from '../core';
export {UsageError, ErrorMeta, ErrorWithMeta} from '../errors';
export {formatMarkdownish, ColorFormat} from '../format';
export {LazyTree} from '../lazy';

export {run, runExit} from './Cli';

Expand Down
67 changes: 67 additions & 0 deletions sources/lazy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
function findIndexOrLength<T>(array: Array<T>, predicate: (value: T) => boolean) {
const index = array.findIndex(predicate);
return index !== -1 ? index : array.length;
}

export type LazyFactory<TContext, TRet> = (
segment: string,
ctx: TContext | undefined,
) => Promise<{
context: TContext | null;
node: TRet | null;
} | null>;

export async function lazyFactory<TContext, TRet>(args: Array<string>, factory: LazyFactory<TContext, TRet>) {
const production: Array<TRet> = [];
const firstOptionIndex = findIndexOrLength(args, arg => arg[0] === `-`);

function loadBranch(argIndex: number, context?: TContext) {
if (argIndex >= firstOptionIndex) {
return Promise.all([loadBranchImpl(argIndex, context), loadBranchImpl(argIndex + 1, context)]);
} else {
return loadBranchImpl(argIndex, context);
}
}

async function loadBranchImpl(argIndex: number, context?: TContext) {
if (argIndex >= args.length)
return;

const res = await factory(args[argIndex], context);

if (res === null)
return;

if (res.node !== null)
production.push(res.node);

if (res.context !== null) {
await loadBranch(argIndex + 1, res.context);
}
}

await loadBranch(0);

return production.flat();
}

export type LazyTree<T> = {
[key: string]: {
value?: T;
children?: LazyTree<T>;
};
};

export async function lazyTree<T, TMap>(args: Array<string>, tree: LazyTree<T>, map: (val: T) => Promise<Array<TMap>>): Promise<Array<TMap>> {
return lazyFactory(args, async (segment, ctx: LazyTree<T> = tree) => {
if (!Object.prototype.hasOwnProperty.call(ctx, segment))
return null;

const node = ctx[segment];

return {
context: node.children ?? null,
node: node.value != null ? await map(node.value) : null,
};
});
}
Loading
Loading