@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
@@ -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
  }
@@ -0,0 +1,237 @@
1
+ import { resolve } from "node:path";
2
+ import { StackError } from "../errors.ts";
3
+ import { addSecret, revealSecret } from "../phantom.ts";
4
+ import type { ProviderContext, Resource } from "./_base.ts";
5
+
6
+ type Phase = "login" | "provision" | "critical" | "materialize" | "commit";
7
+ interface Scope {
8
+ cwd: string;
9
+ phase: Phase;
10
+ inputs: Map<string, string>;
11
+ candidates: Map<string, string>;
12
+ existing: Map<string, string>;
13
+ critical: Map<Resource, Map<string, string>>;
14
+ createdCritical: Map<string, string>;
15
+ }
16
+ const scopes = new WeakMap<ProviderContext, Scope>();
17
+ // Keep only a no-values tombstone after disposal: a retained operation context
18
+ // must never fall through to standalone persistence after its operation ends.
19
+ const managed = new WeakSet<ProviderContext>();
20
+
21
+ function active(ctx: ProviderContext): Scope | undefined {
22
+ const scope = scopes.get(ctx);
23
+ if (!scope && managed.has(ctx)) {
24
+ throw new StackError("CREDENTIAL_SCOPE_CLOSED", "Credential operation has already ended.");
25
+ }
26
+ return scope;
27
+ }
28
+ function required(ctx: ProviderContext): Scope {
29
+ const scope = active(ctx);
30
+ if (!scope) throw new StackError("CREDENTIAL_SCOPE_REQUIRED", "Credential operation is missing.");
31
+ return scope;
32
+ }
33
+ function conflict(): never {
34
+ throw new StackError(
35
+ "CREDENTIAL_INPUT_CONFLICT",
36
+ "Credential inputs disagree for one secret slot.",
37
+ );
38
+ }
39
+ function validate(value: string): void {
40
+ if (/[\r\n]/.test(value)) {
41
+ throw new StackError(
42
+ "PHANTOM_SECRET_VALUE_UNSUPPORTED",
43
+ "Phantom Secrets accepts one line per secret; CR/LF values cannot be stored unchanged.",
44
+ );
45
+ }
46
+ }
47
+ function merge(inputs: Map<string, string>, key: string, value: string): void {
48
+ validate(value);
49
+ if (inputs.has(key) && inputs.get(key) !== value) conflict();
50
+ inputs.set(key, value);
51
+ }
52
+
53
+ /** Internal source coordination only; no public receipt or vault attestation. */
54
+ export function beginCredentialStage(ctx: ProviderContext): void {
55
+ if (managed.has(ctx)) {
56
+ throw new StackError("CREDENTIAL_SCOPE_CLOSED", "Credential context cannot be reused.");
57
+ }
58
+ managed.add(ctx);
59
+ scopes.set(ctx, {
60
+ cwd: resolve(ctx.cwd),
61
+ phase: "login",
62
+ inputs: new Map(),
63
+ candidates: new Map(),
64
+ existing: new Map(),
65
+ critical: new Map(),
66
+ createdCritical: new Map(),
67
+ });
68
+ }
69
+ export function credentialPhase(ctx: ProviderContext, phase: Phase): void {
70
+ const scope = required(ctx);
71
+ scope.candidates.clear();
72
+ scope.phase = phase;
73
+ }
74
+ export function closeCredentialStage(ctx: ProviderContext): void {
75
+ const scope = scopes.get(ctx);
76
+ if (!scope) return;
77
+ scope.inputs.clear();
78
+ scope.candidates.clear();
79
+ scope.existing.clear();
80
+ for (const values of scope.critical.values()) values.clear();
81
+ scope.critical.clear();
82
+ scope.createdCritical.clear();
83
+ scopes.delete(ctx);
84
+ }
85
+
86
+ export async function readProviderSecret(ctx: ProviderContext, key: string): Promise<string> {
87
+ const scope = active(ctx);
88
+ const value = await revealSecret(key, scope?.cwd ?? ctx.cwd);
89
+ if (scope) {
90
+ if (active(ctx) !== scope) {
91
+ throw new StackError("CREDENTIAL_SCOPE_CLOSED", "Credential operation has already ended.");
92
+ }
93
+ if (scope.phase === "login" && value) scope.candidates.set(key, value);
94
+ }
95
+ return value;
96
+ }
97
+ /** Called only by the original cached branch after its fresh verifier succeeds.
98
+ * Exact input equality authorizes omitting this operation's duplicate create;
99
+ * it does not attest a concurrently unchanged vault or authenticate arbitrary JS.
100
+ */
101
+ export function acceptVerifiedExisting(ctx: ProviderContext, key: string, value: string): void {
102
+ const scope = active(ctx);
103
+ if (!scope) return;
104
+ if (scope.phase !== "login" || scope.candidates.get(key) !== value) {
105
+ throw new StackError(
106
+ "CREDENTIAL_EXISTING_UNCONFIRMED",
107
+ "Existing credential was not verified in this login.",
108
+ );
109
+ }
110
+ if (scope.inputs.has(key) && scope.inputs.get(key) !== value) conflict();
111
+ scope.existing.set(key, value);
112
+ scope.candidates.delete(key);
113
+ }
114
+
115
+ async function createCredential(key: string, value: string, cwd: string): Promise<void> {
116
+ try {
117
+ await addSecret(key, value, cwd);
118
+ } catch (err) {
119
+ // Producer output can contain input values. Preserve a recognized refusal
120
+ // code, never its raw message/cause or an upstream-derived secret name.
121
+ const code = err instanceof StackError ? err.code : "PHANTOM_EXEC_FAILED";
122
+ throw new StackError(
123
+ code,
124
+ code === "PHANTOM_SECRET_EXISTS"
125
+ ? "Credential storage was refused; the secret name already exists."
126
+ : "Credential storage could not be confirmed.",
127
+ );
128
+ }
129
+ }
130
+ export async function commitOrdinaryCredential(
131
+ ctx: ProviderContext,
132
+ key: string,
133
+ value: string,
134
+ ): Promise<void> {
135
+ const scope = required(ctx);
136
+ if (scope.phase !== "commit") {
137
+ throw new StackError("CREDENTIAL_PHASE_CLOSED", "Credential commit is not active.");
138
+ }
139
+ await createCredential(key, value, scope.cwd);
140
+ }
141
+
142
+ export async function storeProviderSecret(
143
+ ctx: ProviderContext,
144
+ key: string,
145
+ value: string,
146
+ ): Promise<void> {
147
+ const scope = active(ctx);
148
+ if (!scope) return createCredential(key, value, ctx.cwd);
149
+ if (scope.phase !== "login" && scope.phase !== "provision" && scope.phase !== "materialize") {
150
+ throw new StackError("CREDENTIAL_PHASE_CLOSED", "Credential input collection has ended.");
151
+ }
152
+ if (scope.existing.has(key) && scope.existing.get(key) !== value) conflict();
153
+ merge(scope.inputs, key, value);
154
+ }
155
+
156
+ /** The generated password is not recoverable from the API. Carry it privately
157
+ * on the exact returned resource until the pipeline owns failure cleanup.
158
+ * Standalone provision retains its immediate strict persistence contract.
159
+ */
160
+ export async function carryResourceCredential(
161
+ ctx: ProviderContext,
162
+ resource: Resource,
163
+ key: string,
164
+ value: string,
165
+ ): Promise<void> {
166
+ const scope = active(ctx);
167
+ if (!scope) return createCredential(key, value, ctx.cwd);
168
+ if (scope.phase !== "provision") {
169
+ throw new StackError("CREDENTIAL_PHASE_CLOSED", "Resource credential collection has ended.");
170
+ }
171
+ let values = scope.critical.get(resource);
172
+ if (!values) {
173
+ values = new Map();
174
+ scope.critical.set(resource, values);
175
+ }
176
+ merge(values, key, value);
177
+ }
178
+ export async function commitResourceCredentials(
179
+ ctx: ProviderContext,
180
+ resource: Resource,
181
+ created: (key: string) => void,
182
+ ): Promise<void> {
183
+ const scope = required(ctx);
184
+ if (scope.phase !== "critical") {
185
+ throw new StackError("CREDENTIAL_PHASE_CLOSED", "Resource credential commit is not active.");
186
+ }
187
+ const values = scope.critical.get(resource);
188
+ if (!values) return;
189
+ // Take once before the first await. Refusal or unknown completion is never
190
+ // converted into a successful write or retried from this private carry.
191
+ scope.critical.delete(resource);
192
+ try {
193
+ for (const [key, value] of values) {
194
+ if (scope.inputs.has(key) && scope.inputs.get(key) !== value) conflict();
195
+ if (scope.existing.has(key) || scope.createdCritical.has(key)) conflict();
196
+ try {
197
+ await addSecret(key, value, scope.cwd);
198
+ } catch {
199
+ // The generated name includes an upstream identifier. Do not reflect
200
+ // that name, producer output or the password into a failure diagnostic.
201
+ throw new StackError(
202
+ "RESOURCE_CREDENTIAL_UNCONFIRMED",
203
+ "Resource credential storage was refused or could not be confirmed.",
204
+ );
205
+ }
206
+ scope.createdCritical.set(key, value);
207
+ created(key);
208
+ }
209
+ } finally {
210
+ values.clear();
211
+ }
212
+ }
213
+
214
+ /** Validate the entire ordinary plan before its first create. Critical resource
215
+ * writes necessarily precede materialize; a later conflict uses owned rollback.
216
+ * Materialized slots stay first; auth-only slots follow, before MCP/config.
217
+ */
218
+ export function ordinaryCredentialPlan(
219
+ ctx: ProviderContext,
220
+ materialized: Record<string, string>,
221
+ ): Array<[string, string]> {
222
+ const scope = required(ctx);
223
+ const plan = new Map<string, string>();
224
+ for (const [key, value] of Object.entries(materialized)) merge(plan, key, value);
225
+ for (const [key, value] of scope.inputs) merge(plan, key, value);
226
+ for (const [key, value] of plan) {
227
+ if (scope.existing.has(key)) {
228
+ if (scope.existing.get(key) !== value) conflict();
229
+ plan.delete(key);
230
+ }
231
+ if (scope.createdCritical.has(key)) {
232
+ if (scope.createdCritical.get(key) !== value) conflict();
233
+ plan.delete(key);
234
+ }
235
+ }
236
+ return [...plan];
237
+ }
@@ -1,5 +1,8 @@
1
+ import { StackError } from "../errors.ts";
2
+ import { type RetryOptions, fetchWithRetry } from "../http.ts";
1
3
  import { revealSecret } from "../phantom.ts";
2
- import { fetchWithRetry, type RetryOptions } from "../http.ts";
4
+ import type { ProviderContext } from "./_base.ts";
5
+ import { readProviderSecret } from "./_credential-stage.ts";
3
6
 
4
7
  /**
5
8
  * Shared helpers for every hand-written provider. Before this existed, each
@@ -7,18 +10,61 @@ import { fetchWithRetry, type RetryOptions } from "../http.ts";
7
10
  * one source of truth keeps behaviour consistent.
8
11
  */
9
12
 
10
- export async function tryRevealSecret(key: string): Promise<string | undefined> {
13
+ /**
14
+ * Names whose stored value Phantom refused to reveal to this process (it only
15
+ * reveals into an attached terminal). Lets `stack doctor` tell "stored but
16
+ * unreadable here" apart from "missing", since `tryRevealSecret` returns
17
+ * `undefined` for both. Names only, never values.
18
+ */
19
+ const revealRefusals = new Set<string>();
20
+
21
+ /** Return and clear the names Phantom refused to reveal since the last call. */
22
+ export function takeRevealRefusals(): string[] {
23
+ const names = [...revealRefusals];
24
+ revealRefusals.clear();
25
+ return names;
26
+ }
27
+
28
+ export async function tryRevealSecret(
29
+ key: string,
30
+ ctx?: ProviderContext,
31
+ ): Promise<string | undefined> {
11
32
  try {
12
- const value = await revealSecret(key);
33
+ const value = ctx ? await readProviderSecret(ctx, key) : await revealSecret(key);
13
34
  return value.length > 0 ? value : undefined;
14
- } catch {
35
+ } catch (err) {
36
+ if (err instanceof StackError && err.code === "PHANTOM_TRUSTED_TERMINAL_REQUIRED") {
37
+ revealRefusals.add(key);
38
+ }
15
39
  return undefined;
16
40
  }
17
41
  }
18
42
 
19
43
  /**
20
- * Read a single line from stdin. Used by every provider's PAT-paste fallback
21
- * when no OAuth client is configured. Resumes stdin, reads until newline, then
44
+ * Prompt the user for a credential. When the host supplies `ctx.prompt` (the
45
+ * CLI always does), the request goes through it — the CLI pauses its spinner,
46
+ * runs a @clack prompt, then resumes. This is the ONLY safe way to read a
47
+ * credential while a spinner is running: writing the prompt to stderr directly
48
+ * gets clobbered by the spinner's repaint and `stack add` looks like it hung.
49
+ *
50
+ * Falls back to a bare stderr-prompt + stdin read for non-CLI hosts and tests,
51
+ * where no spinner is competing for the terminal.
52
+ */
53
+ export async function promptSecret(
54
+ ctx: ProviderContext,
55
+ req: { message: string; howTo?: string },
56
+ ): Promise<string> {
57
+ if (ctx.prompt) {
58
+ return (await ctx.prompt({ message: req.message, howTo: req.howTo, secret: true })).trim();
59
+ }
60
+ if (req.howTo) process.stderr.write(`\n ${req.howTo}\n`);
61
+ process.stderr.write(` ${req.message}: `);
62
+ return (await readLine()).trim();
63
+ }
64
+
65
+ /**
66
+ * Read a single line from stdin. Used by `promptSecret`'s non-CLI fallback when
67
+ * no host prompt is configured. Resumes stdin, reads until newline, then
22
68
  * pauses stdin so the parent process doesn't hang waiting for EOF.
23
69
  */
24
70
  export async function readLine(): Promise<string> {
@@ -42,7 +88,7 @@ export async function readLine(): Promise<string> {
42
88
  * Bearer-token providers. Providers with custom auth header shapes (Anthropic,
43
89
  * Upstash, Linear, Supabase PKCE) inline their own.
44
90
  */
45
- export function bearerJsonHeaders(token: string): HeadersInit {
91
+ export function bearerJsonHeaders(token: string): NonNullable<RequestInit["headers"]> {
46
92
  return {
47
93
  Authorization: `Bearer ${token}`,
48
94
  "content-type": "application/json",
@@ -58,7 +104,7 @@ export function bearerJsonHeaders(token: string): HeadersInit {
58
104
  * 429/5xx is always safe. Keeps each provider's `verify` free of retry glue.
59
105
  */
60
106
  export function verifyFetch(
61
- input: RequestInfo | URL,
107
+ input: string | URL | Request,
62
108
  init?: RequestInit,
63
109
  opts?: RetryOptions,
64
110
  ): Promise<Response> {
@@ -74,7 +120,7 @@ export function verifyFetch(
74
120
  * Usage: `ctx.log({ msg: \`token ${scrub(t)} rejected\` })` prints
75
121
  * `token ****abcd rejected`, never the raw secret.
76
122
  */
77
- export function scrub(value: string, keepLast: number = 4): string {
123
+ export function scrub(value: string, keepLast = 4): string {
78
124
  if (!value) return "";
79
125
  if (value.length <= keepLast) return "****";
80
126
  return `****${value.slice(-keepLast)}`;
@@ -1,3 +1,4 @@
1
+ import { anthropicBaseUrl } from "../base-urls.ts";
1
2
  import { makeApiKeyProvider } from "./_api-key.ts";
2
3
  import { verifyFetch } from "./_helpers.ts";
3
4
 
@@ -11,7 +12,7 @@ export default makeApiKeyProvider({
11
12
  dashboard: "https://console.anthropic.com",
12
13
  async verify(key) {
13
14
  try {
14
- const res = await verifyFetch("https://api.anthropic.com/v1/models", {
15
+ const res = await verifyFetch(`${anthropicBaseUrl()}/v1/models`, {
15
16
  headers: {
16
17
  "x-api-key": key,
17
18
  "anthropic-version": "2023-06-01",
@@ -0,0 +1,27 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "auth0",
6
+ displayName: "Auth0",
7
+ category: "auth",
8
+ docs: "https://auth0.com/docs/api/management/v2",
9
+ secretName: "AUTH0_DOMAIN",
10
+ howTo:
11
+ "Enter your Auth0 tenant domain, such as your-tenant.us.auth0.com. This adapter checks its discovery response and stores only that domain.",
12
+ dashboard: "https://manage.auth0.com",
13
+ async verify(key) {
14
+ // `key` here is the domain (e.g. "myapp.us.auth0.com").
15
+ // We hit the OIDC discovery endpoint — no credentials required — to confirm
16
+ // the tenant is reachable. Full M2M credential verification requires an
17
+ // access-token exchange which is deferred to the multi-secret login path.
18
+ try {
19
+ const res = await verifyFetch(`https://${key}/.well-known/openid-configuration`);
20
+ if (!res.ok) return undefined;
21
+ const body = (await res.json()) as { issuer?: string };
22
+ return body.issuer ? { issuer: body.issuer } : undefined;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ },
27
+ });
@@ -1,8 +1,6 @@
1
- import type { ServiceEntry } from "../config.ts";
2
1
  import { createHash, createHmac } from "node:crypto";
2
+ import type { ServiceEntry } from "../config.ts";
3
3
  import { StackError } from "../errors.ts";
4
- import { addSecret } from "../phantom.ts";
5
- import { readLine, tryRevealSecret } from "./_helpers.ts";
6
4
  import type {
7
5
  AuthHandle,
8
6
  HealthStatus,
@@ -11,6 +9,8 @@ import type {
11
9
  ProviderContext,
12
10
  Resource,
13
11
  } from "./_base.ts";
12
+ import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
13
+ import { promptSecret, tryRevealSecret } from "./_helpers.ts";
14
14
 
15
15
  /**
16
16
  * AWS — v1 accepts an IAM access key pair (access key id + secret access key).
@@ -32,25 +32,29 @@ const aws: Provider = {
32
32
  docs: "https://docs.aws.amazon.com/iam/",
33
33
 
34
34
  async login(ctx: ProviderContext): Promise<AuthHandle> {
35
- const cachedId = await tryRevealSecret(SECRET_ID_KEY);
36
- const cachedSecret = await tryRevealSecret(SECRET_SECRET_KEY);
35
+ const cachedId = await tryRevealSecret(SECRET_ID_KEY, ctx);
36
+ const cachedSecret = await tryRevealSecret(SECRET_SECRET_KEY, ctx);
37
37
  if (cachedId && cachedSecret) {
38
38
  const identity = await callStsIdentity(cachedId, cachedSecret, "us-east-1");
39
- if (identity) return { token: `${cachedId}:${cachedSecret}`, identity };
39
+ if (identity) {
40
+ acceptVerifiedExisting(ctx, SECRET_ID_KEY, cachedId);
41
+ acceptVerifiedExisting(ctx, SECRET_SECRET_KEY, cachedSecret);
42
+ return { token: `${cachedId}:${cachedSecret}`, identity };
43
+ }
40
44
  ctx.log({ level: "warn", msg: "Cached AWS credentials invalid." });
41
45
  }
42
46
  if (!ctx.interactive)
43
47
  throw new StackError("AWS_AUTH_REQUIRED", "No valid AWS credentials in vault.");
44
- process.stderr.write(
45
- "\n Create an IAM access key at https://console.aws.amazon.com/iam/home#/security_credentials\n AWS_ACCESS_KEY_ID: ",
46
- );
47
- const accessKeyId = (await readLine()).trim();
48
- process.stderr.write(" AWS_SECRET_ACCESS_KEY: ");
49
- const secretAccessKey = (await readLine()).trim();
48
+ const accessKeyId = await promptSecret(ctx, {
49
+ message: "AWS_ACCESS_KEY_ID",
50
+ howTo:
51
+ "Create an IAM access key at https://console.aws.amazon.com/iam/home#/security_credentials",
52
+ });
53
+ const secretAccessKey = await promptSecret(ctx, { message: "AWS_SECRET_ACCESS_KEY" });
50
54
  const identity = await callStsIdentity(accessKeyId, secretAccessKey, "us-east-1");
51
55
  if (!identity) throw new StackError("AWS_AUTH_INVALID", "AWS rejected those credentials.");
52
- await addSecret(SECRET_ID_KEY, accessKeyId);
53
- await addSecret(SECRET_SECRET_KEY, secretAccessKey);
56
+ await storeProviderSecret(ctx, SECRET_ID_KEY, accessKeyId);
57
+ await storeProviderSecret(ctx, SECRET_SECRET_KEY, secretAccessKey);
54
58
  return { token: `${accessKeyId}:${secretAccessKey}`, identity };
55
59
  },
56
60
 
@@ -64,7 +68,7 @@ const aws: Provider = {
64
68
  };
65
69
  },
66
70
 
67
- async materialize(_ctx, resource, auth): Promise<Materialized> {
71
+ async materialize(ctx, resource, auth): Promise<Materialized> {
68
72
  const sep = auth.token.indexOf(":");
69
73
  if (sep <= 0 || sep === auth.token.length - 1) {
70
74
  throw new StackError(
@@ -80,7 +84,7 @@ const aws: Provider = {
80
84
  "AWS auth handle missing access key id or secret access key.",
81
85
  );
82
86
  }
83
- await addSecret(SECRET_REGION, resource.region ?? "us-east-1");
87
+ await storeProviderSecret(ctx, SECRET_REGION, resource.region ?? "us-east-1");
84
88
  return {
85
89
  secrets: {
86
90
  AWS_ACCESS_KEY_ID: accessKeyId,
@@ -90,10 +94,10 @@ const aws: Provider = {
90
94
  };
91
95
  },
92
96
 
93
- async healthcheck(_ctx, _entry: ServiceEntry): Promise<HealthStatus> {
94
- const id = await tryRevealSecret(SECRET_ID_KEY);
95
- const secret = await tryRevealSecret(SECRET_SECRET_KEY);
96
- const region = (await tryRevealSecret(SECRET_REGION)) ?? "us-east-1";
97
+ async healthcheck(ctx, _entry: ServiceEntry): Promise<HealthStatus> {
98
+ const id = await tryRevealSecret(SECRET_ID_KEY, ctx);
99
+ const secret = await tryRevealSecret(SECRET_SECRET_KEY, ctx);
100
+ const region = (await tryRevealSecret(SECRET_REGION, ctx)) ?? "us-east-1";
97
101
  if (!id || !secret) return { kind: "error", detail: "AWS credentials missing from vault" };
98
102
  const start = Date.now();
99
103
  const identity = await callStsIdentity(id, secret, region);
@@ -127,19 +131,11 @@ async function callStsIdentity(
127
131
  const payload = "Action=GetCallerIdentity&Version=2011-06-15";
128
132
  const payloadHash = createHash("sha256").update(payload).digest("hex");
129
133
 
130
- const canonicalHeaders =
131
- `content-type:application/x-www-form-urlencoded\n` +
132
- `host:${host}\n` +
133
- `x-amz-date:${amzDate}\n`;
134
+ const canonicalHeaders = `content-type:application/x-www-form-urlencoded\nhost:${host}\nx-amz-date:${amzDate}\n`;
134
135
  const signedHeaders = "content-type;host;x-amz-date";
135
- const canonicalRequest = [
136
- method,
137
- "/",
138
- "",
139
- canonicalHeaders,
140
- signedHeaders,
141
- payloadHash,
142
- ].join("\n");
136
+ const canonicalRequest = [method, "/", "", canonicalHeaders, signedHeaders, payloadHash].join(
137
+ "\n",
138
+ );
143
139
  const scope = `${datestamp}/${region}/${service}/aws4_request`;
144
140
  const stringToSign = [
145
141
  "AWS4-HMAC-SHA256",
@@ -189,7 +185,10 @@ function hmac(key: string | Buffer, value: string): Buffer {
189
185
  }
190
186
 
191
187
  function toAmzDate(d: Date): string {
192
- return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
188
+ return d
189
+ .toISOString()
190
+ .replace(/[-:]/g, "")
191
+ .replace(/\.\d{3}/, "");
193
192
  }
194
193
 
195
194
  function matchXml(xml: string, tag: string): string | undefined {