@ashlr/stack-core 0.1.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/package.json +57 -0
  4. package/src/__tests__/_harness.ts +146 -0
  5. package/src/__tests__/api-key-providers.test.ts +237 -0
  6. package/src/__tests__/catalog-index.test.ts +103 -0
  7. package/src/__tests__/circuit-breaker.test.ts +112 -0
  8. package/src/__tests__/clone-url-validation.test.ts +47 -0
  9. package/src/__tests__/cloudflare-provider.test.ts +74 -0
  10. package/src/__tests__/completion-ci.test.ts +62 -0
  11. package/src/__tests__/config-split.test.ts +138 -0
  12. package/src/__tests__/config.test.ts +66 -0
  13. package/src/__tests__/deps.test.ts +58 -0
  14. package/src/__tests__/detect-source.test.ts +147 -0
  15. package/src/__tests__/detect.test.ts +67 -0
  16. package/src/__tests__/github-provider.test.ts +65 -0
  17. package/src/__tests__/helpers.test.ts +38 -0
  18. package/src/__tests__/http.test.ts +235 -0
  19. package/src/__tests__/import-integration.test.ts +87 -0
  20. package/src/__tests__/inference.test.ts +326 -0
  21. package/src/__tests__/neon-provider.test.ts +79 -0
  22. package/src/__tests__/openai-provider.test.ts +54 -0
  23. package/src/__tests__/phantom-harness.test.ts +44 -0
  24. package/src/__tests__/phantom-wire.test.ts +85 -0
  25. package/src/__tests__/pipeline-integration.test.ts +95 -0
  26. package/src/__tests__/pipeline-recovery.test.ts +106 -0
  27. package/src/__tests__/pipeline.test.ts +92 -0
  28. package/src/__tests__/recipe.test.ts +164 -0
  29. package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
  30. package/src/__tests__/registry.test.ts +78 -0
  31. package/src/__tests__/secret-redaction.test.ts +170 -0
  32. package/src/__tests__/security.test.ts +80 -0
  33. package/src/__tests__/supabase-provider.test.ts +108 -0
  34. package/src/__tests__/templates.test.ts +26 -0
  35. package/src/__tests__/turso-provider.test.ts +71 -0
  36. package/src/ai/catalog-index.ts +256 -0
  37. package/src/ai/circuit-breaker.ts +116 -0
  38. package/src/ai/cost-tracker.ts +103 -0
  39. package/src/ai/inference.ts +470 -0
  40. package/src/ai/phantom-wire.ts +184 -0
  41. package/src/ai/recipe.ts +178 -0
  42. package/src/catalog.ts +430 -0
  43. package/src/config.ts +293 -0
  44. package/src/detect-source.ts +344 -0
  45. package/src/detect.ts +207 -0
  46. package/src/env.ts +35 -0
  47. package/src/errors.ts +29 -0
  48. package/src/http.ts +212 -0
  49. package/src/index.ts +61 -0
  50. package/src/mcp-writer.ts +50 -0
  51. package/src/oauth.ts +184 -0
  52. package/src/phantom.ts +156 -0
  53. package/src/pipeline.ts +131 -0
  54. package/src/providers/_api-key.ts +122 -0
  55. package/src/providers/_base.ts +96 -0
  56. package/src/providers/_helpers.ts +81 -0
  57. package/src/providers/anthropic.ts +27 -0
  58. package/src/providers/aws.ts +198 -0
  59. package/src/providers/braintrust.ts +33 -0
  60. package/src/providers/clerk.ts +23 -0
  61. package/src/providers/cloudflare.ts +146 -0
  62. package/src/providers/convex.ts +28 -0
  63. package/src/providers/deepseek.ts +24 -0
  64. package/src/providers/firebase.ts +35 -0
  65. package/src/providers/fly.ts +24 -0
  66. package/src/providers/github.ts +180 -0
  67. package/src/providers/index.ts +59 -0
  68. package/src/providers/linear.ts +46 -0
  69. package/src/providers/mailgun.ts +26 -0
  70. package/src/providers/modal.ts +27 -0
  71. package/src/providers/neon.ts +175 -0
  72. package/src/providers/openai.ts +24 -0
  73. package/src/providers/posthog.ts +30 -0
  74. package/src/providers/postmark.ts +27 -0
  75. package/src/providers/railway.ts +35 -0
  76. package/src/providers/render.ts +31 -0
  77. package/src/providers/replicate.ts +30 -0
  78. package/src/providers/resend.ts +24 -0
  79. package/src/providers/sendgrid.ts +24 -0
  80. package/src/providers/sentry.ts +183 -0
  81. package/src/providers/stripe.ts +33 -0
  82. package/src/providers/supabase.ts +309 -0
  83. package/src/providers/turso.ts +197 -0
  84. package/src/providers/upstash.ts +32 -0
  85. package/src/providers/vercel.ts +134 -0
  86. package/src/providers/xai.ts +24 -0
  87. package/src/registry.ts +114 -0
  88. package/src/templates.ts +39 -0
@@ -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, readConfig, writeConfig } from "../config.ts";
6
+ import { addService } from "../pipeline.ts";
7
+ import type { Provider } from "../providers/_base.ts";
8
+ import { providers } from "../providers/index.ts";
9
+ import { type Harness, setupFakePhantom } from "./_harness.ts";
10
+
11
+ /**
12
+ * Partial-failure regression: if `materialize()` throws after `provision()`
13
+ * succeeded, we MUST still write a breadcrumb entry to .stack.toml so
14
+ * `stack doctor --fix` / `stack remove` can find and clean up the dangling
15
+ * upstream resource. Without this guard, the user ends up with a live
16
+ * provider-side resource and no local record of it at all.
17
+ */
18
+ describe("addService partial-failure breadcrumb", () => {
19
+ let h: Harness;
20
+ let cwd: string;
21
+ let originalCwd: string;
22
+
23
+ beforeEach(async () => {
24
+ h = setupFakePhantom();
25
+ cwd = mkdtempSync(join(tmpdir(), "stack-partial-"));
26
+ originalCwd = process.cwd();
27
+ process.chdir(cwd);
28
+ await writeConfig(emptyConfig("test-template"), cwd);
29
+ });
30
+
31
+ afterEach(() => {
32
+ process.chdir(originalCwd);
33
+ h.cleanup();
34
+ });
35
+
36
+ test("writes a partial entry when materialize throws post-provision", async () => {
37
+ const failingProvider: Provider = {
38
+ name: "partialsvc",
39
+ displayName: "Partial Service",
40
+ category: "database",
41
+ authKind: "api_key",
42
+ async login() {
43
+ return { token: "stub-token", identity: { id: "user-1" } };
44
+ },
45
+ async provision() {
46
+ return { id: "partial-resource-42", displayName: "the one", region: "us-east-1" };
47
+ },
48
+ async materialize() {
49
+ throw new Error("simulated materialize failure (e.g. keys endpoint 500)");
50
+ },
51
+ };
52
+
53
+ providers.partialsvc = async () => failingProvider;
54
+
55
+ await expect(
56
+ addService({ providerName: "partialsvc", cwd, interactive: false }),
57
+ ).rejects.toThrow(/simulated materialize failure/);
58
+
59
+ // Breadcrumb must be present so cleanup tooling can find the dangling resource.
60
+ const config = await readConfig(cwd);
61
+ expect(config.services.partialsvc).toBeDefined();
62
+ expect(config.services.partialsvc.resource_id).toBe("partial-resource-42");
63
+ expect(config.services.partialsvc.provider).toBe("partialsvc");
64
+ expect(config.services.partialsvc.region).toBe("us-east-1");
65
+ // We don't know what was materialized, so secrets must be empty.
66
+ expect(config.services.partialsvc.secrets).toEqual([]);
67
+ // Marker lets `stack doctor` distinguish partial rows from healthy ones.
68
+ expect(config.services.partialsvc.created_by).toBe("stack add (partial)");
69
+
70
+ delete providers.partialsvc;
71
+ });
72
+
73
+ test("non-persist runs do NOT leave a breadcrumb (dry-run / preview)", async () => {
74
+ const failingProvider: Provider = {
75
+ name: "drypartialsvc",
76
+ displayName: "Dry Partial Service",
77
+ category: "database",
78
+ authKind: "api_key",
79
+ async login() {
80
+ return { token: "stub-token" };
81
+ },
82
+ async provision() {
83
+ return { id: "dry-resource-1", displayName: "dry" };
84
+ },
85
+ async materialize() {
86
+ throw new Error("boom");
87
+ },
88
+ };
89
+
90
+ providers.drypartialsvc = async () => failingProvider;
91
+
92
+ await expect(
93
+ addService({
94
+ providerName: "drypartialsvc",
95
+ cwd,
96
+ interactive: false,
97
+ persist: false,
98
+ }),
99
+ ).rejects.toThrow(/boom/);
100
+
101
+ const config = await readConfig(cwd);
102
+ expect(config.services.drypartialsvc).toBeUndefined();
103
+
104
+ delete providers.drypartialsvc;
105
+ });
106
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { getProvider, listProviderNames } from "../providers/index.ts";
6
+
7
+ /**
8
+ * Pipeline sanity checks — load every registered provider so a broken import
9
+ * in any provider shows up immediately, even without Phantom or real API keys.
10
+ */
11
+ describe("provider registry", () => {
12
+ test("lists a curated catalog", () => {
13
+ const names = listProviderNames();
14
+ expect(names.length).toBeGreaterThanOrEqual(15);
15
+ // Spot-check the core four that define the product thesis.
16
+ expect(names).toContain("supabase");
17
+ expect(names).toContain("openai");
18
+ expect(names).toContain("vercel");
19
+ expect(names).toContain("github");
20
+ });
21
+
22
+ test("every provider loads and exposes the Provider contract", async () => {
23
+ for (const name of listProviderNames()) {
24
+ const p = await getProvider(name);
25
+ expect(p.name).toBe(name);
26
+ expect(typeof p.displayName).toBe("string");
27
+ expect(typeof p.login).toBe("function");
28
+ expect(typeof p.provision).toBe("function");
29
+ expect(typeof p.materialize).toBe("function");
30
+ }
31
+ });
32
+
33
+ test("getProvider rejects unknown names", async () => {
34
+ await expect(getProvider("not-a-real-provider")).rejects.toThrow(/PROVIDER_NOT_FOUND|No provider/);
35
+ });
36
+ });
37
+
38
+ describe("templates", () => {
39
+ test("templates directory resolves inside the monorepo", async () => {
40
+ const { resolveTemplatesDir, loadTemplate } = await import("../templates.ts");
41
+ const dir = resolveTemplatesDir();
42
+ expect(dir).toBeDefined();
43
+ const template = await loadTemplate("nextjs-supabase-posthog");
44
+ expect(template).toBeDefined();
45
+ expect(template?.services.supabase?.provider).toBe("supabase");
46
+ });
47
+
48
+ test("loadTemplate returns undefined for unknown names", async () => {
49
+ const { loadTemplate } = await import("../templates.ts");
50
+ const result = await loadTemplate("not-real");
51
+ expect(result).toBeUndefined();
52
+ });
53
+ });
54
+
55
+ describe("mcp-writer", () => {
56
+ test("mergeMcpEntry creates .mcp.json with the entry", async () => {
57
+ const { mergeMcpEntry } = await import("../mcp-writer.ts");
58
+ const cwd = mkdtempSync(join(tmpdir(), "stack-mcp-"));
59
+ await mergeMcpEntry(
60
+ {
61
+ name: "supabase",
62
+ type: "stdio",
63
+ command: "npx",
64
+ args: ["-y", "@supabase/mcp-server-supabase"],
65
+ },
66
+ cwd,
67
+ );
68
+ const raw = await Bun.file(join(cwd, ".mcp.json")).text();
69
+ const parsed = JSON.parse(raw) as { mcpServers: Record<string, { command: string }> };
70
+ expect(parsed.mcpServers.supabase.command).toBe("npx");
71
+ });
72
+
73
+ test("mergeMcpEntry preserves unrelated keys in an existing file", async () => {
74
+ const { mergeMcpEntry } = await import("../mcp-writer.ts");
75
+ const cwd = mkdtempSync(join(tmpdir(), "stack-mcp-"));
76
+ writeFileSync(
77
+ join(cwd, ".mcp.json"),
78
+ JSON.stringify({
79
+ mcpServers: { other: { command: "echo" } },
80
+ customKey: "keep-me",
81
+ }),
82
+ );
83
+ await mergeMcpEntry({ name: "new-one", type: "stdio", command: "npx" }, cwd);
84
+ const parsed = JSON.parse(await Bun.file(join(cwd, ".mcp.json")).text()) as {
85
+ mcpServers: Record<string, unknown>;
86
+ customKey: string;
87
+ };
88
+ expect(parsed.mcpServers.other).toBeDefined();
89
+ expect(parsed.mcpServers["new-one"]).toBeDefined();
90
+ expect(parsed.customKey).toBe("keep-me");
91
+ });
92
+ });
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ listRecipes,
7
+ readRecipe,
8
+ recipeFromRetrieval,
9
+ slugifyQuery,
10
+ writeRecipe,
11
+ type Recipe,
12
+ } from "../ai/recipe.ts";
13
+
14
+ function scratchDir(): { dir: string; cleanup: () => void } {
15
+ const dir = mkdtempSync(join(tmpdir(), "stack-recipe-"));
16
+ return {
17
+ dir,
18
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
19
+ };
20
+ }
21
+
22
+ describe("ai/recipe", () => {
23
+ it("round-trips a Recipe through write → read with fields preserved", async () => {
24
+ const { dir, cleanup } = scratchDir();
25
+ try {
26
+ const original: Recipe = {
27
+ id: "b2b-saas-auth-ai",
28
+ query: "B2B SaaS with auth + AI",
29
+ createdAt: "2026-04-18T00:00:00.000Z",
30
+ providers: [
31
+ { name: "supabase", rationale: "auth, database" },
32
+ { name: "anthropic", rationale: "ai" },
33
+ ],
34
+ guidance: "Top pick per category: Auth→supabase, AI→anthropic.",
35
+ projectContext: { hasAuth: true, language: "typescript" },
36
+ };
37
+ const path = await writeRecipe(original, dir);
38
+ expect(path).toContain(".stack/recipes/b2b-saas-auth-ai.toml");
39
+
40
+ const round = await readRecipe("b2b-saas-auth-ai", dir);
41
+ expect(round).toEqual(original);
42
+ } finally {
43
+ cleanup();
44
+ }
45
+ });
46
+
47
+ it("rejects reading a recipe referencing an unknown provider", async () => {
48
+ const { dir, cleanup } = scratchDir();
49
+ try {
50
+ // Write a valid recipe first, then corrupt it to contain an unknown provider.
51
+ await writeRecipe(
52
+ {
53
+ id: "bad",
54
+ query: "bad",
55
+ createdAt: "2026-01-01T00:00:00.000Z",
56
+ providers: [{ name: "supabase" }],
57
+ },
58
+ dir,
59
+ );
60
+ const fs = await import("node:fs/promises");
61
+ await fs.writeFile(
62
+ join(dir, ".stack/recipes/bad.toml"),
63
+ `id = "bad"
64
+ query = "bad"
65
+ createdAt = "2026-01-01T00:00:00.000Z"
66
+
67
+ [[providers]]
68
+ name = "totally-made-up-provider"
69
+ `,
70
+ "utf-8",
71
+ );
72
+ await expect(readRecipe("bad", dir)).rejects.toThrow(/UNKNOWN_PROVIDER_IN_RECIPE|unknown provider/i);
73
+ } finally {
74
+ cleanup();
75
+ }
76
+ });
77
+
78
+ it("rejects writing a recipe with an unknown provider", async () => {
79
+ const { dir, cleanup } = scratchDir();
80
+ try {
81
+ await expect(
82
+ writeRecipe(
83
+ {
84
+ id: "x",
85
+ query: "x",
86
+ createdAt: "2026-01-01T00:00:00.000Z",
87
+ providers: [{ name: "nope-not-real" }],
88
+ },
89
+ dir,
90
+ ),
91
+ ).rejects.toThrow(/unknown provider/i);
92
+ } finally {
93
+ cleanup();
94
+ }
95
+ });
96
+
97
+ it("listRecipes returns newest first and skips corrupt files", async () => {
98
+ const { dir, cleanup } = scratchDir();
99
+ try {
100
+ await writeRecipe(
101
+ {
102
+ id: "older",
103
+ query: "older",
104
+ createdAt: "2026-01-01T00:00:00.000Z",
105
+ providers: [{ name: "neon" }],
106
+ },
107
+ dir,
108
+ );
109
+ await writeRecipe(
110
+ {
111
+ id: "newer",
112
+ query: "newer",
113
+ createdAt: "2026-06-01T00:00:00.000Z",
114
+ providers: [{ name: "supabase" }],
115
+ },
116
+ dir,
117
+ );
118
+ const list = await listRecipes(dir);
119
+ expect(list.map((r) => r.id)).toEqual(["newer", "older"]);
120
+ } finally {
121
+ cleanup();
122
+ }
123
+ });
124
+
125
+ it("listRecipes returns [] when no recipes dir exists", async () => {
126
+ const { dir, cleanup } = scratchDir();
127
+ try {
128
+ const list = await listRecipes(dir);
129
+ expect(list).toEqual([]);
130
+ } finally {
131
+ cleanup();
132
+ }
133
+ });
134
+
135
+ it("recipeFromRetrieval derives id, rationale, and createdAt", () => {
136
+ const recipe = recipeFromRetrieval(
137
+ "B2B SaaS with auth + AI + payments",
138
+ [
139
+ {
140
+ provider: { name: "supabase" } as never,
141
+ score: 9.9,
142
+ matched: ["auth", "database"],
143
+ },
144
+ {
145
+ provider: { name: "stripe" } as never,
146
+ score: 8.1,
147
+ matched: ["payments"],
148
+ },
149
+ ],
150
+ "pick supabase + stripe",
151
+ );
152
+ expect(recipe.id).toBe("b2b-saas-with-auth-ai-payments");
153
+ expect(recipe.providers[0]).toEqual({ name: "supabase", rationale: "auth, database" });
154
+ expect(recipe.providers[1]).toEqual({ name: "stripe", rationale: "payments" });
155
+ expect(recipe.guidance).toBe("pick supabase + stripe");
156
+ expect(new Date(recipe.createdAt).getTime()).toBeGreaterThan(0);
157
+ });
158
+
159
+ it("slugifyQuery handles empty / punctuation-only input", () => {
160
+ expect(slugifyQuery("")).toBe("recipe");
161
+ expect(slugifyQuery("!!!")).toBe("recipe");
162
+ expect(slugifyQuery(" B2B SaaS ")).toBe("b2b-saas");
163
+ });
164
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * End-to-end integration test for the `stack recommend → stack apply` flow.
3
+ *
4
+ * Exercises the real pipeline via core exports (not by spawning the CLI):
5
+ *
6
+ * retrieve(query)
7
+ * → recipeFromRetrieval(...)
8
+ * → writeRecipe(..., cwd) // creates .stack/recipes/<id>.toml
9
+ * → readRecipe(id, cwd) // round-trips through TOML
10
+ * → wirePhantomForRecipe(recipe, { cwd }) // fills the fake Phantom vault
11
+ *
12
+ * Calling the real provider CLIs (`stack add <provider>`) is out of scope:
13
+ * those make live HTTPS/OAuth calls. This test stops at the phantom-wire
14
+ * boundary, which is exactly what `stack apply --no-wire` would skip.
15
+ *
16
+ * The `setupFakePhantom` harness stubs out the Phantom CLI via a PATH-prefix
17
+ * shell script, so `wirePhantomForRecipe` can shell out for real without
18
+ * hitting the user's actual vault. We assert on its call log.
19
+ */
20
+
21
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
22
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { retrieve } from "../ai/catalog-index.ts";
26
+ import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
27
+ import {
28
+ type Recipe,
29
+ listRecipes,
30
+ readRecipe,
31
+ recipeFromRetrieval,
32
+ writeRecipe,
33
+ } from "../ai/recipe.ts";
34
+ import { type Harness, setupFakePhantom } from "./_harness.ts";
35
+
36
+ describe("recommend → apply e2e", () => {
37
+ let harness: Harness;
38
+ // A project-scratch cwd, separate from the phantom harness's own dir, so
39
+ // `.stack/recipes/*.toml` lands in an isolated location we can assert on.
40
+ let projectDir: string;
41
+
42
+ beforeEach(() => {
43
+ harness = setupFakePhantom();
44
+ projectDir = mkdtempSync(join(tmpdir(), "stack-e2e-project-"));
45
+ });
46
+
47
+ afterEach(() => {
48
+ harness.cleanup();
49
+ try {
50
+ rmSync(projectDir, { recursive: true, force: true });
51
+ } catch {
52
+ /* best-effort */
53
+ }
54
+ });
55
+
56
+ it("happy path: retrieve → recipeFromRetrieval → writeRecipe → readRecipe → wirePhantomForRecipe", async () => {
57
+ // 1) Simulate `stack recommend "postgres database"` — catalog retrieval.
58
+ const hits = retrieve("postgres database", { k: 3 });
59
+ expect(hits.length).toBeGreaterThan(0);
60
+
61
+ // Build a recipe deterministically from small, pure-API-key providers so
62
+ // the apply path never touches a real OAuth flow.
63
+ const recipe: Recipe = {
64
+ id: "postgres-database-e2e",
65
+ query: "postgres database",
66
+ createdAt: new Date().toISOString(),
67
+ providers: [
68
+ { name: "neon", rationale: "database, postgres" },
69
+ { name: "openai", rationale: "ai" },
70
+ { name: "anthropic", rationale: "ai" },
71
+ ],
72
+ guidance: "Neon for DB, OpenAI + Anthropic for AI.",
73
+ };
74
+
75
+ // 2) Simulate `--save` — freeze the recipe to TOML under the project cwd.
76
+ const path = await writeRecipe(recipe, projectDir);
77
+ expect(path).toContain(".stack/recipes/postgres-database-e2e.toml");
78
+ expect(existsSync(path)).toBe(true);
79
+
80
+ // 3) Simulate `stack apply <id>` reading the TOML back.
81
+ const loaded = await readRecipe("postgres-database-e2e", projectDir);
82
+ expect(loaded.id).toBe(recipe.id);
83
+ expect(loaded.providers.map((p) => p.name)).toEqual(["neon", "openai", "anthropic"]);
84
+
85
+ // 4) Wire envelopes via the fake Phantom CLI.
86
+ const result = await wirePhantomForRecipe(loaded, { cwd: projectDir });
87
+
88
+ // Every provider's declared catalog secrets must be envelope-added.
89
+ // (neon / openai / anthropic are all api_key providers with known secrets.)
90
+ expect(result.envelopes).toEqual(
91
+ expect.arrayContaining([
92
+ "neon:NEON_API_KEY",
93
+ "openai:OPENAI_API_KEY",
94
+ "anthropic:ANTHROPIC_API_KEY",
95
+ ]),
96
+ );
97
+ expect(result.skipped).toEqual([]);
98
+
99
+ // No webhook stubs for these providers — none are in the webhook-capable set.
100
+ expect(result.webhooks).toEqual([]);
101
+
102
+ // 5) Inspect the fake Phantom call log — every secret should have been
103
+ // shelled out as `phantom add <KEY> <placeholder>`.
104
+ const adds = harness.callsTo("add");
105
+ const addedKeys = adds.flatMap((c) => c.args);
106
+ expect(addedKeys).toContain("NEON_API_KEY");
107
+ expect(addedKeys).toContain("OPENAI_API_KEY");
108
+ expect(addedKeys).toContain("ANTHROPIC_API_KEY");
109
+
110
+ // The placeholder is intentional — `stack add <provider>` would replace it
111
+ // with the real secret. Stopping here is the "noWire: false but no OAuth"
112
+ // boundary: the vault now holds placeholders, not real credentials.
113
+ const vault = JSON.parse(readFileSync(harness.vaultPath, "utf-8")) as Record<string, string>;
114
+ expect(Object.keys(vault)).toEqual(
115
+ expect.arrayContaining(["NEON_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]),
116
+ );
117
+ });
118
+
119
+ it("noWire: true performs zero phantom envelope calls and writes no webhook stubs", async () => {
120
+ // Use stripe so we can also prove the webhook stub is skipped — stripe is
121
+ // in WEBHOOK_PROVIDERS, so it would otherwise drop a stub file.
122
+ const recipe: Recipe = {
123
+ id: "payments-nowire",
124
+ query: "payments",
125
+ createdAt: "2026-04-18T00:00:00.000Z",
126
+ providers: [{ name: "stripe", rationale: "payments" }],
127
+ };
128
+ await writeRecipe(recipe, projectDir);
129
+ const loaded = await readRecipe("payments-nowire", projectDir);
130
+
131
+ const result = await wirePhantomForRecipe(loaded, {
132
+ cwd: projectDir,
133
+ noWire: true,
134
+ });
135
+ expect(result).toEqual({ envelopes: [], webhooks: [], skipped: [] });
136
+
137
+ // Zero phantom calls for recipe secrets.
138
+ expect(harness.callsTo("add")).toEqual([]);
139
+
140
+ // And no webhook stub file dropped.
141
+ expect(existsSync(join(projectDir, ".stack/webhooks/stripe.ts"))).toBe(false);
142
+ });
143
+
144
+ it("writeRecipe rejects a recipe referencing an unknown provider", async () => {
145
+ const recipe: Recipe = {
146
+ id: "bad",
147
+ query: "unknown",
148
+ createdAt: "2026-04-18T00:00:00.000Z",
149
+ providers: [{ name: "not-a-real-provider" }],
150
+ };
151
+ await expect(writeRecipe(recipe, projectDir)).rejects.toThrow(/unknown provider/i);
152
+
153
+ // The thrown error should be a `StackError` with the typed code.
154
+ let caughtCode: string | undefined;
155
+ try {
156
+ await writeRecipe(recipe, projectDir);
157
+ } catch (err) {
158
+ caughtCode = (err as { code?: string }).code;
159
+ }
160
+ expect(caughtCode).toBe("UNKNOWN_PROVIDER_IN_RECIPE");
161
+ });
162
+
163
+ it("recipe round-trip preserves rationale fields on providers", async () => {
164
+ const recipe: Recipe = {
165
+ id: "rationale-roundtrip",
166
+ query: "mixed",
167
+ createdAt: "2026-04-18T00:00:00.000Z",
168
+ providers: [
169
+ { name: "openai", rationale: "primary llm" },
170
+ { name: "anthropic", rationale: "fallback + tool use" },
171
+ { name: "stripe", rationale: "billing" },
172
+ ],
173
+ guidance: "ai + payments",
174
+ };
175
+ await writeRecipe(recipe, projectDir);
176
+ const loaded = await readRecipe("rationale-roundtrip", projectDir);
177
+
178
+ expect(loaded.providers).toEqual([
179
+ { name: "openai", rationale: "primary llm" },
180
+ { name: "anthropic", rationale: "fallback + tool use" },
181
+ { name: "stripe", rationale: "billing" },
182
+ ]);
183
+ expect(loaded.guidance).toBe("ai + payments");
184
+ });
185
+
186
+ it("listRecipes returns freshly-written recipe by id", async () => {
187
+ const base: Omit<Recipe, "id" | "query"> = {
188
+ createdAt: "2026-04-18T12:00:00.000Z",
189
+ providers: [{ name: "openai" }],
190
+ };
191
+ await writeRecipe({ ...base, id: "list-me-a", query: "a" }, projectDir);
192
+ await writeRecipe({ ...base, id: "list-me-b", query: "b" }, projectDir);
193
+
194
+ // recipeFromRetrieval → writeRecipe is the shape `stack recommend --save`
195
+ // uses; prove it lands in the same listing surface `stack apply` reads.
196
+ const fromRetrieval = recipeFromRetrieval(
197
+ "openai inference",
198
+ [
199
+ {
200
+ provider: { name: "openai" } as never,
201
+ score: 9,
202
+ matched: ["ai"],
203
+ },
204
+ ],
205
+ "ai only",
206
+ );
207
+ await writeRecipe(fromRetrieval, projectDir);
208
+
209
+ const listed = await listRecipes(projectDir);
210
+ const ids = listed.map((r) => r.id);
211
+ expect(ids).toContain("list-me-a");
212
+ expect(ids).toContain("list-me-b");
213
+ expect(ids).toContain(fromRetrieval.id);
214
+
215
+ // Spot-check the `recipeFromRetrieval`-derived entry survived the
216
+ // round-trip with its rationale intact.
217
+ const fromList = listed.find((r) => r.id === fromRetrieval.id);
218
+ if (!fromList) throw new Error(`listRecipes missing ${fromRetrieval.id}`);
219
+ expect(fromList.providers).toEqual([{ name: "openai", rationale: "ai" }]);
220
+ expect(fromList.guidance).toBe("ai only");
221
+ });
222
+ });
@@ -0,0 +1,78 @@
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 {
7
+ findProjectByName,
8
+ listProjects,
9
+ registerProject,
10
+ registryPath,
11
+ unregisterProject,
12
+ } from "../registry.ts";
13
+
14
+ describe("cross-project registry", () => {
15
+ let previousDir: string | undefined;
16
+
17
+ beforeEach(() => {
18
+ previousDir = process.env.STACK_REGISTRY_DIR;
19
+ process.env.STACK_REGISTRY_DIR = mkdtempSync(join(tmpdir(), "stack-registry-"));
20
+ });
21
+
22
+ afterEach(() => {
23
+ if (previousDir === undefined) delete process.env.STACK_REGISTRY_DIR;
24
+ else process.env.STACK_REGISTRY_DIR = previousDir;
25
+ });
26
+
27
+ test("registryPath honors STACK_REGISTRY_DIR override", () => {
28
+ expect(registryPath().startsWith(process.env.STACK_REGISTRY_DIR!)).toBe(true);
29
+ });
30
+
31
+ test("writeConfig auto-registers the cwd", async () => {
32
+ const cwd = mkdtempSync(join(tmpdir(), "stack-auto-reg-"));
33
+ await writeConfig(emptyConfig("auto"), cwd);
34
+ const projects = await listProjects();
35
+ expect(projects.length).toBe(1);
36
+ expect(projects[0].path).toBe(cwd);
37
+ expect(projects[0].template).toBe("auto");
38
+ });
39
+
40
+ test("registerProject noops on directories without .stack.toml", async () => {
41
+ const cwd = mkdtempSync(join(tmpdir(), "stack-bare-"));
42
+ const entry = await registerProject(cwd);
43
+ expect(entry).toBeUndefined();
44
+ const projects = await listProjects();
45
+ expect(projects.length).toBe(0);
46
+ });
47
+
48
+ test("findProjectByName finds exact and case-insensitive matches", async () => {
49
+ const cwd1 = mkdtempSync(join(tmpdir(), "stack-alpha-"));
50
+ const cwd2 = mkdtempSync(join(tmpdir(), "stack-beta-"));
51
+ await writeConfig(emptyConfig(), cwd1);
52
+ await writeConfig(emptyConfig(), cwd2);
53
+ const all = await listProjects();
54
+ const firstName = all[0].name;
55
+ const found = await findProjectByName(firstName);
56
+ expect(found?.path).toBe(all[0].path);
57
+ const foundCaseInsensitive = await findProjectByName(firstName.toUpperCase());
58
+ expect(foundCaseInsensitive?.path).toBe(all[0].path);
59
+ });
60
+
61
+ test("unregisterProject removes without deleting files", async () => {
62
+ const cwd = mkdtempSync(join(tmpdir(), "stack-unreg-"));
63
+ await writeConfig(emptyConfig(), cwd);
64
+ expect((await listProjects()).length).toBe(1);
65
+ await unregisterProject(cwd);
66
+ expect((await listProjects()).length).toBe(0);
67
+ });
68
+
69
+ test("listProjects prunes entries whose .stack.toml was deleted", async () => {
70
+ const cwd = mkdtempSync(join(tmpdir(), "stack-prune-"));
71
+ await writeConfig(emptyConfig(), cwd);
72
+ // Simulate the file vanishing out from under us.
73
+ const { rmSync } = await import("node:fs");
74
+ rmSync(join(cwd, ".stack.toml"));
75
+ const projects = await listProjects();
76
+ expect(projects.length).toBe(0);
77
+ });
78
+ });