|
|
@@ -1,10 +1,10 @@ |
|
|
|
import yargs from 'yargs'; |
|
|
|
import yargs, {Argv} from 'yargs'; |
|
|
|
import * as clack from '@clack/prompts'; |
|
|
|
import {CommandHandler} from './common'; |
|
|
|
import * as tty from 'tty'; |
|
|
|
import { CommandHandler } from './common'; |
|
|
|
|
|
|
|
export interface CommandArgs { |
|
|
|
aliases: string[]; |
|
|
|
aliases?: string[]; |
|
|
|
command: string; |
|
|
|
parameters: string[]; |
|
|
|
describe: string; |
|
|
@@ -41,15 +41,18 @@ export interface TestModeResult { |
|
|
|
stderr: DummyWriteStream; |
|
|
|
} |
|
|
|
|
|
|
|
interface InteractiveModeOptions { |
|
|
|
option: string; |
|
|
|
alias: string; |
|
|
|
intro: string; |
|
|
|
cancelled: string; |
|
|
|
outro: string; |
|
|
|
describe: string; |
|
|
|
} |
|
|
|
|
|
|
|
export interface CliOptions { |
|
|
|
interactiveMode?: { |
|
|
|
option?: string; |
|
|
|
alias?: string; |
|
|
|
intro?: string; |
|
|
|
cancelled?: string; |
|
|
|
outro?: string; |
|
|
|
describe?: string; |
|
|
|
} |
|
|
|
name: string; |
|
|
|
interactiveMode?: Partial<InteractiveModeOptions>; |
|
|
|
} |
|
|
|
|
|
|
|
export class Cli { |
|
|
@@ -63,122 +66,172 @@ export class Cli { |
|
|
|
stderr: new DummyWriteStream(), |
|
|
|
}; |
|
|
|
|
|
|
|
private readonly cli = yargs() |
|
|
|
.help() |
|
|
|
.fail(false) |
|
|
|
.strict(false); |
|
|
|
private readonly cli: Argv; |
|
|
|
|
|
|
|
constructor(private readonly options: CliOptions) { |
|
|
|
// noop |
|
|
|
this.cli = yargs() |
|
|
|
.scriptName(options.name) |
|
|
|
.help() |
|
|
|
.fail(false) |
|
|
|
.strict(false); |
|
|
|
} |
|
|
|
|
|
|
|
private exit(code: number) { |
|
|
|
if (this.testMode) { |
|
|
|
this.testModeResult.exitCode = code; |
|
|
|
return; |
|
|
|
} |
|
|
|
process.exit(code); |
|
|
|
} |
|
|
|
|
|
|
|
private async prompt(messages: Record<string, string>, interactiveModeOptions: InteractiveModeOptions) { |
|
|
|
const { |
|
|
|
intro, |
|
|
|
cancelled, |
|
|
|
outro, |
|
|
|
} = interactiveModeOptions; |
|
|
|
const messagesEntries = Object.entries(messages); |
|
|
|
|
|
|
|
if (messagesEntries.length > 0) { |
|
|
|
clack.intro(intro); |
|
|
|
const values = await clack.group( |
|
|
|
Object.fromEntries( |
|
|
|
messagesEntries.map(([key, value]) => [key, () => clack.text({ message: value })]) |
|
|
|
), |
|
|
|
{ |
|
|
|
onCancel: () => { |
|
|
|
clack.cancel(cancelled); |
|
|
|
this.exit(1); |
|
|
|
} |
|
|
|
} |
|
|
|
); |
|
|
|
clack.outro(outro); |
|
|
|
return values; |
|
|
|
} |
|
|
|
|
|
|
|
return {}; |
|
|
|
} |
|
|
|
|
|
|
|
private async generateCommandArgs( |
|
|
|
context: any, |
|
|
|
originalCommandArgs: Record<string, unknown>, |
|
|
|
interactive: boolean, |
|
|
|
interactiveModeOptions: InteractiveModeOptions, |
|
|
|
) { |
|
|
|
if (!interactive) { |
|
|
|
return originalCommandArgs; |
|
|
|
} |
|
|
|
|
|
|
|
// TODO properly filter attributes |
|
|
|
const clackGroup = context.optional |
|
|
|
.filter((optional: { cmd: string[] }) => { |
|
|
|
const { |
|
|
|
'$0': _$0, |
|
|
|
i: _i, |
|
|
|
interactive: _interactive, |
|
|
|
test: _test, |
|
|
|
_: __, |
|
|
|
...rest |
|
|
|
} = originalCommandArgs; |
|
|
|
|
|
|
|
return !Object.keys(rest).includes(optional.cmd[0]); |
|
|
|
}) |
|
|
|
.reduce( |
|
|
|
(group: Record<string, string>, optional: { cmd: string[], describe: string }) => ({ |
|
|
|
...group, |
|
|
|
[optional.cmd[0]]: optional.describe ?? optional.cmd[0], |
|
|
|
}), |
|
|
|
{} as Record<string, string>, |
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const promptedArgs = this.testMode |
|
|
|
? this.promptValues |
|
|
|
: await this.prompt(clackGroup, interactiveModeOptions); |
|
|
|
|
|
|
|
return { |
|
|
|
...originalCommandArgs, |
|
|
|
...promptedArgs, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
private generateLogger() { |
|
|
|
return { |
|
|
|
log: (message: unknown) => { |
|
|
|
const stdout = this.testMode ? this.testModeResult.stdout : process.stdout; |
|
|
|
stdout.write(`${message?.toString()}\n`); |
|
|
|
}, |
|
|
|
warn: (message: unknown) => { |
|
|
|
const stdout = this.testMode ? this.testModeResult.stdout : process.stdout; |
|
|
|
stdout.write(`WARN: ${message?.toString()}\n`); |
|
|
|
}, |
|
|
|
debug: (message: unknown) => { |
|
|
|
const stdout = this.testMode ? this.testModeResult.stdout : process.stdout; |
|
|
|
stdout.write(`DEBUG: ${message?.toString()}\n`); |
|
|
|
}, |
|
|
|
error: (message: unknown) => { |
|
|
|
const stderr = this.testMode ? this.testModeResult.stderr : process.stderr; |
|
|
|
stderr.write(`${message?.toString()}\n`); |
|
|
|
}, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
command({ parameters, command, interactiveMode = true, ...args }: CommandArgs): Cli { |
|
|
|
private buildHandler(handlerFn: Function, interactiveModeOptions: InteractiveModeOptions) { |
|
|
|
const thisCli = this; |
|
|
|
const { interactiveMode: theInteractiveMode = {} } = this.options; |
|
|
|
return async function handler(this: any, commandArgs: Record<string, unknown>) { |
|
|
|
const self = this; |
|
|
|
const { option, alias } = interactiveModeOptions; |
|
|
|
const interactiveLong = option in commandArgs ? Boolean(commandArgs.interactive) : false; |
|
|
|
const interactiveShort = alias in commandArgs ? Boolean(commandArgs.i) : false; |
|
|
|
const interactive = interactiveLong || interactiveShort; |
|
|
|
const buildArgs = await thisCli.generateCommandArgs( |
|
|
|
self, |
|
|
|
commandArgs, |
|
|
|
interactive, |
|
|
|
interactiveModeOptions, |
|
|
|
); |
|
|
|
|
|
|
|
let exited = false; |
|
|
|
const returnCode = await handlerFn({ |
|
|
|
self, |
|
|
|
interactive, |
|
|
|
logger: thisCli.generateLogger(), |
|
|
|
send: (code: number) => { |
|
|
|
exited = true; |
|
|
|
thisCli.exit(code); |
|
|
|
}, |
|
|
|
args: buildArgs, |
|
|
|
}); |
|
|
|
|
|
|
|
if (!exited) { |
|
|
|
thisCli.exit(returnCode ? returnCode : 0); |
|
|
|
} |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
command({ parameters, command, interactiveMode = true, handler, ...args }: CommandArgs): Cli { |
|
|
|
const { |
|
|
|
option = 'interactive', |
|
|
|
alias = 'i', |
|
|
|
describe = 'Interactive mode', |
|
|
|
intro = 'Please provide the following values:', |
|
|
|
cancelled = 'Operation cancelled.', |
|
|
|
outro = 'Thank you!', |
|
|
|
describe = 'Interactive mode', |
|
|
|
} = theInteractiveMode; |
|
|
|
} = this.options?.interactiveMode ?? {}; |
|
|
|
|
|
|
|
const commandArgs = { |
|
|
|
...args, |
|
|
|
command: `${command} ${parameters.join(' ').replace(/</g, '[').replace(/>/g, ']')}`, |
|
|
|
usage: parameters.map((p) => `${command} ${p}`).join('\n'), |
|
|
|
handler: async function handler(commandArgs: Record<string, unknown>) { |
|
|
|
const self = this as any; |
|
|
|
|
|
|
|
const interactiveLong = option in commandArgs ? Boolean(commandArgs.interactive) : false; |
|
|
|
const interactiveShort = alias in commandArgs ? Boolean(commandArgs.i) : false; |
|
|
|
const interactive = interactiveLong || interactiveShort; |
|
|
|
let buildArgs = { |
|
|
|
...commandArgs, |
|
|
|
}; |
|
|
|
|
|
|
|
const exit = (code: number) => { |
|
|
|
if (thisCli.testMode) { |
|
|
|
thisCli.testModeResult.exitCode = code; |
|
|
|
return; |
|
|
|
} |
|
|
|
process.exit(code); |
|
|
|
} |
|
|
|
|
|
|
|
if (interactive) { |
|
|
|
// TODO properly filter attributes |
|
|
|
const clackGroup = self.optional |
|
|
|
.filter((optional: { cmd: string[] }) => { |
|
|
|
const { |
|
|
|
'$0': _$0, |
|
|
|
i: _i, |
|
|
|
interactive: _interactive, |
|
|
|
test: _test, |
|
|
|
_: __, |
|
|
|
...rest |
|
|
|
} = buildArgs; |
|
|
|
|
|
|
|
return !Object.keys(rest).includes(optional.cmd[0]); |
|
|
|
}) |
|
|
|
.reduce( |
|
|
|
(group: Record<string, string>, optional: { cmd: string[], describe: string }) => ({ |
|
|
|
...group, |
|
|
|
[optional.cmd[0]]: optional.describe ?? optional.cmd[0], |
|
|
|
}), |
|
|
|
{} as Record<string, string>, |
|
|
|
); |
|
|
|
|
|
|
|
const prompt = async (messages: Record<string, string>) => { |
|
|
|
const messagesEntries = Object.entries(messages); |
|
|
|
|
|
|
|
if (messagesEntries.length > 0) { |
|
|
|
clack.intro(intro); |
|
|
|
const values = await clack.group( |
|
|
|
Object.fromEntries( |
|
|
|
messagesEntries.map(([key, value]) => [key, () => clack.text({message: value})]) |
|
|
|
), |
|
|
|
{ |
|
|
|
onCancel: () => { |
|
|
|
clack.cancel(cancelled); |
|
|
|
exit(1); |
|
|
|
} |
|
|
|
} |
|
|
|
); |
|
|
|
clack.outro(outro); |
|
|
|
return values; |
|
|
|
} |
|
|
|
|
|
|
|
return {}; |
|
|
|
}; |
|
|
|
|
|
|
|
const promptedArgs = thisCli.testMode ? thisCli.promptValues : await prompt(clackGroup); |
|
|
|
buildArgs = { |
|
|
|
...buildArgs, |
|
|
|
...promptedArgs, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
const returnCode = await args.handler({ |
|
|
|
self, |
|
|
|
interactive, |
|
|
|
logger: { |
|
|
|
log: (message: unknown) => { |
|
|
|
const stdout = thisCli.testMode ? thisCli.testModeResult.stdout : process.stdout; |
|
|
|
stdout.write(`${message?.toString()}\n`); |
|
|
|
}, |
|
|
|
error: (message: unknown) => { |
|
|
|
const stderr = thisCli.testMode ? thisCli.testModeResult.stderr : process.stderr; |
|
|
|
stderr.write(`${message?.toString()}\n`); |
|
|
|
}, |
|
|
|
}, |
|
|
|
send: exit, |
|
|
|
args: buildArgs, |
|
|
|
}); |
|
|
|
|
|
|
|
exit(returnCode ? returnCode : 0); |
|
|
|
}, |
|
|
|
} as any; |
|
|
|
handler: this.buildHandler(handler, { |
|
|
|
option, |
|
|
|
alias, |
|
|
|
intro, |
|
|
|
cancelled, |
|
|
|
outro, |
|
|
|
describe, |
|
|
|
}), |
|
|
|
} as Record<string, unknown> |
|
|
|
|
|
|
|
if (interactiveMode) { |
|
|
|
commandArgs.options = { |
|
|
@@ -193,48 +246,52 @@ export class Cli { |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
this.cli.command(commandArgs); |
|
|
|
this.cli.command(commandArgs as unknown as Parameters<typeof this.cli.command>[0]); |
|
|
|
return this; |
|
|
|
} |
|
|
|
|
|
|
|
async run(args: string[]): Promise<void> { |
|
|
|
await this.cli.parse(args); |
|
|
|
if (args.length > 0) { |
|
|
|
await this.cli.parse(args); |
|
|
|
return; |
|
|
|
} |
|
|
|
this.cli.showHelp(); |
|
|
|
} |
|
|
|
|
|
|
|
test() { |
|
|
|
this.testMode = true; |
|
|
|
this.promptValues = {}; |
|
|
|
const testDelegate = { |
|
|
|
promptValue: (...args: PromptValueArgs) => { |
|
|
|
const thisCli = this; |
|
|
|
return { |
|
|
|
promptValue(...args: PromptValueArgs) { |
|
|
|
const [testArg] = args; |
|
|
|
|
|
|
|
if (typeof testArg === 'string') { |
|
|
|
const [key, value] = args as [string, unknown]; |
|
|
|
this.promptValues[key] = this.promptValues[key] ?? value; |
|
|
|
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; |
|
|
|
} |
|
|
|
|
|
|
|
if (Array.isArray(testArg)) { |
|
|
|
const [key, value] = testArg as [string, unknown]; |
|
|
|
this.promptValues[key] = this.promptValues[key] ?? value; |
|
|
|
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; |
|
|
|
} |
|
|
|
|
|
|
|
if (typeof testArg === 'object' && testArg !== null) { |
|
|
|
Object.entries(testArg).forEach(([key, value]) => { |
|
|
|
this.promptValues[key] = this.promptValues[key] ?? value; |
|
|
|
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
return testDelegate; |
|
|
|
return this; |
|
|
|
}, |
|
|
|
run: async (args: string[]): Promise<TestModeResult> => { |
|
|
|
await this.cli.parse(args); |
|
|
|
return this.testModeResult; |
|
|
|
async run(args: string[]): Promise<TestModeResult> { |
|
|
|
await thisCli.cli.parse(args); |
|
|
|
return thisCli.testModeResult; |
|
|
|
}, |
|
|
|
}; |
|
|
|
return testDelegate; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
export const createCli = (options = {} as CliOptions) => { |
|
|
|
export const createCli = (options: CliOptions) => { |
|
|
|
return new Cli(options); |
|
|
|
}; |