@crouton-kit/capture 1.3.4 → 1.3.6
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/bin/capture +211996 -376
- package/package.json +7 -3
- package/src/capture.ts +15 -5
- package/src/cdp/args.ts +37 -5
- package/src/cdp/commands/exec.ts +31 -10
- package/src/cdp/commands/lib.ts +356 -0
- package/src/cdp/commands/tabs.ts +2 -8
- package/src/cdp/commands/ui.ts +1 -1
- package/src/cdp/connection.ts +23 -21
- package/src/cdp/detect.ts +62 -13
- package/src/cdp/dispatch.ts +2 -0
- package/src/cdp/exec-expression.ts +49 -0
- package/src/cdp/execute.ts +5 -9
- package/src/cdp/record.ts +63 -20
- package/src/cdp/targets.ts +120 -4
- package/src/cdp/types.ts +1 -0
- package/src/cdp/usage.ts +11 -3
- package/src/session/commands.ts +107 -32
- package/src/vault/bundle.ts +205 -0
- package/src/vault/docs.ts +224 -0
- package/src/vault/paths.ts +87 -0
package/src/session/commands.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
setActiveSession,
|
|
13
13
|
clearActiveSession,
|
|
14
14
|
} from '../session-context.js';
|
|
15
|
+
import { expandEqualsFlags } from '../cdp/args.js';
|
|
15
16
|
|
|
16
17
|
const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
|
|
17
18
|
|
|
@@ -65,11 +66,60 @@ function generateId(): string {
|
|
|
65
66
|
return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function hasHelpFlag(args: string[]): boolean {
|
|
70
|
+
return args.some((arg) => arg === '-h' || arg === '--help');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function waitForPageLoad(
|
|
74
|
+
client: { waitReady(): Promise<void>; send(method: string, params?: Record<string, unknown>): Promise<unknown>; on(event: string, handler: (params: unknown) => void): void; },
|
|
75
|
+
timeoutMs: number,
|
|
76
|
+
): Promise<boolean> {
|
|
77
|
+
await client.waitReady();
|
|
78
|
+
await client.send('Page.enable');
|
|
79
|
+
|
|
80
|
+
return await new Promise<boolean>((resolve) => {
|
|
81
|
+
const timer = setTimeout(() => resolve(true), timeoutMs);
|
|
82
|
+
client.on('Page.loadEventFired', () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
resolve(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function printSessionHelp(): void {
|
|
90
|
+
console.log(`capture session — manage capture sessions
|
|
91
|
+
|
|
92
|
+
Sub-commands:
|
|
93
|
+
start [--url <url>] Start a session (opens tab, records HAR, sets active context)
|
|
94
|
+
stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
|
|
95
|
+
list List active and stopped sessions
|
|
96
|
+
view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs
|
|
97
|
+
|
|
98
|
+
Why sessions: once started, every subsequent capture command auto-fills
|
|
99
|
+
--target (the tab) and --har (the recording). No manual flag threading.
|
|
100
|
+
|
|
101
|
+
Typical flow:
|
|
102
|
+
1. capture session start --url http://localhost:3000
|
|
103
|
+
2. Interact — no --target / --har needed:
|
|
104
|
+
capture a11y --interactive
|
|
105
|
+
capture click "Sign in"
|
|
106
|
+
capture type "hi@me.com" --into "Email"
|
|
107
|
+
capture screenshot
|
|
108
|
+
capture navigate https://app.example.com/dashboard
|
|
109
|
+
capture har read --filter-url /api
|
|
110
|
+
3. capture session stop <session-id>
|
|
111
|
+
4. capture session view <session-id>
|
|
112
|
+
|
|
113
|
+
Related: capture log <path> [--name label] Tail a log into the active session
|
|
114
|
+
See also: capture --help Full command list`);
|
|
115
|
+
}
|
|
116
|
+
|
|
68
117
|
// ============================================================================
|
|
69
118
|
// Session Commands
|
|
70
119
|
// ============================================================================
|
|
71
120
|
|
|
72
|
-
async function start(
|
|
121
|
+
async function start(rawArgs: string[]): Promise<void> {
|
|
122
|
+
const args = expandEqualsFlags(rawArgs);
|
|
73
123
|
let url: string | null = null;
|
|
74
124
|
|
|
75
125
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -92,16 +142,49 @@ async function start(args: string[]): Promise<void> {
|
|
|
92
142
|
console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
|
|
93
143
|
}
|
|
94
144
|
|
|
95
|
-
// Open tab if URL provided
|
|
145
|
+
// Open tab if URL provided. Fail fast if the new target cannot attach.
|
|
96
146
|
let targetId: string | null = null;
|
|
147
|
+
let pageLoadTimedOut = false;
|
|
97
148
|
if (url) {
|
|
98
149
|
try {
|
|
99
|
-
const { detectCdpPort,
|
|
150
|
+
const { detectCdpPort, openTab, CDPClient } = await import('../cdp.js');
|
|
100
151
|
const port = await detectCdpPort();
|
|
101
|
-
const tab = await
|
|
152
|
+
const tab = await openTab(port, url);
|
|
102
153
|
targetId = tab.id;
|
|
154
|
+
|
|
155
|
+
if (!targetId) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`capture session start could not attach to ${url}. Reuse an existing tab with --target.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (!tab.webSocketDebuggerUrl) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`capture session start could not attach to ${url}: missing WebSocket debugger URL. Reuse an existing tab with --target.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
167
|
+
try {
|
|
168
|
+
try {
|
|
169
|
+
await client.send('Runtime.enable', {}, 5_000);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
pageLoadTimedOut = await waitForPageLoad(client, 10_000);
|
|
176
|
+
} finally {
|
|
177
|
+
client.close();
|
|
178
|
+
}
|
|
103
179
|
} catch (err) {
|
|
104
|
-
|
|
180
|
+
if (harId) {
|
|
181
|
+
try {
|
|
182
|
+
deleteHarRecording(harId);
|
|
183
|
+
} catch {
|
|
184
|
+
/* best effort */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw err;
|
|
105
188
|
}
|
|
106
189
|
}
|
|
107
190
|
|
|
@@ -132,6 +215,7 @@ async function start(args: string[]): Promise<void> {
|
|
|
132
215
|
bundleDir: dir,
|
|
133
216
|
harId,
|
|
134
217
|
targetId,
|
|
218
|
+
pageLoadTimedOut,
|
|
135
219
|
shotsDir: path.join(dir, 'shots'),
|
|
136
220
|
a11yDir: path.join(dir, 'a11y'),
|
|
137
221
|
};
|
|
@@ -141,11 +225,20 @@ async function start(args: string[]): Promise<void> {
|
|
|
141
225
|
console.error(`\nCapture session started: ${id}`);
|
|
142
226
|
if (targetId) {
|
|
143
227
|
console.error(`Tab opened — session context active. No need to pass --target or --har.`);
|
|
228
|
+
if (pageLoadTimedOut) {
|
|
229
|
+
console.error(`Page load timed out, but the session is attached to target ${targetId.slice(0, 8)}. Use \`capture exec --target ${targetId.slice(0, 8)}\` or \`capture list\` if you need to recover it.`);
|
|
230
|
+
}
|
|
144
231
|
}
|
|
145
232
|
console.error(`\nWhen done: capture session stop ${id}`);
|
|
146
233
|
}
|
|
147
234
|
|
|
148
|
-
export function logCommand(
|
|
235
|
+
export function logCommand(rawArgs: string[]): void {
|
|
236
|
+
const args = expandEqualsFlags(rawArgs);
|
|
237
|
+
if (hasHelpFlag(args)) {
|
|
238
|
+
console.log('Usage: capture log <path> [--name label] [--session <id>]');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
149
242
|
const sourcePath = args[0];
|
|
150
243
|
if (!sourcePath) {
|
|
151
244
|
console.error('Usage: capture log <path> [--name label] [--session <id>]');
|
|
@@ -321,7 +414,8 @@ function list(): void {
|
|
|
321
414
|
console.log(JSON.stringify(sessions, null, 2));
|
|
322
415
|
}
|
|
323
416
|
|
|
324
|
-
function view(
|
|
417
|
+
function view(rawArgs: string[]): void {
|
|
418
|
+
const args = expandEqualsFlags(rawArgs);
|
|
325
419
|
const id = args[0];
|
|
326
420
|
if (!id) {
|
|
327
421
|
console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y]');
|
|
@@ -350,36 +444,17 @@ function view(args: string[]): void {
|
|
|
350
444
|
export async function sessionMain(args: string[]): Promise<void> {
|
|
351
445
|
const [subcommand, ...rest] = args;
|
|
352
446
|
|
|
447
|
+
if (!subcommand || hasHelpFlag(args)) {
|
|
448
|
+
printSessionHelp();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
353
452
|
switch (subcommand) {
|
|
354
453
|
case 'start': return start(rest);
|
|
355
454
|
case 'stop': return stop(rest);
|
|
356
455
|
case 'list': return list();
|
|
357
456
|
case 'view': return view(rest);
|
|
358
457
|
default:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
Sub-commands:
|
|
362
|
-
start [--url <url>] Start a session (opens tab, records HAR, sets active context)
|
|
363
|
-
stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
|
|
364
|
-
list List active and stopped sessions
|
|
365
|
-
view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs
|
|
366
|
-
|
|
367
|
-
Why sessions: once started, every subsequent capture command auto-fills
|
|
368
|
-
--target (the tab) and --har (the recording). No manual flag threading.
|
|
369
|
-
|
|
370
|
-
Typical flow:
|
|
371
|
-
1. capture session start --url http://localhost:3000
|
|
372
|
-
2. Interact — no --target / --har needed:
|
|
373
|
-
capture a11y --interactive
|
|
374
|
-
capture click "Sign in"
|
|
375
|
-
capture type "hi@me.com" --into "Email"
|
|
376
|
-
capture screenshot
|
|
377
|
-
capture navigate https://app.example.com/dashboard
|
|
378
|
-
capture har read --filter-url /api
|
|
379
|
-
3. capture session stop <session-id>
|
|
380
|
-
4. capture session view <session-id>
|
|
381
|
-
|
|
382
|
-
Related: capture log <path> [--name label] Tail a log into the active session
|
|
383
|
-
See also: capture --help Full command list`);
|
|
458
|
+
printSessionHelp();
|
|
384
459
|
}
|
|
385
460
|
}
|
|
@@ -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
|
+
}
|