@galda/cli 0.10.38 → 0.10.40
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/CLAUDE.md +7 -3
- package/app/index.html +511 -342
- package/engine/lib.mjs +450 -37
- package/engine/server.mjs +274 -279
- package/engine/shot.mjs +96 -0
- package/package.json +1 -1
package/engine/shot.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Manager for AI — the worker's camera.
|
|
3
|
+
//
|
|
4
|
+
// One job: take a URL or a local HTML file and write a PNG, in headless Chrome,
|
|
5
|
+
// without ever coming to the front. It is handed to the worker (see SHOT_CLI in
|
|
6
|
+
// lib.mjs) so that a request like "3案見せて" can come back as pictures of the
|
|
7
|
+
// thing that was actually built.
|
|
8
|
+
//
|
|
9
|
+
// It decides nothing. It does not know what a UI change is, does not pick what
|
|
10
|
+
// to shoot, and is never invoked by the Manager on the worker's behalf — the
|
|
11
|
+
// agent that did the work aims the camera. Staging the photo ourselves is the
|
|
12
|
+
// bug this replaces, not a feature to re-add here.
|
|
13
|
+
//
|
|
14
|
+
// node engine/shot.mjs <url|file> <out.png> [--width N] [--height N]
|
|
15
|
+
// [--full] [--wait MS] [--wait-for SELECTOR]
|
|
16
|
+
//
|
|
17
|
+
// Exit 0 prints the absolute path written; any failure exits 1 with one line
|
|
18
|
+
// the worker can act on (a half-written PNG is worse than a clear error).
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { dirname, resolve } from 'node:path';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { parseShotArgs, shotTargetUrl, pickChromePath } from './lib.mjs';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
function die(msg) {
|
|
28
|
+
console.error(`shot: ${msg}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const opt = parseShotArgs(process.argv.slice(2));
|
|
33
|
+
if (opt.error) die(opt.error);
|
|
34
|
+
|
|
35
|
+
// A local target that does not exist is the common typo, and Chrome would
|
|
36
|
+
// happily screenshot its own "file not found" page — a picture of nothing.
|
|
37
|
+
if (!/^(https?|file):\/\//i.test(opt.target) && !existsSync(resolve(process.cwd(), opt.target))) {
|
|
38
|
+
die(`no such file: ${opt.target}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const url = shotTargetUrl(opt.target, (p) => resolve(process.cwd(), p));
|
|
42
|
+
const out = resolve(process.cwd(), opt.out);
|
|
43
|
+
const chrome = pickChromePath(process.env, existsSync);
|
|
44
|
+
if (!chrome) die('Chrome not found — install Google Chrome or set CHROME_PATH to the binary');
|
|
45
|
+
|
|
46
|
+
let puppeteer;
|
|
47
|
+
try {
|
|
48
|
+
puppeteer = require('puppeteer-core');
|
|
49
|
+
} catch {
|
|
50
|
+
die('puppeteer-core is not installed next to the Manager — run npm install in the Manager directory');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
54
|
+
|
|
55
|
+
// Failures inside the block below are recorded, not exited on: process.exit()
|
|
56
|
+
// skips `finally`, and that would leave a headless Chrome running on the user's
|
|
57
|
+
// machine for every mistyped selector.
|
|
58
|
+
let browser, failure = null;
|
|
59
|
+
try {
|
|
60
|
+
browser = await puppeteer.launch({
|
|
61
|
+
executablePath: chrome,
|
|
62
|
+
// headless: no window exists, so it cannot steal focus from the human whose
|
|
63
|
+
// machine this is running on (a standing rule for every browser we launch).
|
|
64
|
+
headless: 'new',
|
|
65
|
+
args: ['--no-sandbox', '--hide-scrollbars', '--force-color-profile=srgb'],
|
|
66
|
+
defaultViewport: { width: opt.width, height: opt.height, deviceScaleFactor: 2 },
|
|
67
|
+
});
|
|
68
|
+
const page = await browser.newPage();
|
|
69
|
+
// domcontentloaded, not networkidle: a live page that polls never settles, and
|
|
70
|
+
// a screenshot that times out is a picture nobody gets (see the same trap in
|
|
71
|
+
// the verification core).
|
|
72
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
73
|
+
if (opt.waitFor) {
|
|
74
|
+
try {
|
|
75
|
+
await page.waitForSelector(opt.waitFor, { timeout: 15000 });
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error(`--wait-for never matched: ${opt.waitFor}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (opt.wait) await new Promise((r) => setTimeout(r, opt.wait));
|
|
81
|
+
await page.screenshot({ path: out, fullPage: opt.full });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
failure = String(e?.message ?? e).split('\n')[0];
|
|
84
|
+
} finally {
|
|
85
|
+
// disconnect + kill rather than close(): close() has hung for minutes on this
|
|
86
|
+
// transport, and a camera that never returns blocks the whole task.
|
|
87
|
+
try {
|
|
88
|
+
const proc = browser?.process?.();
|
|
89
|
+
await browser?.disconnect?.();
|
|
90
|
+
proc?.kill('SIGKILL');
|
|
91
|
+
} catch { /* the browser is already gone — nothing to clean up */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (failure) die(failure);
|
|
95
|
+
if (!existsSync(out) || statSync(out).size === 0) die('no image was written');
|
|
96
|
+
console.log(out);
|
package/package.json
CHANGED