@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,22 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { webhookStubFor } from "./phantom-wire.ts";
3
+
4
+ describe("ai/phantom-wire single quotes in replacement text", () => {
5
+ it.each(["pay'hook", "'hook", "hook'", "'", "a''b", "svc$'pay"])(
6
+ "preserves quotes at every expansion site for %j",
7
+ (name) => {
8
+ const stub = webhookStubFor(name);
9
+ // A sentinel render provides a whole-output oracle without treating $'
10
+ // as a String.replace replacement directive.
11
+ const sentinel = name.replaceAll("'", "\u0000");
12
+ const expected = webhookStubFor(sentinel).replaceAll("\u0000", () => "'");
13
+
14
+ expect(stub).toBe(expected);
15
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
16
+ expect(stub).toContain(`forge a ${name} webhook.`);
17
+ expect(stub).toContain(`handle${name[0].toUpperCase()}${name.slice(1)}Webhook`);
18
+ expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
19
+ expect(stub.split("'").length - 1).toBe((name.split("'").length - 1) * 5 + 1);
20
+ },
21
+ );
22
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { verificationHintFor, webhookStubFor } from "./phantom-wire.ts";
3
+
4
+ const tabCount = (s: string) => s.split("\t").length - 1;
5
+
6
+ describe("ai/phantom-wire replacement text containing a tab", () => {
7
+ it("preserves the literal tab byte at all five expansion sites", () => {
8
+ const stub = webhookStubFor("a\tb");
9
+ const flat = webhookStubFor("ab");
10
+
11
+ expect(stub).toContain("webhook handler stub for a\tb.");
12
+ expect(stub).toContain("forge a a\tb webhook.");
13
+ expect(stub).toContain("handleA\tbWebhook(req: Request)");
14
+ expect(stub.split("phantom reveal A\tB_WEBHOOK_SECRET").length - 1).toBe(2);
15
+
16
+ // Exactly one tab per site over the flat render; none expanded or escaped.
17
+ expect(tabCount(stub)).toBe(tabCount(flat) + 5);
18
+ expect(stub.charCodeAt(stub.indexOf("stub for a") + "stub for a".length)).toBe(0x09);
19
+ expect(stub).not.toContain("\\t");
20
+ expect(stub).not.toContain("${");
21
+
22
+ // Other slots are unaffected by the tab-bearing name.
23
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
24
+ expect(verificationHintFor("a\tb")).toBe(verificationHintFor("zzq"));
25
+ });
26
+
27
+ it.each(["\tx", "x\t", "a\tb\tc", "\t", "a\t\tb"])(
28
+ "keeps every literal tab in %j at all five expansion sites",
29
+ (name) => {
30
+ const stub = webhookStubFor(name);
31
+ const flat = webhookStubFor(name.replace(/\t/g, ""));
32
+ const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
33
+
34
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
35
+ expect(stub).toContain(`forge a ${name} webhook.`);
36
+ expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
37
+ expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
38
+ expect(tabCount(stub)).toBe(tabCount(flat) + 5 * tabCount(name));
39
+ expect(stub).not.toContain("\\t");
40
+ },
41
+ );
42
+ });
@@ -0,0 +1,189 @@
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 unknown placeholder surrounded by ordinary text", () => {
14
+ let harness: Harness;
15
+ let dir: string;
16
+
17
+ beforeEach(() => {
18
+ harness = setupFakePhantom();
19
+ dir = mkdtempSync(join(tmpdir(), "wire-unknown-placeholder-"));
20
+ });
21
+ afterEach(() => {
22
+ harness.cleanup();
23
+ rmSync(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ const recipe = (...names: string[]): Recipe => ({
27
+ id: "unknown-placeholder",
28
+ query: "q",
29
+ createdAt: "2026-04-18T00:00:00.000Z",
30
+ providers: names.map((name) => ({ name })),
31
+ });
32
+
33
+ it.each(["${UNKNOWN}stripe", "stripe${UNKNOWN}"])(
34
+ "substitutes the provider beside an unknown token without resolving the token: %s",
35
+ (name) => {
36
+ const stub = webhookStubFor(name);
37
+
38
+ // The provider interpolation resolves to the supplied name in both
39
+ // locations, while the adjacent unknown token remains literal data.
40
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
41
+ expect(stub).toContain(`forge a ${name} webhook.`);
42
+ expect(stub).not.toContain("${provider}");
43
+ expect(stub.split("${UNKNOWN}").length - 1).toBe(5);
44
+ },
45
+ );
46
+
47
+ it("keeps an unknown ${...} token and the text around it byte-for-byte at every site", () => {
48
+ // `pre${HOOK_URL}post` matches none of the template's slots — expansion
49
+ // fills `provider`, `capitalize(provider)`, `webhookSecretKeyFor(provider)`
50
+ // and `signatureHeaderFor(provider)`; a `${...}` arriving inside the name
51
+ // has no substitution and is pure data. It must reach each of the five
52
+ // expansion sites still seated between the name's own `pre`/`post` text:
53
+ // a resolver would empty it, splice a value in, or orphan the text around
54
+ // it. The token is already uppercase, so even the `toUpperCase()`
55
+ // secret-key sites hold the identical bytes.
56
+ const name = "pre${HOOK_URL}post";
57
+ const stub = webhookStubFor(name);
58
+
59
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
60
+ expect(stub).toContain(`forge a ${name} webhook.`);
61
+ // `capitalize(provider)` folds only the name's first letter — the token
62
+ // and the text after it are untouched.
63
+ expect(stub).toContain("handlePre${HOOK_URL}postWebhook(req: Request)");
64
+ // `webhookSecretKeyFor(provider)` sites: the already-uppercase token and
65
+ // its uppercase surrounding text survive in both reveal lines.
66
+ expect(stub.split("phantom reveal PRE${HOOK_URL}POST_WEBHOOK_SECRET").length - 1).toBe(2);
67
+
68
+ // Every `${...}` in the stub is the carried token — five expansion
69
+ // sites, five occurrences, no other unresolved placeholder-shaped text.
70
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
71
+ expect(tokens).toEqual(Array(5).fill("${HOOK_URL}"));
72
+ // The ordinary text still flanks the token at every site: each
73
+ // occurrence sits between the name's own `pre` and `post` (in whatever
74
+ // case that site's transform gives them), never beside injected text.
75
+ expect(stub.match(/pre\$\{HOOK_URL\}post/gi)).toHaveLength(5);
76
+
77
+ // Nothing resolved the token as a slot: the generic signature header and
78
+ // the fallback verification hint apply unchanged.
79
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
80
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
81
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
82
+ });
83
+
84
+ it("folds a lowercase token's interior only at the secret-key sites and keeps it verbatim elsewhere", () => {
85
+ // Same scenario with a token whose interior is lowercase. The three
86
+ // case-preserving sites carry the name — token and surrounding text —
87
+ // byte-for-byte; the two `toUpperCase()` secret-key sites fold the
88
+ // token's letters along with the text around it, because they uppercase
89
+ // the whole name. Either way the token is still there, still `${...}`,
90
+ // still between the same surrounding text — never resolved, emptied or
91
+ // duplicated.
92
+ const stub = webhookStubFor("before${hook_url}after");
93
+
94
+ // Case-preserving sites carry `before${hook_url}after` byte-for-byte.
95
+ expect(stub).toContain("webhook handler stub for before${hook_url}after.");
96
+ expect(stub).toContain("forge a before${hook_url}after webhook.");
97
+ expect(stub).toContain("handleBefore${hook_url}afterWebhook(req: Request)");
98
+ // The original-case token appears only at those three sites; its folded
99
+ // form appears only at the two reveal lines.
100
+ expect(stub.split("${hook_url}").length - 1).toBe(3);
101
+ expect(stub.split("${HOOK_URL}").length - 1).toBe(2);
102
+ expect(stub.split("phantom reveal BEFORE${HOOK_URL}AFTER_WEBHOOK_SECRET").length - 1).toBe(2);
103
+
104
+ // Five `${...}` tokens in total, every one the carried placeholder in
105
+ // either its original or its reveal-site case — and each still seated
106
+ // between the surrounding `before`/`after` text.
107
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
108
+ expect(tokens).toHaveLength(5);
109
+ for (const token of tokens) expect(token.toLowerCase()).toBe("${hook_url}");
110
+ expect(stub.match(/before\$\{hook_url\}after/gi)).toHaveLength(5);
111
+ });
112
+
113
+ it.each(["a{{mustache}}b", "a%name%b", "a%sb", "a$1b"])(
114
+ "carries the foreign placeholder syntax in %s literally when wrapped in ordinary text",
115
+ (name) => {
116
+ // `{{...}}`, `%...%`, `%s` and `$1` are placeholder syntaxes this
117
+ // template does not use at all. Surrounded by ordinary text they are
118
+ // pure data: byte-for-byte at the title and forge sites, folded only
119
+ // at the first letter by `capitalize` at the handler site, and folded
120
+ // with the rest of the name at the two `toUpperCase()` secret-key
121
+ // sites — never resolved, emptied or merged.
122
+ const stub = webhookStubFor(name);
123
+ const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
124
+
125
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
126
+ expect(stub).toContain(`forge a ${name} webhook.`);
127
+ expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
128
+ expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
129
+
130
+ // Two verbatim sites + one first-letter-folded site + two folded
131
+ // sites = the five expansion sites. The template contributes none of
132
+ // these syntaxes itself, so every occurrence is one the name carried.
133
+ expect(stub.split(name).length - 1).toBe(2);
134
+ expect(stub.split(capitalized).length - 1).toBe(1);
135
+ expect(stub.split(name.toUpperCase()).length - 1).toBe(2);
136
+ expect(stub).not.toContain("${");
137
+ },
138
+ );
139
+
140
+ it("skips a placeholder-surrounded name byte-for-byte through wiring and writes nothing for it", async () => {
141
+ // Through `wirePhantomForRecipe` the name matches no catalog provider:
142
+ // it returns in `skipped` byte-for-byte — token and surrounding text
143
+ // intact — gets no stub on disk, and provokes no vault write. The known
144
+ // provider listed beside it wires normally, unaffected by the
145
+ // placeholder-shaped neighbour.
146
+ const name = "pre${HOOK_URL}post";
147
+ const stubPath = join(dir, ".stack", "webhooks", "stripe.ts");
148
+ const result = await wirePhantomForRecipe(recipe(name, "stripe"), { cwd: dir });
149
+
150
+ expect(result).toEqual({
151
+ present: [],
152
+ missing: ["stripe:STRIPE_SECRET_KEY"],
153
+ webhooks: [stubPath],
154
+ skipped: [name],
155
+ });
156
+ expect(result.skipped[0]).toBe(name);
157
+
158
+ // Only stripe's documented stub lands — nothing for the
159
+ // placeholder-shaped name, and the stub on disk is the same render
160
+ // `webhookStubFor` returns.
161
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["stripe.ts"]);
162
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("stripe"));
163
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
164
+
165
+ // The whole call log is reads (`--version`, `list`); the vault file
166
+ // still parses to an empty object.
167
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
168
+ expect(harness.callsTo("add")).toEqual([]);
169
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
170
+ });
171
+
172
+ it("renders the known provider's placeholders and leaves a bare unknown ${...} token untouched in one recipe", async () => {
173
+ // Known (`stripe`) and unknown (`${UNKNOWN}`) together: the known
174
+ // provider's stub has every slot replaced (no `${` left); the unknown
175
+ // token is skipped verbatim and no file is written under its name.
176
+ const unknown = "${UNKNOWN}";
177
+ const stubPath = join(dir, ".stack", "webhooks", "stripe.ts");
178
+ const result = await wirePhantomForRecipe(recipe("stripe", unknown), { cwd: dir });
179
+
180
+ expect(result.skipped).toEqual([unknown]);
181
+ expect(result.webhooks).toEqual([stubPath]);
182
+ const stub = readFileSync(stubPath, "utf-8");
183
+ expect(stub).toBe(webhookStubFor("stripe"));
184
+ expect(stub).toContain("webhook handler stub for stripe.");
185
+ expect(stub).toContain("handleStripeWebhook");
186
+ expect(stub).not.toContain("${");
187
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["stripe.ts"]);
188
+ });
189
+ });
@@ -0,0 +1,46 @@
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { mkdtempSync, 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 { wirePhantomForRecipe } from "./phantom-wire.ts";
7
+ import type { Recipe } from "./recipe.ts";
8
+
9
+ setDefaultTimeout(30_000);
10
+
11
+ const recipe: Recipe = {
12
+ id: "vault-case",
13
+ query: "q",
14
+ createdAt: "2026-04-18T00:00:00.000Z",
15
+ providers: [{ name: "github" }],
16
+ };
17
+
18
+ describe("ai/phantom-wire vault secret name case", () => {
19
+ let harness: Harness | undefined;
20
+ let dir: string;
21
+
22
+ beforeEach(() => {
23
+ dir = mkdtempSync(join(tmpdir(), "wire-vault-case-"));
24
+ });
25
+ afterEach(() => {
26
+ harness?.cleanup();
27
+ rmSync(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ it("treats the exact-case vault name as present", async () => {
31
+ harness = setupFakePhantom({ GITHUB_TOKEN: "x" });
32
+ const result = await wirePhantomForRecipe(recipe, { cwd: dir });
33
+ expect(result.present).toEqual(["github:GITHUB_TOKEN"]);
34
+ expect(result.missing).toEqual([]);
35
+ });
36
+
37
+ it.each(["github_token", "Github_Token"])(
38
+ "treats the differently cased vault name %s as missing",
39
+ async (stored) => {
40
+ harness = setupFakePhantom({ [stored]: "x" });
41
+ const result = await wirePhantomForRecipe(recipe, { cwd: dir });
42
+ expect(result.present).toEqual([]);
43
+ expect(result.missing).toEqual(["github:GITHUB_TOKEN"]);
44
+ },
45
+ );
46
+ });
@@ -0,0 +1,144 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+ import {
4
+ chmodSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ realpathSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { __resetPhantomCache, __setPhantomSpawn } from "../phantom.ts";
15
+ import { wirePhantomForRecipe } from "./phantom-wire.ts";
16
+ import type { Recipe } from "./recipe.ts";
17
+
18
+ // Stubbed `phantom` subprocess, like phantom-wire-order.test.ts, but recording
19
+ // every argv: an empty wiring request may run the read-only probes
20
+ // (`--version`, `list --json`) but must never reach a mutating subcommand
21
+ // (`add`, `remove`, `init`) or write a file.
22
+ let installed = true;
23
+ let vault: string[] | "unreadable" = [];
24
+ let calls: string[][] = [];
25
+
26
+ function fakeSpawn(_bin: string, args: string[]) {
27
+ calls.push([...args]);
28
+ const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
29
+ const out = new EventEmitter();
30
+ const err = new EventEmitter();
31
+ Object.assign(child, {
32
+ stdout: Object.assign(out, { destroy() {} }),
33
+ stderr: Object.assign(err, { destroy() {} }),
34
+ stdin: { on() {}, write() {}, end() {}, destroy() {} },
35
+ pid: undefined,
36
+ kill() {},
37
+ unref() {},
38
+ });
39
+ queueMicrotask(() => {
40
+ if (!installed) {
41
+ child.emit("error", Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
42
+ return;
43
+ }
44
+ if (args[0] === "list") {
45
+ if (vault === "unreadable") {
46
+ child.emit("close", 1, null);
47
+ return;
48
+ }
49
+ out.emit("data", Buffer.from(JSON.stringify(vault.map((name) => ({ name })))));
50
+ }
51
+ child.emit("close", 0, null);
52
+ });
53
+ return child;
54
+ }
55
+
56
+ function recipeOf(...names: string[]): Recipe {
57
+ return {
58
+ id: "empty-wire",
59
+ query: "empty",
60
+ createdAt: "2026-04-18T00:00:00.000Z",
61
+ providers: names.map((name) => ({ name })),
62
+ };
63
+ }
64
+
65
+ const EMPTY_RESULT = { present: [], missing: [], webhooks: [], skipped: [] };
66
+
67
+ describe("ai/phantom-wire empty wiring request", () => {
68
+ const savedPath = process.env.PATH;
69
+ let binDir: string;
70
+ let projectDir: string;
71
+
72
+ beforeEach(() => {
73
+ binDir = realpathSync(mkdtempSync(join(tmpdir(), "wire-empty-bin-")));
74
+ // The project dir starts empty and must stay empty: no `.stack/` tree,
75
+ // no webhook stubs, nothing.
76
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), "wire-empty-")));
77
+ // Binary only needs to resolve on PATH; spawn is stubbed. `.sh` target
78
+ // matches the sibling tests (macOS provenance policy).
79
+ writeFileSync(join(binDir, "phantom.sh"), "#!/bin/sh\n");
80
+ chmodSync(join(binDir, "phantom.sh"), 0o755);
81
+ symlinkSync(join(binDir, "phantom.sh"), join(binDir, "phantom"));
82
+ process.env.PATH = binDir;
83
+ installed = true;
84
+ vault = [];
85
+ calls = [];
86
+ __resetPhantomCache();
87
+ __setPhantomSpawn(fakeSpawn as never);
88
+ });
89
+
90
+ afterEach(() => {
91
+ process.env.PATH = savedPath;
92
+ rmSync(binDir, { recursive: true, force: true });
93
+ rmSync(projectDir, { recursive: true, force: true });
94
+ });
95
+
96
+ afterAll(() => {
97
+ __setPhantomSpawn();
98
+ __resetPhantomCache();
99
+ });
100
+
101
+ it("returns the documented empty result and performs no write for a providerless recipe", async () => {
102
+ const r = await wirePhantomForRecipe(recipeOf(), { cwd: projectDir });
103
+
104
+ expect(r).toEqual(EMPTY_RESULT);
105
+ // Nothing requested, so no process ran at all: no install probe, no
106
+ // `list`, and certainly no `add`, `remove`, or `init`.
107
+ expect(calls).toEqual([]);
108
+ expect(readdirSync(projectDir)).toEqual([]);
109
+ });
110
+
111
+ it("performs no write for a providerless recipe even when the vault is unreadable", async () => {
112
+ vault = "unreadable";
113
+ const r = await wirePhantomForRecipe(recipeOf(), { cwd: projectDir });
114
+
115
+ // The unreadable vault leaves nothing to skip: every list stays empty.
116
+ expect(r).toEqual(EMPTY_RESULT);
117
+ expect(calls).toEqual([]);
118
+ expect(readdirSync(projectDir)).toEqual([]);
119
+ });
120
+
121
+ it("performs no write for a providerless recipe when phantom is not installed", async () => {
122
+ installed = false;
123
+ const r = await wirePhantomForRecipe(recipeOf(), { cwd: projectDir });
124
+
125
+ // With no providers there is nothing to skip and no stub to write, so a
126
+ // missing phantom still yields the documented empty result.
127
+ expect(r).toEqual(EMPTY_RESULT);
128
+ expect(calls).toEqual([]);
129
+ expect(readdirSync(projectDir)).toEqual([]);
130
+ });
131
+
132
+ it("spawns nothing and writes nothing when the request opts out with noWire", async () => {
133
+ // `--no-wire` returns before the install probe: providers or not, an
134
+ // opted-out request must not touch phantom or the filesystem at all.
135
+ for (const recipe of [recipeOf(), recipeOf("stripe", "github")]) {
136
+ calls = [];
137
+ const r = await wirePhantomForRecipe(recipe, { cwd: projectDir, noWire: true });
138
+
139
+ expect(r).toEqual(EMPTY_RESULT);
140
+ expect(calls).toEqual([]);
141
+ expect(readdirSync(projectDir)).toEqual([]);
142
+ }
143
+ });
144
+ });
@@ -50,7 +50,8 @@ export async function wirePhantomForRecipe(
50
50
  opts: { cwd?: string; noWire?: boolean } = {},
51
51
  ): Promise<WireResult> {
52
52
  const result: WireResult = { present: [], missing: [], webhooks: [], skipped: [] };
53
- if (opts.noWire) return result;
53
+ // Nothing requested: no vault to check, no stub to write, no process to spawn.
54
+ if (opts.noWire || recipe.providers.length === 0) return result;
54
55
 
55
56
  const cwd = opts.cwd ?? process.cwd();
56
57
 
@@ -114,7 +115,7 @@ async function writeWebhookStub(cwd: string, provider: string): Promise<string>
114
115
  return path;
115
116
  }
116
117
 
117
- function webhookStubFor(provider: string): string {
118
+ export function webhookStubFor(provider: string): string {
118
119
  const base = `// Auto-generated by \`stack apply\` — webhook handler stub for ${provider}.
119
120
  // Fill in the secret retrieval + signature verification before deploying.
120
121
  // This entire example is commented out and does not verify signatures.
@@ -147,9 +148,11 @@ function webhookStubFor(provider: string): string {
147
148
  /**
148
149
  * One-line pointer to the provider's documented signature scheme, inlined into
149
150
  * the stub's TODO so the implementer starts at the right verification API.
151
+ * Template strings carry no placeholders — each known provider returns a
152
+ * fixed, self-contained hint.
150
153
  * Falls back to a generic instruction for providers added later.
151
154
  */
152
- function verificationHintFor(provider: string): string {
155
+ export function verificationHintFor(provider: string): string {
153
156
  switch (provider) {
154
157
  case "stripe":
155
158
  return "Stripe: `stripe.webhooks.constructEvent` — HMAC-SHA256 with timestamp tolerance.";
@@ -195,5 +198,9 @@ function signatureHeaderFor(provider: string): string {
195
198
  }
196
199
 
197
200
  function capitalize(s: string): string {
198
- return s.charAt(0).toUpperCase() + s.slice(1);
201
+ // Code-point aware: charAt(0) would take only the high surrogate of an
202
+ // astral first letter (e.g. Deseret 𐐨) and never upper-case it.
203
+ if (s === "") return s;
204
+ const first = String.fromCodePoint(s.codePointAt(0) as number);
205
+ return first.toUpperCase() + s.slice(first.length);
199
206
  }
package/src/phantom.ts CHANGED
@@ -35,6 +35,7 @@ export function completionForClose(
35
35
  }
36
36
 
37
37
  let cachedInstalled: boolean | undefined;
38
+ let spawnImpl: typeof spawn = spawn;
38
39
 
39
40
  /**
40
41
  * `phantom --version` as three states. A timeout is not cached, because a slow
@@ -88,7 +89,7 @@ export async function exec(args: string[], opts: ExecOptions = {}): Promise<Phan
88
89
  // phantom and any grandchild holding its stdio. Interactive runs stay in
89
90
  // the foreground group so the TTY keeps working.
90
91
  const useGroup = hasTimeout && !opts.interactive && process.platform !== "win32";
91
- const child = spawn(binary, args, {
92
+ const child = spawnImpl(binary, args, {
92
93
  env,
93
94
  cwd: opts.cwd,
94
95
  stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
@@ -424,6 +425,14 @@ export function __resetPhantomCache(): void {
424
425
  cachedInstalled = undefined;
425
426
  }
426
427
 
428
+ /**
429
+ * For tests only. Replaces the process spawner so a test can stub `phantom`
430
+ * without a real subprocess; call with no argument to restore the real one.
431
+ */
432
+ export function __setPhantomSpawn(impl?: typeof spawn): void {
433
+ spawnImpl = impl ?? spawn;
434
+ }
435
+
427
436
  /**
428
437
  * Redact the secret value in phantom CLI arg lists before they're interpolated
429
438
  * into any error message or log line. `phantom add <KEY> <VALUE>` is the main
@@ -26,9 +26,9 @@ export const STACK_MANIFEST = Object.freeze({
26
26
  schema_version: 1,
27
27
  kind: "ashlr-stack-manifest",
28
28
  components: Object.freeze([
29
- Object.freeze({ id: "core", name: "@ashlr/stack-core", version: "0.3.1", distribution: "npm" }),
30
- Object.freeze({ id: "cli", name: "@ashlr/stack", version: "0.3.1", distribution: "npm" }),
31
- Object.freeze({ id: "mcp", name: "ashlr-stack-mcp", version: "0.3.1", distribution: "npm" }),
29
+ Object.freeze({ id: "core", name: "@ashlr/stack-core", version: "0.3.6", distribution: "npm" }),
30
+ Object.freeze({ id: "cli", name: "@ashlr/stack", version: "0.3.6", distribution: "npm" }),
31
+ Object.freeze({ id: "mcp", name: "ashlr-stack-mcp", version: "0.3.6", distribution: "npm" }),
32
32
  Object.freeze({
33
33
  id: "plugin",
34
34
  name: "stack",