@ashlr/stack-core 0.3.1 → 0.3.7
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.
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +65 -39
- package/src/__tests__/apply-placeholder-history.test.ts +55 -0
- package/src/__tests__/apply-placeholder-operator-checklist.test.ts +28 -0
- package/src/__tests__/apply-terminology-examples.test.ts +46 -0
- package/src/__tests__/config.test.ts +36 -0
- package/src/__tests__/credential-staging.test.ts +7 -6
- package/src/__tests__/deps.test.ts +35 -0
- package/src/__tests__/detect-source.test.ts +97 -0
- package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
- package/src/__tests__/phantom-process.test.ts +21 -3
- package/src/__tests__/phantom-wire-failing-subprocess.test.ts +126 -0
- package/src/__tests__/phantom-wire-repeated-placeholders.test.ts +84 -0
- package/src/__tests__/phantom-wire-unicode-combining.test.ts +100 -0
- package/src/__tests__/phantom-wire.test.ts +6381 -363
- package/src/__tests__/pipeline-integration.test.ts +26 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +2 -2
- package/src/__tests__/secret-redaction.test.ts +13 -3
- package/src/ai/phantom-wire-adjacent-different-providers.test.ts +78 -0
- package/src/ai/phantom-wire-adjacent-known-placeholders.test.ts +266 -0
- package/src/ai/phantom-wire-args.test.ts +63 -0
- package/src/ai/phantom-wire-blank-lines.test.ts +174 -0
- package/src/ai/phantom-wire-carriage-return.test.ts +125 -0
- package/src/ai/phantom-wire-case-changed-placeholder.test.ts +209 -0
- package/src/ai/phantom-wire-closing-brace.test.ts +16 -0
- package/src/ai/phantom-wire-crlf.test.ts +48 -0
- package/src/ai/phantom-wire-dollar-amp.test.ts +128 -0
- package/src/ai/phantom-wire-double-quote.test.ts +135 -0
- package/src/ai/phantom-wire-empty-replacement.test.ts +122 -0
- package/src/ai/phantom-wire-empty-stderr.test.ts +145 -0
- package/src/ai/phantom-wire-empty-template.test.ts +81 -0
- package/src/ai/phantom-wire-empty-values.test.ts +129 -0
- package/src/ai/phantom-wire-errors.test.ts +296 -0
- package/src/ai/phantom-wire-known-placeholder-adjacent-punctuation.test.ts +188 -0
- package/src/ai/phantom-wire-known-placeholder-at-end.test.ts +160 -0
- package/src/ai/phantom-wire-known-placeholder.test.ts +170 -0
- package/src/ai/phantom-wire-malformed-json.test.ts +227 -0
- package/src/ai/phantom-wire-missing.test.ts +123 -0
- package/src/ai/phantom-wire-mixed-placeholders.test.ts +183 -0
- package/src/ai/phantom-wire-nested-placeholder.test.ts +149 -0
- package/src/ai/phantom-wire-newline.test.ts +136 -0
- package/src/ai/phantom-wire-order.test.ts +173 -0
- package/src/ai/phantom-wire-placeholder-two-lines.test.ts +196 -0
- package/src/ai/phantom-wire-repeated-known-placeholder.test.ts +241 -0
- package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +55 -0
- package/src/ai/phantom-wire-replacement-dollar.test.ts +170 -0
- package/src/ai/phantom-wire-single-quote.test.ts +22 -0
- package/src/ai/phantom-wire-tab.test.ts +42 -0
- package/src/ai/phantom-wire-unknown-placeholder.test.ts +189 -0
- package/src/ai/phantom-wire-vault-name-case.test.ts +46 -0
- package/src/ai/phantom-wire.test.ts +144 -0
- package/src/ai/phantom-wire.ts +11 -4
- package/src/phantom.ts +10 -1
- package/src/stack-manifest.ts +3 -3
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
// A `${...}` token that spells none of the template's slots. Already
|
|
16
|
+
// uppercase, so even the `toUpperCase()` secret-key sites hold identical
|
|
17
|
+
// bytes — any difference it shows is the renderer's, not the fold's.
|
|
18
|
+
const UNKNOWN = "${HOOK_URL}";
|
|
19
|
+
|
|
20
|
+
describe("ai/phantom-wire a name mixing a known and an unknown placeholder", () => {
|
|
21
|
+
let harness: Harness;
|
|
22
|
+
let dir: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
harness = setupFakePhantom();
|
|
26
|
+
dir = mkdtempSync(join(tmpdir(), "wire-mixed-placeholders-"));
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
harness.cleanup();
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
34
|
+
id: "mixed-placeholders",
|
|
35
|
+
query: "q",
|
|
36
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
37
|
+
providers: names.map((name) => ({ name })),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it.each([
|
|
41
|
+
"${provider}", // the name slot's own spelling
|
|
42
|
+
"${capitalize(provider)}", // the handler-name slot's spelling
|
|
43
|
+
"${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
|
|
44
|
+
"${signatureHeaderFor(provider)}", // the header slot's spelling
|
|
45
|
+
"${verificationHintFor(provider)}", // the hint slot's spelling
|
|
46
|
+
])(
|
|
47
|
+
"expands every known slot to the name while the unknown token in it stays literal: %j",
|
|
48
|
+
(slot) => {
|
|
49
|
+
// The name carries two `${...}` tokens separated by ordinary text:
|
|
50
|
+
// one spelling a real template slot (a known placeholder), one
|
|
51
|
+
// spelling nothing (unknown). The template's known placeholders
|
|
52
|
+
// still expand — each slot emits the whole name once — while both
|
|
53
|
+
// tokens inside the emitted name arrive unchanged: the known one is
|
|
54
|
+
// never offered a second pass, and the unknown one has no expansion
|
|
55
|
+
// to begin with.
|
|
56
|
+
const name = `pre${slot}mid${UNKNOWN}post`;
|
|
57
|
+
const stub = webhookStubFor(name);
|
|
58
|
+
|
|
59
|
+
// The `${provider}` and `${capitalize(provider)}` slots expanded to
|
|
60
|
+
// the full name — both tokens included — byte-for-byte at the three
|
|
61
|
+
// case-preserving sites; `capitalize` folds only the leading `p`.
|
|
62
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
63
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
64
|
+
expect(stub).toContain(`handlePre${slot}mid${UNKNOWN}postWebhook(req: Request)`);
|
|
65
|
+
// The `webhookSecretKeyFor(provider)` slot expanded too: both reveal
|
|
66
|
+
// lines hold the whole-name-uppercased replacement — the carried
|
|
67
|
+
// known token folded with it, the already-uppercase unknown token
|
|
68
|
+
// byte-identical.
|
|
69
|
+
expect(
|
|
70
|
+
count(stub, `phantom reveal PRE${slot.toUpperCase()}MID${UNKNOWN}POST_WEBHOOK_SECRET`),
|
|
71
|
+
).toBe(2);
|
|
72
|
+
|
|
73
|
+
// Census: two tokens per name × five expansion sites, exactly half
|
|
74
|
+
// the known spelling and half the unknown — none resolved away,
|
|
75
|
+
// emptied, or minted into extra copies.
|
|
76
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
77
|
+
expect(tokens).toHaveLength(10);
|
|
78
|
+
expect(tokens.filter((t) => t.toLowerCase() === slot.toLowerCase())).toHaveLength(5);
|
|
79
|
+
expect(tokens.filter((t) => t === UNKNOWN)).toHaveLength(5);
|
|
80
|
+
|
|
81
|
+
// Order and seating per site: at each of the five sites the emitted
|
|
82
|
+
// name keeps the known token before `mid` and the unknown token
|
|
83
|
+
// after it — the unknown placeholder remains exactly where the name
|
|
84
|
+
// put it, never transposed or reseated beside injected text.
|
|
85
|
+
expect(stub.match(/pre\$\{[^}]*\}mid\$\{HOOK_URL\}post/gi)).toHaveLength(5);
|
|
86
|
+
|
|
87
|
+
// Single pass: a re-resolve of the carried known token would double
|
|
88
|
+
// the flanking text or drop the unknown token; neither happened.
|
|
89
|
+
expect(stub).not.toMatch(/prepre|midmid|postpost/i);
|
|
90
|
+
|
|
91
|
+
// The remaining known slots took their own generic values in the
|
|
92
|
+
// same pass — expansion ran everywhere it was defined.
|
|
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(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
["${provider}", UNKNOWN], // known spelling leads, unknown follows
|
|
101
|
+
[UNKNOWN, "${provider}"], // unknown leads, known spelling follows
|
|
102
|
+
])(
|
|
103
|
+
"keeps the flush pair %j + %j adjacent and in order at every expansion site",
|
|
104
|
+
(known, unknown) => {
|
|
105
|
+
// The whole name is the two tokens back-to-back: a known slot
|
|
106
|
+
// spelling flush against an unknown one. The known placeholder in
|
|
107
|
+
// the template expands — the slots emit the pair as the replacement
|
|
108
|
+
// — while the pair itself stays data: in order, nothing spliced
|
|
109
|
+
// between the first token's `}` and the second's `${`, the unknown
|
|
110
|
+
// token unchanged.
|
|
111
|
+
const name = `${known}${unknown}`;
|
|
112
|
+
const other = `${unknown}${known}`;
|
|
113
|
+
const stub = webhookStubFor(name);
|
|
114
|
+
|
|
115
|
+
// The `$`-led name is carried byte-for-byte at the three
|
|
116
|
+
// case-preserving sites (`capitalize` leaves it) and whole-name
|
|
117
|
+
// uppercased at both secret-key sites.
|
|
118
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
119
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
120
|
+
expect(stub).toContain(`handle${name}Webhook(req: Request)`);
|
|
121
|
+
expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
|
|
122
|
+
|
|
123
|
+
// The flush pair occurs exact-case at the three mixed-case sites
|
|
124
|
+
// and uppercased together at the two reveal sites — at each site
|
|
125
|
+
// the second token starts on the very byte the first ends.
|
|
126
|
+
expect(stub.match(/\$\{[^}]*\}\$\{[^}]*\}/g)).toHaveLength(5);
|
|
127
|
+
expect(count(stub, name)).toBe(3);
|
|
128
|
+
expect(count(stub, name.toUpperCase())).toBe(2);
|
|
129
|
+
|
|
130
|
+
// The transposed order never lands, in either casing.
|
|
131
|
+
expect(stub).not.toContain(other);
|
|
132
|
+
expect(stub).not.toContain(other.toUpperCase());
|
|
133
|
+
|
|
134
|
+
// Census: five occurrences of each token — the known spelling and
|
|
135
|
+
// the unknown one — and nothing else placeholder-shaped.
|
|
136
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
137
|
+
expect(tokens).toHaveLength(10);
|
|
138
|
+
expect(tokens.filter((t) => t.toLowerCase() === known.toLowerCase())).toHaveLength(5);
|
|
139
|
+
expect(tokens.filter((t) => t.toLowerCase() === unknown.toLowerCase())).toHaveLength(5);
|
|
140
|
+
|
|
141
|
+
// The template's real slots still take their own known values.
|
|
142
|
+
expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
|
|
143
|
+
expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
|
|
144
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
it("skips a mixed-token name byte-for-byte through wiring and writes nothing for it", async () => {
|
|
149
|
+
// Through `wirePhantomForRecipe` the mixed-token name matches no
|
|
150
|
+
// catalog provider: it returns in `skipped` byte-for-byte — the
|
|
151
|
+
// known-spelling token, the unknown token and the text between them
|
|
152
|
+
// intact — gets no stub on disk, and provokes no vault write. The
|
|
153
|
+
// known provider listed beside it wires normally: every one of its
|
|
154
|
+
// placeholders expands, unaffected by the placeholder-shaped
|
|
155
|
+
// neighbour.
|
|
156
|
+
const name = "pay${provider}load${HOOK_URL}end";
|
|
157
|
+
const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
|
|
158
|
+
const result = await wirePhantomForRecipe(recipe(name, "clerk"), { cwd: dir });
|
|
159
|
+
|
|
160
|
+
expect(result).toEqual({
|
|
161
|
+
present: [],
|
|
162
|
+
missing: ["clerk:CLERK_SECRET_KEY"],
|
|
163
|
+
webhooks: [stubPath],
|
|
164
|
+
skipped: [name],
|
|
165
|
+
});
|
|
166
|
+
expect(result.skipped[0]).toBe(name);
|
|
167
|
+
|
|
168
|
+
// Only clerk's documented stub lands — fully expanded, no `${...}`
|
|
169
|
+
// left — and nothing is written under the mixed-token name.
|
|
170
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
|
|
171
|
+
const stub = readFileSync(stubPath, "utf-8");
|
|
172
|
+
expect(stub).toBe(webhookStubFor("clerk"));
|
|
173
|
+
expect(stub).not.toContain("${");
|
|
174
|
+
expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
|
|
175
|
+
|
|
176
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
177
|
+
// still parses to an empty object — a token-carrying name provokes
|
|
178
|
+
// no write.
|
|
179
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
180
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
181
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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 nested inside replacement text", () => {
|
|
14
|
+
let harness: Harness;
|
|
15
|
+
let dir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
harness = setupFakePhantom();
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), "wire-nested-placeholder-"));
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
harness.cleanup();
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
27
|
+
id: "nested-placeholder",
|
|
28
|
+
query: "q",
|
|
29
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
30
|
+
providers: names.map((name) => ({ name })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it.each([
|
|
34
|
+
"${provider}", // the name slot's own spelling
|
|
35
|
+
"${capitalize(provider)}", // the handler-name slot's spelling
|
|
36
|
+
"${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
|
|
37
|
+
"${signatureHeaderFor(provider)}", // the header slot's spelling
|
|
38
|
+
"${verificationHintFor(provider)}", // the hint slot's spelling
|
|
39
|
+
])("never expands the inserted text a second time for the nested token %j", (slot) => {
|
|
40
|
+
// The name is ordinary text carrying one of the template's own slot
|
|
41
|
+
// spellings. This is the decisive re-expansion case: unlike an unknown
|
|
42
|
+
// `${...}` token, this one *would* resolve if the renderer ever fed
|
|
43
|
+
// inserted text back through the slots — a second pass would splice
|
|
44
|
+
// the whole name in again and double the flanking `pre`/`post` text
|
|
45
|
+
// (or recurse forever). The template literal evaluates each slot
|
|
46
|
+
// exactly once, so the carried token must arrive at every expansion
|
|
47
|
+
// site byte-for-byte, still seated between the name's own text.
|
|
48
|
+
const name = `pre${slot}post`;
|
|
49
|
+
const stub = webhookStubFor(name);
|
|
50
|
+
|
|
51
|
+
// Case-preserving sites emit the name — token plus surrounding text —
|
|
52
|
+
// byte-for-byte; `capitalize` folds only the first letter at the
|
|
53
|
+
// handler site.
|
|
54
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
55
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
56
|
+
expect(stub).toContain(`handlePre${slot}postWebhook(req: Request)`);
|
|
57
|
+
// `webhookSecretKeyFor(provider)` uppercases the whole name — token
|
|
58
|
+
// included — at both reveal lines.
|
|
59
|
+
expect(
|
|
60
|
+
stub.split(`phantom reveal PRE${slot.toUpperCase()}POST_WEBHOOK_SECRET`).length - 1,
|
|
61
|
+
).toBe(2);
|
|
62
|
+
|
|
63
|
+
// Five `${...}` spans — one per expansion site — every one the carried
|
|
64
|
+
// token in that site's case, none resolved away or duplicated.
|
|
65
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
66
|
+
expect(tokens).toHaveLength(5);
|
|
67
|
+
for (const token of tokens) expect(token.toLowerCase()).toBe(slot.toLowerCase());
|
|
68
|
+
|
|
69
|
+
// The no-second-pass proof: each token still sits between exactly one
|
|
70
|
+
// `pre` and one `post` (in whatever case that site's transform gives
|
|
71
|
+
// them), and the flanking text is never doubled. A re-resolve would
|
|
72
|
+
// have produced `prepre${...}postpost` at every site.
|
|
73
|
+
expect(stub.match(/pre\$\{[^}]*\}post/gi)).toHaveLength(5);
|
|
74
|
+
expect(stub).not.toMatch(/prepre|postpost/i);
|
|
75
|
+
|
|
76
|
+
// The remaining slots took their own generic values in the same
|
|
77
|
+
// single pass — the carried token never reached their switch cases.
|
|
78
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
79
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
80
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("emits the nested `${provider}` token literally at all five sites — line-for-line", () => {
|
|
84
|
+
// The sharpest form: the carried token spells the name slot itself, so
|
|
85
|
+
// at the name sites the emitted text is indistinguishable from an
|
|
86
|
+
// unresolved slot. Exact-line equality pins every site: the inserted
|
|
87
|
+
// text arrived verbatim, once, with no re-expansion splicing extra
|
|
88
|
+
// copies of the name around it.
|
|
89
|
+
const stub = webhookStubFor("pre${provider}post");
|
|
90
|
+
|
|
91
|
+
expect(stub).toContain(
|
|
92
|
+
"// Auto-generated by `stack apply` — webhook handler stub for pre${provider}post.",
|
|
93
|
+
);
|
|
94
|
+
expect(stub).toContain("// phantom reveal PRE${PROVIDER}POST_WEBHOOK_SECRET");
|
|
95
|
+
expect(stub).toContain(
|
|
96
|
+
"// export async function handlePre${provider}postWebhook(req: Request): Promise<Response> {",
|
|
97
|
+
);
|
|
98
|
+
expect(stub).toContain("// // to this endpoint can forge a pre${provider}post webhook.");
|
|
99
|
+
expect(stub).toContain(
|
|
100
|
+
"// // (`phantom reveal PRE${PROVIDER}POST_WEBHOOK_SECRET`) and compare in",
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Census: exactly five `${...}` spans — three in original case at the
|
|
104
|
+
// case-preserving sites, two uppercased at the reveal sites. A second
|
|
105
|
+
// pass would have re-resolved each `${provider}` into
|
|
106
|
+
// `pre${provider}post`, doubling the flanks; none appear.
|
|
107
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
108
|
+
expect(tokens).toEqual([
|
|
109
|
+
"${provider}",
|
|
110
|
+
"${PROVIDER}",
|
|
111
|
+
"${provider}",
|
|
112
|
+
"${provider}",
|
|
113
|
+
"${PROVIDER}",
|
|
114
|
+
]);
|
|
115
|
+
expect(stub).not.toMatch(/prepre|postpost/i);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("skips a nested-placeholder name byte-for-byte through wiring and writes nothing for it", async () => {
|
|
119
|
+
// Through `wirePhantomForRecipe` the nested-token name matches no
|
|
120
|
+
// catalog provider: it returns in `skipped` byte-for-byte — token and
|
|
121
|
+
// surrounding text intact — gets no stub on disk, and provokes no vault
|
|
122
|
+
// write. The known provider listed beside it wires normally, unaffected
|
|
123
|
+
// by the placeholder-shaped neighbour.
|
|
124
|
+
const name = "pre${provider}post";
|
|
125
|
+
const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
|
|
126
|
+
const result = await wirePhantomForRecipe(recipe(name, "clerk"), { cwd: dir });
|
|
127
|
+
|
|
128
|
+
expect(result).toEqual({
|
|
129
|
+
present: [],
|
|
130
|
+
missing: ["clerk:CLERK_SECRET_KEY"],
|
|
131
|
+
webhooks: [stubPath],
|
|
132
|
+
skipped: [name],
|
|
133
|
+
});
|
|
134
|
+
expect(result.skipped[0]).toBe(name);
|
|
135
|
+
|
|
136
|
+
// Only clerk's documented stub lands — nothing for the nested-token
|
|
137
|
+
// name, and the stub on disk is the same render `webhookStubFor`
|
|
138
|
+
// returns.
|
|
139
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
|
|
140
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("clerk"));
|
|
141
|
+
expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
|
|
142
|
+
|
|
143
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
144
|
+
// still parses to an empty object.
|
|
145
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
146
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
147
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
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 newlineCount = (s: string) => s.split("\n").length - 1;
|
|
14
|
+
|
|
15
|
+
describe("ai/phantom-wire replacement text containing a newline", () => {
|
|
16
|
+
let harness: Harness;
|
|
17
|
+
let dir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
harness = setupFakePhantom();
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), "wire-newline-"));
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
harness.cleanup();
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
29
|
+
id: "newline",
|
|
30
|
+
query: "q",
|
|
31
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
32
|
+
providers: names.map((name) => ({ name })),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("emits the literal newline byte at all five expansion sites", () => {
|
|
36
|
+
// A newline inside the replacement text is ordinary data: carried
|
|
37
|
+
// through the template's single pass it must arrive as a real 0x0A byte
|
|
38
|
+
// at every expansion site — not collapsed, not stripped, and not
|
|
39
|
+
// emitted as the two-character escape `\n`. Rendering the same letters
|
|
40
|
+
// without the break (`ab`) gives the flat baseline: the only difference
|
|
41
|
+
// between the two stubs is exactly five inserted line breaks, one per
|
|
42
|
+
// site that splices the name.
|
|
43
|
+
const stub = webhookStubFor("a\nb");
|
|
44
|
+
const flat = webhookStubFor("ab");
|
|
45
|
+
|
|
46
|
+
// Case-preserving sites emit the name byte-for-byte — newline included.
|
|
47
|
+
expect(stub).toContain("webhook handler stub for a\nb.");
|
|
48
|
+
expect(stub).toContain("forge a a\nb webhook.");
|
|
49
|
+
// `capitalize` folds only the first code point; the newline is untouched.
|
|
50
|
+
expect(stub).toContain("handleA\nbWebhook(req: Request)");
|
|
51
|
+
// `webhookSecretKeyFor` uppercases the letters around the break; it
|
|
52
|
+
// survives at both reveal lines.
|
|
53
|
+
expect(stub.split("phantom reveal A\nB_WEBHOOK_SECRET").length - 1).toBe(2);
|
|
54
|
+
|
|
55
|
+
// Exactly five extra line breaks over the flat render — one per
|
|
56
|
+
// expansion site, none eaten or doubled by the pass.
|
|
57
|
+
expect(newlineCount(stub)).toBe(newlineCount(flat) + 5);
|
|
58
|
+
|
|
59
|
+
// Byte-level pin: the character after `stub for a` is 0x0A itself, and
|
|
60
|
+
// no escaped `\n` two-character sequence or `${...}` residue appears
|
|
61
|
+
// anywhere in the output.
|
|
62
|
+
expect(stub.charCodeAt(stub.indexOf("stub for a") + "stub for a".length)).toBe(0x0a);
|
|
63
|
+
expect(stub).not.toContain("\\n");
|
|
64
|
+
expect(stub).not.toContain("${");
|
|
65
|
+
|
|
66
|
+
// Proof the byte is a real line break and not an escape: each site's
|
|
67
|
+
// tail continues on a fresh line with no `//` comment prefix.
|
|
68
|
+
expect(stub).toContain("\nb.\n");
|
|
69
|
+
expect(stub).toContain("\nbWebhook(req: Request): Promise<Response> {\n");
|
|
70
|
+
expect(stub).toContain("\nb webhook.\n");
|
|
71
|
+
expect(stub.split("\nB_WEBHOOK_SECRET").length - 1).toBe(2);
|
|
72
|
+
|
|
73
|
+
// The remaining slots resolved to their own generic values in the same
|
|
74
|
+
// pass — the newline-bearing name never reached their switch cases.
|
|
75
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
76
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
77
|
+
expect(verificationHintFor("a\nb")).toBe(verificationHintFor("zzq"));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it.each(["\nx", "x\n", "a\nb\nc", "\n", "a\n\nb"])(
|
|
81
|
+
"keeps every literal newline in %j at all five expansion sites",
|
|
82
|
+
(name) => {
|
|
83
|
+
// Leading, trailing, adjacent, and repeated breaks are the same data.
|
|
84
|
+
// `capitalize` and `toUpperCase` leave 0x0A untouched — a newline has
|
|
85
|
+
// no case mapping — so each break in the name lands once per
|
|
86
|
+
// expansion site and the count scales with the newline count.
|
|
87
|
+
const stub = webhookStubFor(name);
|
|
88
|
+
const flat = webhookStubFor(name.replace(/\n/g, ""));
|
|
89
|
+
const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
|
|
90
|
+
|
|
91
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
92
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
93
|
+
expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
|
|
94
|
+
expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
|
|
95
|
+
|
|
96
|
+
// Five expansion sites carry the name; each preserves every newline
|
|
97
|
+
// in it. Escaped sequences would not raise the line count, and
|
|
98
|
+
// stripping would lower it below the flat baseline's own breaks.
|
|
99
|
+
expect(newlineCount(stub)).toBe(newlineCount(flat) + 5 * newlineCount(name));
|
|
100
|
+
expect(stub).not.toContain("\\n");
|
|
101
|
+
expect(stub).not.toContain("${");
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
it("skips a newline name byte-for-byte through wiring and writes nothing for it", async () => {
|
|
106
|
+
// Through `wirePhantomForRecipe` the newline-bearing name matches no
|
|
107
|
+
// catalog provider: it returns in `skipped` byte-for-byte — the 0x0A
|
|
108
|
+
// intact — gets no stub on disk, and provokes no vault write. The
|
|
109
|
+
// known provider listed beside it wires normally, unaffected by the
|
|
110
|
+
// line-break-shaped neighbour.
|
|
111
|
+
const name = "a\nb";
|
|
112
|
+
const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
|
|
113
|
+
const result = await wirePhantomForRecipe(recipe(name, "clerk"), { cwd: dir });
|
|
114
|
+
|
|
115
|
+
expect(result).toEqual({
|
|
116
|
+
present: [],
|
|
117
|
+
missing: ["clerk:CLERK_SECRET_KEY"],
|
|
118
|
+
webhooks: [stubPath],
|
|
119
|
+
skipped: [name],
|
|
120
|
+
});
|
|
121
|
+
expect(result.skipped[0]).toBe(name);
|
|
122
|
+
expect(result.skipped[0]?.charCodeAt(1)).toBe(0x0a);
|
|
123
|
+
|
|
124
|
+
// Only clerk's documented stub lands — nothing for the newline name,
|
|
125
|
+
// and the stub on disk is the same render `webhookStubFor` returns.
|
|
126
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
|
|
127
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("clerk"));
|
|
128
|
+
expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
|
|
129
|
+
|
|
130
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
131
|
+
// still parses to an empty object.
|
|
132
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
133
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
134
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { chmodSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { __resetPhantomCache, __setPhantomSpawn } from "../phantom.ts";
|
|
7
|
+
import { wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
8
|
+
import type { Recipe } from "./recipe.ts";
|
|
9
|
+
|
|
10
|
+
// Stubbed `phantom` subprocess: `--version` succeeds (unless `installed` is
|
|
11
|
+
// false) and `list --json` returns the names in `vault`.
|
|
12
|
+
let installed = true;
|
|
13
|
+
let vault: string[] | "unreadable" = [];
|
|
14
|
+
|
|
15
|
+
function fakeSpawn(_bin: string, args: string[]) {
|
|
16
|
+
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
|
|
17
|
+
const out = new EventEmitter();
|
|
18
|
+
const err = new EventEmitter();
|
|
19
|
+
Object.assign(child, {
|
|
20
|
+
stdout: Object.assign(out, { destroy() {} }),
|
|
21
|
+
stderr: Object.assign(err, { destroy() {} }),
|
|
22
|
+
stdin: { on() {}, write() {}, end() {}, destroy() {} },
|
|
23
|
+
pid: undefined,
|
|
24
|
+
kill() {},
|
|
25
|
+
unref() {},
|
|
26
|
+
});
|
|
27
|
+
queueMicrotask(() => {
|
|
28
|
+
if (!installed) {
|
|
29
|
+
child.emit("error", Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (args[0] === "list") {
|
|
33
|
+
if (vault === "unreadable") {
|
|
34
|
+
child.emit("close", 1, null);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
out.emit("data", Buffer.from(JSON.stringify(vault.map((name) => ({ name })))));
|
|
38
|
+
}
|
|
39
|
+
child.emit("close", 0, null);
|
|
40
|
+
});
|
|
41
|
+
return child;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function recipeOf(...names: string[]): Recipe {
|
|
45
|
+
return {
|
|
46
|
+
id: "order",
|
|
47
|
+
query: "order",
|
|
48
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
49
|
+
providers: names.map((name) => ({ name })),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("ai/phantom-wire result ordering with multiple providers", () => {
|
|
54
|
+
const savedPath = process.env.PATH;
|
|
55
|
+
let dir: string;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
dir = realpathSync(mkdtempSync(join(tmpdir(), "wire-order-")));
|
|
59
|
+
// Binary only needs to resolve on PATH; spawn is stubbed. `.sh` target
|
|
60
|
+
// matches the sibling failing-subprocess test (macOS provenance policy).
|
|
61
|
+
writeFileSync(join(dir, "phantom.sh"), "#!/bin/sh\n");
|
|
62
|
+
chmodSync(join(dir, "phantom.sh"), 0o755);
|
|
63
|
+
symlinkSync(join(dir, "phantom.sh"), join(dir, "phantom"));
|
|
64
|
+
process.env.PATH = dir;
|
|
65
|
+
installed = true;
|
|
66
|
+
vault = [];
|
|
67
|
+
__resetPhantomCache();
|
|
68
|
+
__setPhantomSpawn(fakeSpawn as never);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
process.env.PATH = savedPath;
|
|
73
|
+
rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(() => {
|
|
77
|
+
__setPhantomSpawn();
|
|
78
|
+
__resetPhantomCache();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reports present and missing secrets in recipe order, then catalog secret order", async () => {
|
|
82
|
+
vault = ["OPENAI_API_KEY", "SUPABASE_ANON_KEY"];
|
|
83
|
+
const r = await wirePhantomForRecipe(recipeOf("stripe", "supabase", "openai", "github"), {
|
|
84
|
+
cwd: dir,
|
|
85
|
+
});
|
|
86
|
+
expect(r.present).toEqual(["supabase:SUPABASE_ANON_KEY", "openai:OPENAI_API_KEY"]);
|
|
87
|
+
expect(r.missing).toEqual([
|
|
88
|
+
"stripe:STRIPE_SECRET_KEY",
|
|
89
|
+
"supabase:SUPABASE_URL",
|
|
90
|
+
"supabase:SUPABASE_SERVICE_ROLE_KEY",
|
|
91
|
+
"github:GITHUB_TOKEN",
|
|
92
|
+
]);
|
|
93
|
+
expect(r.skipped).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("follows input order, not alphabetical or catalog order", async () => {
|
|
97
|
+
const forward = await wirePhantomForRecipe(recipeOf("github", "stripe", "clerk"), { cwd: dir });
|
|
98
|
+
const reversed = await wirePhantomForRecipe(recipeOf("clerk", "stripe", "github"), {
|
|
99
|
+
cwd: dir,
|
|
100
|
+
});
|
|
101
|
+
const names = (r: { webhooks: string[] }) =>
|
|
102
|
+
r.webhooks.map((p) => p.split("/").pop()?.replace(/\.ts$/, ""));
|
|
103
|
+
expect(names(forward)).toEqual(["github", "stripe", "clerk"]);
|
|
104
|
+
expect(names(reversed)).toEqual(["clerk", "stripe", "github"]);
|
|
105
|
+
expect(forward.missing.map((m) => m.split(":")[0])).toEqual(["github", "stripe", "clerk"]);
|
|
106
|
+
expect(reversed.missing.map((m) => m.split(":")[0])).toEqual(["clerk", "stripe", "github"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("keeps unknown providers in place in skipped and still orders the rest", async () => {
|
|
110
|
+
const r = await wirePhantomForRecipe(
|
|
111
|
+
recipeOf("zzz-unknown", "stripe", "aaa-unknown", "github"),
|
|
112
|
+
{
|
|
113
|
+
cwd: dir,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
expect(r.skipped).toEqual(["zzz-unknown", "aaa-unknown"]);
|
|
117
|
+
expect(r.missing).toEqual(["stripe:STRIPE_SECRET_KEY", "github:GITHUB_TOKEN"]);
|
|
118
|
+
expect(r.webhooks.map((p) => p.split("/").pop())).toEqual(["stripe.ts", "github.ts"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("marks every provider skipped, in input order, when the vault is unreadable", async () => {
|
|
122
|
+
vault = "unreadable";
|
|
123
|
+
const r = await wirePhantomForRecipe(recipeOf("github", "openai", "stripe"), { cwd: dir });
|
|
124
|
+
expect(r.skipped).toEqual(["github", "openai", "stripe"]);
|
|
125
|
+
expect(r.present).toEqual([]);
|
|
126
|
+
expect(r.missing).toEqual([]);
|
|
127
|
+
expect(r.webhooks.map((p) => p.split("/").pop())).toEqual(["github.ts", "stripe.ts"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("lists skipped providers and webhook stubs in input order when phantom is missing", async () => {
|
|
131
|
+
installed = false;
|
|
132
|
+
const r = await wirePhantomForRecipe(recipeOf("stripe", "openai", "clerk", "github"), {
|
|
133
|
+
cwd: dir,
|
|
134
|
+
});
|
|
135
|
+
expect(r.skipped).toEqual(["stripe", "openai", "clerk", "github"]);
|
|
136
|
+
expect(r.webhooks.map((p) => p.split("/").pop())).toEqual([
|
|
137
|
+
"stripe.ts",
|
|
138
|
+
"clerk.ts",
|
|
139
|
+
"github.ts",
|
|
140
|
+
]);
|
|
141
|
+
expect(r.present).toEqual([]);
|
|
142
|
+
expect(r.missing).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("repeats duplicate providers once per input entry, in order", async () => {
|
|
146
|
+
vault = ["STRIPE_SECRET_KEY"];
|
|
147
|
+
const r = await wirePhantomForRecipe(recipeOf("stripe", "github", "stripe"), { cwd: dir });
|
|
148
|
+
expect(r.present).toEqual(["stripe:STRIPE_SECRET_KEY", "stripe:STRIPE_SECRET_KEY"]);
|
|
149
|
+
expect(r.missing).toEqual(["github:GITHUB_TOKEN"]);
|
|
150
|
+
expect(r.webhooks.map((p) => p.split("/").pop())).toEqual([
|
|
151
|
+
"stripe.ts",
|
|
152
|
+
"github.ts",
|
|
153
|
+
"stripe.ts",
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("is deterministic across repeated runs of the same recipe", async () => {
|
|
158
|
+
vault = ["SENTRY_DSN", "RESEND_API_KEY"];
|
|
159
|
+
const recipe = recipeOf("sentry", "nope", "resend", "stripe", "supabase");
|
|
160
|
+
const first = await wirePhantomForRecipe(recipe, { cwd: dir });
|
|
161
|
+
for (let i = 0; i < 5; i++) {
|
|
162
|
+
expect(await wirePhantomForRecipe(recipe, { cwd: dir })).toEqual(first);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns empty ordered lists for an empty recipe and for noWire", async () => {
|
|
167
|
+
const empty = { present: [], missing: [], webhooks: [], skipped: [] };
|
|
168
|
+
expect(await wirePhantomForRecipe(recipeOf(), { cwd: dir })).toEqual(empty);
|
|
169
|
+
expect(
|
|
170
|
+
await wirePhantomForRecipe(recipeOf("stripe", "github"), { cwd: dir, noWire: true }),
|
|
171
|
+
).toEqual(empty);
|
|
172
|
+
});
|
|
173
|
+
});
|