@ashlr/stack-core 0.2.2 → 0.3.1

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 (32) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-onboard.test.ts +62 -0
  4. package/src/__tests__/base-urls.test.ts +48 -2
  5. package/src/__tests__/catalog.test.ts +31 -0
  6. package/src/__tests__/cloudflare-provider.test.ts +23 -0
  7. package/src/__tests__/config-split.test.ts +66 -0
  8. package/src/__tests__/config.test.ts +92 -3
  9. package/src/__tests__/credential-staging.test.ts +3 -2
  10. package/src/__tests__/detect-source.test.ts +182 -3
  11. package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
  12. package/src/__tests__/import-integration.test.ts +18 -4
  13. package/src/__tests__/inference.test.ts +38 -0
  14. package/src/__tests__/no-orphan-modules.test.ts +114 -0
  15. package/src/__tests__/phantom-harness.test.ts +102 -1
  16. package/src/__tests__/phantom-process.test.ts +135 -0
  17. package/src/__tests__/phantom-wire.test.ts +372 -2
  18. package/src/__tests__/pipeline-integration.test.ts +32 -0
  19. package/src/__tests__/publish-script.test.ts +63 -18
  20. package/src/__tests__/recipe.test.ts +23 -0
  21. package/src/__tests__/recommend-apply-e2e.test.ts +16 -1
  22. package/src/__tests__/scan-missing-dir.test.ts +45 -0
  23. package/src/__tests__/stack-manifest.test.ts +38 -0
  24. package/src/__tests__/template-litellm-gateway.test.ts +29 -0
  25. package/src/__tests__/template-temporal-worker-phantom.test.ts +14 -0
  26. package/src/ai/phantom-wire.ts +44 -6
  27. package/src/detect-source.ts +14 -1
  28. package/src/errors.ts +6 -0
  29. package/src/index.ts +1 -0
  30. package/src/phantom-process.ts +72 -0
  31. package/src/phantom.ts +144 -33
  32. package/src/stack-manifest.ts +39 -0
@@ -0,0 +1,45 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { scanSource } from "../detect-source.ts";
6
+ import { SourceDirectoryNotFoundError } from "../errors.ts";
7
+
8
+ const roots: string[] = [];
9
+ afterEach(() => {
10
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
11
+ });
12
+
13
+ function makeRoot(): string {
14
+ const root = mkdtempSync(join(tmpdir(), "stack-scan-missing-"));
15
+ roots.push(root);
16
+ return root;
17
+ }
18
+
19
+ test("a missing directory fails with a typed error without being created", async () => {
20
+ const path = join(makeRoot(), "missing", "project");
21
+ try {
22
+ await scanSource(path);
23
+ throw new Error("expected scanSource to reject");
24
+ } catch (error) {
25
+ expect(error).toBeInstanceOf(SourceDirectoryNotFoundError);
26
+ expect((error as SourceDirectoryNotFoundError).code).toBe("SOURCE_DIRECTORY_NOT_FOUND");
27
+ expect((error as Error).message).toContain(path);
28
+ }
29
+ expect(existsSync(path)).toBe(false);
30
+ expect(existsSync(join(path, ".."))).toBe(false);
31
+ });
32
+
33
+ test("a file path and a path beneath a file fail with the typed error", async () => {
34
+ const path = join(makeRoot(), "file");
35
+ writeFileSync(path, "unchanged");
36
+ await expect(scanSource(path)).rejects.toBeInstanceOf(SourceDirectoryNotFoundError);
37
+ await expect(scanSource(join(path, "child"))).rejects.toBeInstanceOf(
38
+ SourceDirectoryNotFoundError,
39
+ );
40
+ expect(readFileSync(path, "utf8")).toBe("unchanged");
41
+ });
42
+
43
+ test("an empty directory still has no detections", async () => {
44
+ expect(await scanSource(makeRoot())).toEqual([]);
45
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import cli from "../../../cli/package.json";
3
+ import mcp from "../../../mcp/package.json";
4
+ import plugin from "../../../plugin/.claude-plugin/plugin.json";
5
+ import core from "../../package.json";
6
+ import { STACK_MANIFEST, type StackManifest } from "../index.ts";
7
+ import fixture from "./fixtures/stack-manifest.v1.json";
8
+
9
+ describe("Stack ecosystem manifest v1", () => {
10
+ test("public export preserves the fixture's JSON wire contract", () => {
11
+ const manifest: StackManifest = STACK_MANIFEST;
12
+ expect(JSON.parse(JSON.stringify(manifest))).toEqual(fixture);
13
+ });
14
+
15
+ test("every distributable matches its source name and version", () => {
16
+ const metadata = { core, cli, mcp, plugin };
17
+ const manifest: StackManifest = STACK_MANIFEST;
18
+ expect(Object.keys(metadata).sort()).toEqual(manifest.components.map(({ id }) => id).sort());
19
+ for (const component of manifest.components) {
20
+ expect(component.name).toBe(metadata[component.id].name);
21
+ expect(component.version).toBe(metadata[component.id].version);
22
+ expect(component.version).toMatch(/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/);
23
+ }
24
+ });
25
+
26
+ test("component IDs are unique", () => {
27
+ const ids = STACK_MANIFEST.components.map(({ id }) => id);
28
+ expect(new Set(ids).size).toBe(ids.length);
29
+ });
30
+
31
+ test("shared metadata cannot be mutated by consumers", () => {
32
+ expect(Object.isFrozen(STACK_MANIFEST)).toBe(true);
33
+ expect(Object.isFrozen(STACK_MANIFEST.components)).toBe(true);
34
+ for (const component of STACK_MANIFEST.components) {
35
+ expect(Object.isFrozen(component)).toBe(true);
36
+ }
37
+ });
38
+ });
@@ -38,4 +38,33 @@ describe("litellm-gateway template", () => {
38
38
  }
39
39
  expect(readme).not.toContain("\u2014");
40
40
  });
41
+
42
+ test("README prerequisites match the template and distinguish local checks from integration", async () => {
43
+ const cfg = await loadTemplate("litellm-gateway");
44
+ expect(cfg).toBeDefined();
45
+ const table = readme.match(
46
+ /\| Service \| Phantom secret name \|\n\| --- \| --- \|\n((?:\| `[^`]+` \| `[^`]+` \|\n)+)/,
47
+ );
48
+ expect(table).not.toBeNull();
49
+ const documentedSlots = [...(table?.[1] ?? "").matchAll(/\| `([^`]+)` \| `([^`]+)` \|/g)].map(
50
+ ([, service, secret]) => [service, secret],
51
+ );
52
+ const configuredSlots = Object.entries(cfg?.services ?? {}).flatMap(([service, entry]) =>
53
+ entry.secrets.map((secret) => [service, secret]),
54
+ );
55
+ expect(documentedSlots).toEqual(configuredSlots);
56
+ expect(cfg?.environments).toEqual([{ name: "dev", default: true }]);
57
+ expect(configuredSlots.flat()).not.toContain("LITELLM_MASTER_KEY");
58
+ for (const phrase of [
59
+ "stack init --template litellm-gateway --noProvision",
60
+ "service is already in the stack",
61
+ "does not set it",
62
+ "stack templates list --json",
63
+ "template-litellm-gateway.test.ts",
64
+ "local mock upstream and dummy credentials",
65
+ "does not establish those properties",
66
+ ]) {
67
+ expect(readme, phrase).toContain(phrase);
68
+ }
69
+ });
41
70
  });
@@ -11,10 +11,24 @@ describe("temporal-worker-phantom template", () => {
11
11
  const cfg = await loadTemplate("temporal-worker-phantom");
12
12
  expect(cfg).toBeDefined();
13
13
  expect(cfg?.services).toEqual({});
14
+ expect(cfg?.environments).toEqual([{ name: "dev", default: true }]);
14
15
  const raw = readFileSync(join(dir, "stack.toml"), "utf-8");
15
16
  expect(raw).toContain('template = "temporal-worker-phantom"');
16
17
  });
17
18
 
19
+ test("README describes the configuration and limits of local verification", () => {
20
+ for (const phrase of [
21
+ "stack init --template temporal-worker-phantom",
22
+ "stack list --json",
23
+ '"services": []',
24
+ "default `dev` environment",
25
+ "not connectivity, credentials or worker execution",
26
+ "does not guarantee raw credentials",
27
+ ]) {
28
+ expect(readme, phrase).toContain(phrase);
29
+ }
30
+ });
31
+
18
32
  test("README keeps the security rules and avoids em dashes", () => {
19
33
  for (const phrase of [
20
34
  "phantom exec",
@@ -9,9 +9,16 @@
9
9
  * fail or block the real value from landing later.
10
10
  * 2. For webhook-capable providers (Stripe, Clerk, Supabase, GitHub),
11
11
  * scaffold a commented-out handler stub in `.stack/webhooks/<provider>.ts`
12
- * so the user has somewhere concrete to land the signature verification.
12
+ * as an example only. Signature verification is not implemented: the
13
+ * stub's TODO states the trust limitation — a present signature header
14
+ * authenticates nothing and the body is untrusted input — and points at
15
+ * the provider-specific verification to add before deploying a handler.
13
16
  *
14
17
  * An opt-out (`--no-wire`) skips both steps.
18
+ *
19
+ * Recipe manifests are unsigned: signature verification is not implemented.
20
+ * Checking provider names against the catalog does not authenticate a recipe's
21
+ * origin or integrity; callers must use recipes from a trusted source.
15
22
  */
16
23
 
17
24
  import { mkdir, writeFile } from "node:fs/promises";
@@ -33,10 +40,8 @@ export interface WireResult {
33
40
  }
34
41
 
35
42
  /**
36
- * Providers with webhooks worth pre-wiring. Keep this list tight — each entry
37
- * gets a hand-maintained stub in `webhookStubFor`. Adding a new provider here
38
- * without also adding its stub produces an empty file, which is worse than
39
- * no file.
43
+ * Providers with webhooks worth pre-wiring. Keep this list tight and update
44
+ * the provider-specific secret and signature header mappings for each entry.
40
45
  */
41
46
  const WEBHOOK_PROVIDERS = new Set(["stripe", "clerk", "supabase", "github"]);
42
47
 
@@ -112,20 +117,53 @@ async function writeWebhookStub(cwd: string, provider: string): Promise<string>
112
117
  function webhookStubFor(provider: string): string {
113
118
  const base = `// Auto-generated by \`stack apply\` — webhook handler stub for ${provider}.
114
119
  // Fill in the secret retrieval + signature verification before deploying.
120
+ // This entire example is commented out and does not verify signatures.
121
+ // If uncommented as-is, it accepts any non-empty signature header and returns
122
+ // 200 without authenticating the request. Implement verification first.
115
123
  //
116
124
  // The secret lives in Phantom; read it at call time with:
117
125
  // phantom reveal ${webhookSecretKeyFor(provider)}
118
126
  //
119
127
  // export async function handle${capitalize(provider)}Webhook(req: Request): Promise<Response> {
120
128
  // const signature = req.headers.get("${signatureHeaderFor(provider)}");
129
+ // if (!signature) return new Response("Missing signature", { status: 400 });
121
130
  // const body = await req.text();
122
- // // TODO: verify(signature, body) before trusting.
131
+ // // TODO(security): signature verification is NOT implemented here. The
132
+ // // header check above proves only that a signature header arrived; its
133
+ // // value is never compared to anything. Until this TODO is done, \`body\`
134
+ // // is unauthenticated, attacker-controlled input — anyone who can POST
135
+ // // to this endpoint can forge a ${provider} webhook.
136
+ // // ${verificationHintFor(provider)}
137
+ // // Verify over the exact raw body bytes with the webhook secret
138
+ // // (\`phantom reveal ${webhookSecretKeyFor(provider)}\`) and compare in
139
+ // // constant time; reject missing or invalid signatures, and only then
140
+ // // parse, trust, or act on the body.
123
141
  // return new Response("ok", { status: 200 });
124
142
  // }
125
143
  `;
126
144
  return base;
127
145
  }
128
146
 
147
+ /**
148
+ * One-line pointer to the provider's documented signature scheme, inlined into
149
+ * the stub's TODO so the implementer starts at the right verification API.
150
+ * Falls back to a generic instruction for providers added later.
151
+ */
152
+ function verificationHintFor(provider: string): string {
153
+ switch (provider) {
154
+ case "stripe":
155
+ return "Stripe: `stripe.webhooks.constructEvent` — HMAC-SHA256 with timestamp tolerance.";
156
+ case "clerk":
157
+ return "Clerk (svix): `new Webhook(secret).verify(body, headers)`.";
158
+ case "supabase":
159
+ return "Supabase: `x-supabase-signature` per Supabase's documented webhook scheme.";
160
+ case "github":
161
+ return "GitHub: `x-hub-signature-256` is `sha256=` + HMAC-SHA256 of the raw body.";
162
+ default:
163
+ return "Verify per the provider's documented signature scheme.";
164
+ }
165
+ }
166
+
129
167
  function webhookSecretKeyFor(provider: string): string {
130
168
  switch (provider) {
131
169
  case "stripe":
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import { SourceDirectoryNotFoundError } from "./errors.ts";
4
5
 
5
6
  /**
6
7
  * Source-level provider detection. Reads package.json / requirements.txt /
@@ -165,6 +166,18 @@ export async function scanSource(
165
166
  cwd: string,
166
167
  _opts: SourceScanOptions = {},
167
168
  ): Promise<SourceDetection[]> {
169
+ let root: Awaited<ReturnType<typeof stat>>;
170
+ try {
171
+ root = await stat(cwd);
172
+ } catch (error) {
173
+ const code = (error as NodeJS.ErrnoException).code;
174
+ if (code === "ENOENT" || code === "ENOTDIR") {
175
+ throw new SourceDirectoryNotFoundError(cwd, { cause: error });
176
+ }
177
+ throw error;
178
+ }
179
+ if (!root.isDirectory()) throw new SourceDirectoryNotFoundError(cwd);
180
+
168
181
  const hits = new Map<string, SourceDetection>();
169
182
 
170
183
  const add = (provider: string, signal: string, confidence: DetectionConfidence) => {
package/src/errors.ts CHANGED
@@ -27,3 +27,9 @@ export class ConfigNotFoundError extends StackError {
27
27
  super("CONFIG_NOT_FOUND", `No .stack.toml at ${path}. Run \`stack init\` first.`);
28
28
  }
29
29
  }
30
+
31
+ export class SourceDirectoryNotFoundError extends StackError {
32
+ constructor(path: string, options?: { cause?: unknown }) {
33
+ super("SOURCE_DIRECTORY_NOT_FOUND", `Source directory not found: ${path}`, options);
34
+ }
35
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./config.ts";
2
+ export { STACK_MANIFEST, type StackManifest, type StackComponent } from "./stack-manifest.ts";
2
3
  export {
3
4
  isEnabled as isTelemetryEnabled,
4
5
  promptFirstRun as promptTelemetryFirstRun,
@@ -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 —
@@ -13,6 +14,16 @@ export interface PhantomExecResult {
13
14
  code: number;
14
15
  signal?: NodeJS.Signals;
15
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
+ }
16
27
  }
17
28
 
18
29
  /** Interpret close fields only; this does not attest provider or vault state. */
@@ -25,15 +36,31 @@ export function completionForClose(
25
36
 
26
37
  let cachedInstalled: boolean | undefined;
27
38
 
28
- export async function isPhantomInstalled(): Promise<boolean> {
29
- 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";
30
47
  try {
31
- const result = await exec(["--version"], { allowFailure: true });
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";
32
55
  cachedInstalled = result.code === 0 && result.completion === "exited";
33
56
  } catch {
34
57
  cachedInstalled = false;
35
58
  }
36
- 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";
37
64
  }
38
65
 
39
66
  export async function assertPhantomInstalled(): Promise<void> {
@@ -46,17 +73,98 @@ export interface ExecOptions {
46
73
  stdin?: string;
47
74
  /** If true, inherit stdio so interactive flows (OAuth browser) work. */
48
75
  interactive?: boolean;
76
+ /** Kill the phantom child after this many milliseconds. */
77
+ timeoutMs?: number;
49
78
  }
50
79
 
51
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);
52
85
  return new Promise((resolvePromise, reject) => {
53
- 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,
54
93
  cwd: opts.cwd,
55
94
  stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
95
+ ...(useGroup ? { detached: true } : {}),
56
96
  });
57
97
 
58
98
  let stdout = "";
59
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
+ }
60
168
 
61
169
  if (!opts.interactive) {
62
170
  child.stdout?.on("data", (chunk) => {
@@ -75,6 +183,9 @@ export async function exec(args: string[], opts: ExecOptions = {}): Promise<Phan
75
183
  }
76
184
 
77
185
  child.on("error", (err) => {
186
+ if (settled) return;
187
+ settled = true;
188
+ clearTimer();
78
189
  if ((err as NodeJS.ErrnoException).code === "ENOENT") {
79
190
  reject(new PhantomNotInstalledError());
80
191
  return;
@@ -82,28 +193,9 @@ export async function exec(args: string[], opts: ExecOptions = {}): Promise<Phan
82
193
  reject(err);
83
194
  });
84
195
 
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) {
95
- reject(
96
- new StackError(
97
- "PHANTOM_EXEC_FAILED",
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`,
101
- ),
102
- );
103
- return;
104
- }
105
- resolvePromise(result);
106
- });
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));
107
199
  });
108
200
  }
109
201
 
@@ -175,9 +267,17 @@ export async function removeSecret(
175
267
  * Secret names in the vault, from `phantom list --json` (never values). The
176
268
  * human `phantom list` output is a decorated table and is not parsed.
177
269
  */
178
- export async function listSecrets(cwd?: string): Promise<string[]> {
179
- await assertPhantomInstalled();
180
- const { stdout, code } = await exec(["list", "--json"], { cwd, allowFailure: true });
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);
181
281
  if (code !== 0) {
182
282
  throw new StackError(
183
283
  "PHANTOM_METADATA_UNAVAILABLE",
@@ -224,10 +324,21 @@ export async function listSecrets(cwd?: string): Promise<string[]> {
224
324
  * inventory cannot be read (Phantom missing, no .phantom.toml, unrecognized
225
325
  * output), so callers never report an unreadable vault as empty.
226
326
  */
227
- export async function countSecrets(cwd?: string): Promise<number | null> {
327
+ export async function countSecrets(
328
+ cwd?: string,
329
+ opts?: { timeoutMs?: number; skipInstallCheck?: boolean },
330
+ ): Promise<number | null> {
228
331
  try {
229
- if (!(await isPhantomInstalled())) return null;
230
- return (await listSecrets(cwd)).length;
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;
231
342
  } catch {
232
343
  return null;
233
344
  }
@@ -0,0 +1,39 @@
1
+ /** A distributable Stack component, rather than an installed companion tool. */
2
+ export interface StackComponent {
3
+ readonly id: "core" | "cli" | "mcp" | "plugin";
4
+ readonly name: string;
5
+ readonly version: string;
6
+ readonly distribution: "npm" | "claude-code-plugin";
7
+ }
8
+
9
+ /**
10
+ * Source release metadata for ecosystem consumers. Versions do not establish
11
+ * registry publication or describe tools installed on the current machine.
12
+ * Additive fields may stay in v1; removals or renames require a schema bump.
13
+ */
14
+ export interface StackManifest {
15
+ readonly schema_version: 1;
16
+ readonly kind: "ashlr-stack-manifest";
17
+ readonly components: readonly StackComponent[];
18
+ }
19
+
20
+ /**
21
+ * Import from @ashlr/stack-core. This JSON-safe export performs no filesystem,
22
+ * network, credential or subprocess access. Keep versions in sync on release;
23
+ * the fixture contract test checks the package and plugin metadata for drift.
24
+ */
25
+ export const STACK_MANIFEST = Object.freeze({
26
+ schema_version: 1,
27
+ kind: "ashlr-stack-manifest",
28
+ components: Object.freeze([
29
+ Object.freeze({ id: "core", name: "@ashlr/stack-core", version: "0.3.1", distribution: "npm" }),
30
+ Object.freeze({ id: "cli", name: "@ashlr/stack", version: "0.3.1", distribution: "npm" }),
31
+ Object.freeze({ id: "mcp", name: "ashlr-stack-mcp", version: "0.3.1", distribution: "npm" }),
32
+ Object.freeze({
33
+ id: "plugin",
34
+ name: "stack",
35
+ version: "0.1.0",
36
+ distribution: "claude-code-plugin",
37
+ }),
38
+ ]),
39
+ } as const) satisfies StackManifest;