@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,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
  });
@@ -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;
@@ -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
  });
@@ -0,0 +1,183 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
6
+ import {
7
+ CONFIG_FILENAME,
8
+ LOCAL_FILENAME,
9
+ type ServiceEntry,
10
+ emptyConfig,
11
+ readConfig,
12
+ writeConfig,
13
+ } from "../config.ts";
14
+
15
+ const SERVICE_NAMES = ["__proto__", "constructor", "toString", "github"];
16
+ const ENTRY: ServiceEntry = {
17
+ provider: "github",
18
+ secrets: ["FIXTURE_TOKEN_SLOT"],
19
+ mcp: "fixture-mcp",
20
+ resource_id: "fixture-resource",
21
+ region: "fixture-region",
22
+ meta: { account: "fixture-local-account" },
23
+ created_at: "2026-10-14T00:00:00Z",
24
+ created_by: "fixture-user",
25
+ };
26
+
27
+ function expectOwnService(
28
+ services: Record<string, ServiceEntry>,
29
+ name: string,
30
+ entry: ServiceEntry,
31
+ ): void {
32
+ expect(Object.getPrototypeOf(services)).toBe(Object.prototype);
33
+ expect(Object.hasOwn(services, name)).toBe(true);
34
+ expect(Object.keys(services)).toEqual([name]);
35
+ expect(services[name]).toEqual(entry);
36
+ expect(JSON.parse(JSON.stringify(services))).toEqual(Object.fromEntries([[name, entry]]));
37
+ }
38
+
39
+ describe("service names remain own configuration data", () => {
40
+ let root: string;
41
+ let cwd: string;
42
+ let registryDir: string;
43
+ let previousRegistryDir: string | undefined;
44
+
45
+ beforeEach(() => {
46
+ root = mkdtempSync(join(tmpdir(), "stack-own-services-"));
47
+ cwd = join(root, "project");
48
+ registryDir = join(root, "registry");
49
+ mkdirSync(cwd);
50
+ previousRegistryDir = process.env.STACK_REGISTRY_DIR;
51
+ process.env.STACK_REGISTRY_DIR = registryDir;
52
+ });
53
+
54
+ afterEach(() => {
55
+ if (previousRegistryDir === undefined)
56
+ Reflect.deleteProperty(process.env, "STACK_REGISTRY_DIR");
57
+ else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
58
+ rmSync(root, { recursive: true, force: true });
59
+ });
60
+
61
+ test.each(SERVICE_NAMES)("reads split files without losing the %s service", async (name) => {
62
+ writeFileSync(
63
+ join(cwd, CONFIG_FILENAME),
64
+ stringifyToml({
65
+ stack: { version: "1" },
66
+ services: Object.fromEntries([
67
+ [
68
+ name,
69
+ {
70
+ provider: ENTRY.provider,
71
+ secrets: ENTRY.secrets,
72
+ mcp: ENTRY.mcp,
73
+ },
74
+ ],
75
+ ]),
76
+ }),
77
+ );
78
+ writeFileSync(
79
+ join(cwd, LOCAL_FILENAME),
80
+ stringifyToml({
81
+ stack: { project_id: "stk_fixture" },
82
+ services: Object.fromEntries([
83
+ [
84
+ name,
85
+ {
86
+ resource_id: ENTRY.resource_id,
87
+ region: ENTRY.region,
88
+ meta: ENTRY.meta,
89
+ created_at: ENTRY.created_at,
90
+ created_by: ENTRY.created_by,
91
+ },
92
+ ],
93
+ ]),
94
+ }),
95
+ );
96
+
97
+ const config = await readConfig(cwd);
98
+
99
+ expect(config.stack.project_id).toBe("stk_fixture");
100
+ expectOwnService(config.services, name, ENTRY);
101
+ });
102
+
103
+ test.each(SERVICE_NAMES)("keeps %s when no local service entry exists", async (name) => {
104
+ writeFileSync(
105
+ join(cwd, CONFIG_FILENAME),
106
+ stringifyToml({
107
+ stack: { version: "1" },
108
+ services: Object.fromEntries([
109
+ [name, { provider: ENTRY.provider, secrets: ENTRY.secrets }],
110
+ ]),
111
+ }),
112
+ );
113
+ writeFileSync(
114
+ join(cwd, LOCAL_FILENAME),
115
+ stringifyToml({
116
+ stack: { project_id: "stk_fixture" },
117
+ services: {},
118
+ }),
119
+ );
120
+
121
+ const config = await readConfig(cwd);
122
+
123
+ expectOwnService(config.services, name, {
124
+ provider: ENTRY.provider,
125
+ secrets: ENTRY.secrets,
126
+ created_at: "1970-01-01T00:00:00Z",
127
+ });
128
+ });
129
+
130
+ test.each(SERVICE_NAMES)(
131
+ "writes and registers %s while keeping instance fields local",
132
+ async (name) => {
133
+ const original = emptyConfig("fixture-template");
134
+ original.services = Object.fromEntries([[name, ENTRY]]);
135
+
136
+ await writeConfig(original, cwd);
137
+
138
+ const shapeText = readFileSync(join(cwd, CONFIG_FILENAME), "utf8");
139
+ const shape = parseToml(shapeText);
140
+ const local = parseToml(readFileSync(join(cwd, LOCAL_FILENAME), "utf8"));
141
+ expect(Object.hasOwn(shape.services as object, name)).toBe(true);
142
+ expect(Object.hasOwn(local.services as object, name)).toBe(true);
143
+ expect((shape.services as Record<string, unknown>)[name]).toEqual({
144
+ provider: ENTRY.provider,
145
+ secrets: ENTRY.secrets,
146
+ mcp: ENTRY.mcp,
147
+ });
148
+ expect((local.services as Record<string, unknown>)[name]).toEqual({
149
+ resource_id: ENTRY.resource_id,
150
+ region: ENTRY.region,
151
+ meta: ENTRY.meta,
152
+ created_at: ENTRY.created_at,
153
+ created_by: ENTRY.created_by,
154
+ });
155
+ expect(shapeText).not.toContain(ENTRY.resource_id!);
156
+ expect(shapeText).not.toContain("fixture-local-account");
157
+ expect(shapeText).not.toContain(original.stack.project_id);
158
+ expectOwnService((await readConfig(cwd)).services, name, ENTRY);
159
+ const registry = JSON.parse(readFileSync(join(registryDir, "projects.json"), "utf8"));
160
+ expect(registry.projects[0].services).toEqual([name]);
161
+ },
162
+ );
163
+
164
+ test.each(SERVICE_NAMES)("migrates a legacy %s service without losing it", async (name) => {
165
+ writeFileSync(
166
+ join(cwd, CONFIG_FILENAME),
167
+ stringifyToml({
168
+ stack: { version: "1", project_id: "stk_legacy_fixture" },
169
+ services: Object.fromEntries([[name, ENTRY]]),
170
+ }),
171
+ );
172
+
173
+ const legacy = await readConfig(cwd);
174
+ expectOwnService(legacy.services, name, ENTRY);
175
+ expect(existsSync(join(cwd, LOCAL_FILENAME))).toBe(false);
176
+
177
+ await writeConfig(legacy, cwd);
178
+
179
+ const roundTrip = await readConfig(cwd);
180
+ expect(roundTrip.stack.project_id).toBe("stk_legacy_fixture");
181
+ expectOwnService(roundTrip.services, name, ENTRY);
182
+ });
183
+ });