@crouton-kit/capture 1.3.3 → 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,385 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { spawn } from 'child_process';
5
+ import {
6
+ createHarRecording,
7
+ readHarRecording,
8
+ deleteHarRecording,
9
+ } from '../har-manager.js';
10
+ import {
11
+ getActiveSession,
12
+ setActiveSession,
13
+ clearActiveSession,
14
+ } from '../session-context.js';
15
+
16
+ const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
17
+
18
+ interface LogPid {
19
+ pid: number;
20
+ name: string;
21
+ sourcePath: string;
22
+ }
23
+
24
+ interface Session {
25
+ id: string;
26
+ dir: string;
27
+ harId: string | null;
28
+ startedAt: string;
29
+ url: string | null;
30
+ targetId: string | null;
31
+ stepCount: number;
32
+ logPids: LogPid[];
33
+ }
34
+
35
+ interface BundleManifest {
36
+ id: string;
37
+ startedAt: string;
38
+ stoppedAt: string;
39
+ duration: number;
40
+ url: string | null;
41
+ screenshots: Array<{ name: string; path: string }>;
42
+ har: { id: string; path: string; entryCount: number } | null;
43
+ a11y: Array<{ name: string; path: string }>;
44
+ logs: Array<{ name: string; path: string; lines: number }>;
45
+ other: Array<{ name: string; path: string }>;
46
+ }
47
+
48
+ function sessionDir(id: string): string {
49
+ return path.join(CAPTURE_ROOT, id);
50
+ }
51
+
52
+ function sessionMetaPath(id: string): string {
53
+ return path.join(sessionDir(id), '.session.json');
54
+ }
55
+
56
+ function readSession(id: string): Session {
57
+ const metaPath = sessionMetaPath(id);
58
+ if (!fs.existsSync(metaPath)) {
59
+ throw new Error(`No capture session found: ${id}`);
60
+ }
61
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as Session;
62
+ }
63
+
64
+ function generateId(): string {
65
+ return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
66
+ }
67
+
68
+ // ============================================================================
69
+ // Session Commands
70
+ // ============================================================================
71
+
72
+ async function start(args: string[]): Promise<void> {
73
+ let url: string | null = null;
74
+
75
+ for (let i = 0; i < args.length; i++) {
76
+ if (args[i] === '--url' && args[i + 1]) {
77
+ url = args[++i];
78
+ }
79
+ }
80
+
81
+ const id = generateId();
82
+ const dir = sessionDir(id);
83
+ fs.mkdirSync(path.join(dir, 'shots'), { recursive: true });
84
+ fs.mkdirSync(path.join(dir, 'a11y'), { recursive: true });
85
+
86
+ // Start HAR recording directly
87
+ let harId: string | null = null;
88
+ try {
89
+ const harResult = createHarRecording();
90
+ harId = harResult.id;
91
+ } catch (err) {
92
+ console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
93
+ }
94
+
95
+ // Open tab if URL provided
96
+ let targetId: string | null = null;
97
+ if (url) {
98
+ try {
99
+ const { detectCdpPort, navigateAndWait } = await import('../cdp.js');
100
+ const port = await detectCdpPort();
101
+ const tab = await navigateAndWait(port, url);
102
+ targetId = tab.id;
103
+ } catch (err) {
104
+ console.error(`Warning: could not open tab: ${err instanceof Error ? err.message : err}`);
105
+ }
106
+ }
107
+
108
+ const session: Session = {
109
+ id,
110
+ dir,
111
+ harId,
112
+ startedAt: new Date().toISOString(),
113
+ url,
114
+ targetId,
115
+ stepCount: 0,
116
+ logPids: [],
117
+ };
118
+ fs.writeFileSync(sessionMetaPath(id), JSON.stringify(session, null, 2));
119
+
120
+ // Set as active session for auto-defaults
121
+ setActiveSession({
122
+ sessionId: id,
123
+ dir,
124
+ harId,
125
+ targetId,
126
+ stepCount: 0,
127
+ });
128
+
129
+ // Output for agent consumption
130
+ const result = {
131
+ sessionId: id,
132
+ bundleDir: dir,
133
+ harId,
134
+ targetId,
135
+ shotsDir: path.join(dir, 'shots'),
136
+ a11yDir: path.join(dir, 'a11y'),
137
+ };
138
+ console.log(JSON.stringify(result, null, 2));
139
+
140
+ // Agent-friendly next steps on stderr
141
+ console.error(`\nCapture session started: ${id}`);
142
+ if (targetId) {
143
+ console.error(`Tab opened — session context active. No need to pass --target or --har.`);
144
+ }
145
+ console.error(`\nWhen done: capture session stop ${id}`);
146
+ }
147
+
148
+ export function logCommand(args: string[]): void {
149
+ const sourcePath = args[0];
150
+ if (!sourcePath) {
151
+ console.error('Usage: capture log <path> [--name label] [--session <id>]');
152
+ process.exit(1);
153
+ }
154
+
155
+ const resolved = path.resolve(sourcePath);
156
+ if (!fs.existsSync(resolved)) {
157
+ throw new Error(`Log file not found: ${resolved}`);
158
+ }
159
+
160
+ let name: string | null = null;
161
+ let sessionId: string | null = null;
162
+ for (let i = 1; i < args.length; i++) {
163
+ if (args[i] === '--name' && args[i + 1]) name = args[++i];
164
+ if (args[i] === '--session' && args[i + 1]) sessionId = args[++i];
165
+ }
166
+
167
+ if (!sessionId) {
168
+ const active = getActiveSession();
169
+ if (!active) {
170
+ throw new Error('No active capture session. Start one or pass --session <id>.');
171
+ }
172
+ sessionId = active.sessionId;
173
+ }
174
+
175
+ const session = readSession(sessionId);
176
+ name = name ?? path.basename(resolved, path.extname(resolved));
177
+
178
+ const logsDir = path.join(session.dir, 'logs');
179
+ fs.mkdirSync(logsDir, { recursive: true });
180
+
181
+ const destPath = path.join(logsDir, `${name}.log`);
182
+ const outFd = fs.openSync(destPath, 'a');
183
+
184
+ const child = spawn(
185
+ 'sh',
186
+ ['-c', `tail -f "${resolved}" | perl -MPOSIX -ne 'print strftime("%Y-%m-%dT%H:%M:%SZ",gmtime())." ".$_'`],
187
+ { detached: true, stdio: ['ignore', outFd, 'ignore'] },
188
+ );
189
+ child.unref();
190
+ fs.closeSync(outFd);
191
+
192
+ const pid = child.pid!;
193
+ session.logPids.push({ pid, name, sourcePath: resolved });
194
+ fs.writeFileSync(sessionMetaPath(session.id), JSON.stringify(session, null, 2));
195
+
196
+ console.log(JSON.stringify({ name, sourcePath: resolved, destPath, pid }, null, 2));
197
+ }
198
+
199
+ async function stop(args: string[]): Promise<void> {
200
+ const id = args[0];
201
+ if (!id) {
202
+ console.error('Usage: capture session stop <session-id>');
203
+ process.exit(1);
204
+ }
205
+
206
+ const session = readSession(id);
207
+ const stoppedAt = new Date().toISOString();
208
+ const startMs = new Date(session.startedAt).getTime();
209
+ const duration = Date.now() - startMs;
210
+
211
+ // Kill log tailers
212
+ for (const lp of session.logPids ?? []) {
213
+ try { process.kill(-lp.pid, 'SIGTERM'); } catch { /* already dead */ }
214
+ }
215
+ if (session.logPids?.length) {
216
+ await new Promise((r) => setTimeout(r, 200));
217
+ }
218
+
219
+ // Collect screenshots
220
+ const shotsDir = path.join(session.dir, 'shots');
221
+ const screenshots = fs.existsSync(shotsDir)
222
+ ? fs.readdirSync(shotsDir)
223
+ .filter((f) => f.endsWith('.png') || f.endsWith('.jpg'))
224
+ .map((f) => ({ name: f, path: path.join(shotsDir, f) }))
225
+ : [];
226
+
227
+ // Collect a11y snapshots
228
+ const a11yDir = path.join(session.dir, 'a11y');
229
+ const a11y = fs.existsSync(a11yDir)
230
+ ? fs.readdirSync(a11yDir)
231
+ .filter((f) => f.endsWith('.json') || f.endsWith('.txt'))
232
+ .map((f) => ({ name: f, path: path.join(a11yDir, f) }))
233
+ : [];
234
+
235
+ // Collect HAR directly from har-manager
236
+ let har: BundleManifest['har'] = null;
237
+ if (session.harId) {
238
+ try {
239
+ const harData = readHarRecording(session.harId);
240
+ if (harData) {
241
+ const harPath = path.join(session.dir, 'har.json');
242
+ fs.writeFileSync(harPath, JSON.stringify(harData, null, 2));
243
+ har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
244
+ // Clean up the HAR recording
245
+ try { deleteHarRecording(session.harId); } catch { /* best effort */ }
246
+ }
247
+ } catch (err) {
248
+ console.error(`Warning: could not read HAR: ${err instanceof Error ? err.message : err}`);
249
+ }
250
+ }
251
+
252
+ // Collect log files
253
+ const logsDir = path.join(session.dir, 'logs');
254
+ const logs = fs.existsSync(logsDir)
255
+ ? fs.readdirSync(logsDir)
256
+ .filter((f) => f.endsWith('.log'))
257
+ .map((f) => {
258
+ const filePath = path.join(logsDir, f);
259
+ const content = fs.readFileSync(filePath, 'utf-8');
260
+ const lines = content ? content.split('\n').filter(Boolean).length : 0;
261
+ return { name: f, path: filePath, lines };
262
+ })
263
+ : [];
264
+
265
+ // Collect anything else dropped in the session dir
266
+ const knownDirs = new Set(['shots', 'a11y', 'logs']);
267
+ const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json']);
268
+ const other = fs.readdirSync(session.dir)
269
+ .filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
270
+ .map((f) => ({ name: f, path: path.join(session.dir, f) }));
271
+
272
+ // Clear active session context
273
+ clearActiveSession();
274
+
275
+ const manifest: BundleManifest = {
276
+ id: session.id,
277
+ startedAt: session.startedAt,
278
+ stoppedAt,
279
+ duration,
280
+ url: session.url,
281
+ screenshots,
282
+ har,
283
+ a11y,
284
+ logs,
285
+ other,
286
+ };
287
+
288
+ const bundlePath = path.join(session.dir, 'bundle.json');
289
+ fs.writeFileSync(bundlePath, JSON.stringify(manifest, null, 2));
290
+
291
+ console.log(JSON.stringify({
292
+ bundlePath,
293
+ summary: {
294
+ duration,
295
+ screenshots: screenshots.length,
296
+ harEntries: har?.entryCount ?? 0,
297
+ a11ySnapshots: a11y.length,
298
+ logFiles: logs.length,
299
+ otherFiles: other.length,
300
+ },
301
+ }, null, 2));
302
+
303
+ console.error(`\nBundle written: ${bundlePath}`);
304
+ console.error(`Read it: capture session view ${id}`);
305
+ }
306
+
307
+ function list(): void {
308
+ if (!fs.existsSync(CAPTURE_ROOT)) {
309
+ console.log('[]');
310
+ return;
311
+ }
312
+
313
+ const sessions = fs.readdirSync(CAPTURE_ROOT)
314
+ .filter((d) => fs.existsSync(sessionMetaPath(d)))
315
+ .map((d) => {
316
+ const session = readSession(d);
317
+ const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
318
+ return { id: session.id, startedAt: session.startedAt, url: session.url, status: hasBundled ? 'stopped' : 'active' };
319
+ });
320
+
321
+ console.log(JSON.stringify(sessions, null, 2));
322
+ }
323
+
324
+ function view(args: string[]): void {
325
+ const id = args[0];
326
+ if (!id) {
327
+ console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y]');
328
+ process.exit(1);
329
+ }
330
+
331
+ const session = readSession(id);
332
+ const bundlePath = path.join(session.dir, 'bundle.json');
333
+
334
+ if (!fs.existsSync(bundlePath)) {
335
+ console.error(`Session ${id} hasn't been stopped yet. Run: capture session stop ${id}`);
336
+ process.exit(1);
337
+ }
338
+
339
+ const manifest = JSON.parse(fs.readFileSync(bundlePath, 'utf-8')) as BundleManifest;
340
+
341
+ const filter = args.find((_, i) => args[i - 1] === '--filter');
342
+ if (filter) {
343
+ const section = manifest[filter as keyof BundleManifest];
344
+ console.log(JSON.stringify(section, null, 2));
345
+ } else {
346
+ console.log(JSON.stringify(manifest, null, 2));
347
+ }
348
+ }
349
+
350
+ export async function sessionMain(args: string[]): Promise<void> {
351
+ const [subcommand, ...rest] = args;
352
+
353
+ switch (subcommand) {
354
+ case 'start': return start(rest);
355
+ case 'stop': return stop(rest);
356
+ case 'list': return list();
357
+ case 'view': return view(rest);
358
+ default:
359
+ console.log(`capture session — manage capture sessions
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`);
384
+ }
385
+ }
@@ -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
+ }