@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
@@ -1,9 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync } from "node:fs";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
6
- import { readRecipe, writeRecipe } from "../ai/recipe.ts";
6
+ import { type Recipe, readRecipe, writeRecipe } from "../ai/recipe.ts";
7
7
  import { emptyConfig, writeConfig } from "../config.ts";
8
8
  import { addService } from "../pipeline.ts";
9
9
  import type { Provider } from "../providers/_base.ts";
@@ -94,6 +94,30 @@ describe("addService full loop", () => {
94
94
 
95
95
  Reflect.deleteProperty(providers, "stubsvc");
96
96
  });
97
+
98
+ test("stack apply reports every missing value without writing placeholders", async () => {
99
+ const vault = { NEON_API_KEY: "existing-value", UNRELATED_KEY: "keep-me" };
100
+ writeFileSync(h.vaultPath, JSON.stringify(vault));
101
+ const recipe: Recipe = {
102
+ id: "missing-values",
103
+ query: "database and ai",
104
+ createdAt: "2026-04-18T00:00:00.000Z",
105
+ providers: [{ name: "neon" }, { name: "openai" }, { name: "anthropic" }],
106
+ };
107
+ await writeRecipe(recipe, cwd);
108
+
109
+ // Apply reads the saved recipe and uses this wiring result for its report.
110
+ const result = await wirePhantomForRecipe(await readRecipe(recipe.id, cwd), { cwd });
111
+ expect(result).toEqual({
112
+ present: ["neon:NEON_API_KEY"],
113
+ missing: ["neon:DATABASE_URL", "openai:OPENAI_API_KEY", "anthropic:ANTHROPIC_API_KEY"],
114
+ webhooks: [],
115
+ skipped: [],
116
+ });
117
+ expect(h.callsTo("list").length).toBeGreaterThan(0);
118
+ expect(h.callsTo("add")).toEqual([]);
119
+ expect(await readVault(h.dir)).toEqual(vault);
120
+ });
97
121
  });
98
122
 
99
123
  test("apply wiring preserves an existing value and leaves missing values absent", async () => {
@@ -33,8 +33,8 @@ 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.
36
+ // `wirePhantomForRecipe` spawns the fake `phantom` shim (bash + a Bun JSON
37
+ // helper), which takes multiple seconds on loaded machines.
38
38
  setDefaultTimeout(30_000);
39
39
 
40
40
  describe("recommend → apply e2e", () => {
@@ -92,7 +92,7 @@ describe("phantom error formatting — regression guard for formatArgsForError",
92
92
  * exercise the error path.
93
93
  */
94
94
  function setupAlwaysFailingPhantom(): { cleanup(): void } {
95
- const { chmodSync, mkdtempSync, rmSync, writeFileSync } = require("node:fs") as typeof import("node:fs"); // biome-ignore format: Bun parser rejects split typeof import()
95
+ const { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } = require("node:fs") as typeof import("node:fs"); // biome-ignore format: Bun parser rejects split typeof import()
96
96
  const { tmpdir } = require("node:os") as typeof import("node:os");
97
97
  const { join } = require("node:path") as typeof import("node:path");
98
98
  const { __resetPhantomCache } = require("../phantom.ts") as typeof import("../phantom.ts");
@@ -104,9 +104,19 @@ case "\${1:-}" in
104
104
  *) echo "boom" >&2; exit 1 ;;
105
105
  esac
106
106
  `;
107
+ // Sandboxed runners and macOS Gatekeeper's provenance policy refuse to
108
+ // exec an extensionless file written by a non-platform process, while a
109
+ // `*.sh` target passes the check: point `phantom` at a `phantom.sh` shim.
107
110
  const binPath = join(dir, "phantom");
108
- writeFileSync(binPath, script);
109
- chmodSync(binPath, 0o755);
111
+ const realPath = join(dir, "phantom.sh");
112
+ writeFileSync(realPath, script);
113
+ chmodSync(realPath, 0o755);
114
+ try {
115
+ symlinkSync(realPath, binPath);
116
+ } catch {
117
+ writeFileSync(binPath, script);
118
+ chmodSync(binPath, 0o755);
119
+ }
110
120
 
111
121
  const previousPath = process.env.PATH ?? "";
112
122
  process.env.PATH = `${dir}:${previousPath}`;
@@ -0,0 +1,78 @@
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { mkdtempSync, readFileSync, 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
+ setDefaultTimeout(30_000);
10
+
11
+ // Each known provider's own values for the stub's placeholders.
12
+ const OWN = {
13
+ stripe: { header: "stripe-signature", secret: "STRIPE_WEBHOOK_SECRET", cap: "Stripe" },
14
+ clerk: { header: "svix-signature", secret: "CLERK_WEBHOOK_SECRET", cap: "Clerk" },
15
+ supabase: { header: "x-supabase-signature", secret: "SUPABASE_WEBHOOK_SECRET", cap: "Supabase" },
16
+ github: { header: "x-hub-signature-256", secret: "GITHUB_WEBHOOK_SECRET", cap: "Github" },
17
+ } as const;
18
+ type Known = keyof typeof OWN;
19
+ const KNOWN = Object.keys(OWN) as Known[];
20
+
21
+ /** Assert a stub carries this provider's values and none of the others'. */
22
+ function expectOwnValuesOnly(stub: string, provider: Known) {
23
+ const own = OWN[provider];
24
+ expect(stub).toContain(`webhook handler stub for ${provider}.`);
25
+ expect(stub).toContain(`export async function handle${own.cap}Webhook(`);
26
+ expect(stub).toContain(`req.headers.get("${own.header}")`);
27
+ expect(stub).toContain(verificationHintFor(provider));
28
+ expect(stub.split(`phantom reveal ${own.secret}`).length - 1).toBe(2);
29
+ for (const other of KNOWN.filter((k) => k !== provider)) {
30
+ expect(stub).not.toContain(OWN[other].header);
31
+ expect(stub).not.toContain(OWN[other].secret);
32
+ expect(stub).not.toContain(`handle${OWN[other].cap}Webhook`);
33
+ expect(stub).not.toContain(verificationHintFor(other));
34
+ }
35
+ }
36
+
37
+ describe("ai/phantom-wire adjacent different known providers", () => {
38
+ let harness: Harness;
39
+ let dir: string;
40
+
41
+ beforeEach(() => {
42
+ harness = setupFakePhantom();
43
+ dir = mkdtempSync(join(tmpdir(), "wire-adjacent-different-"));
44
+ });
45
+ afterEach(() => {
46
+ harness.cleanup();
47
+ rmSync(dir, { recursive: true, force: true });
48
+ });
49
+
50
+ it.each(KNOWN)("renders %s with only its own values", (provider) => {
51
+ expectOwnValuesOnly(webhookStubFor(provider), provider);
52
+ });
53
+
54
+ it("gives each adjacent provider its own stub, secrets and result slot, in order", async () => {
55
+ const recipe: Recipe = {
56
+ id: "adjacent-different",
57
+ query: "q",
58
+ createdAt: "2026-04-18T00:00:00.000Z",
59
+ providers: KNOWN.map((name) => ({ name })),
60
+ };
61
+ const result = await wirePhantomForRecipe(recipe, { cwd: dir });
62
+
63
+ // Webhook paths follow recipe order, one per provider.
64
+ expect(result.webhooks).toEqual(KNOWN.map((p) => join(dir, ".stack", "webhooks", `${p}.ts`)));
65
+ // Every reported secret is prefixed by its own provider, grouped in order.
66
+ const order = [...result.present, ...result.missing].map((e) => e.split(":")[0]);
67
+ const firstSeen = [...new Set(order)];
68
+ expect(firstSeen).toEqual(KNOWN.filter((p) => firstSeen.includes(p)));
69
+ expect(result.skipped).toEqual([]);
70
+
71
+ // Each file on disk carries its own provider's values only.
72
+ for (const [i, provider] of KNOWN.entries()) {
73
+ expectOwnValuesOnly(readFileSync(result.webhooks[i] as string, "utf-8"), provider);
74
+ }
75
+ // Wiring only reads the vault.
76
+ expect(harness.callsTo("add")).toEqual([]);
77
+ });
78
+ });
@@ -0,0 +1,266 @@
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
+ const count = (s: string, needle: string) => s.split(needle).length - 1;
14
+
15
+ describe("ai/phantom-wire adjacent known placeholders in a provider name", () => {
16
+ let harness: Harness;
17
+ let dir: string;
18
+
19
+ beforeEach(() => {
20
+ harness = setupFakePhantom();
21
+ dir = mkdtempSync(join(tmpdir(), "wire-adjacent-known-"));
22
+ });
23
+ afterEach(() => {
24
+ harness.cleanup();
25
+ rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ const recipe = (...names: string[]): Recipe => ({
29
+ id: "adjacent-known",
30
+ query: "q",
31
+ createdAt: "2026-04-18T00:00:00.000Z",
32
+ providers: names.map((name) => ({ name })),
33
+ });
34
+
35
+ it.each([
36
+ "${provider}${signatureHeaderFor(provider)}", // pair is the whole name
37
+ "${signatureHeaderFor(provider)}${provider}", // reversed order
38
+ "pay${capitalize(provider)}${webhookSecretKeyFor(provider)}ment", // pair mid-name
39
+ "hook${verificationHintFor(provider)}${provider}line", // hint slot beside the name slot
40
+ "${capitalize(provider)}${signatureHeaderFor(provider)}hook", // pair leads the name
41
+ "pay${webhookSecretKeyFor(provider)}${verificationHintFor(provider)}", // pair ends the name
42
+ ])("keeps the adjacent pair %j flush and in order at every expansion site", (name) => {
43
+ // Each token in the pair spells one of the template's own slots — both
44
+ // are known placeholders — but arriving inside the name they are data.
45
+ // They must land at all five expansion sites flush against each other,
46
+ // in the order the name gave them: nothing spliced between the first
47
+ // token's `}` and the second's `${`, neither token resolved to the value
48
+ // its spelling names, neither reordered.
49
+ const stub = webhookStubFor(name);
50
+ const pair = name.match(/\$\{[^}]*\}\$\{[^}]*\}/)?.[0] as string;
51
+ const first = pair.match(/^\$\{[^}]*\}/)?.[0] as string;
52
+ const second = pair.slice(first.length);
53
+
54
+ // The name — pair included — lands byte-for-byte at the three
55
+ // case-preserving sites and whole-name uppercased at both secret-key
56
+ // sites. `capitalize` folds only a leading letter; a `$`-led name is
57
+ // carried unchanged.
58
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
59
+ expect(stub).toContain(`forge a ${name} webhook.`);
60
+ expect(stub).toContain(`handle${name[0].toUpperCase()}${name.slice(1)}Webhook(req: Request)`);
61
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
62
+
63
+ // Both values appear in order without extra characters: the flush pair
64
+ // occurs exact-case at the three mixed-case sites and uppercased
65
+ // together at the two secret-key sites — and at each occurrence the
66
+ // second token starts on the very next byte after the first ends.
67
+ for (const [variant, sites] of [
68
+ [pair, 3],
69
+ [pair.toUpperCase(), 2],
70
+ ] as const) {
71
+ const f = variant === pair ? first : first.toUpperCase();
72
+ const s = variant === pair ? second : second.toUpperCase();
73
+ let occurrences = 0;
74
+ for (let at = stub.indexOf(variant); at !== -1; at = stub.indexOf(variant, at + 1)) {
75
+ occurrences++;
76
+ expect(stub.startsWith(f, at)).toBe(true);
77
+ // Zero bytes between the two values: second begins exactly where
78
+ // first ends — no separator inserted, no byte of either eaten.
79
+ expect(stub.startsWith(s, at + f.length)).toBe(true);
80
+ }
81
+ expect(occurrences).toBe(sites);
82
+ }
83
+ // The pair never lands transposed, in either casing.
84
+ expect(stub).not.toContain(second + first);
85
+ expect(stub).not.toContain(second.toUpperCase() + first.toUpperCase());
86
+
87
+ // Two tokens per name × five sites: no `${` minted, dropped, or
88
+ // re-expanded into the value its spelling names.
89
+ expect(count(stub, "${")).toBe(10);
90
+ // The template's real slots still take their own known values, once
91
+ // each — the generic signature header at its own site, the fallback
92
+ // hint at its own — never re-spliced into a name site.
93
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
94
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
95
+ expect(stub).not.toContain("WEBHOOK_SECRET_WEBHOOK_SECRET");
96
+ });
97
+
98
+ it.each([
99
+ "${provider}", // the name slot's own spelling
100
+ "${capitalize(provider)}", // the handler-name slot's spelling
101
+ "${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
102
+ "${signatureHeaderFor(provider)}", // the header slot's spelling
103
+ "${verificationHintFor(provider)}", // the hint slot's spelling
104
+ ])(
105
+ "keeps the doubled token %j flush — both copies in order, nothing between — at every expansion site",
106
+ (slot) => {
107
+ // The whole name is one known placeholder spelling written twice,
108
+ // back-to-back: an adjacent pair whose two values are identical, so
109
+ // "in order" is purely positional — the first copy occupies its span
110
+ // and the second begins on the very byte the first ends. Each copy is
111
+ // data: neither may resolve to the value its spelling names, merge
112
+ // into a single span, gain a separator, or re-expand into a third.
113
+ const name = `${slot}${slot}`;
114
+ const stub = webhookStubFor(name);
115
+
116
+ // The name — both copies included — lands byte-for-byte at the three
117
+ // case-preserving sites and whole-name uppercased at both secret-key
118
+ // sites. `capitalize` leaves the `$`-led name unchanged.
119
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
120
+ expect(stub).toContain(`forge a ${name} webhook.`);
121
+ expect(stub).toContain(`handle${name}Webhook(req: Request)`);
122
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
123
+
124
+ // Both values appear in order without added characters: the flush
125
+ // pair occurs exact-case at the three mixed-case sites and uppercased
126
+ // together at the two secret-key sites — at each occurrence the first
127
+ // copy fills its span and the second starts on the byte it ends.
128
+ for (const [pair, sites] of [
129
+ [slot + slot, 3],
130
+ [(slot + slot).toUpperCase(), 2],
131
+ ] as const) {
132
+ const v = pair === slot + slot ? slot : slot.toUpperCase();
133
+ let occurrences = 0;
134
+ for (let at = stub.indexOf(pair); at !== -1; at = stub.indexOf(pair, at + 1)) {
135
+ occurrences++;
136
+ expect(stub.startsWith(v, at)).toBe(true);
137
+ // Zero bytes between the two values: the second copy begins
138
+ // exactly where the first ends — no separator inserted, no byte
139
+ // of either eaten.
140
+ expect(stub.startsWith(v, at + v.length)).toBe(true);
141
+ }
142
+ expect(occurrences).toBe(sites);
143
+
144
+ // Every single-token occurrence belongs to a flush pair: scan the
145
+ // lone spelling and require a sibling touching it on exactly one
146
+ // side — a lone token or a pair with a spliced separator fails here.
147
+ let singles = 0;
148
+ for (let at = stub.indexOf(v); at !== -1; at = stub.indexOf(v, at + 1)) {
149
+ singles++;
150
+ const leftSibling = at >= v.length && stub.startsWith(v, at - v.length);
151
+ const rightSibling = stub.startsWith(v, at + v.length);
152
+ expect(leftSibling !== rightSibling).toBe(true);
153
+ }
154
+ expect(singles).toBe(sites * 2);
155
+ }
156
+
157
+ // No third copy: a duplication or re-expansion would mint a longer
158
+ // run — absent in either casing.
159
+ expect(stub).not.toContain(slot + slot + slot);
160
+ expect(stub).not.toContain((slot + slot + slot).toUpperCase());
161
+
162
+ // Two copies per name × five sites: no `${` minted, dropped, or
163
+ // fused — each copy stays its own `${...}` span.
164
+ expect(count(stub, "${")).toBe(10);
165
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
166
+ expect(tokens).toHaveLength(10);
167
+ for (const token of tokens) expect(token.toLowerCase()).toBe(slot.toLowerCase());
168
+
169
+ // The template's real slots still take their own known values, once
170
+ // each — never re-spliced into a name site.
171
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
172
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
173
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
174
+ },
175
+ );
176
+
177
+ it("keeps a three-token run flush and in order at every expansion site", () => {
178
+ // Adjacency generalizes: three known spellings back-to-back inside the
179
+ // name. The run is data — each site's expansion emits all three tokens
180
+ // in the name's order, the next starting on the byte the previous ends,
181
+ // with no inserted separator and the middle token neither dropped nor
182
+ // reseated.
183
+ const t1 = "${provider}";
184
+ const t2 = "${capitalize(provider)}";
185
+ const t3 = "${signatureHeaderFor(provider)}";
186
+ const name = `${t1}${t2}${t3}trail`;
187
+ const run = t1 + t2 + t3;
188
+ const stub = webhookStubFor(name);
189
+
190
+ // The name — run included — lands byte-for-byte at the three
191
+ // case-preserving sites and whole-name uppercased at both secret-key
192
+ // sites. `capitalize` leaves the `$`-led name unchanged.
193
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
194
+ expect(stub).toContain(`forge a ${name} webhook.`);
195
+ expect(stub).toContain(`handle${name}Webhook(req: Request)`);
196
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
197
+
198
+ // At every occurrence, in both casings: the three values sit in the
199
+ // name's order, each starting on the byte the previous one ends.
200
+ for (const [variant, sites] of [
201
+ [run, 3],
202
+ [run.toUpperCase(), 2],
203
+ ] as const) {
204
+ const [a, b, c] =
205
+ variant === run ? [t1, t2, t3] : [t1.toUpperCase(), t2.toUpperCase(), t3.toUpperCase()];
206
+ let occurrences = 0;
207
+ for (let at = stub.indexOf(variant); at !== -1; at = stub.indexOf(variant, at + 1)) {
208
+ occurrences++;
209
+ expect(stub.startsWith(a, at)).toBe(true);
210
+ // Zero bytes between values: b begins exactly where a ends, c
211
+ // exactly where b ends — no separator inserted, no byte eaten.
212
+ expect(stub.startsWith(b, at + a.length)).toBe(true);
213
+ expect(stub.startsWith(c, at + a.length + b.length)).toBe(true);
214
+ }
215
+ expect(occurrences).toBe(sites);
216
+ }
217
+
218
+ // No reordering: neither adjacent swap nor the reversed run appears,
219
+ // in either casing.
220
+ for (const swapped of [t2 + t1, t3 + t2, t3 + t2 + t1]) {
221
+ expect(stub).not.toContain(swapped);
222
+ expect(stub).not.toContain(swapped.toUpperCase());
223
+ }
224
+
225
+ // Three tokens per name × five sites: no `${` minted, dropped, or
226
+ // re-expanded into the value its spelling names.
227
+ expect(count(stub, "${")).toBe(15);
228
+ // The template's real slots still take their own values, once each.
229
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
230
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
231
+ });
232
+
233
+ it("skips a pair-carrying name byte-for-byte through wiring and writes nothing for it", async () => {
234
+ // Through `wirePhantomForRecipe` the name matches no catalog provider:
235
+ // it returns in `skipped` byte-for-byte — both tokens and their flush
236
+ // adjacency intact — gets no stub on disk, and provokes no vault write.
237
+ // The known provider listed beside it wires normally.
238
+ const name = "pay${capitalize(provider)}${webhookSecretKeyFor(provider)}ment";
239
+ const stubPath = join(dir, ".stack", "webhooks", "stripe.ts");
240
+ const result = await wirePhantomForRecipe(recipe(name, "stripe"), { cwd: dir });
241
+
242
+ expect(result).toEqual({
243
+ present: [],
244
+ missing: ["stripe:STRIPE_SECRET_KEY"],
245
+ webhooks: [stubPath],
246
+ skipped: [name],
247
+ });
248
+ expect(result.skipped[0]).toBe(name);
249
+
250
+ // Only stripe's documented stub lands — nothing for the pair-carrying
251
+ // name, and the stub on disk is the same render `webhookStubFor`
252
+ // returns, fully substituted.
253
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["stripe.ts"]);
254
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("stripe"));
255
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
256
+
257
+ // The whole call log is reads (`--version`, `list`); the vault file
258
+ // still parses to an empty object.
259
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
260
+ expect(harness.callsTo("add")).toEqual([]);
261
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
262
+
263
+ // The hint is a fixed string: a pair-carrying name never leaks into it.
264
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
265
+ });
266
+ });
@@ -0,0 +1,63 @@
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, readFileSync, 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 { exec } from "../phantom.ts";
7
+ import { wirePhantomForRecipe } from "./phantom-wire.ts";
8
+ import type { Recipe } from "./recipe.ts";
9
+
10
+ // Each test spawns the fake `phantom` shim (bash + python3).
11
+ setDefaultTimeout(30_000);
12
+
13
+ describe("ai/phantom-wire argv handling with spaces", () => {
14
+ let harness: Harness;
15
+ let spaced: string;
16
+
17
+ beforeEach(() => {
18
+ harness = setupFakePhantom({ STRIPE_SECRET_KEY: "x" });
19
+ spaced = join(mkdtempSync(join(tmpdir(), "stack wire args-")), "my project dir");
20
+ mkdirSync(spaced, { recursive: true });
21
+ });
22
+ afterEach(() => {
23
+ harness.cleanup();
24
+ rmSync(join(spaced, ".."), { recursive: true, force: true });
25
+ });
26
+
27
+ const recipe = (name: string): Recipe => ({
28
+ id: "spaces",
29
+ query: "q",
30
+ createdAt: "2026-04-18T00:00:00.000Z",
31
+ providers: [{ name }],
32
+ });
33
+
34
+ it("runs phantom with fixed argv and a project path containing spaces intact", async () => {
35
+ const result = await wirePhantomForRecipe(recipe("stripe"), { cwd: spaced });
36
+
37
+ // The spaced path is the child's cwd, never split into argv.
38
+ const list = harness.callsTo("list");
39
+ expect(list).toHaveLength(1);
40
+ expect(list[0]?.args).toEqual(["--json"]);
41
+ expect(result.present).toContain("stripe:STRIPE_SECRET_KEY");
42
+ expect(result.webhooks).toEqual([join(spaced, ".stack", "webhooks", "stripe.ts")]);
43
+ expect(readFileSync(result.webhooks[0] as string, "utf-8")).toContain("stripe");
44
+ });
45
+
46
+ it("never forwards a provider name containing spaces to phantom", async () => {
47
+ const result = await wirePhantomForRecipe(recipe("my provider name"), { cwd: spaced });
48
+
49
+ expect(result.skipped).toEqual(["my provider name"]);
50
+ for (const call of harness.calls()) {
51
+ expect(call.args.join("\0")).not.toContain("my provider name");
52
+ }
53
+ });
54
+
55
+ it("delivers an argument containing spaces to the subprocess as one argument", async () => {
56
+ const value = "SECRET WITH SPACES";
57
+ await exec(["remove", value], { cwd: spaced, allowFailure: true });
58
+
59
+ const calls = harness.callsTo("remove");
60
+ expect(calls).toHaveLength(1);
61
+ expect(calls[0]?.args).toEqual([value]);
62
+ });
63
+ });
@@ -0,0 +1,174 @@
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
+ // Each of the template's own slot spellings, usable inside a provider name
14
+ // as a known placeholder.
15
+ const SLOT_TOKENS = [
16
+ "${provider}", // the name slot's own spelling
17
+ "${capitalize(provider)}", // the handler-name slot's spelling
18
+ "${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
19
+ "${signatureHeaderFor(provider)}", // the header slot's spelling
20
+ "${verificationHintFor(provider)}", // the hint slot's spelling
21
+ ];
22
+
23
+ // A blank line is a newline with another newline right behind it. Counting
24
+ // the `\n`s that run straight into a second `\n` needs no assumptions about
25
+ // where the run sits: a run of k consecutive newlines — leading, trailing,
26
+ // or interior — always scores k-1 blank lines, and a run of one scores 0.
27
+ const blankLines = (s: string) => s.match(/\n(?=\n)/g)?.length ?? 0;
28
+
29
+ const count = (s: string, needle: string) => s.split(needle).length - 1;
30
+
31
+ describe("ai/phantom-wire known placeholder in a multiline template with blank lines", () => {
32
+ let harness: Harness;
33
+ let dir: string;
34
+
35
+ beforeEach(() => {
36
+ harness = setupFakePhantom();
37
+ dir = mkdtempSync(join(tmpdir(), "wire-blank-lines-"));
38
+ });
39
+ afterEach(() => {
40
+ harness.cleanup();
41
+ rmSync(dir, { recursive: true, force: true });
42
+ });
43
+
44
+ const recipe = (...names: string[]): Recipe => ({
45
+ id: "blank-lines",
46
+ query: "q",
47
+ createdAt: "2026-04-18T00:00:00.000Z",
48
+ providers: names.map((name) => ({ name })),
49
+ });
50
+
51
+ it.each(SLOT_TOKENS.map((token) => `alpha\n\n${token}\n\nomega`))(
52
+ "preserves the blank lines around the carried token %j at every expansion site",
53
+ (name) => {
54
+ // The name is itself a little multiline template: ordinary text, a
55
+ // blank line, one of the stub's own slot spellings on a line of its
56
+ // own, another blank line, more text. As replacement text the whole
57
+ // thing is data — a resolver would resolve the token away, a
58
+ // whitespace normalizer would collapse the doubled newlines. The
59
+ // template's single pass does neither: the name lands at each of the
60
+ // five expansion sites with token and blank lines intact.
61
+ const stub = webhookStubFor(name);
62
+ const token = name.match(/\$\{[^}]*\}/)?.[0] as string;
63
+ const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
64
+
65
+ // The three case-preserving sites carry the name — blank lines and
66
+ // carried token included — byte-for-byte; `capitalize` folds only
67
+ // the name's first letter at the handler site.
68
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
69
+ expect(stub).toContain(`forge a ${name} webhook.`);
70
+ expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
71
+ // `webhookSecretKeyFor(provider)` uppercases the whole name: the
72
+ // letters fold, the newline bytes and the token's `$`/`{`/`}` do
73
+ // not. Both reveal lines hold the folded name, blank runs and all.
74
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
75
+
76
+ // The stub template itself contains not one blank line — its comment
77
+ // lines all carry at least `//` — so every blank line in the output
78
+ // is one the name carried. Two blank lines in the name x five
79
+ // expansion sites: substitution preserved all ten, collapsing none
80
+ // and minting none.
81
+ expect(blankLines(webhookStubFor("plain"))).toBe(0);
82
+ expect(blankLines(name)).toBe(2);
83
+ expect(blankLines(stub)).toBe(5 * blankLines(name));
84
+
85
+ // The carried token still sits on its own line between the name's
86
+ // two blank lines at every site — in original case at the two
87
+ // verbatim sites plus the handler site (interior text is
88
+ // untouched), uppercased at the two reveal sites.
89
+ expect(count(stub, `\n\n${token}\n\n`)).toBe(3);
90
+ expect(count(stub, `\n\n${token.toUpperCase()}\n\n`)).toBe(2);
91
+
92
+ // Five `${...}` spans — one per expansion site — every one the
93
+ // carried token in that site's case, none resolved away or doubled.
94
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
95
+ expect(tokens).toHaveLength(5);
96
+ for (const t of tokens) expect(t.toLowerCase()).toBe(token.toLowerCase());
97
+
98
+ // The remaining slots took their own generic values in the same
99
+ // single pass — the carried token never reached their switch cases.
100
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
101
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
102
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
103
+ },
104
+ );
105
+
106
+ it.each([
107
+ "${provider}\n\nbody", // token on the first line, one blank line after
108
+ "head\n\n\n${provider}\n\n\ntail", // two blank lines on each side
109
+ "one\n\ntwo\n\n${provider}", // stacked blanks, token on the last line
110
+ "\n\n${provider}\n\n", // nothing but blank lines around the token
111
+ ])("preserves the blank lines in %j around the carried token", (name) => {
112
+ // Leading, trailing, and doubled blank runs are the same data. `\n`
113
+ // has no case mapping — `capitalize` and `toUpperCase` both leave
114
+ // every newline byte alone — so each blank line lands once per
115
+ // expansion site and the count scales exactly with the name's.
116
+ const stub = webhookStubFor(name);
117
+ const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
118
+
119
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
120
+ expect(stub).toContain(`forge a ${name} webhook.`);
121
+ expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
122
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
123
+
124
+ // The name's blank lines survive verbatim at the case-preserving
125
+ // sites: `stub for ${name}.` holds every run the name contains.
126
+ // Escaped sequences would show as literal `\` `n` pairs instead.
127
+ expect(blankLines(stub)).toBe(5 * blankLines(name));
128
+ expect(stub).not.toContain("\\n");
129
+
130
+ // Still five expansion sites, still one carried `${provider}` token at
131
+ // each — blank lines change nothing about the substitution's arity.
132
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
133
+ expect(tokens).toHaveLength(5);
134
+ for (const t of tokens) expect(t.toLowerCase()).toBe("${provider}");
135
+
136
+ // The fixed slots resolved to their generic values in the same pass.
137
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
138
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
139
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
140
+ });
141
+
142
+ it("skips a blank-line template name byte-for-byte through wiring and writes nothing for it", async () => {
143
+ // Through `wirePhantomForRecipe` the multiline name matches no catalog
144
+ // provider: it returns in `skipped` byte-for-byte — token, text, and
145
+ // both blank lines intact — gets no stub on disk, and provokes no
146
+ // vault write. The known provider listed beside it wires normally,
147
+ // unaffected by the template-shaped neighbour.
148
+ const name = "alpha\n\n${provider}\n\nomega";
149
+ const stubPath = join(dir, ".stack", "webhooks", "stripe.ts");
150
+ const result = await wirePhantomForRecipe(recipe(name, "stripe"), { cwd: dir });
151
+
152
+ expect(result).toEqual({
153
+ present: [],
154
+ missing: ["stripe:STRIPE_SECRET_KEY"],
155
+ webhooks: [stubPath],
156
+ skipped: [name],
157
+ });
158
+ expect(result.skipped[0]).toBe(name);
159
+ expect(blankLines(result.skipped[0] ?? "")).toBe(2);
160
+
161
+ // Only stripe's documented stub lands — nothing for the multiline
162
+ // name, and the stub on disk is the same render `webhookStubFor`
163
+ // returns.
164
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["stripe.ts"]);
165
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("stripe"));
166
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
167
+
168
+ // The whole call log is reads (`--version`, `list`); the vault file
169
+ // still parses to an empty object.
170
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
171
+ expect(harness.callsTo("add")).toEqual([]);
172
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
173
+ });
174
+ });