@ashlr/stack-core 0.2.2 → 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/package.json +1 -1
- package/src/__tests__/agent-onboard.test.ts +62 -0
- package/src/__tests__/base-urls.test.ts +48 -2
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +23 -0
- package/src/__tests__/config-split.test.ts +66 -0
- package/src/__tests__/config.test.ts +92 -3
- package/src/__tests__/credential-staging.test.ts +3 -2
- package/src/__tests__/detect-source.test.ts +182 -3
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +38 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/phantom-harness.test.ts +102 -1
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-wire.test.ts +372 -2
- package/src/__tests__/pipeline-integration.test.ts +32 -0
- package/src/__tests__/publish-script.test.ts +63 -18
- package/src/__tests__/recipe.test.ts +23 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +16 -1
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/template-litellm-gateway.test.ts +29 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +14 -0
- package/src/ai/phantom-wire.ts +44 -6
- package/src/detect-source.ts +14 -1
- package/src/errors.ts +6 -0
- package/src/index.ts +1 -0
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +144 -33
- package/src/stack-manifest.ts +39 -0
package/package.json
CHANGED
|
@@ -215,3 +215,65 @@ describe("applyOnboarding and undoOnboarding", () => {
|
|
|
215
215
|
expect(readFileSync(configPathFor("gemini", home), "utf-8")).not.toMatch(/key|token|secret/i);
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
it.each(["null", "[]", "42", '{"mcpServers":null}', '{"mcpServers":42}', "{broken"])(
|
|
220
|
+
"refuses malformed JSON config %s without writing",
|
|
221
|
+
async (raw) => {
|
|
222
|
+
const path = configPathFor("claude", home);
|
|
223
|
+
writeFileSync(path, raw);
|
|
224
|
+
const result = await applyOnboarding({ ...opts(), clients: ["claude"] });
|
|
225
|
+
expect(result.written).toEqual([]);
|
|
226
|
+
expect(result.manifestPath).toBeNull();
|
|
227
|
+
expect(result.skipped[0]?.state).toBe("error");
|
|
228
|
+
expect(result.skipped[0]?.error).toBeTruthy();
|
|
229
|
+
expect(readFileSync(path, "utf-8")).toBe(raw);
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
it.each([
|
|
234
|
+
["", "not-wired"],
|
|
235
|
+
['[mcp_servers.other]\ncommand = "ashlr"', "not-wired"],
|
|
236
|
+
[
|
|
237
|
+
'[mcp_servers."ashlr-hub"] # gateway\r\ncommand = "ashlr" # command\r\nargs = ["mcp"]\r\n[other]\r\ncommand = "different"',
|
|
238
|
+
"wired",
|
|
239
|
+
],
|
|
240
|
+
['[mcp_servers.ashlr-hub]\ncommand = "ashlr"\nargs = []', "differs"],
|
|
241
|
+
['[mcp_servers.ashlr-hub]\nargs = ["mcp"]', "differs"],
|
|
242
|
+
] as const)("inspects TOML %s", (raw, state) => {
|
|
243
|
+
const path = configPathFor("codex", home);
|
|
244
|
+
mkdirSync(join(home, ".codex"));
|
|
245
|
+
writeFileSync(path, raw);
|
|
246
|
+
expect(planOnboarding({ ...opts(), clients: ["codex"] })[0]?.state).toBe(state);
|
|
247
|
+
expect(readFileSync(path, "utf-8")).toBe(raw);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it.each(["missing config", "missing backup"])(
|
|
251
|
+
"undo retains manifest for %s and permits retry",
|
|
252
|
+
async (mode) => {
|
|
253
|
+
const path = configPathFor("claude", home);
|
|
254
|
+
writeFileSync(path, "{}");
|
|
255
|
+
const applied = await applyOnboarding({
|
|
256
|
+
...opts(),
|
|
257
|
+
clients: ["claude"],
|
|
258
|
+
includeUndetected: true,
|
|
259
|
+
});
|
|
260
|
+
const entry = applied.written[0]!;
|
|
261
|
+
const target = mode === "missing config" ? path : entry.backupPath!;
|
|
262
|
+
const original = readFileSync(target, "utf-8");
|
|
263
|
+
rmSync(target);
|
|
264
|
+
const result = await undoOnboarding({ home });
|
|
265
|
+
expect(result.restored).toEqual([]);
|
|
266
|
+
expect(result.refused).toEqual([
|
|
267
|
+
{
|
|
268
|
+
entry,
|
|
269
|
+
reason:
|
|
270
|
+
mode === "missing config" ? "config file no longer exists" : "backup file is missing",
|
|
271
|
+
},
|
|
272
|
+
]);
|
|
273
|
+
expect(JSON.parse(readFileSync(applied.manifestPath!, "utf-8")).entries).toEqual([entry]);
|
|
274
|
+
writeFileSync(target, original);
|
|
275
|
+
expect((await undoOnboarding({ home })).restored).toEqual([entry]);
|
|
276
|
+
expect(readFileSync(path, "utf-8")).toBe("{}");
|
|
277
|
+
expect(existsSync(applied.manifestPath!)).toBe(false);
|
|
278
|
+
},
|
|
279
|
+
);
|
|
@@ -55,6 +55,17 @@ describe("overrides are honored", () => {
|
|
|
55
55
|
"http://gpu-box:11434/v1",
|
|
56
56
|
);
|
|
57
57
|
});
|
|
58
|
+
test.each(["/", "///"])("trailing %s does not duplicate composed separators", (slashes) => {
|
|
59
|
+
expect(
|
|
60
|
+
`${anthropicBaseUrl({ ANTHROPIC_BASE_URL: `https://gw.example/anthropic${slashes}` })}/v1/models`,
|
|
61
|
+
).toBe("https://gw.example/anthropic/v1/models");
|
|
62
|
+
expect(
|
|
63
|
+
`${openaiBaseUrl({ OPENAI_BASE_URL: `https://gw.example/openai/v1${slashes}` })}/models`,
|
|
64
|
+
).toBe("https://gw.example/openai/v1/models");
|
|
65
|
+
expect(
|
|
66
|
+
`${defaultOllamaEndpoint({ OLLAMA_HOST: `https://gw.example/ollama${slashes}` }).baseUrl}/models`,
|
|
67
|
+
).toBe("https://gw.example/ollama/v1/models");
|
|
68
|
+
});
|
|
58
69
|
});
|
|
59
70
|
|
|
60
71
|
describe("callers read the variables at call time", () => {
|
|
@@ -108,14 +119,39 @@ describe("callers read the variables at call time", () => {
|
|
|
108
119
|
|
|
109
120
|
test("key checks follow ANTHROPIC_BASE_URL and OPENAI_BASE_URL", async () => {
|
|
110
121
|
process.env.ANTHROPIC_BASE_URL = "http://127.0.0.1:4000/";
|
|
111
|
-
process.env.OPENAI_BASE_URL = "http://127.0.0.1:4000/v1";
|
|
122
|
+
process.env.OPENAI_BASE_URL = "http://127.0.0.1:4000/v1/";
|
|
112
123
|
await anthropic.healthcheck!(ctx, entry("anthropic", "ANTHROPIC_API_KEY"));
|
|
113
124
|
await openai.healthcheck!(ctx, entry("openai", "OPENAI_API_KEY"));
|
|
114
125
|
expect(urls).toEqual(["http://127.0.0.1:4000/v1/models", "http://127.0.0.1:4000/v1/models"]);
|
|
115
126
|
});
|
|
116
127
|
|
|
128
|
+
test.each(["", "/", "///"])(
|
|
129
|
+
"equivalent base URLs with trailing %j hit the same endpoints",
|
|
130
|
+
async (suffix) => {
|
|
131
|
+
process.env.ANTHROPIC_BASE_URL = `https://gw.example/anthropic${suffix}`;
|
|
132
|
+
process.env.OPENAI_BASE_URL = `https://gw.example/openai/v1${suffix}`;
|
|
133
|
+
process.env.OLLAMA_HOST = `https://gw.example/ollama${suffix}`;
|
|
134
|
+
|
|
135
|
+
expect(
|
|
136
|
+
(await anthropic.healthcheck!(ctx, entry("anthropic", "ANTHROPIC_API_KEY"))).kind,
|
|
137
|
+
).toBe("ok");
|
|
138
|
+
expect((await openai.healthcheck!(ctx, entry("openai", "OPENAI_API_KEY"))).kind).toBe("ok");
|
|
139
|
+
expect(
|
|
140
|
+
await new LocalSLMBackend({
|
|
141
|
+
endpoints: [defaultOllamaEndpoint()],
|
|
142
|
+
fetchImpl: globalThis.fetch,
|
|
143
|
+
}).healthy(),
|
|
144
|
+
).toBe(true);
|
|
145
|
+
expect(urls).toEqual([
|
|
146
|
+
"https://gw.example/anthropic/v1/models",
|
|
147
|
+
"https://gw.example/openai/v1/models",
|
|
148
|
+
"https://gw.example/ollama/v1/models",
|
|
149
|
+
]);
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
117
153
|
test("the local SLM backend picks up OLLAMA_HOST when constructed", async () => {
|
|
118
|
-
process.env.OLLAMA_HOST = "gpu-box:11435";
|
|
154
|
+
process.env.OLLAMA_HOST = "gpu-box:11435/";
|
|
119
155
|
const seen: string[] = [];
|
|
120
156
|
const down = (async (u: string | URL | Request) => {
|
|
121
157
|
seen.push(String(u));
|
|
@@ -160,3 +196,13 @@ describe("gateway rule for keyed providers", () => {
|
|
|
160
196
|
);
|
|
161
197
|
});
|
|
162
198
|
});
|
|
199
|
+
|
|
200
|
+
test.each([
|
|
201
|
+
["gpu-box/models///", "http://gpu-box:11434/models"],
|
|
202
|
+
[":8080/models", "http://localhost:8080/models"],
|
|
203
|
+
["[::1]/models", "http://[::1]:11434/models"],
|
|
204
|
+
["[::1]:8080/models", "http://[::1]:8080/models"],
|
|
205
|
+
[" HTTPS://ollama.example/models/// ", "HTTPS://ollama.example/models"],
|
|
206
|
+
])("Ollama normalizes %s", (input, expected) => {
|
|
207
|
+
expect(ollamaBaseUrl({ OLLAMA_HOST: input })).toBe(expected);
|
|
208
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
PROVIDERS_REF,
|
|
4
|
+
PROVIDER_CATEGORIES,
|
|
5
|
+
findProviderRef,
|
|
6
|
+
groupByCategory,
|
|
7
|
+
} from "../catalog.ts";
|
|
8
|
+
|
|
9
|
+
test.each(PROVIDERS_REF)("catalog resolves $name by identity", (provider) => {
|
|
10
|
+
expect(findProviderRef(provider.name)).toBe(provider);
|
|
11
|
+
expect(PROVIDER_CATEGORIES).toContain(provider.category);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test.each(["", "OPENAI", " openai", "openai ", "constructor", "__proto__", "missing"])(
|
|
15
|
+
"catalog rejects noncanonical name %s",
|
|
16
|
+
(name) => expect(findProviderRef(name)).toBeUndefined(),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
test("category groups partition the catalog without sharing mutable buckets", () => {
|
|
20
|
+
const groups = groupByCategory();
|
|
21
|
+
expect(Object.values(groups).flat()).toHaveLength(PROVIDERS_REF.length);
|
|
22
|
+
expect(new Set(PROVIDERS_REF.map((p) => p.name)).size).toBe(PROVIDERS_REF.length);
|
|
23
|
+
for (const [category, providers] of Object.entries(groups)) {
|
|
24
|
+
expect(providers).toEqual(PROVIDERS_REF.filter((p) => p.category === category));
|
|
25
|
+
}
|
|
26
|
+
const first = PROVIDERS_REF[0]!;
|
|
27
|
+
groups[first.category]!.pop();
|
|
28
|
+
expect(groupByCategory()[first.category]).toEqual(
|
|
29
|
+
PROVIDERS_REF.filter((p) => p.category === first.category),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
@@ -70,4 +70,27 @@ describe("cloudflare provider (mocked fetch)", () => {
|
|
|
70
70
|
expect(status.kind).toBe("ok");
|
|
71
71
|
expect(seenAuth).toBe("Bearer cf-cached");
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
test("an empty 204 verification response does not verify a cached token", async () => {
|
|
75
|
+
const { addSecret } = await import("../phantom.ts");
|
|
76
|
+
await addSecret("CLOUDFLARE_API_TOKEN", "cf-cached");
|
|
77
|
+
|
|
78
|
+
let requests = 0;
|
|
79
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
80
|
+
expect(String(url)).toBe("https://api.cloudflare.com/client/v4/user/tokens/verify");
|
|
81
|
+
requests++;
|
|
82
|
+
return new Response(null, { status: 204 });
|
|
83
|
+
}) as unknown as typeof fetch;
|
|
84
|
+
|
|
85
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
86
|
+
expect(
|
|
87
|
+
await cloudflare.healthcheck!(ctx, {
|
|
88
|
+
provider: "cloudflare",
|
|
89
|
+
secrets: ["CLOUDFLARE_API_TOKEN"],
|
|
90
|
+
created_at: new Date().toISOString(),
|
|
91
|
+
}),
|
|
92
|
+
).toEqual({ kind: "error", detail: "token invalid" });
|
|
93
|
+
await expect(cloudflare.login(ctx)).rejects.toMatchObject({ code: "CLOUDFLARE_AUTH_REQUIRED" });
|
|
94
|
+
expect(requests).toBe(2);
|
|
95
|
+
});
|
|
73
96
|
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CONFIG_FILENAME,
|
|
8
8
|
LOCAL_FILENAME,
|
|
9
9
|
emptyConfig,
|
|
10
|
+
getService,
|
|
10
11
|
readConfig,
|
|
11
12
|
writeConfig,
|
|
12
13
|
} from "../config.ts";
|
|
@@ -72,6 +73,71 @@ describe("committed + local config split", () => {
|
|
|
72
73
|
expect(roundtrip.services.supabase.secrets).toEqual(["FOO"]);
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
test("round-trips a service name containing a hyphen through both files", async () => {
|
|
77
|
+
const original = emptyConfig("fixture-template");
|
|
78
|
+
original.services["fixture-api"] = {
|
|
79
|
+
provider: "neon",
|
|
80
|
+
resource_id: "hyphen-resource",
|
|
81
|
+
region: "us-east-1",
|
|
82
|
+
secrets: ["FIXTURE_API_URL", "FIXTURE_API_KEY"],
|
|
83
|
+
mcp: "fixture-api",
|
|
84
|
+
meta: { endpoint: "fixture-endpoint" },
|
|
85
|
+
created_at: "2026-10-30T00:00:00Z",
|
|
86
|
+
created_by: "test",
|
|
87
|
+
};
|
|
88
|
+
await writeConfig(original, cwd);
|
|
89
|
+
|
|
90
|
+
const shape = parseToml(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8"));
|
|
91
|
+
const local = parseToml(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8"));
|
|
92
|
+
expect(Object.keys(shape.services as object)).toEqual(["fixture-api"]);
|
|
93
|
+
expect(Object.keys(local.services as object)).toEqual(["fixture-api"]);
|
|
94
|
+
expect(shape.services).toEqual({
|
|
95
|
+
"fixture-api": {
|
|
96
|
+
provider: "neon",
|
|
97
|
+
secrets: ["FIXTURE_API_URL", "FIXTURE_API_KEY"],
|
|
98
|
+
mcp: "fixture-api",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
expect(local.services).toEqual({
|
|
102
|
+
"fixture-api": {
|
|
103
|
+
resource_id: "hyphen-resource",
|
|
104
|
+
region: "us-east-1",
|
|
105
|
+
meta: { endpoint: "fixture-endpoint" },
|
|
106
|
+
created_at: "2026-10-30T00:00:00Z",
|
|
107
|
+
created_by: "test",
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const merged = await readConfig(cwd);
|
|
112
|
+
expect(Object.keys(merged.services)).toEqual(["fixture-api"]);
|
|
113
|
+
expect(getService(merged, "fixture-api")).toEqual(original.services["fixture-api"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("legacy file with a hyphenated service name migrates without losing it", async () => {
|
|
117
|
+
const legacy = `
|
|
118
|
+
[stack]
|
|
119
|
+
version = "1"
|
|
120
|
+
project_id = "stk_hyphen"
|
|
121
|
+
|
|
122
|
+
[services.fixture-api]
|
|
123
|
+
provider = "neon"
|
|
124
|
+
resource_id = "hyphen-legacy"
|
|
125
|
+
secrets = ["X"]
|
|
126
|
+
created_at = "2026-04-17T00:00:00Z"
|
|
127
|
+
`.trim();
|
|
128
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), legacy);
|
|
129
|
+
const config = await readConfig(cwd);
|
|
130
|
+
expect(Object.keys(config.services)).toEqual(["fixture-api"]);
|
|
131
|
+
expect(config.services["fixture-api"].resource_id).toBe("hyphen-legacy");
|
|
132
|
+
|
|
133
|
+
await writeConfig(config, cwd);
|
|
134
|
+
const shape = readFileSync(join(cwd, CONFIG_FILENAME), "utf-8");
|
|
135
|
+
expect(shape).toContain("fixture-api");
|
|
136
|
+
expect(shape).not.toContain("hyphen-legacy");
|
|
137
|
+
const roundTrip = await readConfig(cwd);
|
|
138
|
+
expect(roundTrip.services["fixture-api"]).toEqual(config.services["fixture-api"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
75
141
|
test("writeConfig auto-appends .stack.local.toml to .gitignore", async () => {
|
|
76
142
|
await writeConfig(emptyConfig(), cwd);
|
|
77
143
|
const gitignore = readFileSync(join(cwd, ".gitignore"), "utf-8");
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
CONFIG_FILENAME,
|
|
7
|
+
LOCAL_FILENAME,
|
|
7
8
|
emptyConfig,
|
|
8
9
|
hasConfig,
|
|
9
10
|
readConfig,
|
|
@@ -11,8 +12,15 @@ import {
|
|
|
11
12
|
writeConfig,
|
|
12
13
|
} from "../config.ts";
|
|
13
14
|
|
|
15
|
+
const scratch: string[] = [];
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
14
20
|
function freshCwd(): string {
|
|
15
|
-
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), "stack-test-"));
|
|
22
|
+
scratch.push(dir);
|
|
23
|
+
return dir;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
describe("config", () => {
|
|
@@ -20,7 +28,7 @@ describe("config", () => {
|
|
|
20
28
|
|
|
21
29
|
beforeEach(() => {
|
|
22
30
|
previousRegistryDir = process.env.STACK_REGISTRY_DIR;
|
|
23
|
-
process.env.STACK_REGISTRY_DIR =
|
|
31
|
+
process.env.STACK_REGISTRY_DIR = freshCwd();
|
|
24
32
|
});
|
|
25
33
|
|
|
26
34
|
afterEach(() => {
|
|
@@ -64,4 +72,85 @@ describe("config", () => {
|
|
|
64
72
|
const cwd = freshCwd();
|
|
65
73
|
await expect(readConfig(cwd)).rejects.toThrow(/No .stack.toml at/);
|
|
66
74
|
});
|
|
75
|
+
test.each([".stack.toml", ".stack.local.toml"])(
|
|
76
|
+
"rejects malformed %s without rewriting files",
|
|
77
|
+
async (file) => {
|
|
78
|
+
const cwd = freshCwd();
|
|
79
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), '[stack]\nversion = "1"');
|
|
80
|
+
writeFileSync(join(cwd, file), "[broken");
|
|
81
|
+
await expect(readConfig(cwd)).rejects.toThrow();
|
|
82
|
+
expect(readFileSync(join(cwd, file), "utf-8")).toBe("[broken");
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
test.each(["", "node_modules/", "node_modules/\n", " .stack.local.toml \r\n"])(
|
|
87
|
+
"gitignore update preserves existing content %j and is idempotent",
|
|
88
|
+
async (initial) => {
|
|
89
|
+
const cwd = freshCwd();
|
|
90
|
+
writeFileSync(join(cwd, ".gitignore"), initial);
|
|
91
|
+
const config = emptyConfig();
|
|
92
|
+
await writeConfig(config, cwd);
|
|
93
|
+
const first = readFileSync(join(cwd, ".gitignore"), "utf-8");
|
|
94
|
+
expect(first.startsWith(initial)).toBe(true);
|
|
95
|
+
expect(
|
|
96
|
+
first.split(/\r?\n/).filter((line) => line.trim() === ".stack.local.toml"),
|
|
97
|
+
).toHaveLength(1);
|
|
98
|
+
await writeConfig(config, cwd);
|
|
99
|
+
expect(readFileSync(join(cwd, ".gitignore"), "utf-8")).toBe(first);
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
test("empty shape supplies defaults without creating local files", async () => {
|
|
104
|
+
const cwd = freshCwd();
|
|
105
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), "");
|
|
106
|
+
const config = await readConfig(cwd);
|
|
107
|
+
expect(config.stack.version).toBe("1");
|
|
108
|
+
expect(config.stack.project_id).toMatch(/^stk_[a-f0-9]{12}$/);
|
|
109
|
+
expect(config.services).toEqual({});
|
|
110
|
+
expect(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8")).toBe("");
|
|
111
|
+
expect(existsSync(join(cwd, LOCAL_FILENAME))).toBe(false);
|
|
112
|
+
expect(existsSync(join(cwd, ".gitignore"))).toBe(false);
|
|
113
|
+
expect(config.environments).toEqual([{ name: "dev", default: true }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("empty shape keeps the local project id but does not add local-only services", async () => {
|
|
117
|
+
const cwd = freshCwd();
|
|
118
|
+
const local =
|
|
119
|
+
'[stack]\nproject_id = "stk_fixture"\n[services.orphan]\nresource_id = "fixture-resource"\n';
|
|
120
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), "");
|
|
121
|
+
writeFileSync(join(cwd, LOCAL_FILENAME), local);
|
|
122
|
+
|
|
123
|
+
expect(await readConfig(cwd)).toEqual({
|
|
124
|
+
stack: { version: "1", project_id: "stk_fixture" },
|
|
125
|
+
services: {},
|
|
126
|
+
environments: [{ name: "dev", default: true }],
|
|
127
|
+
});
|
|
128
|
+
expect(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8")).toBe("");
|
|
129
|
+
expect(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8")).toBe(local);
|
|
130
|
+
expect(existsSync(join(cwd, ".gitignore"))).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
test.each(['created_at = "2026-01-01T00:00:00Z"', 'resource_id = "resource-1"'])(
|
|
133
|
+
"legacy service marker %s normalizes optional fields",
|
|
134
|
+
async (marker) => {
|
|
135
|
+
const cwd = freshCwd();
|
|
136
|
+
writeFileSync(
|
|
137
|
+
join(cwd, CONFIG_FILENAME),
|
|
138
|
+
`[services.example]\n${marker}\nmcp = "example"\nregion = "local"\ncreated_by = "test"\n[services.example.meta]\ncount = 2\n`,
|
|
139
|
+
);
|
|
140
|
+
const config = await readConfig(cwd);
|
|
141
|
+
expect(config.stack.version).toBe("1");
|
|
142
|
+
expect(config.stack.project_id).toMatch(/^stk_[a-f0-9]{12}$/);
|
|
143
|
+
expect(config.services.example).toMatchObject({
|
|
144
|
+
provider: "example",
|
|
145
|
+
secrets: [],
|
|
146
|
+
mcp: "example",
|
|
147
|
+
region: "local",
|
|
148
|
+
created_by: "test",
|
|
149
|
+
meta: { count: 2 },
|
|
150
|
+
});
|
|
151
|
+
expect(Number.isNaN(Date.parse(config.services.example!.created_at))).toBe(false);
|
|
152
|
+
await writeConfig(config, cwd);
|
|
153
|
+
expect(await readConfig(cwd)).toEqual(config);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
67
156
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { emptyConfig, readConfig, writeConfig } from "../config.ts";
|
|
@@ -267,8 +267,9 @@ describe("operation-owned credential persistence", () => {
|
|
|
267
267
|
.trim()
|
|
268
268
|
.split("\n")
|
|
269
269
|
.map((line) => JSON.parse(line));
|
|
270
|
+
// Python getcwd resolves filesystem aliases such as macOS /var.
|
|
270
271
|
for (const row of rows.filter((r) => ["status", "reveal", "add"].includes(r.cmd)))
|
|
271
|
-
expect(row.cwd).toBe(cwd);
|
|
272
|
+
expect(row.cwd).toBe(realpathSync(cwd));
|
|
272
273
|
expect((await readConfig(cwd)).services.stagefixture).toBeDefined();
|
|
273
274
|
});
|
|
274
275
|
for (const fail of ["login", "provision", "materialize", "success"] as const) {
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { delimiter, dirname, join } from "node:path";
|
|
5
5
|
import { scanSource } from "../detect-source.ts";
|
|
6
6
|
|
|
7
|
+
const projects: string[] = [];
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
for (const dir of projects.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
7
12
|
function makeProject(files: Record<string, string>): string {
|
|
8
13
|
const dir = mkdtempSync(join(tmpdir(), "stack-scan-"));
|
|
14
|
+
projects.push(dir);
|
|
9
15
|
for (const [path, content] of Object.entries(files)) {
|
|
10
16
|
const full = join(dir, path);
|
|
11
17
|
const parent = join(full, "..");
|
|
@@ -145,3 +151,176 @@ require (
|
|
|
145
151
|
expect(names).toContain("stripe");
|
|
146
152
|
});
|
|
147
153
|
});
|
|
154
|
+
|
|
155
|
+
test.each(["{broken", "null", "{}"])(
|
|
156
|
+
"malformed or empty package %s does not hide other evidence",
|
|
157
|
+
async (raw) => {
|
|
158
|
+
const cwd = makeProject({ "package.json": raw, "fly.toml": "" });
|
|
159
|
+
expect(await scanSource(cwd)).toEqual([
|
|
160
|
+
{ provider: "fly", confidence: "high", signals: ["fly.toml"] },
|
|
161
|
+
]);
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
test.each([
|
|
166
|
+
[
|
|
167
|
+
"pyproject.toml",
|
|
168
|
+
'[project]\ndependencies = ["supabase-py>=1", "anthropic", "resend"]',
|
|
169
|
+
["anthropic", "resend", "supabase"],
|
|
170
|
+
],
|
|
171
|
+
[
|
|
172
|
+
"Cargo.toml",
|
|
173
|
+
'[dependencies]\nasync-openai = "1"\nsentry = "1"\nasync-stripe = "1"',
|
|
174
|
+
["openai", "sentry", "stripe"],
|
|
175
|
+
],
|
|
176
|
+
[
|
|
177
|
+
"requirements.txt",
|
|
178
|
+
"# openai\n\nANTHROPIC[extra]>=1\r\nmodal==1\nreplicate~=1\nbraintrust!=1\nunknown",
|
|
179
|
+
["anthropic", "braintrust", "modal", "replicate"],
|
|
180
|
+
],
|
|
181
|
+
[
|
|
182
|
+
"go.mod",
|
|
183
|
+
"module example.com/app\nrequire github.com/liushuangls/go-anthropic v1\nrequire github.com/sashabaranov/go-openai v1",
|
|
184
|
+
["openai", "anthropic"],
|
|
185
|
+
],
|
|
186
|
+
] as const)("scans %s with exact evidence", async (file, content, providers) => {
|
|
187
|
+
const hits = await scanSource(makeProject({ [file]: content }));
|
|
188
|
+
expect(hits.map((hit) => hit.provider)).toEqual([...providers]);
|
|
189
|
+
for (const hit of hits) {
|
|
190
|
+
expect(hit.signals).toHaveLength(1);
|
|
191
|
+
expect(hit.signals[0]).toStartWith(`${file}:`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("deduplicates dependency evidence and upgrades confidence without losing signals", async () => {
|
|
196
|
+
const hits = await scanSource(
|
|
197
|
+
makeProject({
|
|
198
|
+
"package.json": JSON.stringify({
|
|
199
|
+
dependencies: { "@vercel/kv": "1" },
|
|
200
|
+
devDependencies: { "@vercel/kv": "1" },
|
|
201
|
+
}),
|
|
202
|
+
"vercel.json": "{}",
|
|
203
|
+
".env.sample": "VERCEL_TOKEN=\nUNKNOWN_NAME=",
|
|
204
|
+
".env.template": "VERCEL_TOKEN=",
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
expect(hits).toEqual([
|
|
208
|
+
{
|
|
209
|
+
provider: "vercel",
|
|
210
|
+
confidence: "high",
|
|
211
|
+
signals: [
|
|
212
|
+
"package.json:@vercel/kv",
|
|
213
|
+
"vercel.json",
|
|
214
|
+
".env.sample:VERCEL_TOKEN",
|
|
215
|
+
".env.template:VERCEL_TOKEN",
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("scans a project directory containing spaces and reports relative file paths", async () => {
|
|
222
|
+
const root = makeProject({
|
|
223
|
+
"project with spaces/package.json": JSON.stringify({ dependencies: { openai: "1" } }),
|
|
224
|
+
"project with spaces/supabase/config.toml": "[api]",
|
|
225
|
+
"project with spaces/.env.example": "OPENAI_API_KEY=",
|
|
226
|
+
"project with spaces/vercel.json": "{}",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(await scanSource(join(root, "project with spaces"))).toEqual([
|
|
230
|
+
{
|
|
231
|
+
provider: "openai",
|
|
232
|
+
confidence: "high",
|
|
233
|
+
signals: ["package.json:openai", ".env.example:OPENAI_API_KEY"],
|
|
234
|
+
},
|
|
235
|
+
{ provider: "supabase", confidence: "high", signals: ["supabase/config.toml"] },
|
|
236
|
+
{ provider: "vercel", confidence: "high", signals: ["vercel.json"] },
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("finds the same source markers in equivalent paths with and without spaces", async () => {
|
|
241
|
+
const files: Record<string, string> = {
|
|
242
|
+
"package.json": JSON.stringify({ dependencies: { openai: "1" } }),
|
|
243
|
+
"requirements.txt": "modal==1",
|
|
244
|
+
"pyproject.toml": '[project]\ndependencies = ["resend"]',
|
|
245
|
+
"go.mod": "module example.com/app\nrequire github.com/stripe/stripe-go/v76 v76.0.0",
|
|
246
|
+
"Cargo.toml": '[dependencies]\nsentry = "1"',
|
|
247
|
+
"supabase/config.toml": "[api]",
|
|
248
|
+
".github/workflows/ci.yml": "name: CI",
|
|
249
|
+
".env.example": "ANTHROPIC_API_KEY=",
|
|
250
|
+
};
|
|
251
|
+
const root = makeProject(
|
|
252
|
+
Object.fromEntries(
|
|
253
|
+
["project", "parent with spaces/project with spaces"].flatMap((dir) =>
|
|
254
|
+
Object.entries(files).map(([path, content]) => [join(dir, path), content]),
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const plain = await scanSource(join(root, "project"));
|
|
260
|
+
const spaced = await scanSource(join(root, "parent with spaces", "project with spaces"));
|
|
261
|
+
expect(plain).toEqual([
|
|
262
|
+
{
|
|
263
|
+
provider: "github",
|
|
264
|
+
confidence: "high",
|
|
265
|
+
signals: [".github/workflows", ".github/workflows/ci.yml"],
|
|
266
|
+
},
|
|
267
|
+
{ provider: "modal", confidence: "high", signals: ["requirements.txt:modal"] },
|
|
268
|
+
{ provider: "openai", confidence: "high", signals: ["package.json:openai"] },
|
|
269
|
+
{ provider: "resend", confidence: "high", signals: ["pyproject.toml:resend"] },
|
|
270
|
+
{ provider: "sentry", confidence: "high", signals: ["Cargo.toml:sentry"] },
|
|
271
|
+
{ provider: "stripe", confidence: "high", signals: ["go.mod:github.com/stripe/stripe-go/v76"] },
|
|
272
|
+
{ provider: "supabase", confidence: "high", signals: ["supabase/config.toml"] },
|
|
273
|
+
{ provider: "anthropic", confidence: "medium", signals: [".env.example:ANTHROPIC_API_KEY"] },
|
|
274
|
+
]);
|
|
275
|
+
expect(spaced).toEqual(plain);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("empty project has no detections", async () => {
|
|
279
|
+
expect(await scanSource(makeProject({}))).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test.each([["--json"], ["--yes"]])(
|
|
283
|
+
"scan %s rejects a nonexistent project without creating files",
|
|
284
|
+
async (flag) => {
|
|
285
|
+
const root = makeProject({});
|
|
286
|
+
const home = makeProject({});
|
|
287
|
+
const missing = join(root, "missing", "project");
|
|
288
|
+
const cli = join(import.meta.dir, "..", "..", "..", "cli", "src", "index.ts");
|
|
289
|
+
const proc = Bun.spawn([process.execPath, cli, "scan", flag, "--path", missing], {
|
|
290
|
+
cwd: root,
|
|
291
|
+
env: {
|
|
292
|
+
PATH: [dirname(process.execPath), "/usr/bin", "/bin"].join(delimiter),
|
|
293
|
+
HOME: home,
|
|
294
|
+
CI: "1",
|
|
295
|
+
NO_COLOR: "1",
|
|
296
|
+
STACK_TELEMETRY: "0",
|
|
297
|
+
STACK_NO_UPDATE_CHECK: "1",
|
|
298
|
+
},
|
|
299
|
+
stdout: "pipe",
|
|
300
|
+
stderr: "pipe",
|
|
301
|
+
});
|
|
302
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
303
|
+
proc.exited,
|
|
304
|
+
new Response(proc.stdout).text(),
|
|
305
|
+
new Response(proc.stderr).text(),
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
expect(code).toBe(1);
|
|
309
|
+
expect(stdout + stderr).toContain(`Path "${missing}" does not exist or is not a directory.`);
|
|
310
|
+
expect(stdout).not.toContain('"hits"');
|
|
311
|
+
expect(stdout).not.toContain("No providers detected");
|
|
312
|
+
expect(existsSync(missing)).toBe(false);
|
|
313
|
+
expect(readdirSync(root)).toEqual([]);
|
|
314
|
+
},
|
|
315
|
+
30_000,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
test.each([
|
|
319
|
+
["package.json", JSON.stringify({ name: "openai", dependencies: {}, devDependencies: {} })],
|
|
320
|
+
["requirements.txt", ""],
|
|
321
|
+
["pyproject.toml", "[project]\ndependencies = []\n"],
|
|
322
|
+
["go.mod", "module example.com/app\n\ngo 1.22\n"],
|
|
323
|
+
["Cargo.toml", '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\n'],
|
|
324
|
+
] as const)("empty dependency manifest %s produces no detections", async (file, content) => {
|
|
325
|
+
expect(await scanSource(makeProject({ [file]: content }))).toEqual([]);
|
|
326
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"kind": "ashlr-stack-manifest",
|
|
4
|
+
"components": [
|
|
5
|
+
{ "id": "core", "name": "@ashlr/stack-core", "version": "0.3.0", "distribution": "npm" },
|
|
6
|
+
{ "id": "cli", "name": "@ashlr/stack", "version": "0.3.0", "distribution": "npm" },
|
|
7
|
+
{ "id": "mcp", "name": "ashlr-stack-mcp", "version": "0.3.0", "distribution": "npm" },
|
|
8
|
+
{ "id": "plugin", "name": "stack", "version": "0.1.0", "distribution": "claude-code-plugin" }
|
|
9
|
+
]
|
|
10
|
+
}
|