@ashlr/stack-core 0.2.2 → 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/package.json +1 -1
- package/src/__tests__/agent-onboard.test.ts +62 -0
- package/src/__tests__/base-urls.test.ts +48 -2
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +23 -0
- package/src/__tests__/config-split.test.ts +66 -0
- package/src/__tests__/config.test.ts +92 -3
- package/src/__tests__/credential-staging.test.ts +3 -2
- package/src/__tests__/detect-source.test.ts +182 -3
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +38 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/phantom-harness.test.ts +102 -1
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-wire.test.ts +372 -2
- package/src/__tests__/pipeline-integration.test.ts +32 -0
- package/src/__tests__/publish-script.test.ts +63 -18
- package/src/__tests__/recipe.test.ts +23 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +16 -1
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/template-litellm-gateway.test.ts +29 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +14 -0
- package/src/ai/phantom-wire.ts +44 -6
- package/src/detect-source.ts +14 -1
- package/src/errors.ts +6 -0
- package/src/index.ts +1 -0
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +144 -33
- 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",
|
package/src/ai/phantom-wire.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
37
|
-
*
|
|
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:
|
|
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":
|
package/src/detect-source.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
29
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
179
|
-
|
|
180
|
-
|
|
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(
|
|
327
|
+
export async function countSecrets(
|
|
328
|
+
cwd?: string,
|
|
329
|
+
opts?: { timeoutMs?: number; skipInstallCheck?: boolean },
|
|
330
|
+
): Promise<number | null> {
|
|
228
331
|
try {
|
|
229
|
-
|
|
230
|
-
|
|
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.0", distribution: "npm" }),
|
|
30
|
+
Object.freeze({ id: "cli", name: "@ashlr/stack", version: "0.3.0", distribution: "npm" }),
|
|
31
|
+
Object.freeze({ id: "mcp", name: "ashlr-stack-mcp", version: "0.3.0", 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;
|