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(); + } +});