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 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
+ }
@@ -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.15.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
- log: (m) => info(`${label} ${c.dim(m)}`),
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 }; // so teardown can drop the link
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
  };
@@ -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" // optional — Host sent to the origin;
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
- const openTunnel = (p) => {
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:${p}`];
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) finish({ url: m[0], kind, stop });
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.15.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": {