flowviant 0.15.0 → 0.18.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 +3 -0
- package/bin/lib/authproxy.mjs +95 -0
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/fleet.mjs +6 -0
- package/bin/lib/live.mjs +103 -5
- package/bin/lib/preview.mjs +110 -7
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -80,6 +80,9 @@ if (process.argv[2] === 'clean') {
|
|
|
80
80
|
const { join } = await import('node:path');
|
|
81
81
|
const { homedir } = await import('node:os');
|
|
82
82
|
const { execFileSync } = await import('node:child_process');
|
|
83
|
+
// Also reap any preview dev-server/tunnel groups a crashed daemon left running.
|
|
84
|
+
const { reapOrphanPreviews } = await import('./lib/preview.mjs');
|
|
85
|
+
reapOrphanPreviews((m) => console.log(m));
|
|
83
86
|
const dir = join(homedir(), '.flowviant', 'worktrees');
|
|
84
87
|
if (!existsSync(dir)) {
|
|
85
88
|
console.log('nothing to clean — no worktrees at ~/.flowviant/worktrees.');
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in basic-auth reverse proxy for previews (`.flowviant/preview.json`
|
|
3
|
+
* "auth": true). The public tunnel is otherwise a capability URL — anyone with
|
|
4
|
+
* it reaches the running app. This gates it: cloudflared → this proxy → dev
|
|
5
|
+
* server, with a generated password the app surfaces to the reviewer.
|
|
6
|
+
*
|
|
7
|
+
* Minimal + dependency-free: forwards HTTP, and pipes WS upgrades (HMR) — the
|
|
8
|
+
* browser re-sends the cached Basic-auth header on same-origin upgrades, so HMR
|
|
9
|
+
* still authenticates. If a proxy request fails the page just 502s; it never
|
|
10
|
+
* touches the default (no-auth) preview path.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createServer, request } from 'node:http';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
/** Start the proxy in front of a dev server on `targetPort`. Resolves
|
|
17
|
+
* { port, user, password, stop }. Binds loopback only (cloudflared connects
|
|
18
|
+
* locally); the password is what gates the public tunnel. */
|
|
19
|
+
export function startAuthProxy({ targetPort, log }) {
|
|
20
|
+
const user = 'preview';
|
|
21
|
+
const password = randomBytes(9).toString('base64url'); // ~12 url-safe chars
|
|
22
|
+
const expected = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
|
23
|
+
const authed = (req) => req.headers['authorization'] === expected;
|
|
24
|
+
|
|
25
|
+
const forwardOpts = (req) => ({
|
|
26
|
+
host: '127.0.0.1',
|
|
27
|
+
port: targetPort,
|
|
28
|
+
method: req.method,
|
|
29
|
+
path: req.url,
|
|
30
|
+
headers: req.headers,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const server = createServer((req, res) => {
|
|
34
|
+
if (!authed(req)) {
|
|
35
|
+
res.writeHead(401, {
|
|
36
|
+
'WWW-Authenticate': 'Basic realm="Flowviant preview"',
|
|
37
|
+
'Content-Type': 'text/plain',
|
|
38
|
+
});
|
|
39
|
+
res.end('This preview is password-protected. Enter the password shown in Flowviant.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const proxyReq = request(forwardOpts(req), (proxyRes) => {
|
|
43
|
+
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
|
44
|
+
proxyRes.pipe(res);
|
|
45
|
+
});
|
|
46
|
+
proxyReq.on('error', () => {
|
|
47
|
+
if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
48
|
+
res.end('preview origin not reachable');
|
|
49
|
+
});
|
|
50
|
+
req.pipe(proxyReq);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// WS upgrade (HMR). The browser resends the Basic-auth header on same-origin
|
|
54
|
+
// upgrades, so we can gate it too, then pipe the two sockets together.
|
|
55
|
+
server.on('upgrade', (req, socket, head) => {
|
|
56
|
+
if (!authed(req)) {
|
|
57
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
|
|
58
|
+
socket.destroy();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const proxyReq = request(forwardOpts(req));
|
|
62
|
+
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
63
|
+
const headerLines = Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`);
|
|
64
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\n${headerLines.join('\r\n')}\r\n\r\n`);
|
|
65
|
+
if (proxyHead && proxyHead.length) proxySocket.unshift(proxyHead);
|
|
66
|
+
proxySocket.pipe(socket);
|
|
67
|
+
socket.pipe(proxySocket);
|
|
68
|
+
proxySocket.on('error', () => socket.destroy());
|
|
69
|
+
socket.on('error', () => proxySocket.destroy());
|
|
70
|
+
});
|
|
71
|
+
proxyReq.on('error', () => socket.destroy());
|
|
72
|
+
if (head && head.length) proxyReq.write(head);
|
|
73
|
+
proxyReq.end();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
server.on('error', () => resolve(null)); // couldn't bind → caller falls back to no proxy
|
|
78
|
+
server.listen(0, '127.0.0.1', () => {
|
|
79
|
+
const port = server.address().port;
|
|
80
|
+
log?.(`auth proxy on :${port} — preview is password-gated`);
|
|
81
|
+
resolve({
|
|
82
|
+
port,
|
|
83
|
+
user,
|
|
84
|
+
password,
|
|
85
|
+
stop: () => {
|
|
86
|
+
try {
|
|
87
|
+
server.close();
|
|
88
|
+
} catch {
|
|
89
|
+
/* already closed */
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
|
|
7
|
-
export const VERSION = '0.
|
|
7
|
+
export const VERSION = '0.18.0';
|
|
8
8
|
|
|
9
9
|
// Credential stored by `flowviant login` (device auth) — the no-token,
|
|
10
10
|
// no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
SINGLE_RESUME,
|
|
48
48
|
} from './claude.mjs';
|
|
49
49
|
import { runLiveWorker } from './live.mjs';
|
|
50
|
+
import { reapOrphanPreviews } from './preview.mjs';
|
|
50
51
|
import { preflight } from './preflight.mjs';
|
|
51
52
|
|
|
52
53
|
async function fetchRoster(haveIds) {
|
|
@@ -176,6 +177,9 @@ export async function runFleetDaemon() {
|
|
|
176
177
|
info(`server · ${FLEET_URL}`);
|
|
177
178
|
console.log('');
|
|
178
179
|
await preflight({ needGit: true });
|
|
180
|
+
// Kill any preview dev-server/tunnel groups a previously-crashed daemon left
|
|
181
|
+
// running (detached children survive an ungraceful exit) before we start fresh.
|
|
182
|
+
reapOrphanPreviews((m) => info(m));
|
|
179
183
|
|
|
180
184
|
// Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
|
|
181
185
|
// so Ctrl+C mid-task never loses local work. Keyed per repo path; each
|
|
@@ -481,6 +485,8 @@ export async function runFleetDaemon() {
|
|
|
481
485
|
label,
|
|
482
486
|
cwd: wt,
|
|
483
487
|
baseRef,
|
|
488
|
+
repoRoot, // for copying the repo's local env into the preview worktree
|
|
489
|
+
|
|
484
490
|
getToken: (id) => tokenByAgent.get(id),
|
|
485
491
|
getHasWork: (id) => hasWorkByAgent.get(id) ?? false,
|
|
486
492
|
getMcpUrl: () => mcpUrl,
|
package/bin/lib/live.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* live fleet + repo to shake out. Old (poll/sentinel) mode is untouched.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
19
|
+
import { readFileSync, writeFileSync, rmSync, existsSync, copyFileSync } from 'node:fs';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
22
22
|
import {
|
|
@@ -37,6 +37,13 @@ import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
|
37
37
|
// Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
|
|
38
38
|
// reviewer then drives it via "Open live preview" in the node.
|
|
39
39
|
const LIVE_TARGET_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target');
|
|
40
|
+
// Short TTL + a heartbeat that re-asserts while the tunnel is alive. So a live
|
|
41
|
+
// preview stays linked indefinitely (survives long reviews), but one whose
|
|
42
|
+
// daemon DIED ungracefully (no more heartbeats) drops off the card within the
|
|
43
|
+
// TTL instead of showing a dead URL for 2 hours. TTL comfortably covers a few
|
|
44
|
+
// missed heartbeats.
|
|
45
|
+
const PREVIEW_TTL_MINUTES = 6;
|
|
46
|
+
const PREVIEW_HEARTBEAT_MS = 90_000;
|
|
40
47
|
async function registerLiveTarget(intentId, kind, url) {
|
|
41
48
|
try {
|
|
42
49
|
await fetch(LIVE_TARGET_URL, {
|
|
@@ -47,7 +54,7 @@ async function registerLiveTarget(intentId, kind, url) {
|
|
|
47
54
|
'Content-Type': 'application/json',
|
|
48
55
|
},
|
|
49
56
|
signal: AbortSignal.timeout(30_000),
|
|
50
|
-
body: JSON.stringify({ intentId, kind, url }),
|
|
57
|
+
body: JSON.stringify({ intentId, kind, url, ttlMinutes: PREVIEW_TTL_MINUTES }),
|
|
51
58
|
});
|
|
52
59
|
} catch {
|
|
53
60
|
/* best-effort — the tunnel still works; it just isn't linked in the app */
|
|
@@ -71,6 +78,23 @@ function clearLiveTarget(intentId, kind) {
|
|
|
71
78
|
}).catch(() => {});
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
// Report WHY a preview didn't come up into the task thread, so the reason is
|
|
82
|
+
// visible in the app (not just the daemon console). The card grace-window shows
|
|
83
|
+
// "starting…"; this is the honest terminal state when it can't.
|
|
84
|
+
const PREVIEW_NOTE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-note');
|
|
85
|
+
function postPreviewNote(intentId, text) {
|
|
86
|
+
return fetch(PREVIEW_NOTE_URL, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
90
|
+
'User-Agent': USER_AGENT,
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
},
|
|
93
|
+
signal: AbortSignal.timeout(10_000),
|
|
94
|
+
body: JSON.stringify({ intentId, text }),
|
|
95
|
+
}).catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
|
|
74
98
|
// Safe mode's curated toolset. Bash is scoped to the specific CLIs the agent
|
|
75
99
|
// needs (git/gh/npm/bun) — NOT bare `Bash`, which would auto-approve arbitrary
|
|
76
100
|
// shell (rm -rf, curl|sh, reading ~/.ssh) and defeat the point of safe mode.
|
|
@@ -586,11 +610,39 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
586
610
|
|
|
587
611
|
// Per-agent loop — same signature/scaffolding as runFleetWorker, but each task
|
|
588
612
|
// is a persistent SDK session instead of a one-shot claude turn.
|
|
613
|
+
// A preview runs in the agent's WORKTREE — a fresh checkout that lacks the repo's
|
|
614
|
+
// gitignored env files (.env.local etc.), so the app's DB/auth secrets are absent
|
|
615
|
+
// and anything that hits them (sign-in!) 500s. Copy the files the checkout is
|
|
616
|
+
// missing from the real repo into the worktree so the preview runs like local
|
|
617
|
+
// dev. We only copy files ABSENT from the worktree — i.e. the gitignored ones —
|
|
618
|
+
// so nothing tracked is overwritten and (being gitignored) nothing gets committed.
|
|
619
|
+
const PREVIEW_ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local'];
|
|
620
|
+
function copyLocalEnvFiles(repoRoot, worktree, log) {
|
|
621
|
+
if (!repoRoot || repoRoot === worktree) return;
|
|
622
|
+
let copied = 0;
|
|
623
|
+
for (const f of PREVIEW_ENV_FILES) {
|
|
624
|
+
const src = join(repoRoot, f);
|
|
625
|
+
const dst = join(worktree, f);
|
|
626
|
+
if (existsSync(src) && !existsSync(dst)) {
|
|
627
|
+
try {
|
|
628
|
+
copyFileSync(src, dst);
|
|
629
|
+
copied++;
|
|
630
|
+
} catch {
|
|
631
|
+
/* best-effort */
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (copied) {
|
|
636
|
+
log?.(`preview: brought ${copied} local env file(s) into the worktree so the app has its secrets.`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
589
640
|
export async function runLiveWorker({
|
|
590
641
|
agentId,
|
|
591
642
|
label,
|
|
592
643
|
cwd,
|
|
593
644
|
baseRef,
|
|
645
|
+
repoRoot,
|
|
594
646
|
getToken,
|
|
595
647
|
getHasWork,
|
|
596
648
|
getMcpUrl,
|
|
@@ -615,8 +667,16 @@ export async function runLiveWorker({
|
|
|
615
667
|
// kept up while it's in review (a gated agent parks, so it lives until review
|
|
616
668
|
// resolves). Replaced when the next task finishes; torn down on shutdown.
|
|
617
669
|
let preview = null;
|
|
618
|
-
let previewTarget = null; // { intentId, kind } of the currently-registered link
|
|
670
|
+
let previewTarget = null; // { intentId, kind, url } of the currently-registered link
|
|
671
|
+
let previewHeartbeat = null;
|
|
672
|
+
const stopHeartbeat = () => {
|
|
673
|
+
if (previewHeartbeat) {
|
|
674
|
+
clearInterval(previewHeartbeat);
|
|
675
|
+
previewHeartbeat = null;
|
|
676
|
+
}
|
|
677
|
+
};
|
|
619
678
|
const stopPreview = () => {
|
|
679
|
+
stopHeartbeat();
|
|
620
680
|
if (preview) {
|
|
621
681
|
try {
|
|
622
682
|
preview.stop();
|
|
@@ -650,6 +710,12 @@ export async function runLiveWorker({
|
|
|
650
710
|
'no live preview: no runnable web frontend found (searched the repo root, web/frontend/client/…, and apps/* + packages/*). If your app is elsewhere or not vite/next/astro/etc., add .flowviant/preview.json: {"ui":{"cmd":"cd <dir> && npm install && npm run dev","port":5173}}.'
|
|
651
711
|
)}`
|
|
652
712
|
);
|
|
713
|
+
if (intentId) {
|
|
714
|
+
await postPreviewNote(
|
|
715
|
+
intentId,
|
|
716
|
+
'No live preview: no runnable web frontend found (searched the repo root, common frontend dirs, and apps/* + packages/*). If this task has a web app, add a `.flowviant/preview.json` pointing at it.',
|
|
717
|
+
);
|
|
718
|
+
}
|
|
653
719
|
return;
|
|
654
720
|
}
|
|
655
721
|
// Zero-config win: when we found the app in a subdir, say where, so it's
|
|
@@ -657,7 +723,11 @@ export async function runLiveWorker({
|
|
|
657
723
|
if (cfg.dir && cfg.dir !== '.') {
|
|
658
724
|
info(`${label} ${c.dim(`live preview: detected a frontend at ${cfg.dir}/ (port ${entry.port})`)}`);
|
|
659
725
|
}
|
|
726
|
+
// Give the dev server the repo's local env (gitignored secrets the fresh
|
|
727
|
+
// worktree is missing) so DB/auth-backed paths like sign-in don't 500.
|
|
728
|
+
copyLocalEnvFiles(repoRoot, cwd, (m) => info(`${label} ${c.dim(m)}`));
|
|
660
729
|
info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
|
|
730
|
+
let lastPreviewLog = ''; // captured so a failure's reason reaches the app
|
|
661
731
|
preview = await startPreview({
|
|
662
732
|
worktree: cwd,
|
|
663
733
|
kind,
|
|
@@ -665,12 +735,40 @@ export async function runLiveWorker({
|
|
|
665
735
|
port: entry.port,
|
|
666
736
|
env: entry.env, // optional: extra env from .flowviant/preview.json
|
|
667
737
|
hostHeader: entry.hostHeader, // optional: override/disable the Host rewrite
|
|
668
|
-
|
|
738
|
+
auth: entry.auth === true, // optional: password-gate the public tunnel
|
|
739
|
+
log: (m) => {
|
|
740
|
+
lastPreviewLog = m;
|
|
741
|
+
info(`${label} ${c.dim(m)}`);
|
|
742
|
+
},
|
|
669
743
|
});
|
|
744
|
+
if (!preview) {
|
|
745
|
+
// Dev server crashed on boot / tunnel never came up — surface the reason
|
|
746
|
+
// (the last log line is the specific failure) in the thread, not just the
|
|
747
|
+
// console, so the reviewer isn't left guessing.
|
|
748
|
+
await postPreviewNote(
|
|
749
|
+
intentId,
|
|
750
|
+
`Live preview didn't start — ${lastPreviewLog || 'the dev server did not come up'}. (Full output is in the daemon console.)`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
670
753
|
if (preview) {
|
|
671
754
|
onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
|
|
672
755
|
await registerLiveTarget(intentId, kind, preview.url);
|
|
673
|
-
previewTarget = { intentId, kind }; //
|
|
756
|
+
previewTarget = { intentId, kind, url: preview.url }; // teardown drops it; heartbeat re-asserts it
|
|
757
|
+
// Re-assert the link while the tunnel is alive so it survives long reviews
|
|
758
|
+
// (and a dead daemon stops re-asserting → the record expires by itself).
|
|
759
|
+
stopHeartbeat();
|
|
760
|
+
previewHeartbeat = setInterval(() => {
|
|
761
|
+
if (previewTarget) void registerLiveTarget(previewTarget.intentId, previewTarget.kind, previewTarget.url);
|
|
762
|
+
}, PREVIEW_HEARTBEAT_MS);
|
|
763
|
+
previewHeartbeat.unref?.();
|
|
764
|
+
// Auth on: post the password into the thread so the reviewer can enter it
|
|
765
|
+
// at the browser prompt (the tunnel is otherwise a capability URL).
|
|
766
|
+
if (preview.auth && intentId) {
|
|
767
|
+
await postPreviewNote(
|
|
768
|
+
intentId,
|
|
769
|
+
`🔒 This live preview is password-protected. At the browser prompt, sign in with user \`${preview.auth.user}\` and password \`${preview.auth.password}\`.`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
674
772
|
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
675
773
|
}
|
|
676
774
|
};
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
* "cmd": "<start dev server>", // required
|
|
18
18
|
* "port": 5173, // required
|
|
19
19
|
* "env": { "FOO": "bar" }, // optional — extra env for the dev server
|
|
20
|
-
* "hostHeader": "localhost"
|
|
20
|
+
* "hostHeader": "localhost", // optional — Host sent to the origin;
|
|
21
21
|
* // false disables the rewrite (for apps
|
|
22
22
|
* // that need their real public Host)
|
|
23
|
+
* "auth": true // optional — gate the public tunnel
|
|
24
|
+
* // behind a generated password (shown
|
|
25
|
+
* // in the app); off by default
|
|
23
26
|
* },
|
|
24
27
|
* "api": { "cmd": "<start api>", "port": 8787 } }
|
|
25
28
|
*/
|
|
@@ -28,6 +31,7 @@ import { spawn, execFileSync } from 'node:child_process';
|
|
|
28
31
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, readdirSync, rmSync } from 'node:fs';
|
|
29
32
|
import { join } from 'node:path';
|
|
30
33
|
import { homedir, platform, arch } from 'node:os';
|
|
34
|
+
import { startAuthProxy } from './authproxy.mjs';
|
|
31
35
|
|
|
32
36
|
// ── Config: explicit file, else infer from package.json ────────────────────
|
|
33
37
|
|
|
@@ -212,6 +216,74 @@ const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
|
212
216
|
// Where a dev server announces it bound — "Local: http://localhost:3001/".
|
|
213
217
|
const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
|
|
214
218
|
|
|
219
|
+
// ── Orphan reaping ─────────────────────────────────────────────────────────
|
|
220
|
+
// Preview children (dev server + tunnel) are detached so we can kill the whole
|
|
221
|
+
// group — but that also means they SURVIVE an ungraceful daemon death (SIGKILL,
|
|
222
|
+
// crash, box sleep), leaking ports/memory. We record each spawned group's pid +
|
|
223
|
+
// a signature; on the next daemon start we reap any that are still ours.
|
|
224
|
+
const PREVIEW_REGISTRY = join(homedir(), '.flowviant', 'previews.json');
|
|
225
|
+
function readRegistry() {
|
|
226
|
+
try {
|
|
227
|
+
const v = JSON.parse(readFileSync(PREVIEW_REGISTRY, 'utf8'));
|
|
228
|
+
return Array.isArray(v) ? v : [];
|
|
229
|
+
} catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function writeRegistry(list) {
|
|
234
|
+
try {
|
|
235
|
+
mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
|
|
236
|
+
writeFileSync(PREVIEW_REGISTRY, JSON.stringify(list));
|
|
237
|
+
} catch {
|
|
238
|
+
/* best-effort */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function recordPreviewPid(pid, sig) {
|
|
242
|
+
if (!pid) return;
|
|
243
|
+
writeRegistry([...readRegistry(), { pid, sig }]);
|
|
244
|
+
}
|
|
245
|
+
function forgetPreviewPid(pid) {
|
|
246
|
+
if (!pid) return;
|
|
247
|
+
writeRegistry(readRegistry().filter((e) => e.pid !== pid));
|
|
248
|
+
}
|
|
249
|
+
// Only kill a pid we can VERIFY is still one of ours — its /proc cmdline must
|
|
250
|
+
// still contain the signature we stored. A reused pid (belonging to something
|
|
251
|
+
// unrelated) won't match, so we never kill a stranger. Linux-only (that's where
|
|
252
|
+
// /proc + process groups work); elsewhere we just clear the registry.
|
|
253
|
+
function stillOurs(pid, sig) {
|
|
254
|
+
if (platform() !== 'linux') return false;
|
|
255
|
+
try {
|
|
256
|
+
const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
|
257
|
+
return typeof sig === 'string' && sig.length > 0 && cmd.includes(sig);
|
|
258
|
+
} catch {
|
|
259
|
+
return false; // process gone / unreadable
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Reap preview process groups left behind by a previously-crashed daemon.
|
|
264
|
+
* Call once at daemon startup, before spawning workers. */
|
|
265
|
+
export function reapOrphanPreviews(log) {
|
|
266
|
+
const list = readRegistry();
|
|
267
|
+
if (list.length === 0) return;
|
|
268
|
+
let killed = 0;
|
|
269
|
+
for (const { pid, sig } of list) {
|
|
270
|
+
if (!stillOurs(pid, sig)) continue;
|
|
271
|
+
try {
|
|
272
|
+
process.kill(-pid, 'SIGKILL'); // whole group
|
|
273
|
+
killed++;
|
|
274
|
+
} catch {
|
|
275
|
+
try {
|
|
276
|
+
process.kill(pid, 'SIGKILL');
|
|
277
|
+
killed++;
|
|
278
|
+
} catch {
|
|
279
|
+
/* already gone */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
writeRegistry([]);
|
|
284
|
+
if (killed) log?.(`reaped ${killed} orphaned preview process${killed === 1 ? '' : 'es'} from a previous run.`);
|
|
285
|
+
}
|
|
286
|
+
|
|
215
287
|
/**
|
|
216
288
|
* Start the dev server + tunnel for one worktree. Resolves { url, kind, stop }
|
|
217
289
|
* once the tunnel URL is captured, or null if it can't come up. stop() kills
|
|
@@ -229,6 +301,7 @@ export async function startPreview({
|
|
|
229
301
|
port,
|
|
230
302
|
env: extraEnv,
|
|
231
303
|
hostHeader,
|
|
304
|
+
auth,
|
|
232
305
|
log,
|
|
233
306
|
timeoutMs = 180_000,
|
|
234
307
|
}) {
|
|
@@ -264,10 +337,14 @@ export async function startPreview({
|
|
|
264
337
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
265
338
|
env,
|
|
266
339
|
});
|
|
340
|
+
// Track for orphan reaping: the shell's cmdline stays `sh -c <cmd>`, so `cmd`
|
|
341
|
+
// is a safe signature to re-verify against later.
|
|
342
|
+
recordPreviewPid(server.pid, cmd);
|
|
267
343
|
|
|
268
344
|
let settled = false;
|
|
269
345
|
let tunnel = null;
|
|
270
346
|
let tunnelStarted = false;
|
|
347
|
+
let authProxy = null; // opt-in basic-auth proxy in front of the dev server
|
|
271
348
|
let out = '';
|
|
272
349
|
const tail = () => out.trim().split('\n').slice(-15).join('\n');
|
|
273
350
|
const killGroup = (child) => {
|
|
@@ -283,6 +360,13 @@ export async function startPreview({
|
|
|
283
360
|
}
|
|
284
361
|
};
|
|
285
362
|
const stop = () => {
|
|
363
|
+
forgetPreviewPid(server.pid);
|
|
364
|
+
forgetPreviewPid(tunnel?.pid);
|
|
365
|
+
try {
|
|
366
|
+
authProxy?.stop();
|
|
367
|
+
} catch {
|
|
368
|
+
/* already closed */
|
|
369
|
+
}
|
|
286
370
|
killGroup(server);
|
|
287
371
|
killGroup(tunnel);
|
|
288
372
|
};
|
|
@@ -297,21 +381,40 @@ export async function startPreview({
|
|
|
297
381
|
resolve(val);
|
|
298
382
|
};
|
|
299
383
|
|
|
300
|
-
// Open the tunnel once we know the real port (detected or fallback).
|
|
301
|
-
|
|
384
|
+
// Open the tunnel once we know the real port (detected or fallback). When
|
|
385
|
+
// auth is opted in, put the password proxy in front and tunnel to THAT.
|
|
386
|
+
const openTunnel = async (p) => {
|
|
302
387
|
if (tunnelStarted || settled) return;
|
|
303
388
|
tunnelStarted = true;
|
|
304
389
|
clearTimeout(bindTimer);
|
|
390
|
+
let tunnelPort = p;
|
|
391
|
+
if (auth) {
|
|
392
|
+
authProxy = await startAuthProxy({ targetPort: p, log });
|
|
393
|
+
if (settled) {
|
|
394
|
+
authProxy?.stop(); // torn down while the proxy was coming up — don't leak it
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (authProxy) tunnelPort = authProxy.port;
|
|
398
|
+
else log?.('auth proxy failed to start — tunneling WITHOUT a password.');
|
|
399
|
+
}
|
|
305
400
|
log?.(`preview: dev server on :${p} — opening the tunnel…`);
|
|
306
401
|
// --http-host-header: send the origin the Host it expects (default
|
|
307
402
|
// localhost — see hostRewrite above). Passes Vite/webpack/Next host checks
|
|
308
403
|
// with zero repo config; skipped when preview.json sets hostHeader:false.
|
|
309
|
-
const args = ['tunnel', '--url', `http://localhost:${
|
|
404
|
+
const args = ['tunnel', '--url', `http://localhost:${tunnelPort}`];
|
|
310
405
|
if (hostRewrite) args.push('--http-host-header', hostRewrite);
|
|
311
406
|
tunnel = spawn(cf, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
407
|
+
recordPreviewPid(tunnel.pid, 'cloudflared'); // signature for orphan reaping
|
|
312
408
|
const onTunnel = (d) => {
|
|
313
409
|
const m = TUNNEL_RE.exec(d.toString());
|
|
314
|
-
if (m)
|
|
410
|
+
if (m) {
|
|
411
|
+
finish({
|
|
412
|
+
url: m[0],
|
|
413
|
+
kind,
|
|
414
|
+
stop,
|
|
415
|
+
auth: authProxy ? { user: authProxy.user, password: authProxy.password } : undefined,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
315
418
|
};
|
|
316
419
|
tunnel.stdout.on('data', onTunnel);
|
|
317
420
|
tunnel.stderr.on('data', onTunnel);
|
|
@@ -324,7 +427,7 @@ export async function startPreview({
|
|
|
324
427
|
out = (out + s).slice(-4000);
|
|
325
428
|
if (!tunnelStarted) {
|
|
326
429
|
const m = BIND_RE.exec(s);
|
|
327
|
-
if (m) openTunnel(Number(m[1]));
|
|
430
|
+
if (m) void openTunnel(Number(m[1]));
|
|
328
431
|
}
|
|
329
432
|
};
|
|
330
433
|
server.stdout.on('data', onServer);
|
|
@@ -343,7 +446,7 @@ export async function startPreview({
|
|
|
343
446
|
|
|
344
447
|
// If the server never prints a URL we recognize (quiet server), tunnel to the
|
|
345
448
|
// configured port as a last resort.
|
|
346
|
-
bindTimer = setTimeout(() => openTunnel(port), 30_000);
|
|
449
|
+
bindTimer = setTimeout(() => void openTunnel(port), 30_000);
|
|
347
450
|
timer = setTimeout(() => {
|
|
348
451
|
log?.(
|
|
349
452
|
`preview tunnel did not come up in ${Math.round(timeoutMs / 1000)}s — skipping.${
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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": {
|