@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.
- 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 +279 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +208 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +32 -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 +272 -2
- package/src/__tests__/config.test.ts +94 -4
- package/src/__tests__/credential-staging.test.ts +660 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +183 -4
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- 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__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +57 -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__/no-orphan-modules.test.ts +114 -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 +226 -8
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +403 -22
- package/src/__tests__/pipeline-integration.test.ts +33 -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 +704 -0
- package/src/__tests__/recipe.test.ts +27 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/stack-manifest.test.ts +38 -0
- 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 +70 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -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 +73 -58
- 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 +50 -9
- package/src/detect.ts +13 -5
- package/src/errors.ts +6 -0
- package/src/http.ts +6 -5
- package/src/index.ts +16 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +319 -30
- 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/stack-manifest.ts +39 -0
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- 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(
|
|
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 }) //
|
|
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)
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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)
|
|
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.
|
|
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(
|
|
115
|
-
|
|
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
|
|
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({
|
|
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)
|
|
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
|
+
});
|
|
@@ -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
|
-
|
|
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 () => {
|
|
@@ -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
|
-
|
|
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
|
+
});
|