@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { constants, accessSync, statSync } from "node:fs";
|
|
2
|
+
import { delimiter, resolve } from "node:path";
|
|
3
|
+
import { PhantomNotInstalledError } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
// Keep runtime, terminal and vault-location settings, plus Phantom's explicit
|
|
6
|
+
// vault unlock input. Never forward arbitrary provider credentials or loader hooks.
|
|
7
|
+
const ENV_KEYS = [
|
|
8
|
+
"PATH",
|
|
9
|
+
"HOME",
|
|
10
|
+
"USER",
|
|
11
|
+
"USERPROFILE",
|
|
12
|
+
"SystemRoot",
|
|
13
|
+
"APPDATA",
|
|
14
|
+
"LOCALAPPDATA",
|
|
15
|
+
"PATHEXT",
|
|
16
|
+
"XDG_CONFIG_HOME",
|
|
17
|
+
"XDG_DATA_HOME",
|
|
18
|
+
"XDG_STATE_HOME",
|
|
19
|
+
"XDG_RUNTIME_DIR",
|
|
20
|
+
"TMPDIR",
|
|
21
|
+
"TEMP",
|
|
22
|
+
"TMP",
|
|
23
|
+
"LANG",
|
|
24
|
+
"LC_ALL",
|
|
25
|
+
"LC_CTYPE",
|
|
26
|
+
"TERM",
|
|
27
|
+
"NO_COLOR",
|
|
28
|
+
"PHANTOM_VAULT_PASSPHRASE",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export function phantomEnv(source: NodeJS.ProcessEnv): Record<string, string> {
|
|
32
|
+
const env: Record<string, string> = {};
|
|
33
|
+
for (const key of ENV_KEYS) {
|
|
34
|
+
const value = source[key];
|
|
35
|
+
if (value !== undefined) env[key] = value;
|
|
36
|
+
}
|
|
37
|
+
if (process.platform === "win32") {
|
|
38
|
+
// Windows environment names are case insensitive. Emit one canonical key.
|
|
39
|
+
for (const key of ENV_KEYS) {
|
|
40
|
+
const alias = Object.keys(source).find((name) => name.toLowerCase() === key.toLowerCase());
|
|
41
|
+
if (alias !== undefined && source[alias] !== undefined) env[key] = source[alias];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Normalize PATH while the caller's cwd is still unambiguous: after spawn
|
|
45
|
+
// applies the operation cwd, a relative entry would resolve inside that
|
|
46
|
+
// project. Empty entries (an implicit current directory) are dropped, not
|
|
47
|
+
// resolved anywhere.
|
|
48
|
+
if (env.PATH !== undefined) {
|
|
49
|
+
env.PATH = env.PATH.split(delimiter)
|
|
50
|
+
.filter((entry) => entry !== "")
|
|
51
|
+
.map((entry) => resolve(entry))
|
|
52
|
+
.join(delimiter);
|
|
53
|
+
}
|
|
54
|
+
return env;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve before spawn changes cwd; do not search an implicit current directory. */
|
|
58
|
+
export function resolvePhantomBinary(env: NodeJS.ProcessEnv, cwd = process.cwd()): string {
|
|
59
|
+
const name = process.platform === "win32" ? "phantom.exe" : "phantom";
|
|
60
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
61
|
+
if (!dir) continue;
|
|
62
|
+
const candidate = resolve(cwd, dir, name);
|
|
63
|
+
try {
|
|
64
|
+
if (!statSync(candidate).isFile()) continue;
|
|
65
|
+
accessSync(candidate, constants.X_OK);
|
|
66
|
+
return candidate;
|
|
67
|
+
} catch {
|
|
68
|
+
// Missing or non-executable entries do not shadow a later installation.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw new PhantomNotInstalledError();
|
|
72
|
+
}
|
package/src/phantom.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { PhantomNotInstalledError, StackError } from "./errors.ts";
|
|
3
|
+
import { phantomEnv, resolvePhantomBinary } from "./phantom-process.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Thin wrapper around the `phantom` CLI. Stack never stores secrets locally —
|
|
@@ -11,19 +12,55 @@ export interface PhantomExecResult {
|
|
|
11
12
|
stdout: string;
|
|
12
13
|
stderr: string;
|
|
13
14
|
code: number;
|
|
15
|
+
signal?: NodeJS.Signals;
|
|
16
|
+
completion?: "exited" | "signal" | "unknown";
|
|
17
|
+
/** True when `timeoutMs` elapsed and Stack killed the child. */
|
|
18
|
+
timedOut?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A phantom call was killed after its `timeoutMs`. Distinct from a failed or unparseable call. */
|
|
22
|
+
export class PhantomTimeoutError extends Error {
|
|
23
|
+
constructor(args: string[], timeoutMs: number | undefined) {
|
|
24
|
+
super(`phantom ${args[0] ?? ""} timed out after ${timeoutMs ?? "?"}ms`);
|
|
25
|
+
this.name = "PhantomTimeoutError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Interpret close fields only; this does not attest provider or vault state. */
|
|
30
|
+
export function completionForClose(
|
|
31
|
+
code: number | null,
|
|
32
|
+
signal: NodeJS.Signals | null,
|
|
33
|
+
): NonNullable<PhantomExecResult["completion"]> {
|
|
34
|
+
return signal ? "signal" : code === null ? "unknown" : "exited";
|
|
14
35
|
}
|
|
15
36
|
|
|
16
37
|
let cachedInstalled: boolean | undefined;
|
|
17
38
|
|
|
18
|
-
|
|
19
|
-
|
|
39
|
+
/**
|
|
40
|
+
* `phantom --version` as three states. A timeout is not cached, because a slow
|
|
41
|
+
* Phantom is still installed; the next probe asks again.
|
|
42
|
+
*/
|
|
43
|
+
export async function probePhantomInstalled(opts?: {
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}): Promise<"installed" | "missing" | "timeout"> {
|
|
46
|
+
if (cachedInstalled !== undefined) return cachedInstalled ? "installed" : "missing";
|
|
20
47
|
try {
|
|
21
|
-
const result = await exec(
|
|
22
|
-
|
|
48
|
+
const result = await exec(
|
|
49
|
+
["--version"],
|
|
50
|
+
opts?.timeoutMs !== undefined
|
|
51
|
+
? { allowFailure: true, timeoutMs: opts.timeoutMs }
|
|
52
|
+
: { allowFailure: true },
|
|
53
|
+
);
|
|
54
|
+
if (result.timedOut) return "timeout";
|
|
55
|
+
cachedInstalled = result.code === 0 && result.completion === "exited";
|
|
23
56
|
} catch {
|
|
24
57
|
cachedInstalled = false;
|
|
25
58
|
}
|
|
26
|
-
return cachedInstalled;
|
|
59
|
+
return cachedInstalled ? "installed" : "missing";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function isPhantomInstalled(opts?: { timeoutMs?: number }): Promise<boolean> {
|
|
63
|
+
return (await probePhantomInstalled(opts)) === "installed";
|
|
27
64
|
}
|
|
28
65
|
|
|
29
66
|
export async function assertPhantomInstalled(): Promise<void> {
|
|
@@ -36,17 +73,98 @@ export interface ExecOptions {
|
|
|
36
73
|
stdin?: string;
|
|
37
74
|
/** If true, inherit stdio so interactive flows (OAuth browser) work. */
|
|
38
75
|
interactive?: boolean;
|
|
76
|
+
/** Kill the phantom child after this many milliseconds. */
|
|
77
|
+
timeoutMs?: number;
|
|
39
78
|
}
|
|
40
79
|
|
|
41
80
|
export async function exec(args: string[], opts: ExecOptions = {}): Promise<PhantomExecResult> {
|
|
81
|
+
// Filter the inherited environment and resolve against the caller's PATH
|
|
82
|
+
// before applying the project cwd; see phantom-process.ts.
|
|
83
|
+
const env = phantomEnv(process.env);
|
|
84
|
+
const binary = resolvePhantomBinary(env);
|
|
42
85
|
return new Promise((resolvePromise, reject) => {
|
|
43
|
-
const
|
|
86
|
+
const hasTimeout = typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs);
|
|
87
|
+
// A timed probe gets its own process group (POSIX) so a timeout can kill
|
|
88
|
+
// phantom and any grandchild holding its stdio. Interactive runs stay in
|
|
89
|
+
// the foreground group so the TTY keeps working.
|
|
90
|
+
const useGroup = hasTimeout && !opts.interactive && process.platform !== "win32";
|
|
91
|
+
const child = spawn(binary, args, {
|
|
92
|
+
env,
|
|
44
93
|
cwd: opts.cwd,
|
|
45
94
|
stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
|
|
95
|
+
...(useGroup ? { detached: true } : {}),
|
|
46
96
|
});
|
|
47
97
|
|
|
48
98
|
let stdout = "";
|
|
49
99
|
let stderr = "";
|
|
100
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
101
|
+
let timedOut = false;
|
|
102
|
+
let settled = false;
|
|
103
|
+
const clearTimer = () => {
|
|
104
|
+
if (timer !== undefined) {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
timer = undefined;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const killTree = () => {
|
|
110
|
+
if (useGroup && child.pid !== undefined) {
|
|
111
|
+
try {
|
|
112
|
+
process.kill(-child.pid, "SIGKILL");
|
|
113
|
+
return;
|
|
114
|
+
} catch {
|
|
115
|
+
// Group already gone or not a group leader; fall back to the child.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
child.kill("SIGKILL");
|
|
120
|
+
} catch {
|
|
121
|
+
// Already exited.
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const releaseChild = () => {
|
|
125
|
+
// A grandchild may still hold the pipes; never let them (or the child)
|
|
126
|
+
// keep the CLI's event loop alive after we have settled.
|
|
127
|
+
child.stdin?.destroy();
|
|
128
|
+
child.stdout?.destroy();
|
|
129
|
+
child.stderr?.destroy();
|
|
130
|
+
child.unref();
|
|
131
|
+
};
|
|
132
|
+
const settle = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
133
|
+
if (settled) return;
|
|
134
|
+
settled = true;
|
|
135
|
+
clearTimer();
|
|
136
|
+
if (timedOut) releaseChild();
|
|
137
|
+
// A signal/unknown exit is not evidence that a vault mutation committed.
|
|
138
|
+
const result: PhantomExecResult = {
|
|
139
|
+
stdout,
|
|
140
|
+
stderr,
|
|
141
|
+
code: signal ? code || 1 : (code ?? 1),
|
|
142
|
+
completion: completionForClose(code, signal),
|
|
143
|
+
...(signal ? { signal } : {}),
|
|
144
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
145
|
+
};
|
|
146
|
+
if (result.code !== 0 && !opts.allowFailure) {
|
|
147
|
+
reject(
|
|
148
|
+
new StackError(
|
|
149
|
+
"PHANTOM_EXEC_FAILED",
|
|
150
|
+
result.completion === "exited"
|
|
151
|
+
? `phantom ${formatArgsForError(args)} failed (exit ${result.code}): ${stderr.trim() || stdout.trim()}`
|
|
152
|
+
: `phantom ${formatArgsForError(args)} did not complete (${result.completion}); result unconfirmed`,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
resolvePromise(result);
|
|
158
|
+
};
|
|
159
|
+
if (hasTimeout) {
|
|
160
|
+
timer = setTimeout(() => {
|
|
161
|
+
timer = undefined;
|
|
162
|
+
timedOut = true;
|
|
163
|
+
killTree();
|
|
164
|
+
// Settle now: do not wait for `close`, which a grandchild can delay.
|
|
165
|
+
settle(null, "SIGKILL");
|
|
166
|
+
}, opts.timeoutMs);
|
|
167
|
+
}
|
|
50
168
|
|
|
51
169
|
if (!opts.interactive) {
|
|
52
170
|
child.stdout?.on("data", (chunk) => {
|
|
@@ -56,12 +174,18 @@ export async function exec(args: string[], opts: ExecOptions = {}): Promise<Phan
|
|
|
56
174
|
stderr += chunk.toString();
|
|
57
175
|
});
|
|
58
176
|
if (opts.stdin !== undefined) {
|
|
177
|
+
// Phantom may refuse (e.g. an existing name) and exit before reading
|
|
178
|
+
// stdin; the exit code reports that, so a broken pipe is not fatal.
|
|
179
|
+
child.stdin?.on("error", () => {});
|
|
59
180
|
child.stdin?.write(opts.stdin);
|
|
60
181
|
child.stdin?.end();
|
|
61
182
|
}
|
|
62
183
|
}
|
|
63
184
|
|
|
64
185
|
child.on("error", (err) => {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
clearTimer();
|
|
65
189
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
66
190
|
reject(new PhantomNotInstalledError());
|
|
67
191
|
return;
|
|
@@ -69,45 +193,210 @@ export async function exec(args: string[], opts: ExecOptions = {}): Promise<Phan
|
|
|
69
193
|
reject(err);
|
|
70
194
|
});
|
|
71
195
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
reject(
|
|
76
|
-
new StackError(
|
|
77
|
-
"PHANTOM_EXEC_FAILED",
|
|
78
|
-
`phantom ${formatArgsForError(args)} failed (exit ${code}): ${stderr.trim() || stdout.trim()}`,
|
|
79
|
-
),
|
|
80
|
-
);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
resolvePromise(result);
|
|
84
|
-
});
|
|
196
|
+
// `close` waits for stdio to drain, so normal runs keep full output.
|
|
197
|
+
// A timed out run has already settled from the timer.
|
|
198
|
+
child.on("close", (code, signal) => settle(code, signal));
|
|
85
199
|
});
|
|
86
200
|
}
|
|
87
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Store a NEW secret. The value goes over stdin (`phantom add KEY --stdin`):
|
|
204
|
+
* Phantom Secrets >= 0.7.3 rejects a positional value because argv is visible
|
|
205
|
+
* to process inspection. Phantom never replaces an existing name, so a
|
|
206
|
+
* every completed collision surfaces as `PHANTOM_SECRET_EXISTS`. Historical
|
|
207
|
+
* input equality is never evidence that the current vault still has that value.
|
|
208
|
+
*/
|
|
88
209
|
export async function addSecret(key: string, value: string, cwd?: string): Promise<void> {
|
|
210
|
+
// Phantom 0.7.9 reads one stdin line and strips CR/LF. Refuse values it
|
|
211
|
+
// cannot preserve before any subprocess, rather than silently truncating.
|
|
212
|
+
if (/[\r\n]/.test(value)) {
|
|
213
|
+
throw new StackError(
|
|
214
|
+
"PHANTOM_SECRET_VALUE_UNSUPPORTED",
|
|
215
|
+
"Phantom Secrets accepts one line per secret; CR/LF values cannot be stored unchanged.",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
89
218
|
await assertPhantomInstalled();
|
|
90
|
-
await exec(["add", key,
|
|
219
|
+
const result = await exec(["add", key, "--stdin"], { cwd, stdin: value, allowFailure: true });
|
|
220
|
+
// Collision wording from an interrupted child cannot attest a completed
|
|
221
|
+
// create-only refusal.
|
|
222
|
+
if (result.completion !== "exited") {
|
|
223
|
+
throw new StackError(
|
|
224
|
+
"PHANTOM_EXEC_FAILED",
|
|
225
|
+
"Phantom add did not complete; secret storage is unconfirmed.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (result.code === 0) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
232
|
+
if (/already protected|already exists/i.test(detail)) {
|
|
233
|
+
throw new StackError(
|
|
234
|
+
"PHANTOM_SECRET_EXISTS",
|
|
235
|
+
`${key} is already stored in Phantom, and Phantom never replaces a stored secret. Run \`phantom remove ${key}\` in your terminal, then retry.`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
throw new StackError(
|
|
239
|
+
"PHANTOM_EXEC_FAILED",
|
|
240
|
+
`phantom add ${key} --stdin failed (exit ${result.code}): ${detail}`,
|
|
241
|
+
);
|
|
91
242
|
}
|
|
92
243
|
|
|
93
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Remove a secret. Phantom Secrets requires a typed confirmation in an
|
|
246
|
+
* attached terminal for `remove`, so headless callers (rollback, CI, agents)
|
|
247
|
+
* cannot remove anything. Pass `interactive: true` only from a human-driven
|
|
248
|
+
* CLI command with a TTY; Phantom then runs its own confirmation prompt.
|
|
249
|
+
* Returns true only when Phantom reports the secret removed.
|
|
250
|
+
*/
|
|
251
|
+
export async function removeSecret(
|
|
252
|
+
key: string,
|
|
253
|
+
cwd?: string,
|
|
254
|
+
opts: { interactive?: boolean } = {},
|
|
255
|
+
): Promise<boolean> {
|
|
94
256
|
await assertPhantomInstalled();
|
|
95
|
-
await exec(["remove", key], {
|
|
257
|
+
const result = await exec(["remove", key], {
|
|
258
|
+
cwd,
|
|
259
|
+
allowFailure: true,
|
|
260
|
+
interactive: opts.interactive === true,
|
|
261
|
+
});
|
|
262
|
+
const removed = result.code === 0 && result.completion === "exited";
|
|
263
|
+
return removed;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Secret names in the vault, from `phantom list --json` (never values). The
|
|
268
|
+
* human `phantom list` output is a decorated table and is not parsed.
|
|
269
|
+
*/
|
|
270
|
+
export async function listSecrets(
|
|
271
|
+
cwd?: string,
|
|
272
|
+
opts?: { timeoutMs?: number; skipInstallCheck?: boolean },
|
|
273
|
+
): Promise<string[]> {
|
|
274
|
+
if (!opts?.skipInstallCheck) await assertPhantomInstalled();
|
|
275
|
+
const { stdout, code, timedOut } = await exec(["list", "--json"], {
|
|
276
|
+
cwd,
|
|
277
|
+
allowFailure: true,
|
|
278
|
+
...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
279
|
+
});
|
|
280
|
+
if (timedOut) throw new PhantomTimeoutError(["list"], opts?.timeoutMs);
|
|
281
|
+
if (code !== 0) {
|
|
282
|
+
throw new StackError(
|
|
283
|
+
"PHANTOM_METADATA_UNAVAILABLE",
|
|
284
|
+
"Phantom secret inventory is unavailable; stored names are unknown.",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
let parsed: unknown;
|
|
288
|
+
try {
|
|
289
|
+
parsed = JSON.parse(stdout);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new StackError(
|
|
292
|
+
"PHANTOM_OUTPUT_UNRECOGNIZED",
|
|
293
|
+
"phantom list --json did not return JSON; upgrade Phantom Secrets to 0.7.9 or newer.",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (!Array.isArray(parsed)) {
|
|
297
|
+
throw new StackError(
|
|
298
|
+
"PHANTOM_OUTPUT_UNRECOGNIZED",
|
|
299
|
+
"phantom list --json did not return an array.",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const names: string[] = [];
|
|
303
|
+
for (const entry of parsed) {
|
|
304
|
+
if (
|
|
305
|
+
!entry ||
|
|
306
|
+
typeof entry !== "object" ||
|
|
307
|
+
Array.isArray(entry) ||
|
|
308
|
+
!("name" in entry) ||
|
|
309
|
+
typeof entry.name !== "string" ||
|
|
310
|
+
entry.name.length === 0
|
|
311
|
+
) {
|
|
312
|
+
throw new StackError(
|
|
313
|
+
"PHANTOM_OUTPUT_UNRECOGNIZED",
|
|
314
|
+
"phantom list --json contains unrecognized secret-name metadata.",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
names.push(entry.name);
|
|
318
|
+
}
|
|
319
|
+
return names;
|
|
96
320
|
}
|
|
97
321
|
|
|
98
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Number of secrets in the vault, from `phantom list --json`. Null when the
|
|
324
|
+
* inventory cannot be read (Phantom missing, no .phantom.toml, unrecognized
|
|
325
|
+
* output), so callers never report an unreadable vault as empty.
|
|
326
|
+
*/
|
|
327
|
+
export async function countSecrets(
|
|
328
|
+
cwd?: string,
|
|
329
|
+
opts?: { timeoutMs?: number; skipInstallCheck?: boolean },
|
|
330
|
+
): Promise<number | null> {
|
|
331
|
+
try {
|
|
332
|
+
// skipInstallCheck lets `stack status` overlap this list with the version probe.
|
|
333
|
+
if (
|
|
334
|
+
!opts?.skipInstallCheck &&
|
|
335
|
+
!(await isPhantomInstalled(
|
|
336
|
+
opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : undefined,
|
|
337
|
+
))
|
|
338
|
+
) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
return (await listSecrets(cwd, opts)).length;
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Configuration observation only: no vault access, initialization or grants. */
|
|
348
|
+
export async function assertProjectInitialized(cwd?: string): Promise<void> {
|
|
99
349
|
await assertPhantomInstalled();
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
350
|
+
const result = await exec(["status", "--json"], { cwd, allowFailure: true });
|
|
351
|
+
let parsed: unknown;
|
|
352
|
+
try {
|
|
353
|
+
parsed = JSON.parse(result.stdout);
|
|
354
|
+
} catch {
|
|
355
|
+
parsed = undefined;
|
|
356
|
+
}
|
|
357
|
+
if (
|
|
358
|
+
result.code !== 0 ||
|
|
359
|
+
!parsed ||
|
|
360
|
+
typeof parsed !== "object" ||
|
|
361
|
+
Array.isArray(parsed) ||
|
|
362
|
+
!("schema_version" in parsed) ||
|
|
363
|
+
parsed.schema_version !== 1 ||
|
|
364
|
+
!("inspection" in parsed) ||
|
|
365
|
+
parsed.inspection !== "metadata-only" ||
|
|
366
|
+
!("initialized" in parsed) ||
|
|
367
|
+
typeof parsed.initialized !== "boolean"
|
|
368
|
+
) {
|
|
369
|
+
throw new StackError(
|
|
370
|
+
"PHANTOM_METADATA_UNAVAILABLE",
|
|
371
|
+
"Phantom project initialization is unknown. Use Phantom Secrets 0.7.9 or newer and check its metadata status before retrying.",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (!parsed.initialized) {
|
|
375
|
+
throw new StackError(
|
|
376
|
+
"PHANTOM_PROJECT_NOT_INITIALIZED",
|
|
377
|
+
"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.",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
105
380
|
}
|
|
106
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Read a secret value. Phantom Secrets >= 0.7.3 only reveals into an attached
|
|
384
|
+
* terminal after a typed confirmation, so from Stack (piped stdio) this fails
|
|
385
|
+
* with `PHANTOM_TRUSTED_TERMINAL_REQUIRED`. Callers must treat a stored value
|
|
386
|
+
* as unreadable and fall back to prompting.
|
|
387
|
+
*/
|
|
107
388
|
export async function revealSecret(key: string, cwd?: string): Promise<string> {
|
|
108
389
|
await assertPhantomInstalled();
|
|
109
|
-
const
|
|
110
|
-
return stdout.trim();
|
|
390
|
+
const result = await exec(["reveal", key], { cwd, allowFailure: true });
|
|
391
|
+
if (result.code === 0) return result.stdout.trim();
|
|
392
|
+
const detail = result.stderr.trim();
|
|
393
|
+
if (/terminal/i.test(detail)) {
|
|
394
|
+
throw new StackError(
|
|
395
|
+
"PHANTOM_TRUSTED_TERMINAL_REQUIRED",
|
|
396
|
+
`Phantom only reveals ${key} in an attached terminal after typed confirmation; Stack cannot read it.`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
throw new StackError("PHANTOM_EXEC_FAILED", `phantom reveal ${key} failed (exit ${result.code})`);
|
|
111
400
|
}
|
|
112
401
|
|
|
113
402
|
export async function ensureInitialized(cwd?: string): Promise<void> {
|