@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
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
__resetIndex,
|
|
4
|
-
retrieve,
|
|
5
|
-
retrieveByCategory,
|
|
6
|
-
} from "../ai/catalog-index.ts";
|
|
2
|
+
import { __resetIndex, retrieve, retrieveByCategory } from "../ai/catalog-index.ts";
|
|
7
3
|
|
|
8
4
|
/**
|
|
9
5
|
* Golden retrieval tests. Each query must surface the right provider(s) in
|
|
@@ -63,13 +59,12 @@ describe("catalog-index.retrieve", () => {
|
|
|
63
59
|
});
|
|
64
60
|
|
|
65
61
|
it("composes a B2B SaaS recipe across categories", () => {
|
|
66
|
-
const hits = names(
|
|
67
|
-
"B2B SaaS with auth and payments and AI and error tracking",
|
|
68
|
-
|
|
69
|
-
));
|
|
62
|
+
const hits = names(
|
|
63
|
+
retrieve("B2B SaaS with auth and payments and AI and error tracking", { k: 8 }),
|
|
64
|
+
);
|
|
70
65
|
// Expect at least one provider from each intended category.
|
|
71
|
-
expect(hits).toContain("stripe");
|
|
72
|
-
expect(hits).toContain("sentry");
|
|
66
|
+
expect(hits).toContain("stripe"); // Payments
|
|
67
|
+
expect(hits).toContain("sentry"); // Errors
|
|
73
68
|
// One of the auth providers
|
|
74
69
|
expect(hits.some((n) => ["clerk", "supabase"].includes(n))).toBe(true);
|
|
75
70
|
// One of the AI providers
|
|
@@ -77,12 +72,14 @@ describe("catalog-index.retrieve", () => {
|
|
|
77
72
|
});
|
|
78
73
|
|
|
79
74
|
it("respects category filter", () => {
|
|
80
|
-
const hits = names(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
const hits = names(
|
|
76
|
+
retrieve("edge compute", {
|
|
77
|
+
k: 5,
|
|
78
|
+
categories: ["Deploy"],
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
84
81
|
for (const n of hits) {
|
|
85
|
-
const cat = ["vercel", "railway", "fly", "cloudflare", "render"].includes(n);
|
|
82
|
+
const cat = ["vercel", "railway", "fly", "cloudflare", "render", "modal"].includes(n);
|
|
86
83
|
expect(cat).toBe(true);
|
|
87
84
|
}
|
|
88
85
|
});
|
|
@@ -93,10 +90,7 @@ describe("catalog-index.retrieve", () => {
|
|
|
93
90
|
});
|
|
94
91
|
|
|
95
92
|
it("retrieveByCategory buckets results", () => {
|
|
96
|
-
const grouped = retrieveByCategory(
|
|
97
|
-
"B2B SaaS with auth and payments and AI",
|
|
98
|
-
{ k: 20 },
|
|
99
|
-
);
|
|
93
|
+
const grouped = retrieveByCategory("B2B SaaS with auth and payments and AI", { k: 20 });
|
|
100
94
|
expect(Object.keys(grouped).length).toBeGreaterThan(1);
|
|
101
95
|
expect(grouped.Payments?.[0]?.provider.name).toBe("stripe");
|
|
102
96
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import cloudflare from "../providers/cloudflare.ts";
|
|
3
2
|
import type { ProviderContext } from "../providers/_base.ts";
|
|
3
|
+
import cloudflare from "../providers/cloudflare.ts";
|
|
4
4
|
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
5
5
|
|
|
6
6
|
describe("cloudflare provider (mocked fetch)", () => {
|
|
@@ -8,7 +8,8 @@ describe("cloudflare provider (mocked fetch)", () => {
|
|
|
8
8
|
let realFetch: typeof fetch;
|
|
9
9
|
|
|
10
10
|
beforeEach(() => {
|
|
11
|
-
|
|
11
|
+
// Provider logic after a token is read; real Phantom 0.7.9 refuses headless reveal.
|
|
12
|
+
h = setupFakePhantom({}, { legacyHeadlessReveal: true });
|
|
12
13
|
realFetch = globalThis.fetch;
|
|
13
14
|
});
|
|
14
15
|
|
|
@@ -24,10 +25,9 @@ describe("cloudflare provider (mocked fetch)", () => {
|
|
|
24
25
|
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
25
26
|
const u = String(url);
|
|
26
27
|
if (u.endsWith("/user/tokens/verify")) {
|
|
27
|
-
return new Response(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
);
|
|
28
|
+
return new Response(JSON.stringify({ result: { id: "tok1", status: "active" } }), {
|
|
29
|
+
status: 200,
|
|
30
|
+
});
|
|
31
31
|
}
|
|
32
32
|
if (u.endsWith("/accounts")) {
|
|
33
33
|
return new Response(JSON.stringify({ result: accounts }), { status: 200 });
|
|
@@ -54,10 +54,9 @@ describe("cloudflare provider (mocked fetch)", () => {
|
|
|
54
54
|
const headers = init?.headers as Record<string, string> | undefined;
|
|
55
55
|
if (headers?.Authorization) seenAuth = headers.Authorization;
|
|
56
56
|
if (String(url).endsWith("/user/tokens/verify")) {
|
|
57
|
-
return new Response(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
);
|
|
57
|
+
return new Response(JSON.stringify({ result: { id: "tok1", status: "active" } }), {
|
|
58
|
+
status: 200,
|
|
59
|
+
});
|
|
61
60
|
}
|
|
62
61
|
throw new Error(`unexpected ${url}`);
|
|
63
62
|
}) as unknown as typeof fetch;
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
3
13
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { delimiter, dirname, join } from "node:path";
|
|
6
15
|
|
|
7
16
|
const CLI = join(import.meta.dir, "..", "..", "..", "cli", "src", "index.ts");
|
|
8
17
|
|
|
@@ -34,29 +43,236 @@ describe("stack completion", () => {
|
|
|
34
43
|
});
|
|
35
44
|
});
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const cwd = mkdtempSync(join(tmpdir(), "stack-ci-"));
|
|
40
|
-
const registryDir = mkdtempSync(join(tmpdir(), "stack-ci-reg-"));
|
|
46
|
+
const CI_UNAVAILABLE =
|
|
47
|
+
"CI health workflow unavailable: Phantom Cloud pull requires trusted-terminal approval. No workflow was written.";
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
await writeConfig(emptyConfig(), cwd);
|
|
49
|
+
interface CiFixture {
|
|
50
|
+
cwd: string;
|
|
51
|
+
env: Record<string, string>;
|
|
52
|
+
}
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
async function withCiFixture(
|
|
55
|
+
configured: boolean,
|
|
56
|
+
run: (fixture: CiFixture) => Promise<void>,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const root = mkdtempSync(join(tmpdir(), "stack-ci-refusal-"));
|
|
59
|
+
try {
|
|
60
|
+
for (const dir of ["project", "home", "tmp", "registry", "xdg"]) {
|
|
61
|
+
mkdirSync(join(root, dir));
|
|
62
|
+
}
|
|
63
|
+
const cwd = join(root, "project");
|
|
64
|
+
if (configured) {
|
|
65
|
+
writeFileSync(
|
|
66
|
+
join(cwd, ".stack.toml"),
|
|
67
|
+
'[stack]\nversion = "1"\nproject_id = "synthetic-ci-project"\n',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const env: Record<string, string> = {
|
|
71
|
+
PATH: [dirname(process.execPath), "/usr/bin", "/bin"].join(delimiter),
|
|
72
|
+
HOME: join(root, "home"),
|
|
73
|
+
USERPROFILE: join(root, "home"),
|
|
74
|
+
TMPDIR: join(root, "tmp"),
|
|
75
|
+
TMP: join(root, "tmp"),
|
|
76
|
+
TEMP: join(root, "tmp"),
|
|
77
|
+
XDG_CONFIG_HOME: join(root, "xdg"),
|
|
78
|
+
XDG_CACHE_HOME: join(root, "xdg"),
|
|
79
|
+
XDG_DATA_HOME: join(root, "xdg"),
|
|
80
|
+
STACK_REGISTRY_DIR: join(root, "registry"),
|
|
81
|
+
CI: "true",
|
|
82
|
+
STACK_NO_UPDATE_CHECK: "1",
|
|
83
|
+
STACK_TELEMETRY: "0",
|
|
84
|
+
PHANTOM_CLOUD_TOKEN: "SYNTHETIC-CLOUD-DO-NOT-PRINT",
|
|
85
|
+
PHANTOM_VAULT_PASSPHRASE: "SYNTHETIC-VAULT-DO-NOT-PRINT",
|
|
86
|
+
};
|
|
87
|
+
if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot;
|
|
88
|
+
await run({ cwd, env });
|
|
89
|
+
} finally {
|
|
90
|
+
rmSync(root, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function projectSnapshot(cwd: string): Record<string, string> {
|
|
95
|
+
const snapshot: Record<string, string> = {};
|
|
96
|
+
function visit(dir: string, prefix = ""): void {
|
|
97
|
+
for (const name of readdirSync(dir).sort()) {
|
|
98
|
+
const file = join(dir, name);
|
|
99
|
+
const key = `${prefix}${name}`;
|
|
100
|
+
const stat = lstatSync(file);
|
|
101
|
+
if (stat.isDirectory()) {
|
|
102
|
+
snapshot[key] = `directory:${stat.mode}`;
|
|
103
|
+
visit(file, `${key}/`);
|
|
104
|
+
} else if (stat.isFile()) {
|
|
105
|
+
snapshot[key] = `file:${stat.mode}:${readFileSync(file).toString("hex")}`;
|
|
106
|
+
} else {
|
|
107
|
+
throw new Error("Unexpected CI fixture entry");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
visit(cwd);
|
|
112
|
+
return snapshot;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reuse the existing async fixture transport: Bun 1.2's sync facade can
|
|
116
|
+
// mislabel exit 1 and does not reliably enforce timeout/output bounds.
|
|
117
|
+
function runBoundedCiFixture(
|
|
118
|
+
command: string,
|
|
119
|
+
args: string[],
|
|
120
|
+
cwd: string,
|
|
121
|
+
env: Record<string, string>,
|
|
122
|
+
) {
|
|
123
|
+
return new Promise<{
|
|
124
|
+
status: number | null;
|
|
125
|
+
signal: NodeJS.Signals | null;
|
|
126
|
+
error?: Error;
|
|
127
|
+
stdout: string;
|
|
128
|
+
stderr: string;
|
|
129
|
+
timedOut: boolean;
|
|
130
|
+
outputLimitExceeded: boolean;
|
|
131
|
+
}>((resolve) => {
|
|
132
|
+
let child: ReturnType<typeof spawn>;
|
|
133
|
+
try {
|
|
134
|
+
child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// Bun 1.2 throws launch errors synchronously; newer runtimes emit error.
|
|
137
|
+
resolve({
|
|
138
|
+
status: null,
|
|
139
|
+
signal: null,
|
|
140
|
+
error: error instanceof Error ? error : new Error("fixture launch failed"),
|
|
141
|
+
stdout: "",
|
|
142
|
+
stderr: "",
|
|
143
|
+
timedOut: false,
|
|
144
|
+
outputLimitExceeded: false,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const stdout: Buffer[] = [];
|
|
149
|
+
const stderr: Buffer[] = [];
|
|
150
|
+
let bytes = 0;
|
|
151
|
+
let timedOut = false;
|
|
152
|
+
let outputLimitExceeded = false;
|
|
153
|
+
let error: Error | undefined;
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
timedOut = true;
|
|
156
|
+
child.kill("SIGKILL");
|
|
157
|
+
}, 1500);
|
|
158
|
+
function capture(chunks: Buffer[], chunk: Buffer) {
|
|
159
|
+
if (outputLimitExceeded) return;
|
|
160
|
+
const remaining = 64 * 1024 - bytes;
|
|
161
|
+
const retained = chunk.subarray(0, remaining);
|
|
162
|
+
chunks.push(Buffer.from(retained));
|
|
163
|
+
bytes += retained.length;
|
|
164
|
+
if (chunk.length > remaining) {
|
|
165
|
+
outputLimitExceeded = true;
|
|
166
|
+
child.kill("SIGKILL");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
child.stdout?.on("data", (chunk: Buffer) => capture(stdout, chunk));
|
|
170
|
+
child.stderr?.on("data", (chunk: Buffer) => capture(stderr, chunk));
|
|
171
|
+
child.once("error", (failure) => {
|
|
172
|
+
error = failure;
|
|
173
|
+
if (child.pid) child.kill("SIGKILL");
|
|
174
|
+
});
|
|
175
|
+
child.once("close", (status, signal) => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
resolve({
|
|
178
|
+
status,
|
|
179
|
+
signal,
|
|
180
|
+
error,
|
|
181
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
182
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
183
|
+
timedOut,
|
|
184
|
+
outputLimitExceeded,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function runCi(fixture: CiFixture, args: string[] = []) {
|
|
191
|
+
const result = await runBoundedCiFixture(
|
|
192
|
+
process.execPath,
|
|
193
|
+
["run", CLI, "ci", "init", ...args],
|
|
194
|
+
fixture.cwd,
|
|
195
|
+
fixture.env,
|
|
196
|
+
);
|
|
197
|
+
expect(result.error).toBeUndefined();
|
|
198
|
+
expect(result.timedOut).toBe(false);
|
|
199
|
+
expect(result.outputLimitExceeded).toBe(false);
|
|
200
|
+
expect(result.signal).toBeNull();
|
|
201
|
+
expect(result.status).not.toBeNull();
|
|
202
|
+
const output = result.stdout + result.stderr;
|
|
203
|
+
expect(output).not.toContain("SYNTHETIC-CLOUD-DO-NOT-PRINT");
|
|
204
|
+
expect(output).not.toContain("SYNTHETIC-VAULT-DO-NOT-PRINT");
|
|
205
|
+
return { result, output };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function expectUnavailable(fixture: CiFixture, args: string[] = []): Promise<void> {
|
|
209
|
+
const before = projectSnapshot(fixture.cwd);
|
|
210
|
+
const { result, output } = await runCi(fixture, args);
|
|
211
|
+
expect(result.status).toBe(1);
|
|
212
|
+
expect(output).toContain(CI_UNAVAILABLE);
|
|
213
|
+
expect(output).not.toContain("CI workflow scaffolded.");
|
|
214
|
+
expect(output).not.toContain("PHANTOM_CLOUD_TOKEN");
|
|
215
|
+
expect(output).not.toContain("PHANTOM_VAULT_PASSPHRASE");
|
|
216
|
+
expect(projectSnapshot(fixture.cwd)).toEqual(before);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
describe("stack ci init", async () => {
|
|
220
|
+
test("preserves missing-config refusal without creating workflow parents", async () => {
|
|
221
|
+
await withCiFixture(false, async (fixture) => {
|
|
222
|
+
const before = projectSnapshot(fixture.cwd);
|
|
223
|
+
const { result, output } = await runCi(fixture);
|
|
224
|
+
expect(result.status).toBe(1);
|
|
225
|
+
expect(output).toContain("No .stack.toml found. Run `stack init` first.");
|
|
226
|
+
expect(projectSnapshot(fixture.cwd)).toEqual(before);
|
|
227
|
+
expect(existsSync(join(fixture.cwd, ".github"))).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
50
230
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
231
|
+
test("refuses the unsupported Cloud scaffold before creating either parent", async () => {
|
|
232
|
+
await withCiFixture(true, async (fixture) => {
|
|
233
|
+
await expectUnavailable(fixture);
|
|
234
|
+
expect(existsSync(join(fixture.cwd, ".github"))).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("leaves an existing workflow directory and unrelated file unchanged", async () => {
|
|
239
|
+
await withCiFixture(true, async (fixture) => {
|
|
240
|
+
const dir = join(fixture.cwd, ".github", "workflows");
|
|
241
|
+
mkdirSync(dir, { recursive: true });
|
|
242
|
+
writeFileSync(join(dir, "owned.yml"), "owner sentinel\n");
|
|
243
|
+
await expectUnavailable(fixture);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("preserves the existing-file guard without force", async () => {
|
|
248
|
+
await withCiFixture(true, async (fixture) => {
|
|
249
|
+
const dir = join(fixture.cwd, ".github", "workflows");
|
|
250
|
+
mkdirSync(dir, { recursive: true });
|
|
251
|
+
writeFileSync(join(dir, "stack-ci.yml"), "owner sentinel\n");
|
|
252
|
+
const before = projectSnapshot(fixture.cwd);
|
|
253
|
+
const { result, output } = await runCi(fixture);
|
|
254
|
+
expect(result.status).toBe(1);
|
|
255
|
+
expect(output).toContain("already exists. It was not changed.");
|
|
256
|
+
expect(projectSnapshot(fixture.cwd)).toEqual(before);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("force cannot overwrite an existing workflow with an unsupported scaffold", async () => {
|
|
261
|
+
await withCiFixture(true, async (fixture) => {
|
|
262
|
+
const dir = join(fixture.cwd, ".github", "workflows");
|
|
263
|
+
mkdirSync(dir, { recursive: true });
|
|
264
|
+
writeFileSync(join(dir, "stack-ci.yml"), "owner sentinel\n");
|
|
265
|
+
await expectUnavailable(fixture, ["--force"]);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
58
268
|
|
|
59
|
-
|
|
60
|
-
|
|
269
|
+
test("preserves help and the force flag without creating files", async () => {
|
|
270
|
+
await withCiFixture(false, async (fixture) => {
|
|
271
|
+
const before = projectSnapshot(fixture.cwd);
|
|
272
|
+
const { result, output } = await runCi(fixture, ["--help"]);
|
|
273
|
+
expect(result.status).toBe(0);
|
|
274
|
+
expect(output).toContain("--force");
|
|
275
|
+
expect(projectSnapshot(fixture.cwd)).toEqual(before);
|
|
276
|
+
});
|
|
61
277
|
});
|
|
62
278
|
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
6
|
+
import {
|
|
7
|
+
CONFIG_FILENAME,
|
|
8
|
+
LOCAL_FILENAME,
|
|
9
|
+
type ServiceEntry,
|
|
10
|
+
emptyConfig,
|
|
11
|
+
readConfig,
|
|
12
|
+
writeConfig,
|
|
13
|
+
} from "../config.ts";
|
|
14
|
+
|
|
15
|
+
const SERVICE_NAMES = ["__proto__", "constructor", "toString", "github"];
|
|
16
|
+
const ENTRY: ServiceEntry = {
|
|
17
|
+
provider: "github",
|
|
18
|
+
secrets: ["FIXTURE_TOKEN_SLOT"],
|
|
19
|
+
mcp: "fixture-mcp",
|
|
20
|
+
resource_id: "fixture-resource",
|
|
21
|
+
region: "fixture-region",
|
|
22
|
+
meta: { account: "fixture-local-account" },
|
|
23
|
+
created_at: "2026-10-14T00:00:00Z",
|
|
24
|
+
created_by: "fixture-user",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function expectOwnService(
|
|
28
|
+
services: Record<string, ServiceEntry>,
|
|
29
|
+
name: string,
|
|
30
|
+
entry: ServiceEntry,
|
|
31
|
+
): void {
|
|
32
|
+
expect(Object.getPrototypeOf(services)).toBe(Object.prototype);
|
|
33
|
+
expect(Object.hasOwn(services, name)).toBe(true);
|
|
34
|
+
expect(Object.keys(services)).toEqual([name]);
|
|
35
|
+
expect(services[name]).toEqual(entry);
|
|
36
|
+
expect(JSON.parse(JSON.stringify(services))).toEqual(Object.fromEntries([[name, entry]]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("service names remain own configuration data", () => {
|
|
40
|
+
let root: string;
|
|
41
|
+
let cwd: string;
|
|
42
|
+
let registryDir: string;
|
|
43
|
+
let previousRegistryDir: string | undefined;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
root = mkdtempSync(join(tmpdir(), "stack-own-services-"));
|
|
47
|
+
cwd = join(root, "project");
|
|
48
|
+
registryDir = join(root, "registry");
|
|
49
|
+
mkdirSync(cwd);
|
|
50
|
+
previousRegistryDir = process.env.STACK_REGISTRY_DIR;
|
|
51
|
+
process.env.STACK_REGISTRY_DIR = registryDir;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
if (previousRegistryDir === undefined)
|
|
56
|
+
Reflect.deleteProperty(process.env, "STACK_REGISTRY_DIR");
|
|
57
|
+
else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
|
|
58
|
+
rmSync(root, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test.each(SERVICE_NAMES)("reads split files without losing the %s service", async (name) => {
|
|
62
|
+
writeFileSync(
|
|
63
|
+
join(cwd, CONFIG_FILENAME),
|
|
64
|
+
stringifyToml({
|
|
65
|
+
stack: { version: "1" },
|
|
66
|
+
services: Object.fromEntries([
|
|
67
|
+
[
|
|
68
|
+
name,
|
|
69
|
+
{
|
|
70
|
+
provider: ENTRY.provider,
|
|
71
|
+
secrets: ENTRY.secrets,
|
|
72
|
+
mcp: ENTRY.mcp,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
]),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
writeFileSync(
|
|
79
|
+
join(cwd, LOCAL_FILENAME),
|
|
80
|
+
stringifyToml({
|
|
81
|
+
stack: { project_id: "stk_fixture" },
|
|
82
|
+
services: Object.fromEntries([
|
|
83
|
+
[
|
|
84
|
+
name,
|
|
85
|
+
{
|
|
86
|
+
resource_id: ENTRY.resource_id,
|
|
87
|
+
region: ENTRY.region,
|
|
88
|
+
meta: ENTRY.meta,
|
|
89
|
+
created_at: ENTRY.created_at,
|
|
90
|
+
created_by: ENTRY.created_by,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
]),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const config = await readConfig(cwd);
|
|
98
|
+
|
|
99
|
+
expect(config.stack.project_id).toBe("stk_fixture");
|
|
100
|
+
expectOwnService(config.services, name, ENTRY);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test.each(SERVICE_NAMES)("keeps %s when no local service entry exists", async (name) => {
|
|
104
|
+
writeFileSync(
|
|
105
|
+
join(cwd, CONFIG_FILENAME),
|
|
106
|
+
stringifyToml({
|
|
107
|
+
stack: { version: "1" },
|
|
108
|
+
services: Object.fromEntries([
|
|
109
|
+
[name, { provider: ENTRY.provider, secrets: ENTRY.secrets }],
|
|
110
|
+
]),
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
writeFileSync(
|
|
114
|
+
join(cwd, LOCAL_FILENAME),
|
|
115
|
+
stringifyToml({
|
|
116
|
+
stack: { project_id: "stk_fixture" },
|
|
117
|
+
services: {},
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const config = await readConfig(cwd);
|
|
122
|
+
|
|
123
|
+
expectOwnService(config.services, name, {
|
|
124
|
+
provider: ENTRY.provider,
|
|
125
|
+
secrets: ENTRY.secrets,
|
|
126
|
+
created_at: "1970-01-01T00:00:00Z",
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test.each(SERVICE_NAMES)(
|
|
131
|
+
"writes and registers %s while keeping instance fields local",
|
|
132
|
+
async (name) => {
|
|
133
|
+
const original = emptyConfig("fixture-template");
|
|
134
|
+
original.services = Object.fromEntries([[name, ENTRY]]);
|
|
135
|
+
|
|
136
|
+
await writeConfig(original, cwd);
|
|
137
|
+
|
|
138
|
+
const shapeText = readFileSync(join(cwd, CONFIG_FILENAME), "utf8");
|
|
139
|
+
const shape = parseToml(shapeText);
|
|
140
|
+
const local = parseToml(readFileSync(join(cwd, LOCAL_FILENAME), "utf8"));
|
|
141
|
+
expect(Object.hasOwn(shape.services as object, name)).toBe(true);
|
|
142
|
+
expect(Object.hasOwn(local.services as object, name)).toBe(true);
|
|
143
|
+
expect((shape.services as Record<string, unknown>)[name]).toEqual({
|
|
144
|
+
provider: ENTRY.provider,
|
|
145
|
+
secrets: ENTRY.secrets,
|
|
146
|
+
mcp: ENTRY.mcp,
|
|
147
|
+
});
|
|
148
|
+
expect((local.services as Record<string, unknown>)[name]).toEqual({
|
|
149
|
+
resource_id: ENTRY.resource_id,
|
|
150
|
+
region: ENTRY.region,
|
|
151
|
+
meta: ENTRY.meta,
|
|
152
|
+
created_at: ENTRY.created_at,
|
|
153
|
+
created_by: ENTRY.created_by,
|
|
154
|
+
});
|
|
155
|
+
expect(shapeText).not.toContain(ENTRY.resource_id!);
|
|
156
|
+
expect(shapeText).not.toContain("fixture-local-account");
|
|
157
|
+
expect(shapeText).not.toContain(original.stack.project_id);
|
|
158
|
+
expectOwnService((await readConfig(cwd)).services, name, ENTRY);
|
|
159
|
+
const registry = JSON.parse(readFileSync(join(registryDir, "projects.json"), "utf8"));
|
|
160
|
+
expect(registry.projects[0].services).toEqual([name]);
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
test.each(SERVICE_NAMES)("migrates a legacy %s service without losing it", async (name) => {
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(cwd, CONFIG_FILENAME),
|
|
167
|
+
stringifyToml({
|
|
168
|
+
stack: { version: "1", project_id: "stk_legacy_fixture" },
|
|
169
|
+
services: Object.fromEntries([[name, ENTRY]]),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const legacy = await readConfig(cwd);
|
|
174
|
+
expectOwnService(legacy.services, name, ENTRY);
|
|
175
|
+
expect(existsSync(join(cwd, LOCAL_FILENAME))).toBe(false);
|
|
176
|
+
|
|
177
|
+
await writeConfig(legacy, cwd);
|
|
178
|
+
|
|
179
|
+
const roundTrip = await readConfig(cwd);
|
|
180
|
+
expect(roundTrip.stack.project_id).toBe("stk_legacy_fixture");
|
|
181
|
+
expectOwnService(roundTrip.services, name, ENTRY);
|
|
182
|
+
});
|
|
183
|
+
});
|