@ashlr/stack-core 0.2.2 → 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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/agent-onboard.test.ts +62 -0
  3. package/src/__tests__/base-urls.test.ts +48 -2
  4. package/src/__tests__/catalog.test.ts +31 -0
  5. package/src/__tests__/cloudflare-provider.test.ts +23 -0
  6. package/src/__tests__/config-split.test.ts +66 -0
  7. package/src/__tests__/config.test.ts +92 -3
  8. package/src/__tests__/credential-staging.test.ts +3 -2
  9. package/src/__tests__/detect-source.test.ts +182 -3
  10. package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
  11. package/src/__tests__/import-integration.test.ts +18 -4
  12. package/src/__tests__/inference.test.ts +38 -0
  13. package/src/__tests__/no-orphan-modules.test.ts +114 -0
  14. package/src/__tests__/phantom-harness.test.ts +102 -1
  15. package/src/__tests__/phantom-process.test.ts +135 -0
  16. package/src/__tests__/phantom-wire.test.ts +372 -2
  17. package/src/__tests__/pipeline-integration.test.ts +32 -0
  18. package/src/__tests__/publish-script.test.ts +63 -18
  19. package/src/__tests__/recipe.test.ts +23 -0
  20. package/src/__tests__/recommend-apply-e2e.test.ts +16 -1
  21. package/src/__tests__/scan-missing-dir.test.ts +45 -0
  22. package/src/__tests__/stack-manifest.test.ts +38 -0
  23. package/src/__tests__/template-litellm-gateway.test.ts +29 -0
  24. package/src/__tests__/template-temporal-worker-phantom.test.ts +14 -0
  25. package/src/ai/phantom-wire.ts +44 -6
  26. package/src/detect-source.ts +14 -1
  27. package/src/errors.ts +6 -0
  28. package/src/index.ts +1 -0
  29. package/src/phantom-process.ts +72 -0
  30. package/src/phantom.ts +144 -33
  31. package/src/stack-manifest.ts +39 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashlr/stack-core",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Ashlr Stack core — provider adapters, .stack.toml config, Phantom integration. Shared logic behind the Stack CLI and MCP server.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -215,3 +215,65 @@ describe("applyOnboarding and undoOnboarding", () => {
215
215
  expect(readFileSync(configPathFor("gemini", home), "utf-8")).not.toMatch(/key|token|secret/i);
216
216
  });
217
217
  });
218
+
219
+ it.each(["null", "[]", "42", '{"mcpServers":null}', '{"mcpServers":42}', "{broken"])(
220
+ "refuses malformed JSON config %s without writing",
221
+ async (raw) => {
222
+ const path = configPathFor("claude", home);
223
+ writeFileSync(path, raw);
224
+ const result = await applyOnboarding({ ...opts(), clients: ["claude"] });
225
+ expect(result.written).toEqual([]);
226
+ expect(result.manifestPath).toBeNull();
227
+ expect(result.skipped[0]?.state).toBe("error");
228
+ expect(result.skipped[0]?.error).toBeTruthy();
229
+ expect(readFileSync(path, "utf-8")).toBe(raw);
230
+ },
231
+ );
232
+
233
+ it.each([
234
+ ["", "not-wired"],
235
+ ['[mcp_servers.other]\ncommand = "ashlr"', "not-wired"],
236
+ [
237
+ '[mcp_servers."ashlr-hub"] # gateway\r\ncommand = "ashlr" # command\r\nargs = ["mcp"]\r\n[other]\r\ncommand = "different"',
238
+ "wired",
239
+ ],
240
+ ['[mcp_servers.ashlr-hub]\ncommand = "ashlr"\nargs = []', "differs"],
241
+ ['[mcp_servers.ashlr-hub]\nargs = ["mcp"]', "differs"],
242
+ ] as const)("inspects TOML %s", (raw, state) => {
243
+ const path = configPathFor("codex", home);
244
+ mkdirSync(join(home, ".codex"));
245
+ writeFileSync(path, raw);
246
+ expect(planOnboarding({ ...opts(), clients: ["codex"] })[0]?.state).toBe(state);
247
+ expect(readFileSync(path, "utf-8")).toBe(raw);
248
+ });
249
+
250
+ it.each(["missing config", "missing backup"])(
251
+ "undo retains manifest for %s and permits retry",
252
+ async (mode) => {
253
+ const path = configPathFor("claude", home);
254
+ writeFileSync(path, "{}");
255
+ const applied = await applyOnboarding({
256
+ ...opts(),
257
+ clients: ["claude"],
258
+ includeUndetected: true,
259
+ });
260
+ const entry = applied.written[0]!;
261
+ const target = mode === "missing config" ? path : entry.backupPath!;
262
+ const original = readFileSync(target, "utf-8");
263
+ rmSync(target);
264
+ const result = await undoOnboarding({ home });
265
+ expect(result.restored).toEqual([]);
266
+ expect(result.refused).toEqual([
267
+ {
268
+ entry,
269
+ reason:
270
+ mode === "missing config" ? "config file no longer exists" : "backup file is missing",
271
+ },
272
+ ]);
273
+ expect(JSON.parse(readFileSync(applied.manifestPath!, "utf-8")).entries).toEqual([entry]);
274
+ writeFileSync(target, original);
275
+ expect((await undoOnboarding({ home })).restored).toEqual([entry]);
276
+ expect(readFileSync(path, "utf-8")).toBe("{}");
277
+ expect(existsSync(applied.manifestPath!)).toBe(false);
278
+ },
279
+ );
@@ -55,6 +55,17 @@ describe("overrides are honored", () => {
55
55
  "http://gpu-box:11434/v1",
56
56
  );
57
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
+ });
58
69
  });
59
70
 
60
71
  describe("callers read the variables at call time", () => {
@@ -108,14 +119,39 @@ describe("callers read the variables at call time", () => {
108
119
 
109
120
  test("key checks follow ANTHROPIC_BASE_URL and OPENAI_BASE_URL", async () => {
110
121
  process.env.ANTHROPIC_BASE_URL = "http://127.0.0.1:4000/";
111
- process.env.OPENAI_BASE_URL = "http://127.0.0.1:4000/v1";
122
+ process.env.OPENAI_BASE_URL = "http://127.0.0.1:4000/v1/";
112
123
  await anthropic.healthcheck!(ctx, entry("anthropic", "ANTHROPIC_API_KEY"));
113
124
  await openai.healthcheck!(ctx, entry("openai", "OPENAI_API_KEY"));
114
125
  expect(urls).toEqual(["http://127.0.0.1:4000/v1/models", "http://127.0.0.1:4000/v1/models"]);
115
126
  });
116
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
+
117
153
  test("the local SLM backend picks up OLLAMA_HOST when constructed", async () => {
118
- process.env.OLLAMA_HOST = "gpu-box:11435";
154
+ process.env.OLLAMA_HOST = "gpu-box:11435/";
119
155
  const seen: string[] = [];
120
156
  const down = (async (u: string | URL | Request) => {
121
157
  seen.push(String(u));
@@ -160,3 +196,13 @@ describe("gateway rule for keyed providers", () => {
160
196
  );
161
197
  });
162
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
+ });
@@ -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
+ });
@@ -70,4 +70,27 @@ describe("cloudflare provider (mocked fetch)", () => {
70
70
  expect(status.kind).toBe("ok");
71
71
  expect(seenAuth).toBe("Bearer cf-cached");
72
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
+ });
73
96
  });
@@ -7,6 +7,7 @@ import {
7
7
  CONFIG_FILENAME,
8
8
  LOCAL_FILENAME,
9
9
  emptyConfig,
10
+ getService,
10
11
  readConfig,
11
12
  writeConfig,
12
13
  } from "../config.ts";
@@ -72,6 +73,71 @@ describe("committed + local config split", () => {
72
73
  expect(roundtrip.services.supabase.secrets).toEqual(["FOO"]);
73
74
  });
74
75
 
76
+ test("round-trips a service name containing a hyphen through both files", async () => {
77
+ const original = emptyConfig("fixture-template");
78
+ original.services["fixture-api"] = {
79
+ provider: "neon",
80
+ resource_id: "hyphen-resource",
81
+ region: "us-east-1",
82
+ secrets: ["FIXTURE_API_URL", "FIXTURE_API_KEY"],
83
+ mcp: "fixture-api",
84
+ meta: { endpoint: "fixture-endpoint" },
85
+ created_at: "2026-10-30T00:00:00Z",
86
+ created_by: "test",
87
+ };
88
+ await writeConfig(original, cwd);
89
+
90
+ const shape = parseToml(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8"));
91
+ const local = parseToml(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8"));
92
+ expect(Object.keys(shape.services as object)).toEqual(["fixture-api"]);
93
+ expect(Object.keys(local.services as object)).toEqual(["fixture-api"]);
94
+ expect(shape.services).toEqual({
95
+ "fixture-api": {
96
+ provider: "neon",
97
+ secrets: ["FIXTURE_API_URL", "FIXTURE_API_KEY"],
98
+ mcp: "fixture-api",
99
+ },
100
+ });
101
+ expect(local.services).toEqual({
102
+ "fixture-api": {
103
+ resource_id: "hyphen-resource",
104
+ region: "us-east-1",
105
+ meta: { endpoint: "fixture-endpoint" },
106
+ created_at: "2026-10-30T00:00:00Z",
107
+ created_by: "test",
108
+ },
109
+ });
110
+
111
+ const merged = await readConfig(cwd);
112
+ expect(Object.keys(merged.services)).toEqual(["fixture-api"]);
113
+ expect(getService(merged, "fixture-api")).toEqual(original.services["fixture-api"]);
114
+ });
115
+
116
+ test("legacy file with a hyphenated service name migrates without losing it", async () => {
117
+ const legacy = `
118
+ [stack]
119
+ version = "1"
120
+ project_id = "stk_hyphen"
121
+
122
+ [services.fixture-api]
123
+ provider = "neon"
124
+ resource_id = "hyphen-legacy"
125
+ secrets = ["X"]
126
+ created_at = "2026-04-17T00:00:00Z"
127
+ `.trim();
128
+ writeFileSync(join(cwd, CONFIG_FILENAME), legacy);
129
+ const config = await readConfig(cwd);
130
+ expect(Object.keys(config.services)).toEqual(["fixture-api"]);
131
+ expect(config.services["fixture-api"].resource_id).toBe("hyphen-legacy");
132
+
133
+ await writeConfig(config, cwd);
134
+ const shape = readFileSync(join(cwd, CONFIG_FILENAME), "utf-8");
135
+ expect(shape).toContain("fixture-api");
136
+ expect(shape).not.toContain("hyphen-legacy");
137
+ const roundTrip = await readConfig(cwd);
138
+ expect(roundTrip.services["fixture-api"]).toEqual(config.services["fixture-api"]);
139
+ });
140
+
75
141
  test("writeConfig auto-appends .stack.local.toml to .gitignore", async () => {
76
142
  await writeConfig(emptyConfig(), cwd);
77
143
  const gitignore = readFileSync(join(cwd, ".gitignore"), "utf-8");
@@ -1,9 +1,10 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  CONFIG_FILENAME,
7
+ LOCAL_FILENAME,
7
8
  emptyConfig,
8
9
  hasConfig,
9
10
  readConfig,
@@ -11,8 +12,15 @@ import {
11
12
  writeConfig,
12
13
  } from "../config.ts";
13
14
 
15
+ const scratch: string[] = [];
16
+ afterEach(() => {
17
+ for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true });
18
+ });
19
+
14
20
  function freshCwd(): string {
15
- return mkdtempSync(join(tmpdir(), "stack-test-"));
21
+ const dir = mkdtempSync(join(tmpdir(), "stack-test-"));
22
+ scratch.push(dir);
23
+ return dir;
16
24
  }
17
25
 
18
26
  describe("config", () => {
@@ -20,7 +28,7 @@ describe("config", () => {
20
28
 
21
29
  beforeEach(() => {
22
30
  previousRegistryDir = process.env.STACK_REGISTRY_DIR;
23
- process.env.STACK_REGISTRY_DIR = mkdtempSync(join(tmpdir(), "stack-cfg-reg-"));
31
+ process.env.STACK_REGISTRY_DIR = freshCwd();
24
32
  });
25
33
 
26
34
  afterEach(() => {
@@ -64,4 +72,85 @@ describe("config", () => {
64
72
  const cwd = freshCwd();
65
73
  await expect(readConfig(cwd)).rejects.toThrow(/No .stack.toml at/);
66
74
  });
75
+ test.each([".stack.toml", ".stack.local.toml"])(
76
+ "rejects malformed %s without rewriting files",
77
+ async (file) => {
78
+ const cwd = freshCwd();
79
+ writeFileSync(join(cwd, CONFIG_FILENAME), '[stack]\nversion = "1"');
80
+ writeFileSync(join(cwd, file), "[broken");
81
+ await expect(readConfig(cwd)).rejects.toThrow();
82
+ expect(readFileSync(join(cwd, file), "utf-8")).toBe("[broken");
83
+ },
84
+ );
85
+
86
+ test.each(["", "node_modules/", "node_modules/\n", " .stack.local.toml \r\n"])(
87
+ "gitignore update preserves existing content %j and is idempotent",
88
+ async (initial) => {
89
+ const cwd = freshCwd();
90
+ writeFileSync(join(cwd, ".gitignore"), initial);
91
+ const config = emptyConfig();
92
+ await writeConfig(config, cwd);
93
+ const first = readFileSync(join(cwd, ".gitignore"), "utf-8");
94
+ expect(first.startsWith(initial)).toBe(true);
95
+ expect(
96
+ first.split(/\r?\n/).filter((line) => line.trim() === ".stack.local.toml"),
97
+ ).toHaveLength(1);
98
+ await writeConfig(config, cwd);
99
+ expect(readFileSync(join(cwd, ".gitignore"), "utf-8")).toBe(first);
100
+ },
101
+ );
102
+
103
+ test("empty shape supplies defaults without creating local files", async () => {
104
+ const cwd = freshCwd();
105
+ writeFileSync(join(cwd, CONFIG_FILENAME), "");
106
+ const config = await readConfig(cwd);
107
+ expect(config.stack.version).toBe("1");
108
+ expect(config.stack.project_id).toMatch(/^stk_[a-f0-9]{12}$/);
109
+ expect(config.services).toEqual({});
110
+ expect(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8")).toBe("");
111
+ expect(existsSync(join(cwd, LOCAL_FILENAME))).toBe(false);
112
+ expect(existsSync(join(cwd, ".gitignore"))).toBe(false);
113
+ expect(config.environments).toEqual([{ name: "dev", default: true }]);
114
+ });
115
+
116
+ test("empty shape keeps the local project id but does not add local-only services", async () => {
117
+ const cwd = freshCwd();
118
+ const local =
119
+ '[stack]\nproject_id = "stk_fixture"\n[services.orphan]\nresource_id = "fixture-resource"\n';
120
+ writeFileSync(join(cwd, CONFIG_FILENAME), "");
121
+ writeFileSync(join(cwd, LOCAL_FILENAME), local);
122
+
123
+ expect(await readConfig(cwd)).toEqual({
124
+ stack: { version: "1", project_id: "stk_fixture" },
125
+ services: {},
126
+ environments: [{ name: "dev", default: true }],
127
+ });
128
+ expect(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8")).toBe("");
129
+ expect(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8")).toBe(local);
130
+ expect(existsSync(join(cwd, ".gitignore"))).toBe(false);
131
+ });
132
+ test.each(['created_at = "2026-01-01T00:00:00Z"', 'resource_id = "resource-1"'])(
133
+ "legacy service marker %s normalizes optional fields",
134
+ async (marker) => {
135
+ const cwd = freshCwd();
136
+ writeFileSync(
137
+ join(cwd, CONFIG_FILENAME),
138
+ `[services.example]\n${marker}\nmcp = "example"\nregion = "local"\ncreated_by = "test"\n[services.example.meta]\ncount = 2\n`,
139
+ );
140
+ const config = await readConfig(cwd);
141
+ expect(config.stack.version).toBe("1");
142
+ expect(config.stack.project_id).toMatch(/^stk_[a-f0-9]{12}$/);
143
+ expect(config.services.example).toMatchObject({
144
+ provider: "example",
145
+ secrets: [],
146
+ mcp: "example",
147
+ region: "local",
148
+ created_by: "test",
149
+ meta: { count: 2 },
150
+ });
151
+ expect(Number.isNaN(Date.parse(config.services.example!.created_at))).toBe(false);
152
+ await writeConfig(config, cwd);
153
+ expect(await readConfig(cwd)).toEqual(config);
154
+ },
155
+ );
67
156
  });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { emptyConfig, readConfig, writeConfig } from "../config.ts";
@@ -267,8 +267,9 @@ describe("operation-owned credential persistence", () => {
267
267
  .trim()
268
268
  .split("\n")
269
269
  .map((line) => JSON.parse(line));
270
+ // Python getcwd resolves filesystem aliases such as macOS /var.
270
271
  for (const row of rows.filter((r) => ["status", "reveal", "add"].includes(r.cmd)))
271
- expect(row.cwd).toBe(cwd);
272
+ expect(row.cwd).toBe(realpathSync(cwd));
272
273
  expect((await readConfig(cwd)).services.stagefixture).toBeDefined();
273
274
  });
274
275
  for (const fail of ["login", "provision", "materialize", "success"] as const) {
@@ -1,11 +1,17 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { delimiter, dirname, join } from "node:path";
5
5
  import { scanSource } from "../detect-source.ts";
6
6
 
7
+ const projects: string[] = [];
8
+ afterEach(() => {
9
+ for (const dir of projects.splice(0)) rmSync(dir, { recursive: true, force: true });
10
+ });
11
+
7
12
  function makeProject(files: Record<string, string>): string {
8
13
  const dir = mkdtempSync(join(tmpdir(), "stack-scan-"));
14
+ projects.push(dir);
9
15
  for (const [path, content] of Object.entries(files)) {
10
16
  const full = join(dir, path);
11
17
  const parent = join(full, "..");
@@ -145,3 +151,176 @@ require (
145
151
  expect(names).toContain("stripe");
146
152
  });
147
153
  });
154
+
155
+ test.each(["{broken", "null", "{}"])(
156
+ "malformed or empty package %s does not hide other evidence",
157
+ async (raw) => {
158
+ const cwd = makeProject({ "package.json": raw, "fly.toml": "" });
159
+ expect(await scanSource(cwd)).toEqual([
160
+ { provider: "fly", confidence: "high", signals: ["fly.toml"] },
161
+ ]);
162
+ },
163
+ );
164
+
165
+ test.each([
166
+ [
167
+ "pyproject.toml",
168
+ '[project]\ndependencies = ["supabase-py>=1", "anthropic", "resend"]',
169
+ ["anthropic", "resend", "supabase"],
170
+ ],
171
+ [
172
+ "Cargo.toml",
173
+ '[dependencies]\nasync-openai = "1"\nsentry = "1"\nasync-stripe = "1"',
174
+ ["openai", "sentry", "stripe"],
175
+ ],
176
+ [
177
+ "requirements.txt",
178
+ "# openai\n\nANTHROPIC[extra]>=1\r\nmodal==1\nreplicate~=1\nbraintrust!=1\nunknown",
179
+ ["anthropic", "braintrust", "modal", "replicate"],
180
+ ],
181
+ [
182
+ "go.mod",
183
+ "module example.com/app\nrequire github.com/liushuangls/go-anthropic v1\nrequire github.com/sashabaranov/go-openai v1",
184
+ ["openai", "anthropic"],
185
+ ],
186
+ ] as const)("scans %s with exact evidence", async (file, content, providers) => {
187
+ const hits = await scanSource(makeProject({ [file]: content }));
188
+ expect(hits.map((hit) => hit.provider)).toEqual([...providers]);
189
+ for (const hit of hits) {
190
+ expect(hit.signals).toHaveLength(1);
191
+ expect(hit.signals[0]).toStartWith(`${file}:`);
192
+ }
193
+ });
194
+
195
+ test("deduplicates dependency evidence and upgrades confidence without losing signals", async () => {
196
+ const hits = await scanSource(
197
+ makeProject({
198
+ "package.json": JSON.stringify({
199
+ dependencies: { "@vercel/kv": "1" },
200
+ devDependencies: { "@vercel/kv": "1" },
201
+ }),
202
+ "vercel.json": "{}",
203
+ ".env.sample": "VERCEL_TOKEN=\nUNKNOWN_NAME=",
204
+ ".env.template": "VERCEL_TOKEN=",
205
+ }),
206
+ );
207
+ expect(hits).toEqual([
208
+ {
209
+ provider: "vercel",
210
+ confidence: "high",
211
+ signals: [
212
+ "package.json:@vercel/kv",
213
+ "vercel.json",
214
+ ".env.sample:VERCEL_TOKEN",
215
+ ".env.template:VERCEL_TOKEN",
216
+ ],
217
+ },
218
+ ]);
219
+ });
220
+
221
+ test("scans a project directory containing spaces and reports relative file paths", async () => {
222
+ const root = makeProject({
223
+ "project with spaces/package.json": JSON.stringify({ dependencies: { openai: "1" } }),
224
+ "project with spaces/supabase/config.toml": "[api]",
225
+ "project with spaces/.env.example": "OPENAI_API_KEY=",
226
+ "project with spaces/vercel.json": "{}",
227
+ });
228
+
229
+ expect(await scanSource(join(root, "project with spaces"))).toEqual([
230
+ {
231
+ provider: "openai",
232
+ confidence: "high",
233
+ signals: ["package.json:openai", ".env.example:OPENAI_API_KEY"],
234
+ },
235
+ { provider: "supabase", confidence: "high", signals: ["supabase/config.toml"] },
236
+ { provider: "vercel", confidence: "high", signals: ["vercel.json"] },
237
+ ]);
238
+ });
239
+
240
+ test("finds the same source markers in equivalent paths with and without spaces", async () => {
241
+ const files: Record<string, string> = {
242
+ "package.json": JSON.stringify({ dependencies: { openai: "1" } }),
243
+ "requirements.txt": "modal==1",
244
+ "pyproject.toml": '[project]\ndependencies = ["resend"]',
245
+ "go.mod": "module example.com/app\nrequire github.com/stripe/stripe-go/v76 v76.0.0",
246
+ "Cargo.toml": '[dependencies]\nsentry = "1"',
247
+ "supabase/config.toml": "[api]",
248
+ ".github/workflows/ci.yml": "name: CI",
249
+ ".env.example": "ANTHROPIC_API_KEY=",
250
+ };
251
+ const root = makeProject(
252
+ Object.fromEntries(
253
+ ["project", "parent with spaces/project with spaces"].flatMap((dir) =>
254
+ Object.entries(files).map(([path, content]) => [join(dir, path), content]),
255
+ ),
256
+ ),
257
+ );
258
+
259
+ const plain = await scanSource(join(root, "project"));
260
+ const spaced = await scanSource(join(root, "parent with spaces", "project with spaces"));
261
+ expect(plain).toEqual([
262
+ {
263
+ provider: "github",
264
+ confidence: "high",
265
+ signals: [".github/workflows", ".github/workflows/ci.yml"],
266
+ },
267
+ { provider: "modal", confidence: "high", signals: ["requirements.txt:modal"] },
268
+ { provider: "openai", confidence: "high", signals: ["package.json:openai"] },
269
+ { provider: "resend", confidence: "high", signals: ["pyproject.toml:resend"] },
270
+ { provider: "sentry", confidence: "high", signals: ["Cargo.toml:sentry"] },
271
+ { provider: "stripe", confidence: "high", signals: ["go.mod:github.com/stripe/stripe-go/v76"] },
272
+ { provider: "supabase", confidence: "high", signals: ["supabase/config.toml"] },
273
+ { provider: "anthropic", confidence: "medium", signals: [".env.example:ANTHROPIC_API_KEY"] },
274
+ ]);
275
+ expect(spaced).toEqual(plain);
276
+ });
277
+
278
+ test("empty project has no detections", async () => {
279
+ expect(await scanSource(makeProject({}))).toEqual([]);
280
+ });
281
+
282
+ test.each([["--json"], ["--yes"]])(
283
+ "scan %s rejects a nonexistent project without creating files",
284
+ async (flag) => {
285
+ const root = makeProject({});
286
+ const home = makeProject({});
287
+ const missing = join(root, "missing", "project");
288
+ const cli = join(import.meta.dir, "..", "..", "..", "cli", "src", "index.ts");
289
+ const proc = Bun.spawn([process.execPath, cli, "scan", flag, "--path", missing], {
290
+ cwd: root,
291
+ env: {
292
+ PATH: [dirname(process.execPath), "/usr/bin", "/bin"].join(delimiter),
293
+ HOME: home,
294
+ CI: "1",
295
+ NO_COLOR: "1",
296
+ STACK_TELEMETRY: "0",
297
+ STACK_NO_UPDATE_CHECK: "1",
298
+ },
299
+ stdout: "pipe",
300
+ stderr: "pipe",
301
+ });
302
+ const [code, stdout, stderr] = await Promise.all([
303
+ proc.exited,
304
+ new Response(proc.stdout).text(),
305
+ new Response(proc.stderr).text(),
306
+ ]);
307
+
308
+ expect(code).toBe(1);
309
+ expect(stdout + stderr).toContain(`Path "${missing}" does not exist or is not a directory.`);
310
+ expect(stdout).not.toContain('"hits"');
311
+ expect(stdout).not.toContain("No providers detected");
312
+ expect(existsSync(missing)).toBe(false);
313
+ expect(readdirSync(root)).toEqual([]);
314
+ },
315
+ 30_000,
316
+ );
317
+
318
+ test.each([
319
+ ["package.json", JSON.stringify({ name: "openai", dependencies: {}, devDependencies: {} })],
320
+ ["requirements.txt", ""],
321
+ ["pyproject.toml", "[project]\ndependencies = []\n"],
322
+ ["go.mod", "module example.com/app\n\ngo 1.22\n"],
323
+ ["Cargo.toml", '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\n'],
324
+ ] as const)("empty dependency manifest %s produces no detections", async (file, content) => {
325
+ expect(await scanSource(makeProject({ [file]: content }))).toEqual([]);
326
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "schema_version": 1,
3
+ "kind": "ashlr-stack-manifest",
4
+ "components": [
5
+ { "id": "core", "name": "@ashlr/stack-core", "version": "0.3.0", "distribution": "npm" },
6
+ { "id": "cli", "name": "@ashlr/stack", "version": "0.3.0", "distribution": "npm" },
7
+ { "id": "mcp", "name": "ashlr-stack-mcp", "version": "0.3.0", "distribution": "npm" },
8
+ { "id": "plugin", "name": "stack", "version": "0.1.0", "distribution": "claude-code-plugin" }
9
+ ]
10
+ }