flowviant 0.28.10 → 0.30.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.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `flowviant shot <url>` — capture a headless-browser screenshot of a running
3
+ * page, so a build agent can attach REAL visual evidence to its delivery card
4
+ * (not just the ephemeral live preview). This is the primitive the daemon agent
5
+ * shells out to; it wraps the two fiddly parts — finding a browser across the
6
+ * user's environment, and driving Chrome's headless `--screenshot` — so the
7
+ * agent doesn't have to guess.
8
+ *
9
+ * Design mirrors the preview/cloudflared path: zero-config where possible, and
10
+ * NEVER a hard failure. No browser found / render crashes → a clear one-line
11
+ * hint on stderr + a non-zero exit, and the agent falls back to text evidence
12
+ * (test output, request/response, sample). It must never block a delivery.
13
+ *
14
+ * Environment coverage (the "what about WSL / a Linux VM?" cases):
15
+ * - Linux: PATH + conventional install paths for chrome/chromium/edge/brave.
16
+ * - macOS / Windows: the standard app locations.
17
+ * - WSL with no Linux browser: falls back to Windows Chrome/Edge via /mnt/c
18
+ * interop, translating paths with `wslpath` (WSL2 forwards localhost, so a
19
+ * Windows browser can still reach the dev server running in WSL).
20
+ * - A bare VM with no browser AND no system libs: probing/render fails →
21
+ * graceful text-evidence fallback with an `apt install chromium` hint.
22
+ * - FLOWVIANT_CHROME / CHROME_PATH override wins over all discovery.
23
+ */
24
+
25
+ import { spawn, execFileSync } from 'node:child_process';
26
+ import { existsSync, statSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { tmpdir, platform } from 'node:os';
29
+
30
+ // ── Browser discovery ───────────────────────────────────────────────────────
31
+
32
+ const LINUX_BINS = [
33
+ 'google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser',
34
+ 'chrome', 'brave-browser', 'microsoft-edge', 'microsoft-edge-stable',
35
+ ];
36
+ const LINUX_PATHS = [
37
+ '/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium',
38
+ '/usr/bin/chromium-browser', '/snap/bin/chromium', '/usr/bin/brave-browser',
39
+ '/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable',
40
+ ];
41
+ const MAC_PATHS = [
42
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
43
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
44
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
45
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
46
+ ];
47
+ const WIN_PATHS = [
48
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
49
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
50
+ 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
51
+ 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
52
+ ];
53
+ // WSL interop: the same Windows installs, seen through the /mnt/c mount.
54
+ const WSL_WIN_PATHS = WIN_PATHS.map((p) => `/mnt/c/${p.slice(3)}`);
55
+
56
+ function which(name) {
57
+ try {
58
+ const p = execFileSync('which', [name], { encoding: 'utf8' }).trim();
59
+ return p || null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function isWSL() {
66
+ if (process.env.WSL_DISTRO_NAME) return true;
67
+ try {
68
+ return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf8'));
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** Locate a usable browser, or null. `viaWindows` means a Windows .exe reached
75
+ * through WSL interop — its file-path args must be translated with wslpath. */
76
+ export function resolveBrowser() {
77
+ const override = process.env.FLOWVIANT_CHROME || process.env.CHROME_PATH;
78
+ if (override && existsSync(override)) return { bin: override, viaWindows: false };
79
+
80
+ const os = platform();
81
+ if (os === 'darwin') {
82
+ for (const p of MAC_PATHS) if (existsSync(p)) return { bin: p, viaWindows: false };
83
+ return null;
84
+ }
85
+ if (os === 'win32') {
86
+ for (const p of WIN_PATHS) if (existsSync(p)) return { bin: p, viaWindows: false };
87
+ return null;
88
+ }
89
+ // linux
90
+ for (const name of LINUX_BINS) {
91
+ const p = which(name);
92
+ if (p) return { bin: p, viaWindows: false };
93
+ }
94
+ for (const p of LINUX_PATHS) if (existsSync(p)) return { bin: p, viaWindows: false };
95
+ // WSL with no Linux browser — reach the Windows one.
96
+ if (isWSL()) {
97
+ for (const p of WSL_WIN_PATHS) if (existsSync(p)) return { bin: p, viaWindows: true };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // ── Capture ─────────────────────────────────────────────────────────────────
103
+
104
+ function toWinPath(p) {
105
+ return execFileSync('wslpath', ['-w', p], { encoding: 'utf8' }).trim();
106
+ }
107
+
108
+ function runChrome(browser, { url, out, width, height, headlessFlag, timeoutMs }) {
109
+ return new Promise((resolve) => {
110
+ const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-shot-'));
111
+ const cleanup = () => { try { rmSync(userDataDir, { recursive: true, force: true }); } catch { /* best effort */ } };
112
+
113
+ // A Windows .exe can't read a WSL path — translate the file args it touches.
114
+ let outArg = out;
115
+ let udArg = userDataDir;
116
+ if (browser.viaWindows) {
117
+ try {
118
+ outArg = toWinPath(out);
119
+ udArg = toWinPath(userDataDir);
120
+ } catch {
121
+ cleanup();
122
+ return resolve({ ok: false, reason: 'wslpath', message: 'wslpath unavailable — cannot use Windows Chrome from WSL.' });
123
+ }
124
+ }
125
+
126
+ const args = [
127
+ headlessFlag,
128
+ '--disable-gpu',
129
+ '--no-sandbox', // required as root / in many VMs + containers
130
+ '--disable-dev-shm-usage', // small /dev/shm in containers/VMs crashes Chrome otherwise
131
+ '--hide-scrollbars',
132
+ '--force-color-profile=srgb',
133
+ `--user-data-dir=${udArg}`,
134
+ `--window-size=${width},${height}`,
135
+ '--virtual-time-budget=5000', // let fonts/JS settle before capture (SPAs)
136
+ `--screenshot=${outArg}`,
137
+ url,
138
+ ];
139
+
140
+ let err = '';
141
+ let child;
142
+ try {
143
+ child = spawn(browser.bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
144
+ } catch (e) {
145
+ cleanup();
146
+ return resolve({ ok: false, reason: 'spawn', message: e.message });
147
+ }
148
+ child.stderr?.on('data', (d) => { err += d.toString(); });
149
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, timeoutMs);
150
+ child.on('error', (e) => {
151
+ clearTimeout(timer);
152
+ cleanup();
153
+ resolve({ ok: false, reason: 'spawn', message: e.message });
154
+ });
155
+ child.on('close', (code) => {
156
+ clearTimeout(timer);
157
+ cleanup();
158
+ if (existsSync(out) && statSync(out).size > 0) return resolve({ ok: true, path: out });
159
+ const tail = err.trim().split('\n').slice(-2).join(' ');
160
+ resolve({ ok: false, reason: 'render', message: `Chrome exited (code ${code}) without an image.${tail ? ' ' + tail : ''}` });
161
+ });
162
+ });
163
+ }
164
+
165
+ /** Capture `url` to a PNG at `out`. Tries new headless, then classic headless
166
+ * (older Chromium). Resolves { ok, path } or { ok:false, reason, message }. */
167
+ export async function captureScreenshot({ url, out, width = 1440, height = 900, timeoutMs = 60_000 }) {
168
+ const browser = resolveBrowser();
169
+ if (!browser) {
170
+ return {
171
+ ok: false,
172
+ reason: 'no-browser',
173
+ message: 'No Chrome/Chromium/Edge found. Install one (Linux: `sudo apt install chromium`) to capture screenshot evidence.',
174
+ };
175
+ }
176
+ let r = await runChrome(browser, { url, out, width, height, headlessFlag: '--headless=new', timeoutMs });
177
+ if (!r.ok && r.reason === 'render') {
178
+ // Older Chromium rejects `--headless=new` — retry with classic headless.
179
+ r = await runChrome(browser, { url, out, width, height, headlessFlag: '--headless', timeoutMs });
180
+ }
181
+ return r;
182
+ }
183
+
184
+ // ── CLI ───────────────────────────────────────────────────────────────────
185
+
186
+ function getOpt(argv, name) {
187
+ const i = argv.indexOf(name);
188
+ return i >= 0 ? argv[i + 1] : undefined;
189
+ }
190
+
191
+ /** `flowviant shot <url> [--out file.png] [--width N] [--height N] [--base64]`
192
+ * On success prints the PNG path (or its base64 with --base64) to stdout and
193
+ * exits 0. On any failure: a hint on stderr, exit 1 (graceful — the agent then
194
+ * attaches text evidence instead). Usage error exits 2. */
195
+ export async function runShot(argv) {
196
+ const url = argv.find((a) => !a.startsWith('--') && /^(https?|file|data):/i.test(a));
197
+ if (!url) {
198
+ console.error('usage: flowviant shot <url> [--out file.png] [--width 1440] [--height 900] [--base64]');
199
+ console.error(' url: a running page (http://localhost:5173/…), a built file (file://…), or a data: URL');
200
+ process.exit(2);
201
+ }
202
+ const out = getOpt(argv, '--out') || join(process.cwd(), `flowviant-shot-${Date.now()}.png`);
203
+ const width = Number(getOpt(argv, '--width')) || 1440;
204
+ const height = Number(getOpt(argv, '--height')) || 900;
205
+ const wantBase64 = argv.includes('--base64');
206
+
207
+ const r = await captureScreenshot({ url, out, width, height });
208
+ if (!r.ok) {
209
+ console.error(`flowviant shot: ${r.message}`);
210
+ process.exit(1);
211
+ }
212
+ if (wantBase64) {
213
+ process.stdout.write(readFileSync(out).toString('base64'));
214
+ } else {
215
+ console.log(r.path);
216
+ }
217
+ process.exit(0);
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.28.10",
3
+ "version": "0.30.0",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {