@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.
- package/LICENSE +1 -1
- package/README.md +26 -15
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +67 -7
- package/src/__tests__/agent-onboard.test.ts +217 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +162 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/cloudflare-provider.test.ts +9 -10
- package/src/__tests__/completion-ci.test.ts +239 -23
- package/src/__tests__/config-own-services.test.ts +183 -0
- package/src/__tests__/config-split.test.ts +206 -2
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/credential-staging.test.ts +659 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +1 -1
- package/src/__tests__/github-provider.test.ts +3 -2
- package/src/__tests__/helpers.test.ts +24 -4
- package/src/__tests__/http.test.ts +2 -2
- package/src/__tests__/inference.test.ts +19 -13
- package/src/__tests__/interactive-prompt.test.ts +106 -0
- package/src/__tests__/mcp-mirror.test.ts +116 -0
- package/src/__tests__/neon-provider.test.ts +136 -3
- package/src/__tests__/new-providers.test.ts +470 -0
- package/src/__tests__/oauth-port.test.ts +84 -0
- package/src/__tests__/openai-provider.test.ts +1 -1
- package/src/__tests__/phantom-contract.test.ts +177 -0
- package/src/__tests__/phantom-harness.test.ts +125 -8
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +31 -20
- package/src/__tests__/pipeline-integration.test.ts +1 -1
- package/src/__tests__/pipeline-recovery.test.ts +29 -28
- package/src/__tests__/pipeline-rollback.test.ts +509 -0
- package/src/__tests__/pipeline.test.ts +3 -1
- package/src/__tests__/pm-detect.test.ts +113 -0
- package/src/__tests__/provider-deprovision.test.ts +269 -0
- package/src/__tests__/provider-parity.test.ts +46 -0
- package/src/__tests__/publish-script.test.ts +659 -0
- package/src/__tests__/recipe.test.ts +4 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/supabase-provider.test.ts +6 -4
- package/src/__tests__/swap.test.ts +114 -0
- package/src/__tests__/telemetry.test.ts +354 -0
- package/src/__tests__/template-litellm-gateway.test.ts +41 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
- package/src/__tests__/templates.test.ts +1 -1
- package/src/__tests__/turso-provider.test.ts +2 -5
- package/src/agent-onboard.ts +346 -0
- package/src/ai/catalog-index.ts +68 -10
- package/src/ai/circuit-breaker.ts +3 -6
- package/src/ai/cost-tracker.ts +6 -15
- package/src/ai/inference.ts +19 -11
- package/src/ai/phantom-wire.ts +29 -52
- package/src/ai/recipe.ts +69 -0
- package/src/base-urls.ts +71 -0
- package/src/catalog.ts +281 -2
- package/src/config.ts +31 -18
- package/src/detect-source.ts +36 -8
- package/src/detect.ts +13 -5
- package/src/http.ts +6 -5
- package/src/index.ts +15 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom.ts +193 -15
- package/src/pipeline.ts +177 -68
- package/src/pm-detect.ts +56 -0
- package/src/providers/_api-key.ts +18 -17
- package/src/providers/_base.ts +28 -1
- package/src/providers/_credential-stage.ts +237 -0
- package/src/providers/_helpers.ts +55 -9
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/auth0.ts +27 -0
- package/src/providers/aws.ts +32 -33
- package/src/providers/cloudflare.ts +18 -37
- package/src/providers/convex.ts +1 -2
- package/src/providers/datadog.ts +29 -0
- package/src/providers/digitalocean.ts +30 -0
- package/src/providers/gcp.ts +36 -0
- package/src/providers/github.ts +37 -35
- package/src/providers/grafana.ts +29 -0
- package/src/providers/hetzner.ts +29 -0
- package/src/providers/index.ts +13 -1
- package/src/providers/launchdarkly.ts +33 -0
- package/src/providers/mixpanel.ts +37 -0
- package/src/providers/neon.ts +50 -26
- package/src/providers/openai.ts +2 -1
- package/src/providers/plausible.ts +26 -0
- package/src/providers/sentry.ts +17 -17
- package/src/providers/stripe.ts +1 -2
- package/src/providers/supabase.ts +66 -37
- package/src/providers/turso.ts +49 -38
- package/src/providers/vercel.ts +50 -20
- package/src/providers/workos.ts +27 -0
- package/src/registry.ts +7 -11
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- 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 }) //
|
|
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)
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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)
|
|
103
|
-
//
|
|
104
|
-
|
|
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(
|
|
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
|
|
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({
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
143
|
+
expect(caught?.message).toContain("LEAKY_KEY");
|
|
144
144
|
// And the redaction sentinel should be present.
|
|
145
|
-
expect(caught
|
|
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
|
|
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
|
|
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
|
|
41
|
-
expect(caught
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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).
|
|
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
|
-
|
|
17
|
-
|
|
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
|
+
});
|