flowviant 0.28.10 → 0.29.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.
package/bin/cli.mjs CHANGED
@@ -101,6 +101,16 @@ if (process.argv[2] === 'clean') {
101
101
  process.exit(0);
102
102
  }
103
103
 
104
+ // `flowviant shot <url>` — capture a headless-browser screenshot of a running
105
+ // page. Build agents shell out to this to attach REAL visual evidence to the
106
+ // delivery card. Self-contained + graceful (no browser → exit 1, agent falls
107
+ // back to text evidence); needs no credential, so it runs before the auth gate.
108
+ if (process.argv[2] === 'shot') {
109
+ const { runShot } = await import('./lib/shot.mjs');
110
+ await runShot(process.argv.slice(3));
111
+ process.exit(0);
112
+ }
113
+
104
114
  // `flowviant env <import|set|show>` — the CLI half of team env sync. Values
105
115
  // are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
106
116
  // the browser); `show` decrypts locally — it only works on an ENROLLED machine.
package/bin/lib/live.mjs CHANGED
@@ -112,6 +112,7 @@ const SAFE_TOOLS = [
112
112
  'Bash(gh:*)',
113
113
  'Bash(npm:*)',
114
114
  'Bash(bun:*)',
115
+ 'Bash(flowviant:*)', // `flowviant shot` — capture screenshot evidence
115
116
  'mcp__flowviant',
116
117
  ];
117
118
 
@@ -131,14 +132,21 @@ tools. When you hit a decision only a human can make, call report_blocker (with
131
132
  options when you can) and then STOP your turn — do not spin or guess; you will be
132
133
  resumed with the answer. As you satisfy each "done when" criterion, call
133
134
  attach_evidence for it — proof the reviewer can SEE without running anything.
134
- Match the evidence to what you built: backend/API work → a request/response
135
- capture or a data sample showing the write; a single screen a screenshot.
136
- CRITICAL for a multi-step FLOW (login, signup, checkout): a screenshot of one
137
- page does NOT prove the flow works — you MUST prove the whole path end to end.
138
- Best: write an e2e/integration test that DRIVES the flow (fill form → submit →
139
- assert the post-login/success state) and attach its test_output; if you have a
140
- browser tool (e.g. Playwright), also attach a screen recording of it running.
141
- Never let a static screenshot stand in for a flow. When the work is done: open ONE draft PR (git push +
135
+ This IS your handover, so make it tangible; match the evidence to what you built:
136
+ UI / any visible screen attach a real SCREENSHOT. Start the app's dev server
137
+ in your worktree, then capture it headlessly with
138
+ \`flowviant shot http://localhost:<PORT>/<route> --out shot.png\` (it finds a
139
+ browser for you and never needs a display), and attach_evidence with kind
140
+ "screenshot" and the file's base64 (\`base64 -w0 shot.png\`). Shoot EVERY key
141
+ screen you changed. If \`flowviant shot\` reports that no browser is available,
142
+ do NOT block fall back to the text evidence below.
143
+ • backend / API work → a request/response capture or a data sample showing the
144
+ write (kind "request_response" or "sample").
145
+ • a multi-step FLOW (login, signup, checkout): one screenshot does NOT prove it
146
+ works — write an e2e/integration test that DRIVES the flow (fill form → submit
147
+ → assert the post-success state), attach its test_output, AND screenshot the
148
+ end state. Never let a single static screenshot stand in for a flow.
149
+ When the work is done: open ONE draft PR (git push +
142
150
  gh pr create --draft), call attach_pr, then call complete with a plain-language
143
151
  summary of what you built AND a criteria self-report (index into the brief's
144
152
  "done when" list + met true/false + a short note per item). That summary +
@@ -168,7 +176,7 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
168
176
  ? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
169
177
  : []),
170
178
  ``,
171
- `${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; attach_evidence for each "done when" criterion as you satisfy it (test output, a request/response, a data sample, or a screenshot — so it's reviewable without running anything); report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
179
+ `${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; attach_evidence for each "done when" criterion as you satisfy it — a real screenshot for UI (run the dev server, then \`flowviant shot <url> --out shot.png\`), or test output / a request-response / a data sample for backend, so it's reviewable without running anything; report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
172
180
  ].join('\n');
173
181
  }
174
182
 
@@ -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.29.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": {