@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
@@ -2,13 +2,14 @@ import { describe, expect, it } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { parseRecipeDraft } from "../ai/inference.ts";
5
6
  import {
7
+ type Recipe,
6
8
  listRecipes,
7
9
  readRecipe,
8
10
  recipeFromRetrieval,
9
11
  slugifyQuery,
10
12
  writeRecipe,
11
- type Recipe,
12
13
  } from "../ai/recipe.ts";
13
14
 
14
15
  function scratchDir(): { dir: string; cleanup: () => void } {
@@ -20,6 +21,28 @@ function scratchDir(): { dir: string; cleanup: () => void } {
20
21
  }
21
22
 
22
23
  describe("ai/recipe", () => {
24
+ it("rejects a draft with no provider steps", () => {
25
+ expect(() => parseRecipeDraft('{"providers":[]}')).toThrow(
26
+ "Inference response contained no usable providers",
27
+ );
28
+ });
29
+
30
+ it("round-trips a saved recipe with no provider steps", async () => {
31
+ const { dir, cleanup } = scratchDir();
32
+ try {
33
+ const recipe: Recipe = {
34
+ id: "empty",
35
+ query: "no providers",
36
+ createdAt: "2026-01-01T00:00:00.000Z",
37
+ providers: [],
38
+ };
39
+ await writeRecipe(recipe, dir);
40
+ expect(await readRecipe(recipe.id, dir)).toEqual(recipe);
41
+ } finally {
42
+ cleanup();
43
+ }
44
+ });
45
+
23
46
  it("round-trips a Recipe through write → read with fields preserved", async () => {
24
47
  const { dir, cleanup } = scratchDir();
25
48
  try {
@@ -69,7 +92,9 @@ name = "totally-made-up-provider"
69
92
  `,
70
93
  "utf-8",
71
94
  );
72
- await expect(readRecipe("bad", dir)).rejects.toThrow(/UNKNOWN_PROVIDER_IN_RECIPE|unknown provider/i);
95
+ await expect(readRecipe("bad", dir)).rejects.toThrow(
96
+ /UNKNOWN_PROVIDER_IN_RECIPE|unknown provider/i,
97
+ );
73
98
  } finally {
74
99
  cleanup();
75
100
  }
@@ -7,7 +7,7 @@
7
7
  * → recipeFromRetrieval(...)
8
8
  * → writeRecipe(..., cwd) // creates .stack/recipes/<id>.toml
9
9
  * → readRecipe(id, cwd) // round-trips through TOML
10
- * → wirePhantomForRecipe(recipe, { cwd }) // fills the fake Phantom vault
10
+ * → wirePhantomForRecipe(recipe, { cwd }) // reads the fake Phantom vault
11
11
  *
12
12
  * Calling the real provider CLIs (`stack add <provider>`) is out of scope:
13
13
  * those make live HTTPS/OAuth calls. This test stops at the phantom-wire
@@ -18,8 +18,8 @@
18
18
  * hitting the user's actual vault. We assert on its call log.
19
19
  */
20
20
 
21
- import { afterEach, beforeEach, describe, expect, it } from "bun:test";
22
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
21
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
22
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import { retrieve } from "../ai/catalog-index.ts";
@@ -33,6 +33,10 @@ import {
33
33
  } from "../ai/recipe.ts";
34
34
  import { type Harness, setupFakePhantom } from "./_harness.ts";
35
35
 
36
+ // `wirePhantomForRecipe` spawns the fake `phantom` shim (bash + several
37
+ // python3 calls), which takes multiple seconds on loaded machines.
38
+ setDefaultTimeout(30_000);
39
+
36
40
  describe("recommend → apply e2e", () => {
37
41
  let harness: Harness;
38
42
  // A project-scratch cwd, separate from the phantom harness's own dir, so
@@ -82,41 +86,39 @@ describe("recommend → apply e2e", () => {
82
86
  expect(loaded.id).toBe(recipe.id);
83
87
  expect(loaded.providers.map((p) => p.name)).toEqual(["neon", "openai", "anthropic"]);
84
88
 
85
- // 4) Wire envelopes via the fake Phantom CLI.
89
+ // 4) Check the vault via the fake Phantom CLI. `stack add` (skipped here,
90
+ // it needs OAuth) already stored NEON_API_KEY; the AI keys are missing.
91
+ writeFileSync(harness.vaultPath, JSON.stringify({ NEON_API_KEY: "neon_real" }));
86
92
  const result = await wirePhantomForRecipe(loaded, { cwd: projectDir });
87
93
 
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
- ]),
94
+ expect(result.present).toEqual(["neon:NEON_API_KEY"]);
95
+ expect(result.missing).toEqual(
96
+ expect.arrayContaining(["openai:OPENAI_API_KEY", "anthropic:ANTHROPIC_API_KEY"]),
96
97
  );
97
98
  expect(result.skipped).toEqual([]);
98
99
 
99
100
  // No webhook stubs for these providers — none are in the webhook-capable set.
100
101
  expect(result.webhooks).toEqual([]);
101
102
 
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.
103
+ // 5) Apply only reads names from Phantom; it never writes placeholders
104
+ // (Phantom refuses secret values in argv) and leaves the vault untouched.
105
+ expect(harness.callsTo("add")).toEqual([]);
113
106
  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"]),
107
+ expect(vault).toEqual({ NEON_API_KEY: "neon_real" });
108
+
109
+ // Simulate a user adding one missing value, then check again.
110
+ const updatedVault = { ...vault, OPENAI_API_KEY: "openai_real" };
111
+ writeFileSync(harness.vaultPath, JSON.stringify(updatedVault));
112
+ const rechecked = await wirePhantomForRecipe(loaded, { cwd: projectDir });
113
+ expect(rechecked.present).toEqual(["neon:NEON_API_KEY", "openai:OPENAI_API_KEY"]);
114
+ expect(rechecked.missing).toEqual(
115
+ result.missing.filter((name) => name !== "openai:OPENAI_API_KEY"),
116
116
  );
117
+ expect(harness.callsTo("add")).toEqual([]);
118
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual(updatedVault);
117
119
  });
118
120
 
119
- it("noWire: true performs zero phantom envelope calls and writes no webhook stubs", async () => {
121
+ it("noWire: true performs zero phantom vault calls and writes no webhook stubs", async () => {
120
122
  // Use stripe so we can also prove the webhook stub is skipped — stripe is
121
123
  // in WEBHOOK_PROVIDERS, so it would otherwise drop a stub file.
122
124
  const recipe: Recipe = {
@@ -132,7 +134,7 @@ describe("recommend → apply e2e", () => {
132
134
  cwd: projectDir,
133
135
  noWire: true,
134
136
  });
135
- expect(result).toEqual({ envelopes: [], webhooks: [], skipped: [] });
137
+ expect(result).toEqual({ present: [], missing: [], webhooks: [], skipped: [] });
136
138
 
137
139
  // Zero phantom calls for recipe secrets.
138
140
  expect(harness.callsTo("add")).toEqual([]);
@@ -20,12 +20,12 @@ describe("cross-project registry", () => {
20
20
  });
21
21
 
22
22
  afterEach(() => {
23
- if (previousDir === undefined) delete process.env.STACK_REGISTRY_DIR;
23
+ if (previousDir === undefined) Reflect.deleteProperty(process.env, "STACK_REGISTRY_DIR");
24
24
  else process.env.STACK_REGISTRY_DIR = previousDir;
25
25
  });
26
26
 
27
27
  test("registryPath honors STACK_REGISTRY_DIR override", () => {
28
- expect(registryPath().startsWith(process.env.STACK_REGISTRY_DIR!)).toBe(true);
28
+ expect(registryPath().startsWith(process.env.STACK_REGISTRY_DIR ?? "")).toBe(true);
29
29
  });
30
30
 
31
31
  test("writeConfig auto-registers the cwd", async () => {
@@ -66,6 +66,15 @@ describe("cross-project registry", () => {
66
66
  expect((await listProjects()).length).toBe(0);
67
67
  });
68
68
 
69
+ test("unregisterProject reports whether anything was removed", async () => {
70
+ const cwd = mkdtempSync(join(tmpdir(), "stack-unreg-bool-"));
71
+ await writeConfig(emptyConfig(), cwd);
72
+ expect(await unregisterProject(join(cwd, "not-registered"))).toBe(false);
73
+ expect((await listProjects()).length).toBe(1);
74
+ expect(await unregisterProject(cwd)).toBe(true);
75
+ expect(await unregisterProject(cwd)).toBe(false);
76
+ });
77
+
69
78
  test("listProjects prunes entries whose .stack.toml was deleted", async () => {
70
79
  const cwd = mkdtempSync(join(tmpdir(), "stack-prune-"));
71
80
  await writeConfig(emptyConfig(), cwd);
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { listProviderNames } from "../providers/index.ts";
5
+
6
+ /**
7
+ * What users install: the npm tarballs for the CLI and MCP server, and the
8
+ * one-line installers (scripts/ plus the copies the site serves).
9
+ */
10
+
11
+ const repoRoot = join(import.meta.dir, "..", "..", "..", "..");
12
+ const read = (path: string) => readFileSync(join(repoRoot, path), "utf8");
13
+
14
+ describe("npm tarballs ship their bins", () => {
15
+ // `npm pack` runs prepack but not prepublishOnly, so a build hooked only to
16
+ // prepublishOnly packs a tarball with no dist/ and a global install of it has
17
+ // no bin. `npm publish` runs prepack as well.
18
+ for (const [dir, bin, entry] of [
19
+ ["packages/cli", "stack", "dist/index.js"],
20
+ ["packages/mcp", "ashlr-stack-mcp", "dist/server.js"],
21
+ ] as const) {
22
+ test(`${dir} builds ${entry} in prepack`, () => {
23
+ const pkg = JSON.parse(read(`${dir}/package.json`));
24
+ expect(pkg.bin).toEqual({ [bin]: `./${entry}` });
25
+ expect(pkg.scripts.prepack).toBe("bun run build");
26
+ expect(pkg.scripts.prepublishOnly).toBeUndefined();
27
+ expect(pkg.scripts.build).toContain(`const f='${entry}'`);
28
+ expect(pkg.files).toContain("dist/**/*");
29
+ });
30
+ }
31
+ });
32
+
33
+ describe("one-line installers", () => {
34
+ for (const name of ["install.sh", "install.ps1"]) {
35
+ test(`the site serves the same ${name} as scripts/`, () => {
36
+ expect(read(`packages/site/public/${name}`)).toBe(read(`scripts/${name}`));
37
+ });
38
+
39
+ test(`${name} states the real provider count`, () => {
40
+ const counts = [...read(`scripts/${name}`).matchAll(/the (\d+) curated providers/g)].map(
41
+ (m) => Number(m[1]),
42
+ );
43
+ expect(counts.length).toBeGreaterThan(0);
44
+ for (const count of counts) expect(count).toBe(listProviderNames().length);
45
+ });
46
+ }
47
+
48
+ test("install.sh tells binary-path users how to get the MCP server", () => {
49
+ const sh = read("scripts/install.sh");
50
+ const binaryBranch = sh.slice(sh.indexOf("if install_via_binary; then"));
51
+ expect(binaryBranch.slice(0, binaryBranch.indexOf("elif"))).toContain(
52
+ "npm i -g ashlr-stack-mcp",
53
+ );
54
+ });
55
+ });
@@ -0,0 +1,45 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { scanSource } from "../detect-source.ts";
6
+ import { SourceDirectoryNotFoundError } from "../errors.ts";
7
+
8
+ const roots: string[] = [];
9
+ afterEach(() => {
10
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
11
+ });
12
+
13
+ function makeRoot(): string {
14
+ const root = mkdtempSync(join(tmpdir(), "stack-scan-missing-"));
15
+ roots.push(root);
16
+ return root;
17
+ }
18
+
19
+ test("a missing directory fails with a typed error without being created", async () => {
20
+ const path = join(makeRoot(), "missing", "project");
21
+ try {
22
+ await scanSource(path);
23
+ throw new Error("expected scanSource to reject");
24
+ } catch (error) {
25
+ expect(error).toBeInstanceOf(SourceDirectoryNotFoundError);
26
+ expect((error as SourceDirectoryNotFoundError).code).toBe("SOURCE_DIRECTORY_NOT_FOUND");
27
+ expect((error as Error).message).toContain(path);
28
+ }
29
+ expect(existsSync(path)).toBe(false);
30
+ expect(existsSync(join(path, ".."))).toBe(false);
31
+ });
32
+
33
+ test("a file path and a path beneath a file fail with the typed error", async () => {
34
+ const path = join(makeRoot(), "file");
35
+ writeFileSync(path, "unchanged");
36
+ await expect(scanSource(path)).rejects.toBeInstanceOf(SourceDirectoryNotFoundError);
37
+ await expect(scanSource(join(path, "child"))).rejects.toBeInstanceOf(
38
+ SourceDirectoryNotFoundError,
39
+ );
40
+ expect(readFileSync(path, "utf8")).toBe("unchanged");
41
+ });
42
+
43
+ test("an empty directory still has no detections", async () => {
44
+ expect(await scanSource(makeRoot())).toEqual([]);
45
+ });
@@ -1,8 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { StackError } from "../errors.ts";
3
+ import { makeApiKeyProvider } from "../providers/_api-key.ts";
2
4
  import type { LogEvent } from "../providers/_base.ts";
3
5
  import { scrub } from "../providers/_helpers.ts";
4
- import { makeApiKeyProvider } from "../providers/_api-key.ts";
5
- import { StackError } from "../errors.ts";
6
6
  import { type Harness, setupFakePhantom } from "./_harness.ts";
7
7
 
8
8
  /**
@@ -38,7 +38,8 @@ describe("api-key provider login — invalid cached token warning", () => {
38
38
 
39
39
  beforeEach(() => {
40
40
  // Seed the vault so the provider's login() finds a cached token to reject.
41
- h = setupFakePhantom({ TEST_PROVIDER_KEY: RAW_TOKEN });
41
+ // Legacy headless reveal: real Phantom 0.7.9 never lets Stack read it back.
42
+ h = setupFakePhantom({ TEST_PROVIDER_KEY: RAW_TOKEN }, { legacyHeadlessReveal: true });
42
43
  realFetch = globalThis.fetch;
43
44
  });
44
45
 
@@ -75,8 +76,7 @@ describe("api-key provider login — invalid cached token warning", () => {
75
76
  const warn = logs.find((l) => l.level === "warn");
76
77
  expect(warn).toBeDefined();
77
78
  // The raw token must not appear anywhere in the message or data payload.
78
- const full =
79
- (warn?.msg ?? "") + " " + JSON.stringify(warn?.data ?? {});
79
+ const full = `${warn?.msg ?? ""} ${JSON.stringify(warn?.data ?? {})}`;
80
80
  expect(full).not.toContain(RAW_TOKEN);
81
81
  // And the tail of the token (last 6 chars) must not leak either — the
82
82
  // current _api-key warn doesn't reference the token at all, so belt + suspenders.
@@ -92,7 +92,7 @@ describe("phantom error formatting — regression guard for formatArgsForError",
92
92
  * exercise the error path.
93
93
  */
94
94
  function setupAlwaysFailingPhantom(): { cleanup(): void } {
95
- const { chmodSync, mkdtempSync, rmSync, writeFileSync } = require("node:fs") as typeof import("node:fs");
95
+ const { chmodSync, mkdtempSync, rmSync, writeFileSync } = require("node:fs") as typeof import("node:fs"); // biome-ignore format: Bun parser rejects split typeof import()
96
96
  const { tmpdir } = require("node:os") as typeof import("node:os");
97
97
  const { join } = require("node:path") as typeof import("node:path");
98
98
  const { __resetPhantomCache } = require("../phantom.ts") as typeof import("../phantom.ts");
@@ -138,11 +138,11 @@ esac
138
138
  }
139
139
  expect(caught).toBeDefined();
140
140
  // The canonical guarantee: the raw value NEVER appears in the error.
141
- expect(caught!.message).not.toContain(SECRET);
141
+ expect(caught?.message).not.toContain(SECRET);
142
142
  // The key name is safe to show — it's the vault slot, not the value.
143
- expect(caught!.message).toContain("LEAKY_KEY");
143
+ expect(caught?.message).toContain("LEAKY_KEY");
144
144
  // And the redaction sentinel should be present.
145
- expect(caught!.message).toContain("<redacted>");
145
+ expect(caught?.message).toContain("<redacted>");
146
146
  } finally {
147
147
  h.cleanup();
148
148
  }
@@ -162,7 +162,7 @@ esac
162
162
  // Reveal's second arg is a key name, not a secret value — it's fine
163
163
  // for it to appear in error output. The point is that `add`'s third arg
164
164
  // is redacted, and reveal's isn't wrongly redacted into uselessness.
165
- expect(caught!.message).toContain("SOME_KEY");
165
+ expect(caught?.message).toContain("SOME_KEY");
166
166
  } finally {
167
167
  h.cleanup();
168
168
  }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { addSecret, exec, revealSecret } from "../phantom.ts";
2
+ import { addSecret, exec } from "../phantom.ts";
3
3
  import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
4
4
 
5
5
  /**
@@ -37,8 +37,8 @@ esac
37
37
  caught = err as Error;
38
38
  }
39
39
  expect(caught).toBeDefined();
40
- expect(caught!.message).not.toContain("super-secret-value-XYZ123");
41
- expect(caught!.message).toContain("<redacted>");
40
+ expect(caught?.message).not.toContain("super-secret-value-XYZ123");
41
+ expect(caught?.message).toContain("<redacted>");
42
42
  });
43
43
  });
44
44
 
@@ -52,19 +52,18 @@ describe("security — fake phantom harness handles hostile secret values", () =
52
52
  test("a secret containing single quotes does not break the harness", async () => {
53
53
  const hostile = `value-with-'-quote-and-\"-and-$VAR-and-\\backslash`;
54
54
  await addSecret("HOSTILE_KEY", hostile);
55
- const revealed = await revealSecret("HOSTILE_KEY");
56
- expect(revealed).toBe(hostile);
57
55
  const vault = await readVault(h.dir);
58
56
  expect(vault.HOSTILE_KEY).toBe(hostile);
59
57
  });
60
58
 
61
- test("a secret containing a newline survives round-trip", async () => {
59
+ test("a secret containing a newline is refused before any Phantom call", async () => {
62
60
  const hostile = "first-line\n-second-line";
63
- await addSecret("MULTILINE_KEY", hostile);
64
- // revealSecret trims trailing whitespace via stdout.trim() — that's fine,
65
- // but round-tripping the full value via the vault file should preserve it.
61
+ await expect(addSecret("MULTILINE_KEY", hostile)).rejects.toMatchObject({
62
+ code: "PHANTOM_SECRET_VALUE_UNSUPPORTED",
63
+ });
66
64
  const vault = await readVault(h.dir);
67
- expect(vault.MULTILINE_KEY).toBe(hostile);
65
+ expect(vault.MULTILINE_KEY).toBeUndefined();
66
+ expect(h.calls()).toEqual([]);
68
67
  });
69
68
 
70
69
  test("a secret that looks like a Python expression is stored literally", async () => {
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import cli from "../../../cli/package.json";
3
+ import mcp from "../../../mcp/package.json";
4
+ import plugin from "../../../plugin/.claude-plugin/plugin.json";
5
+ import core from "../../package.json";
6
+ import { STACK_MANIFEST, type StackManifest } from "../index.ts";
7
+ import fixture from "./fixtures/stack-manifest.v1.json";
8
+
9
+ describe("Stack ecosystem manifest v1", () => {
10
+ test("public export preserves the fixture's JSON wire contract", () => {
11
+ const manifest: StackManifest = STACK_MANIFEST;
12
+ expect(JSON.parse(JSON.stringify(manifest))).toEqual(fixture);
13
+ });
14
+
15
+ test("every distributable matches its source name and version", () => {
16
+ const metadata = { core, cli, mcp, plugin };
17
+ const manifest: StackManifest = STACK_MANIFEST;
18
+ expect(Object.keys(metadata).sort()).toEqual(manifest.components.map(({ id }) => id).sort());
19
+ for (const component of manifest.components) {
20
+ expect(component.name).toBe(metadata[component.id].name);
21
+ expect(component.version).toBe(metadata[component.id].version);
22
+ expect(component.version).toMatch(/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/);
23
+ }
24
+ });
25
+
26
+ test("component IDs are unique", () => {
27
+ const ids = STACK_MANIFEST.components.map(({ id }) => id);
28
+ expect(new Set(ids).size).toBe(ids.length);
29
+ });
30
+
31
+ test("shared metadata cannot be mutated by consumers", () => {
32
+ expect(Object.isFrozen(STACK_MANIFEST)).toBe(true);
33
+ expect(Object.isFrozen(STACK_MANIFEST.components)).toBe(true);
34
+ for (const component of STACK_MANIFEST.components) {
35
+ expect(Object.isFrozen(component)).toBe(true);
36
+ }
37
+ });
38
+ });
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import supabase from "../providers/supabase.ts";
3
2
  import type { ProviderContext } from "../providers/_base.ts";
3
+ import supabase from "../providers/supabase.ts";
4
4
  import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
5
5
 
6
6
  /**
@@ -13,9 +13,11 @@ describe("supabase provider (mocked fetch)", () => {
13
13
  let realFetch: typeof fetch;
14
14
 
15
15
  beforeEach(() => {
16
- h = setupFakePhantom({
17
- STACK_SUPABASE_ACCESS_TOKEN: "sbp_cached_valid_token",
18
- });
16
+ // Provider logic after a token is read; real Phantom 0.7.9 refuses headless reveal.
17
+ h = setupFakePhantom(
18
+ { STACK_SUPABASE_ACCESS_TOKEN: "sbp_cached_valid_token" },
19
+ { legacyHeadlessReveal: true },
20
+ );
19
21
  realFetch = globalThis.fetch;
20
22
  });
21
23
 
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { SWAP_PAIRS, findSwap, suggestSwaps } from "../swap.ts";
3
+
4
+ describe("findSwap", () => {
5
+ it("finds a registered pair", () => {
6
+ const pair = findSwap("clerk", "auth0");
7
+ expect(pair).toBeDefined();
8
+ expect(pair?.from).toBe("clerk");
9
+ expect(pair?.to).toBe("auth0");
10
+ });
11
+
12
+ it("finds the reverse direction", () => {
13
+ const pair = findSwap("auth0", "clerk");
14
+ expect(pair).toBeDefined();
15
+ expect(pair?.from).toBe("auth0");
16
+ expect(pair?.to).toBe("clerk");
17
+ });
18
+
19
+ it("returns undefined for an unknown pair", () => {
20
+ expect(findSwap("clerk", "stripe")).toBeUndefined();
21
+ expect(findSwap("openai", "anthropic")).toBeUndefined();
22
+ });
23
+
24
+ it("finds supabase → neon with DATABASE_URL alias", () => {
25
+ const pair = findSwap("supabase", "neon");
26
+ expect(pair).toBeDefined();
27
+ expect(pair?.aliases).toBeDefined();
28
+ expect(pair?.aliases?.DATABASE_URL).toBe("SUPABASE_URL");
29
+ });
30
+
31
+ it("finds neon → supabase", () => {
32
+ const pair = findSwap("neon", "supabase");
33
+ expect(pair).toBeDefined();
34
+ });
35
+
36
+ it("finds all email provider pairs both ways", () => {
37
+ const emailProviders = ["resend", "sendgrid", "mailgun", "postmark"];
38
+ for (const a of emailProviders) {
39
+ for (const b of emailProviders) {
40
+ if (a === b) continue;
41
+ const pair = findSwap(a, b);
42
+ expect(pair).toBeDefined();
43
+ }
44
+ }
45
+ });
46
+
47
+ it("finds all analytics provider pairs both ways", () => {
48
+ const analytics = ["posthog", "mixpanel", "plausible"];
49
+ for (const a of analytics) {
50
+ for (const b of analytics) {
51
+ if (a === b) continue;
52
+ expect(findSwap(a, b)).toBeDefined();
53
+ }
54
+ }
55
+ });
56
+
57
+ it("finds datadog ↔ grafana", () => {
58
+ expect(findSwap("datadog", "grafana")).toBeDefined();
59
+ expect(findSwap("grafana", "datadog")).toBeDefined();
60
+ });
61
+
62
+ it("finds all deploy provider pairs", () => {
63
+ const deploy = ["vercel", "railway", "fly", "render"];
64
+ for (const a of deploy) {
65
+ for (const b of deploy) {
66
+ if (a === b) continue;
67
+ expect(findSwap(a, b)).toBeDefined();
68
+ }
69
+ }
70
+ });
71
+ });
72
+
73
+ describe("suggestSwaps", () => {
74
+ it("returns auth category peers for clerk", () => {
75
+ const suggestions = suggestSwaps("clerk");
76
+ expect(suggestions).toContain("auth0");
77
+ expect(suggestions).toContain("workos");
78
+ expect(suggestions.length).toBeGreaterThanOrEqual(2);
79
+ });
80
+
81
+ it("returns email peers for resend", () => {
82
+ const suggestions = suggestSwaps("resend");
83
+ expect(suggestions).toContain("sendgrid");
84
+ expect(suggestions).toContain("mailgun");
85
+ expect(suggestions).toContain("postmark");
86
+ });
87
+
88
+ it("returns empty array for a provider with no swaps", () => {
89
+ expect(suggestSwaps("stripe")).toEqual([]);
90
+ expect(suggestSwaps("github")).toEqual([]);
91
+ });
92
+
93
+ it("returns deploy peers for vercel", () => {
94
+ const suggestions = suggestSwaps("vercel");
95
+ expect(suggestions).toContain("railway");
96
+ expect(suggestions).toContain("fly");
97
+ expect(suggestions).toContain("render");
98
+ });
99
+ });
100
+
101
+ describe("SWAP_PAIRS", () => {
102
+ it("contains no duplicate from/to combinations", () => {
103
+ const seen = new Set<string>();
104
+ for (const pair of SWAP_PAIRS) {
105
+ const key = `${pair.from}→${pair.to}`;
106
+ expect(seen.has(key)).toBe(false);
107
+ seen.add(key);
108
+ }
109
+ });
110
+
111
+ it("has at least 30 pairs covering all categories", () => {
112
+ expect(SWAP_PAIRS.length).toBeGreaterThanOrEqual(30);
113
+ });
114
+ });