@ashlr/stack-core 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +217 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +162 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/cloudflare-provider.test.ts +9 -10
  10. package/src/__tests__/completion-ci.test.ts +239 -23
  11. package/src/__tests__/config-own-services.test.ts +183 -0
  12. package/src/__tests__/config-split.test.ts +206 -2
  13. package/src/__tests__/config.test.ts +2 -1
  14. package/src/__tests__/credential-staging.test.ts +659 -0
  15. package/src/__tests__/deps.test.ts +2 -1
  16. package/src/__tests__/detect-source.test.ts +1 -1
  17. package/src/__tests__/github-provider.test.ts +3 -2
  18. package/src/__tests__/helpers.test.ts +24 -4
  19. package/src/__tests__/http.test.ts +2 -2
  20. package/src/__tests__/inference.test.ts +19 -13
  21. package/src/__tests__/interactive-prompt.test.ts +106 -0
  22. package/src/__tests__/mcp-mirror.test.ts +116 -0
  23. package/src/__tests__/neon-provider.test.ts +136 -3
  24. package/src/__tests__/new-providers.test.ts +470 -0
  25. package/src/__tests__/oauth-port.test.ts +84 -0
  26. package/src/__tests__/openai-provider.test.ts +1 -1
  27. package/src/__tests__/phantom-contract.test.ts +177 -0
  28. package/src/__tests__/phantom-harness.test.ts +125 -8
  29. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  30. package/src/__tests__/phantom-wire.test.ts +31 -20
  31. package/src/__tests__/pipeline-integration.test.ts +1 -1
  32. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  33. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  34. package/src/__tests__/pipeline.test.ts +3 -1
  35. package/src/__tests__/pm-detect.test.ts +113 -0
  36. package/src/__tests__/provider-deprovision.test.ts +269 -0
  37. package/src/__tests__/provider-parity.test.ts +46 -0
  38. package/src/__tests__/publish-script.test.ts +659 -0
  39. package/src/__tests__/recipe.test.ts +4 -2
  40. package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
  41. package/src/__tests__/registry.test.ts +11 -2
  42. package/src/__tests__/release-artifacts.test.ts +55 -0
  43. package/src/__tests__/secret-redaction.test.ts +10 -10
  44. package/src/__tests__/security.test.ts +9 -10
  45. package/src/__tests__/supabase-provider.test.ts +6 -4
  46. package/src/__tests__/swap.test.ts +114 -0
  47. package/src/__tests__/telemetry.test.ts +354 -0
  48. package/src/__tests__/template-litellm-gateway.test.ts +41 -0
  49. package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
  50. package/src/__tests__/templates.test.ts +1 -1
  51. package/src/__tests__/turso-provider.test.ts +2 -5
  52. package/src/agent-onboard.ts +346 -0
  53. package/src/ai/catalog-index.ts +68 -10
  54. package/src/ai/circuit-breaker.ts +3 -6
  55. package/src/ai/cost-tracker.ts +6 -15
  56. package/src/ai/inference.ts +19 -11
  57. package/src/ai/phantom-wire.ts +29 -52
  58. package/src/ai/recipe.ts +69 -0
  59. package/src/base-urls.ts +71 -0
  60. package/src/catalog.ts +281 -2
  61. package/src/config.ts +31 -18
  62. package/src/detect-source.ts +36 -8
  63. package/src/detect.ts +13 -5
  64. package/src/http.ts +6 -5
  65. package/src/index.ts +15 -0
  66. package/src/mcp-writer.ts +97 -2
  67. package/src/oauth.ts +85 -53
  68. package/src/phantom.ts +193 -15
  69. package/src/pipeline.ts +177 -68
  70. package/src/pm-detect.ts +56 -0
  71. package/src/providers/_api-key.ts +18 -17
  72. package/src/providers/_base.ts +28 -1
  73. package/src/providers/_credential-stage.ts +237 -0
  74. package/src/providers/_helpers.ts +55 -9
  75. package/src/providers/anthropic.ts +2 -1
  76. package/src/providers/auth0.ts +27 -0
  77. package/src/providers/aws.ts +32 -33
  78. package/src/providers/cloudflare.ts +18 -37
  79. package/src/providers/convex.ts +1 -2
  80. package/src/providers/datadog.ts +29 -0
  81. package/src/providers/digitalocean.ts +30 -0
  82. package/src/providers/gcp.ts +36 -0
  83. package/src/providers/github.ts +37 -35
  84. package/src/providers/grafana.ts +29 -0
  85. package/src/providers/hetzner.ts +29 -0
  86. package/src/providers/index.ts +13 -1
  87. package/src/providers/launchdarkly.ts +33 -0
  88. package/src/providers/mixpanel.ts +37 -0
  89. package/src/providers/neon.ts +50 -26
  90. package/src/providers/openai.ts +2 -1
  91. package/src/providers/plausible.ts +26 -0
  92. package/src/providers/sentry.ts +17 -17
  93. package/src/providers/stripe.ts +1 -2
  94. package/src/providers/supabase.ts +66 -37
  95. package/src/providers/turso.ts +49 -38
  96. package/src/providers/vercel.ts +50 -20
  97. package/src/providers/workos.ts +27 -0
  98. package/src/registry.ts +7 -11
  99. package/src/swap.ts +108 -0
  100. package/src/telemetry.ts +267 -0
  101. package/src/templates.ts +6 -2
@@ -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
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
22
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
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";
@@ -82,41 +82,28 @@ describe("recommend → apply e2e", () => {
82
82
  expect(loaded.id).toBe(recipe.id);
83
83
  expect(loaded.providers.map((p) => p.name)).toEqual(["neon", "openai", "anthropic"]);
84
84
 
85
- // 4) Wire envelopes via the fake Phantom CLI.
85
+ // 4) Check the vault via the fake Phantom CLI. `stack add` (skipped here,
86
+ // it needs OAuth) already stored NEON_API_KEY; the AI keys are missing.
87
+ writeFileSync(harness.vaultPath, JSON.stringify({ NEON_API_KEY: "neon_real" }));
86
88
  const result = await wirePhantomForRecipe(loaded, { cwd: projectDir });
87
89
 
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
- ]),
90
+ expect(result.present).toEqual(["neon:NEON_API_KEY"]);
91
+ expect(result.missing).toEqual(
92
+ expect.arrayContaining(["openai:OPENAI_API_KEY", "anthropic:ANTHROPIC_API_KEY"]),
96
93
  );
97
94
  expect(result.skipped).toEqual([]);
98
95
 
99
96
  // No webhook stubs for these providers — none are in the webhook-capable set.
100
97
  expect(result.webhooks).toEqual([]);
101
98
 
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.
99
+ // 5) Apply only reads names from Phantom; it never writes placeholders
100
+ // (Phantom refuses secret values in argv) and leaves the vault untouched.
101
+ expect(harness.callsTo("add")).toEqual([]);
113
102
  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
- );
103
+ expect(vault).toEqual({ NEON_API_KEY: "neon_real" });
117
104
  });
118
105
 
119
- it("noWire: true performs zero phantom envelope calls and writes no webhook stubs", async () => {
106
+ it("noWire: true performs zero phantom vault calls and writes no webhook stubs", async () => {
120
107
  // Use stripe so we can also prove the webhook stub is skipped — stripe is
121
108
  // in WEBHOOK_PROVIDERS, so it would otherwise drop a stub file.
122
109
  const recipe: Recipe = {
@@ -132,7 +119,7 @@ describe("recommend → apply e2e", () => {
132
119
  cwd: projectDir,
133
120
  noWire: true,
134
121
  });
135
- expect(result).toEqual({ envelopes: [], webhooks: [], skipped: [] });
122
+ expect(result).toEqual({ present: [], missing: [], webhooks: [], skipped: [] });
136
123
 
137
124
  // Zero phantom calls for recipe secrets.
138
125
  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
+ });
@@ -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 () => {
@@ -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
+ });