@ashlr/stack-core 0.1.1 → 0.3.0

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 (111) 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 +279 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +208 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/catalog.test.ts +31 -0
  10. package/src/__tests__/cloudflare-provider.test.ts +32 -10
  11. package/src/__tests__/completion-ci.test.ts +239 -23
  12. package/src/__tests__/config-own-services.test.ts +183 -0
  13. package/src/__tests__/config-split.test.ts +272 -2
  14. package/src/__tests__/config.test.ts +94 -4
  15. package/src/__tests__/credential-staging.test.ts +660 -0
  16. package/src/__tests__/deps.test.ts +2 -1
  17. package/src/__tests__/detect-source.test.ts +183 -4
  18. package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
  19. package/src/__tests__/github-provider.test.ts +3 -2
  20. package/src/__tests__/helpers.test.ts +24 -4
  21. package/src/__tests__/http.test.ts +2 -2
  22. package/src/__tests__/import-integration.test.ts +18 -4
  23. package/src/__tests__/inference.test.ts +57 -13
  24. package/src/__tests__/interactive-prompt.test.ts +106 -0
  25. package/src/__tests__/mcp-mirror.test.ts +116 -0
  26. package/src/__tests__/neon-provider.test.ts +136 -3
  27. package/src/__tests__/new-providers.test.ts +470 -0
  28. package/src/__tests__/no-orphan-modules.test.ts +114 -0
  29. package/src/__tests__/oauth-port.test.ts +84 -0
  30. package/src/__tests__/openai-provider.test.ts +1 -1
  31. package/src/__tests__/phantom-contract.test.ts +177 -0
  32. package/src/__tests__/phantom-harness.test.ts +226 -8
  33. package/src/__tests__/phantom-process.test.ts +135 -0
  34. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  35. package/src/__tests__/phantom-wire.test.ts +403 -22
  36. package/src/__tests__/pipeline-integration.test.ts +33 -1
  37. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  38. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  39. package/src/__tests__/pipeline.test.ts +3 -1
  40. package/src/__tests__/pm-detect.test.ts +113 -0
  41. package/src/__tests__/provider-deprovision.test.ts +269 -0
  42. package/src/__tests__/provider-parity.test.ts +46 -0
  43. package/src/__tests__/publish-script.test.ts +704 -0
  44. package/src/__tests__/recipe.test.ts +27 -2
  45. package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
  46. package/src/__tests__/registry.test.ts +11 -2
  47. package/src/__tests__/release-artifacts.test.ts +55 -0
  48. package/src/__tests__/scan-missing-dir.test.ts +45 -0
  49. package/src/__tests__/secret-redaction.test.ts +10 -10
  50. package/src/__tests__/security.test.ts +9 -10
  51. package/src/__tests__/stack-manifest.test.ts +38 -0
  52. package/src/__tests__/supabase-provider.test.ts +6 -4
  53. package/src/__tests__/swap.test.ts +114 -0
  54. package/src/__tests__/telemetry.test.ts +354 -0
  55. package/src/__tests__/template-litellm-gateway.test.ts +70 -0
  56. package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
  57. package/src/__tests__/templates.test.ts +1 -1
  58. package/src/__tests__/turso-provider.test.ts +2 -5
  59. package/src/agent-onboard.ts +346 -0
  60. package/src/ai/catalog-index.ts +68 -10
  61. package/src/ai/circuit-breaker.ts +3 -6
  62. package/src/ai/cost-tracker.ts +6 -15
  63. package/src/ai/inference.ts +19 -11
  64. package/src/ai/phantom-wire.ts +73 -58
  65. package/src/ai/recipe.ts +69 -0
  66. package/src/base-urls.ts +71 -0
  67. package/src/catalog.ts +281 -2
  68. package/src/config.ts +31 -18
  69. package/src/detect-source.ts +50 -9
  70. package/src/detect.ts +13 -5
  71. package/src/errors.ts +6 -0
  72. package/src/http.ts +6 -5
  73. package/src/index.ts +16 -0
  74. package/src/mcp-writer.ts +97 -2
  75. package/src/oauth.ts +85 -53
  76. package/src/phantom-process.ts +72 -0
  77. package/src/phantom.ts +319 -30
  78. package/src/pipeline.ts +177 -68
  79. package/src/pm-detect.ts +56 -0
  80. package/src/providers/_api-key.ts +18 -17
  81. package/src/providers/_base.ts +28 -1
  82. package/src/providers/_credential-stage.ts +237 -0
  83. package/src/providers/_helpers.ts +55 -9
  84. package/src/providers/anthropic.ts +2 -1
  85. package/src/providers/auth0.ts +27 -0
  86. package/src/providers/aws.ts +32 -33
  87. package/src/providers/cloudflare.ts +18 -37
  88. package/src/providers/convex.ts +1 -2
  89. package/src/providers/datadog.ts +29 -0
  90. package/src/providers/digitalocean.ts +30 -0
  91. package/src/providers/gcp.ts +36 -0
  92. package/src/providers/github.ts +37 -35
  93. package/src/providers/grafana.ts +29 -0
  94. package/src/providers/hetzner.ts +29 -0
  95. package/src/providers/index.ts +13 -1
  96. package/src/providers/launchdarkly.ts +33 -0
  97. package/src/providers/mixpanel.ts +37 -0
  98. package/src/providers/neon.ts +50 -26
  99. package/src/providers/openai.ts +2 -1
  100. package/src/providers/plausible.ts +26 -0
  101. package/src/providers/sentry.ts +17 -17
  102. package/src/providers/stripe.ts +1 -2
  103. package/src/providers/supabase.ts +66 -37
  104. package/src/providers/turso.ts +49 -38
  105. package/src/providers/vercel.ts +50 -20
  106. package/src/providers/workos.ts +27 -0
  107. package/src/registry.ts +7 -11
  108. package/src/stack-manifest.ts +39 -0
  109. package/src/swap.ts +108 -0
  110. package/src/telemetry.ts +267 -0
  111. package/src/templates.ts +6 -2
@@ -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
  });