@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,112 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CircuitBreaker } from "../ai/circuit-breaker.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Circuit breaker state-transition tests. These lean on an injectable
|
|
6
|
+
* clock so we can reason deterministically about the rolling window
|
|
7
|
+
* without actually sleeping.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function makeClock(start = 1_000_000) {
|
|
11
|
+
let t = start;
|
|
12
|
+
return {
|
|
13
|
+
now: () => t,
|
|
14
|
+
advance: (ms: number) => {
|
|
15
|
+
t += ms;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("CircuitBreaker", () => {
|
|
21
|
+
test("starts closed and allows requests", () => {
|
|
22
|
+
const cb = new CircuitBreaker();
|
|
23
|
+
expect(cb.getState()).toBe("closed");
|
|
24
|
+
expect(cb.canRequest()).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("opens after threshold consecutive failures inside the window", () => {
|
|
28
|
+
const clock = makeClock();
|
|
29
|
+
const cb = new CircuitBreaker({ threshold: 3, windowMs: 60_000, now: clock.now });
|
|
30
|
+
|
|
31
|
+
cb.recordFailure();
|
|
32
|
+
expect(cb.getState()).toBe("closed");
|
|
33
|
+
cb.recordFailure();
|
|
34
|
+
expect(cb.getState()).toBe("closed");
|
|
35
|
+
cb.recordFailure();
|
|
36
|
+
expect(cb.getState()).toBe("open");
|
|
37
|
+
expect(cb.canRequest()).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("ignores stale failures outside the rolling window", () => {
|
|
41
|
+
const clock = makeClock();
|
|
42
|
+
const cb = new CircuitBreaker({ threshold: 3, windowMs: 60_000, now: clock.now });
|
|
43
|
+
|
|
44
|
+
cb.recordFailure();
|
|
45
|
+
cb.recordFailure();
|
|
46
|
+
// Move past the window; the first two should no longer count.
|
|
47
|
+
clock.advance(61_000);
|
|
48
|
+
cb.recordFailure();
|
|
49
|
+
cb.recordFailure();
|
|
50
|
+
expect(cb.getState()).toBe("closed");
|
|
51
|
+
expect(cb.getFailureCount()).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("transitions open → half-open after resetTimeMs", () => {
|
|
55
|
+
const clock = makeClock();
|
|
56
|
+
const cb = new CircuitBreaker({
|
|
57
|
+
threshold: 2,
|
|
58
|
+
windowMs: 60_000,
|
|
59
|
+
resetTimeMs: 30_000,
|
|
60
|
+
now: clock.now,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
cb.recordFailure();
|
|
64
|
+
cb.recordFailure();
|
|
65
|
+
expect(cb.getState()).toBe("open");
|
|
66
|
+
expect(cb.canRequest()).toBe(false);
|
|
67
|
+
|
|
68
|
+
clock.advance(30_001);
|
|
69
|
+
expect(cb.canRequest()).toBe(true);
|
|
70
|
+
expect(cb.getState()).toBe("half-open");
|
|
71
|
+
// Only one probe at a time.
|
|
72
|
+
expect(cb.canRequest()).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("half-open success closes the circuit; failure re-opens it", () => {
|
|
76
|
+
const clock = makeClock();
|
|
77
|
+
const cb = new CircuitBreaker({
|
|
78
|
+
threshold: 2,
|
|
79
|
+
windowMs: 60_000,
|
|
80
|
+
resetTimeMs: 1_000,
|
|
81
|
+
now: clock.now,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
cb.recordFailure();
|
|
85
|
+
cb.recordFailure();
|
|
86
|
+
clock.advance(1_001);
|
|
87
|
+
expect(cb.canRequest()).toBe(true);
|
|
88
|
+
cb.recordSuccess();
|
|
89
|
+
expect(cb.getState()).toBe("closed");
|
|
90
|
+
expect(cb.canRequest()).toBe(true);
|
|
91
|
+
|
|
92
|
+
// Trip again, probe again, this time the probe fails.
|
|
93
|
+
cb.recordFailure();
|
|
94
|
+
cb.recordFailure();
|
|
95
|
+
clock.advance(1_001);
|
|
96
|
+
expect(cb.canRequest()).toBe(true); // half-open probe
|
|
97
|
+
cb.recordFailure();
|
|
98
|
+
expect(cb.getState()).toBe("open");
|
|
99
|
+
expect(cb.canRequest()).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("success resets the failure count", () => {
|
|
103
|
+
const cb = new CircuitBreaker({ threshold: 3, windowMs: 60_000 });
|
|
104
|
+
cb.recordFailure();
|
|
105
|
+
cb.recordFailure();
|
|
106
|
+
cb.recordSuccess();
|
|
107
|
+
expect(cb.getFailureCount()).toBe(0);
|
|
108
|
+
cb.recordFailure();
|
|
109
|
+
cb.recordFailure();
|
|
110
|
+
expect(cb.getState()).toBe("closed");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `stack clone` URL validation — rejects file://, --option-prefixed strings,
|
|
5
|
+
* path traversal targets, and anything that isn't https/ssh. Mirrors the
|
|
6
|
+
* `isAllowedGitUrl` helper in packages/cli/src/commands/clone.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Duplicate the validator here rather than export it — keeps the CLI package's
|
|
10
|
+
// surface tight. If the clone command's logic diverges from this, a regression
|
|
11
|
+
// here means the real validator is also drifting.
|
|
12
|
+
function isAllowedGitUrl(url: string): boolean {
|
|
13
|
+
if (!url || url.length > 2048) return false;
|
|
14
|
+
if (url.startsWith("-")) return false;
|
|
15
|
+
if (/^file:/i.test(url)) return false;
|
|
16
|
+
if (/^ext::/i.test(url)) return false;
|
|
17
|
+
if (/^(https?:\/\/)/i.test(url)) return true;
|
|
18
|
+
if (/^git@[\w.-]+:[\w./~-]+$/i.test(url)) return true;
|
|
19
|
+
if (/^ssh:\/\//i.test(url)) return true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("git URL allowlist", () => {
|
|
24
|
+
test("accepts canonical forms", () => {
|
|
25
|
+
expect(isAllowedGitUrl("https://github.com/ashlrai/ashlr-stack")).toBe(true);
|
|
26
|
+
expect(isAllowedGitUrl("https://github.com/ashlrai/ashlr-stack.git")).toBe(true);
|
|
27
|
+
expect(isAllowedGitUrl("http://git.internal/org/repo.git")).toBe(true);
|
|
28
|
+
expect(isAllowedGitUrl("git@github.com:ashlrai/ashlr-stack.git")).toBe(true);
|
|
29
|
+
expect(isAllowedGitUrl("ssh://git@github.com/ashlrai/ashlr-stack.git")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("rejects hostile prefixes and schemes", () => {
|
|
33
|
+
expect(isAllowedGitUrl("--upload-pack=/bin/sh")).toBe(false);
|
|
34
|
+
expect(isAllowedGitUrl("-u")).toBe(false);
|
|
35
|
+
expect(isAllowedGitUrl("file:///etc/passwd")).toBe(false);
|
|
36
|
+
expect(isAllowedGitUrl("FILE:///etc")).toBe(false);
|
|
37
|
+
expect(isAllowedGitUrl("ext::sh -c 'touch /tmp/pwned'")).toBe(false);
|
|
38
|
+
expect(isAllowedGitUrl("ftp://git.internal/repo")).toBe(false);
|
|
39
|
+
expect(isAllowedGitUrl("./local-path")).toBe(false);
|
|
40
|
+
expect(isAllowedGitUrl("/absolute/path")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects empty and absurdly long strings", () => {
|
|
44
|
+
expect(isAllowedGitUrl("")).toBe(false);
|
|
45
|
+
expect(isAllowedGitUrl("a".repeat(3000))).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import cloudflare from "../providers/cloudflare.ts";
|
|
3
|
+
import type { ProviderContext } from "../providers/_base.ts";
|
|
4
|
+
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
5
|
+
|
|
6
|
+
describe("cloudflare 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("provision → materialize writes CLOUDFLARE_API_TOKEN + ACCOUNT_ID", async () => {
|
|
21
|
+
const token = "cf-fake-token";
|
|
22
|
+
const accounts = [{ id: "acc-123", name: "Main" }];
|
|
23
|
+
|
|
24
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
25
|
+
const u = String(url);
|
|
26
|
+
if (u.endsWith("/user/tokens/verify")) {
|
|
27
|
+
return new Response(
|
|
28
|
+
JSON.stringify({ result: { id: "tok1", status: "active" } }),
|
|
29
|
+
{ status: 200 },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (u.endsWith("/accounts")) {
|
|
33
|
+
return new Response(JSON.stringify({ result: accounts }), { status: 200 });
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`unexpected ${u}`);
|
|
36
|
+
}) as unknown as typeof fetch;
|
|
37
|
+
|
|
38
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
39
|
+
const auth = { token, identity: { token_id: "tok1" } };
|
|
40
|
+
const resource = await cloudflare.provision(ctx, auth, {});
|
|
41
|
+
expect(resource.id).toBe("acc-123");
|
|
42
|
+
|
|
43
|
+
const materialized = await cloudflare.materialize(ctx, resource, auth);
|
|
44
|
+
expect(materialized.secrets.CLOUDFLARE_API_TOKEN).toBe(token);
|
|
45
|
+
expect(materialized.secrets.CLOUDFLARE_ACCOUNT_ID).toBe("acc-123");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("healthcheck pings /user/tokens/verify with the stored token", async () => {
|
|
49
|
+
const { addSecret } = await import("../phantom.ts");
|
|
50
|
+
await addSecret("CLOUDFLARE_API_TOKEN", "cf-cached");
|
|
51
|
+
|
|
52
|
+
let seenAuth = "";
|
|
53
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
54
|
+
const headers = init?.headers as Record<string, string> | undefined;
|
|
55
|
+
if (headers?.Authorization) seenAuth = headers.Authorization;
|
|
56
|
+
if (String(url).endsWith("/user/tokens/verify")) {
|
|
57
|
+
return new Response(
|
|
58
|
+
JSON.stringify({ result: { id: "tok1", status: "active" } }),
|
|
59
|
+
{ status: 200 },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`unexpected ${url}`);
|
|
63
|
+
}) as unknown as typeof fetch;
|
|
64
|
+
|
|
65
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
66
|
+
const status = await cloudflare.healthcheck!(ctx, {
|
|
67
|
+
provider: "cloudflare",
|
|
68
|
+
secrets: ["CLOUDFLARE_API_TOKEN"],
|
|
69
|
+
created_at: new Date().toISOString(),
|
|
70
|
+
});
|
|
71
|
+
expect(status.kind).toBe("ok");
|
|
72
|
+
expect(seenAuth).toBe("Bearer cf-cached");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const CLI = join(import.meta.dir, "..", "..", "..", "cli", "src", "index.ts");
|
|
8
|
+
|
|
9
|
+
describe("stack completion", () => {
|
|
10
|
+
test("zsh output is a loadable compdef script", () => {
|
|
11
|
+
const res = spawnSync("bun", ["run", CLI, "completion", "zsh"], { encoding: "utf-8" });
|
|
12
|
+
expect(res.status).toBe(0);
|
|
13
|
+
expect(res.stdout).toMatch(/^#compdef stack/);
|
|
14
|
+
expect(res.stdout).toContain("_stack()");
|
|
15
|
+
expect(res.stdout).toContain('"supabase"');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("bash output registers complete -F", () => {
|
|
19
|
+
const res = spawnSync("bun", ["run", CLI, "completion", "bash"], { encoding: "utf-8" });
|
|
20
|
+
expect(res.status).toBe(0);
|
|
21
|
+
expect(res.stdout).toContain("_stack_completion()");
|
|
22
|
+
expect(res.stdout).toContain("complete -F _stack_completion stack");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("fish output registers complete commands", () => {
|
|
26
|
+
const res = spawnSync("bun", ["run", CLI, "completion", "fish"], { encoding: "utf-8" });
|
|
27
|
+
expect(res.status).toBe(0);
|
|
28
|
+
expect(res.stdout).toContain("complete -c stack");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("unknown shell exits non-zero", () => {
|
|
32
|
+
const res = spawnSync("bun", ["run", CLI, "completion", "powershell"], { encoding: "utf-8" });
|
|
33
|
+
expect(res.status).not.toBe(0);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("stack ci init", () => {
|
|
38
|
+
test("writes a valid-looking workflow under .github/workflows/", async () => {
|
|
39
|
+
const cwd = mkdtempSync(join(tmpdir(), "stack-ci-"));
|
|
40
|
+
const registryDir = mkdtempSync(join(tmpdir(), "stack-ci-reg-"));
|
|
41
|
+
|
|
42
|
+
// Seed a minimal .stack.toml so `ci init` is happy.
|
|
43
|
+
const { emptyConfig, writeConfig } = await import("../config.ts");
|
|
44
|
+
const prev = process.env.STACK_REGISTRY_DIR;
|
|
45
|
+
process.env.STACK_REGISTRY_DIR = registryDir;
|
|
46
|
+
await writeConfig(emptyConfig(), cwd);
|
|
47
|
+
|
|
48
|
+
const res = spawnSync("bun", ["run", CLI, "ci", "init"], { encoding: "utf-8", cwd });
|
|
49
|
+
expect(res.status).toBe(0);
|
|
50
|
+
|
|
51
|
+
const workflowPath = join(cwd, ".github", "workflows", "stack-ci.yml");
|
|
52
|
+
expect(existsSync(workflowPath)).toBe(true);
|
|
53
|
+
const content = readFileSync(workflowPath, "utf-8");
|
|
54
|
+
expect(content).toContain("name: Stack doctor");
|
|
55
|
+
expect(content).toContain("stack doctor --json");
|
|
56
|
+
expect(content).toContain("PHANTOM_CLOUD_TOKEN");
|
|
57
|
+
expect(content).toContain("actions/upload-artifact@v4");
|
|
58
|
+
|
|
59
|
+
if (prev === undefined) delete process.env.STACK_REGISTRY_DIR;
|
|
60
|
+
else process.env.STACK_REGISTRY_DIR = prev;
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
CONFIG_FILENAME,
|
|
7
|
+
LOCAL_FILENAME,
|
|
8
|
+
emptyConfig,
|
|
9
|
+
readConfig,
|
|
10
|
+
writeConfig,
|
|
11
|
+
} from "../config.ts";
|
|
12
|
+
|
|
13
|
+
describe("committed + local config split", () => {
|
|
14
|
+
let cwd: string;
|
|
15
|
+
let previousRegistryDir: string | undefined;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
cwd = mkdtempSync(join(tmpdir(), "stack-split-"));
|
|
19
|
+
// Isolate the registry side-effect so tests don't pollute ~/.stack.
|
|
20
|
+
previousRegistryDir = process.env.STACK_REGISTRY_DIR;
|
|
21
|
+
process.env.STACK_REGISTRY_DIR = mkdtempSync(join(tmpdir(), "stack-registry-"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
if (previousRegistryDir === undefined) delete process.env.STACK_REGISTRY_DIR;
|
|
26
|
+
else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("writeConfig leaves .stack.toml free of instance data", async () => {
|
|
30
|
+
const config = emptyConfig("my-template");
|
|
31
|
+
config.services.supabase = {
|
|
32
|
+
provider: "supabase",
|
|
33
|
+
resource_id: "abcd1234",
|
|
34
|
+
region: "us-east-1",
|
|
35
|
+
secrets: ["SUPABASE_URL", "SUPABASE_ANON_KEY"],
|
|
36
|
+
mcp: "supabase",
|
|
37
|
+
meta: { organization_id: "org-1" },
|
|
38
|
+
created_at: "2026-04-17T05:00:00Z",
|
|
39
|
+
created_by: "test",
|
|
40
|
+
};
|
|
41
|
+
await writeConfig(config, cwd);
|
|
42
|
+
|
|
43
|
+
const shape = readFileSync(join(cwd, CONFIG_FILENAME), "utf-8");
|
|
44
|
+
expect(shape).not.toContain("stk_"); // project_id shouldn't leak
|
|
45
|
+
expect(shape).not.toContain("abcd1234"); // resource_id shouldn't leak
|
|
46
|
+
expect(shape).not.toContain("org-1"); // meta shouldn't leak
|
|
47
|
+
expect(shape).not.toContain("2026-04-17T05:00:00Z"); // timestamps shouldn't leak
|
|
48
|
+
expect(shape).toContain("provider"); // shape fields remain
|
|
49
|
+
expect(shape).toContain("SUPABASE_ANON_KEY");
|
|
50
|
+
|
|
51
|
+
const instance = readFileSync(join(cwd, LOCAL_FILENAME), "utf-8");
|
|
52
|
+
expect(instance).toContain(config.stack.project_id);
|
|
53
|
+
expect(instance).toContain("abcd1234");
|
|
54
|
+
expect(instance).toContain("2026-04-17T05:00:00Z");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("readConfig merges the two files back into a single StackConfig", async () => {
|
|
58
|
+
const original = emptyConfig("my-template");
|
|
59
|
+
original.services.supabase = {
|
|
60
|
+
provider: "supabase",
|
|
61
|
+
resource_id: "merge-me",
|
|
62
|
+
secrets: ["FOO"],
|
|
63
|
+
mcp: "supabase",
|
|
64
|
+
created_at: "2026-04-17T05:00:00Z",
|
|
65
|
+
};
|
|
66
|
+
await writeConfig(original, cwd);
|
|
67
|
+
const roundtrip = await readConfig(cwd);
|
|
68
|
+
expect(roundtrip.stack.project_id).toBe(original.stack.project_id);
|
|
69
|
+
expect(roundtrip.services.supabase.resource_id).toBe("merge-me");
|
|
70
|
+
expect(roundtrip.services.supabase.secrets).toEqual(["FOO"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("writeConfig auto-appends .stack.local.toml to .gitignore", async () => {
|
|
74
|
+
await writeConfig(emptyConfig(), cwd);
|
|
75
|
+
const gitignore = readFileSync(join(cwd, ".gitignore"), "utf-8");
|
|
76
|
+
expect(gitignore).toContain(LOCAL_FILENAME);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("writeConfig does not duplicate an existing .gitignore entry", async () => {
|
|
80
|
+
writeFileSync(join(cwd, ".gitignore"), `node_modules\n${LOCAL_FILENAME}\n`);
|
|
81
|
+
await writeConfig(emptyConfig(), cwd);
|
|
82
|
+
const gitignore = readFileSync(join(cwd, ".gitignore"), "utf-8");
|
|
83
|
+
const occurrences = gitignore.split(LOCAL_FILENAME).length - 1;
|
|
84
|
+
expect(occurrences).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("legacy single-file .stack.toml reads back correctly (back-compat)", async () => {
|
|
88
|
+
const legacy = `
|
|
89
|
+
[stack]
|
|
90
|
+
version = "1"
|
|
91
|
+
project_id = "stk_legacy"
|
|
92
|
+
template = "nextjs-supabase-posthog"
|
|
93
|
+
|
|
94
|
+
[services.supabase]
|
|
95
|
+
provider = "supabase"
|
|
96
|
+
resource_id = "legacy-id"
|
|
97
|
+
secrets = ["X"]
|
|
98
|
+
mcp = "supabase"
|
|
99
|
+
created_at = "2026-04-17T00:00:00Z"
|
|
100
|
+
created_by = "legacy"
|
|
101
|
+
|
|
102
|
+
[[environments]]
|
|
103
|
+
name = "dev"
|
|
104
|
+
default = true
|
|
105
|
+
`.trim();
|
|
106
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), legacy);
|
|
107
|
+
// No .stack.local.toml exists — should still merge via legacy path.
|
|
108
|
+
const config = await readConfig(cwd);
|
|
109
|
+
expect(config.stack.project_id).toBe("stk_legacy");
|
|
110
|
+
expect(config.services.supabase.resource_id).toBe("legacy-id");
|
|
111
|
+
expect(config.services.supabase.secrets).toEqual(["X"]);
|
|
112
|
+
expect(existsSync(join(cwd, LOCAL_FILENAME))).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("re-writing a legacy config migrates it to the split format", async () => {
|
|
116
|
+
const legacy = `
|
|
117
|
+
[stack]
|
|
118
|
+
version = "1"
|
|
119
|
+
project_id = "stk_migrate"
|
|
120
|
+
|
|
121
|
+
[services.openai]
|
|
122
|
+
provider = "openai"
|
|
123
|
+
secrets = ["OPENAI_API_KEY"]
|
|
124
|
+
created_at = "2026-04-17T00:00:00Z"
|
|
125
|
+
|
|
126
|
+
[[environments]]
|
|
127
|
+
name = "dev"
|
|
128
|
+
default = true
|
|
129
|
+
`.trim();
|
|
130
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), legacy);
|
|
131
|
+
const config = await readConfig(cwd);
|
|
132
|
+
await writeConfig(config, cwd);
|
|
133
|
+
expect(existsSync(join(cwd, LOCAL_FILENAME))).toBe(true);
|
|
134
|
+
const shape = readFileSync(join(cwd, CONFIG_FILENAME), "utf-8");
|
|
135
|
+
expect(shape).not.toContain("stk_migrate");
|
|
136
|
+
expect(shape).toContain("OPENAI_API_KEY");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
CONFIG_FILENAME,
|
|
7
|
+
emptyConfig,
|
|
8
|
+
hasConfig,
|
|
9
|
+
readConfig,
|
|
10
|
+
resolveConfigPath,
|
|
11
|
+
writeConfig,
|
|
12
|
+
} from "../config.ts";
|
|
13
|
+
|
|
14
|
+
function freshCwd(): string {
|
|
15
|
+
return mkdtempSync(join(tmpdir(), "stack-test-"));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("config", () => {
|
|
19
|
+
let previousRegistryDir: string | undefined;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
previousRegistryDir = process.env.STACK_REGISTRY_DIR;
|
|
23
|
+
process.env.STACK_REGISTRY_DIR = mkdtempSync(join(tmpdir(), "stack-cfg-reg-"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (previousRegistryDir === undefined) delete process.env.STACK_REGISTRY_DIR;
|
|
28
|
+
else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("emptyConfig produces a fresh project id and default dev env", () => {
|
|
32
|
+
const config = emptyConfig();
|
|
33
|
+
expect(config.stack.version).toBe("1");
|
|
34
|
+
expect(config.stack.project_id).toMatch(/^stk_[a-f0-9]{12}$/);
|
|
35
|
+
expect(config.environments).toEqual([{ name: "dev", default: true }]);
|
|
36
|
+
expect(config.services).toEqual({});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("writeConfig → readConfig round-trips", async () => {
|
|
40
|
+
const cwd = freshCwd();
|
|
41
|
+
const original = emptyConfig("nextjs-supabase-posthog");
|
|
42
|
+
original.services.supabase = {
|
|
43
|
+
provider: "supabase",
|
|
44
|
+
resource_id: "abcd1234",
|
|
45
|
+
region: "us-east-1",
|
|
46
|
+
secrets: ["SUPABASE_URL", "SUPABASE_ANON_KEY"],
|
|
47
|
+
mcp: "supabase",
|
|
48
|
+
created_at: "2026-04-17T05:00:00Z",
|
|
49
|
+
created_by: "test",
|
|
50
|
+
};
|
|
51
|
+
await writeConfig(original, cwd);
|
|
52
|
+
|
|
53
|
+
expect(hasConfig(cwd)).toBe(true);
|
|
54
|
+
expect(resolveConfigPath(cwd).endsWith(CONFIG_FILENAME)).toBe(true);
|
|
55
|
+
|
|
56
|
+
const roundTripped = await readConfig(cwd);
|
|
57
|
+
expect(roundTripped.stack.project_id).toBe(original.stack.project_id);
|
|
58
|
+
expect(roundTripped.stack.template).toBe("nextjs-supabase-posthog");
|
|
59
|
+
expect(roundTripped.services.supabase).toEqual(original.services.supabase);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("readConfig throws ConfigNotFoundError when file is missing", async () => {
|
|
63
|
+
const cwd = freshCwd();
|
|
64
|
+
await expect(readConfig(cwd)).rejects.toThrow(/No .stack.toml at/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { emptyConfig, writeConfig } from "../config.ts";
|
|
6
|
+
import { getProvider, listProviderNames } from "../providers/index.ts";
|
|
7
|
+
|
|
8
|
+
describe("provider loading — wave 9 additions", () => {
|
|
9
|
+
test("render + firebase are in the catalog", () => {
|
|
10
|
+
const names = listProviderNames();
|
|
11
|
+
expect(names).toContain("render");
|
|
12
|
+
expect(names).toContain("firebase");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("render loads and exposes healthcheck", async () => {
|
|
16
|
+
const render = await getProvider("render");
|
|
17
|
+
expect(render.name).toBe("render");
|
|
18
|
+
expect(render.category).toBe("deploy");
|
|
19
|
+
expect(typeof render.healthcheck).toBe("function");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("firebase loads and exposes verify-shape logic", async () => {
|
|
23
|
+
const firebase = await getProvider("firebase");
|
|
24
|
+
expect(firebase.name).toBe("firebase");
|
|
25
|
+
expect(firebase.category).toBe("database");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("config carrying 21+ provider entries still round-trips", () => {
|
|
30
|
+
let cwd: string;
|
|
31
|
+
let previousRegistryDir: string | undefined;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
cwd = mkdtempSync(join(tmpdir(), "stack-deps-"));
|
|
35
|
+
previousRegistryDir = process.env.STACK_REGISTRY_DIR;
|
|
36
|
+
process.env.STACK_REGISTRY_DIR = mkdtempSync(join(tmpdir(), "stack-deps-reg-"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (previousRegistryDir === undefined) delete process.env.STACK_REGISTRY_DIR;
|
|
41
|
+
else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("all 21+ providers can coexist in one .stack.toml", async () => {
|
|
45
|
+
const config = emptyConfig("mega");
|
|
46
|
+
for (const name of listProviderNames()) {
|
|
47
|
+
config.services[name] = {
|
|
48
|
+
provider: name,
|
|
49
|
+
secrets: [`${name.toUpperCase()}_SECRET`],
|
|
50
|
+
created_at: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
await writeConfig(config, cwd);
|
|
54
|
+
const { readConfig } = await import("../config.ts");
|
|
55
|
+
const roundtrip = await readConfig(cwd);
|
|
56
|
+
expect(Object.keys(roundtrip.services).length).toBe(listProviderNames().length);
|
|
57
|
+
});
|
|
58
|
+
});
|