@ashlr/stack-core 0.3.1 → 0.3.6

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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/_harness.ts +65 -39
  3. package/src/__tests__/apply-placeholder-history.test.ts +55 -0
  4. package/src/__tests__/apply-placeholder-operator-checklist.test.ts +28 -0
  5. package/src/__tests__/apply-terminology-examples.test.ts +46 -0
  6. package/src/__tests__/config.test.ts +36 -0
  7. package/src/__tests__/credential-staging.test.ts +7 -6
  8. package/src/__tests__/deps.test.ts +35 -0
  9. package/src/__tests__/detect-source.test.ts +97 -0
  10. package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
  11. package/src/__tests__/phantom-process.test.ts +21 -3
  12. package/src/__tests__/phantom-wire-failing-subprocess.test.ts +126 -0
  13. package/src/__tests__/phantom-wire-repeated-placeholders.test.ts +84 -0
  14. package/src/__tests__/phantom-wire-unicode-combining.test.ts +100 -0
  15. package/src/__tests__/phantom-wire.test.ts +6329 -363
  16. package/src/__tests__/pipeline-integration.test.ts +26 -2
  17. package/src/__tests__/recommend-apply-e2e.test.ts +2 -2
  18. package/src/__tests__/secret-redaction.test.ts +13 -3
  19. package/src/ai/phantom-wire-adjacent-different-providers.test.ts +78 -0
  20. package/src/ai/phantom-wire-adjacent-known-placeholders.test.ts +266 -0
  21. package/src/ai/phantom-wire-args.test.ts +63 -0
  22. package/src/ai/phantom-wire-blank-lines.test.ts +174 -0
  23. package/src/ai/phantom-wire-carriage-return.test.ts +125 -0
  24. package/src/ai/phantom-wire-case-changed-placeholder.test.ts +209 -0
  25. package/src/ai/phantom-wire-closing-brace.test.ts +16 -0
  26. package/src/ai/phantom-wire-crlf.test.ts +48 -0
  27. package/src/ai/phantom-wire-dollar-amp.test.ts +128 -0
  28. package/src/ai/phantom-wire-double-quote.test.ts +135 -0
  29. package/src/ai/phantom-wire-empty-replacement.test.ts +122 -0
  30. package/src/ai/phantom-wire-empty-stderr.test.ts +145 -0
  31. package/src/ai/phantom-wire-empty-template.test.ts +81 -0
  32. package/src/ai/phantom-wire-empty-values.test.ts +129 -0
  33. package/src/ai/phantom-wire-errors.test.ts +296 -0
  34. package/src/ai/phantom-wire-known-placeholder-adjacent-punctuation.test.ts +188 -0
  35. package/src/ai/phantom-wire-known-placeholder-at-end.test.ts +160 -0
  36. package/src/ai/phantom-wire-known-placeholder.test.ts +170 -0
  37. package/src/ai/phantom-wire-malformed-json.test.ts +227 -0
  38. package/src/ai/phantom-wire-missing.test.ts +123 -0
  39. package/src/ai/phantom-wire-mixed-placeholders.test.ts +183 -0
  40. package/src/ai/phantom-wire-nested-placeholder.test.ts +149 -0
  41. package/src/ai/phantom-wire-newline.test.ts +136 -0
  42. package/src/ai/phantom-wire-order.test.ts +173 -0
  43. package/src/ai/phantom-wire-placeholder-two-lines.test.ts +196 -0
  44. package/src/ai/phantom-wire-repeated-known-placeholder.test.ts +241 -0
  45. package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +55 -0
  46. package/src/ai/phantom-wire-replacement-dollar.test.ts +170 -0
  47. package/src/ai/phantom-wire-single-quote.test.ts +22 -0
  48. package/src/ai/phantom-wire-tab.test.ts +42 -0
  49. package/src/ai/phantom-wire-unknown-placeholder.test.ts +189 -0
  50. package/src/ai/phantom-wire-vault-name-case.test.ts +46 -0
  51. package/src/ai/phantom-wire.test.ts +144 -0
  52. package/src/ai/phantom-wire.ts +11 -4
  53. package/src/phantom.ts +10 -1
  54. package/src/stack-manifest.ts +3 -3
@@ -0,0 +1,170 @@
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
6
+ import { verificationHintFor, webhookStubFor, wirePhantomForRecipe } from "./phantom-wire.ts";
7
+ import type { Recipe } from "./recipe.ts";
8
+
9
+ // The wiring check spawns the fake `phantom` shim (bash + python3); keep the
10
+ // sibling files' raised timeout.
11
+ setDefaultTimeout(30_000);
12
+
13
+ describe("ai/phantom-wire known placeholder as the entire template", () => {
14
+ let harness: Harness;
15
+ let dir: string;
16
+
17
+ beforeEach(() => {
18
+ harness = setupFakePhantom();
19
+ dir = mkdtempSync(join(tmpdir(), "wire-known-placeholder-"));
20
+ });
21
+ afterEach(() => {
22
+ harness.cleanup();
23
+ rmSync(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ const recipe = (...names: string[]): Recipe => ({
27
+ id: "known-placeholder",
28
+ query: "q",
29
+ createdAt: "2026-04-18T00:00:00.000Z",
30
+ providers: names.map((name) => ({ name })),
31
+ });
32
+
33
+ it("preserves punctuation adjacent to known placeholder replacements", () => {
34
+ const stub = webhookStubFor("stripe");
35
+
36
+ expect(stub).toContain(
37
+ "// Auto-generated by `stack apply` — webhook handler stub for stripe.\n",
38
+ );
39
+ expect(stub).toContain('// const signature = req.headers.get("stripe-signature");\n');
40
+ expect(stub).toContain("// // (`phantom reveal STRIPE_WEBHOOK_SECRET`) and compare in\n");
41
+ expect(stub).toContain(
42
+ "// export async function handleStripeWebhook(req: Request): Promise<Response> {\n",
43
+ );
44
+ expect(stub).not.toContain("${");
45
+ });
46
+
47
+ it.each([
48
+ "${provider}", // the name slot's own spelling
49
+ "${capitalize(provider)}", // the handler-name slot's spelling
50
+ "${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
51
+ "${signatureHeaderFor(provider)}", // the header slot's spelling
52
+ "${verificationHintFor(provider)}", // the hint slot's spelling
53
+ ])("emits the replacement value itself at every site for the whole-name token %j", (name) => {
54
+ // The name is exactly one of the template's slot spellings — the
55
+ // placeholder is the entire input, with no ordinary text around it.
56
+ // At each expansion site the emitted output equals the replacement
57
+ // value (the name, in that site's case); the token-shaped value is
58
+ // never fed back through the slots — a second pass would erase or
59
+ // rewrite it, and none runs.
60
+ const stub = webhookStubFor(name);
61
+
62
+ // Case-preserving sites: the output is the replacement value
63
+ // byte-for-byte. `capitalize` folds only the first code point, and
64
+ // `$` uppercases to itself, so the handler site is identical too.
65
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
66
+ expect(stub).toContain(`forge a ${name} webhook.`);
67
+ expect(stub).toContain(`handle${name}Webhook(req: Request)`);
68
+ // Both `toUpperCase()` secret-key sites hold the whole-name-uppercased
69
+ // value — the fold happens inside the replacement, proof the value was
70
+ // substituted (an unresolved slot would sit in template case).
71
+ expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
72
+
73
+ // Five `${...}` spans — one per expansion site — and every one is the
74
+ // carried token in that site's case, none resolved away or duplicated.
75
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
76
+ expect(tokens).toHaveLength(5);
77
+ for (const token of tokens) expect(token.toLowerCase()).toBe(name.toLowerCase());
78
+
79
+ // The remaining slots took their own values in the same single pass:
80
+ // the generic signature header and the fallback hint — the carried
81
+ // token never reached their switch cases either.
82
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
83
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
84
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
85
+ });
86
+
87
+ it.each([
88
+ ["stripe", "Stripe: `stripe.webhooks.constructEvent` — HMAC-SHA256 with timestamp tolerance."],
89
+ ["clerk", "Clerk (svix): `new Webhook(secret).verify(body, headers)`."],
90
+ ["supabase", "Supabase: `x-supabase-signature` per Supabase's documented webhook scheme."],
91
+ ["github", "GitHub: `x-hub-signature-256` is `sha256=` + HMAC-SHA256 of the raw body."],
92
+ ])("renders the %s hint slot as exactly its replacement value", (provider, hint) => {
93
+ // A known provider name is the whole template of the hint slot: the
94
+ // render is exactly the fixed replacement — no wrapper, no leftover
95
+ // `${...}` — and the stub carries that value once, on the TODO line.
96
+ expect(verificationHintFor(provider)).toBe(hint);
97
+ const stub = webhookStubFor(provider);
98
+ expect(stub.split(`// // ${hint}\n`).length - 1).toBe(1);
99
+ expect(stub).not.toContain("${");
100
+ });
101
+
102
+ it("emits the name where the `${provider}` slot stood — output equals the value it resolved to", () => {
103
+ // The name spells the `provider` slot itself, so at the name sites the
104
+ // emitted text is indistinguishable from an unfilled slot. The proof
105
+ // the output is the *replacement value* and not a skipped slot: the
106
+ // two reveal sites carry `${PROVIDER}` — the value uppercased, work
107
+ // only the substitution path does — while the sibling slots resolved
108
+ // to their own real values. A recursive pass would have re-resolved
109
+ // the carried `${provider}` into something else; every occurrence
110
+ // stays the literal value.
111
+ const name = "${provider}";
112
+ const stub = webhookStubFor(name);
113
+
114
+ // The slot's replacement IS the name: at all three name sites the
115
+ // rendered text equals the value that was passed in.
116
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
117
+ expect(stub).toContain(`handle${name}Webhook(req: Request)`);
118
+ expect(stub).toContain(`forge a ${name} webhook.`);
119
+ expect(stub.split("${provider}").length - 1).toBe(3);
120
+ // Folded at the reveal sites — substituted, not left raw. The folded
121
+ // token appears exactly twice, each time inside a `phantom reveal` line.
122
+ expect(stub.split("phantom reveal ${PROVIDER}_WEBHOOK_SECRET").length - 1).toBe(2);
123
+ expect(stub.split("${PROVIDER}").length - 1).toBe(2);
124
+
125
+ // Single pass: the header and hint slots took real values; the carried
126
+ // `${provider}` token was never offered back to any slot.
127
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
128
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
129
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
130
+ expect(tokens).toEqual([
131
+ "${provider}",
132
+ "${PROVIDER}",
133
+ "${provider}",
134
+ "${provider}",
135
+ "${PROVIDER}",
136
+ ]);
137
+ });
138
+
139
+ it("skips a whole-name known-placeholder token byte-for-byte through wiring and writes nothing for it", async () => {
140
+ // Through `wirePhantomForRecipe` the slot-spelling name matches no
141
+ // catalog provider: it returns in `skipped` byte-for-byte — the entire
142
+ // token intact — gets no stub on disk, and provokes no vault write.
143
+ // The known provider listed beside it wires normally, unaffected by
144
+ // the placeholder-shaped neighbour.
145
+ const name = "${webhookSecretKeyFor(provider)}";
146
+ const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
147
+ const result = await wirePhantomForRecipe(recipe(name, "clerk"), { cwd: dir });
148
+
149
+ expect(result).toEqual({
150
+ present: [],
151
+ missing: ["clerk:CLERK_SECRET_KEY"],
152
+ webhooks: [stubPath],
153
+ skipped: [name],
154
+ });
155
+ expect(result.skipped[0]).toBe(name);
156
+
157
+ // Only clerk's documented stub lands — nothing for the placeholder
158
+ // name, and the stub on disk is the same render `webhookStubFor`
159
+ // returns.
160
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
161
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("clerk"));
162
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
163
+
164
+ // The whole call log is reads (`--version`, `list`); the vault file
165
+ // still parses to an empty object.
166
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
167
+ expect(harness.callsTo("add")).toEqual([]);
168
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
169
+ });
170
+ });
@@ -0,0 +1,227 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ rmSync,
10
+ symlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { StackError } from "../errors.ts";
17
+ import { __resetPhantomCache, __setPhantomSpawn, listSecrets } from "../phantom.ts";
18
+ import { wirePhantomForRecipe } from "./phantom-wire.ts";
19
+ import type { Recipe } from "./recipe.ts";
20
+
21
+ // Stubbed `phantom` subprocess, like the sibling phantom-wire-*.test.ts files:
22
+ // `--version` succeeds and `list --json` emits whatever bytes `listOutput`
23
+ // holds. Because spawn itself is replaced, no child process — and no socket —
24
+ // ever exists: the malformed-inventory path provably makes no network access.
25
+ // `calls` records every argv so the tests can pin the read-only verbs
26
+ // (`--version`, `list --json`) and exclude every mutating one.
27
+ let installed = true;
28
+ let listOutput: string[] | "unreadable" = ["[]"];
29
+ let calls: string[][] = [];
30
+
31
+ function fakeSpawn(_bin: string, args: string[]) {
32
+ calls.push([...args]);
33
+ const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
34
+ const out = new EventEmitter();
35
+ const err = new EventEmitter();
36
+ Object.assign(child, {
37
+ stdout: Object.assign(out, { destroy() {} }),
38
+ stderr: Object.assign(err, { destroy() {} }),
39
+ stdin: { on() {}, write() {}, end() {}, destroy() {} },
40
+ pid: undefined,
41
+ kill() {},
42
+ unref() {},
43
+ });
44
+ queueMicrotask(() => {
45
+ if (!installed) {
46
+ child.emit("error", Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
47
+ return;
48
+ }
49
+ if (args[0] === "list") {
50
+ if (listOutput === "unreadable") {
51
+ child.emit("close", 1, null);
52
+ return;
53
+ }
54
+ // Emit each element as its own `data` event: exec() reassembles stdout
55
+ // from chunks, and only the joined bytes are parsed.
56
+ for (const chunk of listOutput) out.emit("data", Buffer.from(chunk));
57
+ }
58
+ child.emit("close", 0, null);
59
+ });
60
+ return child;
61
+ }
62
+
63
+ function recipeOf(...names: string[]): Recipe {
64
+ return {
65
+ id: "malformed-json",
66
+ query: "malformed",
67
+ createdAt: "2026-04-18T00:00:00.000Z",
68
+ providers: names.map((name) => ({ name })),
69
+ };
70
+ }
71
+
72
+ async function listSecretsError(cwd: string): Promise<unknown> {
73
+ try {
74
+ await listSecrets(cwd);
75
+ } catch (err) {
76
+ return err;
77
+ }
78
+ throw new Error("listSecrets resolved; expected a rejection");
79
+ }
80
+
81
+ describe("ai/phantom-wire malformed vault inventory JSON", () => {
82
+ const savedPath = process.env.PATH;
83
+ let binDir: string;
84
+ let projectDir: string;
85
+
86
+ beforeEach(() => {
87
+ binDir = realpathSync(mkdtempSync(join(tmpdir(), "wire-badjson-bin-")));
88
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), "wire-badjson-")));
89
+ // Binary only needs to resolve on PATH; spawn is stubbed. `.sh` target
90
+ // matches the sibling tests (macOS provenance policy).
91
+ writeFileSync(join(binDir, "phantom.sh"), "#!/bin/sh\n");
92
+ chmodSync(join(binDir, "phantom.sh"), 0o755);
93
+ symlinkSync(join(binDir, "phantom.sh"), join(binDir, "phantom"));
94
+ process.env.PATH = binDir;
95
+ installed = true;
96
+ listOutput = ["[]"];
97
+ calls = [];
98
+ __resetPhantomCache();
99
+ __setPhantomSpawn(fakeSpawn as never);
100
+ });
101
+
102
+ afterEach(() => {
103
+ process.env.PATH = savedPath;
104
+ rmSync(binDir, { recursive: true, force: true });
105
+ rmSync(projectDir, { recursive: true, force: true });
106
+ });
107
+
108
+ afterAll(() => {
109
+ __setPhantomSpawn();
110
+ __resetPhantomCache();
111
+ });
112
+
113
+ it.each([
114
+ // A zero-byte answer is a successful exit with nothing to parse.
115
+ ["empty stdout", [""]],
116
+ ["whitespace only", [" \n\t "]],
117
+ // Truncated mid-entry: the bytes still carry a plausible secret name,
118
+ // which must never be reported as present.
119
+ ["truncated mid-entry", ['[{"name":"STRIPE_SECRET_KEY"']],
120
+ // A valid array followed by junk: JSON.parse rejects trailing content.
121
+ ["trailing garbage", ['[{"name":"STRIPE_SECRET_KEY"}]\n{"name":"EXTRA"}']],
122
+ // The decorated table an older Phantom prints when it ignores `--json` —
123
+ // the exact "upgrade Phantom Secrets" scenario the error names.
124
+ ["human table output", ["-> 1 secret(s) in vault (encrypted-file):\n - STRIPE_SECRET_KEY\n"]],
125
+ // Leading BOM is not JSON whitespace.
126
+ ["BOM-prefixed array", ['\uFEFF[{"name":"STRIPE_SECRET_KEY"}]']],
127
+ // Interleaved garbage across chunk boundaries is still one unparseable body.
128
+ ["noise split across chunks", ['[{"name":"STRIPE', '"}} nonsense [']],
129
+ ])(
130
+ "reports %s as the documented unrecognized-output error, never an uncaught SyntaxError",
131
+ async (_label, chunks) => {
132
+ listOutput = chunks;
133
+
134
+ const err = await listSecretsError(projectDir);
135
+ // The handler's expected error: a typed StackError with the documented
136
+ // code — not the raw JSON.parse SyntaxError escaping uncaught.
137
+ expect(err).toBeInstanceOf(StackError);
138
+ expect(err).not.toBeInstanceOf(SyntaxError);
139
+ expect(err).toMatchObject({
140
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
141
+ message:
142
+ "phantom list --json did not return JSON; upgrade Phantom Secrets to 0.7.9 or newer.",
143
+ });
144
+
145
+ // The wiring request still resolves: the unreadable inventory degrades
146
+ // to `skipped` with no present/missing claims — including the plausible
147
+ // name the truncated bytes contained — while the webhook stub is written.
148
+ const result = await wirePhantomForRecipe(recipeOf("stripe"), { cwd: projectDir });
149
+ expect(result.present).toEqual([]);
150
+ expect(result.missing).toEqual([]);
151
+ expect(result.skipped).toEqual(["stripe"]);
152
+ const stubPath = join(projectDir, ".stack/webhooks/stripe.ts");
153
+ expect(result.webhooks).toEqual([stubPath]);
154
+ expect(existsSync(stubPath)).toBe(true);
155
+
156
+ // Metadata only: the install probe plus two inventory reads. The spawn
157
+ // stub means no process or socket exists; no mutating verb ever runs.
158
+ expect(calls).toEqual([["--version"], ["list", "--json"], ["list", "--json"]]);
159
+ },
160
+ );
161
+
162
+ it.each([
163
+ ["a string entry", '["STRIPE_SECRET_KEY"]'],
164
+ ["a null entry", "[null]"],
165
+ ["a nested array entry", '[["STRIPE_SECRET_KEY"]]'],
166
+ ["a numeric name", '[{"name":42}]'],
167
+ ["an empty name", '[{"name":""}]'],
168
+ ["an entry with no name", '[{"detected_service":null}]'],
169
+ // One malformed entry poisons the whole inventory: names are never
170
+ // partially trusted.
171
+ ["one bad entry among valid ones", '[{"name":"OPENAI_API_KEY"},{"name":42}]'],
172
+ ])(
173
+ "rejects %s in the inventory array and keeps the provider's secrets unknown",
174
+ async (_label, output) => {
175
+ listOutput = [output];
176
+
177
+ await expect(listSecrets(projectDir)).rejects.toMatchObject({
178
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
179
+ message: "phantom list --json contains unrecognized secret-name metadata.",
180
+ });
181
+
182
+ const result = await wirePhantomForRecipe(recipeOf("stripe"), { cwd: projectDir });
183
+ expect(result).toEqual({
184
+ present: [],
185
+ missing: [],
186
+ skipped: ["stripe"],
187
+ webhooks: [join(projectDir, ".stack/webhooks/stripe.ts")],
188
+ });
189
+ expect(calls).toEqual([["--version"], ["list", "--json"], ["list", "--json"]]);
190
+ },
191
+ );
192
+
193
+ it("parses inventory JSON only after all stdout chunks arrive", async () => {
194
+ // Each partial chunk is malformed JSON on its own; only the reassembled
195
+ // body parses. Guards against regressing to per-chunk parsing.
196
+ listOutput = ['[{"name":"STRI', "PE_SEC", 'RET_KEY"}]'];
197
+
198
+ expect(await listSecrets(projectDir)).toEqual(["STRIPE_SECRET_KEY"]);
199
+ const result = await wirePhantomForRecipe(recipeOf("stripe"), { cwd: projectDir });
200
+ expect(result.present).toEqual(["stripe:STRIPE_SECRET_KEY"]);
201
+ expect(result.skipped).toEqual([]);
202
+ });
203
+
204
+ it("keeps malformed inventory bytes off every verb except the read-only probes", async () => {
205
+ listOutput = ['[{"name":"STRIPE_SECRET_KEY"'];
206
+ await wirePhantomForRecipe(recipeOf("stripe"), { cwd: projectDir });
207
+
208
+ // Whatever the vault answer looks like, wiring may probe the install and
209
+ // read names — never `add`, `remove`, `init`, `reveal`, `status`, `sync`.
210
+ for (const argv of calls) expect(["--version", "list"]).toContain(argv[0]);
211
+ expect(calls).toEqual([["--version"], ["list", "--json"]]);
212
+ });
213
+
214
+ it("makes no network access: the module has no network call sites", () => {
215
+ // phantom-wire.ts shells out to a local `phantom` binary and writes files;
216
+ // the only request handler it mentions is the commented-out stub example.
217
+ // Pin that wiring stays metadata-only and cannot reach the network even if
218
+ // a future edit is tempted to fall back to a remote probe.
219
+ const source = readFileSync(
220
+ fileURLToPath(new URL("./phantom-wire.ts", import.meta.url)),
221
+ "utf-8",
222
+ );
223
+ expect(source).not.toMatch(
224
+ /\bfetch\(|node:(http|https|net|dgram|tls)|new WebSocket|XMLHttpRequest|undici/,
225
+ );
226
+ });
227
+ });
@@ -0,0 +1,123 @@
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
6
+ import { addSecret } from "../phantom.ts";
7
+ import { wirePhantomForRecipe } from "./phantom-wire.ts";
8
+ import type { Recipe } from "./recipe.ts";
9
+
10
+ // Each check spawns the fake `phantom` shim (bash + python3); several checks
11
+ // per test multiply that, so keep the sibling files' raised timeout.
12
+ setDefaultTimeout(30_000);
13
+
14
+ describe("ai/phantom-wire repeated missing-value checks", () => {
15
+ let harness: Harness;
16
+ let dir: string;
17
+
18
+ beforeEach(() => {
19
+ harness = setupFakePhantom();
20
+ dir = mkdtempSync(join(tmpdir(), "stack wire missing-"));
21
+ });
22
+ afterEach(() => {
23
+ harness.cleanup();
24
+ rmSync(dir, { recursive: true, force: true });
25
+ });
26
+
27
+ const recipe = (...names: string[]): Recipe => ({
28
+ id: "recheck",
29
+ query: "q",
30
+ createdAt: "2026-04-18T00:00:00.000Z",
31
+ providers: names.map((name) => ({ name })),
32
+ });
33
+
34
+ const vaultText = () => readFileSync(harness.vaultPath, "utf-8");
35
+ // Every logged phantom call must be a read: the install probe or `list`.
36
+ const onlyReadCalls = () =>
37
+ harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list");
38
+
39
+ it("re-reports the same secrets missing on every check and never writes placeholders", async () => {
40
+ const before = vaultText();
41
+ const first = await wirePhantomForRecipe(recipe("stripe", "github"), { cwd: dir });
42
+ const firstMissing = [...first.missing];
43
+
44
+ // A missing secret is a promise to the user ("store with `phantom add`).
45
+ // Re-checking must not shrink the list, move entries to `present`, or
46
+ // quietly absorb them into `skipped` — each would misreport a write.
47
+ for (let i = 0; i < 3; i++) {
48
+ const again = await wirePhantomForRecipe(recipe("stripe", "github"), { cwd: dir });
49
+ expect(again).toEqual(first);
50
+ expect(again.missing).toEqual(["stripe:STRIPE_SECRET_KEY", "github:GITHUB_TOKEN"]);
51
+ expect(again.present).toEqual([]);
52
+ expect(again.skipped).toEqual([]);
53
+ }
54
+ // Each run builds a fresh result; later checks never mutate earlier ones.
55
+ expect(first.missing).toEqual(firstMissing);
56
+
57
+ // Placeholder values would require a vault write. The whole call log is
58
+ // one `--version` probe plus one `list --json` per check — never `add`,
59
+ // `init`, `remove` or `sync` — and the vault file is byte-identical.
60
+ expect(onlyReadCalls()).toBe(true);
61
+ expect(harness.callsTo("list")).toHaveLength(4);
62
+ expect(harness.callsTo("add")).toEqual([]);
63
+ expect(vaultText()).toBe(before);
64
+ expect(JSON.parse(vaultText())).toEqual({});
65
+ });
66
+
67
+ it("keeps a partial vault's present/missing split identical across repeated checks", async () => {
68
+ writeFileSync(harness.vaultPath, JSON.stringify({ SUPABASE_ANON_KEY: "x" }));
69
+ const missing = [
70
+ "supabase:SUPABASE_URL",
71
+ "supabase:SUPABASE_SERVICE_ROLE_KEY",
72
+ "openai:OPENAI_API_KEY",
73
+ ];
74
+
75
+ for (let i = 0; i < 3; i++) {
76
+ const result = await wirePhantomForRecipe(recipe("supabase", "openai"), { cwd: dir });
77
+ expect(result.present).toEqual(["supabase:SUPABASE_ANON_KEY"]);
78
+ expect(result.missing).toEqual(missing);
79
+ expect(result.skipped).toEqual([]);
80
+ }
81
+
82
+ expect(onlyReadCalls()).toBe(true);
83
+ expect(harness.callsTo("add")).toEqual([]);
84
+ expect(JSON.parse(vaultText())).toEqual({ SUPABASE_ANON_KEY: "x" });
85
+ });
86
+
87
+ it("moves a secret to present only after a real `phantom add`, never from re-checking", async () => {
88
+ for (let i = 0; i < 2; i++) {
89
+ const result = await wirePhantomForRecipe(recipe("stripe"), { cwd: dir });
90
+ expect(result.missing).toEqual(["stripe:STRIPE_SECRET_KEY"]);
91
+ expect(result.present).toEqual([]);
92
+ }
93
+ expect(harness.callsTo("add")).toEqual([]);
94
+
95
+ // A real write through Phantom is the only thing that may shrink
96
+ // `missing`; the check itself reports it faithfully afterwards.
97
+ await addSecret("STRIPE_SECRET_KEY", "user-supplied-real-value", dir);
98
+ const after = await wirePhantomForRecipe(recipe("stripe"), { cwd: dir });
99
+ expect(after.present).toEqual(["stripe:STRIPE_SECRET_KEY"]);
100
+ expect(after.missing).toEqual([]);
101
+
102
+ // Exactly one `add` in the log — the explicit write above, in create-only
103
+ // `--stdin` form. The checks contributed none.
104
+ expect(harness.callsTo("add")).toEqual([
105
+ { cmd: "add", args: ["STRIPE_SECRET_KEY", "--stdin"] },
106
+ ]);
107
+ });
108
+
109
+ it("leaves no placeholder artifacts on disk beyond the documented webhook stubs", async () => {
110
+ // `openai` has missing secrets and no webhook stub: a repeated check must
111
+ // not drop a placeholder file for it anywhere in the project tree.
112
+ for (let i = 0; i < 2; i++) {
113
+ await wirePhantomForRecipe(recipe("stripe", "openai", "github"), { cwd: dir });
114
+ }
115
+
116
+ expect(readdirSync(dir)).toEqual([".stack"]);
117
+ expect(readdirSync(join(dir, ".stack"))).toEqual(["webhooks"]);
118
+ expect(readdirSync(join(dir, ".stack", "webhooks")).sort()).toEqual(["github.ts", "stripe.ts"]);
119
+ expect(existsSync(join(dir, ".env"))).toBe(false);
120
+ expect(harness.callsTo("add")).toEqual([]);
121
+ expect(JSON.parse(vaultText())).toEqual({});
122
+ });
123
+ });