@ashlr/stack-core 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +26 -15
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +67 -7
- package/src/__tests__/agent-onboard.test.ts +279 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +208 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +32 -10
- package/src/__tests__/completion-ci.test.ts +239 -23
- package/src/__tests__/config-own-services.test.ts +183 -0
- package/src/__tests__/config-split.test.ts +272 -2
- package/src/__tests__/config.test.ts +94 -4
- package/src/__tests__/credential-staging.test.ts +660 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +183 -4
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/github-provider.test.ts +3 -2
- package/src/__tests__/helpers.test.ts +24 -4
- package/src/__tests__/http.test.ts +2 -2
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +57 -13
- package/src/__tests__/interactive-prompt.test.ts +106 -0
- package/src/__tests__/mcp-mirror.test.ts +116 -0
- package/src/__tests__/neon-provider.test.ts +136 -3
- package/src/__tests__/new-providers.test.ts +470 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/oauth-port.test.ts +84 -0
- package/src/__tests__/openai-provider.test.ts +1 -1
- package/src/__tests__/phantom-contract.test.ts +177 -0
- package/src/__tests__/phantom-harness.test.ts +226 -8
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +403 -22
- package/src/__tests__/pipeline-integration.test.ts +33 -1
- package/src/__tests__/pipeline-recovery.test.ts +29 -28
- package/src/__tests__/pipeline-rollback.test.ts +509 -0
- package/src/__tests__/pipeline.test.ts +3 -1
- package/src/__tests__/pm-detect.test.ts +113 -0
- package/src/__tests__/provider-deprovision.test.ts +269 -0
- package/src/__tests__/provider-parity.test.ts +46 -0
- package/src/__tests__/publish-script.test.ts +704 -0
- package/src/__tests__/recipe.test.ts +27 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/supabase-provider.test.ts +6 -4
- package/src/__tests__/swap.test.ts +114 -0
- package/src/__tests__/telemetry.test.ts +354 -0
- package/src/__tests__/template-litellm-gateway.test.ts +70 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
- package/src/__tests__/templates.test.ts +1 -1
- package/src/__tests__/turso-provider.test.ts +2 -5
- package/src/agent-onboard.ts +346 -0
- package/src/ai/catalog-index.ts +68 -10
- package/src/ai/circuit-breaker.ts +3 -6
- package/src/ai/cost-tracker.ts +6 -15
- package/src/ai/inference.ts +19 -11
- package/src/ai/phantom-wire.ts +73 -58
- package/src/ai/recipe.ts +69 -0
- package/src/base-urls.ts +71 -0
- package/src/catalog.ts +281 -2
- package/src/config.ts +31 -18
- package/src/detect-source.ts +50 -9
- package/src/detect.ts +13 -5
- package/src/errors.ts +6 -0
- package/src/http.ts +6 -5
- package/src/index.ts +16 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +319 -30
- package/src/pipeline.ts +177 -68
- package/src/pm-detect.ts +56 -0
- package/src/providers/_api-key.ts +18 -17
- package/src/providers/_base.ts +28 -1
- package/src/providers/_credential-stage.ts +237 -0
- package/src/providers/_helpers.ts +55 -9
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/auth0.ts +27 -0
- package/src/providers/aws.ts +32 -33
- package/src/providers/cloudflare.ts +18 -37
- package/src/providers/convex.ts +1 -2
- package/src/providers/datadog.ts +29 -0
- package/src/providers/digitalocean.ts +30 -0
- package/src/providers/gcp.ts +36 -0
- package/src/providers/github.ts +37 -35
- package/src/providers/grafana.ts +29 -0
- package/src/providers/hetzner.ts +29 -0
- package/src/providers/index.ts +13 -1
- package/src/providers/launchdarkly.ts +33 -0
- package/src/providers/mixpanel.ts +37 -0
- package/src/providers/neon.ts +50 -26
- package/src/providers/openai.ts +2 -1
- package/src/providers/plausible.ts +26 -0
- package/src/providers/sentry.ts +17 -17
- package/src/providers/stripe.ts +1 -2
- package/src/providers/supabase.ts +66 -37
- package/src/providers/turso.ts +49 -38
- package/src/providers/vercel.ts +50 -20
- package/src/providers/workos.ts +27 -0
- package/src/registry.ts +7 -11
- package/src/stack-manifest.ts +39 -0
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- package/src/templates.ts +6 -2
|
@@ -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, "..");
|
|
@@ -94,7 +100,7 @@ describe("source scanner — config files", () => {
|
|
|
94
100
|
|
|
95
101
|
test("wrangler.toml picks up Cloudflare", async () => {
|
|
96
102
|
const cwd = makeProject({
|
|
97
|
-
"wrangler.toml":
|
|
103
|
+
"wrangler.toml": 'name = "my-worker"\nmain = "src/index.ts"',
|
|
98
104
|
});
|
|
99
105
|
const hits = await scanSource(cwd);
|
|
100
106
|
expect(hits.some((h) => h.provider === "cloudflare")).toBe(true);
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import github from "../providers/github.ts";
|
|
3
2
|
import type { ProviderContext } from "../providers/_base.ts";
|
|
3
|
+
import github from "../providers/github.ts";
|
|
4
4
|
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
5
5
|
|
|
6
6
|
describe("github provider (mocked fetch)", () => {
|
|
@@ -8,7 +8,8 @@ describe("github 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
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
import { bearerJsonHeaders, tryRevealSecret } from "../providers/_helpers.ts";
|
|
2
|
+
import { bearerJsonHeaders, takeRevealRefusals, tryRevealSecret } from "../providers/_helpers.ts";
|
|
4
3
|
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
5
4
|
|
|
6
5
|
/**
|
|
@@ -11,7 +10,12 @@ import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
|
11
10
|
describe("provider helpers", () => {
|
|
12
11
|
let h: Harness;
|
|
13
12
|
beforeEach(() => {
|
|
14
|
-
|
|
13
|
+
// Legacy headless reveal exercises the helper's own logic; the last test
|
|
14
|
+
// below covers what Phantom Secrets 0.7.9 actually does.
|
|
15
|
+
h = setupFakePhantom(
|
|
16
|
+
{ PRESENT_KEY: "hello-world", EMPTY_KEY: "" },
|
|
17
|
+
{ legacyHeadlessReveal: true },
|
|
18
|
+
);
|
|
15
19
|
});
|
|
16
20
|
afterEach(() => h.cleanup());
|
|
17
21
|
|
|
@@ -20,7 +24,6 @@ describe("provider helpers", () => {
|
|
|
20
24
|
});
|
|
21
25
|
|
|
22
26
|
test("tryRevealSecret returns undefined for an empty value", async () => {
|
|
23
|
-
await addSecret("EMPTY_KEY", "");
|
|
24
27
|
expect(await tryRevealSecret("EMPTY_KEY")).toBeUndefined();
|
|
25
28
|
});
|
|
26
29
|
|
|
@@ -29,6 +32,23 @@ describe("provider helpers", () => {
|
|
|
29
32
|
expect(await tryRevealSecret("NEVER_STORED_KEY")).toBeUndefined();
|
|
30
33
|
});
|
|
31
34
|
|
|
35
|
+
test("tryRevealSecret returns undefined when Phantom refuses headless reveal", async () => {
|
|
36
|
+
h.cleanup();
|
|
37
|
+
h = setupFakePhantom({ PRESENT_KEY: "hello-world" });
|
|
38
|
+
takeRevealRefusals();
|
|
39
|
+
expect(await tryRevealSecret("PRESENT_KEY")).toBeUndefined();
|
|
40
|
+
// The refusal is recorded by name so doctor can say "stored, unverified".
|
|
41
|
+
expect(takeRevealRefusals()).toEqual(["PRESENT_KEY"]);
|
|
42
|
+
expect(takeRevealRefusals()).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a readable or absent value records no refusal", async () => {
|
|
46
|
+
takeRevealRefusals();
|
|
47
|
+
await tryRevealSecret("PRESENT_KEY");
|
|
48
|
+
await tryRevealSecret("NEVER_STORED_KEY");
|
|
49
|
+
expect(takeRevealRefusals()).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
32
52
|
test("bearerJsonHeaders returns the canonical shape", () => {
|
|
33
53
|
const headers = bearerJsonHeaders("my-token") as Record<string, string>;
|
|
34
54
|
expect(headers.Authorization).toBe("Bearer my-token");
|
|
@@ -220,8 +220,8 @@ describe("parseRetryAfter", () => {
|
|
|
220
220
|
const parsed = parseRetryAfter(future);
|
|
221
221
|
expect(parsed).toBeDefined();
|
|
222
222
|
// Allow drift from test overhead; we just need it to be in the right ballpark.
|
|
223
|
-
expect(parsed
|
|
224
|
-
expect(parsed
|
|
223
|
+
expect(parsed as number).toBeGreaterThan(5_000);
|
|
224
|
+
expect(parsed as number).toBeLessThanOrEqual(12_000);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
test("HTTP-date in the past clamps to 0", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
@@ -33,6 +33,7 @@ describe("import pipeline", () => {
|
|
|
33
33
|
afterEach(() => {
|
|
34
34
|
process.chdir(originalCwd);
|
|
35
35
|
h.cleanup();
|
|
36
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
test("typical Next.js .env gets parsed, stored, and mapped to services", async () => {
|
|
@@ -49,7 +50,7 @@ PORT=3000
|
|
|
49
50
|
`.trim();
|
|
50
51
|
writeFileSync(join(cwd, ".env"), envContent);
|
|
51
52
|
|
|
52
|
-
const parsed = parseEnv(
|
|
53
|
+
const parsed = parseEnv(readFileSync(join(cwd, ".env"), "utf8"));
|
|
53
54
|
expect(parsed.map((p) => p.key)).toContain("OPENAI_API_KEY");
|
|
54
55
|
expect(parsed.map((p) => p.key)).toContain("NODE_ENV");
|
|
55
56
|
|
|
@@ -70,7 +71,12 @@ PORT=3000
|
|
|
70
71
|
const vault = await readVault(h.dir);
|
|
71
72
|
expect(vault.OPENAI_API_KEY).toBe("sk-fake");
|
|
72
73
|
expect(vault.NODE_ENV).toBe("development");
|
|
73
|
-
|
|
74
|
+
const expectedVault = Object.fromEntries(parsed.map(({ key, value }) => [key, value]));
|
|
75
|
+
expect(vault).toEqual(expectedVault);
|
|
76
|
+
expect((await listSecrets()).sort()).toEqual(Object.keys(expectedVault).sort());
|
|
77
|
+
expect(h.callsTo("add").map(({ args }) => args)).toEqual(
|
|
78
|
+
parsed.map(({ key }) => [key, "--stdin"]),
|
|
79
|
+
);
|
|
74
80
|
|
|
75
81
|
// Build a .stack.toml from the detected groups.
|
|
76
82
|
const config = emptyConfig("import-test");
|
|
@@ -83,5 +89,13 @@ PORT=3000
|
|
|
83
89
|
expect(roundTripped.services.supabase).toBeDefined();
|
|
84
90
|
expect(roundTripped.services.openai).toBeDefined();
|
|
85
91
|
expect(roundTripped.services.sentry.mcp).toBe("sentry");
|
|
86
|
-
|
|
92
|
+
expect(Object.keys(roundTripped.services).sort()).toEqual(["openai", "sentry", "supabase"]);
|
|
93
|
+
for (const [provider, keys] of Object.entries(grouped)) {
|
|
94
|
+
expect(roundTripped.services[provider].secrets).toEqual(keys);
|
|
95
|
+
}
|
|
96
|
+
const serialized = readFileSync(join(cwd, ".stack.toml"), "utf8");
|
|
97
|
+
for (const { value } of parsed) expect(serialized).not.toContain(value);
|
|
98
|
+
// Eight sequential real child processes plus list/version probes are integration
|
|
99
|
+
// work, not a five-second performance assertion (especially under coverage).
|
|
100
|
+
}, 30_000);
|
|
87
101
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { CostTracker } from "../ai/cost-tracker.ts";
|
|
2
3
|
import {
|
|
3
4
|
ClaudeMCPBackend,
|
|
4
5
|
LocalSLMBackend,
|
|
@@ -6,7 +7,6 @@ import {
|
|
|
6
7
|
getInferenceBackend,
|
|
7
8
|
parseRecipeDraft,
|
|
8
9
|
} from "../ai/inference.ts";
|
|
9
|
-
import { CostTracker } from "../ai/cost-tracker.ts";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Inference backend tests. The local backend talks to an OpenAI-compatible
|
|
@@ -15,7 +15,10 @@ import { CostTracker } from "../ai/cost-tracker.ts";
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
// Minimal valid chat-completion response.
|
|
18
|
-
function okChatResponse(
|
|
18
|
+
function okChatResponse(
|
|
19
|
+
content: string,
|
|
20
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number },
|
|
21
|
+
) {
|
|
19
22
|
return new Response(
|
|
20
23
|
JSON.stringify({
|
|
21
24
|
choices: [{ message: { content } }],
|
|
@@ -117,7 +120,10 @@ describe("LocalSLMBackend — infer", () => {
|
|
|
117
120
|
test("happy path — parses recipe + records usage with reported tokens", async () => {
|
|
118
121
|
const costTracker = new CostTracker();
|
|
119
122
|
const fetchImpl = (async () =>
|
|
120
|
-
okChatResponse(VALID_RECIPE_JSON, {
|
|
123
|
+
okChatResponse(VALID_RECIPE_JSON, {
|
|
124
|
+
prompt_tokens: 120,
|
|
125
|
+
completion_tokens: 45,
|
|
126
|
+
})) as unknown as typeof fetch;
|
|
121
127
|
|
|
122
128
|
const backend = new LocalSLMBackend({
|
|
123
129
|
endpoints: [{ name: "lm-studio-fake", baseUrl: "http://fake/v1", model: "test" }],
|
|
@@ -146,7 +152,8 @@ describe("LocalSLMBackend — infer", () => {
|
|
|
146
152
|
test("falls back to the second endpoint when the first fails", async () => {
|
|
147
153
|
let call = 0;
|
|
148
154
|
const fetchImpl = (async (input: string | URL | Request) => {
|
|
149
|
-
const url =
|
|
155
|
+
const url =
|
|
156
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
150
157
|
call++;
|
|
151
158
|
if (url.includes("primary")) {
|
|
152
159
|
throw new Error("ECONNREFUSED");
|
|
@@ -169,6 +176,44 @@ describe("LocalSLMBackend — infer", () => {
|
|
|
169
176
|
expect(call).toBeGreaterThanOrEqual(2);
|
|
170
177
|
});
|
|
171
178
|
|
|
179
|
+
test.each(["invalid UTF-8", "replacement character"])(
|
|
180
|
+
"falls back when a 200 response contains an unusable %s assistant message",
|
|
181
|
+
async (kind) => {
|
|
182
|
+
const calls: string[] = [];
|
|
183
|
+
const fetchImpl = (async (input: string | URL | Request) => {
|
|
184
|
+
const url =
|
|
185
|
+
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
186
|
+
calls.push(url);
|
|
187
|
+
if (url.includes("secondary")) return okChatResponse(VALID_RECIPE_JSON);
|
|
188
|
+
|
|
189
|
+
// The envelope is valid JSON even after a malformed byte is decoded as U+FFFD.
|
|
190
|
+
const envelope = '{"choices":[{"message":{"content":"X"}}]}';
|
|
191
|
+
const bytes = new TextEncoder().encode(envelope);
|
|
192
|
+
if (kind === "invalid UTF-8") {
|
|
193
|
+
bytes[envelope.indexOf("X")] = 0xff;
|
|
194
|
+
return new Response(bytes, { status: 200 });
|
|
195
|
+
}
|
|
196
|
+
return new Response(envelope.replace("X", "\uFFFD"), { status: 200 });
|
|
197
|
+
}) as unknown as typeof fetch;
|
|
198
|
+
const backend = new LocalSLMBackend({
|
|
199
|
+
endpoints: [
|
|
200
|
+
{ name: "primary", baseUrl: "http://primary/v1", model: "a" },
|
|
201
|
+
{ name: "secondary", baseUrl: "http://secondary/v1", model: "b" },
|
|
202
|
+
],
|
|
203
|
+
fetchImpl,
|
|
204
|
+
costTracker: new CostTracker(),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const result = await backend.infer({ query: "x", catalogContext: "" });
|
|
208
|
+
expect(result.recipe?.providers.map((provider) => provider.name)).toEqual(["neon", "clerk"]);
|
|
209
|
+
expect(backend.lastEndpoint).toBe("secondary");
|
|
210
|
+
expect(calls).toEqual([
|
|
211
|
+
"http://primary/v1/chat/completions",
|
|
212
|
+
"http://secondary/v1/chat/completions",
|
|
213
|
+
]);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
172
217
|
test("throws NoInferenceBackendError when every endpoint fails", async () => {
|
|
173
218
|
const fetchImpl = (async () => {
|
|
174
219
|
throw new Error("down");
|
|
@@ -228,14 +273,15 @@ describe("LocalSLMBackend — infer", () => {
|
|
|
228
273
|
costTracker: new CostTracker(),
|
|
229
274
|
});
|
|
230
275
|
|
|
231
|
-
await expect(
|
|
232
|
-
|
|
233
|
-
)
|
|
276
|
+
await expect(backend.infer({ query: "x", catalogContext: "" })).rejects.toBeInstanceOf(
|
|
277
|
+
NoInferenceBackendError,
|
|
278
|
+
);
|
|
234
279
|
});
|
|
235
280
|
|
|
236
281
|
test("healthy pings /models and returns true when any endpoint responds", async () => {
|
|
237
282
|
const fetchImpl = (async (input: string | URL | Request) => {
|
|
238
|
-
const url =
|
|
283
|
+
const url =
|
|
284
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
239
285
|
if (url.endsWith("/models")) return new Response("[]", { status: 200 });
|
|
240
286
|
return new Response("", { status: 500 });
|
|
241
287
|
}) as unknown as typeof fetch;
|
|
@@ -252,19 +298,17 @@ describe("parseRecipeDraft", () => {
|
|
|
252
298
|
test("parses plain JSON", () => {
|
|
253
299
|
const draft = parseRecipeDraft(VALID_RECIPE_JSON);
|
|
254
300
|
expect(draft.providers).toHaveLength(2);
|
|
255
|
-
expect(draft.providers[0]
|
|
301
|
+
expect(draft.providers[0]?.name).toBe("neon");
|
|
256
302
|
});
|
|
257
303
|
|
|
258
304
|
test("tolerates markdown fences", () => {
|
|
259
|
-
const wrapped =
|
|
305
|
+
const wrapped = `\`\`\`json\n${VALID_RECIPE_JSON}\n\`\`\``;
|
|
260
306
|
const draft = parseRecipeDraft(wrapped);
|
|
261
307
|
expect(draft.providers).toHaveLength(2);
|
|
262
308
|
});
|
|
263
309
|
|
|
264
310
|
test("tolerates leading prose", () => {
|
|
265
|
-
const draft = parseRecipeDraft(
|
|
266
|
-
"Sure! Here's the recipe:\n" + VALID_RECIPE_JSON + "\nLet me know!",
|
|
267
|
-
);
|
|
311
|
+
const draft = parseRecipeDraft(`Sure! Here's the recipe:\n${VALID_RECIPE_JSON}\nLet me know!`);
|
|
268
312
|
expect(draft.providers).toHaveLength(2);
|
|
269
313
|
});
|
|
270
314
|
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { addService } from "../pipeline.ts";
|
|
7
|
+
import type { PromptRequest, Provider, ProviderContext } from "../providers/_base.ts";
|
|
8
|
+
import { promptSecret } from "../providers/_helpers.ts";
|
|
9
|
+
import { providers } from "../providers/index.ts";
|
|
10
|
+
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regression coverage for the interactive-auth hang: a provider's PAT/key
|
|
14
|
+
* paste must go through the host-supplied `ctx.prompt` (which the CLI pauses
|
|
15
|
+
* its spinner around) rather than writing to stderr and blocking on stdin
|
|
16
|
+
* behind a live spinner. See `promptSecret` + `ProviderContext.prompt`.
|
|
17
|
+
*/
|
|
18
|
+
describe("interactive credential prompting", () => {
|
|
19
|
+
test("promptSecret routes through ctx.prompt, trims, and marks the field secret", async () => {
|
|
20
|
+
const seen: PromptRequest[] = [];
|
|
21
|
+
const ctx: ProviderContext = {
|
|
22
|
+
cwd: ".",
|
|
23
|
+
interactive: true,
|
|
24
|
+
log: () => {},
|
|
25
|
+
prompt: async (req) => {
|
|
26
|
+
seen.push(req);
|
|
27
|
+
return " tok-abc123 "; // surrounding whitespace must be trimmed off
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const token = await promptSecret(ctx, {
|
|
32
|
+
message: "Paste your access token",
|
|
33
|
+
howTo: "create one at https://example.com/tokens",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(token).toBe("tok-abc123");
|
|
37
|
+
expect(seen).toHaveLength(1);
|
|
38
|
+
expect(seen[0]).toEqual({
|
|
39
|
+
message: "Paste your access token",
|
|
40
|
+
howTo: "create one at https://example.com/tokens",
|
|
41
|
+
secret: true,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("threaded through addService", () => {
|
|
46
|
+
let h: Harness;
|
|
47
|
+
let cwd: string;
|
|
48
|
+
let originalCwd: string;
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
h = setupFakePhantom();
|
|
52
|
+
cwd = mkdtempSync(join(tmpdir(), "stack-prompt-"));
|
|
53
|
+
originalCwd = process.cwd();
|
|
54
|
+
process.chdir(cwd);
|
|
55
|
+
await writeConfig(emptyConfig("test-template"), cwd);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
process.chdir(originalCwd);
|
|
60
|
+
Reflect.deleteProperty(providers, "promptsvc");
|
|
61
|
+
h.cleanup();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("addService threads prompt into ctx so login receives the host-entered value", async () => {
|
|
65
|
+
let promptCalls = 0;
|
|
66
|
+
let loginToken: string | undefined;
|
|
67
|
+
const stubProvider: Provider = {
|
|
68
|
+
name: "promptsvc",
|
|
69
|
+
displayName: "Prompt Service",
|
|
70
|
+
category: "database",
|
|
71
|
+
authKind: "pat",
|
|
72
|
+
async login(ctx) {
|
|
73
|
+
// Exactly what every real provider's PAT-paste path now does.
|
|
74
|
+
const token = await promptSecret(ctx, { message: "Paste your Prompt Service token" });
|
|
75
|
+
loginToken = token;
|
|
76
|
+
return { token, identity: { id: "user-1" } };
|
|
77
|
+
},
|
|
78
|
+
async provision() {
|
|
79
|
+
return { id: "res-1", displayName: "res-1" };
|
|
80
|
+
},
|
|
81
|
+
async materialize() {
|
|
82
|
+
// No secrets/mcp keeps the pipeline off the Phantom vault, so this
|
|
83
|
+
// assertion runs identically on every platform.
|
|
84
|
+
return { secrets: {} };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
providers.promptsvc = async () => stubProvider;
|
|
88
|
+
|
|
89
|
+
const result = await addService({
|
|
90
|
+
providerName: "promptsvc",
|
|
91
|
+
cwd,
|
|
92
|
+
interactive: true,
|
|
93
|
+
prompt: async () => {
|
|
94
|
+
promptCalls += 1;
|
|
95
|
+
return "host-entered-token";
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result.providerName).toBe("promptsvc");
|
|
100
|
+
// The host's prompt callback was invoked, and its value reached login via
|
|
101
|
+
// ctx → promptSecret — proof the wiring is end to end, not bypassed.
|
|
102
|
+
expect(promptCalls).toBe(1);
|
|
103
|
+
expect(loginToken).toBe("host-entered-token");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|