@crouton-kit/capture 1.3.4 → 1.3.5

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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * vault/bundle — the import-driven exec engine.
3
+ *
4
+ * Ports the bundling mechanism of northlight's
5
+ * `apps/core/src/tool-registry/lib/bundle-agent-code.ts`, DROPPING the telemetry
6
+ * envelope (no `__vallumStartCall`, no `__snapshot`, no `borgEnabled`). When
7
+ * `capture exec` is handed code containing leading static imports, this turns it
8
+ * into a single self-contained IIFE: the user's `libs/<name>` imports are
9
+ * resolved to the forked vault source, esbuild bundles the whole tree on the
10
+ * fly, and the result is a string the exec path evals over CDP.
11
+ *
12
+ * No top-level `esbuild` import — it is loaded lazily via `loadEsbuild()` so the
13
+ * published package (no esbuild) never crashes at startup.
14
+ */
15
+
16
+ import * as fs from "fs";
17
+ import * as path from "path";
18
+ import type { Plugin, BuildResult, Message } from "esbuild";
19
+ import {
20
+ isValidLibName,
21
+ loadEsbuild,
22
+ unknownLibMsg,
23
+ vaultLibsDir,
24
+ vaultRepoRoot,
25
+ } from "./paths.js";
26
+
27
+ // Sentinel the post-bundle return-insert anchors on. esbuild with minify:false
28
+ // never renames top-level identifiers, and no first-party lib references
29
+ // __CAPTURE_*, so this literal survives bundling intact and uniquely (the same
30
+ // invariant core relies on for __VALLUM_USER_PROMISE).
31
+ const SENTINEL = "__CAPTURE_RESULT";
32
+
33
+ // Matches a leading run of static import statements (plus any leading comments)
34
+ // per statement: leading ws/comments, then `import` through its single
35
+ // module-specifier string and optional `;`. A normal import has exactly one
36
+ // string literal (the specifier), so this is unambiguous. Ported from
37
+ // bundle-agent-code.ts, with one addition: the `(?!\s*\()` lookahead so a
38
+ // leading dynamic `import(...)` CALL is NOT misclassified as a static import
39
+ // statement (it would otherwise be hoisted and the body run detached, silently
40
+ // returning undefined). Dynamic `import(...)` anywhere in the body is untouched.
41
+ const IMPORT_STMT_RE =
42
+ /^\s*(?:(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*import\b(?!\s*\()[^'"`]*?(['"])(?:[^'"`\\]|\\.)*\1[^\n;]*;?/;
43
+
44
+ function splitLeadingImports(code: string): { imports: string; body: string } {
45
+ const imports: string[] = [];
46
+ let body = code;
47
+ for (;;) {
48
+ const m = IMPORT_STMT_RE.exec(body);
49
+ if (!m) break;
50
+ imports.push(m[0]);
51
+ body = body.slice(m[0].length);
52
+ }
53
+ return { imports: imports.join(""), body };
54
+ }
55
+
56
+ /**
57
+ * True iff `code` contains ≥1 leading static `import` statement. This is the
58
+ * trigger exec.ts uses to preserve the no-import fast path: plain exec has no
59
+ * top-level import, so esbuild never runs for it.
60
+ */
61
+ export function hasImports(code: string): boolean {
62
+ return splitLeadingImports(code).imports.length > 0;
63
+ }
64
+
65
+ // Matches a `libs/<name>` module specifier inside a string literal.
66
+ const LIB_SPECIFIER_RE = /(['"])libs\/((?:[^'"\\]|\\.)*)\1/g;
67
+
68
+ // Validate every `libs/<name>` specifier up front. esbuild with treeShaking:true
69
+ // drops an UNUSED import before its resolver ever runs, so a bogus-lib import
70
+ // whose binding is unreferenced would otherwise bundle silently; this guarantees
71
+ // the `Unknown lib` error deterministically, and before esbuild is even loaded
72
+ // (fail fast, before any CDP). Mirrors vaultResolver's existence check exactly.
73
+ function preflightLibs(imports: string): void {
74
+ const libs = vaultLibsDir();
75
+ LIB_SPECIFIER_RE.lastIndex = 0;
76
+ let m: RegExpExecArray | null;
77
+ while ((m = LIB_SPECIFIER_RE.exec(imports)) !== null) {
78
+ const name = m[2];
79
+ if (!isValidLibName(name) || !fs.existsSync(path.join(libs, name, "index.ts"))) {
80
+ throw new Error(unknownLibMsg(name));
81
+ }
82
+ }
83
+ }
84
+
85
+ // Hoist the imports to module top level (so esbuild resolves & tree-shakes them)
86
+ // and wrap the body in a result-capturing async IIFE bound to the sentinel. The
87
+ // trailing `.then()` is a preserved no-op call → DCE-safe anchor for the
88
+ // post-bundle return insert (a bare `__CAPTURE_RESULT;` statement can be dropped
89
+ // by tree-shaking; a call survives).
90
+ function buildEntry(code: string): string {
91
+ const { imports, body } = splitLeadingImports(code);
92
+ const expr = body.trim().replace(/;+\s*$/, "");
93
+ return [
94
+ imports,
95
+ `const ${SENTINEL} = (async () => { ${expr} })();`,
96
+ `${SENTINEL}.then();`,
97
+ ].join("\n");
98
+ }
99
+
100
+ // Maps the two bare specifier shapes (`@vallum/_runtime`, `libs/<name>`) to
101
+ // absolute source paths in the DEFAULT `file` namespace — no custom namespace,
102
+ // no onLoad. esbuild then natively resolves each file's own relative imports
103
+ // (`./context`, `./messages/send`) and npm imports (`zod`) off disk, pulling the
104
+ // whole nested source tree in for free.
105
+ function vaultResolver(): Plugin {
106
+ const libs = vaultLibsDir();
107
+ const runtime = path.join(libs, "_runtime", "index.ts");
108
+ return {
109
+ name: "vault",
110
+ setup(build) {
111
+ build.onResolve({ filter: /^@vallum\/_runtime$/ }, () => ({
112
+ path: runtime,
113
+ }));
114
+ build.onResolve({ filter: /^libs\// }, (args) => {
115
+ const name = args.path.slice("libs/".length);
116
+ if (!isValidLibName(name)) {
117
+ return { errors: [{ text: unknownLibMsg(name) }] };
118
+ }
119
+ const entry = path.join(libs, name, "index.ts");
120
+ return fs.existsSync(entry)
121
+ ? { path: entry }
122
+ : { errors: [{ text: unknownLibMsg(name) }] };
123
+ });
124
+ },
125
+ };
126
+ }
127
+
128
+ async function runEsbuild(entry: string): Promise<BuildResult<{ write: false }>> {
129
+ const esbuild = await loadEsbuild();
130
+ const opts = {
131
+ stdin: {
132
+ contents: entry,
133
+ resolveDir: vaultRepoRoot(),
134
+ loader: "ts" as const,
135
+ sourcefile: "exec.ts",
136
+ },
137
+ plugins: [vaultResolver()],
138
+ bundle: true,
139
+ write: false as const,
140
+ format: "iife" as const,
141
+ target: "es2020",
142
+ platform: "browser" as const,
143
+ treeShaking: true,
144
+ minify: false,
145
+ logLevel: "silent" as const,
146
+ };
147
+ try {
148
+ return await esbuild.build(opts);
149
+ } catch (error) {
150
+ // esbuild's background process can crash, making all future calls fail with
151
+ // "The service is no longer running". stop() forces a fresh process; retry once.
152
+ if (
153
+ error instanceof Error &&
154
+ error.message.includes("service is no longer running")
155
+ ) {
156
+ esbuild.stop();
157
+ return await esbuild.build(opts);
158
+ }
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ // The only post-bundle edit: esbuild's iife wrapper `(() => { … })()` has no
164
+ // return, so the script completion value is undefined. Insert `return ` before
165
+ // the last `__CAPTURE_RESULT.then(` so the IIFE returns the user's promise.
166
+ function applyReturnInsert(bundled: string): string {
167
+ const anchor = `${SENTINEL}.then(`;
168
+ const i = bundled.lastIndexOf(anchor);
169
+ return i === -1 ? bundled : bundled.slice(0, i) + "return " + bundled.slice(i);
170
+ }
171
+
172
+ function formatBuildErrors(errors: Message[]): string {
173
+ return errors
174
+ .map((e) => {
175
+ const l = e.location;
176
+ const loc = l ? ` (${l.file}:${l.line}:${l.column})` : "";
177
+ return `${e.text}${loc}`;
178
+ })
179
+ .join("\n");
180
+ }
181
+
182
+ /**
183
+ * Bundle import-bearing exec code into a complete IIFE string that returns the
184
+ * user's promise. Throws an Error whose message formats esbuild's `errors[]`
185
+ * (text + file:line:col) on a build/resolve failure — exec.ts prints it and
186
+ * exits before opening a CDP connection.
187
+ */
188
+ export async function bundleExec(code: string): Promise<string> {
189
+ preflightLibs(splitLeadingImports(code).imports);
190
+ const entry = buildEntry(code);
191
+ let result: BuildResult<{ write: false }>;
192
+ try {
193
+ result = await runEsbuild(entry);
194
+ } catch (error) {
195
+ const errs = (error as { errors?: Message[] })?.errors;
196
+ if (Array.isArray(errs) && errs.length > 0) {
197
+ throw new Error(formatBuildErrors(errs));
198
+ }
199
+ throw error;
200
+ }
201
+ if (!result.outputFiles || result.outputFiles.length === 0) {
202
+ throw new Error("esbuild produced no output");
203
+ }
204
+ return applyReturnInsert(result.outputFiles[0].text);
205
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * vault/docs — on-demand library doc generation + sha-keyed cache.
3
+ *
4
+ * Reimplements vault's `generateDocs` (northlight-vault
5
+ * lib-pipeline/build/build-libs.ts:138-184) in-process, dropping the
6
+ * manifest/sha/scripts half: for a lib we esbuild-bundle its `schemas.ts` to a
7
+ * self-contained CJS string (zod inlined from node_modules by `bundle:true`),
8
+ * evaluate it in-memory, then convert each function's Zod input/output to JSON
9
+ * Schema (Zod-4-native `toJSONSchema()`, with the `void` special-case ported
10
+ * verbatim). Results are cached gitignored under `vault/.cache/docs/<svc>.json`
11
+ * with an embedded `_srcSha` = sha256(schemas.ts); a stale/missing sha rebuilds.
12
+ *
13
+ * Nothing here imports `esbuild` at the top level — it is loaded lazily via
14
+ * `loadEsbuild()` so the published package (no `vault/`, no `esbuild`) degrades
15
+ * to a single `DEV_ONLY_MSG` instead of crashing.
16
+ */
17
+
18
+ import * as fs from "fs";
19
+ import * as path from "path";
20
+ import { createHash } from "crypto";
21
+ import {
22
+ vaultLibsDir,
23
+ vaultRepoRoot,
24
+ listLibs,
25
+ loadEsbuild,
26
+ isValidLibName,
27
+ unknownLibMsg,
28
+ } from "./paths.js";
29
+
30
+ /** A single library function's doc entry (input/output are JSON Schema). */
31
+ export interface LibraryDocFunction {
32
+ name: string;
33
+ description: string;
34
+ notes?: string;
35
+ input: unknown;
36
+ output: unknown;
37
+ }
38
+
39
+ /** A library's generated doc — same shape `nl lib read --json` returns. */
40
+ export interface LibraryDoc {
41
+ /** `@vallum/<svc>` */
42
+ library: string;
43
+ description: string;
44
+ notes?: string;
45
+ loginUrl?: string;
46
+ /** `'public' | 'chat' | 'hidden'` (default `'public'`). */
47
+ visibility: string;
48
+ functions: LibraryDocFunction[];
49
+ }
50
+
51
+ /** Shape of each entry in a lib's `schemas.ts` exported `allSchemas` array. */
52
+ interface FunctionSchema {
53
+ name: string;
54
+ description: string;
55
+ notes?: string;
56
+ input: unknown;
57
+ output: unknown;
58
+ }
59
+
60
+ /** Mirrors `nl lib`'s agent-visible filter (vault-client.service.ts:49). */
61
+ const AGENT_VISIBLE_VISIBILITIES = new Set(["public", "chat"]);
62
+
63
+ /** True iff a doc is agent-visible (visibility ∈ {public, chat}). */
64
+ export function isAgentVisible(doc: LibraryDoc): boolean {
65
+ return AGENT_VISIBLE_VISIBILITIES.has(doc.visibility);
66
+ }
67
+
68
+ /**
69
+ * A `require` for the in-memory CJS eval below. zod is inlined by `bundle:true`
70
+ * so this is never actually hit — it's passed defensively. Ambient `require` is
71
+ * available under both the built CJS bin (native) and tsx-ESM (injected into a
72
+ * real module). We avoid `createRequire(import.meta.url)` because `import.meta`
73
+ * is empty under the CJS bin build (esbuild) and would throw at module load.
74
+ */
75
+ declare const require: ((id: string) => unknown) | undefined;
76
+ const cjsRequire = (id: string): unknown => {
77
+ if (typeof require === "function") return require(id);
78
+ throw new Error(`cannot require('${id}') — unavailable in this runtime`);
79
+ };
80
+
81
+ function cacheDir(): string {
82
+ return path.join(vaultRepoRoot(), ".cache", "docs");
83
+ }
84
+
85
+ function schemasPathFor(svc: string): string {
86
+ return path.join(vaultLibsDir(), svc, "schemas.ts");
87
+ }
88
+
89
+ function srcSha(svc: string): string {
90
+ return createHash("sha256")
91
+ .update(fs.readFileSync(schemasPathFor(svc), "utf8"))
92
+ .digest("hex");
93
+ }
94
+
95
+ /**
96
+ * Zod → JSON Schema, porting build-libs.ts:155-173 verbatim. `z.void()` has no
97
+ * JSON-Schema representation, so it maps to `{type:'null'}` (carrying its
98
+ * description if present); everything else uses Zod 4's `toJSONSchema()`.
99
+ */
100
+ function toJsonSchema(zodSchema: unknown): unknown {
101
+ const s = zodSchema as {
102
+ _zod?: { def?: { type?: string } };
103
+ description?: string;
104
+ toJSONSchema?: () => unknown;
105
+ };
106
+ if (s?._zod?.def?.type === "void") {
107
+ const desc = s.description;
108
+ return desc ? { type: "null", description: desc } : { type: "null" };
109
+ }
110
+ if (!s?.toJSONSchema) {
111
+ throw new Error(
112
+ "Schema missing toJSONSchema method - ensure Zod 4+ is installed",
113
+ );
114
+ }
115
+ return s.toJSONSchema();
116
+ }
117
+
118
+ /**
119
+ * Build one lib's doc from source: esbuild-bundle its `schemas.ts` to a
120
+ * self-contained CJS string, eval it in-memory, assemble the `LibraryDoc`.
121
+ */
122
+ async function buildDoc(svc: string): Promise<LibraryDoc> {
123
+ const esbuild = await loadEsbuild();
124
+ const result = await esbuild.build({
125
+ entryPoints: [schemasPathFor(svc)],
126
+ bundle: true,
127
+ write: false,
128
+ format: "cjs",
129
+ platform: "node",
130
+ target: "es2020",
131
+ logLevel: "silent",
132
+ });
133
+ const text = result.outputFiles?.[0]?.text;
134
+ if (!text) throw new Error(`esbuild produced no output for ${svc}/schemas.ts`);
135
+
136
+ // In-memory CJS eval (no temp file). zod is inlined by bundle:true, so the
137
+ // `require` arg is never actually hit — passed defensively for any edge.
138
+ const mod = { exports: {} as Record<string, unknown> };
139
+ new Function("module", "exports", "require", text)(
140
+ mod,
141
+ mod.exports,
142
+ cjsRequire,
143
+ );
144
+ const x = mod.exports as {
145
+ allSchemas?: FunctionSchema[];
146
+ libraryDescription?: string;
147
+ libraryNotes?: string;
148
+ loginUrl?: string;
149
+ libraryVisibility?: string;
150
+ };
151
+
152
+ const allSchemas = x.allSchemas ?? [];
153
+ const doc: LibraryDoc = {
154
+ library: `@vallum/${svc}`,
155
+ description: x.libraryDescription ?? `${svc} service library`,
156
+ visibility: x.libraryVisibility ?? "public",
157
+ functions: allSchemas.map((schema) => ({
158
+ name: schema.name,
159
+ description: schema.description,
160
+ ...(schema.notes ? { notes: schema.notes } : {}),
161
+ input: toJsonSchema(schema.input),
162
+ output: toJsonSchema(schema.output),
163
+ })),
164
+ };
165
+ if (x.libraryNotes) doc.notes = x.libraryNotes;
166
+ if (x.loginUrl) doc.loginUrl = x.loginUrl;
167
+ return doc;
168
+ }
169
+
170
+ /**
171
+ * Get one lib's doc — reads the sha-valid cache if present, else (re)builds and
172
+ * writes it. May read ANY present lib (no visibility filter); used by
173
+ * `lib read`. Throws `Unknown lib …` if the named lib has no `schemas.ts`.
174
+ */
175
+ export async function getDoc(name: string): Promise<LibraryDoc> {
176
+ if (!isValidLibName(name) || !fs.existsSync(schemasPathFor(name))) {
177
+ throw new Error(unknownLibMsg(name));
178
+ }
179
+ const sha = srcSha(name);
180
+ const cacheFile = path.join(cacheDir(), `${name}.json`);
181
+ if (fs.existsSync(cacheFile)) {
182
+ try {
183
+ const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8")) as
184
+ & LibraryDoc
185
+ & { _srcSha?: string };
186
+ if (cached._srcSha === sha) {
187
+ delete cached._srcSha;
188
+ return cached;
189
+ }
190
+ } catch {
191
+ // corrupt/unreadable cache → fall through and rebuild
192
+ }
193
+ }
194
+ const doc = await buildDoc(name);
195
+ fs.mkdirSync(cacheDir(), { recursive: true });
196
+ fs.writeFileSync(
197
+ cacheFile,
198
+ JSON.stringify({ ...doc, _srcSha: sha }, null, 2),
199
+ );
200
+ return doc;
201
+ }
202
+
203
+ /**
204
+ * All agent-visible libs' docs (visibility ∈ {public, chat}). Lazily builds any
205
+ * missing/stale doc, then filters by visibility. A lib whose `schemas.ts` fails
206
+ * to build is skipped with a stderr warning rather than failing the whole list.
207
+ * Used by `lib list`/`search`/`show`.
208
+ */
209
+ export async function listDocs(): Promise<LibraryDoc[]> {
210
+ const out: LibraryDoc[] = [];
211
+ for (const name of listLibs()) {
212
+ let doc: LibraryDoc;
213
+ try {
214
+ doc = await getDoc(name);
215
+ } catch (e) {
216
+ console.error(
217
+ `WARN: skipping lib "${name}" — ${(e as Error).message}`,
218
+ );
219
+ continue;
220
+ }
221
+ if (isAgentVisible(doc)) out.push(doc);
222
+ }
223
+ return out;
224
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * vault/paths — locate the forked vault lib source on disk + the dev-only guard.
3
+ *
4
+ * Shared foundation for the import-driven exec bundler (`bundle.ts`) and the
5
+ * on-demand doc generator (`docs.ts`). This module must NOT import `esbuild` at
6
+ * the top level — esbuild is loaded lazily (see `loadEsbuild`) so the published
7
+ * package (which ships neither `vault/` nor `esbuild`) never crashes at startup.
8
+ */
9
+
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+
13
+ /**
14
+ * The single dev-only degradation message. Thrown when `vault/libs` is missing
15
+ * (published package) or when `esbuild` cannot be loaded. (design §2.6, verbatim)
16
+ */
17
+ export const DEV_ONLY_MSG =
18
+ "This is a dev-only feature of capture — run it from a capture checkout\n" +
19
+ "(vault/ source + `pnpm install`). Not available in the published package.";
20
+
21
+ /**
22
+ * Absolute path to the forked `vault/libs` directory. Dual `__dirname`
23
+ * candidates cover the built bin (`bin/../vault/libs`) and tsx dev
24
+ * (`src/vault/../../vault/libs`) — mirrors the `__dirname`+`..` precedent in
25
+ * `capture.ts`. Throws `DEV_ONLY_MSG` if neither candidate holds the source.
26
+ */
27
+ export function vaultLibsDir(): string {
28
+ const candidates = [
29
+ path.resolve(__dirname, "..", "vault", "libs"), // built bin: bin/../vault/libs
30
+ path.resolve(__dirname, "..", "..", "vault", "libs"), // tsx dev: src/vault/../../vault/libs
31
+ ];
32
+ const hit = candidates.find((p) =>
33
+ fs.existsSync(path.join(p, "_runtime", "index.ts"))
34
+ );
35
+ if (!hit) throw new Error(DEV_ONLY_MSG);
36
+ return hit;
37
+ }
38
+
39
+ /**
40
+ * The `vault/` directory (parent of `vault/libs`). Used as esbuild's
41
+ * `resolveDir` so its native node-resolution finds `node_modules/zod` etc.
42
+ */
43
+ export function vaultRepoRoot(): string {
44
+ return path.dirname(vaultLibsDir());
45
+ }
46
+
47
+ /**
48
+ * Sorted list of available lib names — directories under `vault/libs`,
49
+ * excluding `_`-prefixed internal dirs (e.g. `_runtime`). Used by the
50
+ * resolver's "Available: …" error and by the docs/lib surfaces.
51
+ */
52
+ export function listLibs(): string[] {
53
+ return fs
54
+ .readdirSync(vaultLibsDir(), { withFileTypes: true })
55
+ .filter((e) => e.isDirectory() && !e.name.startsWith("_"))
56
+ .map((e) => e.name)
57
+ .sort();
58
+ }
59
+
60
+ /**
61
+ * The friendly "Unknown lib" message, shared by the exec resolver/preflight
62
+ * (`bundle.ts`) and the doc generator (`docs.ts`) so an unknown OR malformed
63
+ * lib name surfaces byte-identical text everywhere.
64
+ */
65
+ export function unknownLibMsg(name: string): string {
66
+ return `Unknown lib "${name}". Available: ${listLibs().join(", ")}`;
67
+ }
68
+
69
+ // A lib name is a single path segment of `[A-Za-z0-9_.-]` (covers `hunter.io`)
70
+ // with no `..`. Rejects path separators / traversal (`libs/../x`, `libs/a/b`)
71
+ // before a name reaches `path.join`/esbuild — defense-in-depth (exec already
72
+ // runs arbitrary JS, so this only buys a clearer error, not isolation).
73
+ const VALID_LIB_NAME = /^[A-Za-z0-9_.-]+$/;
74
+ export function isValidLibName(name: string): boolean {
75
+ return VALID_LIB_NAME.test(name) && !name.includes("..");
76
+ }
77
+
78
+ /**
79
+ * Lazily load `esbuild`. Kept here so both `bundle.ts` and `docs.ts` share one
80
+ * lazy loader and the top-level module never statically imports esbuild — a
81
+ * published install (no esbuild) only fails when the feature path is exercised.
82
+ */
83
+ export async function loadEsbuild(): Promise<typeof import("esbuild")> {
84
+ return await import("esbuild").catch(() => {
85
+ throw new Error(DEV_ONLY_MSG);
86
+ });
87
+ }