@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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { isAbsolute, join } from "node:path";
|
|
6
|
+
import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
|
|
7
|
+
import { StackError } from "../errors.ts";
|
|
8
|
+
import {
|
|
9
|
+
__resetPhantomCache,
|
|
10
|
+
addSecret,
|
|
11
|
+
isPhantomInstalled,
|
|
12
|
+
listSecrets,
|
|
13
|
+
removeSecret,
|
|
14
|
+
revealSecret,
|
|
15
|
+
} from "../phantom.ts";
|
|
16
|
+
import { takeRevealRefusals, tryRevealSecret } from "../providers/_helpers.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Producer/consumer contract: Stack's Phantom wrapper against a REAL
|
|
20
|
+
* `phantom` binary (Phantom Secrets 0.7.9 or newer).
|
|
21
|
+
*
|
|
22
|
+
* The fake in `_harness.ts` encodes what Stack assumes Phantom does; this
|
|
23
|
+
* suite checks those assumptions against the producer. It is opt-in because
|
|
24
|
+
* CI does not install Phantom:
|
|
25
|
+
*
|
|
26
|
+
* STACK_PHANTOM_CONTRACT_BIN=/abs/path/to/phantom bun test phantom-contract
|
|
27
|
+
*
|
|
28
|
+
* The vault is an isolated encrypted-file vault under a temp HOME, unlocked
|
|
29
|
+
* with a throwaway passphrase. No keychain, network or real credential is used.
|
|
30
|
+
* Tests share that vault and run in order: later tests rely on names stored
|
|
31
|
+
* by earlier ones.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const realBin = process.env.STACK_PHANTOM_CONTRACT_BIN;
|
|
35
|
+
const enabled = Boolean(realBin && isAbsolute(realBin));
|
|
36
|
+
|
|
37
|
+
const SYNTHETIC_VALUE = "stack-contract-synthetic-value-7f3e";
|
|
38
|
+
|
|
39
|
+
describe.skipIf(!enabled)("phantom CLI contract (real binary)", () => {
|
|
40
|
+
let root = "";
|
|
41
|
+
let project = "";
|
|
42
|
+
const saved: Record<string, string | undefined> = {};
|
|
43
|
+
const cwd = process.cwd();
|
|
44
|
+
|
|
45
|
+
function phantom(args: string[], input?: string) {
|
|
46
|
+
return spawnSync("phantom", args, {
|
|
47
|
+
cwd: project,
|
|
48
|
+
input,
|
|
49
|
+
encoding: "utf-8",
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function vaultNames(): string[] {
|
|
55
|
+
const out = phantom(["list", "--json"]);
|
|
56
|
+
expect(out.status).toBe(0);
|
|
57
|
+
return (JSON.parse(out.stdout) as Array<{ name: string }>).map((e) => e.name).sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeAll(() => {
|
|
61
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "stack-phantom-contract-")));
|
|
62
|
+
const bin = join(root, "bin");
|
|
63
|
+
project = join(root, "project");
|
|
64
|
+
mkdirSync(bin);
|
|
65
|
+
mkdirSync(project);
|
|
66
|
+
mkdirSync(join(root, "home"));
|
|
67
|
+
symlinkSync(realBin as string, join(bin, "phantom"));
|
|
68
|
+
for (const k of ["PATH", "HOME", "PHANTOM_VAULT_PASSPHRASE", "RUST_BACKTRACE"]) {
|
|
69
|
+
saved[k] = process.env[k];
|
|
70
|
+
}
|
|
71
|
+
process.env.PATH = `${bin}:${process.env.PATH ?? ""}`;
|
|
72
|
+
process.env.HOME = join(root, "home");
|
|
73
|
+
process.env.PHANTOM_VAULT_PASSPHRASE = "stack-contract-throwaway-passphrase";
|
|
74
|
+
process.env.RUST_BACKTRACE = "0";
|
|
75
|
+
__resetPhantomCache();
|
|
76
|
+
spawnSync("git", ["init", "-q"], { cwd: project });
|
|
77
|
+
const init = phantom(["init", "--empty"]);
|
|
78
|
+
expect(init.status).toBe(0);
|
|
79
|
+
process.chdir(project);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(() => {
|
|
83
|
+
process.chdir(cwd);
|
|
84
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
85
|
+
if (v === undefined) delete process.env[k];
|
|
86
|
+
else process.env[k] = v;
|
|
87
|
+
}
|
|
88
|
+
__resetPhantomCache();
|
|
89
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("version probe detects the binary", async () => {
|
|
93
|
+
expect(await isPhantomInstalled()).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Argv is asserted against the fake in phantom-harness.test.ts; the real
|
|
97
|
+
// binary rejects a positional value, so storing at all proves stdin.
|
|
98
|
+
test("addSecret stores a new name", async () => {
|
|
99
|
+
await addSecret("STACK_CONTRACT_A", SYNTHETIC_VALUE, project);
|
|
100
|
+
expect(vaultNames()).toContain("STACK_CONTRACT_A");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("listSecrets returns exactly the stored names", async () => {
|
|
104
|
+
await addSecret("STACK_CONTRACT_B", SYNTHETIC_VALUE, project);
|
|
105
|
+
expect((await listSecrets(project)).sort()).toEqual(vaultNames());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("completed identical creates refuse with typed errors and preserve the full name inventory", async () => {
|
|
109
|
+
await addSecret("STACK_CONTRACT_C", SYNTHETIC_VALUE, project);
|
|
110
|
+
const names = vaultNames();
|
|
111
|
+
expect(names).toContain("STACK_CONTRACT_C");
|
|
112
|
+
for (let repeat = 0; repeat < 2; repeat++) {
|
|
113
|
+
let caught: unknown;
|
|
114
|
+
try {
|
|
115
|
+
await addSecret("STACK_CONTRACT_C", SYNTHETIC_VALUE, project);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
caught = err;
|
|
118
|
+
}
|
|
119
|
+
expect(caught).toBeInstanceOf(StackError);
|
|
120
|
+
if (!(caught instanceof StackError)) throw new Error("Expected a typed collision.");
|
|
121
|
+
expect(caught.code).toBe("PHANTOM_SECRET_EXISTS");
|
|
122
|
+
expect(caught.message).not.toContain(SYNTHETIC_VALUE);
|
|
123
|
+
expect(vaultNames()).toEqual(names);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Phantom offers no headless read, so only the name's survival is observable.
|
|
128
|
+
test("addSecret on an existing name fails with a typed error and keeps the name", async () => {
|
|
129
|
+
let caught: unknown;
|
|
130
|
+
try {
|
|
131
|
+
await addSecret("STACK_CONTRACT_A", "replacement-value", project);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
caught = err;
|
|
134
|
+
}
|
|
135
|
+
expect(caught).toBeInstanceOf(StackError);
|
|
136
|
+
expect((caught as StackError).code).toBe("PHANTOM_SECRET_EXISTS");
|
|
137
|
+
expect((caught as StackError).message).not.toContain("replacement-value");
|
|
138
|
+
expect(vaultNames()).toContain("STACK_CONTRACT_A");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("revealSecret without a terminal fails closed with a typed error", async () => {
|
|
142
|
+
let caught: unknown;
|
|
143
|
+
try {
|
|
144
|
+
await revealSecret("STACK_CONTRACT_A", project);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
caught = err;
|
|
147
|
+
}
|
|
148
|
+
expect((caught as StackError).code).toBe("PHANTOM_TRUSTED_TERMINAL_REQUIRED");
|
|
149
|
+
expect(String((caught as Error).message)).not.toContain(SYNTHETIC_VALUE);
|
|
150
|
+
takeRevealRefusals();
|
|
151
|
+
expect(await tryRevealSecret("STACK_CONTRACT_A")).toBeUndefined();
|
|
152
|
+
// Doctor relies on this to report "stored, unverified" instead of "missing".
|
|
153
|
+
expect(takeRevealRefusals()).toEqual(["STACK_CONTRACT_A"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("removeSecret without a terminal reports that nothing was removed", async () => {
|
|
157
|
+
expect(await removeSecret("STACK_CONTRACT_B", project)).toBe(false);
|
|
158
|
+
expect(vaultNames()).toContain("STACK_CONTRACT_B");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("recipe wiring never writes placeholder values into the vault", async () => {
|
|
162
|
+
const before = vaultNames();
|
|
163
|
+
const result = await wirePhantomForRecipe(
|
|
164
|
+
{
|
|
165
|
+
id: "contract",
|
|
166
|
+
query: "contract",
|
|
167
|
+
createdAt: new Date(0).toISOString(),
|
|
168
|
+
providers: [{ name: "openai" }],
|
|
169
|
+
},
|
|
170
|
+
{ cwd: project },
|
|
171
|
+
);
|
|
172
|
+
expect(result.present).toEqual([]);
|
|
173
|
+
expect(result.missing).toContain("openai:OPENAI_API_KEY");
|
|
174
|
+
expect(result.skipped).toEqual([]);
|
|
175
|
+
expect(vaultNames()).toEqual(before);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import {
|
|
3
5
|
addSecret,
|
|
6
|
+
assertProjectInitialized,
|
|
7
|
+
completionForClose,
|
|
8
|
+
exec,
|
|
4
9
|
isPhantomInstalled,
|
|
5
10
|
listSecrets,
|
|
6
11
|
removeSecret,
|
|
7
12
|
revealSecret,
|
|
8
13
|
} from "../phantom.ts";
|
|
9
|
-
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
14
|
+
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
10
15
|
|
|
11
16
|
describe("phantom wrapper against the fake harness", () => {
|
|
12
17
|
let h: Harness;
|
|
@@ -23,10 +28,123 @@ describe("phantom wrapper against the fake harness", () => {
|
|
|
23
28
|
expect(await isPhantomInstalled()).toBe(true);
|
|
24
29
|
});
|
|
25
30
|
|
|
26
|
-
test("addSecret writes to the fake vault", async () => {
|
|
31
|
+
test("addSecret writes to the fake vault over stdin, never argv", async () => {
|
|
27
32
|
await addSecret("NEW_KEY", "new-value");
|
|
28
|
-
expect(await
|
|
29
|
-
expect(h.callsTo("add")
|
|
33
|
+
expect((await readVault(h.dir)).NEW_KEY).toBe("new-value");
|
|
34
|
+
expect(h.callsTo("add")).toEqual([{ cmd: "add", args: ["NEW_KEY", "--stdin"] }]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test.each(["one\ntwo", "one\rtwo", "one\r\ntwo", "one\n", "one\r"])(
|
|
38
|
+
"addSecret refuses unsupported CR/LF input before spawning (%j)",
|
|
39
|
+
async (value) => {
|
|
40
|
+
await expect(addSecret("NEW_KEY", value)).rejects.toMatchObject({
|
|
41
|
+
code: "PHANTOM_SECRET_VALUE_UNSUPPORTED",
|
|
42
|
+
});
|
|
43
|
+
expect(h.calls()).toEqual([]);
|
|
44
|
+
expect((await readVault(h.dir)).NEW_KEY).toBeUndefined();
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
test("the inert producer fixture matches 0.7.9 read_line and CR/LF stripping", async () => {
|
|
49
|
+
await exec(["add", "DIRECT_KEY", "--stdin"], { stdin: "first\r\nsecond" });
|
|
50
|
+
expect((await readVault(h.dir)).DIRECT_KEY).toBe("first");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("signal-terminated add fails and never authorizes a later collision", async () => {
|
|
54
|
+
const bin = join(h.dir, "phantom");
|
|
55
|
+
const original = readFileSync(bin, "utf8");
|
|
56
|
+
writeFileSync(
|
|
57
|
+
bin,
|
|
58
|
+
'#!/bin/sh\ncase "$1" in --version) echo fixture;; *) kill -TERM $$;; esac\n',
|
|
59
|
+
);
|
|
60
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
61
|
+
code: "PHANTOM_EXEC_FAILED",
|
|
62
|
+
});
|
|
63
|
+
writeFileSync(h.vaultPath, JSON.stringify({ NEW_KEY: "foreign" }));
|
|
64
|
+
writeFileSync(bin, original);
|
|
65
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
66
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
67
|
+
});
|
|
68
|
+
expect((await readVault(h.dir)).NEW_KEY).toBe("foreign");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("signal-terminated remove reports failure; subsequent collision still refuses", async () => {
|
|
72
|
+
await addSecret("NEW_KEY", "same");
|
|
73
|
+
const bin = join(h.dir, "phantom");
|
|
74
|
+
const original = readFileSync(bin, "utf8");
|
|
75
|
+
writeFileSync(bin, "#!/bin/sh\nkill -TERM $$\n");
|
|
76
|
+
const result = await exec(["remove", "NEW_KEY"], { allowFailure: true });
|
|
77
|
+
expect(result).toMatchObject({ code: 1, signal: "SIGTERM", completion: "signal" });
|
|
78
|
+
expect(await removeSecret("NEW_KEY")).toBe(false);
|
|
79
|
+
writeFileSync(bin, original);
|
|
80
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
81
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
82
|
+
});
|
|
83
|
+
expect((await readVault(h.dir)).NEW_KEY).toBe("same");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("collision wording from a killed repeat never becomes confirmed success", async () => {
|
|
87
|
+
await addSecret("NEW_KEY", "same");
|
|
88
|
+
const bin = join(h.dir, "phantom");
|
|
89
|
+
const original = readFileSync(bin, "utf8");
|
|
90
|
+
writeFileSync(bin, "#!/bin/sh\necho 'already protected' >&2\nkill -TERM $$\n");
|
|
91
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
92
|
+
code: "PHANTOM_EXEC_FAILED",
|
|
93
|
+
message: "Phantom add did not complete; secret storage is unconfirmed.",
|
|
94
|
+
});
|
|
95
|
+
writeFileSync(bin, original);
|
|
96
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
97
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
98
|
+
});
|
|
99
|
+
expect((await readVault(h.dir)).NEW_KEY).toBe("same");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("null close fields remain unknown without a signal; signals never attest completion", () => {
|
|
103
|
+
expect(completionForClose(null, null)).toBe("unknown");
|
|
104
|
+
expect(completionForClose(null, "SIGTERM")).toBe("signal");
|
|
105
|
+
expect(completionForClose(0, "SIGTERM")).toBe("signal");
|
|
106
|
+
expect(completionForClose(0, null)).toBe("exited");
|
|
107
|
+
expect(completionForClose(9, null)).toBe("exited");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("initialization preflight consumes only metadata status", async () => {
|
|
111
|
+
await assertProjectInitialized(h.dir);
|
|
112
|
+
expect(h.callsTo("status")).toEqual([{ cmd: "status", args: ["--json"] }]);
|
|
113
|
+
expect(h.callsTo("list")).toEqual([]);
|
|
114
|
+
expect(h.callsTo("init")).toEqual([]);
|
|
115
|
+
expect(h.callsTo("reveal")).toEqual([]);
|
|
116
|
+
expect(h.callsTo("add")).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("failed inventory stays unknown instead of appearing empty", async () => {
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(h.dir, "phantom"),
|
|
122
|
+
'#!/bin/sh\ncase "$1" in --version) echo fixture;; *) exit 9;; esac\n',
|
|
123
|
+
);
|
|
124
|
+
await expect(listSecrets()).rejects.toMatchObject({ code: "PHANTOM_METADATA_UNAVAILABLE" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("addSecret of an existing name fails with PHANTOM_SECRET_EXISTS", async () => {
|
|
128
|
+
await expect(addSecret("EXISTING_KEY", "other")).rejects.toMatchObject({
|
|
129
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
130
|
+
});
|
|
131
|
+
expect((await readVault(h.dir)).EXISTING_KEY).toBe("preset-value");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("addSecret refuses identical and different completed repeats", async () => {
|
|
135
|
+
await addSecret("NEW_KEY", "same");
|
|
136
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
137
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
138
|
+
});
|
|
139
|
+
await expect(addSecret("NEW_KEY", "different")).rejects.toMatchObject({
|
|
140
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("revealSecret fails closed without a terminal", async () => {
|
|
145
|
+
await expect(revealSecret("EXISTING_KEY")).rejects.toMatchObject({
|
|
146
|
+
code: "PHANTOM_TRUSTED_TERMINAL_REQUIRED",
|
|
147
|
+
});
|
|
30
148
|
});
|
|
31
149
|
|
|
32
150
|
test("listSecrets returns vault keys", async () => {
|
|
@@ -36,9 +154,8 @@ describe("phantom wrapper against the fake harness", () => {
|
|
|
36
154
|
expect(keys).toContain("ANOTHER");
|
|
37
155
|
});
|
|
38
156
|
|
|
39
|
-
test("removeSecret
|
|
40
|
-
await removeSecret("EXISTING_KEY");
|
|
41
|
-
|
|
42
|
-
expect(keys).not.toContain("EXISTING_KEY");
|
|
157
|
+
test("removeSecret without a terminal reports false and keeps the secret", async () => {
|
|
158
|
+
expect(await removeSecret("EXISTING_KEY")).toBe(false);
|
|
159
|
+
expect(await listSecrets()).toContain("EXISTING_KEY");
|
|
43
160
|
});
|
|
44
161
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { emptyConfig, readConfig, writeConfig } from "../config.ts";
|
|
6
|
+
import { addSecret } from "../phantom.ts";
|
|
7
|
+
import { addService } from "../pipeline.ts";
|
|
8
|
+
import type { Provider } from "../providers/_base.ts";
|
|
9
|
+
import { providers } from "../providers/index.ts";
|
|
10
|
+
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
11
|
+
|
|
12
|
+
describe("project metadata before provider contact", () => {
|
|
13
|
+
let h: Harness;
|
|
14
|
+
let cwd: string;
|
|
15
|
+
const contacts = { login: 0, provision: 0, materialize: 0, deprovision: 0 };
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
h = setupFakePhantom({}, { initialized: false });
|
|
19
|
+
cwd = mkdtempSync(join(tmpdir(), "stack-preflight-"));
|
|
20
|
+
await writeConfig(emptyConfig(), cwd);
|
|
21
|
+
contacts.login = contacts.provision = contacts.materialize = contacts.deprovision = 0;
|
|
22
|
+
const provider: Provider = {
|
|
23
|
+
name: "preflightfixture",
|
|
24
|
+
displayName: "Inert fixture",
|
|
25
|
+
category: "ai",
|
|
26
|
+
authKind: "api_key",
|
|
27
|
+
async login() {
|
|
28
|
+
contacts.login++;
|
|
29
|
+
return { token: "synthetic" };
|
|
30
|
+
},
|
|
31
|
+
async provision() {
|
|
32
|
+
contacts.provision++;
|
|
33
|
+
return { id: "synthetic", displayName: "fixture" };
|
|
34
|
+
},
|
|
35
|
+
async materialize() {
|
|
36
|
+
contacts.materialize++;
|
|
37
|
+
return { secrets: { NEW_KEY: "synthetic" } };
|
|
38
|
+
},
|
|
39
|
+
async deprovision() {
|
|
40
|
+
contacts.deprovision++;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
providers.preflightfixture = async () => provider;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
Reflect.deleteProperty(providers, "preflightfixture");
|
|
48
|
+
h.cleanup();
|
|
49
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("uninitialized config refuses login and provisioning without initializing a vault", async () => {
|
|
53
|
+
const before = readFileSync(join(cwd, ".stack.toml"), "utf8");
|
|
54
|
+
await expect(addService({ providerName: "preflightfixture", cwd })).rejects.toMatchObject({
|
|
55
|
+
code: "PHANTOM_PROJECT_NOT_INITIALIZED",
|
|
56
|
+
});
|
|
57
|
+
expect(contacts).toEqual({ login: 0, provision: 0, materialize: 0, deprovision: 0 });
|
|
58
|
+
expect(h.callsTo("status")).toEqual([{ cmd: "status", args: ["--json"] }]);
|
|
59
|
+
expect(h.callsTo("init")).toEqual([]);
|
|
60
|
+
expect(h.callsTo("add")).toEqual([]);
|
|
61
|
+
expect(h.callsTo("list")).toEqual([]);
|
|
62
|
+
expect(readFileSync(join(cwd, ".stack.toml"), "utf8")).toBe(before);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test.each([
|
|
66
|
+
"not-json",
|
|
67
|
+
'{"schema_version":1,"inspection":"metadata-only"}',
|
|
68
|
+
'{"schema_version":2,"inspection":"metadata-only","initialized":true}',
|
|
69
|
+
'{"schema_version":1,"inspection":"metadata-only","initialized":"true"}',
|
|
70
|
+
'{"schema_version":1,"inspection":"vault-health","initialized":true}',
|
|
71
|
+
])("unknown metadata refuses all provider contact (%s)", async (status) => {
|
|
72
|
+
writeFileSync(
|
|
73
|
+
join(h.dir, "phantom"),
|
|
74
|
+
`#!/bin/sh\ncase "$1" in --version) echo fixture;; *) printf '%s' '${status}';; esac\n`,
|
|
75
|
+
);
|
|
76
|
+
await expect(addService({ providerName: "preflightfixture", cwd })).rejects.toMatchObject({
|
|
77
|
+
code: "PHANTOM_METADATA_UNAVAILABLE",
|
|
78
|
+
});
|
|
79
|
+
expect(contacts).toEqual({ login: 0, provision: 0, materialize: 0, deprovision: 0 });
|
|
80
|
+
expect((await readConfig(cwd)).services.preflightfixture).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("signal-terminated storage never commits pipeline configuration", async () => {
|
|
84
|
+
writeFileSync(
|
|
85
|
+
join(h.dir, "phantom"),
|
|
86
|
+
`#!/bin/sh
|
|
87
|
+
case "$1" in
|
|
88
|
+
--version) echo fixture;;
|
|
89
|
+
status) echo '{"schema_version":1,"inspection":"metadata-only","initialized":true}';;
|
|
90
|
+
*) kill -TERM $$;;
|
|
91
|
+
esac
|
|
92
|
+
`,
|
|
93
|
+
);
|
|
94
|
+
await expect(addService({ providerName: "preflightfixture", cwd })).rejects.toMatchObject({
|
|
95
|
+
code: "ADD_SERVICE_ROLLED_BACK",
|
|
96
|
+
});
|
|
97
|
+
expect(contacts).toEqual({ login: 1, provision: 1, materialize: 1, deprovision: 1 });
|
|
98
|
+
expect((await readConfig(cwd)).services.preflightfixture).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("cached collision output followed by signal cannot commit pipeline configuration", async () => {
|
|
102
|
+
await addSecret("NEW_KEY", "synthetic", cwd);
|
|
103
|
+
writeFileSync(
|
|
104
|
+
join(h.dir, "phantom"),
|
|
105
|
+
`#!/bin/sh
|
|
106
|
+
case "$1" in
|
|
107
|
+
status) echo '{"schema_version":1,"inspection":"metadata-only","initialized":true}';;
|
|
108
|
+
*) echo 'already protected' >&2; kill -TERM $$;;
|
|
109
|
+
esac
|
|
110
|
+
`,
|
|
111
|
+
);
|
|
112
|
+
await expect(addService({ providerName: "preflightfixture", cwd })).rejects.toMatchObject({
|
|
113
|
+
code: "ADD_SERVICE_ROLLED_BACK",
|
|
114
|
+
});
|
|
115
|
+
expect(contacts).toEqual({ login: 1, provision: 1, materialize: 1, deprovision: 1 });
|
|
116
|
+
expect((await readConfig(cwd)).services.preflightfixture).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
|
|
5
5
|
import type { Recipe } from "../ai/recipe.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
7
7
|
|
|
8
8
|
describe("ai/phantom-wire", () => {
|
|
9
9
|
let harness: Harness;
|
|
@@ -22,17 +22,16 @@ describe("ai/phantom-wire", () => {
|
|
|
22
22
|
providers: [{ name: "stripe" }],
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
it("
|
|
25
|
+
it("never writes to the vault, reports the missing secret, and writes a webhook stub", async () => {
|
|
26
26
|
const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
expect(keys).toContain("STRIPE_SECRET_KEY");
|
|
28
|
+
// Phantom Secrets refuses values in argv and denies overwriting names, so
|
|
29
|
+
// apply must not try to plant placeholders. It only reads names.
|
|
30
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
31
|
+
expect(harness.callsTo("list")).toEqual([{ cmd: "list", args: ["--json"] }]);
|
|
32
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
33
|
+
expect(result.present).toEqual([]);
|
|
34
|
+
expect(result.missing).toContain("stripe:STRIPE_SECRET_KEY");
|
|
36
35
|
|
|
37
36
|
const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
|
|
38
37
|
expect(existsSync(stubPath)).toBe(true);
|
|
@@ -42,19 +41,22 @@ describe("ai/phantom-wire", () => {
|
|
|
42
41
|
expect(result.webhooks).toHaveLength(1);
|
|
43
42
|
});
|
|
44
43
|
|
|
45
|
-
it("noWire: true skips
|
|
44
|
+
it("noWire: true skips the vault check and webhooks entirely", async () => {
|
|
46
45
|
const result = await wirePhantomForRecipe(STRIPE_RECIPE, {
|
|
47
46
|
cwd: harness.dir,
|
|
48
47
|
noWire: true,
|
|
49
48
|
});
|
|
50
|
-
expect(result).toEqual({
|
|
49
|
+
expect(result).toEqual({ present: [], missing: [], webhooks: [], skipped: [] });
|
|
51
50
|
|
|
52
51
|
const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
|
|
53
52
|
expect(existsSync(stubPath)).toBe(false);
|
|
54
53
|
expect(harness.callsTo("add")).toEqual([]);
|
|
54
|
+
expect(harness.callsTo("list")).toEqual([]);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
it("
|
|
57
|
+
it("splits a multi-secret provider into present and missing", async () => {
|
|
58
|
+
harness.cleanup();
|
|
59
|
+
harness = setupFakePhantom({ SUPABASE_URL: "https://x.supabase.co", SUPABASE_ANON_KEY: "a" });
|
|
58
60
|
const recipe: Recipe = {
|
|
59
61
|
id: "supa",
|
|
60
62
|
query: "database",
|
|
@@ -62,15 +64,24 @@ describe("ai/phantom-wire", () => {
|
|
|
62
64
|
providers: [{ name: "supabase" }],
|
|
63
65
|
};
|
|
64
66
|
const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
|
|
65
|
-
expect(result.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
]);
|
|
67
|
+
expect(result.present).toEqual(["supabase:SUPABASE_URL", "supabase:SUPABASE_ANON_KEY"]);
|
|
68
|
+
expect(result.missing).toEqual(["supabase:SUPABASE_SERVICE_ROLE_KEY"]);
|
|
69
|
+
expect(result.skipped).toEqual([]);
|
|
70
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
70
71
|
// Supabase is webhook-capable in our moat list.
|
|
71
72
|
expect(result.webhooks).toHaveLength(1);
|
|
72
73
|
});
|
|
73
74
|
|
|
75
|
+
it("skips providers when the vault can't be listed, but still writes stubs", async () => {
|
|
76
|
+
writeFileSync(harness.vaultPath, "not json");
|
|
77
|
+
const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
|
|
78
|
+
expect(result.skipped).toEqual(["stripe"]);
|
|
79
|
+
expect(result.present).toEqual([]);
|
|
80
|
+
expect(result.missing).toEqual([]);
|
|
81
|
+
expect(result.webhooks).toHaveLength(1);
|
|
82
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
74
85
|
it("skips providers with no webhook stub without crashing", async () => {
|
|
75
86
|
const recipe: Recipe = {
|
|
76
87
|
id: "neon",
|
|
@@ -80,6 +91,6 @@ describe("ai/phantom-wire", () => {
|
|
|
80
91
|
};
|
|
81
92
|
const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
|
|
82
93
|
expect(result.webhooks).toEqual([]);
|
|
83
|
-
expect(result.
|
|
94
|
+
expect(result.missing.length).toBeGreaterThan(0);
|
|
84
95
|
});
|
|
85
96
|
});
|
|
@@ -9,11 +9,12 @@ import { providers } from "../providers/index.ts";
|
|
|
9
9
|
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Partial-failure
|
|
13
|
-
* succeeded, we MUST
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Partial-failure rollback: if `materialize()` throws after `provision()`
|
|
13
|
+
* succeeded, we MUST roll back atomically — tear down the upstream resource
|
|
14
|
+
* (if the provider implements `deprovision`), remove any written Phantom
|
|
15
|
+
* secrets, and NOT write to .stack.toml. The user gets a clear error message
|
|
16
|
+
* directing them to manual cleanup or `stack doctor --fix` if automatic
|
|
17
|
+
* teardown isn't available.
|
|
17
18
|
*/
|
|
18
19
|
describe("addService partial-failure breadcrumb", () => {
|
|
19
20
|
let h: Harness;
|
|
@@ -33,7 +34,7 @@ describe("addService partial-failure breadcrumb", () => {
|
|
|
33
34
|
h.cleanup();
|
|
34
35
|
});
|
|
35
36
|
|
|
36
|
-
test("
|
|
37
|
+
test("rolls back and does NOT write .stack.toml when materialize throws post-provision (no deprovision)", async () => {
|
|
37
38
|
const failingProvider: Provider = {
|
|
38
39
|
name: "partialsvc",
|
|
39
40
|
displayName: "Partial Service",
|
|
@@ -48,26 +49,24 @@ describe("addService partial-failure breadcrumb", () => {
|
|
|
48
49
|
async materialize() {
|
|
49
50
|
throw new Error("simulated materialize failure (e.g. keys endpoint 500)");
|
|
50
51
|
},
|
|
52
|
+
// No deprovision — exercises the "manual cleanup" warning path.
|
|
51
53
|
};
|
|
52
54
|
|
|
53
55
|
providers.partialsvc = async () => failingProvider;
|
|
54
56
|
|
|
55
|
-
await
|
|
56
|
-
|
|
57
|
-
)
|
|
57
|
+
const err = await addService({ providerName: "partialsvc", cwd, interactive: false }).catch(
|
|
58
|
+
(e: Error) => e,
|
|
59
|
+
);
|
|
58
60
|
|
|
59
|
-
//
|
|
61
|
+
// Error should reference the resource id and direct to manual cleanup.
|
|
62
|
+
expect((err as Error).message).toMatch(/partial-resource-42/);
|
|
63
|
+
expect((err as Error).message).toMatch(/stack doctor --fix/);
|
|
64
|
+
|
|
65
|
+
// .stack.toml must NOT have an entry — atomicity is the point.
|
|
60
66
|
const config = await readConfig(cwd);
|
|
61
|
-
expect(config.services.partialsvc).
|
|
62
|
-
expect(config.services.partialsvc.resource_id).toBe("partial-resource-42");
|
|
63
|
-
expect(config.services.partialsvc.provider).toBe("partialsvc");
|
|
64
|
-
expect(config.services.partialsvc.region).toBe("us-east-1");
|
|
65
|
-
// We don't know what was materialized, so secrets must be empty.
|
|
66
|
-
expect(config.services.partialsvc.secrets).toEqual([]);
|
|
67
|
-
// Marker lets `stack doctor` distinguish partial rows from healthy ones.
|
|
68
|
-
expect(config.services.partialsvc.created_by).toBe("stack add (partial)");
|
|
67
|
+
expect(config.services.partialsvc).toBeUndefined();
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
Reflect.deleteProperty(providers, "partialsvc");
|
|
71
70
|
});
|
|
72
71
|
|
|
73
72
|
test("non-persist runs do NOT leave a breadcrumb (dry-run / preview)", async () => {
|
|
@@ -85,22 +84,24 @@ describe("addService partial-failure breadcrumb", () => {
|
|
|
85
84
|
async materialize() {
|
|
86
85
|
throw new Error("boom");
|
|
87
86
|
},
|
|
87
|
+
// No deprovision — exercises the "manual cleanup" warning path.
|
|
88
88
|
};
|
|
89
89
|
|
|
90
90
|
providers.drypartialsvc = async () => failingProvider;
|
|
91
91
|
|
|
92
|
-
await
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
).
|
|
92
|
+
const err = await addService({
|
|
93
|
+
providerName: "drypartialsvc",
|
|
94
|
+
cwd,
|
|
95
|
+
interactive: false,
|
|
96
|
+
persist: false,
|
|
97
|
+
}).catch((e: Error) => e);
|
|
98
|
+
|
|
99
|
+
// Error must reference the resource id (manual cleanup path).
|
|
100
|
+
expect((err as Error).message).toMatch(/dry-resource-1/);
|
|
100
101
|
|
|
101
102
|
const config = await readConfig(cwd);
|
|
102
103
|
expect(config.services.drypartialsvc).toBeUndefined();
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
Reflect.deleteProperty(providers, "drypartialsvc");
|
|
105
106
|
});
|
|
106
107
|
});
|