Make the setup consistent between CLI and API.master
@@ -1,2 +1,3 @@ | |||
node_modules | |||
.DS_Store | |||
.idea/ |
@@ -1,8 +0,0 @@ | |||
# Default ignored files | |||
/shelf/ | |||
/workspace.xml | |||
# Editor-based HTTP Client requests | |||
/httpRequests/ | |||
# Datasource local storage ignored files | |||
/dataSources/ | |||
/dataSources.local.xml |
@@ -1,13 +0,0 @@ | |||
<?xml version="1.0" encoding="UTF-8"?> | |||
<module type="WEB_MODULE" version="4"> | |||
<component name="NewModuleRootManager"> | |||
<content url="file://$MODULE_DIR$"> | |||
<excludeFolder url="file://$MODULE_DIR$/.tmp" /> | |||
<excludeFolder url="file://$MODULE_DIR$/packages/core/dist" /> | |||
<excludeFolder url="file://$MODULE_DIR$/temp" /> | |||
<excludeFolder url="file://$MODULE_DIR$/tmp" /> | |||
</content> | |||
<orderEntry type="inheritedJdk" /> | |||
<orderEntry type="sourceFolder" forTests="false" /> | |||
</component> | |||
</module> |
@@ -1,8 +0,0 @@ | |||
<?xml version="1.0" encoding="UTF-8"?> | |||
<project version="4"> | |||
<component name="ProjectModuleManager"> | |||
<modules> | |||
<module fileurl="file://$PROJECT_DIR$/.idea/backend-template.iml" filepath="$PROJECT_DIR$/.idea/backend-template.iml" /> | |||
</modules> | |||
</component> | |||
</project> |
@@ -1,6 +0,0 @@ | |||
<?xml version="1.0" encoding="UTF-8"?> | |||
<project version="4"> | |||
<component name="VcsDirectoryMappings"> | |||
<mapping directory="$PROJECT_DIR$" vcs="Git" /> | |||
</component> | |||
</project> |
@@ -1,297 +1,9 @@ | |||
import yargs, {Argv} from 'yargs'; | |||
import * as clack from '@clack/prompts'; | |||
import * as tty from 'tty'; | |||
import { CommandHandler } from './common'; | |||
import { Cli, CliOptions } from './packages/cli-wrapper'; | |||
export interface CommandArgs { | |||
aliases?: string[]; | |||
command: string; | |||
parameters: string[]; | |||
describe: string; | |||
handler: CommandHandler; | |||
interactiveMode?: boolean; | |||
} | |||
type PromptValueArgs = [Record<string, string>, ...never[]] | |||
| [string, string] | |||
| [[string, string], ...never[]] | |||
| [[string, string][], ...never[]]; | |||
class DummyWriteStream extends tty.WriteStream { | |||
private bufferInternal = Buffer.from(''); | |||
constructor() { | |||
super(0); | |||
} | |||
write(input: Uint8Array | string, _encoding?: BufferEncoding | ((err?: Error) => void), _cb?: (err?: Error) => void): boolean { | |||
this.bufferInternal = Buffer.concat([this.bufferInternal, Buffer.from(input)]); | |||
// noop | |||
return true; | |||
} | |||
get buffer(): Buffer { | |||
return this.bufferInternal; | |||
} | |||
} | |||
export interface TestModeResult { | |||
exitCode?: number; | |||
stdout: DummyWriteStream; | |||
stderr: DummyWriteStream; | |||
} | |||
interface InteractiveModeOptions { | |||
option: string; | |||
alias: string; | |||
intro: string; | |||
cancelled: string; | |||
outro: string; | |||
describe: string; | |||
} | |||
export interface CliOptions { | |||
name: string; | |||
interactiveMode?: Partial<InteractiveModeOptions>; | |||
} | |||
export class Cli { | |||
private testMode = false; | |||
private promptValues = {} as Record<string, unknown>; | |||
private testModeResult: TestModeResult = { | |||
exitCode: undefined as number | undefined, | |||
stdout: new DummyWriteStream(), | |||
stderr: new DummyWriteStream(), | |||
}; | |||
private readonly cli: Argv; | |||
constructor(private readonly options: CliOptions) { | |||
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`); | |||
}, | |||
}; | |||
} | |||
private buildHandler(handlerFn: Function, interactiveModeOptions: InteractiveModeOptions) { | |||
const thisCli = this; | |||
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!', | |||
} = this.options?.interactiveMode ?? {}; | |||
const commandArgs = { | |||
...args, | |||
command: `${command} ${parameters.join(' ').replace(/</g, '[').replace(/>/g, ']')}`, | |||
usage: parameters.map((p) => `${command} ${p}`).join('\n'), | |||
handler: this.buildHandler(handler, { | |||
option, | |||
alias, | |||
intro, | |||
cancelled, | |||
outro, | |||
describe, | |||
}), | |||
} as Record<string, unknown> | |||
if (interactiveMode) { | |||
commandArgs.options = { | |||
...(commandArgs.options ?? {}), | |||
[option]: { | |||
alias, | |||
describe, | |||
type: 'boolean', | |||
default: false, | |||
hidden: true, | |||
}, | |||
}; | |||
} | |||
this.cli.command(commandArgs as unknown as Parameters<typeof this.cli.command>[0]); | |||
return this; | |||
} | |||
async run(args: string[]): Promise<void> { | |||
if (args.length > 0) { | |||
await this.cli.parse(args); | |||
return; | |||
} | |||
this.cli.showHelp(); | |||
} | |||
test() { | |||
this.testMode = true; | |||
this.promptValues = {}; | |||
const thisCli = this; | |||
return { | |||
promptValue(...args: PromptValueArgs) { | |||
const [testArg] = args; | |||
if (typeof testArg === 'string') { | |||
const [key, value] = args as [string, unknown]; | |||
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; | |||
} | |||
if (Array.isArray(testArg)) { | |||
const [key, value] = testArg as [string, unknown]; | |||
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; | |||
} | |||
if (typeof testArg === 'object' && testArg !== null) { | |||
Object.entries(testArg).forEach(([key, value]) => { | |||
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; | |||
}); | |||
} | |||
export const createCli = (options: CliOptions) => { | |||
const cli = new Cli(options); | |||
return this; | |||
}, | |||
async run(args: string[]): Promise<TestModeResult> { | |||
await thisCli.cli.parse(args); | |||
return thisCli.testModeResult; | |||
}, | |||
}; | |||
} | |||
} | |||
// TODO setup CLI here | |||
export const createCli = (options: CliOptions) => { | |||
return new Cli(options); | |||
return cli; | |||
}; |
@@ -1,10 +1,10 @@ | |||
import { Cli } from './cli'; | |||
import { Cli } from './packages/cli-wrapper'; | |||
import { AdderController, AdderControllerImpl } from './modules/adder'; | |||
export const addCommands = (cli: Cli) => { | |||
const adderController: AdderController = new AdderControllerImpl(); | |||
cli | |||
return cli | |||
.command({ | |||
aliases: ['a'], | |||
command: 'add', | |||
@@ -19,6 +19,4 @@ export const addCommands = (cli: Cli) => { | |||
describe: 'Subtract two numbers', | |||
handler: adderController.subtractNumbers, | |||
}); | |||
return cli; | |||
}; |
@@ -1,16 +0,0 @@ | |||
export interface Logger { | |||
log: (message: unknown) => void; | |||
error: (message: unknown) => void; | |||
warn: (message: unknown) => void; | |||
debug: (message: unknown) => void; | |||
} | |||
export interface CommandHandlerArgs { | |||
self: any; | |||
interactive: boolean; | |||
send: (message: number) => void; | |||
logger: Logger; | |||
args: any; | |||
} | |||
export type CommandHandler = (args: CommandHandlerArgs) => void | number | Promise<number> | Promise<void>; |
@@ -4,7 +4,7 @@ import { | |||
ArgumentOutOfRangeError, | |||
InvalidArgumentTypeError, | |||
} from './adder.service'; | |||
import {CommandHandler} from '@/common'; | |||
import { CommandHandler } from '../../packages/cli-wrapper'; | |||
export interface AdderController { | |||
addNumbers: CommandHandler; | |||
@@ -0,0 +1,309 @@ | |||
import tty from 'tty'; | |||
import yargs, {Argv} from 'yargs'; | |||
import * as clack from '@clack/prompts'; | |||
export interface Logger { | |||
log: (message: unknown) => void; | |||
error: (message: unknown) => void; | |||
warn: (message: unknown) => void; | |||
debug: (message: unknown) => void; | |||
} | |||
export interface CommandHandlerArgs { | |||
self: any; | |||
interactive: boolean; | |||
send: (message: number) => void; | |||
logger: Logger; | |||
args: any; | |||
} | |||
export type CommandHandler = (args: CommandHandlerArgs) => void | number | Promise<number> | Promise<void>; | |||
export interface CommandArgs { | |||
aliases?: string[]; | |||
command: string; | |||
parameters: string[]; | |||
describe: string; | |||
handler: CommandHandler; | |||
interactiveMode?: boolean; | |||
} | |||
type PromptValueArgs = [Record<string, string>, ...never[]] | |||
| [string, string] | |||
| [[string, string], ...never[]] | |||
| [[string, string][], ...never[]]; | |||
class DummyWriteStream extends tty.WriteStream { | |||
private bufferInternal = Buffer.from(''); | |||
constructor() { | |||
super(0); | |||
} | |||
write(input: Uint8Array | string, _encoding?: BufferEncoding | ((err?: Error) => void), _cb?: (err?: Error) => void): boolean { | |||
this.bufferInternal = Buffer.concat([this.bufferInternal, Buffer.from(input)]); | |||
// noop | |||
return true; | |||
} | |||
get buffer(): Buffer { | |||
return this.bufferInternal; | |||
} | |||
} | |||
export interface TestModeResult { | |||
exitCode?: number; | |||
stdout: DummyWriteStream; | |||
stderr: DummyWriteStream; | |||
} | |||
interface InteractiveModeOptions { | |||
option: string; | |||
alias: string; | |||
intro: string; | |||
cancelled: string; | |||
outro: string; | |||
describe: string; | |||
} | |||
export interface CliOptions { | |||
name: string; | |||
interactiveMode?: Partial<InteractiveModeOptions>; | |||
} | |||
export class Cli { | |||
private testMode = false; | |||
private promptValues = {} as Record<string, unknown>; | |||
private testModeResult: TestModeResult = { | |||
exitCode: undefined as number | undefined, | |||
stdout: new DummyWriteStream(), | |||
stderr: new DummyWriteStream(), | |||
}; | |||
private readonly cli: Argv; | |||
constructor(private readonly options: CliOptions) { | |||
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`); | |||
}, | |||
}; | |||
} | |||
private buildHandler(handlerFn: Function, interactiveModeOptions: InteractiveModeOptions) { | |||
const thisCli = this; | |||
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!', | |||
} = this.options?.interactiveMode ?? {}; | |||
const commandArgs = { | |||
...args, | |||
command: `${command} ${parameters.join(' ').replace(/</g, '[').replace(/>/g, ']')}`, | |||
usage: parameters.map((p) => `${command} ${p}`).join('\n'), | |||
handler: this.buildHandler(handler, { | |||
option, | |||
alias, | |||
intro, | |||
cancelled, | |||
outro, | |||
describe, | |||
}), | |||
} as Record<string, unknown> | |||
if (interactiveMode) { | |||
commandArgs.options = { | |||
...(commandArgs.options ?? {}), | |||
[option]: { | |||
alias, | |||
describe, | |||
type: 'boolean', | |||
default: false, | |||
hidden: true, | |||
}, | |||
}; | |||
} | |||
this.cli.command(commandArgs as unknown as Parameters<typeof this.cli.command>[0]); | |||
return this; | |||
} | |||
async run(args: string[]): Promise<void> { | |||
if (args.length > 0) { | |||
await this.cli.parse(args); | |||
return; | |||
} | |||
this.cli.showHelp(); | |||
} | |||
test() { | |||
this.testMode = true; | |||
this.promptValues = {}; | |||
const thisCli = this; | |||
return { | |||
promptValue(...args: PromptValueArgs) { | |||
const [testArg] = args; | |||
if (typeof testArg === 'string') { | |||
const [key, value] = args as [string, unknown]; | |||
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; | |||
} | |||
if (Array.isArray(testArg)) { | |||
const [key, value] = testArg as [string, unknown]; | |||
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; | |||
} | |||
if (typeof testArg === 'object' && testArg !== null) { | |||
Object.entries(testArg).forEach(([key, value]) => { | |||
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; | |||
}); | |||
} | |||
return this; | |||
}, | |||
async run(args: string[]): Promise<TestModeResult> { | |||
await thisCli.cli.parse(args); | |||
return thisCli.testModeResult; | |||
}, | |||
}; | |||
} | |||
} |
@@ -4,11 +4,10 @@ import { AdderController, AdderControllerImpl } from '@/modules/adder'; | |||
export const addRoutes = (server: FastifyInstance) => { | |||
const adderController: AdderController = new AdderControllerImpl(); | |||
server.route({ | |||
method: 'POST', | |||
url: '/', | |||
handler: adderController.addNumbers, | |||
}); | |||
return server; | |||
return server | |||
.route({ | |||
method: 'POST', | |||
url: '/', | |||
handler: adderController.addNumbers, | |||
}); | |||
}; |