@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.
- package/LICENSE +1 -1
- package/README.md +26 -15
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +67 -7
- package/src/__tests__/agent-onboard.test.ts +279 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +208 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +32 -10
- package/src/__tests__/completion-ci.test.ts +239 -23
- package/src/__tests__/config-own-services.test.ts +183 -0
- package/src/__tests__/config-split.test.ts +272 -2
- package/src/__tests__/config.test.ts +94 -4
- package/src/__tests__/credential-staging.test.ts +660 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +183 -4
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/github-provider.test.ts +3 -2
- package/src/__tests__/helpers.test.ts +24 -4
- package/src/__tests__/http.test.ts +2 -2
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +57 -13
- package/src/__tests__/interactive-prompt.test.ts +106 -0
- package/src/__tests__/mcp-mirror.test.ts +116 -0
- package/src/__tests__/neon-provider.test.ts +136 -3
- package/src/__tests__/new-providers.test.ts +470 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/oauth-port.test.ts +84 -0
- package/src/__tests__/openai-provider.test.ts +1 -1
- package/src/__tests__/phantom-contract.test.ts +177 -0
- package/src/__tests__/phantom-harness.test.ts +226 -8
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +403 -22
- package/src/__tests__/pipeline-integration.test.ts +33 -1
- package/src/__tests__/pipeline-recovery.test.ts +29 -28
- package/src/__tests__/pipeline-rollback.test.ts +509 -0
- package/src/__tests__/pipeline.test.ts +3 -1
- package/src/__tests__/pm-detect.test.ts +113 -0
- package/src/__tests__/provider-deprovision.test.ts +269 -0
- package/src/__tests__/provider-parity.test.ts +46 -0
- package/src/__tests__/publish-script.test.ts +704 -0
- package/src/__tests__/recipe.test.ts +27 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/supabase-provider.test.ts +6 -4
- package/src/__tests__/swap.test.ts +114 -0
- package/src/__tests__/telemetry.test.ts +354 -0
- package/src/__tests__/template-litellm-gateway.test.ts +70 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
- package/src/__tests__/templates.test.ts +1 -1
- package/src/__tests__/turso-provider.test.ts +2 -5
- package/src/agent-onboard.ts +346 -0
- package/src/ai/catalog-index.ts +68 -10
- package/src/ai/circuit-breaker.ts +3 -6
- package/src/ai/cost-tracker.ts +6 -15
- package/src/ai/inference.ts +19 -11
- package/src/ai/phantom-wire.ts +73 -58
- package/src/ai/recipe.ts +69 -0
- package/src/base-urls.ts +71 -0
- package/src/catalog.ts +281 -2
- package/src/config.ts +31 -18
- package/src/detect-source.ts +50 -9
- package/src/detect.ts +13 -5
- package/src/errors.ts +6 -0
- package/src/http.ts +6 -5
- package/src/index.ts +16 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +319 -30
- package/src/pipeline.ts +177 -68
- package/src/pm-detect.ts +56 -0
- package/src/providers/_api-key.ts +18 -17
- package/src/providers/_base.ts +28 -1
- package/src/providers/_credential-stage.ts +237 -0
- package/src/providers/_helpers.ts +55 -9
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/auth0.ts +27 -0
- package/src/providers/aws.ts +32 -33
- package/src/providers/cloudflare.ts +18 -37
- package/src/providers/convex.ts +1 -2
- package/src/providers/datadog.ts +29 -0
- package/src/providers/digitalocean.ts +30 -0
- package/src/providers/gcp.ts +36 -0
- package/src/providers/github.ts +37 -35
- package/src/providers/grafana.ts +29 -0
- package/src/providers/hetzner.ts +29 -0
- package/src/providers/index.ts +13 -1
- package/src/providers/launchdarkly.ts +33 -0
- package/src/providers/mixpanel.ts +37 -0
- package/src/providers/neon.ts +50 -26
- package/src/providers/openai.ts +2 -1
- package/src/providers/plausible.ts +26 -0
- package/src/providers/sentry.ts +17 -17
- package/src/providers/stripe.ts +1 -2
- package/src/providers/supabase.ts +66 -37
- package/src/providers/turso.ts +49 -38
- package/src/providers/vercel.ts +50 -20
- package/src/providers/workos.ts +27 -0
- package/src/registry.ts +7 -11
- package/src/stack-manifest.ts +39 -0
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- package/src/templates.ts +6 -2
package/src/pipeline.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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 {
|
|
9
|
-
import {
|
|
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] && !
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
122
|
+
created_by: "stack add",
|
|
119
123
|
};
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/pm-detect.ts
ADDED
|
@@ -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)
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
79
|
-
|
|
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(
|
|
99
|
-
await
|
|
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
|
-
|
package/src/providers/_base.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|