@ashlr/stack-core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +279 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +208 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/catalog.test.ts +31 -0
  10. package/src/__tests__/cloudflare-provider.test.ts +32 -10
  11. package/src/__tests__/completion-ci.test.ts +239 -23
  12. package/src/__tests__/config-own-services.test.ts +183 -0
  13. package/src/__tests__/config-split.test.ts +272 -2
  14. package/src/__tests__/config.test.ts +94 -4
  15. package/src/__tests__/credential-staging.test.ts +660 -0
  16. package/src/__tests__/deps.test.ts +2 -1
  17. package/src/__tests__/detect-source.test.ts +183 -4
  18. package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
  19. package/src/__tests__/github-provider.test.ts +3 -2
  20. package/src/__tests__/helpers.test.ts +24 -4
  21. package/src/__tests__/http.test.ts +2 -2
  22. package/src/__tests__/import-integration.test.ts +18 -4
  23. package/src/__tests__/inference.test.ts +57 -13
  24. package/src/__tests__/interactive-prompt.test.ts +106 -0
  25. package/src/__tests__/mcp-mirror.test.ts +116 -0
  26. package/src/__tests__/neon-provider.test.ts +136 -3
  27. package/src/__tests__/new-providers.test.ts +470 -0
  28. package/src/__tests__/no-orphan-modules.test.ts +114 -0
  29. package/src/__tests__/oauth-port.test.ts +84 -0
  30. package/src/__tests__/openai-provider.test.ts +1 -1
  31. package/src/__tests__/phantom-contract.test.ts +177 -0
  32. package/src/__tests__/phantom-harness.test.ts +226 -8
  33. package/src/__tests__/phantom-process.test.ts +135 -0
  34. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  35. package/src/__tests__/phantom-wire.test.ts +403 -22
  36. package/src/__tests__/pipeline-integration.test.ts +33 -1
  37. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  38. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  39. package/src/__tests__/pipeline.test.ts +3 -1
  40. package/src/__tests__/pm-detect.test.ts +113 -0
  41. package/src/__tests__/provider-deprovision.test.ts +269 -0
  42. package/src/__tests__/provider-parity.test.ts +46 -0
  43. package/src/__tests__/publish-script.test.ts +704 -0
  44. package/src/__tests__/recipe.test.ts +27 -2
  45. package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
  46. package/src/__tests__/registry.test.ts +11 -2
  47. package/src/__tests__/release-artifacts.test.ts +55 -0
  48. package/src/__tests__/scan-missing-dir.test.ts +45 -0
  49. package/src/__tests__/secret-redaction.test.ts +10 -10
  50. package/src/__tests__/security.test.ts +9 -10
  51. package/src/__tests__/stack-manifest.test.ts +38 -0
  52. package/src/__tests__/supabase-provider.test.ts +6 -4
  53. package/src/__tests__/swap.test.ts +114 -0
  54. package/src/__tests__/telemetry.test.ts +354 -0
  55. package/src/__tests__/template-litellm-gateway.test.ts +70 -0
  56. package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
  57. package/src/__tests__/templates.test.ts +1 -1
  58. package/src/__tests__/turso-provider.test.ts +2 -5
  59. package/src/agent-onboard.ts +346 -0
  60. package/src/ai/catalog-index.ts +68 -10
  61. package/src/ai/circuit-breaker.ts +3 -6
  62. package/src/ai/cost-tracker.ts +6 -15
  63. package/src/ai/inference.ts +19 -11
  64. package/src/ai/phantom-wire.ts +73 -58
  65. package/src/ai/recipe.ts +69 -0
  66. package/src/base-urls.ts +71 -0
  67. package/src/catalog.ts +281 -2
  68. package/src/config.ts +31 -18
  69. package/src/detect-source.ts +50 -9
  70. package/src/detect.ts +13 -5
  71. package/src/errors.ts +6 -0
  72. package/src/http.ts +6 -5
  73. package/src/index.ts +16 -0
  74. package/src/mcp-writer.ts +97 -2
  75. package/src/oauth.ts +85 -53
  76. package/src/phantom-process.ts +72 -0
  77. package/src/phantom.ts +319 -30
  78. package/src/pipeline.ts +177 -68
  79. package/src/pm-detect.ts +56 -0
  80. package/src/providers/_api-key.ts +18 -17
  81. package/src/providers/_base.ts +28 -1
  82. package/src/providers/_credential-stage.ts +237 -0
  83. package/src/providers/_helpers.ts +55 -9
  84. package/src/providers/anthropic.ts +2 -1
  85. package/src/providers/auth0.ts +27 -0
  86. package/src/providers/aws.ts +32 -33
  87. package/src/providers/cloudflare.ts +18 -37
  88. package/src/providers/convex.ts +1 -2
  89. package/src/providers/datadog.ts +29 -0
  90. package/src/providers/digitalocean.ts +30 -0
  91. package/src/providers/gcp.ts +36 -0
  92. package/src/providers/github.ts +37 -35
  93. package/src/providers/grafana.ts +29 -0
  94. package/src/providers/hetzner.ts +29 -0
  95. package/src/providers/index.ts +13 -1
  96. package/src/providers/launchdarkly.ts +33 -0
  97. package/src/providers/mixpanel.ts +37 -0
  98. package/src/providers/neon.ts +50 -26
  99. package/src/providers/openai.ts +2 -1
  100. package/src/providers/plausible.ts +26 -0
  101. package/src/providers/sentry.ts +17 -17
  102. package/src/providers/stripe.ts +1 -2
  103. package/src/providers/supabase.ts +66 -37
  104. package/src/providers/turso.ts +49 -38
  105. package/src/providers/vercel.ts +50 -20
  106. package/src/providers/workos.ts +27 -0
  107. package/src/registry.ts +7 -11
  108. package/src/stack-manifest.ts +39 -0
  109. package/src/swap.ts +108 -0
  110. package/src/telemetry.ts +267 -0
  111. package/src/templates.ts +6 -2
@@ -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
- export async function isPhantomInstalled(): Promise<boolean> {
19
- if (cachedInstalled !== undefined) return cachedInstalled;
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(["--version"], { allowFailure: true });
22
- cachedInstalled = result.code === 0;
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 child = spawn("phantom", args, {
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
- child.on("close", (code) => {
73
- const result: PhantomExecResult = { stdout, stderr, code: code ?? 0 };
74
- if (code !== 0 && !opts.allowFailure) {
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, value], { cwd });
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
- export async function removeSecret(key: string, cwd?: string): Promise<void> {
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], { cwd, allowFailure: true });
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
- export async function listSecrets(cwd?: string): Promise<string[]> {
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 { stdout } = await exec(["list"], { cwd, allowFailure: true });
101
- return stdout
102
- .split("\n")
103
- .map((line) => line.trim())
104
- .filter((line) => line.length > 0 && !line.startsWith("#"));
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 { stdout } = await exec(["reveal", key], { cwd });
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> {