@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.
Files changed (101) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +217 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +162 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/cloudflare-provider.test.ts +9 -10
  10. package/src/__tests__/completion-ci.test.ts +239 -23
  11. package/src/__tests__/config-own-services.test.ts +183 -0
  12. package/src/__tests__/config-split.test.ts +206 -2
  13. package/src/__tests__/config.test.ts +2 -1
  14. package/src/__tests__/credential-staging.test.ts +659 -0
  15. package/src/__tests__/deps.test.ts +2 -1
  16. package/src/__tests__/detect-source.test.ts +1 -1
  17. package/src/__tests__/github-provider.test.ts +3 -2
  18. package/src/__tests__/helpers.test.ts +24 -4
  19. package/src/__tests__/http.test.ts +2 -2
  20. package/src/__tests__/inference.test.ts +19 -13
  21. package/src/__tests__/interactive-prompt.test.ts +106 -0
  22. package/src/__tests__/mcp-mirror.test.ts +116 -0
  23. package/src/__tests__/neon-provider.test.ts +136 -3
  24. package/src/__tests__/new-providers.test.ts +470 -0
  25. package/src/__tests__/oauth-port.test.ts +84 -0
  26. package/src/__tests__/openai-provider.test.ts +1 -1
  27. package/src/__tests__/phantom-contract.test.ts +177 -0
  28. package/src/__tests__/phantom-harness.test.ts +125 -8
  29. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  30. package/src/__tests__/phantom-wire.test.ts +31 -20
  31. package/src/__tests__/pipeline-integration.test.ts +1 -1
  32. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  33. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  34. package/src/__tests__/pipeline.test.ts +3 -1
  35. package/src/__tests__/pm-detect.test.ts +113 -0
  36. package/src/__tests__/provider-deprovision.test.ts +269 -0
  37. package/src/__tests__/provider-parity.test.ts +46 -0
  38. package/src/__tests__/publish-script.test.ts +659 -0
  39. package/src/__tests__/recipe.test.ts +4 -2
  40. package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
  41. package/src/__tests__/registry.test.ts +11 -2
  42. package/src/__tests__/release-artifacts.test.ts +55 -0
  43. package/src/__tests__/secret-redaction.test.ts +10 -10
  44. package/src/__tests__/security.test.ts +9 -10
  45. package/src/__tests__/supabase-provider.test.ts +6 -4
  46. package/src/__tests__/swap.test.ts +114 -0
  47. package/src/__tests__/telemetry.test.ts +354 -0
  48. package/src/__tests__/template-litellm-gateway.test.ts +41 -0
  49. package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
  50. package/src/__tests__/templates.test.ts +1 -1
  51. package/src/__tests__/turso-provider.test.ts +2 -5
  52. package/src/agent-onboard.ts +346 -0
  53. package/src/ai/catalog-index.ts +68 -10
  54. package/src/ai/circuit-breaker.ts +3 -6
  55. package/src/ai/cost-tracker.ts +6 -15
  56. package/src/ai/inference.ts +19 -11
  57. package/src/ai/phantom-wire.ts +29 -52
  58. package/src/ai/recipe.ts +69 -0
  59. package/src/base-urls.ts +71 -0
  60. package/src/catalog.ts +281 -2
  61. package/src/config.ts +31 -18
  62. package/src/detect-source.ts +36 -8
  63. package/src/detect.ts +13 -5
  64. package/src/http.ts +6 -5
  65. package/src/index.ts +15 -0
  66. package/src/mcp-writer.ts +97 -2
  67. package/src/oauth.ts +85 -53
  68. package/src/phantom.ts +193 -15
  69. package/src/pipeline.ts +177 -68
  70. package/src/pm-detect.ts +56 -0
  71. package/src/providers/_api-key.ts +18 -17
  72. package/src/providers/_base.ts +28 -1
  73. package/src/providers/_credential-stage.ts +237 -0
  74. package/src/providers/_helpers.ts +55 -9
  75. package/src/providers/anthropic.ts +2 -1
  76. package/src/providers/auth0.ts +27 -0
  77. package/src/providers/aws.ts +32 -33
  78. package/src/providers/cloudflare.ts +18 -37
  79. package/src/providers/convex.ts +1 -2
  80. package/src/providers/datadog.ts +29 -0
  81. package/src/providers/digitalocean.ts +30 -0
  82. package/src/providers/gcp.ts +36 -0
  83. package/src/providers/github.ts +37 -35
  84. package/src/providers/grafana.ts +29 -0
  85. package/src/providers/hetzner.ts +29 -0
  86. package/src/providers/index.ts +13 -1
  87. package/src/providers/launchdarkly.ts +33 -0
  88. package/src/providers/mixpanel.ts +37 -0
  89. package/src/providers/neon.ts +50 -26
  90. package/src/providers/openai.ts +2 -1
  91. package/src/providers/plausible.ts +26 -0
  92. package/src/providers/sentry.ts +17 -17
  93. package/src/providers/stripe.ts +1 -2
  94. package/src/providers/supabase.ts +66 -37
  95. package/src/providers/turso.ts +49 -38
  96. package/src/providers/vercel.ts +50 -20
  97. package/src/providers/workos.ts +27 -0
  98. package/src/registry.ts +7 -11
  99. package/src/swap.ts +108 -0
  100. package/src/telemetry.ts +267 -0
  101. 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 revealSecret("NEW_KEY")).toBe("new-value");
29
- expect(h.callsTo("add").length).toBe(1);
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 deletes from the fake vault", async () => {
40
- await removeSecret("EXISTING_KEY");
41
- const keys = await listSecrets();
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 { setupFakePhantom, type Harness } from "./_harness.ts";
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("calls `phantom add STRIPE_SECRET_KEY` and writes a webhook stub", async () => {
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
- expect(result.envelopes).toContain("stripe:STRIPE_SECRET_KEY");
29
-
30
- // Fake phantom treats `--rotate` as an unknown arg and succeeds anyway
31
- // (our fake echoes success for unknown cases via the `*` branch exit 0),
32
- // but we always log the call — so the `add` log should mention the key.
33
- const adds = harness.callsTo("add");
34
- const keys = adds.flatMap((c) => c.args);
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 envelopes and webhooks entirely", async () => {
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({ envelopes: [], webhooks: [], skipped: [] });
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("creates envelopes for every secret on a multi-secret provider", async () => {
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.envelopes).toEqual([
66
- "supabase:SUPABASE_URL",
67
- "supabase:SUPABASE_ANON_KEY",
68
- "supabase:SUPABASE_SERVICE_ROLE_KEY",
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.envelopes.length).toBeGreaterThan(0);
94
+ expect(result.missing.length).toBeGreaterThan(0);
84
95
  });
85
96
  });
@@ -90,6 +90,6 @@ describe("addService full loop", () => {
90
90
  const mcp = JSON.parse(mcpRaw) as { mcpServers: Record<string, unknown> };
91
91
  expect(mcp.mcpServers.stubsvc).toBeDefined();
92
92
 
93
- delete providers.stubsvc;
93
+ Reflect.deleteProperty(providers, "stubsvc");
94
94
  });
95
95
  });
@@ -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 regression: if `materialize()` throws after `provision()`
13
- * succeeded, we MUST still write a breadcrumb entry to .stack.toml so
14
- * `stack doctor --fix` / `stack remove` can find and clean up the dangling
15
- * upstream resource. Without this guard, the user ends up with a live
16
- * provider-side resource and no local record of it at all.
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("writes a partial entry when materialize throws post-provision", async () => {
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 expect(
56
- addService({ providerName: "partialsvc", cwd, interactive: false }),
57
- ).rejects.toThrow(/simulated materialize failure/);
57
+ const err = await addService({ providerName: "partialsvc", cwd, interactive: false }).catch(
58
+ (e: Error) => e,
59
+ );
58
60
 
59
- // Breadcrumb must be present so cleanup tooling can find the dangling resource.
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).toBeDefined();
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
- delete providers.partialsvc;
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 expect(
93
- addService({
94
- providerName: "drypartialsvc",
95
- cwd,
96
- interactive: false,
97
- persist: false,
98
- }),
99
- ).rejects.toThrow(/boom/);
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
- delete providers.drypartialsvc;
105
+ Reflect.deleteProperty(providers, "drypartialsvc");
105
106
  });
106
107
  });