@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.
- 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 +217 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +162 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/cloudflare-provider.test.ts +9 -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 +206 -2
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/credential-staging.test.ts +659 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +1 -1
- 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__/inference.test.ts +19 -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__/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 +125 -8
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +31 -20
- package/src/__tests__/pipeline-integration.test.ts +1 -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 +659 -0
- package/src/__tests__/recipe.test.ts +4 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- 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 +41 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -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 +29 -52
- 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 +36 -8
- package/src/detect.ts +13 -5
- package/src/http.ts +6 -5
- package/src/index.ts +15 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom.ts +193 -15
- 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/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- 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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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], {
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|