diff --git a/package.json b/package.json index e6c344a..5aed253 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@insforge/cli", - "version": "0.1.49", + "version": "0.1.50", "description": "InsForge CLI - Command line tool for InsForge platform", "type": "module", "bin": { diff --git a/src/commands/create.ts b/src/commands/create.ts index 6b75f54..0ea9cd6 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -5,6 +5,7 @@ import { promisify } from 'node:util'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as clack from '@clack/prompts'; +import * as prompts from '../lib/prompts.js'; import { listOrganizations, createProject, @@ -181,15 +182,15 @@ export function registerCreateCommand(program: Command): void { if (json) { throw new CLIError('Multiple organizations found. Specify --org-id.'); } - const selected = await clack.select({ + const selected = await prompts.select({ message: 'Select an organization:', options: orgs.map((o) => ({ value: o.id, label: o.name, })), }); - if (clack.isCancel(selected)) process.exit(0); - orgId = selected as string; + if (prompts.isCancel(selected)) process.exit(0); + orgId = selected; } } @@ -203,13 +204,13 @@ export function registerCreateCommand(program: Command): void { if (!projectName) { if (json) throw new CLIError('--name is required in JSON mode.'); const defaultName = getDefaultProjectName(); - const name = await clack.text({ + const name = await prompts.text({ message: 'Project name:', ...(defaultName ? { initialValue: defaultName } : {}), validate: (v) => (v.length >= 2 ? undefined : 'Name must be at least 2 characters'), }); - if (clack.isCancel(name)) process.exit(0); - projectName = name as string; + if (prompts.isCancel(name)) process.exit(0); + projectName = name; } // Sanitize project name to prevent path traversal @@ -228,23 +229,23 @@ export function registerCreateCommand(program: Command): void { if (json) { template = 'empty'; } else { - const approach = await clack.select({ + const approach = await prompts.select({ message: 'How would you like to start?', options: [ { value: 'blank', label: 'Blank project', hint: 'Start from scratch with .env.local ready' }, { value: 'template', label: 'Start from a template', hint: 'Pre-built starter apps' }, ], }); - if (clack.isCancel(approach)) process.exit(0); + if (prompts.isCancel(approach)) process.exit(0); captureEvent(orgId, 'create_approach_selected', { - approach: approach as string, + approach, }); if (approach === 'blank') { template = 'empty'; } else { - const selected = await clack.select({ + const selected = await prompts.select({ message: 'Choose a starter template:', options: [ { value: 'react', label: 'Web app template with React' }, @@ -255,8 +256,8 @@ export function registerCreateCommand(program: Command): void { { value: 'todo', label: 'Todo app with Next.js' }, ], }); - if (clack.isCancel(selected)) process.exit(0); - template = selected as string; + if (prompts.isCancel(selected)) process.exit(0); + template = selected; } } } @@ -275,7 +276,7 @@ export function registerCreateCommand(program: Command): void { if (hasTemplate) { dirName = projectName; if (!json) { - const inputDir = await clack.text({ + const inputDir = await prompts.text({ message: 'Directory name:', initialValue: projectName, validate: (v) => { @@ -285,8 +286,8 @@ export function registerCreateCommand(program: Command): void { return undefined; }, }); - if (clack.isCancel(inputDir)) process.exit(0); - dirName = path.basename(inputDir as string).replace(/[^a-zA-Z0-9._-]/g, '-'); + if (prompts.isCancel(inputDir)) process.exit(0); + dirName = path.basename(inputDir).replace(/[^a-zA-Z0-9._-]/g, '-'); } // Validate normalized dirName @@ -396,11 +397,11 @@ export function registerCreateCommand(program: Command): void { // 9. Offer to deploy (template projects, interactive mode only) let liveUrl: string | null = null; if (templateDownloaded && !json) { - const shouldDeploy = await clack.confirm({ + const shouldDeploy = await prompts.confirm({ message: 'Would you like to deploy now?', }); - if (!clack.isCancel(shouldDeploy) && shouldDeploy) { + if (!prompts.isCancel(shouldDeploy) && shouldDeploy) { try { // Read env vars from .env.local or .env to pass to deployment const envVars = await readEnvFile(process.cwd()); diff --git a/src/commands/deployments/cancel.ts b/src/commands/deployments/cancel.ts index 621cdd5..acba108 100644 --- a/src/commands/deployments/cancel.ts +++ b/src/commands/deployments/cancel.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import * as clack from '@clack/prompts'; +import * as prompts from '../../lib/prompts.js'; import { ossFetch } from '../../lib/api/oss.js'; import { requireAuth } from '../../lib/credentials.js'; import { getProjectConfig } from '../../lib/config.js'; @@ -17,10 +17,10 @@ export function registerDeploymentsCancelCommand(deploymentsCmd: Command): void if (!getProjectConfig()) throw new ProjectNotLinkedError(); if (!yes && !json) { - const confirmed = await clack.confirm({ + const confirmed = await prompts.confirm({ message: `Cancel deployment ${id}?`, }); - if (clack.isCancel(confirmed) || !confirmed) process.exit(0); + if (prompts.isCancel(confirmed) || !confirmed) process.exit(0); } const res = await ossFetch(`/api/deployments/${id}/cancel`, { method: 'POST' }); diff --git a/src/commands/diagnose/index.ts b/src/commands/diagnose/index.ts index 2cbb4d9..c8f1101 100644 --- a/src/commands/diagnose/index.ts +++ b/src/commands/diagnose/index.ts @@ -1,6 +1,7 @@ import type { Command } from 'commander'; import * as os from 'node:os'; import * as clack from '@clack/prompts'; +import * as prompts from '../../lib/prompts.js'; import { requireAuth } from '../../lib/credentials.js'; import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../../lib/errors.js'; import { getProjectConfig, FAKE_PROJECT_ID } from '../../lib/config.js'; @@ -255,7 +256,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { // Optional rating prompt (interactive only) if (!json && sessionId) { - const ratingChoice = await clack.select({ + const ratingChoice = await prompts.select({ message: 'Was this analysis helpful?', options: [ { value: 'skip', label: 'Skip', hint: 'no rating' }, @@ -265,7 +266,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { ], }); - if (!clack.isCancel(ratingChoice) && ratingChoice !== 'skip') { + if (!prompts.isCancel(ratingChoice) && ratingChoice !== 'skip') { try { await rateDiagnosticSession( sessionId, diff --git a/src/commands/functions/delete.ts b/src/commands/functions/delete.ts index ff5d25a..9fdeb16 100644 --- a/src/commands/functions/delete.ts +++ b/src/commands/functions/delete.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander'; import * as clack from '@clack/prompts'; +import * as prompts from '../../lib/prompts.js'; import { ossFetch } from '../../lib/api/oss.js'; import { requireAuth } from '../../lib/credentials.js'; import { handleError, getRootOpts } from '../../lib/errors.js'; @@ -17,10 +18,10 @@ export function registerFunctionsDeleteCommand(functionsCmd: Command): void { await requireAuth(); if (!yes && !json) { - const confirmed = await clack.confirm({ + const confirmed = await prompts.confirm({ message: `Delete function "${slug}"? This cannot be undone.`, }); - if (clack.isCancel(confirmed) || !confirmed) { + if (prompts.isCancel(confirmed) || !confirmed) { clack.log.info('Cancelled.'); return; } diff --git a/src/commands/login.ts b/src/commands/login.ts index b624e33..8006001 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander'; import * as clack from '@clack/prompts'; +import * as prompts from '../lib/prompts.js'; import { saveCredentials } from '../lib/config.js'; import { login as platformLogin } from '../lib/api/platform.js'; import { performOAuthLogin } from '../lib/auth.js'; @@ -37,23 +38,23 @@ async function loginWithEmail(json: boolean, apiUrl?: string): Promise { const email = json ? process.env.INSFORGE_EMAIL - : await clack.text({ + : await prompts.text({ message: 'Email:', validate: (v) => (v.includes('@') ? undefined : 'Please enter a valid email'), }); - if (clack.isCancel(email)) { + if (prompts.isCancel(email)) { clack.cancel('Login cancelled.'); throw new Error('cancelled'); } const password = json ? process.env.INSFORGE_PASSWORD - : await clack.password({ + : await prompts.password({ message: 'Password:', }); - if (clack.isCancel(password)) { + if (prompts.isCancel(password)) { clack.cancel('Login cancelled.'); throw new Error('cancelled'); } diff --git a/src/commands/projects/link.ts b/src/commands/projects/link.ts index b7a1d15..91fd4f3 100644 --- a/src/commands/projects/link.ts +++ b/src/commands/projects/link.ts @@ -5,6 +5,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as clack from '@clack/prompts'; import pc from 'picocolors'; +import * as prompts from '../../lib/prompts.js'; import { listOrganizations, listProjects, @@ -108,15 +109,15 @@ export function registerProjectLinkCommand(program: Command): void { if (json) { throw new CLIError('Multiple organizations found. Specify --org-id.'); } - const selected = await clack.select({ + const selected = await prompts.select({ message: 'Select an organization:', options: orgs.map((o) => ({ value: o.id, label: o.name, })), }); - if (clack.isCancel(selected)) process.exit(0); - orgId = selected as string; + if (prompts.isCancel(selected)) process.exit(0); + orgId = selected; } } @@ -134,15 +135,15 @@ export function registerProjectLinkCommand(program: Command): void { if (json) { throw new CLIError('Specify --project-id in JSON mode.'); } - const selected = await clack.select({ + const selected = await prompts.select({ message: 'Select a project to link:', options: projects.map((p) => ({ value: p.id, label: `${p.name} (${p.region}, ${p.status})`, })), }); - if (clack.isCancel(selected)) process.exit(0); - projectId = selected as string; + if (prompts.isCancel(selected)) process.exit(0); + projectId = selected; } // Fetch project details and API key @@ -204,7 +205,7 @@ export function registerProjectLinkCommand(program: Command): void { // Ask for directory name let dirName = project.name; if (!json) { - const inputDir = await clack.text({ + const inputDir = await prompts.text({ message: 'Directory name:', initialValue: project.name, validate: (v) => { @@ -214,8 +215,8 @@ export function registerProjectLinkCommand(program: Command): void { return undefined; }, }); - if (clack.isCancel(inputDir)) process.exit(0); - dirName = path.basename(inputDir as string).replace(/[^a-zA-Z0-9._-]/g, '-'); + if (prompts.isCancel(inputDir)) process.exit(0); + dirName = path.basename(inputDir).replace(/[^a-zA-Z0-9._-]/g, '-'); } if (!dirName || dirName === '.' || dirName === '..') { diff --git a/src/commands/projects/list.ts b/src/commands/projects/list.ts index 94f8334..a0a8a17 100644 --- a/src/commands/projects/list.ts +++ b/src/commands/projects/list.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import * as clack from '@clack/prompts'; +import * as prompts from '../../lib/prompts.js'; import { listOrganizations, listProjects } from '../../lib/api/platform.js'; import { getGlobalConfig } from '../../lib/config.js'; import { requireAuth } from '../../lib/credentials.js'; @@ -27,17 +27,17 @@ export function registerProjectsCommands(projectsCmd: Command): void { if (orgs.length === 1) { orgId = orgs[0].id; } else if (!json) { - const selected = await clack.select({ + const selected = await prompts.select({ message: 'Select an organization:', options: orgs.map((o) => ({ value: o.id, label: o.name, })), }); - if (clack.isCancel(selected)) { + if (prompts.isCancel(selected)) { process.exit(0); } - orgId = selected as string; + orgId = selected; } else { throw new CLIError('Multiple organizations found. Specify --org-id.'); } diff --git a/src/commands/schedules/delete.ts b/src/commands/schedules/delete.ts index 378fe8c..577b49d 100644 --- a/src/commands/schedules/delete.ts +++ b/src/commands/schedules/delete.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import * as clack from '@clack/prompts'; +import * as prompts from '../../lib/prompts.js'; import { ossFetch } from '../../lib/api/oss.js'; import { requireAuth } from '../../lib/credentials.js'; import { handleError, getRootOpts } from '../../lib/errors.js'; @@ -15,10 +15,10 @@ export function registerSchedulesDeleteCommand(schedulesCmd: Command): void { await requireAuth(); if (!yes && !json) { - const confirm = await clack.confirm({ + const confirm = await prompts.confirm({ message: `Delete schedule "${id}"? This cannot be undone.`, }); - if (!confirm || clack.isCancel(confirm)) { + if (prompts.isCancel(confirm) || !confirm) { process.exit(0); } } diff --git a/src/commands/secrets/delete.ts b/src/commands/secrets/delete.ts index 2a59468..e9520bc 100644 --- a/src/commands/secrets/delete.ts +++ b/src/commands/secrets/delete.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import * as clack from '@clack/prompts'; +import * as prompts from '../../lib/prompts.js'; import { ossFetch } from '../../lib/api/oss.js'; import { requireAuth } from '../../lib/credentials.js'; import { handleError, getRootOpts } from '../../lib/errors.js'; @@ -16,10 +16,10 @@ export function registerSecretsDeleteCommand(secretsCmd: Command): void { await requireAuth(); if (!yes && !json) { - const confirm = await clack.confirm({ + const confirm = await prompts.confirm({ message: `Delete secret "${key}"? This cannot be undone.`, }); - if (!confirm || clack.isCancel(confirm)) { + if (prompts.isCancel(confirm) || !confirm) { process.exit(0); } } diff --git a/src/commands/storage/delete-bucket.ts b/src/commands/storage/delete-bucket.ts index fd2f7b8..f00cc24 100644 --- a/src/commands/storage/delete-bucket.ts +++ b/src/commands/storage/delete-bucket.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import * as clack from '@clack/prompts'; +import * as prompts from '../../lib/prompts.js'; import { ossFetch } from '../../lib/api/oss.js'; import { requireAuth } from '../../lib/credentials.js'; import { handleError, getRootOpts } from '../../lib/errors.js'; @@ -15,10 +15,10 @@ export function registerStorageDeleteBucketCommand(storageCmd: Command): void { await requireAuth(); if (!yes && !json) { - const confirm = await clack.confirm({ + const confirm = await prompts.confirm({ message: `Delete bucket "${name}" and all its objects? This cannot be undone.`, }); - if (!confirm || clack.isCancel(confirm)) { + if (prompts.isCancel(confirm) || !confirm) { process.exit(0); } } diff --git a/src/index.ts b/src/index.ts index c2a7cee..1ac0f57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import * as clack from '@clack/prompts'; +import * as prompts from './lib/prompts.js'; import { getCredentials, getProjectConfig } from './lib/config.js'; import { registerLoginCommand } from './commands/login.js'; import { registerLogoutCommand } from './commands/logout.js'; @@ -242,12 +243,12 @@ async function showInteractiveMenu(): Promise { { value: 'help', label: 'Show all commands' }, ); - const action = await clack.select({ + const action = await prompts.select({ message: 'What would you like to do?', options, }); - if (clack.isCancel(action)) { + if (prompts.isCancel(action)) { clack.cancel('Bye!'); process.exit(0); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index bf93248..c141897 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,6 +2,8 @@ import { createServer } from 'node:http'; import { randomBytes, createHash } from 'node:crypto'; import { URL } from 'node:url'; import * as clack from '@clack/prompts'; +import pc from 'picocolors'; +import { isInteractive } from './prompts.js'; import { getGlobalConfig, getPlatformApiUrl, saveCredentials } from './config.js'; import { getProfile } from './api/platform.js'; import { formatFetchError } from './errors.js'; @@ -221,20 +223,27 @@ export async function performOAuthLogin(apiUrl?: string): Promise { @@ -34,8 +35,8 @@ export async function requireAuth(apiUrl?: string, allowOssBypass = true): Promi const msg = err instanceof Error ? err.message : 'Unknown error'; clack.log.error(`Login failed: ${msg}`); - const retry = await clack.confirm({ message: 'Would you like to try again?' }); - if (clack.isCancel(retry) || !retry) { + const retry = await prompts.confirm({ message: 'Would you like to try again?' }); + if (prompts.isCancel(retry) || !retry) { throw new AuthError('Authentication required. Run `npx @insforge/cli login` to authenticate.'); } } diff --git a/src/lib/prompts.test.ts b/src/lib/prompts.test.ts new file mode 100644 index 0000000..0748014 --- /dev/null +++ b/src/lib/prompts.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest'; +import { PassThrough } from 'node:stream'; +import { nonTtyText, nonTtySelect, nonTtyConfirm, isCancel, CANCEL, LineReader } from './prompts.js'; + +function harness() { + const input = new PassThrough(); + const output = new PassThrough(); + const stderr = new PassThrough(); + const reader = new LineReader(input, output); + + const outChunks: string[] = []; + const errChunks: string[] = []; + output.on('data', (c) => outChunks.push(c.toString())); + stderr.on('data', (c) => errChunks.push(c.toString())); + + return { + reader, + stdout: output, + stderr, + write: (line: string) => input.write(line), + end: () => input.end(), + stdout_text: () => outChunks.join(''), + stderr_text: () => errChunks.join(''), + }; +} + +describe('nonTtyText', () => { + it('reads a single line answer', async () => { + const h = harness(); + const p = nonTtyText({ message: 'Name?' }, { reader: h.reader, stderr: h.stderr }); + h.write('alice\n'); + expect(await p).toBe('alice'); + }); + + it('trims whitespace from input', async () => { + const h = harness(); + const p = nonTtyText({ message: 'Name?' }, { reader: h.reader, stderr: h.stderr }); + h.write(' bob \n'); + expect(await p).toBe('bob'); + }); + + it('uses initialValue when input is empty', async () => { + const h = harness(); + const p = nonTtyText({ message: 'Name?', initialValue: 'default' }, { reader: h.reader, stderr: h.stderr }); + h.write('\n'); + expect(await p).toBe('default'); + }); + + it('retries when validate returns an error', async () => { + const h = harness(); + const p = nonTtyText( + { + message: 'Name?', + validate: (v) => (v.length < 3 ? 'too short' : undefined), + }, + { reader: h.reader, stderr: h.stderr }, + ); + h.write('hi\n'); + h.write('hello\n'); + expect(await p).toBe('hello'); + expect(h.stderr_text()).toContain('too short'); + }); + + it('returns CANCEL on stdin EOF', async () => { + const h = harness(); + const p = nonTtyText({ message: 'Name?' }, { reader: h.reader, stderr: h.stderr }); + h.end(); + expect(await p).toBe(CANCEL); + }); + + it('preserves whitespace when trim is false (for passwords)', async () => { + const h = harness(); + const p = nonTtyText({ message: 'Secret?', trim: false }, { reader: h.reader, stderr: h.stderr }); + h.write(' spaces matter \n'); + expect(await p).toBe(' spaces matter '); + }); +}); + +describe('nonTtySelect', () => { + it('resolves numeric answer to the option value', async () => { + const h = harness(); + const p = nonTtySelect( + { + message: 'Pick:', + options: [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Beta' }, + { value: 'c', label: 'Charlie' }, + ], + }, + { reader: h.reader, stdout: h.stdout, stderr: h.stderr }, + ); + h.write('2\n'); + expect(await p).toBe('b'); + }); + + it('prints numbered list with labels and hints', async () => { + const h = harness(); + const p = nonTtySelect( + { + message: 'Pick:', + options: [ + { value: 'a', label: 'Alpha', hint: 'the first one' }, + { value: 'b', label: 'Beta' }, + ], + }, + { reader: h.reader, stdout: h.stdout, stderr: h.stderr }, + ); + h.write('1\n'); + await p; + const out = h.stdout_text(); + expect(out).toContain('1) Alpha'); + expect(out).toContain('the first one'); + expect(out).toContain('2) Beta'); + }); + + it('retries on out-of-range input', async () => { + const h = harness(); + const p = nonTtySelect( + { + message: 'Pick:', + options: [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Beta' }, + ], + }, + { reader: h.reader, stdout: h.stdout, stderr: h.stderr }, + ); + h.write('5\n'); + h.write('1\n'); + expect(await p).toBe('a'); + expect(h.stderr_text()).toContain('between 1 and 2'); + }); + + it('retries on non-numeric input', async () => { + const h = harness(); + const p = nonTtySelect( + { + message: 'Pick:', + options: [{ value: 'a', label: 'Alpha' }], + }, + { reader: h.reader, stdout: h.stdout, stderr: h.stderr }, + ); + h.write('hello\n'); + h.write('1\n'); + expect(await p).toBe('a'); + }); + + it('returns CANCEL on EOF', async () => { + const h = harness(); + const p = nonTtySelect( + { message: 'Pick:', options: [{ value: 'a', label: 'Alpha' }] }, + { reader: h.reader, stdout: h.stdout, stderr: h.stderr }, + ); + h.end(); + expect(await p).toBe(CANCEL); + }); + + it('throws when options list is empty (no infinite loop)', async () => { + const h = harness(); + await expect( + nonTtySelect( + { message: 'Pick:', options: [] }, + { reader: h.reader, stdout: h.stdout, stderr: h.stderr }, + ), + ).rejects.toThrow(/No options available/); + }); +}); + +describe('nonTtyConfirm', () => { + it('returns true for "y"', async () => { + const h = harness(); + const p = nonTtyConfirm({ message: 'OK?' }, { reader: h.reader, stderr: h.stderr }); + h.write('y\n'); + expect(await p).toBe(true); + }); + + it('returns true for "yes"', async () => { + const h = harness(); + const p = nonTtyConfirm({ message: 'OK?' }, { reader: h.reader, stderr: h.stderr }); + h.write('yes\n'); + expect(await p).toBe(true); + }); + + it('returns false for "n"', async () => { + const h = harness(); + const p = nonTtyConfirm({ message: 'OK?' }, { reader: h.reader, stderr: h.stderr }); + h.write('n\n'); + expect(await p).toBe(false); + }); + + it('uses initialValue on empty input', async () => { + const h = harness(); + const p = nonTtyConfirm({ message: 'OK?', initialValue: true }, { reader: h.reader, stderr: h.stderr }); + h.write('\n'); + expect(await p).toBe(true); + }); + + it('is case-insensitive', async () => { + const h = harness(); + const p = nonTtyConfirm({ message: 'OK?' }, { reader: h.reader, stderr: h.stderr }); + h.write('YES\n'); + expect(await p).toBe(true); + }); + + it('retries on unrecognized input', async () => { + const h = harness(); + const p = nonTtyConfirm({ message: 'OK?' }, { reader: h.reader, stderr: h.stderr }); + h.write('maybe\n'); + h.write('y\n'); + expect(await p).toBe(true); + expect(h.stderr_text()).toContain('Please answer y or n'); + }); + + it('returns CANCEL on EOF', async () => { + const h = harness(); + const p = nonTtyConfirm({ message: 'OK?' }, { reader: h.reader, stderr: h.stderr }); + h.end(); + expect(await p).toBe(CANCEL); + }); +}); + +describe('isCancel', () => { + it('recognizes our CANCEL symbol', () => { + expect(isCancel(CANCEL)).toBe(true); + }); + + it('returns false for regular values', () => { + expect(isCancel('hello')).toBe(false); + expect(isCancel(42)).toBe(false); + expect(isCancel(null)).toBe(false); + }); +}); + +describe('multiple prompts sharing one readline interface', () => { + it('consumes sequential lines from the same pipe', async () => { + const h = harness(); + const p1 = nonTtyText({ message: 'First?' }, { reader: h.reader, stderr: h.stderr }); + h.write('one\n'); + expect(await p1).toBe('one'); + + const p2 = nonTtyText({ message: 'Second?' }, { reader: h.reader, stderr: h.stderr }); + h.write('two\n'); + expect(await p2).toBe('two'); + + const p3 = nonTtySelect( + { message: 'Third?', options: [{ value: 'x', label: 'X' }, { value: 'y', label: 'Y' }] }, + { reader: h.reader, stdout: h.stdout, stderr: h.stderr }, + ); + h.write('2\n'); + expect(await p3).toBe('y'); + }); + + it('handles buffered multi-line input written at once', async () => { + const h = harness(); + h.write('alpha\nbeta\n'); + + const p1 = nonTtyText({ message: 'A?' }, { reader: h.reader, stderr: h.stderr }); + expect(await p1).toBe('alpha'); + + const p2 = nonTtyText({ message: 'B?' }, { reader: h.reader, stderr: h.stderr }); + expect(await p2).toBe('beta'); + }); +}); diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts new file mode 100644 index 0000000..ff73d57 --- /dev/null +++ b/src/lib/prompts.ts @@ -0,0 +1,211 @@ +import * as readline from 'node:readline'; +import * as clack from '@clack/prompts'; + +export const isInteractive = !!(process.stdin.isTTY && process.stdout.isTTY); + +/** + * A line reader that buffers parsed lines and exposes a pull-based readLine API. + * + * readline.promises.question() loses 'line' events that fire between question() calls, + * and does not reject cleanly on stream close. A buffered queue handles both. + */ +export class LineReader { + private queue: string[] = []; + private waiter: ((line: string | null) => void) | null = null; + private closed = false; + private rl: readline.Interface; + + constructor( + input: NodeJS.ReadableStream, + private output: NodeJS.WritableStream, + ) { + this.rl = readline.createInterface({ input }); + this.rl.on('line', (line) => { + if (this.waiter) { + const w = this.waiter; + this.waiter = null; + w(line); + } else { + this.queue.push(line); + } + }); + this.rl.on('close', () => { + this.closed = true; + if (this.waiter) { + const w = this.waiter; + this.waiter = null; + w(null); + } + }); + } + + async readLine(prompt: string): Promise { + this.output.write(prompt); + if (this.queue.length > 0) return this.queue.shift()!; + if (this.closed) return null; + return new Promise((resolve) => { + this.waiter = resolve; + }); + } + + close(): void { + this.rl.close(); + } +} + +let sharedReader: LineReader | null = null; +function getReader(): LineReader { + if (!sharedReader) { + sharedReader = new LineReader(process.stdin, process.stdout); + } + return sharedReader; +} + +export const CANCEL: unique symbol = Symbol('prompt.cancel'); +export type CancelSymbol = typeof CANCEL; + +export function isCancel(v: T | CancelSymbol): v is CancelSymbol { + return v === CANCEL || clack.isCancel(v); +} + +export interface TextOptions { + message: string; + initialValue?: string; + placeholder?: string; + validate?: (value: string) => string | undefined; +} + +export interface SelectOption { + value: T; + label: string; + hint?: string; +} + +export interface SelectOptions { + message: string; + options: SelectOption[]; + initialValue?: T; +} + +export interface ConfirmOptions { + message: string; + initialValue?: boolean; +} + +export async function text(opts: TextOptions): Promise { + if (isInteractive) { + const result = await clack.text({ + message: opts.message, + initialValue: opts.initialValue, + placeholder: opts.placeholder, + validate: opts.validate, + }); + if (clack.isCancel(result)) return CANCEL; + return result; + } + return nonTtyText(opts); +} + +export async function select(opts: SelectOptions): Promise { + if (isInteractive) { + const result = await clack.select({ + message: opts.message, + options: opts.options as { value: T; label: string; hint?: string }[], + initialValue: opts.initialValue, + }); + if (clack.isCancel(result)) return CANCEL; + return result as T; + } + return nonTtySelect(opts); +} + +export async function confirm(opts: ConfirmOptions): Promise { + if (isInteractive) { + const result = await clack.confirm({ + message: opts.message, + initialValue: opts.initialValue, + }); + if (clack.isCancel(result)) return CANCEL; + return result; + } + return nonTtyConfirm(opts); +} + +export async function password(opts: { message: string }): Promise { + if (isInteractive) { + const result = await clack.password({ message: opts.message }); + if (clack.isCancel(result)) return CANCEL; + return result; + } + // Non-TTY: can't mask input over a pipe, just read the line. Preserve whitespace + // since it can be valid in passwords. + return nonTtyText({ message: opts.message, trim: false }); +} + +export async function nonTtyText( + opts: TextOptions & { trim?: boolean }, + io: { reader?: LineReader; stderr?: NodeJS.WritableStream } = {}, +): Promise { + const reader = io.reader ?? getReader(); + const stderr = io.stderr ?? process.stderr; + const shouldTrim = opts.trim ?? true; + const defaultHint = opts.initialValue ? ` [${opts.initialValue}]` : ''; + for (;;) { + const raw = await reader.readLine(`? ${opts.message}${defaultHint} `); + if (raw === null) return CANCEL; + const normalized = shouldTrim ? raw.trim() : raw; + const value = normalized === '' ? (opts.initialValue ?? '') : normalized; + if (opts.validate) { + const err = opts.validate(value); + if (err) { + stderr.write(` ${err}\n`); + continue; + } + } + return value; + } +} + +export async function nonTtySelect( + opts: SelectOptions, + io: { reader?: LineReader; stdout?: NodeJS.WritableStream; stderr?: NodeJS.WritableStream } = {}, +): Promise { + if (opts.options.length === 0) { + throw new Error(`No options available for prompt "${opts.message}".`); + } + const reader = io.reader ?? getReader(); + const stdout = io.stdout ?? process.stdout; + const stderr = io.stderr ?? process.stderr; + stdout.write(`? ${opts.message}\n`); + opts.options.forEach((o, i) => { + const hint = o.hint ? ` — ${o.hint}` : ''; + stdout.write(` ${i + 1}) ${o.label}${hint}\n`); + }); + for (;;) { + const raw = await reader.readLine(`Enter number [1-${opts.options.length}]: `); + if (raw === null) return CANCEL; + const n = Number.parseInt(raw.trim(), 10); + if (Number.isInteger(n) && n >= 1 && n <= opts.options.length) { + return opts.options[n - 1].value; + } + stderr.write(` Please enter a number between 1 and ${opts.options.length}.\n`); + } +} + +export async function nonTtyConfirm( + opts: ConfirmOptions, + io: { reader?: LineReader; stderr?: NodeJS.WritableStream } = {}, +): Promise { + const reader = io.reader ?? getReader(); + const stderr = io.stderr ?? process.stderr; + const defaultHint = opts.initialValue === true ? ' [Y/n]' : opts.initialValue === false ? ' [y/N]' : ' [y/n]'; + for (;;) { + const raw = await reader.readLine(`? ${opts.message}${defaultHint} `); + if (raw === null) return CANCEL; + const answer = raw.trim().toLowerCase(); + if (answer === '' && opts.initialValue !== undefined) return opts.initialValue; + if (answer === 'y' || answer === 'yes') return true; + if (answer === 'n' || answer === 'no') return false; + stderr.write(` Please answer y or n.\n`); + } +}