@ashlr/stack-core 0.1.1 → 0.2.2

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 (101) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +217 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +162 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/cloudflare-provider.test.ts +9 -10
  10. package/src/__tests__/completion-ci.test.ts +239 -23
  11. package/src/__tests__/config-own-services.test.ts +183 -0
  12. package/src/__tests__/config-split.test.ts +206 -2
  13. package/src/__tests__/config.test.ts +2 -1
  14. package/src/__tests__/credential-staging.test.ts +659 -0
  15. package/src/__tests__/deps.test.ts +2 -1
  16. package/src/__tests__/detect-source.test.ts +1 -1
  17. package/src/__tests__/github-provider.test.ts +3 -2
  18. package/src/__tests__/helpers.test.ts +24 -4
  19. package/src/__tests__/http.test.ts +2 -2
  20. package/src/__tests__/inference.test.ts +19 -13
  21. package/src/__tests__/interactive-prompt.test.ts +106 -0
  22. package/src/__tests__/mcp-mirror.test.ts +116 -0
  23. package/src/__tests__/neon-provider.test.ts +136 -3
  24. package/src/__tests__/new-providers.test.ts +470 -0
  25. package/src/__tests__/oauth-port.test.ts +84 -0
  26. package/src/__tests__/openai-provider.test.ts +1 -1
  27. package/src/__tests__/phantom-contract.test.ts +177 -0
  28. package/src/__tests__/phantom-harness.test.ts +125 -8
  29. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  30. package/src/__tests__/phantom-wire.test.ts +31 -20
  31. package/src/__tests__/pipeline-integration.test.ts +1 -1
  32. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  33. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  34. package/src/__tests__/pipeline.test.ts +3 -1
  35. package/src/__tests__/pm-detect.test.ts +113 -0
  36. package/src/__tests__/provider-deprovision.test.ts +269 -0
  37. package/src/__tests__/provider-parity.test.ts +46 -0
  38. package/src/__tests__/publish-script.test.ts +659 -0
  39. package/src/__tests__/recipe.test.ts +4 -2
  40. package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
  41. package/src/__tests__/registry.test.ts +11 -2
  42. package/src/__tests__/release-artifacts.test.ts +55 -0
  43. package/src/__tests__/secret-redaction.test.ts +10 -10
  44. package/src/__tests__/security.test.ts +9 -10
  45. package/src/__tests__/supabase-provider.test.ts +6 -4
  46. package/src/__tests__/swap.test.ts +114 -0
  47. package/src/__tests__/telemetry.test.ts +354 -0
  48. package/src/__tests__/template-litellm-gateway.test.ts +41 -0
  49. package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
  50. package/src/__tests__/templates.test.ts +1 -1
  51. package/src/__tests__/turso-provider.test.ts +2 -5
  52. package/src/agent-onboard.ts +346 -0
  53. package/src/ai/catalog-index.ts +68 -10
  54. package/src/ai/circuit-breaker.ts +3 -6
  55. package/src/ai/cost-tracker.ts +6 -15
  56. package/src/ai/inference.ts +19 -11
  57. package/src/ai/phantom-wire.ts +29 -52
  58. package/src/ai/recipe.ts +69 -0
  59. package/src/base-urls.ts +71 -0
  60. package/src/catalog.ts +281 -2
  61. package/src/config.ts +31 -18
  62. package/src/detect-source.ts +36 -8
  63. package/src/detect.ts +13 -5
  64. package/src/http.ts +6 -5
  65. package/src/index.ts +15 -0
  66. package/src/mcp-writer.ts +97 -2
  67. package/src/oauth.ts +85 -53
  68. package/src/phantom.ts +193 -15
  69. package/src/pipeline.ts +177 -68
  70. package/src/pm-detect.ts +56 -0
  71. package/src/providers/_api-key.ts +18 -17
  72. package/src/providers/_base.ts +28 -1
  73. package/src/providers/_credential-stage.ts +237 -0
  74. package/src/providers/_helpers.ts +55 -9
  75. package/src/providers/anthropic.ts +2 -1
  76. package/src/providers/auth0.ts +27 -0
  77. package/src/providers/aws.ts +32 -33
  78. package/src/providers/cloudflare.ts +18 -37
  79. package/src/providers/convex.ts +1 -2
  80. package/src/providers/datadog.ts +29 -0
  81. package/src/providers/digitalocean.ts +30 -0
  82. package/src/providers/gcp.ts +36 -0
  83. package/src/providers/github.ts +37 -35
  84. package/src/providers/grafana.ts +29 -0
  85. package/src/providers/hetzner.ts +29 -0
  86. package/src/providers/index.ts +13 -1
  87. package/src/providers/launchdarkly.ts +33 -0
  88. package/src/providers/mixpanel.ts +37 -0
  89. package/src/providers/neon.ts +50 -26
  90. package/src/providers/openai.ts +2 -1
  91. package/src/providers/plausible.ts +26 -0
  92. package/src/providers/sentry.ts +17 -17
  93. package/src/providers/stripe.ts +1 -2
  94. package/src/providers/supabase.ts +66 -37
  95. package/src/providers/turso.ts +49 -38
  96. package/src/providers/vercel.ts +50 -20
  97. package/src/providers/workos.ts +27 -0
  98. package/src/registry.ts +7 -11
  99. package/src/swap.ts +108 -0
  100. package/src/telemetry.ts +267 -0
  101. package/src/templates.ts +6 -2
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import github from "../providers/github.ts";
3
2
  import type { ProviderContext } from "../providers/_base.ts";
3
+ import github from "../providers/github.ts";
4
4
  import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
5
5
 
6
6
  describe("github provider (mocked fetch)", () => {
@@ -8,7 +8,8 @@ describe("github provider (mocked fetch)", () => {
8
8
  let realFetch: typeof fetch;
9
9
 
10
10
  beforeEach(() => {
11
- h = setupFakePhantom();
11
+ // Provider logic after a token is read; real Phantom 0.7.9 refuses headless reveal.
12
+ h = setupFakePhantom({}, { legacyHeadlessReveal: true });
12
13
  realFetch = globalThis.fetch;
13
14
  });
14
15
 
@@ -1,6 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { addSecret } from "../phantom.ts";
3
- import { bearerJsonHeaders, tryRevealSecret } from "../providers/_helpers.ts";
2
+ import { bearerJsonHeaders, takeRevealRefusals, tryRevealSecret } from "../providers/_helpers.ts";
4
3
  import { type Harness, setupFakePhantom } from "./_harness.ts";
5
4
 
6
5
  /**
@@ -11,7 +10,12 @@ import { type Harness, setupFakePhantom } from "./_harness.ts";
11
10
  describe("provider helpers", () => {
12
11
  let h: Harness;
13
12
  beforeEach(() => {
14
- h = setupFakePhantom({ PRESENT_KEY: "hello-world" });
13
+ // Legacy headless reveal exercises the helper's own logic; the last test
14
+ // below covers what Phantom Secrets 0.7.9 actually does.
15
+ h = setupFakePhantom(
16
+ { PRESENT_KEY: "hello-world", EMPTY_KEY: "" },
17
+ { legacyHeadlessReveal: true },
18
+ );
15
19
  });
16
20
  afterEach(() => h.cleanup());
17
21
 
@@ -20,7 +24,6 @@ describe("provider helpers", () => {
20
24
  });
21
25
 
22
26
  test("tryRevealSecret returns undefined for an empty value", async () => {
23
- await addSecret("EMPTY_KEY", "");
24
27
  expect(await tryRevealSecret("EMPTY_KEY")).toBeUndefined();
25
28
  });
26
29
 
@@ -29,6 +32,23 @@ describe("provider helpers", () => {
29
32
  expect(await tryRevealSecret("NEVER_STORED_KEY")).toBeUndefined();
30
33
  });
31
34
 
35
+ test("tryRevealSecret returns undefined when Phantom refuses headless reveal", async () => {
36
+ h.cleanup();
37
+ h = setupFakePhantom({ PRESENT_KEY: "hello-world" });
38
+ takeRevealRefusals();
39
+ expect(await tryRevealSecret("PRESENT_KEY")).toBeUndefined();
40
+ // The refusal is recorded by name so doctor can say "stored, unverified".
41
+ expect(takeRevealRefusals()).toEqual(["PRESENT_KEY"]);
42
+ expect(takeRevealRefusals()).toEqual([]);
43
+ });
44
+
45
+ test("a readable or absent value records no refusal", async () => {
46
+ takeRevealRefusals();
47
+ await tryRevealSecret("PRESENT_KEY");
48
+ await tryRevealSecret("NEVER_STORED_KEY");
49
+ expect(takeRevealRefusals()).toEqual([]);
50
+ });
51
+
32
52
  test("bearerJsonHeaders returns the canonical shape", () => {
33
53
  const headers = bearerJsonHeaders("my-token") as Record<string, string>;
34
54
  expect(headers.Authorization).toBe("Bearer my-token");
@@ -220,8 +220,8 @@ describe("parseRetryAfter", () => {
220
220
  const parsed = parseRetryAfter(future);
221
221
  expect(parsed).toBeDefined();
222
222
  // Allow drift from test overhead; we just need it to be in the right ballpark.
223
- expect(parsed!).toBeGreaterThan(5_000);
224
- expect(parsed!).toBeLessThanOrEqual(12_000);
223
+ expect(parsed as number).toBeGreaterThan(5_000);
224
+ expect(parsed as number).toBeLessThanOrEqual(12_000);
225
225
  });
226
226
 
227
227
  test("HTTP-date in the past clamps to 0", () => {
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { CostTracker } from "../ai/cost-tracker.ts";
2
3
  import {
3
4
  ClaudeMCPBackend,
4
5
  LocalSLMBackend,
@@ -6,7 +7,6 @@ import {
6
7
  getInferenceBackend,
7
8
  parseRecipeDraft,
8
9
  } from "../ai/inference.ts";
9
- import { CostTracker } from "../ai/cost-tracker.ts";
10
10
 
11
11
  /**
12
12
  * Inference backend tests. The local backend talks to an OpenAI-compatible
@@ -15,7 +15,10 @@ import { CostTracker } from "../ai/cost-tracker.ts";
15
15
  */
16
16
 
17
17
  // Minimal valid chat-completion response.
18
- function okChatResponse(content: string, usage?: { prompt_tokens?: number; completion_tokens?: number }) {
18
+ function okChatResponse(
19
+ content: string,
20
+ usage?: { prompt_tokens?: number; completion_tokens?: number },
21
+ ) {
19
22
  return new Response(
20
23
  JSON.stringify({
21
24
  choices: [{ message: { content } }],
@@ -117,7 +120,10 @@ describe("LocalSLMBackend — infer", () => {
117
120
  test("happy path — parses recipe + records usage with reported tokens", async () => {
118
121
  const costTracker = new CostTracker();
119
122
  const fetchImpl = (async () =>
120
- okChatResponse(VALID_RECIPE_JSON, { prompt_tokens: 120, completion_tokens: 45 })) as unknown as typeof fetch;
123
+ okChatResponse(VALID_RECIPE_JSON, {
124
+ prompt_tokens: 120,
125
+ completion_tokens: 45,
126
+ })) as unknown as typeof fetch;
121
127
 
122
128
  const backend = new LocalSLMBackend({
123
129
  endpoints: [{ name: "lm-studio-fake", baseUrl: "http://fake/v1", model: "test" }],
@@ -146,7 +152,8 @@ describe("LocalSLMBackend — infer", () => {
146
152
  test("falls back to the second endpoint when the first fails", async () => {
147
153
  let call = 0;
148
154
  const fetchImpl = (async (input: string | URL | Request) => {
149
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
155
+ const url =
156
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
150
157
  call++;
151
158
  if (url.includes("primary")) {
152
159
  throw new Error("ECONNREFUSED");
@@ -228,14 +235,15 @@ describe("LocalSLMBackend — infer", () => {
228
235
  costTracker: new CostTracker(),
229
236
  });
230
237
 
231
- await expect(
232
- backend.infer({ query: "x", catalogContext: "" }),
233
- ).rejects.toBeInstanceOf(NoInferenceBackendError);
238
+ await expect(backend.infer({ query: "x", catalogContext: "" })).rejects.toBeInstanceOf(
239
+ NoInferenceBackendError,
240
+ );
234
241
  });
235
242
 
236
243
  test("healthy pings /models and returns true when any endpoint responds", async () => {
237
244
  const fetchImpl = (async (input: string | URL | Request) => {
238
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
245
+ const url =
246
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
239
247
  if (url.endsWith("/models")) return new Response("[]", { status: 200 });
240
248
  return new Response("", { status: 500 });
241
249
  }) as unknown as typeof fetch;
@@ -252,19 +260,17 @@ describe("parseRecipeDraft", () => {
252
260
  test("parses plain JSON", () => {
253
261
  const draft = parseRecipeDraft(VALID_RECIPE_JSON);
254
262
  expect(draft.providers).toHaveLength(2);
255
- expect(draft.providers[0]!.name).toBe("neon");
263
+ expect(draft.providers[0]?.name).toBe("neon");
256
264
  });
257
265
 
258
266
  test("tolerates markdown fences", () => {
259
- const wrapped = "```json\n" + VALID_RECIPE_JSON + "\n```";
267
+ const wrapped = `\`\`\`json\n${VALID_RECIPE_JSON}\n\`\`\``;
260
268
  const draft = parseRecipeDraft(wrapped);
261
269
  expect(draft.providers).toHaveLength(2);
262
270
  });
263
271
 
264
272
  test("tolerates leading prose", () => {
265
- const draft = parseRecipeDraft(
266
- "Sure! Here's the recipe:\n" + VALID_RECIPE_JSON + "\nLet me know!",
267
- );
273
+ const draft = parseRecipeDraft(`Sure! Here's the recipe:\n${VALID_RECIPE_JSON}\nLet me know!`);
268
274
  expect(draft.providers).toHaveLength(2);
269
275
  });
270
276
 
@@ -0,0 +1,106 @@
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 { PromptRequest, Provider, ProviderContext } from "../providers/_base.ts";
8
+ import { promptSecret } from "../providers/_helpers.ts";
9
+ import { providers } from "../providers/index.ts";
10
+ import { type Harness, setupFakePhantom } from "./_harness.ts";
11
+
12
+ /**
13
+ * Regression coverage for the interactive-auth hang: a provider's PAT/key
14
+ * paste must go through the host-supplied `ctx.prompt` (which the CLI pauses
15
+ * its spinner around) rather than writing to stderr and blocking on stdin
16
+ * behind a live spinner. See `promptSecret` + `ProviderContext.prompt`.
17
+ */
18
+ describe("interactive credential prompting", () => {
19
+ test("promptSecret routes through ctx.prompt, trims, and marks the field secret", async () => {
20
+ const seen: PromptRequest[] = [];
21
+ const ctx: ProviderContext = {
22
+ cwd: ".",
23
+ interactive: true,
24
+ log: () => {},
25
+ prompt: async (req) => {
26
+ seen.push(req);
27
+ return " tok-abc123 "; // surrounding whitespace must be trimmed off
28
+ },
29
+ };
30
+
31
+ const token = await promptSecret(ctx, {
32
+ message: "Paste your access token",
33
+ howTo: "create one at https://example.com/tokens",
34
+ });
35
+
36
+ expect(token).toBe("tok-abc123");
37
+ expect(seen).toHaveLength(1);
38
+ expect(seen[0]).toEqual({
39
+ message: "Paste your access token",
40
+ howTo: "create one at https://example.com/tokens",
41
+ secret: true,
42
+ });
43
+ });
44
+
45
+ describe("threaded through addService", () => {
46
+ let h: Harness;
47
+ let cwd: string;
48
+ let originalCwd: string;
49
+
50
+ beforeEach(async () => {
51
+ h = setupFakePhantom();
52
+ cwd = mkdtempSync(join(tmpdir(), "stack-prompt-"));
53
+ originalCwd = process.cwd();
54
+ process.chdir(cwd);
55
+ await writeConfig(emptyConfig("test-template"), cwd);
56
+ });
57
+
58
+ afterEach(() => {
59
+ process.chdir(originalCwd);
60
+ Reflect.deleteProperty(providers, "promptsvc");
61
+ h.cleanup();
62
+ });
63
+
64
+ test("addService threads prompt into ctx so login receives the host-entered value", async () => {
65
+ let promptCalls = 0;
66
+ let loginToken: string | undefined;
67
+ const stubProvider: Provider = {
68
+ name: "promptsvc",
69
+ displayName: "Prompt Service",
70
+ category: "database",
71
+ authKind: "pat",
72
+ async login(ctx) {
73
+ // Exactly what every real provider's PAT-paste path now does.
74
+ const token = await promptSecret(ctx, { message: "Paste your Prompt Service token" });
75
+ loginToken = token;
76
+ return { token, identity: { id: "user-1" } };
77
+ },
78
+ async provision() {
79
+ return { id: "res-1", displayName: "res-1" };
80
+ },
81
+ async materialize() {
82
+ // No secrets/mcp keeps the pipeline off the Phantom vault, so this
83
+ // assertion runs identically on every platform.
84
+ return { secrets: {} };
85
+ },
86
+ };
87
+ providers.promptsvc = async () => stubProvider;
88
+
89
+ const result = await addService({
90
+ providerName: "promptsvc",
91
+ cwd,
92
+ interactive: true,
93
+ prompt: async () => {
94
+ promptCalls += 1;
95
+ return "host-entered-token";
96
+ },
97
+ });
98
+
99
+ expect(result.providerName).toBe("promptsvc");
100
+ // The host's prompt callback was invoked, and its value reached login via
101
+ // ctx → promptSecret — proof the wiring is end to end, not bypassed.
102
+ expect(promptCalls).toBe(1);
103
+ expect(loginToken).toBe("host-entered-token");
104
+ });
105
+ });
106
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { mergeMcpEntry, removeMcpEntry } from "../mcp-writer.ts";
6
+
7
+ const tmp = () => mkdtempSync(join(tmpdir(), "stack-mirror-"));
8
+ const readJson = (p: string) => JSON.parse(readFileSync(p, "utf-8"));
9
+
10
+ describe("mcp-writer client mirroring", () => {
11
+ test("does not create client dirs that are absent", async () => {
12
+ const cwd = tmp();
13
+ await mergeMcpEntry({ name: "s", type: "stdio", command: "npx" }, cwd);
14
+ expect(existsSync(join(cwd, ".mcp.json"))).toBe(true);
15
+ expect(existsSync(join(cwd, ".cursor"))).toBe(false);
16
+ expect(existsSync(join(cwd, ".gemini"))).toBe(false);
17
+ });
18
+
19
+ test("mirrors into .cursor/mcp.json and .gemini/settings.json when dirs exist", async () => {
20
+ const cwd = tmp();
21
+ mkdirSync(join(cwd, ".cursor"));
22
+ mkdirSync(join(cwd, ".gemini"));
23
+ writeFileSync(
24
+ join(cwd, ".gemini", "settings.json"),
25
+ JSON.stringify({ theme: "dark", mcpServers: { other: { command: "o" } } }),
26
+ );
27
+ await mergeMcpEntry(
28
+ { name: "s", type: "stdio", command: "npx", args: ["-y", "x"], env: { K: "v" } },
29
+ cwd,
30
+ );
31
+ const g = readJson(join(cwd, ".gemini", "settings.json"));
32
+ expect(g.theme).toBe("dark");
33
+ expect(g.mcpServers.other.command).toBe("o");
34
+ expect(g.mcpServers.s).toEqual({ command: "npx", args: ["-y", "x"], env: { K: "v" } });
35
+ expect(readJson(join(cwd, ".cursor", "mcp.json")).mcpServers.s.command).toBe("npx");
36
+ // idempotent
37
+ await mergeMcpEntry(
38
+ { name: "s", type: "stdio", command: "npx", args: ["-y", "x"], env: { K: "v" } },
39
+ cwd,
40
+ );
41
+ expect(Object.keys(readJson(join(cwd, ".gemini", "settings.json")).mcpServers).sort()).toEqual([
42
+ "other",
43
+ "s",
44
+ ]);
45
+ });
46
+
47
+ test("gemini uses httpUrl for http and url for sse, with no type field", async () => {
48
+ const cwd = tmp();
49
+ mkdirSync(join(cwd, ".gemini"));
50
+ await mergeMcpEntry({ name: "h", type: "http", url: "https://a.example/mcp" }, cwd);
51
+ await mergeMcpEntry({ name: "e", type: "sse", url: "https://b.example/sse" }, cwd);
52
+ const m = readJson(join(cwd, ".gemini", "settings.json")).mcpServers;
53
+ expect(m.h).toEqual({ httpUrl: "https://a.example/mcp" });
54
+ expect(m.e).toEqual({ url: "https://b.example/sse" });
55
+ expect(readJson(join(cwd, ".mcp.json")).mcpServers.h.type).toBe("http");
56
+ });
57
+
58
+ test("a malformed sibling config is left untouched and does not fail the write", async () => {
59
+ const cwd = tmp();
60
+ mkdirSync(join(cwd, ".gemini"));
61
+ writeFileSync(join(cwd, ".gemini", "settings.json"), "{not json");
62
+ await mergeMcpEntry({ name: "s", type: "stdio", command: "npx" }, cwd);
63
+ expect(readFileSync(join(cwd, ".gemini", "settings.json"), "utf-8")).toBe("{not json");
64
+ expect(readJson(join(cwd, ".mcp.json")).mcpServers.s.command).toBe("npx");
65
+ });
66
+
67
+ test("removeMcpEntry removes from mirrors and keeps other entries", async () => {
68
+ const cwd = tmp();
69
+ mkdirSync(join(cwd, ".cursor"));
70
+ mkdirSync(join(cwd, ".gemini"));
71
+ await mergeMcpEntry({ name: "a", type: "stdio", command: "x" }, cwd);
72
+ await mergeMcpEntry({ name: "b", type: "stdio", command: "y" }, cwd);
73
+ await removeMcpEntry("a", cwd);
74
+ for (const p of [
75
+ join(cwd, ".mcp.json"),
76
+ join(cwd, ".cursor", "mcp.json"),
77
+ join(cwd, ".gemini", "settings.json"),
78
+ ]) {
79
+ const m = readJson(p).mcpServers;
80
+ expect(m.a).toBeUndefined();
81
+ expect(m.b.command).toBe("y");
82
+ }
83
+ });
84
+
85
+ test("a user's own same-name entry is neither overwritten nor deleted", async () => {
86
+ // Cursor and Gemini configs are user-owned. A hand-written "github" entry
87
+ // must survive `stack add github` and its rollback or `stack remove`.
88
+ const cwd = tmp();
89
+ mkdirSync(join(cwd, ".cursor"));
90
+ mkdirSync(join(cwd, ".gemini"));
91
+ const mine = { command: "my-github-mcp", env: { TOKEN: "${env:GH}" } };
92
+ for (const p of [join(cwd, ".cursor", "mcp.json"), join(cwd, ".gemini", "settings.json")]) {
93
+ writeFileSync(p, JSON.stringify({ mcpServers: { github: mine } }));
94
+ }
95
+ await mergeMcpEntry({ name: "github", type: "stdio", command: "npx", args: ["-y", "gh"] }, cwd);
96
+ expect(readJson(join(cwd, ".mcp.json")).mcpServers.github.command).toBe("npx");
97
+ for (const p of [join(cwd, ".cursor", "mcp.json"), join(cwd, ".gemini", "settings.json")]) {
98
+ expect(readJson(p).mcpServers.github).toEqual(mine);
99
+ }
100
+ await removeMcpEntry("github", cwd);
101
+ expect(readJson(join(cwd, ".mcp.json")).mcpServers.github).toBeUndefined();
102
+ for (const p of [join(cwd, ".cursor", "mcp.json"), join(cwd, ".gemini", "settings.json")]) {
103
+ expect(readJson(p).mcpServers.github).toEqual(mine);
104
+ }
105
+ });
106
+
107
+ test("an entry Stack mirrored earlier is updated when the provider entry changes", async () => {
108
+ const cwd = tmp();
109
+ mkdirSync(join(cwd, ".cursor"));
110
+ await mergeMcpEntry({ name: "s", type: "stdio", command: "npx", args: ["v1"] }, cwd);
111
+ await mergeMcpEntry({ name: "s", type: "stdio", command: "npx", args: ["v2"] }, cwd);
112
+ expect(readJson(join(cwd, ".cursor", "mcp.json")).mcpServers.s.args).toEqual(["v2"]);
113
+ await removeMcpEntry("s", cwd);
114
+ expect(readJson(join(cwd, ".cursor", "mcp.json")).mcpServers.s).toBeUndefined();
115
+ });
116
+ });
@@ -1,8 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import neon from "../providers/neon.ts";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { emptyConfig, readConfig, writeConfig } from "../config.ts";
4
+ import { addService } from "../pipeline.ts";
3
5
  import type { ProviderContext } from "../providers/_base.ts";
6
+ import { providers } from "../providers/index.ts";
7
+ import neon from "../providers/neon.ts";
4
8
  import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
5
9
 
10
+ const inertPreconnect: typeof fetch.preconnect = () => {
11
+ throw new Error("Unexpected fetch preconnect in inert fixture.");
12
+ };
13
+
6
14
  describe("neon provider (mocked fetch)", () => {
7
15
  let h: Harness;
8
16
  let realFetch: typeof fetch;
@@ -14,6 +22,7 @@ describe("neon provider (mocked fetch)", () => {
14
22
 
15
23
  afterEach(() => {
16
24
  globalThis.fetch = realFetch;
25
+ Reflect.deleteProperty(providers, "neonrollbackfixture");
17
26
  h.cleanup();
18
27
  });
19
28
 
@@ -51,9 +60,60 @@ describe("neon provider (mocked fetch)", () => {
51
60
  expect(materialized.secrets.NEON_API_KEY).toBe("neon_fake");
52
61
  expect(materialized.secrets.DATABASE_URL).toBe(connectionUri);
53
62
 
54
- // Provision stores a project-scoped copy so other providers can't stomp it.
63
+ // Provision never writes the vault before pipeline rollback owns the resource.
55
64
  const vault = await readVault(h.dir);
56
- expect(vault[`NEON_DATABASE_URL_${projectId}`]).toBe(connectionUri);
65
+ expect(vault[`NEON_DATABASE_URL_${projectId}`]).toBeUndefined();
66
+ // DATABASE_URL is written once, by the pipeline from materialize(): Phantom
67
+ // never replaces a stored name, so provision must not claim it first.
68
+ expect(vault.DATABASE_URL).toBeUndefined();
69
+ });
70
+
71
+ test("fresh URI storage failure reaches rollback after the created resource is returned", async () => {
72
+ const projectId = "proj_rollback";
73
+ await writeConfig(emptyConfig(), h.dir);
74
+ writeFileSync(h.vaultPath, JSON.stringify({ DATABASE_URL: "synthetic-existing" }));
75
+ const before = readFileSync(`${h.dir}/.stack.toml`, "utf8");
76
+ const calls = { created: 0, freshURI: 0, deleted: 0 };
77
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
78
+ const u = String(url);
79
+ const method = init?.method ?? "GET";
80
+ if (u.endsWith("/projects") && method === "POST") {
81
+ calls.created++;
82
+ return new Response(
83
+ JSON.stringify({
84
+ project: { id: projectId, name: "inert", region_id: "fixture" },
85
+ connection_uris: [{ connection_uri: "synthetic-provision-uri" }],
86
+ }),
87
+ { status: 201 },
88
+ );
89
+ }
90
+ if (u.endsWith(`/projects/${projectId}/connection_uri`) && method === "GET") {
91
+ calls.freshURI++;
92
+ return new Response(JSON.stringify({ uri: "synthetic-fresh-uri" }), { status: 200 });
93
+ }
94
+ if (u.endsWith(`/projects/${projectId}`) && method === "DELETE") {
95
+ calls.deleted++;
96
+ return new Response("", { status: 200 });
97
+ }
98
+ throw new Error(`unexpected inert ${method} route`);
99
+ }) as unknown as typeof fetch;
100
+ providers.neonrollbackfixture = async () => ({
101
+ ...neon,
102
+ name: "neonrollbackfixture",
103
+ async login() {
104
+ return { token: "synthetic-key", identity: {} };
105
+ },
106
+ });
107
+ await expect(
108
+ addService({ providerName: "neonrollbackfixture", cwd: h.dir, interactive: false }),
109
+ ).rejects.toMatchObject({ code: "ADD_SERVICE_ROLLED_BACK" });
110
+ expect(calls).toEqual({ created: 1, freshURI: 1, deleted: 1 });
111
+ expect(readFileSync(`${h.dir}/.stack.toml`, "utf8")).toBe(before);
112
+ expect((await readConfig(h.dir)).services.neonrollbackfixture).toBeUndefined();
113
+ const vault = await readVault(h.dir);
114
+ expect(vault.DATABASE_URL).toBe("synthetic-existing");
115
+ expect(vault[`NEON_DATABASE_URL_${projectId}`]).toBeUndefined();
116
+ expect(h.callsTo("add").map((call) => call.args[0])).toEqual(["NEON_API_KEY", "DATABASE_URL"]);
57
117
  });
58
118
 
59
119
  test("healthcheck returns error when the project 404s", async () => {
@@ -76,4 +136,77 @@ describe("neon provider (mocked fetch)", () => {
76
136
  });
77
137
  expect(status.kind).toBe("error");
78
138
  });
139
+ test.each([204, 404])(
140
+ "DELETE %i is accepted exactly once without reading response bodies",
141
+ async (status) => {
142
+ let deletes = 0;
143
+ globalThis.fetch = Object.assign(
144
+ async (_url: unknown, init?: RequestInit) => {
145
+ expect(init?.method).toBe("DELETE");
146
+ deletes++;
147
+ return new Response(null, { status });
148
+ },
149
+ { preconnect: inertPreconnect },
150
+ );
151
+ await expect(
152
+ neon.deprovision!(
153
+ { cwd: h.dir, interactive: false, log() {} },
154
+ { token: "synthetic-key" },
155
+ "inert-project",
156
+ ),
157
+ ).resolves.toBeUndefined();
158
+ expect(deletes).toBe(1);
159
+ },
160
+ );
161
+
162
+ test.each([403, 500])(
163
+ "DELETE %i refuses unconfirmed teardown without exposing response contents",
164
+ async (status) => {
165
+ let deletes = 0;
166
+ globalThis.fetch = Object.assign(
167
+ async (_url: unknown, init?: RequestInit) => {
168
+ expect(init?.method).toBe("DELETE");
169
+ deletes++;
170
+ return new Response("synthetic-private-response-must-not-surface", { status });
171
+ },
172
+ { preconnect: inertPreconnect },
173
+ );
174
+ const error = await neon.deprovision!(
175
+ { cwd: h.dir, interactive: false, log() {} },
176
+ { token: "synthetic-key" },
177
+ "inert-project",
178
+ ).catch((err: Error) => err);
179
+ expect(error).toMatchObject({ code: "NEON_DELETE_FAILED" });
180
+ expect((error as Error).message).toContain(String(status));
181
+ expect((error as Error).message).not.toContain("synthetic-private-response-must-not-surface");
182
+ expect(deletes).toBe(1);
183
+ },
184
+ );
185
+
186
+ test("network failure refuses teardown with a fixed safe error and no raw transport output", async () => {
187
+ let deletes = 0;
188
+ const logs: string[] = [];
189
+ globalThis.fetch = Object.assign(
190
+ async () => {
191
+ deletes++;
192
+ throw new Error("synthetic-sensitive-transport-detail");
193
+ },
194
+ { preconnect: inertPreconnect },
195
+ );
196
+ const error = await neon.deprovision!(
197
+ {
198
+ cwd: h.dir,
199
+ interactive: false,
200
+ log(event) {
201
+ logs.push(event.msg);
202
+ },
203
+ },
204
+ { token: "synthetic-key" },
205
+ "inert-project",
206
+ ).catch((err: Error) => err);
207
+ expect(error).toMatchObject({ code: "NEON_DELETE_UNCONFIRMED" });
208
+ expect((error as Error).message).not.toContain("synthetic-sensitive-transport-detail");
209
+ expect(logs.join(" ")).not.toContain("synthetic-sensitive-transport-detail");
210
+ expect(deletes).toBe(1);
211
+ });
79
212
  });