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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ npm run push -- <org> # Push all local changes to V
npm run push -- <org> assistants # Push only assistants
npm run push -- <org> resources/<org>/assistants/my-agent.md # Push single file
npm run push -- <org> <path1> <path2> # Push multiple specific files (one state write)
npm run push -- <org> --dry-run # Preview without applying any platform changes
npm run apply -- <org> # Pull then push (full sync)

# Testing
Expand Down
2 changes: 1 addition & 1 deletion improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
53 changes: 52 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -60,6 +91,20 @@ export async function vapiRequest<T = VapiResponse>(
): Promise<T> {
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, {
Expand Down Expand Up @@ -93,6 +138,12 @@ export async function vapiRequest<T = VapiResponse>(
export async function vapiDelete(endpoint: string): Promise<void> {
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, {
Expand Down
7 changes: 6 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
};

Expand All @@ -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 <slug>: consumed by cleanup.ts directly. Eat the value here so
// parseFlags' strict-arg check below doesn't trip on the slug.
Expand Down Expand Up @@ -238,6 +242,7 @@ export const VAPI_ENV = parseEnvironment();
export const {
forceDelete: FORCE_DELETE,
bootstrapSync: BOOTSTRAP_SYNC,
dryRun: DRY_RUN,
applyFilter: APPLY_FILTER,
} = parseFlags();

Expand Down
43 changes: 32 additions & 11 deletions src/push.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -841,6 +842,9 @@ async function main(): Promise<void> {
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(", ")}`);
}
Expand Down Expand Up @@ -1230,11 +1234,18 @@ async function main(): Promise<void> {
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);

Expand Down Expand Up @@ -1275,16 +1286,26 @@ async function main(): Promise<void> {
// 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.`,
);
}
}
}
}
Expand Down
118 changes: 118 additions & 0 deletions tests/push-dry-run.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});