@ashlr/stack-core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) 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 +279 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +208 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/catalog.test.ts +31 -0
  10. package/src/__tests__/cloudflare-provider.test.ts +32 -10
  11. package/src/__tests__/completion-ci.test.ts +239 -23
  12. package/src/__tests__/config-own-services.test.ts +183 -0
  13. package/src/__tests__/config-split.test.ts +272 -2
  14. package/src/__tests__/config.test.ts +94 -4
  15. package/src/__tests__/credential-staging.test.ts +660 -0
  16. package/src/__tests__/deps.test.ts +2 -1
  17. package/src/__tests__/detect-source.test.ts +183 -4
  18. package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
  19. package/src/__tests__/github-provider.test.ts +3 -2
  20. package/src/__tests__/helpers.test.ts +24 -4
  21. package/src/__tests__/http.test.ts +2 -2
  22. package/src/__tests__/import-integration.test.ts +18 -4
  23. package/src/__tests__/inference.test.ts +57 -13
  24. package/src/__tests__/interactive-prompt.test.ts +106 -0
  25. package/src/__tests__/mcp-mirror.test.ts +116 -0
  26. package/src/__tests__/neon-provider.test.ts +136 -3
  27. package/src/__tests__/new-providers.test.ts +470 -0
  28. package/src/__tests__/no-orphan-modules.test.ts +114 -0
  29. package/src/__tests__/oauth-port.test.ts +84 -0
  30. package/src/__tests__/openai-provider.test.ts +1 -1
  31. package/src/__tests__/phantom-contract.test.ts +177 -0
  32. package/src/__tests__/phantom-harness.test.ts +226 -8
  33. package/src/__tests__/phantom-process.test.ts +135 -0
  34. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  35. package/src/__tests__/phantom-wire.test.ts +403 -22
  36. package/src/__tests__/pipeline-integration.test.ts +33 -1
  37. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  38. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  39. package/src/__tests__/pipeline.test.ts +3 -1
  40. package/src/__tests__/pm-detect.test.ts +113 -0
  41. package/src/__tests__/provider-deprovision.test.ts +269 -0
  42. package/src/__tests__/provider-parity.test.ts +46 -0
  43. package/src/__tests__/publish-script.test.ts +704 -0
  44. package/src/__tests__/recipe.test.ts +27 -2
  45. package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
  46. package/src/__tests__/registry.test.ts +11 -2
  47. package/src/__tests__/release-artifacts.test.ts +55 -0
  48. package/src/__tests__/scan-missing-dir.test.ts +45 -0
  49. package/src/__tests__/secret-redaction.test.ts +10 -10
  50. package/src/__tests__/security.test.ts +9 -10
  51. package/src/__tests__/stack-manifest.test.ts +38 -0
  52. package/src/__tests__/supabase-provider.test.ts +6 -4
  53. package/src/__tests__/swap.test.ts +114 -0
  54. package/src/__tests__/telemetry.test.ts +354 -0
  55. package/src/__tests__/template-litellm-gateway.test.ts +70 -0
  56. package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
  57. package/src/__tests__/templates.test.ts +1 -1
  58. package/src/__tests__/turso-provider.test.ts +2 -5
  59. package/src/agent-onboard.ts +346 -0
  60. package/src/ai/catalog-index.ts +68 -10
  61. package/src/ai/circuit-breaker.ts +3 -6
  62. package/src/ai/cost-tracker.ts +6 -15
  63. package/src/ai/inference.ts +19 -11
  64. package/src/ai/phantom-wire.ts +73 -58
  65. package/src/ai/recipe.ts +69 -0
  66. package/src/base-urls.ts +71 -0
  67. package/src/catalog.ts +281 -2
  68. package/src/config.ts +31 -18
  69. package/src/detect-source.ts +50 -9
  70. package/src/detect.ts +13 -5
  71. package/src/errors.ts +6 -0
  72. package/src/http.ts +6 -5
  73. package/src/index.ts +16 -0
  74. package/src/mcp-writer.ts +97 -2
  75. package/src/oauth.ts +85 -53
  76. package/src/phantom-process.ts +72 -0
  77. package/src/phantom.ts +319 -30
  78. package/src/pipeline.ts +177 -68
  79. package/src/pm-detect.ts +56 -0
  80. package/src/providers/_api-key.ts +18 -17
  81. package/src/providers/_base.ts +28 -1
  82. package/src/providers/_credential-stage.ts +237 -0
  83. package/src/providers/_helpers.ts +55 -9
  84. package/src/providers/anthropic.ts +2 -1
  85. package/src/providers/auth0.ts +27 -0
  86. package/src/providers/aws.ts +32 -33
  87. package/src/providers/cloudflare.ts +18 -37
  88. package/src/providers/convex.ts +1 -2
  89. package/src/providers/datadog.ts +29 -0
  90. package/src/providers/digitalocean.ts +30 -0
  91. package/src/providers/gcp.ts +36 -0
  92. package/src/providers/github.ts +37 -35
  93. package/src/providers/grafana.ts +29 -0
  94. package/src/providers/hetzner.ts +29 -0
  95. package/src/providers/index.ts +13 -1
  96. package/src/providers/launchdarkly.ts +33 -0
  97. package/src/providers/mixpanel.ts +37 -0
  98. package/src/providers/neon.ts +50 -26
  99. package/src/providers/openai.ts +2 -1
  100. package/src/providers/plausible.ts +26 -0
  101. package/src/providers/sentry.ts +17 -17
  102. package/src/providers/stripe.ts +1 -2
  103. package/src/providers/supabase.ts +66 -37
  104. package/src/providers/turso.ts +49 -38
  105. package/src/providers/vercel.ts +50 -20
  106. package/src/providers/workos.ts +27 -0
  107. package/src/registry.ts +7 -11
  108. package/src/stack-manifest.ts +39 -0
  109. package/src/swap.ts +108 -0
  110. package/src/telemetry.ts +267 -0
  111. package/src/templates.ts +6 -2
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
+ }
@@ -1,7 +1,5 @@
1
1
  import type { ServiceEntry } from "../config.ts";
2
2
  import { StackError } from "../errors.ts";
3
- import { addSecret } from "../phantom.ts";
4
- import { readLine, tryRevealSecret } from "./_helpers.ts";
5
3
  import type {
6
4
  AuthHandle,
7
5
  HealthStatus,
@@ -12,6 +10,8 @@ import type {
12
10
  ProviderContext,
13
11
  Resource,
14
12
  } from "./_base.ts";
13
+ import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
14
+ import { promptSecret, tryRevealSecret } from "./_helpers.ts";
15
15
 
16
16
  /**
17
17
  * Factory for API-key-paste providers (OpenAI, Anthropic, xAI, DeepSeek, Resend,
@@ -54,10 +54,13 @@ export function makeApiKeyProvider(spec: ApiKeyProviderSpec): Provider {
54
54
  docs: spec.docs,
55
55
 
56
56
  async login(ctx: ProviderContext): Promise<AuthHandle> {
57
- const cached = await tryRevealSecret(spec.secretName);
57
+ const cached = await tryRevealSecret(spec.secretName, ctx);
58
58
  if (cached) {
59
59
  const identity = await spec.verify(cached);
60
- if (identity) return { token: cached, identity };
60
+ if (identity) {
61
+ acceptVerifiedExisting(ctx, spec.secretName, cached);
62
+ return { token: cached, identity };
63
+ }
61
64
  ctx.log({ level: "warn", msg: `Cached ${spec.displayName} key invalid; re-entering.` });
62
65
  }
63
66
  if (!ctx.interactive) {
@@ -66,20 +69,20 @@ export function makeApiKeyProvider(spec: ApiKeyProviderSpec): Provider {
66
69
  `${spec.displayName}: no valid key in vault and session is non-interactive.`,
67
70
  );
68
71
  }
69
- process.stderr.write(`\n ${spec.howTo}\n Paste your ${spec.displayName} API key: `);
70
- const key = (await readLine()).trim();
71
- if (!key) throw new StackError(`${spec.name.toUpperCase()}_AUTH_REQUIRED`, "No key provided.");
72
+ const key = await promptSecret(ctx, {
73
+ message: `Paste your ${spec.displayName} API key`,
74
+ howTo: spec.howTo,
75
+ });
76
+ if (!key)
77
+ throw new StackError(`${spec.name.toUpperCase()}_AUTH_REQUIRED`, "No key provided.");
72
78
  const identity = await spec.verify(key);
73
79
  if (!identity)
74
80
  throw new StackError(
75
81
  `${spec.name.toUpperCase()}_AUTH_INVALID`,
76
82
  `${spec.displayName} rejected that key.`,
77
83
  );
78
- // Persist the verified key immediately. Previously we deferred to
79
- // `materialize()`, so if any step after `login()` threw (e.g. a
80
- // provision-side API error) the user would have to re-paste their key.
81
- // Matches the hand-written providers (supabase, neon, vercel, github, sentry).
82
- await addSecret(spec.secretName, key);
84
+ // Explicit login writes now; addService owns deferred credential inputs.
85
+ await storeProviderSecret(ctx, spec.secretName, key);
83
86
  return { token: key, identity };
84
87
  },
85
88
 
@@ -95,8 +98,8 @@ export function makeApiKeyProvider(spec: ApiKeyProviderSpec): Provider {
95
98
  };
96
99
  },
97
100
 
98
- async materialize(_ctx, _resource, auth): Promise<Materialized> {
99
- await addSecret(spec.secretName, auth.token);
101
+ async materialize(ctx, _resource, auth): Promise<Materialized> {
102
+ await storeProviderSecret(ctx, spec.secretName, auth.token);
100
103
  return {
101
104
  secrets: { [spec.secretName]: auth.token },
102
105
  ...(spec.mcp ? { mcp: spec.mcp } : {}),
@@ -105,7 +108,7 @@ export function makeApiKeyProvider(spec: ApiKeyProviderSpec): Provider {
105
108
 
106
109
  async healthcheck(ctx, entry) {
107
110
  if (spec.healthcheck) return spec.healthcheck(ctx, entry);
108
- const key = await tryRevealSecret(spec.secretName);
111
+ const key = await tryRevealSecret(spec.secretName, ctx);
109
112
  if (!key) return { kind: "error", detail: `${spec.secretName} missing from vault` };
110
113
  const start = Date.now();
111
114
  const identity = await spec.verify(key);
@@ -118,5 +121,3 @@ export function makeApiKeyProvider(spec: ApiKeyProviderSpec): Provider {
118
121
  },
119
122
  };
120
123
  }
121
-
122
-
@@ -5,6 +5,7 @@ export type ProviderCategory =
5
5
  | "deploy"
6
6
  | "cloud"
7
7
  | "analytics"
8
+ | "observability"
8
9
  | "errors"
9
10
  | "ai"
10
11
  | "payments"
@@ -12,6 +13,7 @@ export type ProviderCategory =
12
13
  | "tickets"
13
14
  | "email"
14
15
  | "auth"
16
+ | "featureflags"
15
17
  | "comms";
16
18
 
17
19
  export type AuthKind = "oauth_pkce" | "oauth_device" | "pat" | "api_key" | "cli_shell";
@@ -22,6 +24,24 @@ export interface ProviderContext {
22
24
  interactive: boolean;
23
25
  /** Structured logger — callers wire this to the CLI's @clack spinner. */
24
26
  log: (event: LogEvent) => void;
27
+ /**
28
+ * Host-owned interactive prompt for credential entry. The CLI wires this to
29
+ * its @clack prompt and pauses any active spinner around it (mirrors `log`).
30
+ * Providers MUST route PAT/key paste through this rather than writing to
31
+ * stderr directly — a stderr prompt is clobbered by a running spinner, which
32
+ * makes `stack add` appear to hang. When absent (tests, non-CLI hosts),
33
+ * `promptSecret` falls back to a bare stdin read.
34
+ */
35
+ prompt?: (req: PromptRequest) => Promise<string>;
36
+ }
37
+
38
+ export interface PromptRequest {
39
+ /** One-line ask, e.g. "Paste your Supabase access token". */
40
+ message: string;
41
+ /** Optional guidance shown above the input (a URL, required scopes). */
42
+ howTo?: string;
43
+ /** Mask the input. Defaults to true — these are always secrets. */
44
+ secret?: boolean;
25
45
  }
26
46
 
27
47
  export interface LogEvent {
@@ -92,5 +112,12 @@ export interface Provider {
92
112
 
93
113
  healthcheck?(ctx: ProviderContext, entry: ServiceEntry): Promise<HealthStatus>;
94
114
  dashboardUrl?(entry: ServiceEntry): string;
95
- deprovision?(ctx: ProviderContext, entry: ServiceEntry, auth: AuthHandle): Promise<void>;
115
+
116
+ /**
117
+ * Tear down an upstream resource created by `provision`. Used by `addService`
118
+ * to roll back on partial failure. Optional — if the provider doesn't
119
+ * implement it, the dangling resource is logged and the user is directed to
120
+ * `stack doctor --fix`.
121
+ */
122
+ deprovision?(ctx: ProviderContext, auth: AuthHandle, resourceId: string): Promise<void>;
96
123
  }