@ashlr/stack-core 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-onboard.test.ts +62 -0
  4. package/src/__tests__/base-urls.test.ts +48 -2
  5. package/src/__tests__/catalog.test.ts +31 -0
  6. package/src/__tests__/cloudflare-provider.test.ts +23 -0
  7. package/src/__tests__/config-split.test.ts +66 -0
  8. package/src/__tests__/config.test.ts +92 -3
  9. package/src/__tests__/credential-staging.test.ts +3 -2
  10. package/src/__tests__/detect-source.test.ts +182 -3
  11. package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
  12. package/src/__tests__/import-integration.test.ts +18 -4
  13. package/src/__tests__/inference.test.ts +38 -0
  14. package/src/__tests__/no-orphan-modules.test.ts +114 -0
  15. package/src/__tests__/phantom-harness.test.ts +102 -1
  16. package/src/__tests__/phantom-process.test.ts +135 -0
  17. package/src/__tests__/phantom-wire.test.ts +372 -2
  18. package/src/__tests__/pipeline-integration.test.ts +32 -0
  19. package/src/__tests__/publish-script.test.ts +63 -18
  20. package/src/__tests__/recipe.test.ts +23 -0
  21. package/src/__tests__/recommend-apply-e2e.test.ts +16 -1
  22. package/src/__tests__/scan-missing-dir.test.ts +45 -0
  23. package/src/__tests__/stack-manifest.test.ts +38 -0
  24. package/src/__tests__/template-litellm-gateway.test.ts +29 -0
  25. package/src/__tests__/template-temporal-worker-phantom.test.ts +14 -0
  26. package/src/ai/phantom-wire.ts +44 -6
  27. package/src/detect-source.ts +14 -1
  28. package/src/errors.ts +6 -0
  29. package/src/index.ts +1 -0
  30. package/src/phantom-process.ts +72 -0
  31. package/src/phantom.ts +144 -33
  32. package/src/stack-manifest.ts +39 -0
@@ -1,10 +1,15 @@
1
- import { afterEach, beforeEach, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
5
- import type { Recipe } from "../ai/recipe.ts";
5
+ import { type Recipe, readRecipe, writeRecipe } from "../ai/recipe.ts";
6
+ import { listSecrets } from "../phantom.ts";
6
7
  import { type Harness, setupFakePhantom } from "./_harness.ts";
7
8
 
9
+ // Every test spawns the fake `phantom` shim (bash + several python3 calls),
10
+ // which takes multiple seconds on loaded machines — well past the 5s default.
11
+ setDefaultTimeout(30_000);
12
+
8
13
  describe("ai/phantom-wire", () => {
9
14
  let harness: Harness;
10
15
 
@@ -41,6 +46,226 @@ describe("ai/phantom-wire", () => {
41
46
  expect(result.webhooks).toHaveLength(1);
42
47
  });
43
48
 
49
+ it("wires an altered local recipe without authenticating its contents", async () => {
50
+ const path = await writeRecipe(STRIPE_RECIPE, harness.dir);
51
+ const original = readFileSync(path, "utf-8");
52
+ expect(original).toContain("stripe");
53
+ writeFileSync(path, original.replace('stripe"', 'github"'));
54
+
55
+ const recipe = await readRecipe(STRIPE_RECIPE.id, harness.dir);
56
+ expect(recipe.providers).toEqual([{ name: "github" }]);
57
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
58
+ expect(result.missing).toContain("github:GITHUB_TOKEN");
59
+ expect(result.webhooks).toEqual([join(harness.dir, ".stack/webhooks/github.ts")]);
60
+ });
61
+
62
+ it("generates a commented request handler and response for every webhook provider", async () => {
63
+ const providers = ["stripe", "clerk", "supabase", "github"];
64
+ const recipe: Recipe = {
65
+ ...STRIPE_RECIPE,
66
+ providers: providers.map((name) => ({ name })),
67
+ };
68
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
69
+ expect(result.webhooks).toEqual(
70
+ providers.map((provider) => join(harness.dir, ".stack/webhooks", `${provider}.ts`)),
71
+ );
72
+
73
+ for (const [index, provider] of providers.entries()) {
74
+ const stub = readFileSync(result.webhooks[index], "utf-8");
75
+ const handlerName = `handle${provider[0].toUpperCase()}${provider.slice(1)}Webhook`;
76
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
77
+ expect(example).toBeDefined();
78
+ expect(example).toContain(
79
+ `// export async function ${handlerName}(req: Request): Promise<Response> {`,
80
+ );
81
+ expect(example).toContain('// return new Response("ok", { status: 200 });');
82
+ }
83
+ });
84
+
85
+ it.each([
86
+ ["stripe", "handleStripeWebhook"],
87
+ ["clerk", "handleClerkWebhook"],
88
+ ["supabase", "handleSupabaseWebhook"],
89
+ ["github", "handleGithubWebhook"],
90
+ ])("returns the unsigned %s response without reading the body", async (provider, name) => {
91
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
92
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
93
+ const stub = readFileSync(result.webhooks[0], "utf-8");
94
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
95
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
96
+ const examplePath = join(harness.dir, `${provider}-unsigned-example.ts`);
97
+ writeFileSync(examplePath, example.replace(/^\/\/ ?/gm, ""));
98
+ const handler: unknown = (await import(examplePath))[name];
99
+ if (typeof handler !== "function") throw new Error(`Missing ${name}`);
100
+
101
+ const request = new Request("https://example.com", {
102
+ method: "POST",
103
+ body: "untrusted webhook payload",
104
+ });
105
+ const response: Response = await handler(request);
106
+ expect(response.status).toBe(400);
107
+ expect(await response.text()).toBe("Missing signature");
108
+ expect(request.bodyUsed).toBe(false);
109
+ });
110
+
111
+ it.each([
112
+ ["stripe", "stripe-signature", "handleStripeWebhook"],
113
+ ["clerk", "svix-signature", "handleClerkWebhook"],
114
+ ["supabase", "x-supabase-signature", "handleSupabaseWebhook"],
115
+ ["github", "x-hub-signature-256", "handleGithubWebhook"],
116
+ ])("rejects missing signatures and interrupted %s requests", async (provider, header, name) => {
117
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
118
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
119
+ expect(result.webhooks).toHaveLength(1);
120
+
121
+ const stub = readFileSync(result.webhooks[0], "utf-8");
122
+ expect(stub.split("\n").every((line) => line === "" || line.startsWith("//"))).toBe(true);
123
+ expect(stub).toContain("This entire example is commented out and does not verify signatures.");
124
+ expect(stub).toContain(
125
+ "If uncommented as-is, it accepts any non-empty signature header and returns\n" +
126
+ "// 200 without authenticating the request. Implement verification first.",
127
+ );
128
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
129
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
130
+ expect(example).toContain(
131
+ "// // TODO(security): signature verification is NOT implemented here. The\n" +
132
+ "// // header check above proves only that a signature header arrived; its\n" +
133
+ "// // value is never compared to anything.",
134
+ );
135
+ expect(example).toContain(
136
+ `// // is unauthenticated, attacker-controlled input — anyone who can POST\n// // to this endpoint can forge a ${provider} webhook.`,
137
+ );
138
+ expect(example).toContain(
139
+ "// // constant time; reject missing or invalid signatures, and only then\n" +
140
+ "// // parse, trust, or act on the body.",
141
+ );
142
+ const examplePath = join(harness.dir, `${provider}-example.ts`);
143
+ writeFileSync(examplePath, example.replace(/^\/\/ ?/gm, ""));
144
+ const handler: unknown = (await import(examplePath))[name];
145
+ expect(typeof handler).toBe("function");
146
+ if (typeof handler !== "function") throw new Error(`Missing ${name}`);
147
+
148
+ const missingHeaders: HeadersInit[] = [
149
+ {},
150
+ { "x-unrelated-signature": "present" },
151
+ { [header]: "" },
152
+ ];
153
+ for (const headers of missingHeaders) {
154
+ const response: Response = await handler(new Request("https://example.com", { headers }));
155
+ expect(response.status).toBe(400);
156
+ expect(await response.text()).toBe("Missing signature");
157
+ }
158
+
159
+ // A non-empty header reaches the placeholder; it is not signature verification.
160
+ const response: Response = await handler(
161
+ new Request("https://example.com", {
162
+ method: "POST",
163
+ headers: { [header]: "placeholder" },
164
+ body: "unverified webhook payload",
165
+ }),
166
+ );
167
+ expect(response.status).toBe(200);
168
+
169
+ const networkError = new Error("request body connection lost");
170
+ const interruptedBody = new ReadableStream<Uint8Array>({
171
+ pull(controller) {
172
+ controller.error(networkError);
173
+ },
174
+ });
175
+ const interruptedRequest = new Request("https://example.com", {
176
+ method: "POST",
177
+ headers: { [header]: "placeholder" },
178
+ body: interruptedBody,
179
+ });
180
+ await expect(handler(interruptedRequest)).rejects.toBe(networkError);
181
+ });
182
+
183
+ it.each([
184
+ ["stripe", "stripe-signature", "handleStripeWebhook"],
185
+ ["clerk", "svix-signature", "handleClerkWebhook"],
186
+ ["supabase", "x-supabase-signature", "handleSupabaseWebhook"],
187
+ ["github", "x-hub-signature-256", "handleGithubWebhook"],
188
+ ])(
189
+ "returns the successful %s webhook response after reading the body",
190
+ async (provider, header, name) => {
191
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
192
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
193
+ const stub = readFileSync(result.webhooks[0], "utf-8");
194
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
195
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
196
+ const examplePath = join(harness.dir, `${provider}-success-example.ts`);
197
+ writeFileSync(examplePath, example.replace(/^\/\/ ?/gm, ""));
198
+ const handler: unknown = (await import(examplePath))[name];
199
+ if (typeof handler !== "function") throw new Error(`Missing ${name}`);
200
+
201
+ const request = new Request("https://example.com", {
202
+ method: "POST",
203
+ headers: { [header]: "placeholder" },
204
+ body: "unverified webhook payload",
205
+ });
206
+ const response: Response = await handler(request);
207
+ expect(request.bodyUsed).toBe(true);
208
+ expect(response.status).toBe(200);
209
+ expect(await response.text()).toBe("ok");
210
+ },
211
+ );
212
+
213
+ it.each(["stripe", "clerk", "supabase", "github"])(
214
+ "places the signature verification TODO before trusting the %s webhook body",
215
+ async (provider) => {
216
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
217
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
218
+ expect(result.webhooks).toHaveLength(1);
219
+ const stub = readFileSync(result.webhooks[0], "utf-8");
220
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
221
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
222
+
223
+ // Reading the raw body supplies an input to verification; no executable
224
+ // handling of it should appear before the verification reminder.
225
+ expect(example).toContain(
226
+ "// const body = await req.text();\n" +
227
+ "// // TODO(security): signature verification is NOT implemented here.",
228
+ );
229
+ const todo = example.indexOf("TODO(security): signature verification is NOT implemented");
230
+ const success = example.indexOf('return new Response("ok", { status: 200 })');
231
+ expect(todo).toBeGreaterThanOrEqual(0);
232
+ expect(success).toBeGreaterThan(todo);
233
+ },
234
+ );
235
+
236
+ it.each([
237
+ ["stripe", "stripe.webhooks.constructEvent"],
238
+ ["clerk", "new Webhook(secret).verify(body, headers)"],
239
+ ["supabase", "`x-supabase-signature` per Supabase's documented webhook scheme"],
240
+ ["github", "`sha256=` + HMAC-SHA256"],
241
+ ])(
242
+ "points the %s stub at the provider's verification scheme without claiming it ran",
243
+ async (provider, hint) => {
244
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
245
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
246
+ const example = readFileSync(result.webhooks[0], "utf-8").match(
247
+ /^\/\/ export async function [\s\S]*?^\/\/ }$/m,
248
+ )?.[0];
249
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
250
+
251
+ // The TODO names where verification belongs and how the provider does it.
252
+ expect(example).toContain(hint);
253
+
254
+ // No comment may describe the request as already verified, and the
255
+ // uncommented handler must contain no verification code — the TODO's
256
+ // "NOT implemented" claim has to be true, not aspirational.
257
+ expect(example).not.toMatch(
258
+ /signature verified|verified signature|request is authenticated/i,
259
+ );
260
+ const runnable = example
261
+ .replace(/^\/\/ ?/gm, "")
262
+ .split("\n")
263
+ .filter((line) => !line.trimStart().startsWith("//"))
264
+ .join("\n");
265
+ expect(runnable).not.toMatch(/createHmac|timingSafeEqual|\.verify\(/);
266
+ },
267
+ );
268
+
44
269
  it("noWire: true skips the vault check and webhooks entirely", async () => {
45
270
  const result = await wirePhantomForRecipe(STRIPE_RECIPE, {
46
271
  cwd: harness.dir,
@@ -82,6 +307,151 @@ describe("ai/phantom-wire", () => {
82
307
  expect(harness.callsTo("add")).toEqual([]);
83
308
  });
84
309
 
310
+ it("surfaces a failed inventory command as unavailable and skips providers", async () => {
311
+ const phantomPath = join(harness.dir, "phantom");
312
+ const shim = readFileSync(phantomPath, "utf-8");
313
+ // Even plausible inventory on stdout is not evidence when the command fails.
314
+ expect(shim).toContain(" list)\n");
315
+ writeFileSync(
316
+ phantomPath,
317
+ shim.replace(
318
+ " list)\n",
319
+ ` list)\n echo '[{"name":"STRIPE_SECRET_KEY"}]'\n echo 'inventory unavailable' >&2\n exit 1\n`,
320
+ ),
321
+ );
322
+
323
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
324
+ code: "PHANTOM_METADATA_UNAVAILABLE",
325
+ message: "Phantom secret inventory is unavailable; stored names are unknown.",
326
+ });
327
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: "stripe" }, { name: "neon" }] };
328
+ const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
329
+ expect(await wirePhantomForRecipe(recipe, { cwd: harness.dir })).toEqual({
330
+ present: [],
331
+ missing: [],
332
+ webhooks: [stubPath],
333
+ skipped: ["stripe", "neon"],
334
+ });
335
+ expect(existsSync(stubPath)).toBe(true);
336
+ expect(harness.callsTo("list")).toEqual([
337
+ { cmd: "list", args: ["--json"] },
338
+ { cmd: "list", args: ["--json"] },
339
+ ]);
340
+ expect(harness.callsTo("add")).toEqual([]);
341
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
342
+ });
343
+
344
+ it("treats surrounding whitespace in vault inventory JSON the same as compact JSON", async () => {
345
+ const inventory = '[{"name":"STRIPE_SECRET_KEY"}]';
346
+ const setListOutput = (output: string) => {
347
+ writeFileSync(
348
+ join(harness.dir, "phantom"),
349
+ `#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) printf '%s' '${output}' ;;\n *) exit 1 ;;\nesac\n`,
350
+ );
351
+ };
352
+
353
+ setListOutput(inventory);
354
+ const compact = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
355
+ expect(compact).toEqual({
356
+ present: ["stripe:STRIPE_SECRET_KEY"],
357
+ missing: [],
358
+ skipped: [],
359
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
360
+ });
361
+
362
+ setListOutput(` \t\r\n${inventory}\n\t `);
363
+ expect(await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir })).toEqual(compact);
364
+ });
365
+
366
+ it("rejects malformed vault inventory JSON and keeps the provider's secrets unknown", async () => {
367
+ // A successful command may still return a truncated inventory containing a plausible name.
368
+ writeFileSync(
369
+ join(harness.dir, "phantom"),
370
+ `#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) printf '%s' '[{"name":"STRIPE_SECRET_KEY"}' ;;\n *) exit 1 ;;\nesac\n`,
371
+ );
372
+
373
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
374
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
375
+ message:
376
+ "phantom list --json did not return JSON; upgrade Phantom Secrets to 0.7.9 or newer.",
377
+ });
378
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
379
+ expect(result).toEqual({
380
+ present: [],
381
+ missing: [],
382
+ skipped: ["stripe"],
383
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
384
+ });
385
+ expect(existsSync(result.webhooks[0])).toBe(true);
386
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
387
+ });
388
+
389
+ it("rejects a JSON number from the vault inventory and skips the provider", async () => {
390
+ // Return valid JSON from a successful list command, but not the required array.
391
+ writeFileSync(
392
+ join(harness.dir, "phantom"),
393
+ '#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) echo 42 ;;\n *) exit 1 ;;\nesac\n',
394
+ );
395
+
396
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
397
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
398
+ message: "phantom list --json did not return an array.",
399
+ });
400
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
401
+ expect(result).toEqual({
402
+ present: [],
403
+ missing: [],
404
+ skipped: ["stripe"],
405
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
406
+ });
407
+ expect(existsSync(result.webhooks[0])).toBe(true);
408
+ });
409
+
410
+ it.each(["true", "false"])(
411
+ "rejects JSON boolean %s from the vault inventory and skips the provider",
412
+ async (output) => {
413
+ writeFileSync(
414
+ join(harness.dir, "phantom"),
415
+ `#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) echo ${output} ;;\n *) exit 1 ;;\nesac\n`,
416
+ );
417
+
418
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
419
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
420
+ message: "phantom list --json did not return an array.",
421
+ });
422
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
423
+ expect(result).toEqual({
424
+ present: [],
425
+ missing: [],
426
+ skipped: ["stripe"],
427
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
428
+ });
429
+ expect(existsSync(result.webhooks[0])).toBe(true);
430
+ expect(harness.callsTo("add")).toEqual([]);
431
+ },
432
+ );
433
+
434
+ it("does not report a JSON string vault response as a verified secret", async () => {
435
+ // A successful command returning the expected name as a scalar is not an inventory.
436
+ writeFileSync(
437
+ join(harness.dir, "phantom"),
438
+ `#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) printf '%s' '"STRIPE_SECRET_KEY"' ;;\n *) exit 1 ;;\nesac\n`,
439
+ );
440
+
441
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
442
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
443
+ message: "phantom list --json did not return an array.",
444
+ });
445
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
446
+ expect(result).toEqual({
447
+ present: [],
448
+ missing: [],
449
+ skipped: ["stripe"],
450
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
451
+ });
452
+ expect(existsSync(result.webhooks[0])).toBe(true);
453
+ });
454
+
85
455
  it("skips providers with no webhook stub without crashing", async () => {
86
456
  const recipe: Recipe = {
87
457
  id: "neon",
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
6
+ import { readRecipe, writeRecipe } from "../ai/recipe.ts";
5
7
  import { emptyConfig, writeConfig } from "../config.ts";
6
8
  import { addService } from "../pipeline.ts";
7
9
  import type { Provider } from "../providers/_base.ts";
@@ -93,3 +95,33 @@ describe("addService full loop", () => {
93
95
  Reflect.deleteProperty(providers, "stubsvc");
94
96
  });
95
97
  });
98
+
99
+ test("apply wiring preserves an existing value and leaves missing values absent", async () => {
100
+ const initialVault = {
101
+ NEON_API_KEY: "existing-test-value",
102
+ UNRELATED_KEY: "unrelated-test-value",
103
+ };
104
+ const h = setupFakePhantom(initialVault);
105
+ try {
106
+ const recipe = {
107
+ id: "preserve-existing-value",
108
+ query: "database",
109
+ createdAt: "2026-04-18T00:00:00.000Z",
110
+ providers: [{ name: "neon" }],
111
+ };
112
+ await writeRecipe(recipe, h.dir);
113
+ const loaded = await readRecipe(recipe.id, h.dir);
114
+
115
+ // This is the vault-wiring phase used by stack apply after provisioning.
116
+ const result = await wirePhantomForRecipe(loaded, { cwd: h.dir });
117
+ expect(result.present).toEqual(["neon:NEON_API_KEY"]);
118
+ expect(result.missing).toEqual(["neon:DATABASE_URL"]);
119
+ expect(result.skipped).toEqual([]);
120
+ expect(h.callsTo("list")).toEqual([{ cmd: "list", args: ["--json"] }]);
121
+ expect(h.callsTo("add")).toEqual([]);
122
+ expect(h.callsTo("remove")).toEqual([]);
123
+ expect(await readVault(h.dir)).toEqual(initialVault);
124
+ } finally {
125
+ h.cleanup();
126
+ }
127
+ });
@@ -151,7 +151,6 @@ describe("publish script early parser (copied script, inert commands)", () => {
151
151
  join(bin, "node-reader.cjs"),
152
152
  `const {readFileSync, appendFileSync} = require('node:fs');
153
153
  const {createHash} = require('node:crypto');
154
- const {spawnSync} = require('node:child_process');
155
154
  const input = readFileSync(0);
156
155
  const args = process.argv.slice(2);
157
156
  if (args.length !== 3 || args[0] !== '--input-type=module' || args[1] !== '-'
@@ -159,12 +158,14 @@ if (args.length !== 3 || args[0] !== '--input-type=module' || args[1] !== '-'
159
158
  appendFileSync(process.env.SCRIPT_FIXTURE_TRACE, 'node\\n'); process.exit(97);
160
159
  }
161
160
  appendFileSync(process.env.VALIDATOR_TRACE, 'read\\n');
162
- const result = spawnSync(${JSON.stringify(realNode)}, args, {
163
- cwd: process.cwd(), env: process.env, input, timeout: 1000, maxBuffer: 64 * 1024,
164
- });
165
- if (result.error || result.signal || result.status === null) process.exit(98);
166
- process.stdout.write(result.stdout); process.stderr.write(result.stderr);
167
- process.exit(result.status);
161
+ // Run the hash-checked module in this Node process: a second Node startup
162
+ // can exhaust the outer 1500ms fixture bound under coverage instrumentation.
163
+ process.argv = [process.execPath, '-', args[2]];
164
+ const timer = setTimeout(() => process.exit(98), 1000);
165
+ import('data:text/javascript;base64,' + input.toString('base64')).then(
166
+ () => clearTimeout(timer),
167
+ () => process.exit(98),
168
+ );
168
169
  `,
169
170
  { mode: 0o700 },
170
171
  );
@@ -398,6 +399,37 @@ process.exit(result.status);
398
399
  ).toBeUndefined();
399
400
  }
400
401
  });
402
+ for (const alteration of ["source", "arguments"] as const) {
403
+ test(`validator wrapper refuses altered ${alteration} before evaluation`, async () => {
404
+ removeFixtureBackups();
405
+ const original = readFileSync(script, "utf8");
406
+ const altered =
407
+ alteration === "source"
408
+ ? original.replace(
409
+ "import { lstatSync, readFileSync }",
410
+ "/* changed */ import { lstatSync, readFileSync }",
411
+ )
412
+ : original.replace(
413
+ 'node --input-type=module - "$VERSION"',
414
+ 'node --input-type=module - "$VERSION" extra',
415
+ );
416
+ expect(altered).not.toBe(original);
417
+ writeFileSync(script, altered);
418
+ const result = await runBackupCase();
419
+ expect(result.status).toBe(1);
420
+ expect(result.output).toContain("source version validation failed");
421
+ expect(readFileSync(trace, "utf8")).toBe("node\n");
422
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("");
423
+ for (const name of ["core", "mcp", "cli"]) {
424
+ expect(
425
+ lstatSync(join(root, "packages", name, "package.json.bak"), {
426
+ throwIfNoEntry: false,
427
+ }),
428
+ ).toBeUndefined();
429
+ }
430
+ });
431
+ }
432
+
401
433
  function inputs() {
402
434
  return [
403
435
  ...preserved.map((file) => file.path),
@@ -570,14 +602,17 @@ fi
570
602
 
571
603
  async function preflight(
572
604
  name: string,
573
- version: string,
605
+ tag: string,
574
606
  otp: string,
575
- ref = "refs/heads/synthetic",
607
+ ref = "refs/heads/main",
608
+ event = "workflow_dispatch",
576
609
  ) {
577
610
  const block = workflowBlock(name);
578
- env.RELEASE_INPUT_VERSION = version;
611
+ // Workflow-level RELEASE_TAG: the pushed tag, or the dispatch tag input.
612
+ env.RELEASE_TAG = tag;
579
613
  env.RELEASE_INPUT_OTP = otp;
580
614
  env.GITHUB_REF = ref;
615
+ env.GITHUB_EVENT_NAME = event;
581
616
  env.GITHUB_OUTPUT = join(root, "github-output");
582
617
  writeFileSync(env.GITHUB_OUTPUT, "", { mode: 0o600 });
583
618
  const before = inputs();
@@ -597,7 +632,7 @@ fi
597
632
  ]) {
598
633
  for (const otp of ["synthetic-otp-secret", " ", "$(synthetic-otp-secret)"]) {
599
634
  test(`${name} refuses nonempty unsupported OTP before subprocesses ${JSON.stringify(otp)}`, async () => {
600
- const result = await preflight(name, "0.2.1", otp);
635
+ const result = await preflight(name, "v0.2.1", otp);
601
636
  expect(result.status).toBe(1);
602
637
  expect(result.output).toContain("release input/source validation failed");
603
638
  expect(result.output).not.toContain(otp.trim() || "OTP");
@@ -605,25 +640,35 @@ fi
605
640
  expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
606
641
  });
607
642
  }
608
- test(`${name} actually validates branch source and exports only its version`, async () => {
609
- const result = await preflight(name, "0.2.1", "");
643
+ test(`${name} validates dispatched tag source and exports only its version`, async () => {
644
+ cannedGit("same", "same");
645
+ const result = await preflight(name, "v0.2.1", "");
610
646
  expect(result.status).toBe(0);
611
647
  expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
612
648
  expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("version=0.2.1\n");
613
649
  });
614
- test(`${name} refuses absent, malformed and mismatched version inputs`, async () => {
615
- for (const version of ["", "synthetic-version-secret", "0.2.2"]) {
616
- const result = await preflight(name, version, "");
650
+ test(`${name} refuses absent, malformed and mismatched dispatch tags`, async () => {
651
+ cannedGit("same", "same");
652
+ for (const tag of ["", "vsynthetic-version-secret", "v0.2.2"]) {
653
+ const result = await preflight(name, tag, "");
617
654
  expect(result.status).toBe(1);
618
655
  expect(result.output).not.toContain("synthetic-version-secret");
619
656
  expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
620
657
  }
621
658
  });
659
+ test(`${name} refuses a dispatched tag that is not the checked-out HEAD`, async () => {
660
+ cannedGit("tag", "head");
661
+ const result = await preflight(name, "v0.2.1", "");
662
+ expect(result.status).toBe(1);
663
+ // Source validation ran, so the refusal is the tag/HEAD check.
664
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
665
+ expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
666
+ });
622
667
  test(`${name} validates actual selected-tag coherence`, async () => {
623
668
  cannedGit("same", "same");
624
- const matching = await preflight(name, "0.2.1", "", "refs/tags/v0.2.1");
669
+ const matching = await preflight(name, "v0.2.1", "", "refs/tags/v0.2.1", "push");
625
670
  expect(matching.status).toBe(0);
626
- const mismatch = await preflight(name, "0.2.2", "", "refs/tags/v0.2.1");
671
+ const mismatch = await preflight(name, "v0.2.2", "", "refs/tags/v0.2.1", "push");
627
672
  expect(mismatch.status).toBe(1);
628
673
  expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
629
674
  });
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { parseRecipeDraft } from "../ai/inference.ts";
5
6
  import {
6
7
  type Recipe,
7
8
  listRecipes,
@@ -20,6 +21,28 @@ function scratchDir(): { dir: string; cleanup: () => void } {
20
21
  }
21
22
 
22
23
  describe("ai/recipe", () => {
24
+ it("rejects a draft with no provider steps", () => {
25
+ expect(() => parseRecipeDraft('{"providers":[]}')).toThrow(
26
+ "Inference response contained no usable providers",
27
+ );
28
+ });
29
+
30
+ it("round-trips a saved recipe with no provider steps", async () => {
31
+ const { dir, cleanup } = scratchDir();
32
+ try {
33
+ const recipe: Recipe = {
34
+ id: "empty",
35
+ query: "no providers",
36
+ createdAt: "2026-01-01T00:00:00.000Z",
37
+ providers: [],
38
+ };
39
+ await writeRecipe(recipe, dir);
40
+ expect(await readRecipe(recipe.id, dir)).toEqual(recipe);
41
+ } finally {
42
+ cleanup();
43
+ }
44
+ });
45
+
23
46
  it("round-trips a Recipe through write → read with fields preserved", async () => {
24
47
  const { dir, cleanup } = scratchDir();
25
48
  try {
@@ -18,7 +18,7 @@
18
18
  * hitting the user's actual vault. We assert on its call log.
19
19
  */
20
20
 
21
- import { afterEach, beforeEach, describe, expect, it } from "bun:test";
21
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
22
22
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
24
  import { join } from "node:path";
@@ -33,6 +33,10 @@ import {
33
33
  } from "../ai/recipe.ts";
34
34
  import { type Harness, setupFakePhantom } from "./_harness.ts";
35
35
 
36
+ // `wirePhantomForRecipe` spawns the fake `phantom` shim (bash + several
37
+ // python3 calls), which takes multiple seconds on loaded machines.
38
+ setDefaultTimeout(30_000);
39
+
36
40
  describe("recommend → apply e2e", () => {
37
41
  let harness: Harness;
38
42
  // A project-scratch cwd, separate from the phantom harness's own dir, so
@@ -101,6 +105,17 @@ describe("recommend → apply e2e", () => {
101
105
  expect(harness.callsTo("add")).toEqual([]);
102
106
  const vault = JSON.parse(readFileSync(harness.vaultPath, "utf-8")) as Record<string, string>;
103
107
  expect(vault).toEqual({ NEON_API_KEY: "neon_real" });
108
+
109
+ // Simulate a user adding one missing value, then check again.
110
+ const updatedVault = { ...vault, OPENAI_API_KEY: "openai_real" };
111
+ writeFileSync(harness.vaultPath, JSON.stringify(updatedVault));
112
+ const rechecked = await wirePhantomForRecipe(loaded, { cwd: projectDir });
113
+ expect(rechecked.present).toEqual(["neon:NEON_API_KEY", "openai:OPENAI_API_KEY"]);
114
+ expect(rechecked.missing).toEqual(
115
+ result.missing.filter((name) => name !== "openai:OPENAI_API_KEY"),
116
+ );
117
+ expect(harness.callsTo("add")).toEqual([]);
118
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual(updatedVault);
104
119
  });
105
120
 
106
121
  it("noWire: true performs zero phantom vault calls and writes no webhook stubs", async () => {