From 87fb394fa2d357a3a149abe4e5b31463d7700fc6 Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Fri, 1 May 2026 12:40:35 -0700 Subject: [PATCH] feat: push --dry-run preview mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ELI5 **Problem.** `npm run push -- ` immediately starts hitting the live dashboard. There was no way to ask "what would this push do?" before firing it. So a fat-fingered command โ€” wrong org, missing file path, wide-scope push when you meant scoped โ€” hit production immediately, and recovery meant `pull` + manual revert. The only existing dry-run concept gated *deletions*, not creates or updates. **What this fix does.** Adds a `--dry-run` flag to `push`. Instead of firing POST/PATCH/DELETE, the engine counts the intent and prints `[dry-run] would ` per resource. The state file is never written (so synthetic IDs don't pollute it), and the end-of-run summary shows `Would create N, would update M, would delete K`. GETs still run because drift detection (Stack G) and operator preview both need to see current platform state. **Outcome you'll notice.** Run `npm run push -- --dry-run` to preview any push. Especially useful for "did I scope this right?" and "is the pre-push lint reporting drift I should address first?" before the real push. Cheapest individual operator-safety win in the stack โ€” no schema changes, no engine architecture moves. --- Operators today can't validate "is this push doing what I think it's doing" before it lands on prod. push.ts has a dry-run concept only for deletions; updates and creates fire immediately. Cheapest individual operator-safety win (improvements.md #5). - src/config.ts: parseFlags now accepts --dry-run alongside --force / --bootstrap. Exports DRY_RUN. - src/api.ts: vapiRequest gates POST/PATCH on DRY_RUN โ€” counts the intent, prints `[dry-run] would ` with a 120-char body preview, and returns a synthetic id so caller code threads through. vapiDelete gets the same treatment. GETs always run (drift preview needs them). - src/push.ts: banner ("๐Ÿงช DRY-RUN") at start, summary at end ("Would create N, would update M, would delete K"), saveState entirely skipped in dry-run so synthetic ids never leak into the state file. - AGENTS.md: document --dry-run in Available Commands. - tests/push-dry-run.test.ts: --dry-run is parse-accepted, banner prints, state file is NEVER created (verified end-to-end via spawn). - improvements.md: #5 โ†’ RESOLVED. Closes improvements.md #5. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- AGENTS.md | 1 + improvements.md | 2 +- src/api.ts | 53 ++++++++++++++++- src/config.ts | 7 ++- src/push.ts | 43 ++++++++++---- tests/push-dry-run.test.ts | 118 +++++++++++++++++++++++++++++++++++++ 6 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 tests/push-dry-run.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5ee3bad..afbf1d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -747,6 +747,7 @@ npm run push -- # Push all local changes to V npm run push -- assistants # Push only assistants npm run push -- resources//assistants/my-agent.md # Push single file npm run push -- # Push multiple specific files (one state write) +npm run push -- --dry-run # Preview without applying any platform changes npm run apply -- # Pull then push (full sync) # Testing diff --git a/improvements.md b/improvements.md index 3267944..33bacaa 100644 --- a/improvements.md +++ b/improvements.md @@ -56,7 +56,7 @@ you which stack PR closes the row.** | 2 | `apply` same-file conflict | `apply` drops concurrent same-file dashboard edits | #4 | Open (Stack G planned) | | 3 | Rollback | Current undo can clobber newer live changes | #4, #5 | Open (Stack H planned) | | 4 | State schema content hashes | Architectural unlock for #1, #2, #3, #6, #7 | None | Open (Stack F planned) | -| 5 | `push --dry-run` | Cheapest operator-safety win | None | Open (Stack C planned) | +| 5 | `push --dry-run` | Cheapest operator-safety win | None | RESOLVED 2026-04-30 (Stack C) | | 6 | API-level optimistic concurrency | Server-side conflict rejection | Platform | Deferred (Stack I, gated) | | 7 | Voice edits drop pronunciation-dictionary attachments | Silent regression on Cartesia + 11labs voice edits | #4 | Open (Stack G planned) | | 8 | Dashboard prompt edits can in-place duplicate the prompt | Two stacked prompt versions = stitched output | None | Open (Stack D planned) | diff --git a/src/api.ts b/src/api.ts index 2d2037e..a6d81dc 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,37 @@ -import { VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts"; +import { DRY_RUN, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts"; import type { VapiResponse } from "./types.ts"; +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Dry-run accounting +// +// In `--dry-run` mode, mutating requests (POST/PATCH/DELETE) are gated and +// counted instead of executed. The end-of-run summary in push.ts reads +// `getDryRunCounts()` to print "would create N, would update M, would delete K." +// +// GETs always run โ€” drift detection (Stack G) and dry-run preview both need +// to fetch current platform state. +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const DRY_RUN_COUNTS = { POST: 0, PATCH: 0, DELETE: 0 }; + +export function getDryRunCounts(): { POST: number; PATCH: number; DELETE: number } { + return { ...DRY_RUN_COUNTS }; +} + +function formatBodyPreview(body: Record): string { + // One-line preview: the first ~120 chars of the canonicalized JSON, + // truncated with an ellipsis. Helps the operator see *what* is being + // requested without dumping a multi-page payload per resource. + let preview: string; + try { + preview = JSON.stringify(body); + } catch { + preview = String(body); + } + if (preview.length > 120) preview = `${preview.slice(0, 117)}...`; + return preview; +} + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // HTTP Client for Vapi API // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -60,6 +91,20 @@ export async function vapiRequest( ): Promise { const url = `${VAPI_BASE_URL}${endpoint}`; + if (DRY_RUN) { + DRY_RUN_COUNTS[method]++; + console.log( + ` ๐Ÿงช [dry-run] would ${method} ${endpoint} ${formatBodyPreview(body)}`, + ); + // Returning a stable fake response shaped like a typical create response. + // For PATCH the engine ignores the return (other than for `.id`); for + // POST the engine writes the returned id into state. In dry-run the + // state file is never persisted, so the synthetic id is local-only. + return { + id: `dry-run-${method.toLowerCase()}-${Date.now()}`, + } as unknown as T; + } + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { await throttle(); const response = await fetch(url, { @@ -93,6 +138,12 @@ export async function vapiRequest( export async function vapiDelete(endpoint: string): Promise { const url = `${VAPI_BASE_URL}${endpoint}`; + if (DRY_RUN) { + DRY_RUN_COUNTS.DELETE++; + console.log(` ๐Ÿงช [dry-run] would DELETE ${endpoint}`); + return; + } + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { await throttle(); const response = await fetch(url, { diff --git a/src/config.ts b/src/config.ts index a3a51f2..6510b53 100644 --- a/src/config.ts +++ b/src/config.ts @@ -85,16 +85,19 @@ const VALID_TYPE_ARGS = [ function parseFlags(): { forceDelete: boolean; bootstrapSync: boolean; + dryRun: boolean; applyFilter: ApplyFilter; } { const args = process.argv.slice(3); const result: { forceDelete: boolean; bootstrapSync: boolean; + dryRun: boolean; applyFilter: ApplyFilter; } = { forceDelete: args.includes("--force"), bootstrapSync: args.includes("--bootstrap"), + dryRun: args.includes("--dry-run"), applyFilter: {}, }; @@ -105,7 +108,8 @@ function parseFlags(): { const arg = args[i]; if (!arg) continue; - if (arg === "--force" || arg === "--bootstrap") continue; + if (arg === "--force" || arg === "--bootstrap" || arg === "--dry-run") + continue; // --confirm : consumed by cleanup.ts directly. Eat the value here so // parseFlags' strict-arg check below doesn't trip on the slug. @@ -238,6 +242,7 @@ export const VAPI_ENV = parseEnvironment(); export const { forceDelete: FORCE_DELETE, bootstrapSync: BOOTSTRAP_SYNC, + dryRun: DRY_RUN, applyFilter: APPLY_FILTER, } = parseFlags(); diff --git a/src/push.ts b/src/push.ts index 55563da..078421b 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1,10 +1,11 @@ import { resolve } from "path"; import { fileURLToPath } from "url"; -import { vapiRequest, VapiApiError } from "./api.ts"; +import { vapiRequest, VapiApiError, getDryRunCounts } from "./api.ts"; import { VAPI_ENV, VAPI_BASE_URL, FORCE_DELETE, + DRY_RUN, APPLY_FILTER, BASE_DIR, removeExcludedKeys, @@ -841,6 +842,9 @@ async function main(): Promise { console.log( ` Deletions: ${FORCE_DELETE ? "โš ๏ธ ENABLED (--force)" : "๐Ÿ”’ Disabled (dry-run)"}`, ); + if (DRY_RUN) { + console.log(" Mode: ๐Ÿงช DRY-RUN (no API mutations, no state file write)"); + } if (APPLY_FILTER.resourceTypes?.length) { console.log(` Filter: ${APPLY_FILTER.resourceTypes.join(", ")}`); } @@ -1230,11 +1234,18 @@ async function main(): Promise { console.log( "\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", ); - console.log("โœ… Apply complete!"); + console.log(DRY_RUN ? "๐Ÿงช Dry-run complete (no changes applied)!" : "โœ… Apply complete!"); console.log( "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n", ); + if (DRY_RUN) { + const counts = getDryRunCounts(); + console.log( + `๐Ÿงช Would create ${counts.POST}, would update ${counts.PATCH}, would delete ${counts.DELETE} (no API calls fired)`, + ); + } + // Summary - show what was applied vs total in state const totalApplied = Object.values(applied).reduce((a, b) => a + b, 0); @@ -1275,16 +1286,26 @@ async function main(): Promise { // Always flush state, even on partial failure โ€” resources that already // received UUIDs from the API must be recorded so the next run does not // re-create them. - try { - await saveState(state); - } catch (saveError) { - console.error( - "\nโš ๏ธ Failed to persist state file after apply:", - saveError instanceof Error ? saveError.message : saveError, - ); - console.error( - ` Local state may be out of sync with platform. Run \`npm run pull -- ${VAPI_ENV} --bootstrap\` to recover.`, + // + // EXCEPT in dry-run mode: no real API calls fired, so the state file + // would be polluted with synthetic dry-run UUIDs. Skip the save entirely. + if (DRY_RUN) { + console.log( + "\n๐Ÿงช [dry-run] Skipping state file write (would have written to " + + `.vapi-state.${VAPI_ENV}.json)`, ); + } else { + try { + await saveState(state); + } catch (saveError) { + console.error( + "\nโš ๏ธ Failed to persist state file after apply:", + saveError instanceof Error ? saveError.message : saveError, + ); + console.error( + ` Local state may be out of sync with platform. Run \`npm run pull -- ${VAPI_ENV} --bootstrap\` to recover.`, + ); + } } } } diff --git a/tests/push-dry-run.test.ts b/tests/push-dry-run.test.ts new file mode 100644 index 0000000..dadd4de --- /dev/null +++ b/tests/push-dry-run.test.ts @@ -0,0 +1,118 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + writeFileSync, + rmSync, + cpSync, + symlinkSync, + existsSync, + mkdirSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +// Stack C โ€” push --dry-run regression coverage. +// +// `--dry-run` MUST: +// 1. Be accepted at parse time (no "Unrecognized argument" error) +// 2. Print the dry-run mode banner so the operator can't miss it +// 3. NOT write the state file (a real run would; dry-run never does) +// 4. NOT fire any actual API calls (verified indirectly by lack of API +// error output and by no state-file mutation, plus the "would PATCH/ +// POST/DELETE" log lines) + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, ".."); + +interface Fixture { + dir: string; + cleanup: () => void; +} + +function setupFixture(): Fixture { + const dir = mkdtempSync(join(tmpdir(), "vapi-dry-run-test-")); + cpSync(join(REPO_ROOT, "src"), join(dir, "src"), { recursive: true }); + cpSync(join(REPO_ROOT, "package.json"), join(dir, "package.json")); + symlinkSync(join(REPO_ROOT, "node_modules"), join(dir, "node_modules"), "dir"); + // Empty resource tree โ€” push has nothing real to do, but parsing and the + // dry-run banner must still fire correctly. + mkdirSync(join(dir, "resources", "test-dry-run"), { recursive: true }); + writeFileSync( + join(dir, ".env.test-dry-run"), + "VAPI_TOKEN=fake-token-not-used\n", + ); + return { + dir, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +function runPush( + cwd: string, + extraArgs: string[], +): { code: number | null; stdout: string; stderr: string } { + const result = spawnSync( + "node", + ["--import", "tsx", "src/push.ts", "test-dry-run", ...extraArgs], + { + cwd, + env: { ...process.env, VAPI_TOKEN: "fake-token-not-used" }, + encoding: "utf-8", + timeout: 20_000, + }, + ); + return { + code: result.status, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +test("--dry-run is accepted at parse time without unrecognized-arg error", () => { + const fx = setupFixture(); + try { + const res = runPush(fx.dir, ["--dry-run", "--bootstrap"]); + assert.doesNotMatch(res.stderr, /Unrecognized argument/); + } finally { + fx.cleanup(); + } +}); + +test("--dry-run prints the dry-run banner so the operator sees it", () => { + const fx = setupFixture(); + try { + const res = runPush(fx.dir, ["--dry-run", "--bootstrap"]); + // The banner mentions DRY-RUN explicitly so it's noisy enough not to be + // missed in a CI log scroll. + assert.match(res.stdout, /DRY-RUN/); + } finally { + fx.cleanup(); + } +}); + +test("--dry-run does NOT write the state file", () => { + const fx = setupFixture(); + try { + const stateFilePath = join(fx.dir, ".vapi-state.test-dry-run.json"); + assert.equal( + existsSync(stateFilePath), + false, + "precondition: state file should not exist before run", + ); + + const res = runPush(fx.dir, ["--dry-run", "--bootstrap"]); + // Even with --bootstrap, dry-run must skip the state save. Bootstrap + // would normally write the state file with refreshed credentials/UUIDs; + // in dry-run we want zero filesystem mutation. + assert.equal( + existsSync(stateFilePath), + false, + `state file must not be created in dry-run; stdout=${res.stdout}`, + ); + } finally { + fx.cleanup(); + } +});