@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,208 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { LocalSLMBackend, defaultOllamaEndpoint } from "../ai/inference.ts";
3
+ import {
4
+ DEFAULT_ANTHROPIC_BASE_URL,
5
+ DEFAULT_OLLAMA_BASE_URL,
6
+ DEFAULT_OPENAI_BASE_URL,
7
+ anthropicBaseUrl,
8
+ ollamaBaseUrl,
9
+ openaiBaseUrl,
10
+ } from "../base-urls.ts";
11
+ import type { ProviderContext } from "../providers/_base.ts";
12
+ import anthropic from "../providers/anthropic.ts";
13
+ import openai from "../providers/openai.ts";
14
+ import { type Harness, setupFakePhantom } from "./_harness.ts";
15
+
16
+ describe("defaults are unchanged", () => {
17
+ test("hosts match what Stack always called", () => {
18
+ expect(DEFAULT_ANTHROPIC_BASE_URL).toBe("https://api.anthropic.com");
19
+ expect(DEFAULT_OPENAI_BASE_URL).toBe("https://api.openai.com/v1");
20
+ expect(DEFAULT_OLLAMA_BASE_URL).toBe("http://localhost:11434");
21
+ expect(defaultOllamaEndpoint({}).baseUrl).toBe("http://localhost:11434/v1");
22
+ });
23
+ test("unset, empty and whitespace-only values fall back", () => {
24
+ for (const v of [undefined, "", " ", "/", " // "]) {
25
+ expect(anthropicBaseUrl({ ANTHROPIC_BASE_URL: v })).toBe(DEFAULT_ANTHROPIC_BASE_URL);
26
+ expect(openaiBaseUrl({ OPENAI_BASE_URL: v })).toBe(DEFAULT_OPENAI_BASE_URL);
27
+ expect(ollamaBaseUrl({ OLLAMA_HOST: v })).toBe(DEFAULT_OLLAMA_BASE_URL);
28
+ }
29
+ });
30
+ });
31
+
32
+ describe("overrides are honored", () => {
33
+ test("anthropic and openai: used as given, trailing slashes trimmed", () => {
34
+ expect(anthropicBaseUrl({ ANTHROPIC_BASE_URL: "http://127.0.0.1:4000" })).toBe(
35
+ "http://127.0.0.1:4000",
36
+ );
37
+ expect(anthropicBaseUrl({ ANTHROPIC_BASE_URL: " https://gw.example/anthropic/// " })).toBe(
38
+ "https://gw.example/anthropic",
39
+ );
40
+ expect(openaiBaseUrl({ OPENAI_BASE_URL: "http://127.0.0.1:4000/v1/" })).toBe(
41
+ "http://127.0.0.1:4000/v1",
42
+ );
43
+ });
44
+ test("ollama host forms", () => {
45
+ expect(ollamaBaseUrl({ OLLAMA_HOST: "192.168.1.5:9999" })).toBe("http://192.168.1.5:9999");
46
+ expect(ollamaBaseUrl({ OLLAMA_HOST: "gpu-box:11435/" })).toBe("http://gpu-box:11435");
47
+ expect(ollamaBaseUrl({ OLLAMA_HOST: "gpu-box" })).toBe("http://gpu-box:11434");
48
+ expect(ollamaBaseUrl({ OLLAMA_HOST: ":8080" })).toBe("http://localhost:8080");
49
+ expect(ollamaBaseUrl({ OLLAMA_HOST: "[::1]:11434" })).toBe("http://[::1]:11434");
50
+ expect(ollamaBaseUrl({ OLLAMA_HOST: "[::1]" })).toBe("http://[::1]:11434");
51
+ expect(ollamaBaseUrl({ OLLAMA_HOST: "https://ollama.example.com/" })).toBe(
52
+ "https://ollama.example.com",
53
+ );
54
+ expect(defaultOllamaEndpoint({ OLLAMA_HOST: "gpu-box" }).baseUrl).toBe(
55
+ "http://gpu-box:11434/v1",
56
+ );
57
+ });
58
+ test.each(["/", "///"])("trailing %s does not duplicate composed separators", (slashes) => {
59
+ expect(
60
+ `${anthropicBaseUrl({ ANTHROPIC_BASE_URL: `https://gw.example/anthropic${slashes}` })}/v1/models`,
61
+ ).toBe("https://gw.example/anthropic/v1/models");
62
+ expect(
63
+ `${openaiBaseUrl({ OPENAI_BASE_URL: `https://gw.example/openai/v1${slashes}` })}/models`,
64
+ ).toBe("https://gw.example/openai/v1/models");
65
+ expect(
66
+ `${defaultOllamaEndpoint({ OLLAMA_HOST: `https://gw.example/ollama${slashes}` }).baseUrl}/models`,
67
+ ).toBe("https://gw.example/ollama/v1/models");
68
+ });
69
+ });
70
+
71
+ describe("callers read the variables at call time", () => {
72
+ const keys = ["ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "OLLAMA_HOST"] as const;
73
+ const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
74
+ let h: Harness;
75
+ let realFetch: typeof fetch;
76
+ let urls: string[];
77
+
78
+ beforeEach(() => {
79
+ // Exercises the HTTP call after a key is read; real Phantom 0.7.9 refuses headless reveal.
80
+ h = setupFakePhantom(
81
+ { ANTHROPIC_API_KEY: "sk-ant-fake", OPENAI_API_KEY: "sk-fake" },
82
+ { legacyHeadlessReveal: true },
83
+ );
84
+ realFetch = globalThis.fetch;
85
+ urls = [];
86
+ globalThis.fetch = (async (u: string | URL | Request) => {
87
+ urls.push(String(u));
88
+ return new Response(JSON.stringify({ data: [] }), { status: 200 });
89
+ }) as unknown as typeof fetch;
90
+ });
91
+
92
+ afterEach(() => {
93
+ globalThis.fetch = realFetch;
94
+ h.cleanup();
95
+ for (const k of keys) {
96
+ if (saved[k] === undefined) delete process.env[k];
97
+ else process.env[k] = saved[k];
98
+ }
99
+ });
100
+
101
+ const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
102
+ const entry = (provider: string, secret: string) => ({
103
+ provider,
104
+ secrets: [secret],
105
+ created_at: new Date().toISOString(),
106
+ });
107
+
108
+ test("key checks hit the default hosts when unset", async () => {
109
+ for (const k of keys) delete process.env[k];
110
+ expect((await anthropic.healthcheck!(ctx, entry("anthropic", "ANTHROPIC_API_KEY"))).kind).toBe(
111
+ "ok",
112
+ );
113
+ expect((await openai.healthcheck!(ctx, entry("openai", "OPENAI_API_KEY"))).kind).toBe("ok");
114
+ expect(urls).toEqual([
115
+ "https://api.anthropic.com/v1/models",
116
+ "https://api.openai.com/v1/models",
117
+ ]);
118
+ });
119
+
120
+ test("key checks follow ANTHROPIC_BASE_URL and OPENAI_BASE_URL", async () => {
121
+ process.env.ANTHROPIC_BASE_URL = "http://127.0.0.1:4000/";
122
+ process.env.OPENAI_BASE_URL = "http://127.0.0.1:4000/v1/";
123
+ await anthropic.healthcheck!(ctx, entry("anthropic", "ANTHROPIC_API_KEY"));
124
+ await openai.healthcheck!(ctx, entry("openai", "OPENAI_API_KEY"));
125
+ expect(urls).toEqual(["http://127.0.0.1:4000/v1/models", "http://127.0.0.1:4000/v1/models"]);
126
+ });
127
+
128
+ test.each(["", "/", "///"])(
129
+ "equivalent base URLs with trailing %j hit the same endpoints",
130
+ async (suffix) => {
131
+ process.env.ANTHROPIC_BASE_URL = `https://gw.example/anthropic${suffix}`;
132
+ process.env.OPENAI_BASE_URL = `https://gw.example/openai/v1${suffix}`;
133
+ process.env.OLLAMA_HOST = `https://gw.example/ollama${suffix}`;
134
+
135
+ expect(
136
+ (await anthropic.healthcheck!(ctx, entry("anthropic", "ANTHROPIC_API_KEY"))).kind,
137
+ ).toBe("ok");
138
+ expect((await openai.healthcheck!(ctx, entry("openai", "OPENAI_API_KEY"))).kind).toBe("ok");
139
+ expect(
140
+ await new LocalSLMBackend({
141
+ endpoints: [defaultOllamaEndpoint()],
142
+ fetchImpl: globalThis.fetch,
143
+ }).healthy(),
144
+ ).toBe(true);
145
+ expect(urls).toEqual([
146
+ "https://gw.example/anthropic/v1/models",
147
+ "https://gw.example/openai/v1/models",
148
+ "https://gw.example/ollama/v1/models",
149
+ ]);
150
+ },
151
+ );
152
+
153
+ test("the local SLM backend picks up OLLAMA_HOST when constructed", async () => {
154
+ process.env.OLLAMA_HOST = "gpu-box:11435/";
155
+ const seen: string[] = [];
156
+ const down = (async (u: string | URL | Request) => {
157
+ seen.push(String(u));
158
+ return new Response("down", { status: 503 });
159
+ }) as unknown as typeof fetch;
160
+ expect(await new LocalSLMBackend({ fetchImpl: down }).healthy()).toBe(false);
161
+ expect(seen).toContain("http://gpu-box:11435/v1/models");
162
+ });
163
+ });
164
+
165
+ describe("gateway rule for keyed providers", () => {
166
+ test("https anywhere and http to loopback literals are allowed", () => {
167
+ expect(anthropicBaseUrl({ ANTHROPIC_BASE_URL: "https://gw.example" })).toBe(
168
+ "https://gw.example",
169
+ );
170
+ expect(openaiBaseUrl({ OPENAI_BASE_URL: "http://[::1]:4000/v1" })).toBe("http://[::1]:4000/v1");
171
+ });
172
+ test.each([
173
+ "http://gw.example",
174
+ "http://localhost:4000",
175
+ "http://10.0.0.5:4000",
176
+ "ftp://127.0.0.1",
177
+ "not a url",
178
+ ])("%s is refused without echoing the URL", (v) => {
179
+ for (const [fn, name] of [
180
+ [anthropicBaseUrl, "ANTHROPIC_BASE_URL"],
181
+ [openaiBaseUrl, "OPENAI_BASE_URL"],
182
+ ] as const) {
183
+ let msg = "";
184
+ try {
185
+ fn({ [name]: v });
186
+ } catch (e) {
187
+ msg = (e as Error).message;
188
+ }
189
+ expect(msg).toContain(name);
190
+ expect(msg).not.toContain(v);
191
+ }
192
+ });
193
+ test("credentials in a refused URL are not echoed", () => {
194
+ expect(() => openaiBaseUrl({ OPENAI_BASE_URL: "http://user:sk-secret@gw.example" })).toThrow(
195
+ /^OPENAI_BASE_URL must use https/,
196
+ );
197
+ });
198
+ });
199
+
200
+ test.each([
201
+ ["gpu-box/models///", "http://gpu-box:11434/models"],
202
+ [":8080/models", "http://localhost:8080/models"],
203
+ ["[::1]/models", "http://[::1]:11434/models"],
204
+ ["[::1]:8080/models", "http://[::1]:8080/models"],
205
+ [" HTTPS://ollama.example/models/// ", "HTTPS://ollama.example/models"],
206
+ ])("Ollama normalizes %s", (input, expected) => {
207
+ expect(ollamaBaseUrl({ OLLAMA_HOST: input })).toBe(expected);
208
+ });
@@ -1,9 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
- import {
3
- __resetIndex,
4
- retrieve,
5
- retrieveByCategory,
6
- } from "../ai/catalog-index.ts";
2
+ import { __resetIndex, retrieve, retrieveByCategory } from "../ai/catalog-index.ts";
7
3
 
8
4
  /**
9
5
  * Golden retrieval tests. Each query must surface the right provider(s) in
@@ -63,13 +59,12 @@ describe("catalog-index.retrieve", () => {
63
59
  });
64
60
 
65
61
  it("composes a B2B SaaS recipe across categories", () => {
66
- const hits = names(retrieve(
67
- "B2B SaaS with auth and payments and AI and error tracking",
68
- { k: 8 },
69
- ));
62
+ const hits = names(
63
+ retrieve("B2B SaaS with auth and payments and AI and error tracking", { k: 8 }),
64
+ );
70
65
  // Expect at least one provider from each intended category.
71
- expect(hits).toContain("stripe"); // Payments
72
- expect(hits).toContain("sentry"); // Errors
66
+ expect(hits).toContain("stripe"); // Payments
67
+ expect(hits).toContain("sentry"); // Errors
73
68
  // One of the auth providers
74
69
  expect(hits.some((n) => ["clerk", "supabase"].includes(n))).toBe(true);
75
70
  // One of the AI providers
@@ -77,12 +72,14 @@ describe("catalog-index.retrieve", () => {
77
72
  });
78
73
 
79
74
  it("respects category filter", () => {
80
- const hits = names(retrieve("edge compute", {
81
- k: 5,
82
- categories: ["Deploy"],
83
- }));
75
+ const hits = names(
76
+ retrieve("edge compute", {
77
+ k: 5,
78
+ categories: ["Deploy"],
79
+ }),
80
+ );
84
81
  for (const n of hits) {
85
- const cat = ["vercel", "railway", "fly", "cloudflare", "render"].includes(n);
82
+ const cat = ["vercel", "railway", "fly", "cloudflare", "render", "modal"].includes(n);
86
83
  expect(cat).toBe(true);
87
84
  }
88
85
  });
@@ -93,10 +90,7 @@ describe("catalog-index.retrieve", () => {
93
90
  });
94
91
 
95
92
  it("retrieveByCategory buckets results", () => {
96
- const grouped = retrieveByCategory(
97
- "B2B SaaS with auth and payments and AI",
98
- { k: 20 },
99
- );
93
+ const grouped = retrieveByCategory("B2B SaaS with auth and payments and AI", { k: 20 });
100
94
  expect(Object.keys(grouped).length).toBeGreaterThan(1);
101
95
  expect(grouped.Payments?.[0]?.provider.name).toBe("stripe");
102
96
  });
@@ -0,0 +1,31 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ PROVIDERS_REF,
4
+ PROVIDER_CATEGORIES,
5
+ findProviderRef,
6
+ groupByCategory,
7
+ } from "../catalog.ts";
8
+
9
+ test.each(PROVIDERS_REF)("catalog resolves $name by identity", (provider) => {
10
+ expect(findProviderRef(provider.name)).toBe(provider);
11
+ expect(PROVIDER_CATEGORIES).toContain(provider.category);
12
+ });
13
+
14
+ test.each(["", "OPENAI", " openai", "openai ", "constructor", "__proto__", "missing"])(
15
+ "catalog rejects noncanonical name %s",
16
+ (name) => expect(findProviderRef(name)).toBeUndefined(),
17
+ );
18
+
19
+ test("category groups partition the catalog without sharing mutable buckets", () => {
20
+ const groups = groupByCategory();
21
+ expect(Object.values(groups).flat()).toHaveLength(PROVIDERS_REF.length);
22
+ expect(new Set(PROVIDERS_REF.map((p) => p.name)).size).toBe(PROVIDERS_REF.length);
23
+ for (const [category, providers] of Object.entries(groups)) {
24
+ expect(providers).toEqual(PROVIDERS_REF.filter((p) => p.category === category));
25
+ }
26
+ const first = PROVIDERS_REF[0]!;
27
+ groups[first.category]!.pop();
28
+ expect(groupByCategory()[first.category]).toEqual(
29
+ PROVIDERS_REF.filter((p) => p.category === first.category),
30
+ );
31
+ });
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import cloudflare from "../providers/cloudflare.ts";
3
2
  import type { ProviderContext } from "../providers/_base.ts";
3
+ import cloudflare from "../providers/cloudflare.ts";
4
4
  import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
5
5
 
6
6
  describe("cloudflare provider (mocked fetch)", () => {
@@ -8,7 +8,8 @@ describe("cloudflare 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
 
@@ -24,10 +25,9 @@ describe("cloudflare provider (mocked fetch)", () => {
24
25
  globalThis.fetch = (async (url: string | URL | Request) => {
25
26
  const u = String(url);
26
27
  if (u.endsWith("/user/tokens/verify")) {
27
- return new Response(
28
- JSON.stringify({ result: { id: "tok1", status: "active" } }),
29
- { status: 200 },
30
- );
28
+ return new Response(JSON.stringify({ result: { id: "tok1", status: "active" } }), {
29
+ status: 200,
30
+ });
31
31
  }
32
32
  if (u.endsWith("/accounts")) {
33
33
  return new Response(JSON.stringify({ result: accounts }), { status: 200 });
@@ -54,10 +54,9 @@ describe("cloudflare provider (mocked fetch)", () => {
54
54
  const headers = init?.headers as Record<string, string> | undefined;
55
55
  if (headers?.Authorization) seenAuth = headers.Authorization;
56
56
  if (String(url).endsWith("/user/tokens/verify")) {
57
- return new Response(
58
- JSON.stringify({ result: { id: "tok1", status: "active" } }),
59
- { status: 200 },
60
- );
57
+ return new Response(JSON.stringify({ result: { id: "tok1", status: "active" } }), {
58
+ status: 200,
59
+ });
61
60
  }
62
61
  throw new Error(`unexpected ${url}`);
63
62
  }) as unknown as typeof fetch;
@@ -71,4 +70,27 @@ describe("cloudflare provider (mocked fetch)", () => {
71
70
  expect(status.kind).toBe("ok");
72
71
  expect(seenAuth).toBe("Bearer cf-cached");
73
72
  });
73
+
74
+ test("an empty 204 verification response does not verify a cached token", async () => {
75
+ const { addSecret } = await import("../phantom.ts");
76
+ await addSecret("CLOUDFLARE_API_TOKEN", "cf-cached");
77
+
78
+ let requests = 0;
79
+ globalThis.fetch = (async (url: string | URL | Request) => {
80
+ expect(String(url)).toBe("https://api.cloudflare.com/client/v4/user/tokens/verify");
81
+ requests++;
82
+ return new Response(null, { status: 204 });
83
+ }) as unknown as typeof fetch;
84
+
85
+ const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
86
+ expect(
87
+ await cloudflare.healthcheck!(ctx, {
88
+ provider: "cloudflare",
89
+ secrets: ["CLOUDFLARE_API_TOKEN"],
90
+ created_at: new Date().toISOString(),
91
+ }),
92
+ ).toEqual({ kind: "error", detail: "token invalid" });
93
+ await expect(cloudflare.login(ctx)).rejects.toMatchObject({ code: "CLOUDFLARE_AUTH_REQUIRED" });
94
+ expect(requests).toBe(2);
95
+ });
74
96
  });
@@ -1,8 +1,17 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, readFileSync, existsSync } from "node:fs";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
3
13
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { spawnSync } from "node:child_process";
14
+ import { delimiter, dirname, join } from "node:path";
6
15
 
7
16
  const CLI = join(import.meta.dir, "..", "..", "..", "cli", "src", "index.ts");
8
17
 
@@ -34,29 +43,236 @@ describe("stack completion", () => {
34
43
  });
35
44
  });
36
45
 
37
- describe("stack ci init", () => {
38
- test("writes a valid-looking workflow under .github/workflows/", async () => {
39
- const cwd = mkdtempSync(join(tmpdir(), "stack-ci-"));
40
- const registryDir = mkdtempSync(join(tmpdir(), "stack-ci-reg-"));
46
+ const CI_UNAVAILABLE =
47
+ "CI health workflow unavailable: Phantom Cloud pull requires trusted-terminal approval. No workflow was written.";
41
48
 
42
- // Seed a minimal .stack.toml so `ci init` is happy.
43
- const { emptyConfig, writeConfig } = await import("../config.ts");
44
- const prev = process.env.STACK_REGISTRY_DIR;
45
- process.env.STACK_REGISTRY_DIR = registryDir;
46
- await writeConfig(emptyConfig(), cwd);
49
+ interface CiFixture {
50
+ cwd: string;
51
+ env: Record<string, string>;
52
+ }
47
53
 
48
- const res = spawnSync("bun", ["run", CLI, "ci", "init"], { encoding: "utf-8", cwd });
49
- expect(res.status).toBe(0);
54
+ async function withCiFixture(
55
+ configured: boolean,
56
+ run: (fixture: CiFixture) => Promise<void>,
57
+ ): Promise<void> {
58
+ const root = mkdtempSync(join(tmpdir(), "stack-ci-refusal-"));
59
+ try {
60
+ for (const dir of ["project", "home", "tmp", "registry", "xdg"]) {
61
+ mkdirSync(join(root, dir));
62
+ }
63
+ const cwd = join(root, "project");
64
+ if (configured) {
65
+ writeFileSync(
66
+ join(cwd, ".stack.toml"),
67
+ '[stack]\nversion = "1"\nproject_id = "synthetic-ci-project"\n',
68
+ );
69
+ }
70
+ const env: Record<string, string> = {
71
+ PATH: [dirname(process.execPath), "/usr/bin", "/bin"].join(delimiter),
72
+ HOME: join(root, "home"),
73
+ USERPROFILE: join(root, "home"),
74
+ TMPDIR: join(root, "tmp"),
75
+ TMP: join(root, "tmp"),
76
+ TEMP: join(root, "tmp"),
77
+ XDG_CONFIG_HOME: join(root, "xdg"),
78
+ XDG_CACHE_HOME: join(root, "xdg"),
79
+ XDG_DATA_HOME: join(root, "xdg"),
80
+ STACK_REGISTRY_DIR: join(root, "registry"),
81
+ CI: "true",
82
+ STACK_NO_UPDATE_CHECK: "1",
83
+ STACK_TELEMETRY: "0",
84
+ PHANTOM_CLOUD_TOKEN: "SYNTHETIC-CLOUD-DO-NOT-PRINT",
85
+ PHANTOM_VAULT_PASSPHRASE: "SYNTHETIC-VAULT-DO-NOT-PRINT",
86
+ };
87
+ if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot;
88
+ await run({ cwd, env });
89
+ } finally {
90
+ rmSync(root, { recursive: true, force: true });
91
+ }
92
+ }
93
+
94
+ function projectSnapshot(cwd: string): Record<string, string> {
95
+ const snapshot: Record<string, string> = {};
96
+ function visit(dir: string, prefix = ""): void {
97
+ for (const name of readdirSync(dir).sort()) {
98
+ const file = join(dir, name);
99
+ const key = `${prefix}${name}`;
100
+ const stat = lstatSync(file);
101
+ if (stat.isDirectory()) {
102
+ snapshot[key] = `directory:${stat.mode}`;
103
+ visit(file, `${key}/`);
104
+ } else if (stat.isFile()) {
105
+ snapshot[key] = `file:${stat.mode}:${readFileSync(file).toString("hex")}`;
106
+ } else {
107
+ throw new Error("Unexpected CI fixture entry");
108
+ }
109
+ }
110
+ }
111
+ visit(cwd);
112
+ return snapshot;
113
+ }
114
+
115
+ // Reuse the existing async fixture transport: Bun 1.2's sync facade can
116
+ // mislabel exit 1 and does not reliably enforce timeout/output bounds.
117
+ function runBoundedCiFixture(
118
+ command: string,
119
+ args: string[],
120
+ cwd: string,
121
+ env: Record<string, string>,
122
+ ) {
123
+ return new Promise<{
124
+ status: number | null;
125
+ signal: NodeJS.Signals | null;
126
+ error?: Error;
127
+ stdout: string;
128
+ stderr: string;
129
+ timedOut: boolean;
130
+ outputLimitExceeded: boolean;
131
+ }>((resolve) => {
132
+ let child: ReturnType<typeof spawn>;
133
+ try {
134
+ child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
135
+ } catch (error) {
136
+ // Bun 1.2 throws launch errors synchronously; newer runtimes emit error.
137
+ resolve({
138
+ status: null,
139
+ signal: null,
140
+ error: error instanceof Error ? error : new Error("fixture launch failed"),
141
+ stdout: "",
142
+ stderr: "",
143
+ timedOut: false,
144
+ outputLimitExceeded: false,
145
+ });
146
+ return;
147
+ }
148
+ const stdout: Buffer[] = [];
149
+ const stderr: Buffer[] = [];
150
+ let bytes = 0;
151
+ let timedOut = false;
152
+ let outputLimitExceeded = false;
153
+ let error: Error | undefined;
154
+ const timer = setTimeout(() => {
155
+ timedOut = true;
156
+ child.kill("SIGKILL");
157
+ }, 1500);
158
+ function capture(chunks: Buffer[], chunk: Buffer) {
159
+ if (outputLimitExceeded) return;
160
+ const remaining = 64 * 1024 - bytes;
161
+ const retained = chunk.subarray(0, remaining);
162
+ chunks.push(Buffer.from(retained));
163
+ bytes += retained.length;
164
+ if (chunk.length > remaining) {
165
+ outputLimitExceeded = true;
166
+ child.kill("SIGKILL");
167
+ }
168
+ }
169
+ child.stdout?.on("data", (chunk: Buffer) => capture(stdout, chunk));
170
+ child.stderr?.on("data", (chunk: Buffer) => capture(stderr, chunk));
171
+ child.once("error", (failure) => {
172
+ error = failure;
173
+ if (child.pid) child.kill("SIGKILL");
174
+ });
175
+ child.once("close", (status, signal) => {
176
+ clearTimeout(timer);
177
+ resolve({
178
+ status,
179
+ signal,
180
+ error,
181
+ stdout: Buffer.concat(stdout).toString("utf8"),
182
+ stderr: Buffer.concat(stderr).toString("utf8"),
183
+ timedOut,
184
+ outputLimitExceeded,
185
+ });
186
+ });
187
+ });
188
+ }
189
+
190
+ async function runCi(fixture: CiFixture, args: string[] = []) {
191
+ const result = await runBoundedCiFixture(
192
+ process.execPath,
193
+ ["run", CLI, "ci", "init", ...args],
194
+ fixture.cwd,
195
+ fixture.env,
196
+ );
197
+ expect(result.error).toBeUndefined();
198
+ expect(result.timedOut).toBe(false);
199
+ expect(result.outputLimitExceeded).toBe(false);
200
+ expect(result.signal).toBeNull();
201
+ expect(result.status).not.toBeNull();
202
+ const output = result.stdout + result.stderr;
203
+ expect(output).not.toContain("SYNTHETIC-CLOUD-DO-NOT-PRINT");
204
+ expect(output).not.toContain("SYNTHETIC-VAULT-DO-NOT-PRINT");
205
+ return { result, output };
206
+ }
207
+
208
+ async function expectUnavailable(fixture: CiFixture, args: string[] = []): Promise<void> {
209
+ const before = projectSnapshot(fixture.cwd);
210
+ const { result, output } = await runCi(fixture, args);
211
+ expect(result.status).toBe(1);
212
+ expect(output).toContain(CI_UNAVAILABLE);
213
+ expect(output).not.toContain("CI workflow scaffolded.");
214
+ expect(output).not.toContain("PHANTOM_CLOUD_TOKEN");
215
+ expect(output).not.toContain("PHANTOM_VAULT_PASSPHRASE");
216
+ expect(projectSnapshot(fixture.cwd)).toEqual(before);
217
+ }
218
+
219
+ describe("stack ci init", async () => {
220
+ test("preserves missing-config refusal without creating workflow parents", async () => {
221
+ await withCiFixture(false, async (fixture) => {
222
+ const before = projectSnapshot(fixture.cwd);
223
+ const { result, output } = await runCi(fixture);
224
+ expect(result.status).toBe(1);
225
+ expect(output).toContain("No .stack.toml found. Run `stack init` first.");
226
+ expect(projectSnapshot(fixture.cwd)).toEqual(before);
227
+ expect(existsSync(join(fixture.cwd, ".github"))).toBe(false);
228
+ });
229
+ });
50
230
 
51
- const workflowPath = join(cwd, ".github", "workflows", "stack-ci.yml");
52
- expect(existsSync(workflowPath)).toBe(true);
53
- const content = readFileSync(workflowPath, "utf-8");
54
- expect(content).toContain("name: Stack doctor");
55
- expect(content).toContain("stack doctor --json");
56
- expect(content).toContain("PHANTOM_CLOUD_TOKEN");
57
- expect(content).toContain("actions/upload-artifact@v4");
231
+ test("refuses the unsupported Cloud scaffold before creating either parent", async () => {
232
+ await withCiFixture(true, async (fixture) => {
233
+ await expectUnavailable(fixture);
234
+ expect(existsSync(join(fixture.cwd, ".github"))).toBe(false);
235
+ });
236
+ });
237
+
238
+ test("leaves an existing workflow directory and unrelated file unchanged", async () => {
239
+ await withCiFixture(true, async (fixture) => {
240
+ const dir = join(fixture.cwd, ".github", "workflows");
241
+ mkdirSync(dir, { recursive: true });
242
+ writeFileSync(join(dir, "owned.yml"), "owner sentinel\n");
243
+ await expectUnavailable(fixture);
244
+ });
245
+ });
246
+
247
+ test("preserves the existing-file guard without force", async () => {
248
+ await withCiFixture(true, async (fixture) => {
249
+ const dir = join(fixture.cwd, ".github", "workflows");
250
+ mkdirSync(dir, { recursive: true });
251
+ writeFileSync(join(dir, "stack-ci.yml"), "owner sentinel\n");
252
+ const before = projectSnapshot(fixture.cwd);
253
+ const { result, output } = await runCi(fixture);
254
+ expect(result.status).toBe(1);
255
+ expect(output).toContain("already exists. It was not changed.");
256
+ expect(projectSnapshot(fixture.cwd)).toEqual(before);
257
+ });
258
+ });
259
+
260
+ test("force cannot overwrite an existing workflow with an unsupported scaffold", async () => {
261
+ await withCiFixture(true, async (fixture) => {
262
+ const dir = join(fixture.cwd, ".github", "workflows");
263
+ mkdirSync(dir, { recursive: true });
264
+ writeFileSync(join(dir, "stack-ci.yml"), "owner sentinel\n");
265
+ await expectUnavailable(fixture, ["--force"]);
266
+ });
267
+ });
58
268
 
59
- if (prev === undefined) delete process.env.STACK_REGISTRY_DIR;
60
- else process.env.STACK_REGISTRY_DIR = prev;
269
+ test("preserves help and the force flag without creating files", async () => {
270
+ await withCiFixture(false, async (fixture) => {
271
+ const before = projectSnapshot(fixture.cwd);
272
+ const { result, output } = await runCi(fixture, ["--help"]);
273
+ expect(result.status).toBe(0);
274
+ expect(output).toContain("--force");
275
+ expect(projectSnapshot(fixture.cwd)).toEqual(before);
276
+ });
61
277
  });
62
278
  });