From 22c5373495bfc0bd0b3bc17ba4df43d67819cdc3 Mon Sep 17 00:00:00 2001 From: Kengo Watanabe Date: Sat, 24 Aug 2024 22:04:15 +0900 Subject: [PATCH 1/4] chore: add `@clack/core`, `sisteransi` to devDependencies --- package.json | 2 ++ yarn.lock | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/package.json b/package.json index aeb5082..c1b5ddf 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "registry": "https://registry.npmjs.org" }, "devDependencies": { + "@clack/core": "^0.3.4", "@hono/eslint-config": "^0.0.2", "@inquirer/confirm": "^3.1.0", "@inquirer/input": "^2.1.0", @@ -42,6 +43,7 @@ "nanospinner": "^1.1.0", "np": "^7.6.3", "prettier": "^3.3.3", + "sisteransi": "^1.0.5", "tsx": "^4.7.1", "typescript": "^5.3.3", "vitest": "^0.34.6" diff --git a/yarn.lock b/yarn.lock index 2c46e72..7c4265a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30,6 +30,14 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@clack/core@^0.3.4": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@clack/core/-/core-0.3.4.tgz#375e82fc8fe46650b37cab2f2ea8752c6b7f0450" + integrity sha512-H4hxZDXgHtWTwV3RAVenqcC4VbJZNegbBjlPvzOzCouXtS2y3sDvlO3IsbrPNWuLWPPlYVYPghQdSF64683Ldw== + dependencies: + picocolors "^1.0.0" + sisteransi "^1.0.5" + "@esbuild/aix-ppc64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f" @@ -4230,6 +4238,11 @@ signal-exit@^4.1.0: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" From 8fe7e1c91243df2e172ce805e728201b1e6ebe98 Mon Sep 17 00:00:00 2001 From: Kengo Watanabe Date: Sun, 8 Sep 2024 11:50:05 +0900 Subject: [PATCH 2/4] chore: use `picocolors` instead of `chalk` --- package.json | 2 +- src/hooks/dependencies.ts | 4 ++-- src/index.ts | 10 +++++----- yarn.lock | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index c492259..62d2ed0 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@inquirer/select": "^2.2.0", "@types/node": "^18.11.18", "@types/yargs-parser": "^21.0.0", - "chalk": "^5.3.0", "commander": "^12.1.0", "esbuild": "^0.16.17", "eslint": "^8.55.0", @@ -42,6 +41,7 @@ "giget": "^1.2.3", "nanospinner": "^1.1.0", "np": "^7.6.3", + "picocolors": "^1.1.0", "prettier": "^3.3.3", "sisteransi": "^1.0.5", "tsx": "^4.7.1", diff --git a/src/hooks/dependencies.ts b/src/hooks/dependencies.ts index 6141aeb..ef72514 100644 --- a/src/hooks/dependencies.ts +++ b/src/hooks/dependencies.ts @@ -3,9 +3,9 @@ import type { EventEmitter } from 'node:events' import { chdir, exit } from 'node:process' import confirm from '@inquirer/confirm' import select from '@inquirer/select' -import chalk from 'chalk' import { execa } from 'execa' import { createSpinner } from 'nanospinner' +import color from 'picocolors' import { projectDependenciesHook } from '../hook' type PackageManager = 'npm' | 'bun' | 'pnpm' | 'yarn' @@ -89,7 +89,7 @@ const registerInstallationHook = ( spinner.success() } else { spinner.stop({ - mark: chalk.red('×'), + mark: color.red('×'), text: 'Failed to install project dependencies', }) exit(procExit) diff --git a/src/index.ts b/src/index.ts index 46942f3..2b856eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,10 +4,10 @@ import path from 'node:path' import confirm from '@inquirer/confirm' import input from '@inquirer/input' import select from '@inquirer/select' -import chalk from 'chalk' import { Option, program, type Command } from 'commander' import { downloadTemplate } from 'giget' import { createSpinner } from 'nanospinner' +import color from 'picocolors' import { version } from '../package.json' import { projectDependenciesHook } from './hook' import { afterCreateHook } from './hooks/after-create' @@ -87,7 +87,7 @@ async function main( options: ArgOptions, command: Command, ) { - console.log(chalk.gray(`${command.name()} version ${command.version()}`)) + console.log(color.gray(`${command.name()} version ${command.version()}`)) const { install, pm, offline, template: templateArg } = options @@ -95,7 +95,7 @@ async function main( if (targetDir) { target = targetDir console.log( - `${chalk.bold(`${chalk.green('✔')} Using target directory`)} … ${target}`, + `${color.bold(`${color.green('✔')} Using target directory`)} … ${target}`, ) } else { const answer = await input({ @@ -200,8 +200,8 @@ async function main( } emitter.on('completed', () => { - console.log(chalk.green(`🎉 ${chalk.bold('Copied project files')}`)) - console.log(chalk.gray('Get started with:'), chalk.bold(`cd ${target}`)) + console.log(color.green(`🎉 ${color.bold('Copied project files')}`)) + console.log(color.gray('Get started with:'), color.bold(`cd ${target}`)) }) } diff --git a/yarn.lock b/yarn.lock index 7c4265a..15516f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1249,11 +1249,6 @@ chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" - integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== - chardet@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" @@ -3829,6 +3824,11 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== +picocolors@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.0.tgz#5358b76a78cde483ba5cef6a9dc9671440b27d59" + integrity sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw== + picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" From 7a812da58a6727f044d7cec879fd1b5e4e209e7e Mon Sep 17 00:00:00 2001 From: Kengo Watanabe Date: Sun, 8 Sep 2024 12:44:56 +0900 Subject: [PATCH 3/4] feat: `./src/prompts.ts` --- README.md | 2 + package.json | 5 +- src/hooks/dependencies.ts | 21 +-- src/index.ts | 22 ++- src/prompts.ts | 332 ++++++++++++++++++++++++++++++++++++++ yarn.lock | 113 ++----------- 6 files changed, 361 insertions(+), 134 deletions(-) create mode 100644 src/prompts.ts diff --git a/README.md b/README.md index 9152e75..f6f86e4 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,5 @@ MIT This project source code is based on **Create Solid** MIT Licensed. + +`./src/prompts.ts` is based on [`@clack/prompts`](https://github.com/bombshell-dev/clack/tree/45ee73b/packages/prompts) MIT Licensed. diff --git a/package.json b/package.json index 62d2ed0..049a488 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,6 @@ "devDependencies": { "@clack/core": "^0.3.4", "@hono/eslint-config": "^0.0.2", - "@inquirer/confirm": "^3.1.0", - "@inquirer/input": "^2.1.0", - "@inquirer/select": "^2.2.0", "@types/node": "^18.11.18", "@types/yargs-parser": "^21.0.0", "commander": "^12.1.0", @@ -39,7 +36,7 @@ "eslint": "^8.55.0", "execa": "^8.0.1", "giget": "^1.2.3", - "nanospinner": "^1.1.0", + "is-unicode-supported": "^2.0.0", "np": "^7.6.3", "picocolors": "^1.1.0", "prettier": "^3.3.3", diff --git a/src/hooks/dependencies.ts b/src/hooks/dependencies.ts index ef72514..5694b19 100644 --- a/src/hooks/dependencies.ts +++ b/src/hooks/dependencies.ts @@ -1,12 +1,10 @@ import { exec } from 'node:child_process' import type { EventEmitter } from 'node:events' import { chdir, exit } from 'node:process' -import confirm from '@inquirer/confirm' -import select from '@inquirer/select' import { execa } from 'execa' -import { createSpinner } from 'nanospinner' import color from 'picocolors' import { projectDependenciesHook } from '../hook' +import { confirm, select, spinner } from '../prompts' type PackageManager = 'npm' | 'bun' | 'pnpm' | 'yarn' @@ -50,7 +48,7 @@ const registerInstallationHook = ( } else { installDeps = await confirm({ message: 'Do you want to install project dependencies?', - default: true, + initialValue: true, }) } @@ -63,11 +61,10 @@ const registerInstallationHook = ( } else { packageManager = await select({ message: 'Which package manager do you want to use?', - choices: installedPackageManagerNames.map((template: string) => ({ - title: template, + options: installedPackageManagerNames.map((template: string) => ({ value: template, })), - default: currentPackageManager, + initialValue: currentPackageManager, }) } @@ -78,7 +75,8 @@ const registerInstallationHook = ( exit(1) } - const spinner = createSpinner('Installing project dependencies').start() + const s = spinner() + s.start('Installing project dependencies') const proc = exec(knownPackageManagers[packageManager]) const procExit: number = await new Promise((res) => { @@ -86,12 +84,9 @@ const registerInstallationHook = ( }) if (procExit === 0) { - spinner.success() + s.stop('Installed successfully.') } else { - spinner.stop({ - mark: color.red('×'), - text: 'Failed to install project dependencies', - }) + s.stop(`${color.red('×')} Failed to install project dependencies`) exit(procExit) } diff --git a/src/index.ts b/src/index.ts index 2b856eb..8f1923b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,8 @@ import EventEmitter from 'node:events' import fs from 'node:fs' import path from 'node:path' -import confirm from '@inquirer/confirm' -import input from '@inquirer/input' -import select from '@inquirer/select' import { Option, program, type Command } from 'commander' import { downloadTemplate } from 'giget' -import { createSpinner } from 'nanospinner' import color from 'picocolors' import { version } from '../package.json' import { projectDependenciesHook } from './hook' @@ -16,6 +12,7 @@ import { knownPackageManagerNames, registerInstallationHook, } from './hooks/dependencies' +import { confirm, select, spinner, text } from './prompts' const directoryName = 'templates' const config = { @@ -98,9 +95,10 @@ async function main( `${color.bold(`${color.green('✔')} Using target directory`)} … ${target}`, ) } else { - const answer = await input({ + const answer = await text({ message: 'Target directory', - default: 'my-app', + placeholder: 'my-app', + defaultValue: 'my-app', }) target = answer } @@ -115,13 +113,10 @@ async function main( const templateName = templateArg || (await select({ - loop: true, message: 'Which template do you want to use?', - choices: templates.map((template) => ({ - title: template, + options: templates.map((template) => ({ value: template, })), - default: 0, })) if (!templateName) { @@ -136,7 +131,7 @@ async function main( if (fs.readdirSync(target).length > 0) { const response = await confirm({ message: 'Directory not empty. Continue?', - default: false, + initialValue: false, }) if (!response) { process.exit(1) @@ -159,7 +154,8 @@ async function main( }), ) - const spinner = createSpinner('Cloning the template').start() + const s = spinner() + s.start('Cloning the template') await downloadTemplate( `gh:${config.user}/${config.repository}/${config.directory}/${templateName}#${config.ref}`, @@ -169,7 +165,7 @@ async function main( force: true, }, ).then(() => { - spinner.success() + s.stop('Cloned.') emitter.emit('dependencies') }) diff --git a/src/prompts.ts b/src/prompts.ts new file mode 100644 index 0000000..afe913a --- /dev/null +++ b/src/prompts.ts @@ -0,0 +1,332 @@ +import type { State } from '@clack/core' +import { + block, + ConfirmPrompt, + isCancel, + SelectPrompt, + TextPrompt, +} from '@clack/core' +import isUnicodeSupported from 'is-unicode-supported' +import color from 'picocolors' +import { cursor, erase } from 'sisteransi' + +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 = s('│', '|') +const S_BAR_END = s('└', '—') + +const S_RADIO_ACTIVE = s('●', '>') +const S_RADIO_INACTIVE = s('○', ' ') + +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) + } +} + +function onCancel() { + console.log('\nOperation canceled.') +} + +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) + }) +} + +interface TextOptions { + message: string + placeholder?: string + defaultValue?: string + initialValue?: string + validate?: (value: string) => string | void +} +export const text = (opts: TextOptions) => { + 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() + .then((result) => { + if (isCancel(result)) { + onCancel() + process.exit(1) + } + return result + }) +} + +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() + .then((result) => { + if (isCancel(result)) { + onCancel() + process.exit(1) + } + return result + }) as Promise +} + +type Primitive = Readonly + +type Option = Value extends Primitive + ? { value: Value; label?: string; hint?: string } + : { value: Value; label: string; hint?: string } + +interface SelectOptions { + message: string + options: Option[] + 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() + .then((result) => { + if (isCancel(result)) { + onCancel() + process.exit(1) + } + return result as Value + }) +} + +export const spinner = () => { + 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, + } +} diff --git a/yarn.lock b/yarn.lock index 15516f1..373e10c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -442,58 +442,6 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917" integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw== -"@inquirer/confirm@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-3.1.0.tgz#526cb71ceab28ba827ea287aa81c969e437017b6" - integrity sha512-nH5mxoTEoqk6WpoBz80GMpDSm9jH5V9AF8n+JZAZfMzd9gHeEG9w1o3KawPRR72lfzpP+QxBHLkOKLEApwhDiQ== - dependencies: - "@inquirer/core" "^7.1.0" - "@inquirer/type" "^1.2.1" - -"@inquirer/core@^7.1.0": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-7.1.0.tgz#fb78738fd6624de50f027c08d6f24298b72a402b" - integrity sha512-FRCiDiU54XHt5B/D8hX4twwZuzSP244ANHbu3R7CAsJfiv1dUOz24ePBgCZjygEjDUi6BWIJuk4eWLKJ7LATUw== - dependencies: - "@inquirer/type" "^1.2.1" - "@types/mute-stream" "^0.0.4" - "@types/node" "^20.11.26" - "@types/wrap-ansi" "^3.0.0" - ansi-escapes "^4.3.2" - chalk "^4.1.2" - cli-spinners "^2.9.2" - cli-width "^4.1.0" - figures "^3.2.0" - mute-stream "^1.0.0" - run-async "^3.0.0" - signal-exit "^4.1.0" - strip-ansi "^6.0.1" - wrap-ansi "^6.2.0" - -"@inquirer/input@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@inquirer/input/-/input-2.1.0.tgz#5ff7028245acd9fa9a25e8a04d71611f76bd82ba" - integrity sha512-o57pST+xxZfGww1h4G7ISiX37KlLcajhKgKGG7/h8J6ClWtsyqwMv1el9Ds/4geuYN/HcPj0MyX9gTEO62UpcA== - dependencies: - "@inquirer/core" "^7.1.0" - "@inquirer/type" "^1.2.1" - -"@inquirer/select@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@inquirer/select/-/select-2.2.0.tgz#f0a6c523f24a7eefd3b912a2a473a2dc82e561d9" - integrity sha512-Pml3DhVM1LnfqasUMIzaBtw+s5UjM5k0bzDeWrWOgqAMWe16AOg0DcAhXHf+SYbnj2CFBeP/TvkvedL4aAEWww== - dependencies: - "@inquirer/core" "^7.1.0" - "@inquirer/type" "^1.2.1" - ansi-escapes "^4.3.2" - chalk "^4.1.2" - figures "^3.2.0" - -"@inquirer/type@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-1.2.1.tgz#fbc7ab3a2e5050d0c150642d5e8f5e88faa066b8" - integrity sha512-xwMfkPAxeo8Ji/IxfUSqzRi0/+F2GIqJmpc5/thelgMGsjNZcjDDRBO9TLXT1s/hdx/mK5QbVIvgoLIFgXhTMQ== - "@jest/schemas@^29.6.3": version "29.6.3" resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" @@ -697,14 +645,7 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== -"@types/mute-stream@^0.0.4": - version "0.0.4" - resolved "https://registry.yarnpkg.com/@types/mute-stream/-/mute-stream-0.0.4.tgz#77208e56a08767af6c5e1237be8888e2f255c478" - integrity sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow== - dependencies: - "@types/node" "*" - -"@types/node@*", "@types/node@^20.11.26": +"@types/node@*": version "20.11.30" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.30.tgz#9c33467fc23167a347e73834f788f4b9f399d66f" integrity sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw== @@ -740,11 +681,6 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== -"@types/wrap-ansi@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz#18b97a972f94f60a679fd5c796d96421b9abb9fd" - integrity sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g== - "@types/yargs-parser@^21.0.0": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -929,7 +865,7 @@ ansi-escapes@^3.0.0, ansi-escapes@^3.2.0: resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== -ansi-escapes@^4.2.1, ansi-escapes@^4.3.2: +ansi-escapes@^4.2.1: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== @@ -1241,7 +1177,7 @@ chalk@^2.4.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: +chalk@^4.0.0, chalk@^4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -1302,11 +1238,6 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" -cli-spinners@^2.9.2: - version "2.9.2" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" - integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== - cli-truncate@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574" @@ -1325,11 +1256,6 @@ cli-width@^3.0.0: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== -cli-width@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.1.0.tgz#42daac41d3c254ef38ad8ac037672130173691c5" - integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== - clone-response@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" @@ -2110,7 +2036,7 @@ figures@^2.0.0: dependencies: escape-string-regexp "^1.0.5" -figures@^3.0.0, figures@^3.2.0: +figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== @@ -2887,6 +2813,11 @@ is-unicode-supported@^0.1.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +is-unicode-supported@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.0.0.tgz#fdf32df9ae98ff6ab2cedc155a5a6e895701c451" + integrity sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q== + is-url-superb@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/is-url-superb/-/is-url-superb-4.0.0.tgz#b54d1d2499bb16792748ac967aa3ecb41a33a8c2" @@ -3349,23 +3280,11 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -mute-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-1.0.0.tgz#e31bd9fe62f0aed23520aa4324ea6671531e013e" - integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA== - nanoid@^3.3.7: version "3.3.7" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== -nanospinner@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/nanospinner/-/nanospinner-1.1.0.tgz#d17ff621cb1784b0a206b400da88a0ef6db39b97" - integrity sha512-yFvNYMig4AthKYfHFl1sLj7B2nkHL4lzdig4osvl9/LdGbXwrdFRoqBS98gsEsOakr0yH+r5NZ/1Y9gdVB8trA== - dependencies: - picocolors "^1.0.0" - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -4107,11 +4026,6 @@ run-async@^2.2.0, run-async@^2.4.0: resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== -run-async@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-3.0.0.tgz#42a432f6d76c689522058984384df28be379daad" - integrity sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q== - run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -4867,15 +4781,6 @@ wrap-ansi@^3.0.1: string-width "^2.1.1" strip-ansi "^4.0.0" -wrap-ansi@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" - integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" From 22ef48db369db4d039b9720da5014d1e8953a26e Mon Sep 17 00:00:00 2001 From: Kengo Watanabe Date: Sun, 8 Sep 2024 14:59:42 +0900 Subject: [PATCH 4/4] feat: change style --- src/hooks/dependencies.ts | 4 +- src/prompts.ts | 83 ++++++++++++--------------------------- 2 files changed, 27 insertions(+), 60 deletions(-) diff --git a/src/hooks/dependencies.ts b/src/hooks/dependencies.ts index 5694b19..cdf9424 100644 --- a/src/hooks/dependencies.ts +++ b/src/hooks/dependencies.ts @@ -2,7 +2,6 @@ import { exec } from 'node:child_process' import type { EventEmitter } from 'node:events' import { chdir, exit } from 'node:process' import { execa } from 'execa' -import color from 'picocolors' import { projectDependenciesHook } from '../hook' import { confirm, select, spinner } from '../prompts' @@ -78,7 +77,6 @@ const registerInstallationHook = ( const s = spinner() s.start('Installing project dependencies') const proc = exec(knownPackageManagers[packageManager]) - const procExit: number = await new Promise((res) => { proc.on('exit', (code) => res(code == null ? 0xff : code)) }) @@ -86,7 +84,7 @@ const registerInstallationHook = ( if (procExit === 0) { s.stop('Installed successfully.') } else { - s.stop(`${color.red('×')} Failed to install project dependencies`) + s.stop('Failed to install project dependencies', 1) exit(procExit) } diff --git a/src/prompts.ts b/src/prompts.ts index afe913a..535ba47 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -1,4 +1,3 @@ -import type { State } from '@clack/core' import { block, ConfirmPrompt, @@ -12,38 +11,22 @@ import { cursor, erase } from 'sisteransi' 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 = s('│', '|') -const S_BAR_END = s('└', '—') +const S_STEP = color.green('?') const S_RADIO_ACTIVE = s('●', '>') const S_RADIO_INACTIVE = s('○', ' ') -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) - } -} +const S_SUCCESS = s('✔', 'o') +const S_CANCEL = s('✖', 'x') +const S_ERROR = s('⚠️', '!') function onCancel() { - console.log('\nOperation canceled.') + console.log(color.red(`\n${S_CANCEL} Operation canceled.`)) } interface LimitOptionsParams { options: TOption[] - maxItems: number | undefined cursor: number style: (option: TOption, active: boolean) => string } @@ -53,10 +36,7 @@ const limitOptions = ( ): 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)) + const maxItems = 7 let slidingWindowLocation = 0 if (cursor >= slidingWindowLocation + maxItems - 3) { @@ -99,7 +79,7 @@ export const text = (opts: TextOptions) => { defaultValue: opts.defaultValue, initialValue: opts.initialValue, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n` + const title = `${S_STEP} ${opts.message}` const placeholder = opts.placeholder ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1)) @@ -108,17 +88,13 @@ export const text = (opts: TextOptions) => { 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` + return `${title.trim()} ${value}\n${color.yellow(this.error)}` case 'submit': - return `${title}${color.gray(S_BAR)} ${color.dim(this.value || opts.placeholder)}` + return `${title} ${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) : ''}` + return `${title} ${color.gray(color.strikethrough(this.value ?? ''))}` default: - return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n` + return `${title} ${value}` } }, }) @@ -146,18 +122,16 @@ export const confirm = (opts: ConfirmOptions) => { inactive, initialValue: opts.initialValue ?? true, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n` + const title = `${S_STEP} ${opts.message}` const value = this.value ? active : inactive switch (this.state) { case 'submit': - return `${title}${color.gray(S_BAR)} ${color.dim(value)}` + return `${title} ${color.dim(value)}` case 'cancel': - return `${title}${color.gray(S_BAR)} ${color.strikethrough( - color.dim(value), - )}\n${color.gray(S_BAR)}` + return `${title} ${color.strikethrough(color.dim(value))}` default: { - return `${title}${color.cyan(S_BAR)} ${ + return `${title} ${ this.value ? `${color.green(S_RADIO_ACTIVE)} ${active}` : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}` @@ -165,7 +139,7 @@ export const confirm = (opts: ConfirmOptions) => { !this.value ? `${color.green(S_RADIO_ACTIVE)} ${inactive}` : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}` - }\n${color.cyan(S_BAR_END)}\n` + }` } } }, @@ -190,7 +164,6 @@ interface SelectOptions { message: string options: Option[] initialValue?: Value - maxItems?: number } export const select = (opts: SelectOptions) => { @@ -217,23 +190,19 @@ export const select = (opts: SelectOptions) => { options: opts.options, initialValue: opts.initialValue, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n` + const title = `${S_STEP} ${opts.message}` switch (this.state) { case 'submit': - return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor], 'selected')}` + return `${title} ${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)}` + return `${title} ${opt(this.options[this.cursor], 'cancelled')}` default: { - return `${title}${color.cyan(S_BAR)} ${limitOptions({ + return `${title}\n${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` + }).join('\n')}` } } }, @@ -259,7 +228,8 @@ export const spinner = () => { const handleExit = (code: number) => { const msg = code > 1 ? 'Something went wrong' : 'Canceled' - if (isSpinnerActive) stop(msg, code) + // Due to commander, there may be cancellations with a `0` code. + if (isSpinnerActive) stop(msg, code !== 0 ? code : 1) } const errorEventHandler = () => handleExit(2) @@ -288,7 +258,6 @@ export const spinner = () => { isSpinnerActive = true unblock = block() _message = msg.replace(/\.+$/, '') - process.stdout.write(`${color.gray(S_BAR)}\n`) let frameIndex = 0 let dotsTimer = 0 registerHooks() @@ -309,10 +278,10 @@ export const spinner = () => { clearInterval(loop) const step = code === 0 - ? color.green(S_STEP_SUBMIT) + ? color.green(S_SUCCESS) : code === 1 - ? color.red(S_STEP_CANCEL) - : color.red(S_STEP_ERROR) + ? color.red(S_CANCEL) + : color.red(S_ERROR) process.stdout.write(cursor.move(-999, 0)) process.stdout.write(erase.down(1)) process.stdout.write(`${step} ${_message}\n`)