@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
package/src/phantom.ts CHANGED
@@ -11,6 +11,16 @@ export interface PhantomExecResult {
11
11
  stdout: string;
12
12
  stderr: string;
13
13
  code: number;
14
+ signal?: NodeJS.Signals;
15
+ completion?: "exited" | "signal" | "unknown";
16
+ }
17
+
18
+ /** Interpret close fields only; this does not attest provider or vault state. */
19
+ export function completionForClose(
20
+ code: number | null,
21
+ signal: NodeJS.Signals | null,
22
+ ): NonNullable<PhantomExecResult["completion"]> {
23
+ return signal ? "signal" : code === null ? "unknown" : "exited";
14
24
  }
15
25
 
16
26
  let cachedInstalled: boolean | undefined;
@@ -19,7 +29,7 @@ export async function isPhantomInstalled(): Promise<boolean> {
19
29
  if (cachedInstalled !== undefined) return cachedInstalled;
20
30
  try {
21
31
  const result = await exec(["--version"], { allowFailure: true });
22
- cachedInstalled = result.code === 0;
32
+ cachedInstalled = result.code === 0 && result.completion === "exited";
23
33
  } catch {
24
34
  cachedInstalled = false;
25
35
  }
@@ -56,6 +66,9 @@ export async function exec(args: string[], opts: ExecOptions = {}): Promise<Phan
56
66
  stderr += chunk.toString();
57
67
  });
58
68
  if (opts.stdin !== undefined) {
69
+ // Phantom may refuse (e.g. an existing name) and exit before reading
70
+ // stdin; the exit code reports that, so a broken pipe is not fatal.
71
+ child.stdin?.on("error", () => {});
59
72
  child.stdin?.write(opts.stdin);
60
73
  child.stdin?.end();
61
74
  }
@@ -69,13 +82,22 @@ export async function exec(args: string[], opts: ExecOptions = {}): Promise<Phan
69
82
  reject(err);
70
83
  });
71
84
 
72
- child.on("close", (code) => {
73
- const result: PhantomExecResult = { stdout, stderr, code: code ?? 0 };
74
- if (code !== 0 && !opts.allowFailure) {
85
+ child.on("close", (code, signal) => {
86
+ // A signal/unknown exit is not evidence that a vault mutation committed.
87
+ const result: PhantomExecResult = {
88
+ stdout,
89
+ stderr,
90
+ code: signal ? code || 1 : (code ?? 1),
91
+ completion: completionForClose(code, signal),
92
+ ...(signal ? { signal } : {}),
93
+ };
94
+ if (result.code !== 0 && !opts.allowFailure) {
75
95
  reject(
76
96
  new StackError(
77
97
  "PHANTOM_EXEC_FAILED",
78
- `phantom ${formatArgsForError(args)} failed (exit ${code}): ${stderr.trim() || stdout.trim()}`,
98
+ result.completion === "exited"
99
+ ? `phantom ${formatArgsForError(args)} failed (exit ${result.code}): ${stderr.trim() || stdout.trim()}`
100
+ : `phantom ${formatArgsForError(args)} did not complete (${result.completion}); result unconfirmed`,
79
101
  ),
80
102
  );
81
103
  return;
@@ -85,29 +107,185 @@ export async function exec(args: string[], opts: ExecOptions = {}): Promise<Phan
85
107
  });
86
108
  }
87
109
 
110
+ /**
111
+ * Store a NEW secret. The value goes over stdin (`phantom add KEY --stdin`):
112
+ * Phantom Secrets >= 0.7.3 rejects a positional value because argv is visible
113
+ * to process inspection. Phantom never replaces an existing name, so a
114
+ * every completed collision surfaces as `PHANTOM_SECRET_EXISTS`. Historical
115
+ * input equality is never evidence that the current vault still has that value.
116
+ */
88
117
  export async function addSecret(key: string, value: string, cwd?: string): Promise<void> {
118
+ // Phantom 0.7.9 reads one stdin line and strips CR/LF. Refuse values it
119
+ // cannot preserve before any subprocess, rather than silently truncating.
120
+ if (/[\r\n]/.test(value)) {
121
+ throw new StackError(
122
+ "PHANTOM_SECRET_VALUE_UNSUPPORTED",
123
+ "Phantom Secrets accepts one line per secret; CR/LF values cannot be stored unchanged.",
124
+ );
125
+ }
89
126
  await assertPhantomInstalled();
90
- await exec(["add", key, value], { cwd });
127
+ const result = await exec(["add", key, "--stdin"], { cwd, stdin: value, allowFailure: true });
128
+ // Collision wording from an interrupted child cannot attest a completed
129
+ // create-only refusal.
130
+ if (result.completion !== "exited") {
131
+ throw new StackError(
132
+ "PHANTOM_EXEC_FAILED",
133
+ "Phantom add did not complete; secret storage is unconfirmed.",
134
+ );
135
+ }
136
+ if (result.code === 0) {
137
+ return;
138
+ }
139
+ const detail = result.stderr.trim() || result.stdout.trim();
140
+ if (/already protected|already exists/i.test(detail)) {
141
+ throw new StackError(
142
+ "PHANTOM_SECRET_EXISTS",
143
+ `${key} is already stored in Phantom, and Phantom never replaces a stored secret. Run \`phantom remove ${key}\` in your terminal, then retry.`,
144
+ );
145
+ }
146
+ throw new StackError(
147
+ "PHANTOM_EXEC_FAILED",
148
+ `phantom add ${key} --stdin failed (exit ${result.code}): ${detail}`,
149
+ );
91
150
  }
92
151
 
93
- export async function removeSecret(key: string, cwd?: string): Promise<void> {
152
+ /**
153
+ * Remove a secret. Phantom Secrets requires a typed confirmation in an
154
+ * attached terminal for `remove`, so headless callers (rollback, CI, agents)
155
+ * cannot remove anything. Pass `interactive: true` only from a human-driven
156
+ * CLI command with a TTY; Phantom then runs its own confirmation prompt.
157
+ * Returns true only when Phantom reports the secret removed.
158
+ */
159
+ export async function removeSecret(
160
+ key: string,
161
+ cwd?: string,
162
+ opts: { interactive?: boolean } = {},
163
+ ): Promise<boolean> {
94
164
  await assertPhantomInstalled();
95
- await exec(["remove", key], { cwd, allowFailure: true });
165
+ const result = await exec(["remove", key], {
166
+ cwd,
167
+ allowFailure: true,
168
+ interactive: opts.interactive === true,
169
+ });
170
+ const removed = result.code === 0 && result.completion === "exited";
171
+ return removed;
96
172
  }
97
173
 
174
+ /**
175
+ * Secret names in the vault, from `phantom list --json` (never values). The
176
+ * human `phantom list` output is a decorated table and is not parsed.
177
+ */
98
178
  export async function listSecrets(cwd?: string): Promise<string[]> {
99
179
  await assertPhantomInstalled();
100
- const { stdout } = await exec(["list"], { cwd, allowFailure: true });
101
- return stdout
102
- .split("\n")
103
- .map((line) => line.trim())
104
- .filter((line) => line.length > 0 && !line.startsWith("#"));
180
+ const { stdout, code } = await exec(["list", "--json"], { cwd, allowFailure: true });
181
+ if (code !== 0) {
182
+ throw new StackError(
183
+ "PHANTOM_METADATA_UNAVAILABLE",
184
+ "Phantom secret inventory is unavailable; stored names are unknown.",
185
+ );
186
+ }
187
+ let parsed: unknown;
188
+ try {
189
+ parsed = JSON.parse(stdout);
190
+ } catch {
191
+ throw new StackError(
192
+ "PHANTOM_OUTPUT_UNRECOGNIZED",
193
+ "phantom list --json did not return JSON; upgrade Phantom Secrets to 0.7.9 or newer.",
194
+ );
195
+ }
196
+ if (!Array.isArray(parsed)) {
197
+ throw new StackError(
198
+ "PHANTOM_OUTPUT_UNRECOGNIZED",
199
+ "phantom list --json did not return an array.",
200
+ );
201
+ }
202
+ const names: string[] = [];
203
+ for (const entry of parsed) {
204
+ if (
205
+ !entry ||
206
+ typeof entry !== "object" ||
207
+ Array.isArray(entry) ||
208
+ !("name" in entry) ||
209
+ typeof entry.name !== "string" ||
210
+ entry.name.length === 0
211
+ ) {
212
+ throw new StackError(
213
+ "PHANTOM_OUTPUT_UNRECOGNIZED",
214
+ "phantom list --json contains unrecognized secret-name metadata.",
215
+ );
216
+ }
217
+ names.push(entry.name);
218
+ }
219
+ return names;
220
+ }
221
+
222
+ /**
223
+ * Number of secrets in the vault, from `phantom list --json`. Null when the
224
+ * inventory cannot be read (Phantom missing, no .phantom.toml, unrecognized
225
+ * output), so callers never report an unreadable vault as empty.
226
+ */
227
+ export async function countSecrets(cwd?: string): Promise<number | null> {
228
+ try {
229
+ if (!(await isPhantomInstalled())) return null;
230
+ return (await listSecrets(cwd)).length;
231
+ } catch {
232
+ return null;
233
+ }
105
234
  }
106
235
 
236
+ /** Configuration observation only: no vault access, initialization or grants. */
237
+ export async function assertProjectInitialized(cwd?: string): Promise<void> {
238
+ await assertPhantomInstalled();
239
+ const result = await exec(["status", "--json"], { cwd, allowFailure: true });
240
+ let parsed: unknown;
241
+ try {
242
+ parsed = JSON.parse(result.stdout);
243
+ } catch {
244
+ parsed = undefined;
245
+ }
246
+ if (
247
+ result.code !== 0 ||
248
+ !parsed ||
249
+ typeof parsed !== "object" ||
250
+ Array.isArray(parsed) ||
251
+ !("schema_version" in parsed) ||
252
+ parsed.schema_version !== 1 ||
253
+ !("inspection" in parsed) ||
254
+ parsed.inspection !== "metadata-only" ||
255
+ !("initialized" in parsed) ||
256
+ typeof parsed.initialized !== "boolean"
257
+ ) {
258
+ throw new StackError(
259
+ "PHANTOM_METADATA_UNAVAILABLE",
260
+ "Phantom project initialization is unknown. Use Phantom Secrets 0.7.9 or newer and check its metadata status before retrying.",
261
+ );
262
+ }
263
+ if (!parsed.initialized) {
264
+ throw new StackError(
265
+ "PHANTOM_PROJECT_NOT_INITIALIZED",
266
+ "Phantom project is not initialized or its configuration is unreadable. Review `phantom status --json`; for a new project run `phantom init --empty` in a trusted terminal, then retry.",
267
+ );
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Read a secret value. Phantom Secrets >= 0.7.3 only reveals into an attached
273
+ * terminal after a typed confirmation, so from Stack (piped stdio) this fails
274
+ * with `PHANTOM_TRUSTED_TERMINAL_REQUIRED`. Callers must treat a stored value
275
+ * as unreadable and fall back to prompting.
276
+ */
107
277
  export async function revealSecret(key: string, cwd?: string): Promise<string> {
108
278
  await assertPhantomInstalled();
109
- const { stdout } = await exec(["reveal", key], { cwd });
110
- return stdout.trim();
279
+ const result = await exec(["reveal", key], { cwd, allowFailure: true });
280
+ if (result.code === 0) return result.stdout.trim();
281
+ const detail = result.stderr.trim();
282
+ if (/terminal/i.test(detail)) {
283
+ throw new StackError(
284
+ "PHANTOM_TRUSTED_TERMINAL_REQUIRED",
285
+ `Phantom only reveals ${key} in an attached terminal after typed confirmation; Stack cannot read it.`,
286
+ );
287
+ }
288
+ throw new StackError("PHANTOM_EXEC_FAILED", `phantom reveal ${key} failed (exit ${result.code})`);
111
289
  }
112
290
 
113
291
  export async function ensureInitialized(cwd?: string): Promise<void> {
package/src/pipeline.ts CHANGED
@@ -1,14 +1,18 @@
1
- import {
2
- type ServiceEntry,
3
- type StackConfig,
4
- readConfig,
5
- writeConfig,
6
- } from "./config.ts";
1
+ import { resolve } from "node:path";
2
+ import { type ServiceEntry, type StackConfig, readConfig, writeConfig } from "./config.ts";
7
3
  import { StackError } from "./errors.ts";
8
- import { addSecret, assertPhantomInstalled } from "./phantom.ts";
9
- import { mergeMcpEntry } from "./mcp-writer.ts";
4
+ import { mergeMcpEntry, removeMcpEntry } from "./mcp-writer.ts";
5
+ import { assertPhantomInstalled, assertProjectInitialized, removeSecret } from "./phantom.ts";
6
+ import type { LogEvent, PromptRequest, ProviderContext } from "./providers/_base.ts";
7
+ import {
8
+ beginCredentialStage,
9
+ closeCredentialStage,
10
+ commitOrdinaryCredential,
11
+ commitResourceCredentials,
12
+ credentialPhase,
13
+ ordinaryCredentialPlan,
14
+ } from "./providers/_credential-stage.ts";
10
15
  import { getProvider } from "./providers/index.ts";
11
- import type { LogEvent, ProviderContext } from "./providers/_base.ts";
12
16
 
13
17
  export interface AddServiceOpts {
14
18
  providerName: string;
@@ -17,6 +21,8 @@ export interface AddServiceOpts {
17
21
  existingResourceId?: string;
18
22
  hints?: Record<string, unknown>;
19
23
  log?: (event: LogEvent) => void;
24
+ /** Host-owned credential prompt. The CLI pauses its spinner around this. */
25
+ prompt?: (req: PromptRequest) => Promise<string>;
20
26
  /** If false, skip persisting to .stack.toml (used by dry-run / preview). */
21
27
  persist?: boolean;
22
28
  }
@@ -36,96 +42,199 @@ export interface AddServiceResult {
36
42
  * `stack templates apply` so behaviour stays in lockstep.
37
43
  */
38
44
  export async function addService(opts: AddServiceOpts): Promise<AddServiceResult> {
45
+ // Attachment never grants rollback authority to delete an upstream resource.
46
+ // Capture the request before awaits so a caller cannot change that custody.
47
+ const existingResourceId = opts.existingResourceId;
48
+ const cwd = resolve(opts.cwd ?? process.cwd());
39
49
  await assertPhantomInstalled();
40
50
  const provider = await getProvider(opts.providerName);
41
- const cwd = opts.cwd ?? process.cwd();
42
51
  const config: StackConfig = await readConfig(cwd);
43
52
 
44
53
  // Attaching to an existing resource id is always allowed — that's the path
45
54
  // `stack doctor --fix` uses to re-run the pipeline for a degraded service.
46
- if (config.services[provider.name] && !opts.existingResourceId) {
55
+ if (config.services[provider.name] && !existingResourceId) {
47
56
  throw new StackError(
48
57
  "SERVICE_ALREADY_ADDED",
49
58
  `${provider.displayName} is already in this stack. Pass existingResourceId to re-attach, or remove it first.`,
50
59
  );
51
60
  }
52
61
 
62
+ // Refuse unreadable/uninitialized project metadata before login, credential
63
+ // verification or provisioning. This observation does not authorize a vault.
64
+ await assertProjectInitialized(cwd);
65
+
53
66
  const ctx: ProviderContext = {
54
67
  cwd,
55
68
  interactive: opts.interactive ?? process.stdout.isTTY === true,
56
69
  log: opts.log ?? (() => {}),
70
+ prompt: opts.prompt,
57
71
  };
58
72
 
59
- const auth = await provider.login(ctx);
60
- const resource = await provider.provision(ctx, auth, {
61
- existingResourceId: opts.existingResourceId,
62
- hints: opts.hints,
63
- });
64
-
65
- // Provision succeeded: from here on, any failure leaves a dangling upstream
66
- // resource. Wrap the rest of the pipeline so we can persist a minimal
67
- // breadcrumb entry to .stack.toml before re-throwing. This is what lets
68
- // `stack doctor --fix` (or `stack remove`) clean up later — without the
69
- // breadcrumb, the user has a live resource on the provider side with no
70
- // local record at all.
73
+ beginCredentialStage(ctx);
71
74
  try {
72
- const materialized = await provider.materialize(ctx, resource, auth);
75
+ const auth = await provider.login(ctx).catch((err: unknown) => {
76
+ throw headlessAuthError(err, provider, ctx.interactive);
77
+ });
78
+ credentialPhase(ctx, "provision");
79
+ const resource = await provider.provision(ctx, auth, {
80
+ existingResourceId,
81
+ hints: opts.hints,
82
+ });
73
83
 
74
- for (const [key, value] of Object.entries(materialized.secrets)) {
75
- await addSecret(key, value, cwd);
76
- }
77
- if (materialized.mcp) await mergeMcpEntry(materialized.mcp, cwd);
84
+ // Provision returned: track local writes for best-effort failure cleanup.
85
+ // Track what has been written so the catch block can undo exactly that.
86
+ const writtenSecrets: string[] = [];
87
+ const criticalSecrets = new Set<string>();
88
+ let writtenMcp: string | undefined;
89
+ let failStep = "resource credential write";
78
90
 
79
- const entry: ServiceEntry = {
80
- provider: provider.name,
81
- resource_id: resource.id,
82
- region: resource.region,
83
- secrets: Object.keys(materialized.secrets),
84
- mcp: materialized.mcp?.name,
85
- meta: resource.meta,
86
- created_at: new Date().toISOString(),
87
- created_by: "stack add",
88
- };
91
+ try {
92
+ credentialPhase(ctx, "critical");
93
+ await commitResourceCredentials(ctx, resource, (key) => {
94
+ writtenSecrets.push(key);
95
+ criticalSecrets.add(key);
96
+ });
97
+ failStep = "materialize";
98
+ credentialPhase(ctx, "materialize");
99
+ const materialized = await provider.materialize(ctx, resource, auth);
100
+ const plan = ordinaryCredentialPlan(ctx, materialized.secrets);
101
+ credentialPhase(ctx, "commit");
102
+ failStep = "secret write";
89
103
 
90
- if (opts.persist !== false) {
91
- config.services[provider.name] = entry;
92
- await writeConfig(config, cwd);
93
- }
104
+ for (const [key, value] of plan) {
105
+ await commitOrdinaryCredential(ctx, key, value);
106
+ writtenSecrets.push(key);
107
+ }
108
+ failStep = "MCP write";
109
+ if (materialized.mcp) {
110
+ await mergeMcpEntry(materialized.mcp, cwd);
111
+ writtenMcp = materialized.mcp.name;
112
+ }
94
113
 
95
- return {
96
- providerName: provider.name,
97
- resourceId: resource.id,
98
- displayName: resource.displayName,
99
- secretCount: Object.keys(materialized.secrets).length,
100
- mcpWired: Boolean(materialized.mcp),
101
- entry,
102
- };
103
- } catch (err) {
104
- // Partial-failure breadcrumb: write just enough to .stack.toml that a
105
- // later `stack doctor --fix` or `stack remove` can find the dangling
106
- // upstream resource. We intentionally leave `secrets: []` — materialize
107
- // didn't run to completion, so we don't know which vault keys (if any)
108
- // were populated. Same goes for `mcp`: we don't know what the MCP entry
109
- // would have been, so we don't claim to have wired one.
110
- if (opts.persist !== false) {
111
- const partial: ServiceEntry = {
114
+ const entry: ServiceEntry = {
112
115
  provider: provider.name,
113
116
  resource_id: resource.id,
114
117
  region: resource.region,
115
- secrets: [],
118
+ secrets: Object.keys(materialized.secrets),
119
+ mcp: materialized.mcp?.name,
116
120
  meta: resource.meta,
117
121
  created_at: new Date().toISOString(),
118
- created_by: "stack add (partial)",
122
+ created_by: "stack add",
119
123
  };
120
- try {
121
- config.services[provider.name] = partial;
124
+
125
+ failStep = "config write";
126
+ if (opts.persist !== false) {
127
+ config.services[provider.name] = entry;
122
128
  await writeConfig(config, cwd);
123
- } catch {
124
- // If even writing the breadcrumb fails, swallow — the original
125
- // error below is the user-visible signal; we don't want to mask it
126
- // with a disk-write failure.
127
129
  }
130
+
131
+ return {
132
+ providerName: provider.name,
133
+ resourceId: resource.id,
134
+ displayName: resource.displayName,
135
+ secretCount: Object.keys(materialized.secrets).length,
136
+ mcpWired: Boolean(materialized.mcp),
137
+ entry,
138
+ };
139
+ } catch (err) {
140
+ // --- Best-effort rollback ---
141
+ // 1. Remove any Phantom secrets already written. Phantom only removes in
142
+ // an attached terminal, so headless rollback usually keeps them; name
143
+ // them in the error so the next attempt does not collide unexplained.
144
+ const keptSecrets: string[] = [];
145
+ let keptCriticalCount = 0;
146
+ for (const key of writtenSecrets) {
147
+ let removed = false;
148
+ try {
149
+ removed = await removeSecret(key, cwd);
150
+ } catch {
151
+ /* best-effort */
152
+ }
153
+ if (!removed) {
154
+ if (criticalSecrets.has(key)) keptCriticalCount++;
155
+ else keptSecrets.push(key);
156
+ }
157
+ }
158
+ const keptNote =
159
+ (keptSecrets.length > 0
160
+ ? ` Phantom kept ${keptSecrets.join(", ")}; run \`phantom remove <NAME>\` in a terminal for each before retrying.`
161
+ : "") +
162
+ (keptCriticalCount > 0
163
+ ? ` Phantom kept ${keptCriticalCount} resource credential(s); manual vault reconciliation is required before retrying.`
164
+ : "");
165
+ // 2. Remove the MCP entry if it was written.
166
+ if (writtenMcp) {
167
+ try {
168
+ await removeMcpEntry(writtenMcp, cwd);
169
+ } catch {
170
+ /* best-effort */
171
+ }
172
+ }
173
+ // 3. Tear down the upstream resource (if provider supports it).
174
+ if (existingResourceId) {
175
+ throw new StackError(
176
+ "ADD_SERVICE_PARTIAL_FAILURE",
177
+ `Reattaching ${provider.displayName} failed at step "${failStep}". The requested existing upstream resource was preserved; no automatic upstream teardown was attempted. Original error: ${(err as Error).message}${keptNote}`,
178
+ );
179
+ }
180
+ if (provider.deprovision) {
181
+ // If deprovision itself throws, we still want to surface the original
182
+ // failure AND note that teardown was incomplete — otherwise the caller
183
+ // has no idea whether the upstream resource is still live.
184
+ let teardownErr: Error | undefined;
185
+ try {
186
+ await provider.deprovision(ctx, auth, resource.id);
187
+ } catch (derr) {
188
+ teardownErr = derr as Error;
189
+ }
190
+ const teardownNote = teardownErr
191
+ ? `Attempted automatic teardown but it FAILED (${teardownErr.message}) — resource ${resource.id} may still exist. Clean it up manually on the ${provider.displayName} dashboard.`
192
+ : `Upstream resource ${resource.id} has been torn down.`;
193
+ throw new StackError(
194
+ teardownErr ? "ADD_SERVICE_PARTIAL_FAILURE" : "ADD_SERVICE_ROLLED_BACK",
195
+ `${teardownErr ? "Adding" : "Rolled back"} ${provider.displayName} after failure at step "${failStep}". ` +
196
+ `${teardownNote} ` +
197
+ `Original error: ${(err as Error).message}${keptNote}`,
198
+ );
199
+ }
200
+ // No deprovision support — warn and direct to manual cleanup.
201
+ ctx.log({
202
+ level: "warn",
203
+ msg: `[stack] Partial failure adding ${provider.displayName}. Upstream resource ID: ${resource.id}. This provider does not support automatic teardown — please delete it manually, then run \`stack doctor --fix\` to resync local state.`,
204
+ data: { provider: provider.name, resourceId: resource.id },
205
+ });
206
+ throw new StackError(
207
+ "ADD_SERVICE_PARTIAL_FAILURE",
208
+ `Adding ${provider.displayName} failed at step "${failStep}" after the upstream resource was created. ` +
209
+ `Resource ID: ${resource.id}. ` +
210
+ `Clean it up manually on the ${provider.displayName} dashboard, then run \`stack doctor --fix\`.${keptNote}`,
211
+ );
212
+ // .stack.toml is intentionally NOT written — the add was not atomic.
128
213
  }
129
- throw err;
214
+ } finally {
215
+ closeCredentialStage(ctx);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * A provider that needs a pasted key or a browser sign-in cannot get one in
221
+ * a session without a terminal (an agent's MCP call, CI), and Phantom will
222
+ * not hand a stored key back to one either. Say so and name the command a
223
+ * person can run, instead of only "no valid key".
224
+ */
225
+ export function headlessAuthError(
226
+ err: unknown,
227
+ provider: { name: string; displayName: string },
228
+ interactive: boolean,
229
+ command: "add" | "login" = "add",
230
+ ): unknown {
231
+ if (interactive || !(err instanceof StackError) || !err.code.endsWith("_AUTH_REQUIRED")) {
232
+ return err;
130
233
  }
234
+ const outcome = command === "add" ? "nothing was added" : "no credential was changed";
235
+ return new StackError(
236
+ err.code,
237
+ `${err.message} ${provider.displayName} needs a credential entered in an interactive terminal, and this session has none (agent or CI). Ask the user to run \`stack ${command} ${provider.name}\` in their own terminal, then retry; ${outcome}.`,
238
+ { cause: err },
239
+ );
131
240
  }
@@ -0,0 +1,56 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export type PackageManager = "bun" | "pnpm" | "npm" | "yarn";
5
+
6
+ /**
7
+ * Detect the PM used in a project by inspecting lockfiles. Walks up from
8
+ * `cwd` (default: process.cwd()) to find the nearest project root (dir
9
+ * containing package.json). Falls back to "npm" if nothing is found.
10
+ *
11
+ * Lockfile precedence:
12
+ * bun.lock > bun.lockb > pnpm-lock.yaml > yarn.lock > package-lock.json
13
+ */
14
+ export async function detectPackageManager(cwd?: string): Promise<PackageManager> {
15
+ const start = cwd ?? process.cwd();
16
+
17
+ // Walk up directory tree until we find a package.json or hit root.
18
+ let dir = start;
19
+ while (true) {
20
+ if (existsSync(join(dir, "bun.lock"))) return "bun";
21
+ if (existsSync(join(dir, "bun.lockb"))) return "bun";
22
+ if (existsSync(join(dir, "pnpm-lock.yaml"))) return "pnpm";
23
+ if (existsSync(join(dir, "yarn.lock"))) return "yarn";
24
+ if (existsSync(join(dir, "package-lock.json"))) return "npm";
25
+
26
+ // Stop walking if we found a package.json (this is the project root).
27
+ if (existsSync(join(dir, "package.json"))) break;
28
+
29
+ const parent = dirname(dir);
30
+ // Hit filesystem root — give up.
31
+ if (parent === dir) break;
32
+ dir = parent;
33
+ }
34
+
35
+ return "npm";
36
+ }
37
+
38
+ /**
39
+ * The install command argv for a given PM + packages. Examples:
40
+ * bun: ["bun", "add", "@supabase/supabase-js"]
41
+ * pnpm: ["pnpm", "add", "@supabase/supabase-js"]
42
+ * npm: ["npm", "install", "@supabase/supabase-js"]
43
+ * yarn: ["yarn", "add", "@supabase/supabase-js"]
44
+ */
45
+ export function installCommand(pm: PackageManager, packages: string[]): string[] {
46
+ switch (pm) {
47
+ case "bun":
48
+ return ["bun", "add", ...packages];
49
+ case "pnpm":
50
+ return ["pnpm", "add", ...packages];
51
+ case "yarn":
52
+ return ["yarn", "add", ...packages];
53
+ case "npm":
54
+ return ["npm", "install", ...packages];
55
+ }
56
+ }