@ashlr/stack-core 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/package.json +57 -0
  4. package/src/__tests__/_harness.ts +146 -0
  5. package/src/__tests__/api-key-providers.test.ts +237 -0
  6. package/src/__tests__/catalog-index.test.ts +103 -0
  7. package/src/__tests__/circuit-breaker.test.ts +112 -0
  8. package/src/__tests__/clone-url-validation.test.ts +47 -0
  9. package/src/__tests__/cloudflare-provider.test.ts +74 -0
  10. package/src/__tests__/completion-ci.test.ts +62 -0
  11. package/src/__tests__/config-split.test.ts +138 -0
  12. package/src/__tests__/config.test.ts +66 -0
  13. package/src/__tests__/deps.test.ts +58 -0
  14. package/src/__tests__/detect-source.test.ts +147 -0
  15. package/src/__tests__/detect.test.ts +67 -0
  16. package/src/__tests__/github-provider.test.ts +65 -0
  17. package/src/__tests__/helpers.test.ts +38 -0
  18. package/src/__tests__/http.test.ts +235 -0
  19. package/src/__tests__/import-integration.test.ts +87 -0
  20. package/src/__tests__/inference.test.ts +326 -0
  21. package/src/__tests__/neon-provider.test.ts +79 -0
  22. package/src/__tests__/openai-provider.test.ts +54 -0
  23. package/src/__tests__/phantom-harness.test.ts +44 -0
  24. package/src/__tests__/phantom-wire.test.ts +85 -0
  25. package/src/__tests__/pipeline-integration.test.ts +95 -0
  26. package/src/__tests__/pipeline-recovery.test.ts +106 -0
  27. package/src/__tests__/pipeline.test.ts +92 -0
  28. package/src/__tests__/recipe.test.ts +164 -0
  29. package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
  30. package/src/__tests__/registry.test.ts +78 -0
  31. package/src/__tests__/secret-redaction.test.ts +170 -0
  32. package/src/__tests__/security.test.ts +80 -0
  33. package/src/__tests__/supabase-provider.test.ts +108 -0
  34. package/src/__tests__/templates.test.ts +26 -0
  35. package/src/__tests__/turso-provider.test.ts +71 -0
  36. package/src/ai/catalog-index.ts +256 -0
  37. package/src/ai/circuit-breaker.ts +116 -0
  38. package/src/ai/cost-tracker.ts +103 -0
  39. package/src/ai/inference.ts +470 -0
  40. package/src/ai/phantom-wire.ts +184 -0
  41. package/src/ai/recipe.ts +178 -0
  42. package/src/catalog.ts +430 -0
  43. package/src/config.ts +293 -0
  44. package/src/detect-source.ts +344 -0
  45. package/src/detect.ts +207 -0
  46. package/src/env.ts +35 -0
  47. package/src/errors.ts +29 -0
  48. package/src/http.ts +212 -0
  49. package/src/index.ts +61 -0
  50. package/src/mcp-writer.ts +50 -0
  51. package/src/oauth.ts +184 -0
  52. package/src/phantom.ts +156 -0
  53. package/src/pipeline.ts +131 -0
  54. package/src/providers/_api-key.ts +122 -0
  55. package/src/providers/_base.ts +96 -0
  56. package/src/providers/_helpers.ts +81 -0
  57. package/src/providers/anthropic.ts +27 -0
  58. package/src/providers/aws.ts +198 -0
  59. package/src/providers/braintrust.ts +33 -0
  60. package/src/providers/clerk.ts +23 -0
  61. package/src/providers/cloudflare.ts +146 -0
  62. package/src/providers/convex.ts +28 -0
  63. package/src/providers/deepseek.ts +24 -0
  64. package/src/providers/firebase.ts +35 -0
  65. package/src/providers/fly.ts +24 -0
  66. package/src/providers/github.ts +180 -0
  67. package/src/providers/index.ts +59 -0
  68. package/src/providers/linear.ts +46 -0
  69. package/src/providers/mailgun.ts +26 -0
  70. package/src/providers/modal.ts +27 -0
  71. package/src/providers/neon.ts +175 -0
  72. package/src/providers/openai.ts +24 -0
  73. package/src/providers/posthog.ts +30 -0
  74. package/src/providers/postmark.ts +27 -0
  75. package/src/providers/railway.ts +35 -0
  76. package/src/providers/render.ts +31 -0
  77. package/src/providers/replicate.ts +30 -0
  78. package/src/providers/resend.ts +24 -0
  79. package/src/providers/sendgrid.ts +24 -0
  80. package/src/providers/sentry.ts +183 -0
  81. package/src/providers/stripe.ts +33 -0
  82. package/src/providers/supabase.ts +309 -0
  83. package/src/providers/turso.ts +197 -0
  84. package/src/providers/upstash.ts +32 -0
  85. package/src/providers/vercel.ts +134 -0
  86. package/src/providers/xai.ts +24 -0
  87. package/src/registry.ts +114 -0
  88. package/src/templates.ts +39 -0
@@ -0,0 +1,326 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ ClaudeMCPBackend,
4
+ LocalSLMBackend,
5
+ NoInferenceBackendError,
6
+ getInferenceBackend,
7
+ parseRecipeDraft,
8
+ } from "../ai/inference.ts";
9
+ import { CostTracker } from "../ai/cost-tracker.ts";
10
+
11
+ /**
12
+ * Inference backend tests. The local backend talks to an OpenAI-compatible
13
+ * HTTP API, so every test either injects a fake `fetchImpl` or monkey-patches
14
+ * `globalThis.fetch`. We never hit the network.
15
+ */
16
+
17
+ // Minimal valid chat-completion response.
18
+ function okChatResponse(content: string, usage?: { prompt_tokens?: number; completion_tokens?: number }) {
19
+ return new Response(
20
+ JSON.stringify({
21
+ choices: [{ message: { content } }],
22
+ usage,
23
+ }),
24
+ { status: 200, headers: { "content-type": "application/json" } },
25
+ );
26
+ }
27
+
28
+ const VALID_RECIPE_JSON = JSON.stringify({
29
+ providers: [
30
+ { name: "neon", rationale: "Managed Postgres with branching" },
31
+ { name: "clerk", rationale: "Drop-in auth UI" },
32
+ ],
33
+ notes: "Add Stripe later if billing shows up",
34
+ });
35
+
36
+ describe("getInferenceBackend — selection", () => {
37
+ test("STACK_MCP_MODE=1 selects ClaudeMCPBackend", async () => {
38
+ const backend = await getInferenceBackend({
39
+ env: { STACK_MCP_MODE: "1" },
40
+ });
41
+ expect(backend.name).toBe("claude-mcp");
42
+ expect(backend).toBeInstanceOf(ClaudeMCPBackend);
43
+ });
44
+
45
+ test("MCP_CLIENT set selects ClaudeMCPBackend", async () => {
46
+ const backend = await getInferenceBackend({
47
+ env: { MCP_CLIENT: "claude-code" },
48
+ });
49
+ expect(backend.name).toBe("claude-mcp");
50
+ });
51
+
52
+ test("no MCP env + reachable LM Studio → LocalSLMBackend", async () => {
53
+ const backend = await getInferenceBackend({
54
+ env: {},
55
+ localOptions: {
56
+ endpoints: [{ name: "lm-studio-fake", baseUrl: "http://fake/v1", model: "x" }],
57
+ fetchImpl: (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch,
58
+ },
59
+ });
60
+ expect(backend.name).toBe("local-slm");
61
+ expect(backend).toBeInstanceOf(LocalSLMBackend);
62
+ });
63
+
64
+ test("preferLocal=true overrides MCP mode", async () => {
65
+ const backend = await getInferenceBackend({
66
+ preferLocal: true,
67
+ env: { STACK_MCP_MODE: "1" },
68
+ localOptions: {
69
+ endpoints: [{ name: "lm-studio-fake", baseUrl: "http://fake/v1", model: "x" }],
70
+ fetchImpl: (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch,
71
+ },
72
+ });
73
+ expect(backend.name).toBe("local-slm");
74
+ });
75
+
76
+ test("local backend with both endpoints unreachable throws NoInferenceBackendError", async () => {
77
+ await expect(
78
+ getInferenceBackend({
79
+ env: {},
80
+ localOptions: {
81
+ endpoints: [
82
+ { name: "lm-studio-fake", baseUrl: "http://127.0.0.1:1/v1", model: "x" },
83
+ { name: "ollama-fake", baseUrl: "http://127.0.0.1:2/v1", model: "y" },
84
+ ],
85
+ fetchImpl: (async () => {
86
+ throw new Error("ECONNREFUSED");
87
+ }) as unknown as typeof fetch,
88
+ },
89
+ }),
90
+ ).rejects.toBeInstanceOf(NoInferenceBackendError);
91
+ });
92
+ });
93
+
94
+ describe("ClaudeMCPBackend", () => {
95
+ test("infer returns mcp-delegated mode with the grounding payload", async () => {
96
+ const backend = new ClaudeMCPBackend();
97
+ const result = await backend.infer({
98
+ query: "payments + auth",
99
+ catalogContext: "stripe — payments\nclerk — auth",
100
+ projectContext: { pkg: "my-app", frameworks: ["next"] },
101
+ });
102
+ expect(result.mode).toBe("mcp-delegated");
103
+ expect(result.recipe).toBeUndefined();
104
+ expect(result.delegatedPayload).toBeDefined();
105
+ const payload = result.delegatedPayload as Record<string, unknown>;
106
+ expect(payload.query).toBe("payments + auth");
107
+ expect(payload.catalogContext).toContain("stripe");
108
+ expect(payload.projectContext).toEqual({ pkg: "my-app", frameworks: ["next"] });
109
+ });
110
+
111
+ test("healthy resolves true without network", async () => {
112
+ expect(await new ClaudeMCPBackend().healthy()).toBe(true);
113
+ });
114
+ });
115
+
116
+ describe("LocalSLMBackend — infer", () => {
117
+ test("happy path — parses recipe + records usage with reported tokens", async () => {
118
+ const costTracker = new CostTracker();
119
+ const fetchImpl = (async () =>
120
+ okChatResponse(VALID_RECIPE_JSON, { prompt_tokens: 120, completion_tokens: 45 })) as unknown as typeof fetch;
121
+
122
+ const backend = new LocalSLMBackend({
123
+ endpoints: [{ name: "lm-studio-fake", baseUrl: "http://fake/v1", model: "test" }],
124
+ costTracker,
125
+ fetchImpl,
126
+ });
127
+
128
+ const result = await backend.infer({
129
+ query: "postgres + auth",
130
+ catalogContext: "neon — Postgres\nclerk — auth",
131
+ });
132
+
133
+ expect(result.mode).toBe("synth");
134
+ expect(result.recipe?.providers.map((p) => p.name)).toEqual(["neon", "clerk"]);
135
+ expect(result.usage?.inputTokens).toBe(120);
136
+ expect(result.usage?.outputTokens).toBe(45);
137
+ expect(result.usage?.costUsd).toBe(0);
138
+ expect(backend.lastEndpoint).toBe("lm-studio-fake");
139
+
140
+ const summary = costTracker.getSummary();
141
+ expect(summary.totalInputTokens).toBe(120);
142
+ expect(summary.totalOutputTokens).toBe(45);
143
+ expect(summary.totalCostUsd).toBe(0);
144
+ });
145
+
146
+ test("falls back to the second endpoint when the first fails", async () => {
147
+ let call = 0;
148
+ const fetchImpl = (async (input: string | URL | Request) => {
149
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
150
+ call++;
151
+ if (url.includes("primary")) {
152
+ throw new Error("ECONNREFUSED");
153
+ }
154
+ return okChatResponse(VALID_RECIPE_JSON);
155
+ }) as unknown as typeof fetch;
156
+
157
+ const backend = new LocalSLMBackend({
158
+ endpoints: [
159
+ { name: "primary", baseUrl: "http://primary/v1", model: "a" },
160
+ { name: "secondary", baseUrl: "http://secondary/v1", model: "b" },
161
+ ],
162
+ fetchImpl,
163
+ costTracker: new CostTracker(),
164
+ });
165
+
166
+ const result = await backend.infer({ query: "x", catalogContext: "" });
167
+ expect(result.mode).toBe("synth");
168
+ expect(backend.lastEndpoint).toBe("secondary");
169
+ expect(call).toBeGreaterThanOrEqual(2);
170
+ });
171
+
172
+ test("throws NoInferenceBackendError when every endpoint fails", async () => {
173
+ const fetchImpl = (async () => {
174
+ throw new Error("down");
175
+ }) as unknown as typeof fetch;
176
+
177
+ const backend = new LocalSLMBackend({
178
+ endpoints: [
179
+ { name: "a", baseUrl: "http://a/v1", model: "m" },
180
+ { name: "b", baseUrl: "http://b/v1", model: "m" },
181
+ ],
182
+ fetchImpl,
183
+ costTracker: new CostTracker(),
184
+ });
185
+
186
+ await expect(backend.infer({ query: "x", catalogContext: "" })).rejects.toBeInstanceOf(
187
+ NoInferenceBackendError,
188
+ );
189
+ });
190
+
191
+ test("circuit breaker opens after 3 consecutive failures and rejects immediately", async () => {
192
+ let calls = 0;
193
+ const fetchImpl = (async () => {
194
+ calls++;
195
+ throw new Error("boom");
196
+ }) as unknown as typeof fetch;
197
+
198
+ // Single-endpoint backend so the breaker state is easy to reason about.
199
+ const backend = new LocalSLMBackend({
200
+ endpoints: [{ name: "solo", baseUrl: "http://solo/v1", model: "m" }],
201
+ fetchImpl,
202
+ costTracker: new CostTracker(),
203
+ circuitBreakerThreshold: 3,
204
+ // Long reset — once open, stays open for the remainder of the test.
205
+ circuitBreakerWindowMs: 60_000,
206
+ });
207
+
208
+ for (let i = 0; i < 3; i++) {
209
+ await expect(backend.infer({ query: "x", catalogContext: "" })).rejects.toBeInstanceOf(
210
+ NoInferenceBackendError,
211
+ );
212
+ }
213
+ const callsAfterTrip = calls;
214
+ // Fourth call should short-circuit — no new fetch attempt.
215
+ await expect(backend.infer({ query: "x", catalogContext: "" })).rejects.toBeInstanceOf(
216
+ NoInferenceBackendError,
217
+ );
218
+ expect(calls).toBe(callsAfterTrip);
219
+ });
220
+
221
+ test("rejects non-2xx with a readable error message", async () => {
222
+ const fetchImpl = (async () =>
223
+ new Response("model not loaded", { status: 503 })) as unknown as typeof fetch;
224
+
225
+ const backend = new LocalSLMBackend({
226
+ endpoints: [{ name: "solo", baseUrl: "http://solo/v1", model: "m" }],
227
+ fetchImpl,
228
+ costTracker: new CostTracker(),
229
+ });
230
+
231
+ await expect(
232
+ backend.infer({ query: "x", catalogContext: "" }),
233
+ ).rejects.toBeInstanceOf(NoInferenceBackendError);
234
+ });
235
+
236
+ test("healthy pings /models and returns true when any endpoint responds", async () => {
237
+ const fetchImpl = (async (input: string | URL | Request) => {
238
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
239
+ if (url.endsWith("/models")) return new Response("[]", { status: 200 });
240
+ return new Response("", { status: 500 });
241
+ }) as unknown as typeof fetch;
242
+
243
+ const backend = new LocalSLMBackend({
244
+ endpoints: [{ name: "solo", baseUrl: "http://solo/v1", model: "m" }],
245
+ fetchImpl,
246
+ });
247
+ expect(await backend.healthy()).toBe(true);
248
+ });
249
+ });
250
+
251
+ describe("parseRecipeDraft", () => {
252
+ test("parses plain JSON", () => {
253
+ const draft = parseRecipeDraft(VALID_RECIPE_JSON);
254
+ expect(draft.providers).toHaveLength(2);
255
+ expect(draft.providers[0]!.name).toBe("neon");
256
+ });
257
+
258
+ test("tolerates markdown fences", () => {
259
+ const wrapped = "```json\n" + VALID_RECIPE_JSON + "\n```";
260
+ const draft = parseRecipeDraft(wrapped);
261
+ expect(draft.providers).toHaveLength(2);
262
+ });
263
+
264
+ test("tolerates leading prose", () => {
265
+ const draft = parseRecipeDraft(
266
+ "Sure! Here's the recipe:\n" + VALID_RECIPE_JSON + "\nLet me know!",
267
+ );
268
+ expect(draft.providers).toHaveLength(2);
269
+ });
270
+
271
+ test("throws when the object has no usable providers", () => {
272
+ expect(() => parseRecipeDraft('{"providers":[]}')).toThrow();
273
+ });
274
+
275
+ test("throws when the response is not JSON", () => {
276
+ expect(() => parseRecipeDraft("sorry, I can't help with that")).toThrow();
277
+ });
278
+ });
279
+
280
+ describe("CostTracker — math", () => {
281
+ test("zero rate yields $0 even for large token counts", () => {
282
+ const t = new CostTracker();
283
+ t.recordUsage("local", 10_000_000, 5_000_000);
284
+ const s = t.getSummary();
285
+ expect(s.totalInputTokens).toBe(10_000_000);
286
+ expect(s.totalOutputTokens).toBe(5_000_000);
287
+ expect(s.totalCostUsd).toBe(0);
288
+ expect(s.perBackend.local?.calls).toBe(1);
289
+ });
290
+
291
+ test("applies per-1M rate card correctly", () => {
292
+ const t = new CostTracker();
293
+ t.setRate("remote", { inputPer1M: 3, outputPer1M: 15 });
294
+ const rec = t.recordUsage("remote", 1_000_000, 500_000);
295
+ // 1M * $3 + 0.5M * $15 = 3 + 7.5 = 10.5
296
+ expect(rec.costUsd).toBeCloseTo(10.5, 6);
297
+ expect(t.getSummary().totalCostUsd).toBeCloseTo(10.5, 6);
298
+ });
299
+
300
+ test("aggregates across backends and calls", () => {
301
+ const t = new CostTracker();
302
+ t.setRate("a", { inputPer1M: 1, outputPer1M: 2 });
303
+ t.setRate("b", { inputPer1M: 4, outputPer1M: 8 });
304
+ t.recordUsage("a", 500_000, 500_000); // 0.5 + 1 = 1.5
305
+ t.recordUsage("a", 500_000, 500_000); // +1.5
306
+ t.recordUsage("b", 250_000, 250_000); // 1 + 2 = 3
307
+ const s = t.getSummary();
308
+ expect(s.perBackend.a?.calls).toBe(2);
309
+ expect(s.perBackend.b?.calls).toBe(1);
310
+ expect(s.totalCostUsd).toBeCloseTo(6, 6);
311
+ });
312
+ });
313
+
314
+ // Ensure the global fetch is untouched if any future test forgets to restore.
315
+ describe("fetch safety net", () => {
316
+ const realFetch = globalThis.fetch;
317
+ beforeEach(() => {
318
+ globalThis.fetch = realFetch;
319
+ });
320
+ afterEach(() => {
321
+ globalThis.fetch = realFetch;
322
+ });
323
+ test("globalThis.fetch is restored", () => {
324
+ expect(globalThis.fetch).toBe(realFetch);
325
+ });
326
+ });
@@ -0,0 +1,79 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import neon from "../providers/neon.ts";
3
+ import type { ProviderContext } from "../providers/_base.ts";
4
+ import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
5
+
6
+ describe("neon provider (mocked fetch)", () => {
7
+ let h: Harness;
8
+ let realFetch: typeof fetch;
9
+
10
+ beforeEach(() => {
11
+ h = setupFakePhantom();
12
+ realFetch = globalThis.fetch;
13
+ });
14
+
15
+ afterEach(() => {
16
+ globalThis.fetch = realFetch;
17
+ h.cleanup();
18
+ });
19
+
20
+ test("provision POSTs /projects and materialize fetches a connection URI", async () => {
21
+ const projectId = "proj_abc123";
22
+ const connectionUri = "postgresql://user:pw@ep-xx.us-east-2.aws.neon.tech/neondb";
23
+
24
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
25
+ const u = String(url);
26
+ const method = init?.method ?? "GET";
27
+
28
+ if (u.endsWith("/projects") && method === "POST") {
29
+ return new Response(
30
+ JSON.stringify({
31
+ project: { id: projectId, name: "stack-xyz", region_id: "aws-us-east-2" },
32
+ connection_uris: [{ connection_uri: connectionUri }],
33
+ }),
34
+ { status: 201 },
35
+ );
36
+ }
37
+ if (u.endsWith(`/projects/${projectId}/connection_uri`)) {
38
+ return new Response(JSON.stringify({ uri: connectionUri }), { status: 200 });
39
+ }
40
+ throw new Error(`unexpected ${method} ${u}`);
41
+ }) as unknown as typeof fetch;
42
+
43
+ const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
44
+ const auth = { token: "neon_fake", identity: {} };
45
+
46
+ const resource = await neon.provision(ctx, auth, {});
47
+ expect(resource.id).toBe(projectId);
48
+ expect(resource.region).toBe("aws-us-east-2");
49
+
50
+ const materialized = await neon.materialize(ctx, resource, auth);
51
+ expect(materialized.secrets.NEON_API_KEY).toBe("neon_fake");
52
+ expect(materialized.secrets.DATABASE_URL).toBe(connectionUri);
53
+
54
+ // Provision stores a project-scoped copy so other providers can't stomp it.
55
+ const vault = await readVault(h.dir);
56
+ expect(vault[`NEON_DATABASE_URL_${projectId}`]).toBe(connectionUri);
57
+ });
58
+
59
+ test("healthcheck returns error when the project 404s", async () => {
60
+ const { addSecret } = await import("../phantom.ts");
61
+ await addSecret("NEON_API_KEY", "neon_cached");
62
+
63
+ globalThis.fetch = (async (url: string | URL | Request) => {
64
+ if (String(url).includes("/projects/")) {
65
+ return new Response("not found", { status: 404 });
66
+ }
67
+ return new Response("nope", { status: 404 });
68
+ }) as unknown as typeof fetch;
69
+
70
+ const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
71
+ const status = await neon.healthcheck!(ctx, {
72
+ provider: "neon",
73
+ resource_id: "missing_proj",
74
+ secrets: ["NEON_API_KEY"],
75
+ created_at: new Date().toISOString(),
76
+ });
77
+ expect(status.kind).toBe("error");
78
+ });
79
+ });
@@ -0,0 +1,54 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import openai from "../providers/openai.ts";
3
+ import type { ProviderContext } from "../providers/_base.ts";
4
+ import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
5
+
6
+ describe("openai provider (mocked fetch)", () => {
7
+ let h: Harness;
8
+ let realFetch: typeof fetch;
9
+
10
+ beforeEach(() => {
11
+ h = setupFakePhantom();
12
+ realFetch = globalThis.fetch;
13
+ });
14
+
15
+ afterEach(() => {
16
+ globalThis.fetch = realFetch;
17
+ h.cleanup();
18
+ });
19
+
20
+ test("materialize stores the key under OPENAI_API_KEY", async () => {
21
+ globalThis.fetch = (async (url: string | URL | Request) => {
22
+ const u = String(url);
23
+ if (u.endsWith("/v1/models")) {
24
+ return new Response(JSON.stringify({ data: [{ id: "gpt-4o" }] }), { status: 200 });
25
+ }
26
+ throw new Error(`unexpected fetch ${u}`);
27
+ }) as typeof fetch;
28
+
29
+ const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
30
+ const auth = { token: "sk-fake", identity: { models: "1" } };
31
+ const resource = await openai.provision(ctx, auth, {});
32
+ const materialized = await openai.materialize(ctx, resource, auth);
33
+
34
+ expect(materialized.secrets.OPENAI_API_KEY).toBe("sk-fake");
35
+ expect((await readVault(h.dir)).OPENAI_API_KEY).toBe("sk-fake");
36
+ });
37
+
38
+ test("healthcheck fails gracefully when the key is invalid", async () => {
39
+ globalThis.fetch = (async () =>
40
+ new Response("unauthorized", { status: 401 })) as unknown as typeof fetch;
41
+
42
+ // Pre-seed an invalid key so healthcheck has something to verify.
43
+ const { addSecret } = await import("../phantom.ts");
44
+ await addSecret("OPENAI_API_KEY", "sk-bad");
45
+
46
+ const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
47
+ const status = await openai.healthcheck!(ctx, {
48
+ provider: "openai",
49
+ secrets: ["OPENAI_API_KEY"],
50
+ created_at: new Date().toISOString(),
51
+ });
52
+ expect(status.kind).toBe("error");
53
+ });
54
+ });
@@ -0,0 +1,44 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ addSecret,
4
+ isPhantomInstalled,
5
+ listSecrets,
6
+ removeSecret,
7
+ revealSecret,
8
+ } from "../phantom.ts";
9
+ import { type Harness, setupFakePhantom } from "./_harness.ts";
10
+
11
+ describe("phantom wrapper against the fake harness", () => {
12
+ let h: Harness;
13
+
14
+ beforeEach(() => {
15
+ h = setupFakePhantom({ EXISTING_KEY: "preset-value" });
16
+ });
17
+
18
+ afterEach(() => {
19
+ h.cleanup();
20
+ });
21
+
22
+ test("isPhantomInstalled resolves true when the fake binary is on PATH", async () => {
23
+ expect(await isPhantomInstalled()).toBe(true);
24
+ });
25
+
26
+ test("addSecret writes to the fake vault", async () => {
27
+ await addSecret("NEW_KEY", "new-value");
28
+ expect(await revealSecret("NEW_KEY")).toBe("new-value");
29
+ expect(h.callsTo("add").length).toBe(1);
30
+ });
31
+
32
+ test("listSecrets returns vault keys", async () => {
33
+ await addSecret("ANOTHER", "v");
34
+ const keys = await listSecrets();
35
+ expect(keys).toContain("EXISTING_KEY");
36
+ expect(keys).toContain("ANOTHER");
37
+ });
38
+
39
+ test("removeSecret deletes from the fake vault", async () => {
40
+ await removeSecret("EXISTING_KEY");
41
+ const keys = await listSecrets();
42
+ expect(keys).not.toContain("EXISTING_KEY");
43
+ });
44
+ });
@@ -0,0 +1,85 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
5
+ import type { Recipe } from "../ai/recipe.ts";
6
+ import { setupFakePhantom, type Harness } from "./_harness.ts";
7
+
8
+ describe("ai/phantom-wire", () => {
9
+ let harness: Harness;
10
+
11
+ beforeEach(() => {
12
+ harness = setupFakePhantom();
13
+ });
14
+ afterEach(() => {
15
+ harness.cleanup();
16
+ });
17
+
18
+ const STRIPE_RECIPE: Recipe = {
19
+ id: "stripe-only",
20
+ query: "payments",
21
+ createdAt: "2026-04-18T00:00:00.000Z",
22
+ providers: [{ name: "stripe" }],
23
+ };
24
+
25
+ it("calls `phantom add STRIPE_SECRET_KEY` and writes a webhook stub", async () => {
26
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
27
+
28
+ expect(result.envelopes).toContain("stripe:STRIPE_SECRET_KEY");
29
+
30
+ // Fake phantom treats `--rotate` as an unknown arg and succeeds anyway
31
+ // (our fake echoes success for unknown cases via the `*` branch exit 0),
32
+ // but we always log the call — so the `add` log should mention the key.
33
+ const adds = harness.callsTo("add");
34
+ const keys = adds.flatMap((c) => c.args);
35
+ expect(keys).toContain("STRIPE_SECRET_KEY");
36
+
37
+ const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
38
+ expect(existsSync(stubPath)).toBe(true);
39
+ const stub = readFileSync(stubPath, "utf-8");
40
+ expect(stub).toContain("stripe-signature");
41
+ expect(stub).toContain("STRIPE_WEBHOOK_SECRET");
42
+ expect(result.webhooks).toHaveLength(1);
43
+ });
44
+
45
+ it("noWire: true skips envelopes and webhooks entirely", async () => {
46
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, {
47
+ cwd: harness.dir,
48
+ noWire: true,
49
+ });
50
+ expect(result).toEqual({ envelopes: [], webhooks: [], skipped: [] });
51
+
52
+ const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
53
+ expect(existsSync(stubPath)).toBe(false);
54
+ expect(harness.callsTo("add")).toEqual([]);
55
+ });
56
+
57
+ it("creates envelopes for every secret on a multi-secret provider", async () => {
58
+ const recipe: Recipe = {
59
+ id: "supa",
60
+ query: "database",
61
+ createdAt: "2026-04-18T00:00:00.000Z",
62
+ providers: [{ name: "supabase" }],
63
+ };
64
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
65
+ expect(result.envelopes).toEqual([
66
+ "supabase:SUPABASE_URL",
67
+ "supabase:SUPABASE_ANON_KEY",
68
+ "supabase:SUPABASE_SERVICE_ROLE_KEY",
69
+ ]);
70
+ // Supabase is webhook-capable in our moat list.
71
+ expect(result.webhooks).toHaveLength(1);
72
+ });
73
+
74
+ it("skips providers with no webhook stub without crashing", async () => {
75
+ const recipe: Recipe = {
76
+ id: "neon",
77
+ query: "database",
78
+ createdAt: "2026-04-18T00:00:00.000Z",
79
+ providers: [{ name: "neon" }],
80
+ };
81
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
82
+ expect(result.webhooks).toEqual([]);
83
+ expect(result.envelopes.length).toBeGreaterThan(0);
84
+ });
85
+ });
@@ -0,0 +1,95 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { emptyConfig, writeConfig } from "../config.ts";
6
+ import { addService } from "../pipeline.ts";
7
+ import type { Provider } from "../providers/_base.ts";
8
+ import { providers } from "../providers/index.ts";
9
+ import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
10
+
11
+ /**
12
+ * End-to-end pipeline test: register a stub provider, run addService, and
13
+ * verify the .stack.toml + fake vault + .mcp.json all get the right writes.
14
+ */
15
+ describe("addService full loop", () => {
16
+ let h: Harness;
17
+ let cwd: string;
18
+ let originalCwd: string;
19
+
20
+ beforeEach(async () => {
21
+ h = setupFakePhantom();
22
+ cwd = mkdtempSync(join(tmpdir(), "stack-pipeline-"));
23
+ originalCwd = process.cwd();
24
+ process.chdir(cwd);
25
+ await writeConfig(emptyConfig("test-template"), cwd);
26
+ });
27
+
28
+ afterEach(() => {
29
+ process.chdir(originalCwd);
30
+ h.cleanup();
31
+ });
32
+
33
+ test("stub provider runs login → provision → materialize → writes everything", async () => {
34
+ const stubProvider: Provider = {
35
+ name: "stubsvc",
36
+ displayName: "Stub Service",
37
+ category: "database",
38
+ authKind: "api_key",
39
+ async login() {
40
+ return { token: "stub-token", identity: { id: "user-1" } };
41
+ },
42
+ async provision() {
43
+ return { id: "stub-resource-1", displayName: "stub-resource-1", region: "us-east-1" };
44
+ },
45
+ async materialize(_ctx, resource) {
46
+ return {
47
+ secrets: {
48
+ STUB_URL: `https://${resource.id}.example.com`,
49
+ STUB_API_KEY: "secret-value",
50
+ },
51
+ mcp: {
52
+ name: "stubsvc",
53
+ type: "stdio",
54
+ command: "npx",
55
+ args: ["-y", "fake-mcp"],
56
+ },
57
+ };
58
+ },
59
+ };
60
+
61
+ // Hot-register the stub in the provider loader.
62
+ providers.stubsvc = async () => stubProvider;
63
+
64
+ const result = await addService({
65
+ providerName: "stubsvc",
66
+ cwd,
67
+ interactive: false,
68
+ });
69
+
70
+ expect(result.providerName).toBe("stubsvc");
71
+ expect(result.resourceId).toBe("stub-resource-1");
72
+ expect(result.secretCount).toBe(2);
73
+ expect(result.mcpWired).toBe(true);
74
+
75
+ // Secrets landed in the fake vault.
76
+ const vault = await readVault(h.dir);
77
+ expect(vault.STUB_URL).toBe("https://stub-resource-1.example.com");
78
+ expect(vault.STUB_API_KEY).toBe("secret-value");
79
+
80
+ // .stack.toml got an entry.
81
+ const { readConfig } = await import("../config.ts");
82
+ const config = await readConfig(cwd);
83
+ expect(config.services.stubsvc).toBeDefined();
84
+ expect(config.services.stubsvc.resource_id).toBe("stub-resource-1");
85
+ expect(config.services.stubsvc.secrets.sort()).toEqual(["STUB_API_KEY", "STUB_URL"]);
86
+ expect(config.services.stubsvc.mcp).toBe("stubsvc");
87
+
88
+ // .mcp.json got merged.
89
+ const mcpRaw = await Bun.file(join(cwd, ".mcp.json")).text();
90
+ const mcp = JSON.parse(mcpRaw) as { mcpServers: Record<string, unknown> };
91
+ expect(mcp.mcpServers.stubsvc).toBeDefined();
92
+
93
+ delete providers.stubsvc;
94
+ });
95
+ });