Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions examples/runtime-matrix.bun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion src/prompts/promptYesNo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,17 @@ import * as readline from 'node:readline/promises';
export async function promptYesNo(
prompt: string,
defaultYes = true,
mirrorOutput?: (text: string) => void,
): Promise<boolean>
{
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())
{
Expand Down
58 changes: 58 additions & 0 deletions src/script/FileLogging.bun.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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]');
Expand Down Expand Up @@ -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');
});
});
21 changes: 21 additions & 0 deletions src/script/OutputContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
27 changes: 27 additions & 0 deletions src/script/Script.bun.test.ts
Original file line number Diff line number Diff line change
@@ -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', () =>
Expand Down Expand Up @@ -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 () =>
Expand Down
12 changes: 9 additions & 3 deletions src/script/Script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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)
{
Expand Down
3 changes: 2 additions & 1 deletion src/script/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export async function ask(
question: string,
defaultYes = true,
alreadyAnswered?: boolean,
mirrorOutput?: (text: string) => void,
): Promise<boolean>
{
if (typeof alreadyAnswered === 'boolean')
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/script/autoRedact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading