From 2f6e710f764fb210d5c17d5c48340660501f5880 Mon Sep 17 00:00:00 2001 From: "mason@axhxrx.com" Date: Sat, 28 Mar 2026 22:43:29 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20Fix=20bug=20where=20--yes=20didn?= =?UTF-8?q?'t=20propagate=20to=20nested=20step-level=20confirmations,=20so?= =?UTF-8?q?=20they'd=20still=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also related bug where emitted prompts didn't make it to the log file when using .file(), and tightened up auto-redaction patterns. --- examples/runtime-matrix.bun.test.ts | 3 +- src/prompts/promptYesNo.ts | 6 ++- src/script/FileLogging.bun.test.ts | 58 +++++++++++++++++++++++++++++ src/script/OutputContext.ts | 21 +++++++++++ src/script/Script.bun.test.ts | 27 ++++++++++++++ src/script/Script.ts | 12 ++++-- src/script/ask.ts | 3 +- src/script/autoRedact.ts | 7 ++++ 8 files changed, 130 insertions(+), 7 deletions(-) diff --git a/examples/runtime-matrix.bun.test.ts b/examples/runtime-matrix.bun.test.ts index e4a11f4..6631ffc 100644 --- a/examples/runtime-matrix.bun.test.ts +++ b/examples/runtime-matrix.bun.test.ts @@ -35,8 +35,7 @@ const examples: ExampleCase[] = [ { file: '02-builder-pattern.ts', args: ['--yes'], - input: 'y\n', - expectedText: 'Write a demo .gitignore?', + expectedText: 'Builder Pattern Example Demo', }, { file: '03-validations.ts', diff --git a/src/prompts/promptYesNo.ts b/src/prompts/promptYesNo.ts index cd18ab0..284086c 100644 --- a/src/prompts/promptYesNo.ts +++ b/src/prompts/promptYesNo.ts @@ -19,13 +19,17 @@ import * as readline from 'node:readline/promises'; export async function promptYesNo( prompt: string, defaultYes = true, + mirrorOutput?: (text: string) => void, ): Promise { const rl = readline.createInterface({ input, output }); try { const suffix = defaultYes ? '[Y/n]' : '[y/N]'; - const answer = await rl.question(`${prompt} ${suffix}: `); + const fullPrompt = `${prompt} ${suffix}: `; + mirrorOutput?.(fullPrompt); + const answer = await rl.question(fullPrompt); + mirrorOutput?.(`${answer}\n`); if (!answer.trim()) { diff --git a/src/script/FileLogging.bun.test.ts b/src/script/FileLogging.bun.test.ts index b3a32c2..ed03ea6 100644 --- a/src/script/FileLogging.bun.test.ts +++ b/src/script/FileLogging.bun.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { readFile, unlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -9,6 +10,8 @@ import { normalizeFileOptions } from './FileOptions.ts'; import { OutputContext } from './OutputContext.ts'; import { Script } from './Script.ts'; +const scriptModuleUrl = new URL('../mod.ts', import.meta.url).href; + /** Helper to create a unique temp file path. */ @@ -77,6 +80,12 @@ describe('autoRedact patterns', () => expect(autoRedact('PASSWORD: "my-pass"')).toBe('[REDACTED_SECRET]'); }); + test('redacts env-style secret assignments like SECRET_KEY', () => + { + expect(autoRedact('SECRET_KEY=hunter2')).toBe('[REDACTED_SECRET]'); + expect(autoRedact('AWS_SECRET_ACCESS_KEY = hunter2')).toBe('[REDACTED_SECRET]'); + }); + test('redacts Bearer tokens', () => { expect(autoRedact('Bearer abc123xyz')).toBe('Bearer [REDACTED_TOKEN]'); @@ -622,4 +631,53 @@ describe('Issue fixes', () => const content = await readFile(path, 'utf-8'); expect(content).toContain('Partial line without newline'); }); + + test('Issue 6: step-level auto redaction redacts env-style secrets', async () => + { + const path = tempPath('step-redaction'); + filesToCleanup.push(path); + + const script = new Script(); + await script.file({ path, mode: 'overwrite', output: 'full', timestamps: true }); + + script.add('echo "SECRET_KEY=hunter2" && echo "normal output"') + .description('Step with secret') + .file({ path, redact: 'auto' }); + + await script.execute({ yes: true, printResults: false }); + await new Promise((r) => setTimeout(r, 100)); + + const content = await readFile(path, 'utf-8'); + expect(content).toContain('[REDACTED_SECRET]'); + expect(content).toContain('normal output'); + expect(content).not.toContain('hunter2'); + }); + + test('Issue 7: prompt logs include the question and user answer', async () => + { + const path = tempPath('prompt-log'); + filesToCleanup.push(path); + + const code = ` +import { createScript } from ${JSON.stringify(scriptModuleUrl)}; +const script = createScript(); +await script.file({ path: ${JSON.stringify(path)}, output: 'full' }); +script.add('echo hello'); +await script.execute({ printResults: false }); +await new Promise((resolve) => setTimeout(resolve, 50)); +`; + + const result = spawnSync('deno', ['eval', code], { + encoding: 'utf-8', + input: 'y\n', + timeout: 4000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + + const content = await readFile(path, 'utf-8'); + expect(content).toContain('Proceed with execution? [Y/n]: y'); + expect(content).toContain('hello'); + }); }); diff --git a/src/script/OutputContext.ts b/src/script/OutputContext.ts index 3f72166..c0ed539 100644 --- a/src/script/OutputContext.ts +++ b/src/script/OutputContext.ts @@ -345,6 +345,27 @@ export class OutputContext } } + /** + Write control-plane text to the log file only, without terminal output. + + Respects `output: 'command'` the same way as write()/log(), so prompts and other framework messages stay out of command-only logs. + */ + fileWrite(text: string): void + { + if (this.#filePath && this.#fileOptions?.output !== 'command') + { + this.#partialLine += text; + + const lastNewline = this.#partialLine.lastIndexOf('\n'); + if (lastNewline !== -1) + { + const completeLines = this.#partialLine.slice(0, lastNewline + 1); + this.#partialLine = this.#partialLine.slice(lastNewline + 1); + this.#queueWrite(completeLines); + } + } + } + /** Write stdout to file only (no terminal output). diff --git a/src/script/Script.bun.test.ts b/src/script/Script.bun.test.ts index af8c66b..647e8e3 100644 --- a/src/script/Script.bun.test.ts +++ b/src/script/Script.bun.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; import process from 'node:process'; import { Script } from './Script.ts'; import type { StepResult } from './StepResult.ts'; +const scriptModuleUrl = new URL('../mod.ts', import.meta.url).href; + describe('Script.add() multi-line handling', () => { test('single-line command creates one step', () => @@ -290,6 +293,30 @@ describe('Script.execute() with parseArgs', () => }); }); +describe('Bug regressions', () => +{ + test('yes: true suppresses per-step confirmations in Deno subprocesses', () => + { + const code = ` +import { createScript } from ${JSON.stringify(scriptModuleUrl)}; +const script = createScript(); +script.add('echo hello').confirm('Run this?', true); +const result = await script.execute({ yes: true, printResults: false }); +console.log(JSON.stringify({ aborted: result.aborted, stepsRun: result.stepsRun })); +`; + + const result = spawnSync('deno', ['eval', code], { + encoding: 'utf-8', + timeout: 2000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toContain('"stepsRun":1'); + expect(result.stdout).not.toContain('Run this? [Y/n]:'); + }); +}); + describe('Script.execute() state and stepResults', () => { test('successful execution has state: complete', async () => diff --git a/src/script/Script.ts b/src/script/Script.ts index 52b3967..4970a27 100644 --- a/src/script/Script.ts +++ b/src/script/Script.ts @@ -543,6 +543,10 @@ export class Script const printResults = options.printResults ?? DEFAULT_EXECUTE_OPTIONS.printResults; const captureOutput = options.captureOutput ?? DEFAULT_EXECUTE_OPTIONS.captureOutput; + const autoYes = yes === true; + const mirrorPromptToFile = ctx.filePath + ? (text: string) => ctx.fileWrite(text) + : undefined; // Reset step results for this execution this.#stepResults = []; @@ -587,10 +591,10 @@ export class Script } // Show plan and ask for confirmation unless --yes was passed - if (!yes) + if (!autoYes) { this.#printPlan(); - const proceed = await ask('Proceed with execution?', true); + const proceed = await ask('Proceed with execution?', true, undefined, mirrorPromptToFile); if (!proceed) { ctx.log('\n❌ Aborted.\n'); @@ -658,7 +662,9 @@ export class Script { const question = step.options.confirmPrompt || `Run: ${stepDesc}?`; const defaultYes = step.options.confirmDefault ?? true; - const proceed = await ask(question, defaultYes); + const proceed = autoYes + ? true + : await ask(question, defaultYes, undefined, mirrorPromptToFile); if (!proceed) { diff --git a/src/script/ask.ts b/src/script/ask.ts index 4d27568..c07f5e7 100644 --- a/src/script/ask.ts +++ b/src/script/ask.ts @@ -18,6 +18,7 @@ export async function ask( question: string, defaultYes = true, alreadyAnswered?: boolean, + mirrorOutput?: (text: string) => void, ): Promise { if (typeof alreadyAnswered === 'boolean') @@ -27,7 +28,7 @@ export async function ask( ); return alreadyAnswered; } - return await promptYesNo(question, defaultYes); + return await promptYesNo(question, defaultYes, mirrorOutput); } if (import.meta.main) diff --git a/src/script/autoRedact.ts b/src/script/autoRedact.ts index cab11e5..f9db1f7 100644 --- a/src/script/autoRedact.ts +++ b/src/script/autoRedact.ts @@ -4,6 +4,13 @@ These patterns aim to catch common sensitive data like passwords, API keys, tokens, and private keys while minimizing false positives. */ const AUTO_REDACT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [ + // Environment-style secret assignments (SECRET_KEY, AWS_SECRET_ACCESS_KEY, CLIENT_SECRET, etc.) + { + pattern: + /\b(?:[A-Za-z0-9]+[_-])*(?:SECRET|PASSWORD|PASSWD|PWD|TOKEN|API[_-]?KEY|AUTH[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|ACCESS[_-]?TOKEN|REFRESH[_-]?TOKEN|CREDENTIALS?)(?:[_-][A-Za-z0-9]+)*\s*[=:]\s*["']?[^\s"']+["']?/gi, + replacement: '[REDACTED_SECRET]', + }, + // Password/secret/token/key assignments (key=value or key: value) { pattern: /(?:password|passwd|pwd|secret|token|api[_-]?key|auth[_-]?key|credentials?)\s*[=:]\s*["']?[^\s"']+["']?/gi,