@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,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,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");
|
|
@@ -228,14 +235,15 @@ describe("LocalSLMBackend — infer", () => {
|
|
|
228
235
|
costTracker: new CostTracker(),
|
|
229
236
|
});
|
|
230
237
|
|
|
231
|
-
await expect(
|
|
232
|
-
|
|
233
|
-
)
|
|
238
|
+
await expect(backend.infer({ query: "x", catalogContext: "" })).rejects.toBeInstanceOf(
|
|
239
|
+
NoInferenceBackendError,
|
|
240
|
+
);
|
|
234
241
|
});
|
|
235
242
|
|
|
236
243
|
test("healthy pings /models and returns true when any endpoint responds", async () => {
|
|
237
244
|
const fetchImpl = (async (input: string | URL | Request) => {
|
|
238
|
-
const url =
|
|
245
|
+
const url =
|
|
246
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
239
247
|
if (url.endsWith("/models")) return new Response("[]", { status: 200 });
|
|
240
248
|
return new Response("", { status: 500 });
|
|
241
249
|
}) as unknown as typeof fetch;
|
|
@@ -252,19 +260,17 @@ describe("parseRecipeDraft", () => {
|
|
|
252
260
|
test("parses plain JSON", () => {
|
|
253
261
|
const draft = parseRecipeDraft(VALID_RECIPE_JSON);
|
|
254
262
|
expect(draft.providers).toHaveLength(2);
|
|
255
|
-
expect(draft.providers[0]
|
|
263
|
+
expect(draft.providers[0]?.name).toBe("neon");
|
|
256
264
|
});
|
|
257
265
|
|
|
258
266
|
test("tolerates markdown fences", () => {
|
|
259
|
-
const wrapped =
|
|
267
|
+
const wrapped = `\`\`\`json\n${VALID_RECIPE_JSON}\n\`\`\``;
|
|
260
268
|
const draft = parseRecipeDraft(wrapped);
|
|
261
269
|
expect(draft.providers).toHaveLength(2);
|
|
262
270
|
});
|
|
263
271
|
|
|
264
272
|
test("tolerates leading prose", () => {
|
|
265
|
-
const draft = parseRecipeDraft(
|
|
266
|
-
"Sure! Here's the recipe:\n" + VALID_RECIPE_JSON + "\nLet me know!",
|
|
267
|
-
);
|
|
273
|
+
const draft = parseRecipeDraft(`Sure! Here's the recipe:\n${VALID_RECIPE_JSON}\nLet me know!`);
|
|
268
274
|
expect(draft.providers).toHaveLength(2);
|
|
269
275
|
});
|
|
270
276
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { mergeMcpEntry, removeMcpEntry } from "../mcp-writer.ts";
|
|
6
|
+
|
|
7
|
+
const tmp = () => mkdtempSync(join(tmpdir(), "stack-mirror-"));
|
|
8
|
+
const readJson = (p: string) => JSON.parse(readFileSync(p, "utf-8"));
|
|
9
|
+
|
|
10
|
+
describe("mcp-writer client mirroring", () => {
|
|
11
|
+
test("does not create client dirs that are absent", async () => {
|
|
12
|
+
const cwd = tmp();
|
|
13
|
+
await mergeMcpEntry({ name: "s", type: "stdio", command: "npx" }, cwd);
|
|
14
|
+
expect(existsSync(join(cwd, ".mcp.json"))).toBe(true);
|
|
15
|
+
expect(existsSync(join(cwd, ".cursor"))).toBe(false);
|
|
16
|
+
expect(existsSync(join(cwd, ".gemini"))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("mirrors into .cursor/mcp.json and .gemini/settings.json when dirs exist", async () => {
|
|
20
|
+
const cwd = tmp();
|
|
21
|
+
mkdirSync(join(cwd, ".cursor"));
|
|
22
|
+
mkdirSync(join(cwd, ".gemini"));
|
|
23
|
+
writeFileSync(
|
|
24
|
+
join(cwd, ".gemini", "settings.json"),
|
|
25
|
+
JSON.stringify({ theme: "dark", mcpServers: { other: { command: "o" } } }),
|
|
26
|
+
);
|
|
27
|
+
await mergeMcpEntry(
|
|
28
|
+
{ name: "s", type: "stdio", command: "npx", args: ["-y", "x"], env: { K: "v" } },
|
|
29
|
+
cwd,
|
|
30
|
+
);
|
|
31
|
+
const g = readJson(join(cwd, ".gemini", "settings.json"));
|
|
32
|
+
expect(g.theme).toBe("dark");
|
|
33
|
+
expect(g.mcpServers.other.command).toBe("o");
|
|
34
|
+
expect(g.mcpServers.s).toEqual({ command: "npx", args: ["-y", "x"], env: { K: "v" } });
|
|
35
|
+
expect(readJson(join(cwd, ".cursor", "mcp.json")).mcpServers.s.command).toBe("npx");
|
|
36
|
+
// idempotent
|
|
37
|
+
await mergeMcpEntry(
|
|
38
|
+
{ name: "s", type: "stdio", command: "npx", args: ["-y", "x"], env: { K: "v" } },
|
|
39
|
+
cwd,
|
|
40
|
+
);
|
|
41
|
+
expect(Object.keys(readJson(join(cwd, ".gemini", "settings.json")).mcpServers).sort()).toEqual([
|
|
42
|
+
"other",
|
|
43
|
+
"s",
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("gemini uses httpUrl for http and url for sse, with no type field", async () => {
|
|
48
|
+
const cwd = tmp();
|
|
49
|
+
mkdirSync(join(cwd, ".gemini"));
|
|
50
|
+
await mergeMcpEntry({ name: "h", type: "http", url: "https://a.example/mcp" }, cwd);
|
|
51
|
+
await mergeMcpEntry({ name: "e", type: "sse", url: "https://b.example/sse" }, cwd);
|
|
52
|
+
const m = readJson(join(cwd, ".gemini", "settings.json")).mcpServers;
|
|
53
|
+
expect(m.h).toEqual({ httpUrl: "https://a.example/mcp" });
|
|
54
|
+
expect(m.e).toEqual({ url: "https://b.example/sse" });
|
|
55
|
+
expect(readJson(join(cwd, ".mcp.json")).mcpServers.h.type).toBe("http");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a malformed sibling config is left untouched and does not fail the write", async () => {
|
|
59
|
+
const cwd = tmp();
|
|
60
|
+
mkdirSync(join(cwd, ".gemini"));
|
|
61
|
+
writeFileSync(join(cwd, ".gemini", "settings.json"), "{not json");
|
|
62
|
+
await mergeMcpEntry({ name: "s", type: "stdio", command: "npx" }, cwd);
|
|
63
|
+
expect(readFileSync(join(cwd, ".gemini", "settings.json"), "utf-8")).toBe("{not json");
|
|
64
|
+
expect(readJson(join(cwd, ".mcp.json")).mcpServers.s.command).toBe("npx");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("removeMcpEntry removes from mirrors and keeps other entries", async () => {
|
|
68
|
+
const cwd = tmp();
|
|
69
|
+
mkdirSync(join(cwd, ".cursor"));
|
|
70
|
+
mkdirSync(join(cwd, ".gemini"));
|
|
71
|
+
await mergeMcpEntry({ name: "a", type: "stdio", command: "x" }, cwd);
|
|
72
|
+
await mergeMcpEntry({ name: "b", type: "stdio", command: "y" }, cwd);
|
|
73
|
+
await removeMcpEntry("a", cwd);
|
|
74
|
+
for (const p of [
|
|
75
|
+
join(cwd, ".mcp.json"),
|
|
76
|
+
join(cwd, ".cursor", "mcp.json"),
|
|
77
|
+
join(cwd, ".gemini", "settings.json"),
|
|
78
|
+
]) {
|
|
79
|
+
const m = readJson(p).mcpServers;
|
|
80
|
+
expect(m.a).toBeUndefined();
|
|
81
|
+
expect(m.b.command).toBe("y");
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a user's own same-name entry is neither overwritten nor deleted", async () => {
|
|
86
|
+
// Cursor and Gemini configs are user-owned. A hand-written "github" entry
|
|
87
|
+
// must survive `stack add github` and its rollback or `stack remove`.
|
|
88
|
+
const cwd = tmp();
|
|
89
|
+
mkdirSync(join(cwd, ".cursor"));
|
|
90
|
+
mkdirSync(join(cwd, ".gemini"));
|
|
91
|
+
const mine = { command: "my-github-mcp", env: { TOKEN: "${env:GH}" } };
|
|
92
|
+
for (const p of [join(cwd, ".cursor", "mcp.json"), join(cwd, ".gemini", "settings.json")]) {
|
|
93
|
+
writeFileSync(p, JSON.stringify({ mcpServers: { github: mine } }));
|
|
94
|
+
}
|
|
95
|
+
await mergeMcpEntry({ name: "github", type: "stdio", command: "npx", args: ["-y", "gh"] }, cwd);
|
|
96
|
+
expect(readJson(join(cwd, ".mcp.json")).mcpServers.github.command).toBe("npx");
|
|
97
|
+
for (const p of [join(cwd, ".cursor", "mcp.json"), join(cwd, ".gemini", "settings.json")]) {
|
|
98
|
+
expect(readJson(p).mcpServers.github).toEqual(mine);
|
|
99
|
+
}
|
|
100
|
+
await removeMcpEntry("github", cwd);
|
|
101
|
+
expect(readJson(join(cwd, ".mcp.json")).mcpServers.github).toBeUndefined();
|
|
102
|
+
for (const p of [join(cwd, ".cursor", "mcp.json"), join(cwd, ".gemini", "settings.json")]) {
|
|
103
|
+
expect(readJson(p).mcpServers.github).toEqual(mine);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("an entry Stack mirrored earlier is updated when the provider entry changes", async () => {
|
|
108
|
+
const cwd = tmp();
|
|
109
|
+
mkdirSync(join(cwd, ".cursor"));
|
|
110
|
+
await mergeMcpEntry({ name: "s", type: "stdio", command: "npx", args: ["v1"] }, cwd);
|
|
111
|
+
await mergeMcpEntry({ name: "s", type: "stdio", command: "npx", args: ["v2"] }, cwd);
|
|
112
|
+
expect(readJson(join(cwd, ".cursor", "mcp.json")).mcpServers.s.args).toEqual(["v2"]);
|
|
113
|
+
await removeMcpEntry("s", cwd);
|
|
114
|
+
expect(readJson(join(cwd, ".cursor", "mcp.json")).mcpServers.s).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { emptyConfig, readConfig, writeConfig } from "../config.ts";
|
|
4
|
+
import { addService } from "../pipeline.ts";
|
|
3
5
|
import type { ProviderContext } from "../providers/_base.ts";
|
|
6
|
+
import { providers } from "../providers/index.ts";
|
|
7
|
+
import neon from "../providers/neon.ts";
|
|
4
8
|
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
5
9
|
|
|
10
|
+
const inertPreconnect: typeof fetch.preconnect = () => {
|
|
11
|
+
throw new Error("Unexpected fetch preconnect in inert fixture.");
|
|
12
|
+
};
|
|
13
|
+
|
|
6
14
|
describe("neon provider (mocked fetch)", () => {
|
|
7
15
|
let h: Harness;
|
|
8
16
|
let realFetch: typeof fetch;
|
|
@@ -14,6 +22,7 @@ describe("neon provider (mocked fetch)", () => {
|
|
|
14
22
|
|
|
15
23
|
afterEach(() => {
|
|
16
24
|
globalThis.fetch = realFetch;
|
|
25
|
+
Reflect.deleteProperty(providers, "neonrollbackfixture");
|
|
17
26
|
h.cleanup();
|
|
18
27
|
});
|
|
19
28
|
|
|
@@ -51,9 +60,60 @@ describe("neon provider (mocked fetch)", () => {
|
|
|
51
60
|
expect(materialized.secrets.NEON_API_KEY).toBe("neon_fake");
|
|
52
61
|
expect(materialized.secrets.DATABASE_URL).toBe(connectionUri);
|
|
53
62
|
|
|
54
|
-
// Provision
|
|
63
|
+
// Provision never writes the vault before pipeline rollback owns the resource.
|
|
55
64
|
const vault = await readVault(h.dir);
|
|
56
|
-
expect(vault[`NEON_DATABASE_URL_${projectId}`]).
|
|
65
|
+
expect(vault[`NEON_DATABASE_URL_${projectId}`]).toBeUndefined();
|
|
66
|
+
// DATABASE_URL is written once, by the pipeline from materialize(): Phantom
|
|
67
|
+
// never replaces a stored name, so provision must not claim it first.
|
|
68
|
+
expect(vault.DATABASE_URL).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("fresh URI storage failure reaches rollback after the created resource is returned", async () => {
|
|
72
|
+
const projectId = "proj_rollback";
|
|
73
|
+
await writeConfig(emptyConfig(), h.dir);
|
|
74
|
+
writeFileSync(h.vaultPath, JSON.stringify({ DATABASE_URL: "synthetic-existing" }));
|
|
75
|
+
const before = readFileSync(`${h.dir}/.stack.toml`, "utf8");
|
|
76
|
+
const calls = { created: 0, freshURI: 0, deleted: 0 };
|
|
77
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
78
|
+
const u = String(url);
|
|
79
|
+
const method = init?.method ?? "GET";
|
|
80
|
+
if (u.endsWith("/projects") && method === "POST") {
|
|
81
|
+
calls.created++;
|
|
82
|
+
return new Response(
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
project: { id: projectId, name: "inert", region_id: "fixture" },
|
|
85
|
+
connection_uris: [{ connection_uri: "synthetic-provision-uri" }],
|
|
86
|
+
}),
|
|
87
|
+
{ status: 201 },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (u.endsWith(`/projects/${projectId}/connection_uri`) && method === "GET") {
|
|
91
|
+
calls.freshURI++;
|
|
92
|
+
return new Response(JSON.stringify({ uri: "synthetic-fresh-uri" }), { status: 200 });
|
|
93
|
+
}
|
|
94
|
+
if (u.endsWith(`/projects/${projectId}`) && method === "DELETE") {
|
|
95
|
+
calls.deleted++;
|
|
96
|
+
return new Response("", { status: 200 });
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`unexpected inert ${method} route`);
|
|
99
|
+
}) as unknown as typeof fetch;
|
|
100
|
+
providers.neonrollbackfixture = async () => ({
|
|
101
|
+
...neon,
|
|
102
|
+
name: "neonrollbackfixture",
|
|
103
|
+
async login() {
|
|
104
|
+
return { token: "synthetic-key", identity: {} };
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
await expect(
|
|
108
|
+
addService({ providerName: "neonrollbackfixture", cwd: h.dir, interactive: false }),
|
|
109
|
+
).rejects.toMatchObject({ code: "ADD_SERVICE_ROLLED_BACK" });
|
|
110
|
+
expect(calls).toEqual({ created: 1, freshURI: 1, deleted: 1 });
|
|
111
|
+
expect(readFileSync(`${h.dir}/.stack.toml`, "utf8")).toBe(before);
|
|
112
|
+
expect((await readConfig(h.dir)).services.neonrollbackfixture).toBeUndefined();
|
|
113
|
+
const vault = await readVault(h.dir);
|
|
114
|
+
expect(vault.DATABASE_URL).toBe("synthetic-existing");
|
|
115
|
+
expect(vault[`NEON_DATABASE_URL_${projectId}`]).toBeUndefined();
|
|
116
|
+
expect(h.callsTo("add").map((call) => call.args[0])).toEqual(["NEON_API_KEY", "DATABASE_URL"]);
|
|
57
117
|
});
|
|
58
118
|
|
|
59
119
|
test("healthcheck returns error when the project 404s", async () => {
|
|
@@ -76,4 +136,77 @@ describe("neon provider (mocked fetch)", () => {
|
|
|
76
136
|
});
|
|
77
137
|
expect(status.kind).toBe("error");
|
|
78
138
|
});
|
|
139
|
+
test.each([204, 404])(
|
|
140
|
+
"DELETE %i is accepted exactly once without reading response bodies",
|
|
141
|
+
async (status) => {
|
|
142
|
+
let deletes = 0;
|
|
143
|
+
globalThis.fetch = Object.assign(
|
|
144
|
+
async (_url: unknown, init?: RequestInit) => {
|
|
145
|
+
expect(init?.method).toBe("DELETE");
|
|
146
|
+
deletes++;
|
|
147
|
+
return new Response(null, { status });
|
|
148
|
+
},
|
|
149
|
+
{ preconnect: inertPreconnect },
|
|
150
|
+
);
|
|
151
|
+
await expect(
|
|
152
|
+
neon.deprovision!(
|
|
153
|
+
{ cwd: h.dir, interactive: false, log() {} },
|
|
154
|
+
{ token: "synthetic-key" },
|
|
155
|
+
"inert-project",
|
|
156
|
+
),
|
|
157
|
+
).resolves.toBeUndefined();
|
|
158
|
+
expect(deletes).toBe(1);
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
test.each([403, 500])(
|
|
163
|
+
"DELETE %i refuses unconfirmed teardown without exposing response contents",
|
|
164
|
+
async (status) => {
|
|
165
|
+
let deletes = 0;
|
|
166
|
+
globalThis.fetch = Object.assign(
|
|
167
|
+
async (_url: unknown, init?: RequestInit) => {
|
|
168
|
+
expect(init?.method).toBe("DELETE");
|
|
169
|
+
deletes++;
|
|
170
|
+
return new Response("synthetic-private-response-must-not-surface", { status });
|
|
171
|
+
},
|
|
172
|
+
{ preconnect: inertPreconnect },
|
|
173
|
+
);
|
|
174
|
+
const error = await neon.deprovision!(
|
|
175
|
+
{ cwd: h.dir, interactive: false, log() {} },
|
|
176
|
+
{ token: "synthetic-key" },
|
|
177
|
+
"inert-project",
|
|
178
|
+
).catch((err: Error) => err);
|
|
179
|
+
expect(error).toMatchObject({ code: "NEON_DELETE_FAILED" });
|
|
180
|
+
expect((error as Error).message).toContain(String(status));
|
|
181
|
+
expect((error as Error).message).not.toContain("synthetic-private-response-must-not-surface");
|
|
182
|
+
expect(deletes).toBe(1);
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
test("network failure refuses teardown with a fixed safe error and no raw transport output", async () => {
|
|
187
|
+
let deletes = 0;
|
|
188
|
+
const logs: string[] = [];
|
|
189
|
+
globalThis.fetch = Object.assign(
|
|
190
|
+
async () => {
|
|
191
|
+
deletes++;
|
|
192
|
+
throw new Error("synthetic-sensitive-transport-detail");
|
|
193
|
+
},
|
|
194
|
+
{ preconnect: inertPreconnect },
|
|
195
|
+
);
|
|
196
|
+
const error = await neon.deprovision!(
|
|
197
|
+
{
|
|
198
|
+
cwd: h.dir,
|
|
199
|
+
interactive: false,
|
|
200
|
+
log(event) {
|
|
201
|
+
logs.push(event.msg);
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{ token: "synthetic-key" },
|
|
205
|
+
"inert-project",
|
|
206
|
+
).catch((err: Error) => err);
|
|
207
|
+
expect(error).toMatchObject({ code: "NEON_DELETE_UNCONFIRMED" });
|
|
208
|
+
expect((error as Error).message).not.toContain("synthetic-sensitive-transport-detail");
|
|
209
|
+
expect(logs.join(" ")).not.toContain("synthetic-sensitive-transport-detail");
|
|
210
|
+
expect(deletes).toBe(1);
|
|
211
|
+
});
|
|
79
212
|
});
|