From b859cf7cd34fbe0077460af10b748d00e98874e0 Mon Sep 17 00:00:00 2001 From: Thomas Osmonson <11803153+aulneau@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:31:09 -0500 Subject: [PATCH] feat: add new dry run functionality --- src/Cli.test.ts | 9 ++++ src/Cli.ts | 16 ++++++ src/Help.test.ts | 7 +++ src/Help.ts | 1 + src/Mcp.ts | 3 ++ src/e2e.test.ts | 105 ++++++++++++++++++++++++++++++++++++++++ src/internal/command.ts | 12 ++++- src/middleware.ts | 2 + 8 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index c5c88e0..108e718 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -917,6 +917,7 @@ describe('subcommands', () => { list Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1356,6 +1357,7 @@ describe('help', () => { skills add Sync skill files to agents Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1394,6 +1396,7 @@ describe('help', () => { skills add Sync skill files to agents Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1428,6 +1431,7 @@ describe('help', () => { name Name Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1462,6 +1466,7 @@ describe('help', () => { list List PRs Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1557,6 +1562,7 @@ describe('help', () => { skills add Sync skill files to agents Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1589,6 +1595,7 @@ describe('help', () => { Run "tool status" to check deployment progress. Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1684,6 +1691,7 @@ describe('env', () => { Usage: test deploy Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1722,6 +1730,7 @@ describe('env', () => { Usage: test deploy Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help diff --git a/src/Cli.ts b/src/Cli.ts index c362421..b28ad1b 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -351,6 +351,8 @@ export declare namespace create { agent: boolean /** Positional arguments. */ args: InferOutput + /** Whether this is a dry-run invocation. Only `true` when the command sets `dryRun: true`. */ + dryRun: boolean /** Parsed environment variables. */ env: InferOutput /** Return an error result with optional CTAs. */ @@ -429,6 +431,7 @@ async function serveImpl( const { verbose, + dryRun, format: formatFlag, formatExplicit, filterOutput, @@ -1142,6 +1145,7 @@ async function serveImpl( const mwCtx: MiddlewareContext = { agent: !human, command: path, + dryRun, env: cliEnv, error: errorFn, format, @@ -1217,6 +1221,7 @@ async function serveImpl( const result = await Command.execute(command, { agent: !human, argv: rest, + dryRun, env: options.envSchema, envSource: options.env, format, @@ -1258,6 +1263,7 @@ async function serveImpl( meta: { command: path, duration, + ...(result.dryRun ? { dryRun: true } : undefined), ...(cta ? { cta } : undefined), }, }) @@ -1302,6 +1308,8 @@ async function serveImpl( /** @internal Options for fetchImpl. */ declare namespace fetchImpl { type Options = { + /** Whether this is a dry-run invocation. */ + dryRun?: boolean | undefined /** CLI-level env schema. */ envSchema?: z.ZodObject | undefined /** Group-level middleware collected during command resolution. */ @@ -1391,6 +1399,7 @@ async function fetchImpl( options: fetchImpl.Options = {}, ): Promise { const start = performance.now() + if (req.headers.get('x-dry-run') === 'true') options = { ...options, dryRun: true } const url = new URL(req.url) const segments = url.pathname.split('/').filter(Boolean) @@ -1542,6 +1551,7 @@ async function executeCommand( const result = await Command.execute(command, { agent: true, argv: rest, + dryRun: options.dryRun, env: options.envSchema, format: 'json', formatExplicit: true, @@ -1627,6 +1637,7 @@ async function executeCommand( meta: { command: path, duration, + ...(result.dryRun ? { dryRun: true } : undefined), ...(cta ? { cta } : undefined), }, }, @@ -1794,6 +1805,7 @@ declare namespace serveImpl { /** @internal Extracts built-in flags (--verbose, --format, --json, --llms, --help, --version) from argv. */ function extractBuiltinFlags(argv: string[]) { let verbose = false + let dryRun = false let llms = false let llmsFull = false let mcp = false @@ -1811,6 +1823,7 @@ function extractBuiltinFlags(argv: string[]) { for (let i = 0; i < argv.length; i++) { const token = argv[i]! if (token === '--verbose') verbose = true + else if (token === '--dry-run') dryRun = true else if (token === '--llms') llms = true else if (token === '--llms-full') llmsFull = true else if (token === '--mcp') mcp = true @@ -1839,6 +1852,7 @@ function extractBuiltinFlags(argv: string[]) { return { verbose, + dryRun, format, formatExplicit, filterOutput, @@ -2527,6 +2541,8 @@ type CommandDefinition< agent: boolean /** Positional arguments. */ args: InferOutput + /** Whether this is a dry-run invocation. Only `true` when the command sets `dryRun: true`. */ + dryRun: boolean /** Parsed environment variables. */ env: InferOutput /** Return an error result with optional CTAs. */ diff --git a/src/Help.test.ts b/src/Help.test.ts index d62dd3f..a981075 100644 --- a/src/Help.test.ts +++ b/src/Help.test.ts @@ -25,6 +25,7 @@ describe('formatCommand', () => { --limit Max PRs to return (default: 30) Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -47,6 +48,7 @@ describe('formatCommand', () => { Usage: tool ping Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -76,6 +78,7 @@ describe('formatCommand', () => { title Title Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -153,6 +156,7 @@ describe('formatRoot', () => { issue list List issues Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -178,6 +182,7 @@ describe('formatRoot', () => { ping Health check Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -207,6 +212,7 @@ describe('formatRoot', () => { fetch Fetch a URL Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -236,6 +242,7 @@ describe('formatRoot', () => { url URL to fetch Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help diff --git a/src/Help.ts b/src/Help.ts index dec8ac1..12cda66 100644 --- a/src/Help.ts +++ b/src/Help.ts @@ -344,6 +344,7 @@ function globalOptionsLines(root = false): string[] { } const flags = [ + { flag: '--dry-run', desc: 'Preview parsed inputs without executing' }, { flag: '--filter-output ', desc: 'Filter output by key paths (e.g. foo,bar.baz,a[0,3])', diff --git a/src/Mcp.ts b/src/Mcp.ts index 27551c4..18e9b9a 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -91,9 +91,12 @@ export async function callTool( ...((tool.command.middleware as MiddlewareHandler[] | undefined) ?? []), ] + const dryRun = (options.extra?._meta as any)?.dryRun === true + const result = await Command.execute(tool.command, { agent: true, argv: [], + dryRun: dryRun || undefined, env: options.env, format: 'json', formatExplicit: true, diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 60b3279..61cf6b6 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -969,6 +969,7 @@ describe('help', () => { skills add Sync skill files to agents Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1003,6 +1004,7 @@ describe('help', () => { status Show authentication status Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1030,6 +1032,7 @@ describe('help', () => { status Check deployment status Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1056,6 +1059,7 @@ describe('help', () => { --archived Include archived (default: false) Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1088,6 +1092,7 @@ describe('help', () => { app project deploy create production --branch release --dryRun true # Dry run a production deploy Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1744,6 +1749,7 @@ describe('root command with subcommands', () => { skills add Sync skill files to agents Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -1921,6 +1927,7 @@ describe('env', () => { --scopes OAuth scopes Global Options: + --dry-run Preview parsed inputs without executing --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) --format Output format --help Show help @@ -2948,6 +2955,104 @@ describe('.well-known/skills', () => { }) }) +describe('--dry-run', () => { + test('run() receives c.dryRun = true', async () => { + let receivedDryRun: boolean | undefined + const cli = Cli.create('app', { version: '1.0.0' }).command('sync', { + args: z.object({ org: z.string() }), + run(c) { + receivedDryRun = c.dryRun + if (c.dryRun) return { wouldSync: [c.args.org] } + return { synced: 1 } + }, + }) + const { output } = await serve(cli, ['sync', 'acme', '--dry-run', '--verbose', '--json']) + expect(receivedDryRun).toBe(true) + const parsed = json(output) + expect(parsed.ok).toBe(true) + expect(parsed.meta.dryRun).toBe(true) + expect(parsed.data).toEqual({ wouldSync: ['acme'] }) + }) + + test('c.dryRun = false without --dry-run', async () => { + let receivedDryRun: boolean | undefined + const cli = Cli.create('app', { version: '1.0.0' }).command('sync', { + args: z.object({ org: z.string() }), + run(c) { + receivedDryRun = c.dryRun + return { synced: 1 } + }, + }) + await serve(cli, ['sync', 'acme']) + expect(receivedDryRun).toBe(false) + }) + + test('validation errors still fire during dry-run', async () => { + const cli = Cli.create('app', { version: '1.0.0' }).command('deploy', { + args: z.object({ env: z.string() }), + run() { + return { ok: true } + }, + }) + const { output, exitCode } = await serve(cli, ['deploy', '--dry-run', '--verbose', '--json']) + const parsed = json(output) + expect(parsed.ok).toBe(false) + expect(parsed.error.code).toBe('VALIDATION_ERROR') + expect(exitCode).toBe(1) + }) + + test('middleware receives c.dryRun during dry-run', async () => { + let mwDryRun: boolean | undefined + const cli = Cli.create('app', { version: '1.0.0' }).command('ping', { + run() { + return { pong: true } + }, + }) + cli.use(async (c, next) => { + mwDryRun = c.dryRun + await next() + }) + await serve(cli, ['ping', '--dry-run']) + expect(mwDryRun).toBe(true) + }) + + test('middleware receives c.dryRun = false without --dry-run', async () => { + let mwDryRun: boolean | undefined + const cli = Cli.create('app', { version: '1.0.0' }).command('ping', { + run() { + return { pong: true } + }, + }) + cli.use(async (c, next) => { + mwDryRun = c.dryRun + await next() + }) + await serve(cli, ['ping']) + expect(mwDryRun).toBe(false) + }) + + test('HTTP: X-Dry-Run header sets c.dryRun', async () => { + let receivedDryRun: boolean | undefined + const cli = Cli.create('app', { version: '1.0.0' }).command('deploy', { + args: z.object({ env: z.string() }), + run(c) { + receivedDryRun = c.dryRun + if (c.dryRun) return { wouldDeploy: c.args.env } + return { url: 'https://example.com' } + }, + }) + const { status, body } = await fetchJson( + cli, + new Request('http://localhost/deploy/staging', { headers: { 'x-dry-run': 'true' } }), + ) + expect(receivedDryRun).toBe(true) + expect(status).toBe(200) + expect(body.ok).toBe(true) + expect(body.meta.dryRun).toBe(true) + expect(body.data).toEqual({ wouldDeploy: 'staging' }) + }) +}) + async function serve( cli: { serve: Cli.Cli['serve'] }, argv: string[], diff --git a/src/internal/command.ts b/src/internal/command.ts index 0af6c3f..774239a 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -37,6 +37,7 @@ export async function execute(command: any, options: execute.Options): Promise | undefined /** Source for environment variables. Defaults to `process.env`. */ @@ -300,7 +308,7 @@ export declare namespace execute { /** Result of executing a command. */ type Result = - | { ok: true; data: unknown; cta?: CtaBlock | undefined } + | { ok: true; data: unknown; cta?: CtaBlock | undefined; dryRun?: true | undefined } | { ok: false error: { diff --git a/src/middleware.ts b/src/middleware.ts index 53b3fd6..55765f1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -33,6 +33,8 @@ export type Context< agent: boolean /** The resolved command path. */ command: string + /** Whether the invocation is a dry run (`--dry-run` was passed). */ + dryRun: boolean /** Parsed environment variables from the CLI-level env schema. */ env: InferEnv /** Return an error result, short-circuiting the middleware chain. */