@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
@@ -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, "..");
@@ -94,7 +100,7 @@ describe("source scanner — config files", () => {
94
100
 
95
101
  test("wrangler.toml picks up Cloudflare", async () => {
96
102
  const cwd = makeProject({
97
- "wrangler.toml": "name = \"my-worker\"\nmain = \"src/index.ts\"",
103
+ "wrangler.toml": 'name = "my-worker"\nmain = "src/index.ts"',
98
104
  });
99
105
  const hits = await scanSource(cwd);
100
106
  expect(hits.some((h) => h.provider === "cloudflare")).toBe(true);
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import github from "../providers/github.ts";
3
2
  import type { ProviderContext } from "../providers/_base.ts";
3
+ import github from "../providers/github.ts";
4
4
  import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
5
5
 
6
6
  describe("github provider (mocked fetch)", () => {
@@ -8,7 +8,8 @@ describe("github provider (mocked fetch)", () => {
8
8
  let realFetch: typeof fetch;
9
9
 
10
10
  beforeEach(() => {
11
- h = setupFakePhantom();
11
+ // Provider logic after a token is read; real Phantom 0.7.9 refuses headless reveal.
12
+ h = setupFakePhantom({}, { legacyHeadlessReveal: true });
12
13
  realFetch = globalThis.fetch;
13
14
  });
14
15
 
@@ -1,6 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { addSecret } from "../phantom.ts";
3
- import { bearerJsonHeaders, tryRevealSecret } from "../providers/_helpers.ts";
2
+ import { bearerJsonHeaders, takeRevealRefusals, tryRevealSecret } from "../providers/_helpers.ts";
4
3
  import { type Harness, setupFakePhantom } from "./_harness.ts";
5
4
 
6
5
  /**
@@ -11,7 +10,12 @@ import { type Harness, setupFakePhantom } from "./_harness.ts";
11
10
  describe("provider helpers", () => {
12
11
  let h: Harness;
13
12
  beforeEach(() => {
14
- h = setupFakePhantom({ PRESENT_KEY: "hello-world" });
13
+ // Legacy headless reveal exercises the helper's own logic; the last test
14
+ // below covers what Phantom Secrets 0.7.9 actually does.
15
+ h = setupFakePhantom(
16
+ { PRESENT_KEY: "hello-world", EMPTY_KEY: "" },
17
+ { legacyHeadlessReveal: true },
18
+ );
15
19
  });
16
20
  afterEach(() => h.cleanup());
17
21
 
@@ -20,7 +24,6 @@ describe("provider helpers", () => {
20
24
  });
21
25
 
22
26
  test("tryRevealSecret returns undefined for an empty value", async () => {
23
- await addSecret("EMPTY_KEY", "");
24
27
  expect(await tryRevealSecret("EMPTY_KEY")).toBeUndefined();
25
28
  });
26
29
 
@@ -29,6 +32,23 @@ describe("provider helpers", () => {
29
32
  expect(await tryRevealSecret("NEVER_STORED_KEY")).toBeUndefined();
30
33
  });
31
34
 
35
+ test("tryRevealSecret returns undefined when Phantom refuses headless reveal", async () => {
36
+ h.cleanup();
37
+ h = setupFakePhantom({ PRESENT_KEY: "hello-world" });
38
+ takeRevealRefusals();
39
+ expect(await tryRevealSecret("PRESENT_KEY")).toBeUndefined();
40
+ // The refusal is recorded by name so doctor can say "stored, unverified".
41
+ expect(takeRevealRefusals()).toEqual(["PRESENT_KEY"]);
42
+ expect(takeRevealRefusals()).toEqual([]);
43
+ });
44
+
45
+ test("a readable or absent value records no refusal", async () => {
46
+ takeRevealRefusals();
47
+ await tryRevealSecret("PRESENT_KEY");
48
+ await tryRevealSecret("NEVER_STORED_KEY");
49
+ expect(takeRevealRefusals()).toEqual([]);
50
+ });
51
+
32
52
  test("bearerJsonHeaders returns the canonical shape", () => {
33
53
  const headers = bearerJsonHeaders("my-token") as Record<string, string>;
34
54
  expect(headers.Authorization).toBe("Bearer my-token");
@@ -220,8 +220,8 @@ describe("parseRetryAfter", () => {
220
220
  const parsed = parseRetryAfter(future);
221
221
  expect(parsed).toBeDefined();
222
222
  // Allow drift from test overhead; we just need it to be in the right ballpark.
223
- expect(parsed!).toBeGreaterThan(5_000);
224
- expect(parsed!).toBeLessThanOrEqual(12_000);
223
+ expect(parsed as number).toBeGreaterThan(5_000);
224
+ expect(parsed as number).toBeLessThanOrEqual(12_000);
225
225
  });
226
226
 
227
227
  test("HTTP-date in the past clamps to 0", () => {
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
@@ -33,6 +33,7 @@ describe("import pipeline", () => {
33
33
  afterEach(() => {
34
34
  process.chdir(originalCwd);
35
35
  h.cleanup();
36
+ rmSync(cwd, { recursive: true, force: true });
36
37
  });
37
38
 
38
39
  test("typical Next.js .env gets parsed, stored, and mapped to services", async () => {
@@ -49,7 +50,7 @@ PORT=3000
49
50
  `.trim();
50
51
  writeFileSync(join(cwd, ".env"), envContent);
51
52
 
52
- const parsed = parseEnv(envContent);
53
+ const parsed = parseEnv(readFileSync(join(cwd, ".env"), "utf8"));
53
54
  expect(parsed.map((p) => p.key)).toContain("OPENAI_API_KEY");
54
55
  expect(parsed.map((p) => p.key)).toContain("NODE_ENV");
55
56
 
@@ -70,7 +71,12 @@ PORT=3000
70
71
  const vault = await readVault(h.dir);
71
72
  expect(vault.OPENAI_API_KEY).toBe("sk-fake");
72
73
  expect(vault.NODE_ENV).toBe("development");
73
- expect((await listSecrets()).length).toBeGreaterThanOrEqual(8);
74
+ const expectedVault = Object.fromEntries(parsed.map(({ key, value }) => [key, value]));
75
+ expect(vault).toEqual(expectedVault);
76
+ expect((await listSecrets()).sort()).toEqual(Object.keys(expectedVault).sort());
77
+ expect(h.callsTo("add").map(({ args }) => args)).toEqual(
78
+ parsed.map(({ key }) => [key, "--stdin"]),
79
+ );
74
80
 
75
81
  // Build a .stack.toml from the detected groups.
76
82
  const config = emptyConfig("import-test");
@@ -83,5 +89,13 @@ PORT=3000
83
89
  expect(roundTripped.services.supabase).toBeDefined();
84
90
  expect(roundTripped.services.openai).toBeDefined();
85
91
  expect(roundTripped.services.sentry.mcp).toBe("sentry");
86
- });
92
+ expect(Object.keys(roundTripped.services).sort()).toEqual(["openai", "sentry", "supabase"]);
93
+ for (const [provider, keys] of Object.entries(grouped)) {
94
+ expect(roundTripped.services[provider].secrets).toEqual(keys);
95
+ }
96
+ const serialized = readFileSync(join(cwd, ".stack.toml"), "utf8");
97
+ for (const { value } of parsed) expect(serialized).not.toContain(value);
98
+ // Eight sequential real child processes plus list/version probes are integration
99
+ // work, not a five-second performance assertion (especially under coverage).
100
+ }, 30_000);
87
101
  });
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { CostTracker } from "../ai/cost-tracker.ts";
2
3
  import {
3
4
  ClaudeMCPBackend,
4
5
  LocalSLMBackend,
@@ -6,7 +7,6 @@ import {
6
7
  getInferenceBackend,
7
8
  parseRecipeDraft,
8
9
  } from "../ai/inference.ts";
9
- import { CostTracker } from "../ai/cost-tracker.ts";
10
10
 
11
11
  /**
12
12
  * Inference backend tests. The local backend talks to an OpenAI-compatible
@@ -15,7 +15,10 @@ import { CostTracker } from "../ai/cost-tracker.ts";
15
15
  */
16
16
 
17
17
  // Minimal valid chat-completion response.
18
- function okChatResponse(content: string, usage?: { prompt_tokens?: number; completion_tokens?: number }) {
18
+ function okChatResponse(
19
+ content: string,
20
+ usage?: { prompt_tokens?: number; completion_tokens?: number },
21
+ ) {
19
22
  return new Response(
20
23
  JSON.stringify({
21
24
  choices: [{ message: { content } }],
@@ -117,7 +120,10 @@ describe("LocalSLMBackend — infer", () => {
117
120
  test("happy path — parses recipe + records usage with reported tokens", async () => {
118
121
  const costTracker = new CostTracker();
119
122
  const fetchImpl = (async () =>
120
- okChatResponse(VALID_RECIPE_JSON, { prompt_tokens: 120, completion_tokens: 45 })) as unknown as typeof fetch;
123
+ okChatResponse(VALID_RECIPE_JSON, {
124
+ prompt_tokens: 120,
125
+ completion_tokens: 45,
126
+ })) as unknown as typeof fetch;
121
127
 
122
128
  const backend = new LocalSLMBackend({
123
129
  endpoints: [{ name: "lm-studio-fake", baseUrl: "http://fake/v1", model: "test" }],
@@ -146,7 +152,8 @@ describe("LocalSLMBackend — infer", () => {
146
152
  test("falls back to the second endpoint when the first fails", async () => {
147
153
  let call = 0;
148
154
  const fetchImpl = (async (input: string | URL | Request) => {
149
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
155
+ const url =
156
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
150
157
  call++;
151
158
  if (url.includes("primary")) {
152
159
  throw new Error("ECONNREFUSED");
@@ -169,6 +176,44 @@ describe("LocalSLMBackend — infer", () => {
169
176
  expect(call).toBeGreaterThanOrEqual(2);
170
177
  });
171
178
 
179
+ test.each(["invalid UTF-8", "replacement character"])(
180
+ "falls back when a 200 response contains an unusable %s assistant message",
181
+ async (kind) => {
182
+ const calls: string[] = [];
183
+ const fetchImpl = (async (input: string | URL | Request) => {
184
+ const url =
185
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
186
+ calls.push(url);
187
+ if (url.includes("secondary")) return okChatResponse(VALID_RECIPE_JSON);
188
+
189
+ // The envelope is valid JSON even after a malformed byte is decoded as U+FFFD.
190
+ const envelope = '{"choices":[{"message":{"content":"X"}}]}';
191
+ const bytes = new TextEncoder().encode(envelope);
192
+ if (kind === "invalid UTF-8") {
193
+ bytes[envelope.indexOf("X")] = 0xff;
194
+ return new Response(bytes, { status: 200 });
195
+ }
196
+ return new Response(envelope.replace("X", "\uFFFD"), { status: 200 });
197
+ }) as unknown as typeof fetch;
198
+ const backend = new LocalSLMBackend({
199
+ endpoints: [
200
+ { name: "primary", baseUrl: "http://primary/v1", model: "a" },
201
+ { name: "secondary", baseUrl: "http://secondary/v1", model: "b" },
202
+ ],
203
+ fetchImpl,
204
+ costTracker: new CostTracker(),
205
+ });
206
+
207
+ const result = await backend.infer({ query: "x", catalogContext: "" });
208
+ expect(result.recipe?.providers.map((provider) => provider.name)).toEqual(["neon", "clerk"]);
209
+ expect(backend.lastEndpoint).toBe("secondary");
210
+ expect(calls).toEqual([
211
+ "http://primary/v1/chat/completions",
212
+ "http://secondary/v1/chat/completions",
213
+ ]);
214
+ },
215
+ );
216
+
172
217
  test("throws NoInferenceBackendError when every endpoint fails", async () => {
173
218
  const fetchImpl = (async () => {
174
219
  throw new Error("down");
@@ -228,14 +273,15 @@ describe("LocalSLMBackend — infer", () => {
228
273
  costTracker: new CostTracker(),
229
274
  });
230
275
 
231
- await expect(
232
- backend.infer({ query: "x", catalogContext: "" }),
233
- ).rejects.toBeInstanceOf(NoInferenceBackendError);
276
+ await expect(backend.infer({ query: "x", catalogContext: "" })).rejects.toBeInstanceOf(
277
+ NoInferenceBackendError,
278
+ );
234
279
  });
235
280
 
236
281
  test("healthy pings /models and returns true when any endpoint responds", async () => {
237
282
  const fetchImpl = (async (input: string | URL | Request) => {
238
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
283
+ const url =
284
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
239
285
  if (url.endsWith("/models")) return new Response("[]", { status: 200 });
240
286
  return new Response("", { status: 500 });
241
287
  }) as unknown as typeof fetch;
@@ -252,19 +298,17 @@ describe("parseRecipeDraft", () => {
252
298
  test("parses plain JSON", () => {
253
299
  const draft = parseRecipeDraft(VALID_RECIPE_JSON);
254
300
  expect(draft.providers).toHaveLength(2);
255
- expect(draft.providers[0]!.name).toBe("neon");
301
+ expect(draft.providers[0]?.name).toBe("neon");
256
302
  });
257
303
 
258
304
  test("tolerates markdown fences", () => {
259
- const wrapped = "```json\n" + VALID_RECIPE_JSON + "\n```";
305
+ const wrapped = `\`\`\`json\n${VALID_RECIPE_JSON}\n\`\`\``;
260
306
  const draft = parseRecipeDraft(wrapped);
261
307
  expect(draft.providers).toHaveLength(2);
262
308
  });
263
309
 
264
310
  test("tolerates leading prose", () => {
265
- const draft = parseRecipeDraft(
266
- "Sure! Here's the recipe:\n" + VALID_RECIPE_JSON + "\nLet me know!",
267
- );
311
+ const draft = parseRecipeDraft(`Sure! Here's the recipe:\n${VALID_RECIPE_JSON}\nLet me know!`);
268
312
  expect(draft.providers).toHaveLength(2);
269
313
  });
270
314
 
@@ -0,0 +1,106 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { emptyConfig, writeConfig } from "../config.ts";
6
+ import { addService } from "../pipeline.ts";
7
+ import type { PromptRequest, Provider, ProviderContext } from "../providers/_base.ts";
8
+ import { promptSecret } from "../providers/_helpers.ts";
9
+ import { providers } from "../providers/index.ts";
10
+ import { type Harness, setupFakePhantom } from "./_harness.ts";
11
+
12
+ /**
13
+ * Regression coverage for the interactive-auth hang: a provider's PAT/key
14
+ * paste must go through the host-supplied `ctx.prompt` (which the CLI pauses
15
+ * its spinner around) rather than writing to stderr and blocking on stdin
16
+ * behind a live spinner. See `promptSecret` + `ProviderContext.prompt`.
17
+ */
18
+ describe("interactive credential prompting", () => {
19
+ test("promptSecret routes through ctx.prompt, trims, and marks the field secret", async () => {
20
+ const seen: PromptRequest[] = [];
21
+ const ctx: ProviderContext = {
22
+ cwd: ".",
23
+ interactive: true,
24
+ log: () => {},
25
+ prompt: async (req) => {
26
+ seen.push(req);
27
+ return " tok-abc123 "; // surrounding whitespace must be trimmed off
28
+ },
29
+ };
30
+
31
+ const token = await promptSecret(ctx, {
32
+ message: "Paste your access token",
33
+ howTo: "create one at https://example.com/tokens",
34
+ });
35
+
36
+ expect(token).toBe("tok-abc123");
37
+ expect(seen).toHaveLength(1);
38
+ expect(seen[0]).toEqual({
39
+ message: "Paste your access token",
40
+ howTo: "create one at https://example.com/tokens",
41
+ secret: true,
42
+ });
43
+ });
44
+
45
+ describe("threaded through addService", () => {
46
+ let h: Harness;
47
+ let cwd: string;
48
+ let originalCwd: string;
49
+
50
+ beforeEach(async () => {
51
+ h = setupFakePhantom();
52
+ cwd = mkdtempSync(join(tmpdir(), "stack-prompt-"));
53
+ originalCwd = process.cwd();
54
+ process.chdir(cwd);
55
+ await writeConfig(emptyConfig("test-template"), cwd);
56
+ });
57
+
58
+ afterEach(() => {
59
+ process.chdir(originalCwd);
60
+ Reflect.deleteProperty(providers, "promptsvc");
61
+ h.cleanup();
62
+ });
63
+
64
+ test("addService threads prompt into ctx so login receives the host-entered value", async () => {
65
+ let promptCalls = 0;
66
+ let loginToken: string | undefined;
67
+ const stubProvider: Provider = {
68
+ name: "promptsvc",
69
+ displayName: "Prompt Service",
70
+ category: "database",
71
+ authKind: "pat",
72
+ async login(ctx) {
73
+ // Exactly what every real provider's PAT-paste path now does.
74
+ const token = await promptSecret(ctx, { message: "Paste your Prompt Service token" });
75
+ loginToken = token;
76
+ return { token, identity: { id: "user-1" } };
77
+ },
78
+ async provision() {
79
+ return { id: "res-1", displayName: "res-1" };
80
+ },
81
+ async materialize() {
82
+ // No secrets/mcp keeps the pipeline off the Phantom vault, so this
83
+ // assertion runs identically on every platform.
84
+ return { secrets: {} };
85
+ },
86
+ };
87
+ providers.promptsvc = async () => stubProvider;
88
+
89
+ const result = await addService({
90
+ providerName: "promptsvc",
91
+ cwd,
92
+ interactive: true,
93
+ prompt: async () => {
94
+ promptCalls += 1;
95
+ return "host-entered-token";
96
+ },
97
+ });
98
+
99
+ expect(result.providerName).toBe("promptsvc");
100
+ // The host's prompt callback was invoked, and its value reached login via
101
+ // ctx → promptSecret — proof the wiring is end to end, not bypassed.
102
+ expect(promptCalls).toBe(1);
103
+ expect(loginToken).toBe("host-entered-token");
104
+ });
105
+ });
106
+ });