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 +10 -0
- package/bin/lib/live.mjs +17 -9
- package/bin/lib/shot.mjs +218 -0
- package/package.json +1 -1
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
|
package/bin/lib/shot.mjs
ADDED
|
@@ -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.
|
|
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": {
|