@ashlr/stack-core 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +57 -0
- package/src/__tests__/_harness.ts +146 -0
- package/src/__tests__/api-key-providers.test.ts +237 -0
- package/src/__tests__/catalog-index.test.ts +103 -0
- package/src/__tests__/circuit-breaker.test.ts +112 -0
- package/src/__tests__/clone-url-validation.test.ts +47 -0
- package/src/__tests__/cloudflare-provider.test.ts +74 -0
- package/src/__tests__/completion-ci.test.ts +62 -0
- package/src/__tests__/config-split.test.ts +138 -0
- package/src/__tests__/config.test.ts +66 -0
- package/src/__tests__/deps.test.ts +58 -0
- package/src/__tests__/detect-source.test.ts +147 -0
- package/src/__tests__/detect.test.ts +67 -0
- package/src/__tests__/github-provider.test.ts +65 -0
- package/src/__tests__/helpers.test.ts +38 -0
- package/src/__tests__/http.test.ts +235 -0
- package/src/__tests__/import-integration.test.ts +87 -0
- package/src/__tests__/inference.test.ts +326 -0
- package/src/__tests__/neon-provider.test.ts +79 -0
- package/src/__tests__/openai-provider.test.ts +54 -0
- package/src/__tests__/phantom-harness.test.ts +44 -0
- package/src/__tests__/phantom-wire.test.ts +85 -0
- package/src/__tests__/pipeline-integration.test.ts +95 -0
- package/src/__tests__/pipeline-recovery.test.ts +106 -0
- package/src/__tests__/pipeline.test.ts +92 -0
- package/src/__tests__/recipe.test.ts +164 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
- package/src/__tests__/registry.test.ts +78 -0
- package/src/__tests__/secret-redaction.test.ts +170 -0
- package/src/__tests__/security.test.ts +80 -0
- package/src/__tests__/supabase-provider.test.ts +108 -0
- package/src/__tests__/templates.test.ts +26 -0
- package/src/__tests__/turso-provider.test.ts +71 -0
- package/src/ai/catalog-index.ts +256 -0
- package/src/ai/circuit-breaker.ts +116 -0
- package/src/ai/cost-tracker.ts +103 -0
- package/src/ai/inference.ts +470 -0
- package/src/ai/phantom-wire.ts +184 -0
- package/src/ai/recipe.ts +178 -0
- package/src/catalog.ts +430 -0
- package/src/config.ts +293 -0
- package/src/detect-source.ts +344 -0
- package/src/detect.ts +207 -0
- package/src/env.ts +35 -0
- package/src/errors.ts +29 -0
- package/src/http.ts +212 -0
- package/src/index.ts +61 -0
- package/src/mcp-writer.ts +50 -0
- package/src/oauth.ts +184 -0
- package/src/phantom.ts +156 -0
- package/src/pipeline.ts +131 -0
- package/src/providers/_api-key.ts +122 -0
- package/src/providers/_base.ts +96 -0
- package/src/providers/_helpers.ts +81 -0
- package/src/providers/anthropic.ts +27 -0
- package/src/providers/aws.ts +198 -0
- package/src/providers/braintrust.ts +33 -0
- package/src/providers/clerk.ts +23 -0
- package/src/providers/cloudflare.ts +146 -0
- package/src/providers/convex.ts +28 -0
- package/src/providers/deepseek.ts +24 -0
- package/src/providers/firebase.ts +35 -0
- package/src/providers/fly.ts +24 -0
- package/src/providers/github.ts +180 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/linear.ts +46 -0
- package/src/providers/mailgun.ts +26 -0
- package/src/providers/modal.ts +27 -0
- package/src/providers/neon.ts +175 -0
- package/src/providers/openai.ts +24 -0
- package/src/providers/posthog.ts +30 -0
- package/src/providers/postmark.ts +27 -0
- package/src/providers/railway.ts +35 -0
- package/src/providers/render.ts +31 -0
- package/src/providers/replicate.ts +30 -0
- package/src/providers/resend.ts +24 -0
- package/src/providers/sendgrid.ts +24 -0
- package/src/providers/sentry.ts +183 -0
- package/src/providers/stripe.ts +33 -0
- package/src/providers/supabase.ts +309 -0
- package/src/providers/turso.ts +197 -0
- package/src/providers/upstash.ts +32 -0
- package/src/providers/vercel.ts +134 -0
- package/src/providers/xai.ts +24 -0
- package/src/registry.ts +114 -0
- package/src/templates.ts +39 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { scanSource } from "../detect-source.ts";
|
|
6
|
+
|
|
7
|
+
function makeProject(files: Record<string, string>): string {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), "stack-scan-"));
|
|
9
|
+
for (const [path, content] of Object.entries(files)) {
|
|
10
|
+
const full = join(dir, path);
|
|
11
|
+
const parent = join(full, "..");
|
|
12
|
+
mkdirSync(parent, { recursive: true });
|
|
13
|
+
writeFileSync(full, content);
|
|
14
|
+
}
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("source scanner — JS/TS", () => {
|
|
19
|
+
test("detects Supabase + Sentry + OpenAI from a Next.js package.json", async () => {
|
|
20
|
+
const cwd = makeProject({
|
|
21
|
+
"package.json": JSON.stringify({
|
|
22
|
+
dependencies: {
|
|
23
|
+
next: "15.0.0",
|
|
24
|
+
"@supabase/supabase-js": "^2.45.0",
|
|
25
|
+
"@supabase/ssr": "^0.5.0",
|
|
26
|
+
"@sentry/nextjs": "^7.0.0",
|
|
27
|
+
openai: "^4.0.0",
|
|
28
|
+
"posthog-js": "^1.0.0",
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
const hits = await scanSource(cwd);
|
|
33
|
+
const names = hits.map((h) => h.provider).sort();
|
|
34
|
+
expect(names).toContain("supabase");
|
|
35
|
+
expect(names).toContain("sentry");
|
|
36
|
+
expect(names).toContain("openai");
|
|
37
|
+
expect(names).toContain("posthog");
|
|
38
|
+
const supabase = hits.find((h) => h.provider === "supabase");
|
|
39
|
+
expect(supabase?.confidence).toBe("high");
|
|
40
|
+
expect(supabase?.signals.some((s) => s.includes("@supabase/"))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("devDependencies count too", async () => {
|
|
44
|
+
const cwd = makeProject({
|
|
45
|
+
"package.json": JSON.stringify({
|
|
46
|
+
devDependencies: { "@anthropic-ai/sdk": "^0.20.0" },
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
const hits = await scanSource(cwd);
|
|
50
|
+
expect(hits.some((h) => h.provider === "anthropic")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("detects Cloudflare (wrangler), Turso (@libsql), Convex", async () => {
|
|
54
|
+
const cwd = makeProject({
|
|
55
|
+
"package.json": JSON.stringify({
|
|
56
|
+
dependencies: {
|
|
57
|
+
"@libsql/client": "^0.4.0",
|
|
58
|
+
convex: "^1.10.0",
|
|
59
|
+
},
|
|
60
|
+
devDependencies: {
|
|
61
|
+
wrangler: "^3.0.0",
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
const hits = await scanSource(cwd);
|
|
66
|
+
const names = hits.map((h) => h.provider);
|
|
67
|
+
expect(names).toContain("cloudflare");
|
|
68
|
+
expect(names).toContain("turso");
|
|
69
|
+
expect(names).toContain("convex");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("source scanner — config files", () => {
|
|
74
|
+
test("fly.toml + vercel.json + supabase/config.toml all get picked up", async () => {
|
|
75
|
+
const cwd = makeProject({
|
|
76
|
+
"fly.toml": "app = 'myapp'",
|
|
77
|
+
"vercel.json": "{}",
|
|
78
|
+
"supabase/config.toml": "[api]\nport = 54321",
|
|
79
|
+
});
|
|
80
|
+
const hits = await scanSource(cwd);
|
|
81
|
+
const names = hits.map((h) => h.provider);
|
|
82
|
+
expect(names).toContain("fly");
|
|
83
|
+
expect(names).toContain("vercel");
|
|
84
|
+
expect(names).toContain("supabase");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test(".github/workflows/*.yml wires up github", async () => {
|
|
88
|
+
const cwd = makeProject({
|
|
89
|
+
".github/workflows/ci.yml": "name: CI\non: push",
|
|
90
|
+
});
|
|
91
|
+
const hits = await scanSource(cwd);
|
|
92
|
+
expect(hits.some((h) => h.provider === "github")).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("wrangler.toml picks up Cloudflare", async () => {
|
|
96
|
+
const cwd = makeProject({
|
|
97
|
+
"wrangler.toml": "name = \"my-worker\"\nmain = \"src/index.ts\"",
|
|
98
|
+
});
|
|
99
|
+
const hits = await scanSource(cwd);
|
|
100
|
+
expect(hits.some((h) => h.provider === "cloudflare")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("source scanner — .env.example", () => {
|
|
105
|
+
test("secret names in .env.example hint at providers", async () => {
|
|
106
|
+
const cwd = makeProject({
|
|
107
|
+
".env.example": `
|
|
108
|
+
OPENAI_API_KEY=
|
|
109
|
+
SENTRY_AUTH_TOKEN=
|
|
110
|
+
NODE_ENV=development
|
|
111
|
+
`.trim(),
|
|
112
|
+
});
|
|
113
|
+
const hits = await scanSource(cwd);
|
|
114
|
+
const names = hits.map((h) => h.provider);
|
|
115
|
+
expect(names).toContain("openai");
|
|
116
|
+
expect(names).toContain("sentry");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("source scanner — Python + Go", () => {
|
|
121
|
+
test("requirements.txt picks up openai + sentry-sdk", async () => {
|
|
122
|
+
const cwd = makeProject({
|
|
123
|
+
"requirements.txt": "openai==1.0.0\nsentry-sdk>=2.0.0\nflask",
|
|
124
|
+
});
|
|
125
|
+
const hits = await scanSource(cwd);
|
|
126
|
+
const names = hits.map((h) => h.provider);
|
|
127
|
+
expect(names).toContain("openai");
|
|
128
|
+
expect(names).toContain("sentry");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("go.mod picks up supabase + stripe", async () => {
|
|
132
|
+
const cwd = makeProject({
|
|
133
|
+
"go.mod": `module example.com/app
|
|
134
|
+
|
|
135
|
+
go 1.22
|
|
136
|
+
|
|
137
|
+
require (
|
|
138
|
+
\tgithub.com/supabase-community/supabase-go v0.3.0
|
|
139
|
+
\tgithub.com/stripe/stripe-go/v76 v76.0.0
|
|
140
|
+
)`,
|
|
141
|
+
});
|
|
142
|
+
const hits = await scanSource(cwd);
|
|
143
|
+
const names = hits.map((h) => h.provider);
|
|
144
|
+
expect(names).toContain("supabase");
|
|
145
|
+
expect(names).toContain("stripe");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { detectProvider, groupByProvider, parseEnv } from "../detect.ts";
|
|
3
|
+
|
|
4
|
+
describe("provider detection", () => {
|
|
5
|
+
test("canonical secret names map to their provider", () => {
|
|
6
|
+
expect(detectProvider("SUPABASE_URL")).toBe("supabase");
|
|
7
|
+
expect(detectProvider("NEXT_PUBLIC_SUPABASE_URL")).toBe("supabase");
|
|
8
|
+
expect(detectProvider("OPENAI_API_KEY")).toBe("openai");
|
|
9
|
+
expect(detectProvider("ANTHROPIC_API_KEY")).toBe("anthropic");
|
|
10
|
+
expect(detectProvider("SENTRY_AUTH_TOKEN")).toBe("sentry");
|
|
11
|
+
expect(detectProvider("SENTRY_DSN")).toBe("sentry");
|
|
12
|
+
expect(detectProvider("GITHUB_TOKEN")).toBe("github");
|
|
13
|
+
expect(detectProvider("AWS_ACCESS_KEY_ID")).toBe("aws");
|
|
14
|
+
// DATABASE_URL is intentionally unattributed — it's generic Postgres
|
|
15
|
+
// convention, used by Supabase, Railway, Render, PlanetScale, etc.
|
|
16
|
+
expect(detectProvider("DATABASE_URL")).toBeUndefined();
|
|
17
|
+
expect(detectProvider("CLOUDFLARE_API_TOKEN")).toBe("cloudflare");
|
|
18
|
+
expect(detectProvider("CF_ACCOUNT_ID")).toBe("cloudflare");
|
|
19
|
+
expect(detectProvider("TURSO_DATABASE_URL")).toBe("turso");
|
|
20
|
+
expect(detectProvider("CONVEX_DEPLOY_KEY")).toBe("convex");
|
|
21
|
+
expect(detectProvider("NEXT_PUBLIC_CONVEX_URL")).toBe("convex");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("unattributed names return undefined", () => {
|
|
25
|
+
expect(detectProvider("NODE_ENV")).toBeUndefined();
|
|
26
|
+
expect(detectProvider("PORT")).toBeUndefined();
|
|
27
|
+
expect(detectProvider("SOME_RANDOM_VAR")).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("groupByProvider buckets names into providers", () => {
|
|
31
|
+
const result = groupByProvider([
|
|
32
|
+
"SUPABASE_URL",
|
|
33
|
+
"SUPABASE_ANON_KEY",
|
|
34
|
+
"OPENAI_API_KEY",
|
|
35
|
+
"NODE_ENV",
|
|
36
|
+
]);
|
|
37
|
+
expect(result.supabase).toEqual(["SUPABASE_URL", "SUPABASE_ANON_KEY"]);
|
|
38
|
+
expect(result.openai).toEqual(["OPENAI_API_KEY"]);
|
|
39
|
+
expect(result.NODE_ENV).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe(".env parser", () => {
|
|
44
|
+
test("parses KEY=VALUE pairs", () => {
|
|
45
|
+
const pairs = parseEnv(
|
|
46
|
+
`# comment line
|
|
47
|
+
FOO=bar
|
|
48
|
+
BAZ = "quoted value"
|
|
49
|
+
EMPTY=
|
|
50
|
+
# trailing comment
|
|
51
|
+
QUOTED='single'`,
|
|
52
|
+
);
|
|
53
|
+
expect(pairs).toEqual([
|
|
54
|
+
{ key: "FOO", value: "bar" },
|
|
55
|
+
{ key: "BAZ", value: "quoted value" },
|
|
56
|
+
{ key: "EMPTY", value: "" },
|
|
57
|
+
{ key: "QUOTED", value: "single" },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("ignores malformed lines", () => {
|
|
62
|
+
expect(parseEnv("not a key\n:colon_line\nlowercase=ok\nOK=ok")).toEqual([
|
|
63
|
+
{ key: "lowercase", value: "ok" },
|
|
64
|
+
{ key: "OK", value: "ok" },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import github from "../providers/github.ts";
|
|
3
|
+
import type { ProviderContext } from "../providers/_base.ts";
|
|
4
|
+
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
5
|
+
|
|
6
|
+
describe("github provider (mocked fetch)", () => {
|
|
7
|
+
let h: Harness;
|
|
8
|
+
let realFetch: typeof fetch;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
h = setupFakePhantom();
|
|
12
|
+
realFetch = globalThis.fetch;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
globalThis.fetch = realFetch;
|
|
17
|
+
h.cleanup();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("materialize writes GITHUB_TOKEN + wires the official MCP server", async () => {
|
|
21
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
22
|
+
const auth = { token: "ghp_fake", identity: { login: "mason" } };
|
|
23
|
+
const resource = { id: "mason", displayName: "@mason" };
|
|
24
|
+
const materialized = await github.materialize(ctx, resource, auth);
|
|
25
|
+
expect(materialized.secrets.GITHUB_TOKEN).toBe("ghp_fake");
|
|
26
|
+
expect(materialized.mcp?.name).toBe("github");
|
|
27
|
+
expect(materialized.mcp?.command).toBe("npx");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("healthcheck accepts a cached token that validates via /user", async () => {
|
|
31
|
+
const { addSecret } = await import("../phantom.ts");
|
|
32
|
+
await addSecret("GITHUB_TOKEN", "ghp_cached");
|
|
33
|
+
|
|
34
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
35
|
+
if (String(url).endsWith("/user")) {
|
|
36
|
+
return new Response(JSON.stringify({ login: "mason", id: 42 }), { status: 200 });
|
|
37
|
+
}
|
|
38
|
+
return new Response("nope", { status: 404 });
|
|
39
|
+
}) as unknown as typeof fetch;
|
|
40
|
+
|
|
41
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
42
|
+
const status = await github.healthcheck!(ctx, {
|
|
43
|
+
provider: "github",
|
|
44
|
+
secrets: ["GITHUB_TOKEN"],
|
|
45
|
+
created_at: new Date().toISOString(),
|
|
46
|
+
});
|
|
47
|
+
expect(status.kind).toBe("ok");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("healthcheck reports error when the token is rejected", async () => {
|
|
51
|
+
const { addSecret } = await import("../phantom.ts");
|
|
52
|
+
await addSecret("GITHUB_TOKEN", "ghp_bad");
|
|
53
|
+
|
|
54
|
+
globalThis.fetch = (async () =>
|
|
55
|
+
new Response("unauthorized", { status: 401 })) as unknown as typeof fetch;
|
|
56
|
+
|
|
57
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
58
|
+
const status = await github.healthcheck!(ctx, {
|
|
59
|
+
provider: "github",
|
|
60
|
+
secrets: ["GITHUB_TOKEN"],
|
|
61
|
+
created_at: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
expect(status.kind).toBe("error");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { addSecret } from "../phantom.ts";
|
|
3
|
+
import { bearerJsonHeaders, tryRevealSecret } from "../providers/_helpers.ts";
|
|
4
|
+
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `_helpers` module backs every provider's PAT/API-key flow. Regressions
|
|
8
|
+
* here would silently corrupt secret reads across the catalog.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
describe("provider helpers", () => {
|
|
12
|
+
let h: Harness;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
h = setupFakePhantom({ PRESENT_KEY: "hello-world" });
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => h.cleanup());
|
|
17
|
+
|
|
18
|
+
test("tryRevealSecret returns the value when present", async () => {
|
|
19
|
+
expect(await tryRevealSecret("PRESENT_KEY")).toBe("hello-world");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("tryRevealSecret returns undefined for an empty value", async () => {
|
|
23
|
+
await addSecret("EMPTY_KEY", "");
|
|
24
|
+
expect(await tryRevealSecret("EMPTY_KEY")).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("tryRevealSecret returns undefined rather than throwing when phantom fails", async () => {
|
|
28
|
+
// Never-stored key — phantom reveal returns an empty string, helper returns undefined.
|
|
29
|
+
expect(await tryRevealSecret("NEVER_STORED_KEY")).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("bearerJsonHeaders returns the canonical shape", () => {
|
|
33
|
+
const headers = bearerJsonHeaders("my-token") as Record<string, string>;
|
|
34
|
+
expect(headers.Authorization).toBe("Bearer my-token");
|
|
35
|
+
expect(headers["content-type"]).toBe("application/json");
|
|
36
|
+
expect(headers.Accept).toBe("application/json");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { fetchWithRetry, parseRetryAfter } from "../http.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `fetchWithRetry` is the single retry loop the rest of core leans on. If it
|
|
6
|
+
* regressed (retrying POSTs, ignoring Retry-After, infinite loop on 5xx) we'd
|
|
7
|
+
* either over-charge provider APIs or burn through rate-limit budgets. These
|
|
8
|
+
* tests exercise each of the contract guarantees.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
describe("fetchWithRetry", () => {
|
|
12
|
+
let realFetch: typeof fetch;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
realFetch = globalThis.fetch;
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
globalThis.fetch = realFetch;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("happy path — returns the first successful response, no retry", async () => {
|
|
21
|
+
let calls = 0;
|
|
22
|
+
globalThis.fetch = (async () => {
|
|
23
|
+
calls++;
|
|
24
|
+
return new Response("ok", { status: 200 });
|
|
25
|
+
}) as unknown as typeof fetch;
|
|
26
|
+
|
|
27
|
+
const res = await fetchWithRetry("https://example.com/", undefined, {
|
|
28
|
+
baseDelayMs: 1,
|
|
29
|
+
maxDelayMs: 2,
|
|
30
|
+
});
|
|
31
|
+
expect(res.status).toBe(200);
|
|
32
|
+
expect(calls).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("429 triggers a retry, then succeeds", async () => {
|
|
36
|
+
let calls = 0;
|
|
37
|
+
globalThis.fetch = (async () => {
|
|
38
|
+
calls++;
|
|
39
|
+
if (calls === 1) return new Response("rate limited", { status: 429 });
|
|
40
|
+
return new Response("ok", { status: 200 });
|
|
41
|
+
}) as unknown as typeof fetch;
|
|
42
|
+
|
|
43
|
+
const res = await fetchWithRetry("https://example.com/", undefined, {
|
|
44
|
+
baseDelayMs: 1,
|
|
45
|
+
maxDelayMs: 2,
|
|
46
|
+
});
|
|
47
|
+
expect(res.status).toBe(200);
|
|
48
|
+
expect(calls).toBe(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("500 retries `retries` times then returns the final failing response", async () => {
|
|
52
|
+
let calls = 0;
|
|
53
|
+
globalThis.fetch = (async () => {
|
|
54
|
+
calls++;
|
|
55
|
+
return new Response("server error", { status: 500 });
|
|
56
|
+
}) as unknown as typeof fetch;
|
|
57
|
+
|
|
58
|
+
const res = await fetchWithRetry("https://example.com/", undefined, {
|
|
59
|
+
retries: 2,
|
|
60
|
+
baseDelayMs: 1,
|
|
61
|
+
maxDelayMs: 2,
|
|
62
|
+
});
|
|
63
|
+
expect(res.status).toBe(500);
|
|
64
|
+
// 1 initial + 2 retries = 3 total
|
|
65
|
+
expect(calls).toBe(3);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("honors Retry-After header (integer seconds form)", async () => {
|
|
69
|
+
let calls = 0;
|
|
70
|
+
const timestamps: number[] = [];
|
|
71
|
+
globalThis.fetch = (async () => {
|
|
72
|
+
calls++;
|
|
73
|
+
timestamps.push(Date.now());
|
|
74
|
+
if (calls === 1) {
|
|
75
|
+
return new Response("slow down", {
|
|
76
|
+
status: 429,
|
|
77
|
+
headers: { "retry-after": "1" },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return new Response("ok", { status: 200 });
|
|
81
|
+
}) as unknown as typeof fetch;
|
|
82
|
+
|
|
83
|
+
const start = Date.now();
|
|
84
|
+
const res = await fetchWithRetry("https://example.com/", undefined, {
|
|
85
|
+
baseDelayMs: 10,
|
|
86
|
+
// Cap is high enough to let the 1-second Retry-After pass through.
|
|
87
|
+
maxDelayMs: 5000,
|
|
88
|
+
});
|
|
89
|
+
const elapsed = Date.now() - start;
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
// Server said to wait 1 second. We should have waited roughly that — at
|
|
92
|
+
// minimum significantly longer than the 10ms baseDelayMs we configured.
|
|
93
|
+
expect(elapsed).toBeGreaterThanOrEqual(900);
|
|
94
|
+
expect(calls).toBe(2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("POST does NOT retry by default (non-idempotent)", async () => {
|
|
98
|
+
let calls = 0;
|
|
99
|
+
globalThis.fetch = (async () => {
|
|
100
|
+
calls++;
|
|
101
|
+
return new Response("server error", { status: 500 });
|
|
102
|
+
}) as unknown as typeof fetch;
|
|
103
|
+
|
|
104
|
+
const res = await fetchWithRetry(
|
|
105
|
+
"https://example.com/charge",
|
|
106
|
+
{ method: "POST", body: "{}" },
|
|
107
|
+
{ retries: 3, baseDelayMs: 1, maxDelayMs: 2 },
|
|
108
|
+
);
|
|
109
|
+
// Got back the 500 on the first try — never retried.
|
|
110
|
+
expect(res.status).toBe(500);
|
|
111
|
+
expect(calls).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("idempotent: true overrides method-based default, letting a POST retry", async () => {
|
|
115
|
+
let calls = 0;
|
|
116
|
+
globalThis.fetch = (async () => {
|
|
117
|
+
calls++;
|
|
118
|
+
if (calls === 1) return new Response("nope", { status: 503 });
|
|
119
|
+
return new Response("ok", { status: 200 });
|
|
120
|
+
}) as unknown as typeof fetch;
|
|
121
|
+
|
|
122
|
+
const res = await fetchWithRetry(
|
|
123
|
+
"https://example.com/graphql",
|
|
124
|
+
{ method: "POST", body: '{"query":"{ me { id } }"}' },
|
|
125
|
+
{ idempotent: true, baseDelayMs: 1, maxDelayMs: 2 },
|
|
126
|
+
);
|
|
127
|
+
expect(res.status).toBe(200);
|
|
128
|
+
expect(calls).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("network errors retry for idempotent requests", async () => {
|
|
132
|
+
let calls = 0;
|
|
133
|
+
globalThis.fetch = (async () => {
|
|
134
|
+
calls++;
|
|
135
|
+
if (calls === 1) throw new TypeError("fetch failed");
|
|
136
|
+
return new Response("ok", { status: 200 });
|
|
137
|
+
}) as unknown as typeof fetch;
|
|
138
|
+
|
|
139
|
+
const res = await fetchWithRetry("https://example.com/", undefined, {
|
|
140
|
+
baseDelayMs: 1,
|
|
141
|
+
maxDelayMs: 2,
|
|
142
|
+
});
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
expect(calls).toBe(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("network errors on non-idempotent requests bubble up immediately", async () => {
|
|
148
|
+
let calls = 0;
|
|
149
|
+
globalThis.fetch = (async () => {
|
|
150
|
+
calls++;
|
|
151
|
+
throw new TypeError("fetch failed");
|
|
152
|
+
}) as unknown as typeof fetch;
|
|
153
|
+
|
|
154
|
+
await expect(
|
|
155
|
+
fetchWithRetry(
|
|
156
|
+
"https://example.com/charge",
|
|
157
|
+
{ method: "POST", body: "{}" },
|
|
158
|
+
{ retries: 3, baseDelayMs: 1, maxDelayMs: 2 },
|
|
159
|
+
),
|
|
160
|
+
).rejects.toThrow(/fetch failed/);
|
|
161
|
+
expect(calls).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("custom retryOn is respected", async () => {
|
|
165
|
+
let calls = 0;
|
|
166
|
+
globalThis.fetch = (async () => {
|
|
167
|
+
calls++;
|
|
168
|
+
return new Response("teapot", { status: 418 });
|
|
169
|
+
}) as unknown as typeof fetch;
|
|
170
|
+
|
|
171
|
+
const res = await fetchWithRetry("https://example.com/", undefined, {
|
|
172
|
+
retries: 2,
|
|
173
|
+
baseDelayMs: 1,
|
|
174
|
+
maxDelayMs: 2,
|
|
175
|
+
retryOn: (r) => r.status === 418,
|
|
176
|
+
});
|
|
177
|
+
expect(res.status).toBe(418);
|
|
178
|
+
expect(calls).toBe(3); // initial + 2 retries
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("respects an external AbortSignal and stops mid-retry", async () => {
|
|
182
|
+
let calls = 0;
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
globalThis.fetch = (async () => {
|
|
185
|
+
calls++;
|
|
186
|
+
// Abort once we've made it past the initial try — the subsequent sleep
|
|
187
|
+
// should be interrupted, not slept through.
|
|
188
|
+
setTimeout(() => controller.abort(), 5);
|
|
189
|
+
return new Response("slow", { status: 503 });
|
|
190
|
+
}) as unknown as typeof fetch;
|
|
191
|
+
|
|
192
|
+
await expect(
|
|
193
|
+
fetchWithRetry("https://example.com/", undefined, {
|
|
194
|
+
retries: 5,
|
|
195
|
+
baseDelayMs: 1000,
|
|
196
|
+
maxDelayMs: 2000,
|
|
197
|
+
signal: controller.signal,
|
|
198
|
+
}),
|
|
199
|
+
).rejects.toThrow();
|
|
200
|
+
// At least one call happened; we aborted before burning through all 5 retries.
|
|
201
|
+
expect(calls).toBeGreaterThanOrEqual(1);
|
|
202
|
+
expect(calls).toBeLessThan(6);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("parseRetryAfter", () => {
|
|
207
|
+
test("parses integer seconds", () => {
|
|
208
|
+
expect(parseRetryAfter("5")).toBe(5000);
|
|
209
|
+
expect(parseRetryAfter("0")).toBe(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("returns undefined for empty / null", () => {
|
|
213
|
+
expect(parseRetryAfter(null)).toBeUndefined();
|
|
214
|
+
expect(parseRetryAfter("")).toBeUndefined();
|
|
215
|
+
expect(parseRetryAfter(" ")).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("parses HTTP-date form", () => {
|
|
219
|
+
const future = new Date(Date.now() + 10_000).toUTCString();
|
|
220
|
+
const parsed = parseRetryAfter(future);
|
|
221
|
+
expect(parsed).toBeDefined();
|
|
222
|
+
// Allow drift from test overhead; we just need it to be in the right ballpark.
|
|
223
|
+
expect(parsed!).toBeGreaterThan(5_000);
|
|
224
|
+
expect(parsed!).toBeLessThanOrEqual(12_000);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("HTTP-date in the past clamps to 0", () => {
|
|
228
|
+
const past = new Date(Date.now() - 10_000).toUTCString();
|
|
229
|
+
expect(parseRetryAfter(past)).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("garbage input falls through to undefined", () => {
|
|
233
|
+
expect(parseRetryAfter("not-a-date-or-number")).toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
addSecret,
|
|
7
|
+
emptyConfig,
|
|
8
|
+
groupByProvider,
|
|
9
|
+
listSecrets,
|
|
10
|
+
parseEnv,
|
|
11
|
+
readConfig,
|
|
12
|
+
toServiceEntry,
|
|
13
|
+
writeConfig,
|
|
14
|
+
} from "../index.ts";
|
|
15
|
+
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Exercises the logic behind `stack import`: parse .env, group by provider,
|
|
19
|
+
* write each secret to the fake vault, and emit .stack.toml service entries.
|
|
20
|
+
*/
|
|
21
|
+
describe("import pipeline", () => {
|
|
22
|
+
let h: Harness;
|
|
23
|
+
let cwd: string;
|
|
24
|
+
let originalCwd: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
h = setupFakePhantom();
|
|
28
|
+
cwd = mkdtempSync(join(tmpdir(), "stack-import-"));
|
|
29
|
+
originalCwd = process.cwd();
|
|
30
|
+
process.chdir(cwd);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
process.chdir(originalCwd);
|
|
35
|
+
h.cleanup();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("typical Next.js .env gets parsed, stored, and mapped to services", async () => {
|
|
39
|
+
const envContent = `
|
|
40
|
+
# mixed real-world contents
|
|
41
|
+
NODE_ENV=development
|
|
42
|
+
NEXT_PUBLIC_SUPABASE_URL=https://abcd.supabase.co
|
|
43
|
+
SUPABASE_ANON_KEY=anon-value
|
|
44
|
+
SUPABASE_SERVICE_ROLE_KEY=service-value
|
|
45
|
+
OPENAI_API_KEY=sk-fake
|
|
46
|
+
SENTRY_DSN=https://abc@sentry.io/123
|
|
47
|
+
SENTRY_AUTH_TOKEN=sntryu_fake
|
|
48
|
+
PORT=3000
|
|
49
|
+
`.trim();
|
|
50
|
+
writeFileSync(join(cwd, ".env"), envContent);
|
|
51
|
+
|
|
52
|
+
const parsed = parseEnv(envContent);
|
|
53
|
+
expect(parsed.map((p) => p.key)).toContain("OPENAI_API_KEY");
|
|
54
|
+
expect(parsed.map((p) => p.key)).toContain("NODE_ENV");
|
|
55
|
+
|
|
56
|
+
const grouped = groupByProvider(parsed.map((p) => p.key));
|
|
57
|
+
expect(grouped.supabase).toContain("SUPABASE_ANON_KEY");
|
|
58
|
+
expect(grouped.supabase).toContain("NEXT_PUBLIC_SUPABASE_URL");
|
|
59
|
+
expect(grouped.openai).toEqual(["OPENAI_API_KEY"]);
|
|
60
|
+
expect(grouped.sentry).toContain("SENTRY_AUTH_TOKEN");
|
|
61
|
+
expect(grouped.NODE_ENV).toBeUndefined();
|
|
62
|
+
expect(grouped.PORT).toBeUndefined();
|
|
63
|
+
|
|
64
|
+
// Store each secret in the fake vault.
|
|
65
|
+
for (const { key, value } of parsed) {
|
|
66
|
+
if (value.length > 0) await addSecret(key, value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Verify vault state.
|
|
70
|
+
const vault = await readVault(h.dir);
|
|
71
|
+
expect(vault.OPENAI_API_KEY).toBe("sk-fake");
|
|
72
|
+
expect(vault.NODE_ENV).toBe("development");
|
|
73
|
+
expect((await listSecrets()).length).toBeGreaterThanOrEqual(8);
|
|
74
|
+
|
|
75
|
+
// Build a .stack.toml from the detected groups.
|
|
76
|
+
const config = emptyConfig("import-test");
|
|
77
|
+
for (const provider of Object.keys(grouped)) {
|
|
78
|
+
config.services[provider] = toServiceEntry(provider, grouped[provider]);
|
|
79
|
+
}
|
|
80
|
+
await writeConfig(config, cwd);
|
|
81
|
+
|
|
82
|
+
const roundTripped = await readConfig(cwd);
|
|
83
|
+
expect(roundTripped.services.supabase).toBeDefined();
|
|
84
|
+
expect(roundTripped.services.openai).toBeDefined();
|
|
85
|
+
expect(roundTripped.services.sentry.mcp).toBe("sentry");
|
|
86
|
+
});
|
|
87
|
+
});
|