From 4483be093c7604b19e63a01a7d0b1c06331027e3 Mon Sep 17 00:00:00 2001 From: sorrycc Date: Wed, 25 Dec 2024 13:50:22 +0800 Subject: [PATCH] feat: improve create-tnf's style --- create-tnf/.fatherrc.ts | 2 +- create-tnf/bin/create-tnf.js | 2 +- create-tnf/package.json | 8 +- create-tnf/src/clack/README.md | 3 + create-tnf/src/clack/core/index.ts | 10 + .../src/clack/core/src/prompts/confirm.ts | 37 + .../core/src/prompts/group-multiselect.ts | 96 ++ .../clack/core/src/prompts/multi-select.ts | 65 ++ .../src/clack/core/src/prompts/password.ts | 33 + .../src/clack/core/src/prompts/prompt.ts | 279 +++++ .../src/clack/core/src/prompts/select-key.ts | 32 + .../src/clack/core/src/prompts/select.ts | 46 + create-tnf/src/clack/core/src/prompts/text.ts | 33 + create-tnf/src/clack/core/src/utils.ts | 59 ++ create-tnf/src/clack/prompt/index.ts | 949 ++++++++++++++++++ create-tnf/src/clack/test.ts | 22 + create-tnf/src/cli.ts | 22 +- create-tnf/src/create.ts | 117 ++- package.json | 2 +- pnpm-lock.yaml | 55 +- src/fishkit/npm.ts | 3 - 21 files changed, 1805 insertions(+), 70 deletions(-) create mode 100644 create-tnf/src/clack/README.md create mode 100644 create-tnf/src/clack/core/index.ts create mode 100644 create-tnf/src/clack/core/src/prompts/confirm.ts create mode 100644 create-tnf/src/clack/core/src/prompts/group-multiselect.ts create mode 100644 create-tnf/src/clack/core/src/prompts/multi-select.ts create mode 100644 create-tnf/src/clack/core/src/prompts/password.ts create mode 100644 create-tnf/src/clack/core/src/prompts/prompt.ts create mode 100644 create-tnf/src/clack/core/src/prompts/select-key.ts create mode 100644 create-tnf/src/clack/core/src/prompts/select.ts create mode 100644 create-tnf/src/clack/core/src/prompts/text.ts create mode 100644 create-tnf/src/clack/core/src/utils.ts create mode 100644 create-tnf/src/clack/prompt/index.ts create mode 100644 create-tnf/src/clack/test.ts diff --git a/create-tnf/.fatherrc.ts b/create-tnf/.fatherrc.ts index c24d9b4..bceab60 100644 --- a/create-tnf/.fatherrc.ts +++ b/create-tnf/.fatherrc.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'father'; export default defineConfig({ - cjs: { + esm: { input: 'src', output: 'dist', }, diff --git a/create-tnf/bin/create-tnf.js b/create-tnf/bin/create-tnf.js index 4f46430..9372a88 100755 --- a/create-tnf/bin/create-tnf.js +++ b/create-tnf/bin/create-tnf.js @@ -1,3 +1,3 @@ #!/usr/bin/env node -require('../dist/cli'); +import('../dist/cli.js'); diff --git a/create-tnf/package.json b/create-tnf/package.json index ba4ca09..136bab3 100644 --- a/create-tnf/package.json +++ b/create-tnf/package.json @@ -7,6 +7,7 @@ "type": "git", "url": "https://github.com/umijs/tnf" }, + "type": "module", "bin": { "create-tnf": "bin/create-tnf.js" }, @@ -22,9 +23,12 @@ "templates" ], "dependencies": { - "@clack/prompts": "^0.7.0", + "@clack/prompts": "^0.9.0", + "yargs-parser": "^21.1.1", + "is-unicode-supported": "^1.3.0", "picocolors": "^1.1.1", - "yargs-parser": "^21.1.1" + "sisteransi": "^1.0.5", + "wrap-ansi": "^8.1.0" }, "devDependencies": { "@types/yargs-parser": "^21.0.3", diff --git a/create-tnf/src/clack/README.md b/create-tnf/src/clack/README.md new file mode 100644 index 0000000..7e7c995 --- /dev/null +++ b/create-tnf/src/clack/README.md @@ -0,0 +1,3 @@ +# @clack/core and @clack/prompt + +Forked from https://github.com/sveltejs/cli . diff --git a/create-tnf/src/clack/core/index.ts b/create-tnf/src/clack/core/index.ts new file mode 100644 index 0000000..3c4faf7 --- /dev/null +++ b/create-tnf/src/clack/core/index.ts @@ -0,0 +1,10 @@ +export { default as ConfirmPrompt } from './src/prompts/confirm.js'; +export { default as GroupMultiSelectPrompt } from './src/prompts/group-multiselect.js'; +export { default as MultiSelectPrompt } from './src/prompts/multi-select.js'; +export { default as PasswordPrompt } from './src/prompts/password.js'; +export { default as Prompt, isCancel } from './src/prompts/prompt.js'; +export type { State } from './src/prompts/prompt.js'; +export { default as SelectPrompt } from './src/prompts/select.js'; +export { default as SelectKeyPrompt } from './src/prompts/select-key.js'; +export { default as TextPrompt } from './src/prompts/text.js'; +export { block } from './src/utils.js'; diff --git a/create-tnf/src/clack/core/src/prompts/confirm.ts b/create-tnf/src/clack/core/src/prompts/confirm.ts new file mode 100644 index 0000000..e1f4ce9 --- /dev/null +++ b/create-tnf/src/clack/core/src/prompts/confirm.ts @@ -0,0 +1,37 @@ +import { cursor } from 'sisteransi'; +import Prompt, { type PromptOptions } from './prompt.js'; + +interface ConfirmOptions extends PromptOptions { + active: string; + inactive: string; + initialValue?: boolean; +} +export default class ConfirmPrompt extends Prompt { + get cursor(): 0 | 1 { + return this.value ? 0 : 1; + } + + private get _value() { + return this.cursor === 0; + } + + constructor(opts: ConfirmOptions) { + super(opts, false); + this.value = opts.initialValue ? true : false; + + this.on('value', () => { + this.value = this._value; + }); + + this.on('confirm', (confirm) => { + this.output.write(cursor.move(0, -1)); + this.value = confirm; + this.state = 'submit'; + this.close(); + }); + + this.on('cursor', () => { + this.value = !this.value; + }); + } +} diff --git a/create-tnf/src/clack/core/src/prompts/group-multiselect.ts b/create-tnf/src/clack/core/src/prompts/group-multiselect.ts new file mode 100644 index 0000000..cf49451 --- /dev/null +++ b/create-tnf/src/clack/core/src/prompts/group-multiselect.ts @@ -0,0 +1,96 @@ +import Prompt, { type PromptOptions } from './prompt.js'; + +interface GroupMultiSelectOptions + extends PromptOptions> { + options: Record; + initialValues?: Array; + required?: boolean; + cursorAt?: T['value']; + selectableGroups?: boolean; +} +export default class GroupMultiSelectPrompt< + T extends { value: any }, +> extends Prompt { + options: Array; + cursor: number = 0; + #selectableGroups: boolean; + + getGroupItems(group: string): T[] { + return this.options.filter((o) => o.group === group); + } + + isGroupSelected(group: string): boolean { + const items = this.getGroupItems(group); + return ( + this.#selectableGroups && items.every((i) => this.value.includes(i.value)) + ); + } + + private toggleValue() { + const item = this.options[this.cursor]!; + if (item.group === true) { + const group = item.value; + const groupedItems = this.getGroupItems(group); + if (this.isGroupSelected(group)) { + this.value = this.value.filter( + (v: string) => groupedItems.findIndex((i) => i.value === v) === -1, + ); + } else { + this.value = [...this.value, ...groupedItems.map((i) => i.value)]; + } + this.value = Array.from(new Set(this.value)); + } else { + const selected = this.value.includes(item.value); + this.value = selected + ? this.value.filter((v: T['value']) => v !== item.value) + : [...this.value, item.value]; + } + } + + constructor(opts: GroupMultiSelectOptions) { + super(opts, false); + const { options } = opts; + this.#selectableGroups = opts.selectableGroups ?? true; + this.options = Object.entries(options).flatMap(([key, option]) => [ + { value: key, group: true, label: key }, + ...option.map((opt) => ({ ...opt, group: key })), + ]) as any; + this.value = [...(opts.initialValues ?? [])]; + this.cursor = Math.max( + this.options.findIndex(({ value }) => value === opts.cursorAt), + this.#selectableGroups ? 0 : 1, + ); + + this.on('cursor', (key) => { + switch (key) { + case 'left': + case 'up': + this.cursor = + this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + if ( + !this.#selectableGroups && + this.options[this.cursor]!.group === true + ) { + this.cursor = + this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + } + break; + case 'down': + case 'right': + this.cursor = + this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + if ( + !this.#selectableGroups && + this.options[this.cursor]!.group === true + ) { + this.cursor = + this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + } + break; + case 'space': + this.toggleValue(); + break; + } + }); + } +} diff --git a/create-tnf/src/clack/core/src/prompts/multi-select.ts b/create-tnf/src/clack/core/src/prompts/multi-select.ts new file mode 100644 index 0000000..6a695ae --- /dev/null +++ b/create-tnf/src/clack/core/src/prompts/multi-select.ts @@ -0,0 +1,65 @@ +import Prompt, { type PromptOptions } from './prompt.js'; + +interface MultiSelectOptions + extends PromptOptions> { + options: T[]; + initialValues?: Array; + required?: boolean; + cursorAt?: T['value']; +} +export default class MultiSelectPrompt< + T extends { value: any }, +> extends Prompt { + options: T[]; + cursor: number = 0; + + private get _value() { + return this.options[this.cursor]!.value; + } + + private toggleAll() { + const allSelected = this.value.length === this.options.length; + this.value = allSelected ? [] : this.options.map((v) => v.value); + } + + private toggleValue() { + const selected = this.value.includes(this._value); + this.value = selected + ? this.value.filter((value: T['value']) => value !== this._value) + : [...this.value, this._value]; + } + + constructor(opts: MultiSelectOptions) { + super(opts, false); + + this.options = opts.options; + this.value = [...(opts.initialValues ?? [])]; + this.cursor = Math.max( + this.options.findIndex(({ value }) => value === opts.cursorAt), + 0, + ); + this.on('key', (char) => { + if (char === 'a') { + this.toggleAll(); + } + }); + + this.on('cursor', (key) => { + switch (key) { + case 'left': + case 'up': + this.cursor = + this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + break; + case 'down': + case 'right': + this.cursor = + this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + break; + case 'space': + this.toggleValue(); + break; + } + }); + } +} diff --git a/create-tnf/src/clack/core/src/prompts/password.ts b/create-tnf/src/clack/core/src/prompts/password.ts new file mode 100644 index 0000000..2b1d7aa --- /dev/null +++ b/create-tnf/src/clack/core/src/prompts/password.ts @@ -0,0 +1,33 @@ +import color from 'picocolors'; +import Prompt, { type PromptOptions } from './prompt.js'; + +interface PasswordOptions extends PromptOptions { + mask?: string; +} +export default class PasswordPrompt extends Prompt { + valueWithCursor = ''; + private _mask = '•'; + get cursor(): number { + return this._cursor; + } + get masked(): string { + return this.value.replaceAll(/./g, this._mask); + } + constructor({ mask, ...opts }: PasswordOptions) { + super(opts); + this._mask = mask ?? '•'; + + this.on('finalize', () => { + this.valueWithCursor = this.masked; + }); + this.on('value', () => { + if (this.cursor >= this.value.length) { + this.valueWithCursor = `${this.masked}${color.inverse(color.hidden('_'))}`; + } else { + const s1 = this.masked.slice(0, this.cursor); + const s2 = this.masked.slice(this.cursor); + this.valueWithCursor = `${s1}${color.inverse(s2[0])}${s2.slice(1)}`; + } + }); + } +} diff --git a/create-tnf/src/clack/core/src/prompts/prompt.ts b/create-tnf/src/clack/core/src/prompts/prompt.ts new file mode 100644 index 0000000..8d77453 --- /dev/null +++ b/create-tnf/src/clack/core/src/prompts/prompt.ts @@ -0,0 +1,279 @@ +import process, { stdin, stdout } from 'process'; +import readline, { type Key, type ReadLine } from 'readline'; +import { cursor, erase } from 'sisteransi'; +import type { Readable, Writable } from 'stream'; +import { WriteStream } from 'tty'; +import wrap from 'wrap-ansi'; + +function diffLines(a: string, b: string) { + if (a === b) return; + + const aLines = a.split('\n'); + const bLines = b.split('\n'); + const diff: number[] = []; + + for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) { + if (aLines[i] !== bLines[i]) diff.push(i); + } + + return diff; +} + +const cancel = Symbol('clack:cancel'); +export function isCancel(value: unknown): value is symbol { + return value === cancel; +} + +function setRawMode(input: Readable, value: boolean) { + if ((input as typeof stdin).isTTY) (input as typeof stdin).setRawMode(value); +} + +const aliases = new Map([ + ['k', 'up'], + ['j', 'down'], + ['h', 'left'], + ['l', 'right'], +]); +const keys = new Set(['up', 'down', 'left', 'right', 'space', 'enter']); + +export interface PromptOptions { + render(this: Omit): string | void; + placeholder?: string; + initialValue?: any; + validate?: ((value: any) => string | void) | undefined; + input?: Readable; + output?: Writable; + debug?: boolean; +} + +export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error'; + +export default class Prompt { + protected input: Readable; + protected output: Writable; + private rl!: ReadLine; + private opts: Omit, 'render' | 'input' | 'output'>; + private _track: boolean = false; + private _render: (context: Omit) => string | void; + protected _cursor: number = 0; + + public state: State = 'initial'; + public value: any; + public error: string = ''; + + constructor( + { render, input = stdin, output = stdout, ...opts }: PromptOptions, + trackValue: boolean = true, + ) { + this.opts = opts; + this.onKeypress = this.onKeypress.bind(this); + this.close = this.close.bind(this); + this.render = this.render.bind(this); + this._render = render.bind(this); + this._track = trackValue; + + this.input = input; + this.output = output; + } + + public prompt(): Promise { + const sink = new WriteStream(0); + sink._write = (chunk, encoding, done) => { + if (this._track) { + this.value = this.rl.line.replace(/\t/g, ''); + this._cursor = this.rl.cursor; + this.emit('value', this.value); + } + done(); + }; + this.input.pipe(sink); + + this.rl = readline.createInterface({ + input: this.input, + output: sink, + tabSize: 2, + prompt: '', + escapeCodeTimeout: 50, + }); + readline.emitKeypressEvents(this.input, this.rl); + this.rl.prompt(); + if (this.opts.initialValue !== undefined && this._track) { + this.rl.write(this.opts.initialValue); + } + + this.input.on('keypress', this.onKeypress); + setRawMode(this.input, true); + this.output.on('resize', this.render); + + this.render(); + + return new Promise((resolve) => { + this.once('submit', () => { + this.output.write(cursor.show); + this.output.off('resize', this.render); + setRawMode(this.input, false); + resolve(this.value); + }); + this.once('cancel', () => { + this.output.write(cursor.show); + this.output.off('resize', this.render); + setRawMode(this.input, false); + resolve(cancel); + }); + }); + } + + private subscribers = new Map< + string, + Array<{ cb: (...args: any) => any; once?: boolean }> + >(); + public on(event: string, cb: (...args: any) => any): void { + const arr = this.subscribers.get(event) ?? []; + arr.push({ cb }); + this.subscribers.set(event, arr); + } + public once(event: string, cb: (...args: any) => any): void { + const arr = this.subscribers.get(event) ?? []; + arr.push({ cb, once: true }); + this.subscribers.set(event, arr); + } + public emit(event: string, ...data: any[]): void { + const cbs = this.subscribers.get(event) ?? []; + const cleanup: Array<() => void> = []; + for (const subscriber of cbs) { + subscriber.cb(...data); + if (subscriber.once) { + cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1)); + } + } + for (const cb of cleanup) { + cb(); + } + } + private unsubscribe() { + this.subscribers.clear(); + } + + private onKeypress(char: string, key?: Key) { + if (this.state === 'error') { + this.state = 'active'; + } + if (key?.name && !this._track && aliases.has(key.name)) { + this.emit('cursor', aliases.get(key.name)); + } + if (key?.name && keys.has(key.name)) { + this.emit('cursor', key.name); + } + if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) { + this.emit('confirm', char.toLowerCase() === 'y'); + } + if (char === '\t' && this.opts.placeholder) { + if (!this.value) { + this.rl.write(this.opts.placeholder); + this.emit('value', this.opts.placeholder); + } + } + if (char) { + this.emit('key', char.toLowerCase()); + } + + if (key?.name === 'return') { + if (this.opts.validate) { + const problem = this.opts.validate(this.value); + if (problem) { + this.error = problem; + this.state = 'error'; + this.rl.write(this.value); + } + } + if (this.state !== 'error') { + this.state = 'submit'; + } + } + if (char === '\x03') { + this.state = 'cancel'; + } + if (this.state === 'submit' || this.state === 'cancel') { + this.emit('finalize'); + } + this.render(); + if (this.state === 'submit' || this.state === 'cancel') { + this.close(); + } + } + + protected close(): void { + this.input.unpipe(); + this.input.removeListener('keypress', this.onKeypress); + this.output.write('\n'); + setRawMode(this.input, false); + this.rl.close(); + this.emit(this.state, this.value); + this.unsubscribe(); + } + + private restoreCursor() { + const lines = + wrap(this._prevFrame, process.stdout.columns, { hard: true }).split('\n') + .length - 1; + this.output.write(cursor.move(-999, lines * -1)); + } + + private _prevFrame = ''; + private render() { + const frame = wrap(this._render(this) ?? '', process.stdout.columns, { + hard: true, + }); + if (frame === this._prevFrame) return; + + if (this.state === 'initial') { + this.output.write(cursor.hide); + } + + const diff = diffLines(this._prevFrame, frame); + this.restoreCursor(); + if (diff) { + const diffLine = diff[0]!; + const lines = frame.split('\n'); + let newLines: string[] = []; + + // If we don't have enough vertical space to print all of the lines simultaneously, + // then we'll sticky the prompt message (first 3 lines) to the top so it's always shown. + // We'll then take the remaining space and render a snippet of the list that's relative + // to the currently selected option + if (lines.length > process.stdout.rows) { + const OFFSET = 3; + const PAGE_SIZE = process.stdout.rows - OFFSET; + + // @ts-expect-error `cursor` is a property that's implemented by prompts extending this class. + const pos: number = this.cursor; + + // page positions + const start = pos <= OFFSET ? OFFSET : pos; + const end = start + PAGE_SIZE; + + this.output.write(erase.down()); + + // stickied headers + const header = lines.slice(0, OFFSET); + const content = lines.slice(start, end); + newLines = newLines.concat(header, content); + } else { + this.output.write(cursor.move(0, diffLine)); + this.output.write(erase.down()); + + newLines = lines.slice(diffLine); + } + + this.output.write(newLines.join('\n')); + this._prevFrame = frame; + return; + } + + this.output.write(frame); + if (this.state === 'initial') { + this.state = 'active'; + } + this._prevFrame = frame; + } +} diff --git a/create-tnf/src/clack/core/src/prompts/select-key.ts b/create-tnf/src/clack/core/src/prompts/select-key.ts new file mode 100644 index 0000000..c9c1e32 --- /dev/null +++ b/create-tnf/src/clack/core/src/prompts/select-key.ts @@ -0,0 +1,32 @@ +import Prompt, { type PromptOptions } from './prompt.js'; + +interface SelectKeyOptions + extends PromptOptions> { + options: T[]; +} +export default class SelectKeyPrompt extends Prompt { + options: T[]; + cursor: number = 0; + + constructor(opts: SelectKeyOptions) { + super(opts, false); + + this.options = opts.options; + const keys = this.options.map(({ value: [initial] }) => + initial?.toLowerCase(), + ); + this.cursor = Math.max(keys.indexOf(opts.initialValue), 0); + + this.on('key', (key) => { + if (!keys.includes(key)) return; + const value = this.options.find( + ({ value: [initial] }) => initial?.toLowerCase() === key, + ); + if (value) { + this.value = value.value; + this.state = 'submit'; + this.emit('submit'); + } + }); + } +} diff --git a/create-tnf/src/clack/core/src/prompts/select.ts b/create-tnf/src/clack/core/src/prompts/select.ts new file mode 100644 index 0000000..0aabfa2 --- /dev/null +++ b/create-tnf/src/clack/core/src/prompts/select.ts @@ -0,0 +1,46 @@ +import Prompt, { type PromptOptions } from './prompt.js'; + +interface SelectOptions + extends PromptOptions> { + options: T[]; + initialValue?: T['value']; +} +export default class SelectPrompt extends Prompt { + options: T[]; + cursor: number = 0; + + private get _value() { + return this.options[this.cursor]; + } + + private changeValue() { + this.value = this._value!.value; + } + + constructor(opts: SelectOptions) { + super(opts, false); + + this.options = opts.options; + this.cursor = this.options.findIndex( + ({ value }) => value === opts.initialValue, + ); + if (this.cursor === -1) this.cursor = 0; + this.changeValue(); + + this.on('cursor', (key) => { + switch (key) { + case 'left': + case 'up': + this.cursor = + this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + break; + case 'down': + case 'right': + this.cursor = + this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + break; + } + this.changeValue(); + }); + } +} diff --git a/create-tnf/src/clack/core/src/prompts/text.ts b/create-tnf/src/clack/core/src/prompts/text.ts new file mode 100644 index 0000000..65a2105 --- /dev/null +++ b/create-tnf/src/clack/core/src/prompts/text.ts @@ -0,0 +1,33 @@ +import color from 'picocolors'; +import Prompt, { type PromptOptions } from './prompt.js'; + +export interface TextOptions extends PromptOptions { + placeholder?: string; + defaultValue?: string; +} + +export default class TextPrompt extends Prompt { + valueWithCursor = ''; + get cursor(): number { + return this._cursor; + } + constructor(opts: TextOptions) { + super(opts); + + this.on('finalize', () => { + if (!this.value) { + this.value = opts.defaultValue; + } + this.valueWithCursor = this.value; + }); + this.on('value', () => { + if (this.cursor >= this.value.length) { + this.valueWithCursor = `${this.value}${color.inverse(color.hidden('_'))}`; + } else { + const s1 = this.value.slice(0, this.cursor); + const s2 = this.value.slice(this.cursor); + this.valueWithCursor = `${s1}${color.inverse(s2[0])}${s2.slice(1)}`; + } + }); + } +} diff --git a/create-tnf/src/clack/core/src/utils.ts b/create-tnf/src/clack/core/src/utils.ts new file mode 100644 index 0000000..27d822c --- /dev/null +++ b/create-tnf/src/clack/core/src/utils.ts @@ -0,0 +1,59 @@ +import process, { stdin, stdout } from 'node:process'; +import type { Key } from 'node:readline'; +import * as readline from 'node:readline'; +import { cursor } from 'sisteransi'; + +const isWindows = process.platform.startsWith('win'); + +export type BlockOptions = { + input?: NodeJS.ReadStream | undefined; + output?: NodeJS.WriteStream | undefined; + overwrite?: boolean | undefined; + hideCursor?: boolean | undefined; +}; + +export function block({ + input = stdin, + output = stdout, + overwrite = true, + hideCursor = true, +}: BlockOptions = {}) { + const rl = readline.createInterface({ + input, + output, + prompt: '', + tabSize: 1, + }); + readline.emitKeypressEvents(input, rl); + if (input.isTTY) input.setRawMode(true); + + const clear = (data: Buffer, { name }: Key) => { + const str = String(data); + if (str === '\x03') { + process.exit(0); + } + if (!overwrite) return; + const dx = name === 'return' ? 0 : -1; + const dy = name === 'return' ? -1 : 0; + + readline.moveCursor(output, dx, dy, () => { + readline.clearLine(output, 1, () => { + input.once('keypress', clear); + }); + }); + }; + if (hideCursor) process.stdout.write(cursor.hide); + input.once('keypress', clear); + + return (): void => { + input.off('keypress', clear); + if (hideCursor) process.stdout.write(cursor.show); + + // Prevent Windows specific issues: https://github.com/natemoo-re/clack/issues/176 + if (input.isTTY && !isWindows) input.setRawMode(false); + + // @ts-expect-error fix for https://github.com/nodejs/node/issues/31762#issuecomment-1441223907 + rl.terminal = false; + rl.close(); + }; +} diff --git a/create-tnf/src/clack/prompt/index.ts b/create-tnf/src/clack/prompt/index.ts new file mode 100644 index 0000000..cf9a39d --- /dev/null +++ b/create-tnf/src/clack/prompt/index.ts @@ -0,0 +1,949 @@ +import isUnicodeSupported from 'is-unicode-supported'; +import process from 'node:process'; +import color from 'picocolors'; +import { cursor, erase } from 'sisteransi'; +import { + ConfirmPrompt, + GroupMultiSelectPrompt, + MultiSelectPrompt, + PasswordPrompt, + SelectKeyPrompt, + SelectPrompt, + type State, + TextPrompt, + block, + isCancel, +} from '../core/index.js'; + +export { isCancel } from '../core/index.js'; + +const unicode = isUnicodeSupported(); +const s = (c: string, fallback: string) => (unicode ? c : fallback); +const S_STEP_ACTIVE = s('◆', '*'); +const S_STEP_CANCEL = s('■', 'x'); +const S_STEP_ERROR = s('▲', 'x'); +const S_STEP_SUBMIT = s('◇', 'o'); + +const S_BAR_START = s('┌', 'T'); +const S_BAR = s('│', '|'); +const S_BAR_END = s('└', '—'); + +const S_RADIO_ACTIVE = s('●', '>'); +const S_RADIO_INACTIVE = s('○', ' '); +const S_CHECKBOX_ACTIVE = s('◻', '[•]'); +const S_CHECKBOX_SELECTED = s('◼', '[+]'); +const S_CHECKBOX_INACTIVE = s('◻', '[ ]'); +const S_PASSWORD_MASK = s('▪', '•'); + +const S_BAR_H = s('─', '-'); +const S_CORNER_TOP_RIGHT = s('╮', '+'); +const S_CONNECT_LEFT = s('├', '+'); +const S_CORNER_BOTTOM_RIGHT = s('╯', '+'); + +const S_INFO = s('●', '•'); +const S_SUCCESS = s('◆', '*'); +const S_WARN = s('▲', '!'); +const S_ERROR = s('■', 'x'); + +const symbol = (state: State) => { + switch (state) { + case 'initial': + case 'active': + return color.cyan(S_STEP_ACTIVE); + case 'cancel': + return color.red(S_STEP_CANCEL); + case 'error': + return color.yellow(S_STEP_ERROR); + case 'submit': + return color.green(S_STEP_SUBMIT); + } +}; + +interface LimitOptionsParams { + options: TOption[]; + maxItems: number | undefined; + cursor: number; + style: (option: TOption, active: boolean) => string; +} + +const limitOptions = ( + params: LimitOptionsParams, +): string[] => { + const { cursor, options, style } = params; + + const paramMaxItems = params.maxItems ?? Infinity; + const outputMaxItems = Math.max(process.stdout.rows - 4, 0); + // We clamp to minimum 5 because anything less doesn't make sense UX wise + const maxItems = Math.min(outputMaxItems, Math.max(paramMaxItems, 5)); + let slidingWindowLocation = 0; + + if (cursor >= slidingWindowLocation + maxItems - 3) { + slidingWindowLocation = Math.max( + Math.min(cursor - maxItems + 3, options.length - maxItems), + 0, + ); + } else if (cursor < slidingWindowLocation + 2) { + slidingWindowLocation = Math.max(cursor - 2, 0); + } + + const shouldRenderTopEllipsis = + maxItems < options.length && slidingWindowLocation > 0; + const shouldRenderBottomEllipsis = + maxItems < options.length && + slidingWindowLocation + maxItems < options.length; + + return options + .slice(slidingWindowLocation, slidingWindowLocation + maxItems) + .map((option, i, arr) => { + const isTopLimit = i === 0 && shouldRenderTopEllipsis; + const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis; + return isTopLimit || isBottomLimit + ? color.dim('...') + : style(option, i + slidingWindowLocation === cursor); + }); +}; + +export interface TextOptions { + message: string; + placeholder?: string; + defaultValue?: string; + initialValue?: string; + validate?: (value: string) => string | void; +} +export const text = (opts: TextOptions): Promise => { + return new TextPrompt({ + validate: opts.validate, + placeholder: opts.placeholder, + defaultValue: opts.defaultValue, + initialValue: opts.initialValue, + render() { + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const placeholder = opts.placeholder + ? color.inverse(opts.placeholder[0]) + + color.dim(opts.placeholder.slice(1)) + : color.inverse(color.hidden('_')); + const value = !this.value ? placeholder : this.valueWithCursor; + + switch (this.state) { + case 'error': + return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( + S_BAR_END, + )} ${color.yellow(this.error)}\n`; + case 'submit': + return `${title}${color.gray(S_BAR)} ${color.dim(this.value || opts.placeholder)}`; + case 'cancel': + return `${title}${color.gray(S_BAR)} ${color.strikethrough( + color.dim(this.value ?? ''), + )}${this.value?.trim() ? '\n' + color.gray(S_BAR) : ''}`; + default: + return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; + } + }, + }).prompt(); +}; + +export interface PasswordOptions { + message: string; + mask?: string; + validate?: (value: string) => string | void; +} +export const password = (opts: PasswordOptions): Promise => { + return new PasswordPrompt({ + validate: opts.validate, + mask: opts.mask ?? S_PASSWORD_MASK, + render() { + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const value = this.valueWithCursor; + const masked = this.masked; + + switch (this.state) { + case 'error': + return `${title.trim()}\n${color.yellow(S_BAR)} ${masked}\n${color.yellow( + S_BAR_END, + )} ${color.yellow(this.error)}\n`; + case 'submit': + return `${title}${color.gray(S_BAR)} ${color.dim(masked)}`; + case 'cancel': + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ''))}${ + masked ? '\n' + color.gray(S_BAR) : '' + }`; + default: + return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; + } + }, + }).prompt(); +}; + +export interface ConfirmOptions { + message: string; + active?: string; + inactive?: string; + initialValue?: boolean; +} +export const confirm = (opts: ConfirmOptions) => { + const active = opts.active ?? 'Yes'; + const inactive = opts.inactive ?? 'No'; + return new ConfirmPrompt({ + active, + inactive, + initialValue: opts.initialValue ?? true, + render() { + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const value = this.value ? active : inactive; + + switch (this.state) { + case 'submit': + return `${title}${color.gray(S_BAR)} ${color.dim(value)}`; + case 'cancel': + return `${title}${color.gray(S_BAR)} ${color.strikethrough( + color.dim(value), + )}\n${color.gray(S_BAR)}`; + default: { + return `${title}${color.cyan(S_BAR)} ${ + this.value + ? `${color.green(S_RADIO_ACTIVE)} ${active}` + : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}` + } ${color.dim('/')} ${ + !this.value + ? `${color.green(S_RADIO_ACTIVE)} ${inactive}` + : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}` + }\n${color.cyan(S_BAR_END)}\n`; + } + } + }, + }).prompt() as Promise; +}; + +type Primitive = Readonly; + +type Option = Value extends Primitive + ? { value: Value; label?: string; hint?: string } + : { value: Value; label: string; hint?: string }; + +export interface SelectOptions { + message: string; + options: Array>; + initialValue?: Value; + maxItems?: number; +} + +export const select = (opts: SelectOptions) => { + const opt = ( + option: Option, + state: 'inactive' | 'active' | 'selected' | 'cancelled', + ) => { + const label = option.label ?? String(option.value); + switch (state) { + case 'selected': + return color.dim(label); + case 'active': + return `${color.green(S_RADIO_ACTIVE)} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + case 'cancelled': + return color.strikethrough(color.dim(label)); + default: + return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`; + } + }; + + return new SelectPrompt({ + options: opts.options, + initialValue: opts.initialValue, + render() { + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + + switch (this.state) { + case 'submit': + return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor]!, 'selected')}`; + case 'cancel': + return `${title}${color.gray(S_BAR)} ${opt( + this.options[this.cursor]!, + 'cancelled', + )}\n${color.gray(S_BAR)}`; + default: { + return `${title}${color.cyan(S_BAR)} ${limitOptions({ + cursor: this.cursor, + options: this.options, + maxItems: opts.maxItems, + style: (item, active) => opt(item, active ? 'active' : 'inactive'), + }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + } + } + }, + }).prompt() as Promise; +}; + +export const selectKey = (opts: SelectOptions) => { + const opt = ( + option: Option, + state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive', + ) => { + const label = option.label ?? String(option.value); + if (state === 'selected') { + return color.dim(label); + } else if (state === 'cancelled') { + return color.strikethrough(color.dim(label)); + } else if (state === 'active') { + return `${color.bgCyan(color.gray(` ${option.value} `))} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + } + return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + }; + + return new SelectKeyPrompt({ + options: opts.options, + initialValue: opts.initialValue, + render() { + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + + switch (this.state) { + case 'submit': + return `${title}${color.gray(S_BAR)} ${opt( + this.options.find((opt) => opt.value === this.value)!, + 'selected', + )}`; + case 'cancel': + return `${title}${color.gray(S_BAR)} ${opt(this.options[0]!, 'cancelled')}\n${color.gray( + S_BAR, + )}`; + default: { + return `${title}${color.cyan(S_BAR)} ${this.options + .map((option, i) => + opt(option, i === this.cursor ? 'active' : 'inactive'), + ) + .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + } + } + }, + }).prompt() as Promise; +}; + +export interface MultiSelectOptions { + message: string; + options: Array>; + initialValues?: Value[]; + maxItems?: number; + required?: boolean; + cursorAt?: Value; +} +export const multiselect = (opts: MultiSelectOptions) => { + const opt = ( + option: Option, + state: + | 'inactive' + | 'active' + | 'selected' + | 'active-selected' + | 'submitted' + | 'cancelled', + ) => { + const label = option.label ?? String(option.value); + if (state === 'active') { + return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + } else if (state === 'selected') { + return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; + } else if (state === 'cancelled') { + return color.strikethrough(color.dim(label)); + } else if (state === 'active-selected') { + return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + } else if (state === 'submitted') { + return color.dim(label); + } + return `${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`; + }; + + return new MultiSelectPrompt({ + options: opts.options, + initialValues: opts.initialValues, + required: opts.required ?? true, + cursorAt: opts.cursorAt, + validate(selected: Value[]) { + if (this.required && selected.length === 0) + return `Please select at least one option.\n${color.reset( + color.dim( + `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( + color.bgWhite(color.inverse(' enter ')), + )} to submit`, + ), + )}`; + }, + render() { + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + + const styleOption = (option: Option, active: boolean) => { + const selected = this.value.includes(option.value); + if (active && selected) { + return opt(option, 'active-selected'); + } + if (selected) { + return opt(option, 'selected'); + } + return opt(option, active ? 'active' : 'inactive'); + }; + + switch (this.state) { + case 'submit': { + return `${title}${color.gray(S_BAR)} ${ + this.options + .filter(({ value }) => this.value.includes(value)) + .map((option) => opt(option, 'submitted')) + .join(color.dim(', ')) || color.dim('none') + }`; + } + case 'cancel': { + const label = this.options + .filter(({ value }) => this.value.includes(value)) + .map((option) => opt(option, 'cancelled')) + .join(color.dim(', ')); + return `${title}${color.gray(S_BAR)} ${ + label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' + }`; + } + case 'error': { + const footer = this.error + .split('\n') + .map((ln, i) => + i === 0 + ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` + : ` ${ln}`, + ) + .join('\n'); + return ( + title + + color.yellow(S_BAR) + + ' ' + + limitOptions({ + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + style: styleOption, + }).join(`\n${color.yellow(S_BAR)} `) + + '\n' + + footer + + '\n' + ); + } + default: { + return `${title}${color.cyan(S_BAR)} ${limitOptions({ + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + style: styleOption, + }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + } + } + }, + }).prompt() as Promise; +}; + +export interface GroupMultiSelectOptions { + message: string; + options: Record>>; + initialValues?: Value[]; + required?: boolean; + cursorAt?: Value; + selectableGroups?: boolean; + spacedGroups?: boolean; +} + +export const groupMultiselect = ( + opts: GroupMultiSelectOptions, +) => { + const { selectableGroups = false, spacedGroups = false } = opts; + const opt = ( + option: Option, + state: + | 'inactive' + | 'active' + | 'selected' + | 'active-selected' + | 'group-active' + | 'group-active-selected' + | 'submitted' + | 'cancelled', + options: Array> = [], + ) => { + const label = option.label ?? String(option.value); + const isItem = typeof (option as any).group === 'string'; + const next = + isItem && (options[options.indexOf(option) + 1] ?? { group: true }); + // @ts-ignore + const isLast = isItem && next.group === true; + const prefix = isItem + ? selectableGroups + ? `${isLast ? S_BAR_END : S_BAR} ` + : ' ' + : ''; + const spacingPrefix = + spacedGroups && !isItem ? `\n${color.cyan(S_BAR)} ` : ''; + + if (state === 'active') { + return `${spacingPrefix}${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + } else if (state === 'group-active') { + return `${spacingPrefix}${prefix}${color.cyan(S_CHECKBOX_ACTIVE)} ${color.dim(label)}`; + } else if (state === 'group-active-selected') { + return `${spacingPrefix}${prefix}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; + } else if (state === 'selected') { + return `${spacingPrefix}${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${color.dim( + label, + )}`; + } else if (state === 'cancelled') { + return color.strikethrough(color.dim(label)); + } else if (state === 'active-selected') { + return `${spacingPrefix}${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + } else if (state === 'submitted') { + return color.dim(label); + } + return `${spacingPrefix}${color.dim(prefix)}${ + isItem || selectableGroups ? `${color.dim(S_CHECKBOX_INACTIVE)} ` : '' + }${color.dim(label)}`; + }; + + return new GroupMultiSelectPrompt({ + options: opts.options, + initialValues: opts.initialValues, + required: opts.required ?? true, + cursorAt: opts.cursorAt, + selectableGroups, + validate(selected: Value[]) { + if (this.required && selected.length === 0) + return `Please select at least one option.\n${color.reset( + color.dim( + `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( + color.bgWhite(color.inverse(' enter ')), + )} to submit`, + ), + )}`; + }, + render() { + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + + switch (this.state) { + case 'submit': { + return `${title}${color.gray(S_BAR)} ${this.options + .filter(({ value }) => this.value.includes(value)) + .map((option) => opt(option, 'submitted')) + .join(color.dim(', '))}`; + } + case 'cancel': { + const label = this.options + .filter(({ value }) => this.value.includes(value)) + .map((option) => opt(option, 'cancelled')) + .join(color.dim(', ')); + return `${title}${color.gray(S_BAR)} ${ + label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' + }`; + } + case 'error': { + const footer = this.error + .split('\n') + .map((ln, i) => + i === 0 + ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` + : ` ${ln}`, + ) + .join('\n'); + return `${title}${color.yellow(S_BAR)} ${this.options + .map((option, i, options) => { + const selected = + this.value.includes(option.value) || + (option.group === true && + this.isGroupSelected(`${option.value}`)); + const active = i === this.cursor; + const groupActive = + !active && + typeof option.group === 'string' && + this.options[this.cursor]!.value === option.group; + if (groupActive) { + return opt( + option, + selected ? 'group-active-selected' : 'group-active', + options, + ); + } + if (active && selected) { + return opt(option, 'active-selected', options); + } + if (selected) { + return opt(option, 'selected', options); + } + return opt(option, active ? 'active' : 'inactive', options); + }) + .join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; + } + default: { + return `${title}${color.cyan(S_BAR)} ${this.options + .map((option, i, options) => { + const selected = + this.value.includes(option.value) || + (option.group === true && + this.isGroupSelected(`${option.value}`)); + const active = i === this.cursor; + const groupActive = + !active && + typeof option.group === 'string' && + this.options[this.cursor]!.value === option.group; + if (groupActive) { + return opt( + option, + selected ? 'group-active-selected' : 'group-active', + options, + ); + } + if (active && selected) { + return opt(option, 'active-selected', options); + } + if (selected) { + return opt(option, 'selected', options); + } + return opt(option, active ? 'active' : 'inactive', options); + }) + .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + } + } + }, + }).prompt() as Promise; +}; + +const strip = (str: string) => str.replace(ansiRegex(), ''); +function buildBox(message = '', title = '', dimmed = true) { + const lines = `\n${message}\n`.split('\n'); + const titleLen = strip(title).length; + const len = + Math.max( + lines.reduce((sum, ln) => { + ln = strip(ln); + return ln.length > sum ? ln.length : sum; + }, 0), + titleLen, + ) + 2; + const msg = lines + .map( + (ln) => + `${color.gray(S_BAR)} ${dimmed ? color.dim(ln) : ln}${' '.repeat(len - strip(ln).length)}${color.gray(S_BAR)}`, + ) + .join('\n'); + process.stdout.write( + `${color.gray(S_BAR)}\n${color.green(S_STEP_SUBMIT)} ${color.reset(title)} ${color.gray( + S_BAR_H.repeat(Math.max(len - titleLen - 1, 1)) + S_CORNER_TOP_RIGHT, + )}\n${msg}\n${color.gray(S_CONNECT_LEFT + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\n`, + ); +} + +export const note = (message = '', title = ''): void => + buildBox(message, title, true); +export const box = (message = '', title = ''): void => + buildBox(message, title, false); +export const taskLog = (title: string) => { + const BAR = color.dim(S_BAR); + const ACTIVE = color.green(S_STEP_SUBMIT); + const SUCCESS = color.green(S_SUCCESS); + const ERROR = color.red(S_ERROR); + + // heading + process.stdout.write(`${BAR}\n`); + process.stdout.write(`${ACTIVE} ${title}\n`); + + let output = ''; + let frame = ''; + + // clears previous output + const clear = (eraseTitle = false): void => { + if (!frame) return; + const terminalWidth = process.stdout.columns; + const frameHeight = frame.split('\n').reduce((height, line) => { + // accounts for line wraps + height += Math.ceil(line.length / terminalWidth); + return height; + }, 0); + const lines = frameHeight + (eraseTitle ? 1 : 0); + + process.stdout.write(cursor.up(lines)); + process.stdout.write(erase.down()); + }; + + // logs the output + const print = (limit = 0): void => { + const lines = output.split('\n').slice(-limit); + // reset frame + frame = ''; + for (const line of lines) { + frame += `${BAR} ${line}\n`; + } + process.stdout.write(color.dim(frame)); + }; + + return { + set text(data: string) { + clear(); + output += data; + // half the height of the terminal + const frameHeight = Math.ceil(process.stdout.rows / 2); + print(frameHeight); + }, + fail(message: string): void { + clear(true); + process.stdout.write(`${ERROR} ${message}\n`); + print(); // log the output on failure + }, + success(message: string): void { + clear(true); + process.stdout.write(`${SUCCESS} ${message}\n`); + }, + }; +}; + +export const cancel = (message = ''): void => { + process.stdout.write(`${color.gray(S_BAR_END)} ${color.red(message)}\n\n`); +}; + +export const intro = (title = ''): void => { + process.stdout.write(`${color.gray(S_BAR_START)} ${title}\n`); +}; + +export const outro = (message = ''): void => { + process.stdout.write( + `${color.gray(S_BAR)}\n${color.gray(S_BAR_END)} ${message}\n\n`, + ); +}; + +export type LogMessageOptions = { + symbol?: string; +}; +export const log = { + message: ( + message = '', + { symbol = color.gray(S_BAR) }: LogMessageOptions = {}, + ): void => { + const parts = [color.gray(S_BAR)]; + if (message) { + const [firstLine, ...lines] = message.split('\n'); + parts.push( + `${symbol} ${firstLine}`, + ...lines.map((ln) => `${color.gray(S_BAR)} ${ln}`), + ); + } + process.stdout.write(`${parts.join('\n')}\n`); + }, + info: (message: string): void => { + log.message(message, { symbol: color.blue(S_INFO) }); + }, + success: (message: string): void => { + log.message(message, { symbol: color.green(S_SUCCESS) }); + }, + step: (message: string): void => { + log.message(message, { symbol: color.green(S_STEP_SUBMIT) }); + }, + warn: (message: string): void => { + log.message(message, { symbol: color.yellow(S_WARN) }); + }, + /** alias for `log.warn()`. */ + warning: (message: string): void => { + log.warn(message); + }, + error: (message: string): void => { + log.message(message, { symbol: color.red(S_ERROR) }); + }, +}; + +export const spinner = (): { + start: (msg?: string) => void; + stop: (msg?: string, code?: number) => void; + message: (msg?: string) => void; +} => { + const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0']; + const delay = unicode ? 80 : 120; + + let unblock: () => void; + let loop: NodeJS.Timeout; + let isSpinnerActive: boolean = false; + let _message: string = ''; + + const handleExit = (code: number) => { + const msg = code > 1 ? 'Something went wrong' : 'Canceled'; + if (isSpinnerActive) stop(msg, code); + }; + + const errorEventHandler = () => { + handleExit(2); + }; + const signalEventHandler = () => { + handleExit(1); + }; + + const registerHooks = () => { + // Reference: https://nodejs.org/api/process.html#event-uncaughtexception + process.on('uncaughtExceptionMonitor', errorEventHandler); + // Reference: https://nodejs.org/api/process.html#event-unhandledrejection + process.on('unhandledRejection', errorEventHandler); + // Reference Signal Events: https://nodejs.org/api/process.html#signal-events + process.on('SIGINT', signalEventHandler); + process.on('SIGTERM', signalEventHandler); + process.on('exit', handleExit); + }; + + const clearHooks = () => { + process.removeListener('uncaughtExceptionMonitor', errorEventHandler); + process.removeListener('unhandledRejection', errorEventHandler); + process.removeListener('SIGINT', signalEventHandler); + process.removeListener('SIGTERM', signalEventHandler); + process.removeListener('exit', handleExit); + }; + + const start = (msg: string = ''): void => { + isSpinnerActive = true; + unblock = block(); + _message = msg.replace(/\.+$/, ''); + process.stdout.write(`${color.gray(S_BAR)}\n`); + let frameIndex = 0; + let dotsTimer = 0; + registerHooks(); + loop = setInterval(() => { + const frame = color.magenta(frames[frameIndex]); + const loadingDots = '.'.repeat(Math.floor(dotsTimer)).slice(0, 3); + process.stdout.write(cursor.move(-999, 0)); + process.stdout.write(erase.down(1)); + process.stdout.write(`${frame} ${_message}${loadingDots}`); + frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0; + dotsTimer = dotsTimer < frames.length ? dotsTimer + 0.125 : 0; + }, delay); + }; + + const stop = (msg: string = '', code: number = 0): void => { + _message = msg ?? _message; + isSpinnerActive = false; + clearInterval(loop); + const step = + code === 0 + ? color.green(S_STEP_SUBMIT) + : code === 1 + ? color.red(S_STEP_CANCEL) + : color.red(S_STEP_ERROR); + process.stdout.write(cursor.move(-999, 0)); + process.stdout.write(erase.down(1)); + process.stdout.write(`${step} ${_message}\n`); + clearHooks(); + unblock(); + }; + + const message = (msg: string = ''): void => { + _message = msg ?? _message; + }; + + return { + start, + stop, + message, + }; +}; + +// Adapted from https://github.com/chalk/ansi-regex +// @see LICENSE +function ansiRegex() { + const pattern = [ + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', + ].join('|'); + + return new RegExp(pattern, 'g'); +} + +export type PromptGroupAwaitedReturn = { + [P in keyof T]: Exclude, symbol>; +}; + +export interface PromptGroupOptions { + /** + * Control how the group can be canceled + * if one of the prompts is canceled. + */ + onCancel?: (opts: { + results: Prettify>>; + }) => void; +} + +type Prettify = { + [P in keyof T]: T[P]; +} & {}; + +export type PromptGroup = { + [P in keyof T]: (opts: { + results: Prettify>>>; + }) => void | Promise; +}; + +/** + * Define a group of prompts to be displayed + * and return a results of objects within the group + */ +export const group = async ( + prompts: PromptGroup, + opts?: PromptGroupOptions, +): Promise>> => { + const results = {} as any; + const promptNames = Object.keys(prompts); + + for (const name of promptNames) { + const prompt = prompts[name as keyof T]; + const result = await prompt({ results })?.catch((e) => { + throw e; + }); + + // Pass the results to the onCancel function + // so the user can decide what to do with the results + // TODO: Switch to callback within core to avoid isCancel Fn + if (typeof opts?.onCancel === 'function' && isCancel(result)) { + results[name] = 'canceled'; + opts.onCancel({ results }); + continue; + } + + results[name] = result; + } + + return results; +}; + +export type Task = { + /** + * Task title + */ + title: string; + /** + * Task function + */ + task: ( + message: (string: string) => void, + ) => string | Promise | void | Promise; + + /** + * If enabled === false the task will be skipped + */ + enabled?: boolean; +}; + +/** + * Define a group of tasks to be executed + */ +export const tasks = async (tasks: Task[]): Promise => { + for (const task of tasks) { + if (task.enabled === false) continue; + + const s = spinner(); + s.start(task.title); + const result = await task.task(s.message); + s.stop(result || task.title); + } +}; diff --git a/create-tnf/src/clack/test.ts b/create-tnf/src/clack/test.ts new file mode 100644 index 0000000..b395543 --- /dev/null +++ b/create-tnf/src/clack/test.ts @@ -0,0 +1,22 @@ +import * as p from './prompt/index.js'; + +(async () => { + console.log('test'); + p.intro('test'); + p.box('a\nb\nc', 'test'); + const s = p.spinner(); + s.start('1'); + s.message('2'); + await new Promise((resolve) => setTimeout(resolve, 1000)); + s.stop('3'); + const task = p.taskLog('test'); + task.text = '1\n'; + await new Promise((resolve) => setTimeout(resolve, 1000)); + task.text = '2\n'; + await new Promise((resolve) => setTimeout(resolve, 1000)); + task.text = '3\n'; + await new Promise((resolve) => setTimeout(resolve, 1000)); + task.success('4'); + p.outro('test'); + // p.cancel('test'); +})(); diff --git a/create-tnf/src/cli.ts b/create-tnf/src/cli.ts index d5e0fff..b6bfd8e 100644 --- a/create-tnf/src/cli.ts +++ b/create-tnf/src/cli.ts @@ -1,6 +1,5 @@ -import { outro } from '@clack/prompts'; -import pc from 'picocolors'; import yargsParser from 'yargs-parser'; +import * as p from './clack/prompt/index.js'; import { create } from './create.js'; async function run(cwd: string) { @@ -28,22 +27,31 @@ Options: --version, -v Show version number --help, -h Show help --template, -t Specify a template for the project + --npm-client, -n Specify the npm client to use (pnpm, yarn, npm) Examples: - create-tnf Create a new project - create-tnf my-app Create a new project named 'my-app' + create-tnf Create a new project + create-tnf my-app Create a new project named 'my-app' create-tnf my-app --template=simple Create a new project named 'my-app' using the 'simple' template`); return; } - return create({ + p.intro('Creating a new TNF project...'); + create({ cwd: cwd, name: argv._[0] as string | undefined, template: argv.template, - }); + }) + .then(() => { + p.outro('Create success!'); + }) + .catch((err) => { + p.cancel(`Create failed, ${err.message}`); + process.exit(1); + }); } run(process.cwd()).catch((err) => { - console.error(outro(pc.red(`Create failed, ${err.message}`))); + console.error(err); process.exit(1); }); diff --git a/create-tnf/src/create.ts b/create-tnf/src/create.ts index 922b848..eeec363 100644 --- a/create-tnf/src/create.ts +++ b/create-tnf/src/create.ts @@ -1,22 +1,27 @@ -import { intro, isCancel, outro, select, text } from '@clack/prompts'; +import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import pc from 'picocolors'; +import { fileURLToPath } from 'url'; +import * as p from './clack/prompt/index.js'; const CANCEL_TEXT = 'Operation cancelled.'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +type NpmClient = 'pnpm' | 'yarn' | 'npm'; export async function create({ cwd, name, template, + npmClient = 'pnpm', }: { cwd: string; name?: string; template?: string; + npmClient?: NpmClient; }) { - console.log(); - intro(pc.bgCyan(' Creating a new TNF project... ')); - const templatesPath = path.join(__dirname, '../templates'); const templateList = fs .readdirSync(templatesPath) @@ -26,16 +31,18 @@ export async function create({ const selectedTemplate = template || - (await select({ - message: 'Which template would you like?', - options: templateList.map((template) => ({ - label: template, - value: template, - })), - })); - - if (isCancel(selectedTemplate)) { - outro(pc.red(CANCEL_TEXT)); + (await (async () => { + const template = await p.select({ + message: 'Which template would you like?', + options: templateList.map((template) => ({ + label: template, + value: template, + })), + }); + return template; + })()); + if (p.isCancel(selectedTemplate)) { + p.cancel(CANCEL_TEXT); return; } @@ -47,26 +54,22 @@ export async function create({ } return name; } - - return await text({ + return await p.text({ message: 'Please enter a name for your new project:', initialValue: 'myapp', validate, }); - function validate(value: string) { if (!value) { return `Project name is required but got ${value}`; } - if (fs.existsSync(path.join(cwd, value))) { return `Project ${path.join(cwd, value)} already exists`; } } })(); - - if (isCancel(projectName)) { - outro(CANCEL_TEXT); + if (p.isCancel(projectName)) { + p.cancel(CANCEL_TEXT); return; } @@ -74,13 +77,75 @@ export async function create({ throw new Error('Project already exists'); } + const copySpinner = p.spinner(); + copySpinner.start(`Copying template ${selectedTemplate}...`); const templatePath = path.join(templatesPath, selectedTemplate as string); const projectPath = path.join(cwd, projectName); fs.cpSync(templatePath, projectPath, { recursive: true }); - outro(pc.green(`Project created in ${projectPath}`)); - console.log(`Now run: + copySpinner.stop(`Copied template ${selectedTemplate}`); + + const installTask = p.taskLog(`Installing dependencies with ${npmClient}...`); + const args = npmClient === 'yarn' ? [] : ['install']; + try { + await execa(npmClient, args, { + cwd: projectPath, + onData: (data) => { + installTask.text = data; + }, + }); + } catch (error) { + installTask.fail(`Failed to install dependencies with ${npmClient}`); + throw error; + } + installTask.success(`Installed dependencies with ${npmClient}`); - cd ${projectName} - npm install - npm run build`); + const syncTask = p.taskLog('Setting up project...'); + try { + await execa('npx', ['tnf', 'sync'], { + cwd: projectPath, + onData: (data) => { + syncTask.text = data; + }, + }); + } catch (error) { + syncTask.fail(`Failed to setup project`); + throw error; + } + syncTask.success(`Project setup complete`); + + p.box( + ` +1: ${pc.bold(pc.cyan(`cd ${projectName}`))} +2: ${pc.bold(pc.cyan(`git init && git add -A && git commit -m "Initial commit"`))} (optional) +3: ${pc.bold(pc.cyan(`${npmClient} run dev`))} + +To close the dev server, hit ${pc.bold(pc.cyan('Ctrl+C'))} + `.trim(), + 'Next Steps', + ); +} + +async function execa( + cmd: string, + args: string[], + options: { cwd: string; onData: (data: string) => void }, +) { + const child = spawn(cmd, args, { + stdio: 'pipe', + cwd: options.cwd, + }); + return new Promise((resolve, reject) => { + child.stdout?.on('data', (data) => { + options.onData(data); + }); + child.stderr?.on('data', (data) => { + options.onData(data); + }); + child.on('close', (code) => { + resolve(code); + }); + child.on('error', (error) => { + reject(error); + }); + }); } diff --git a/package.json b/package.json index c991312..33a198f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@babel/core": "^7.26.0", "@babel/preset-react": "^7.26.3", "@babel/preset-typescript": "^7.26.0", - "@clack/prompts": "^0.8.2", + "@clack/prompts": "^0.9.0", "@tanstack/react-router": "^1.85.10", "@tanstack/router-devtools": "^1.85.10", "@tanstack/router-generator": "^1.85.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce8f702..baf8ace 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^7.26.0 version: 7.26.0(@babel/core@7.26.0) '@clack/prompts': - specifier: ^0.8.2 - version: 0.8.2 + specifier: ^0.9.0 + version: 0.9.0 '@tanstack/react-router': specifier: ^1.85.10 version: 1.85.10(@tanstack/router-generator@1.85.3)(react-dom@19.0.0)(react@19.0.0) @@ -238,18 +238,27 @@ importers: create-tnf: dependencies: '@clack/prompts': - specifier: ^0.7.0 - version: 0.7.0 - '@types/yargs-parser': - specifier: ^21.0.3 - version: 21.0.3 + specifier: ^0.9.0 + version: 0.9.0 + is-unicode-supported: + specifier: ^1.3.0 + version: 1.3.0 picocolors: specifier: ^1.1.1 version: 1.1.1 + sisteransi: + specifier: ^1.0.5 + version: 1.0.5 + wrap-ansi: + specifier: ^8.1.0 + version: 8.1.0 yargs-parser: specifier: ^21.1.1 version: 21.1.1 devDependencies: + '@types/yargs-parser': + specifier: ^21.0.3 + version: 21.0.3 father: specifier: ^4.5.1 version: 4.5.1(@babel/core@7.26.0)(@types/node@22.10.1)(styled-components@6.1.13)(webpack@5.97.1) @@ -1228,34 +1237,17 @@ packages: prettier: 2.8.8 dev: true - /@clack/core@0.3.4: - resolution: {integrity: sha512-H4hxZDXgHtWTwV3RAVenqcC4VbJZNegbBjlPvzOzCouXtS2y3sDvlO3IsbrPNWuLWPPlYVYPghQdSF64683Ldw==} + /@clack/core@0.4.0: + resolution: {integrity: sha512-YJCYBsyJfNDaTbvDUVSJ3SgSuPrcujarRgkJ5NLjexDZKvaOiVVJvAQYx8lIgG0qRT8ff0fPgqyBCVivanIZ+A==} dependencies: picocolors: 1.1.1 sisteransi: 1.0.5 dev: false - /@clack/core@0.3.5: - resolution: {integrity: sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==} + /@clack/prompts@0.9.0: + resolution: {integrity: sha512-nGsytiExgUr4FL0pR/LeqxA28nz3E0cW7eLTSh3Iod9TGrbBt8Y7BHbV3mmkNC4G0evdYyQ3ZsbiBkk7ektArA==} dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 - dev: false - - /@clack/prompts@0.7.0: - resolution: {integrity: sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==} - dependencies: - '@clack/core': 0.3.4 - picocolors: 1.1.1 - sisteransi: 1.0.5 - dev: false - bundledDependencies: - - is-unicode-supported - - /@clack/prompts@0.8.2: - resolution: {integrity: sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ==} - dependencies: - '@clack/core': 0.3.5 + '@clack/core': 0.4.0 picocolors: 1.1.1 sisteransi: 1.0.5 dev: false @@ -7590,6 +7582,11 @@ packages: which-typed-array: 1.1.15 dev: true + /is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + dev: false + /is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} diff --git a/src/fishkit/npm.ts b/src/fishkit/npm.ts index 8b76cb9..6e781bb 100644 --- a/src/fishkit/npm.ts +++ b/src/fishkit/npm.ts @@ -48,15 +48,12 @@ export const installWithNpmClient = ({ cwd?: string; }): void => { const { NODE_ENV: _, ...env } = process.env; - const args = npmClient === 'yarn' ? [] : ['install']; - const npm = spawnSync(npmClient, args, { stdio: 'inherit', cwd, env, }); - if (npm.error) { throw npm.error; }