flowviant 0.14.0 → 0.17.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.');
@@ -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.14.0';
7
+ export const VERSION = '0.17.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 */
@@ -586,11 +593,39 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
586
593
 
587
594
  // Per-agent loop — same signature/scaffolding as runFleetWorker, but each task
588
595
  // is a persistent SDK session instead of a one-shot claude turn.
596
+ // A preview runs in the agent's WORKTREE — a fresh checkout that lacks the repo's
597
+ // gitignored env files (.env.local etc.), so the app's DB/auth secrets are absent
598
+ // and anything that hits them (sign-in!) 500s. Copy the files the checkout is
599
+ // missing from the real repo into the worktree so the preview runs like local
600
+ // dev. We only copy files ABSENT from the worktree — i.e. the gitignored ones —
601
+ // so nothing tracked is overwritten and (being gitignored) nothing gets committed.
602
+ const PREVIEW_ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local'];
603
+ function copyLocalEnvFiles(repoRoot, worktree, log) {
604
+ if (!repoRoot || repoRoot === worktree) return;
605
+ let copied = 0;
606
+ for (const f of PREVIEW_ENV_FILES) {
607
+ const src = join(repoRoot, f);
608
+ const dst = join(worktree, f);
609
+ if (existsSync(src) && !existsSync(dst)) {
610
+ try {
611
+ copyFileSync(src, dst);
612
+ copied++;
613
+ } catch {
614
+ /* best-effort */
615
+ }
616
+ }
617
+ }
618
+ if (copied) {
619
+ log?.(`preview: brought ${copied} local env file(s) into the worktree so the app has its secrets.`);
620
+ }
621
+ }
622
+
589
623
  export async function runLiveWorker({
590
624
  agentId,
591
625
  label,
592
626
  cwd,
593
627
  baseRef,
628
+ repoRoot,
594
629
  getToken,
595
630
  getHasWork,
596
631
  getMcpUrl,
@@ -615,8 +650,16 @@ export async function runLiveWorker({
615
650
  // kept up while it's in review (a gated agent parks, so it lives until review
616
651
  // resolves). Replaced when the next task finishes; torn down on shutdown.
617
652
  let preview = null;
618
- let previewTarget = null; // { intentId, kind } of the currently-registered link
653
+ let previewTarget = null; // { intentId, kind, url } of the currently-registered link
654
+ let previewHeartbeat = null;
655
+ const stopHeartbeat = () => {
656
+ if (previewHeartbeat) {
657
+ clearInterval(previewHeartbeat);
658
+ previewHeartbeat = null;
659
+ }
660
+ };
619
661
  const stopPreview = () => {
662
+ stopHeartbeat();
620
663
  if (preview) {
621
664
  try {
622
665
  preview.stop();
@@ -657,18 +700,30 @@ export async function runLiveWorker({
657
700
  if (cfg.dir && cfg.dir !== '.') {
658
701
  info(`${label} ${c.dim(`live preview: detected a frontend at ${cfg.dir}/ (port ${entry.port})`)}`);
659
702
  }
703
+ // Give the dev server the repo's local env (gitignored secrets the fresh
704
+ // worktree is missing) so DB/auth-backed paths like sign-in don't 500.
705
+ copyLocalEnvFiles(repoRoot, cwd, (m) => info(`${label} ${c.dim(m)}`));
660
706
  info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
661
707
  preview = await startPreview({
662
708
  worktree: cwd,
663
709
  kind,
664
710
  cmd: entry.cmd,
665
711
  port: entry.port,
712
+ env: entry.env, // optional: extra env from .flowviant/preview.json
713
+ hostHeader: entry.hostHeader, // optional: override/disable the Host rewrite
666
714
  log: (m) => info(`${label} ${c.dim(m)}`),
667
715
  });
668
716
  if (preview) {
669
717
  onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
670
718
  await registerLiveTarget(intentId, kind, preview.url);
671
- previewTarget = { intentId, kind }; // so teardown can drop the link
719
+ previewTarget = { intentId, kind, url: preview.url }; // teardown drops it; heartbeat re-asserts it
720
+ // Re-assert the link while the tunnel is alive so it survives long reviews
721
+ // (and a dead daemon stops re-asserting → the record expires by itself).
722
+ stopHeartbeat();
723
+ previewHeartbeat = setInterval(() => {
724
+ if (previewTarget) void registerLiveTarget(previewTarget.intentId, previewTarget.kind, previewTarget.url);
725
+ }, PREVIEW_HEARTBEAT_MS);
726
+ previewHeartbeat.unref?.();
672
727
  ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
673
728
  }
674
729
  };
@@ -11,9 +11,17 @@
11
11
  * cloudflared / no inferable config → no live preview, and review falls back to
12
12
  * the captured evidence the agent attached (never a hard failure).
13
13
  *
14
- * Config shape:
15
- * { "ui": { "cmd": "<start dev server>", "port": 5173 },
16
- * "api": { "cmd": "<start api>", "port": 8787 } }
14
+ * Config shape (the escape hatch for any setup the defaults don't handle — the
15
+ * repo declares its own recipe, so the daemon never needs per-framework code):
16
+ * { "ui": {
17
+ * "cmd": "<start dev server>", // required
18
+ * "port": 5173, // required
19
+ * "env": { "FOO": "bar" }, // optional — extra env for the dev server
20
+ * "hostHeader": "localhost" // optional — Host sent to the origin;
21
+ * // false disables the rewrite (for apps
22
+ * // that need their real public Host)
23
+ * },
24
+ * "api": { "cmd": "<start api>", "port": 8787 } }
17
25
  */
18
26
 
19
27
  import { spawn, execFileSync } from 'node:child_process';
@@ -204,6 +212,74 @@ const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
204
212
  // Where a dev server announces it bound — "Local: http://localhost:3001/".
205
213
  const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
206
214
 
215
+ // ── Orphan reaping ─────────────────────────────────────────────────────────
216
+ // Preview children (dev server + tunnel) are detached so we can kill the whole
217
+ // group — but that also means they SURVIVE an ungraceful daemon death (SIGKILL,
218
+ // crash, box sleep), leaking ports/memory. We record each spawned group's pid +
219
+ // a signature; on the next daemon start we reap any that are still ours.
220
+ const PREVIEW_REGISTRY = join(homedir(), '.flowviant', 'previews.json');
221
+ function readRegistry() {
222
+ try {
223
+ const v = JSON.parse(readFileSync(PREVIEW_REGISTRY, 'utf8'));
224
+ return Array.isArray(v) ? v : [];
225
+ } catch {
226
+ return [];
227
+ }
228
+ }
229
+ function writeRegistry(list) {
230
+ try {
231
+ mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
232
+ writeFileSync(PREVIEW_REGISTRY, JSON.stringify(list));
233
+ } catch {
234
+ /* best-effort */
235
+ }
236
+ }
237
+ function recordPreviewPid(pid, sig) {
238
+ if (!pid) return;
239
+ writeRegistry([...readRegistry(), { pid, sig }]);
240
+ }
241
+ function forgetPreviewPid(pid) {
242
+ if (!pid) return;
243
+ writeRegistry(readRegistry().filter((e) => e.pid !== pid));
244
+ }
245
+ // Only kill a pid we can VERIFY is still one of ours — its /proc cmdline must
246
+ // still contain the signature we stored. A reused pid (belonging to something
247
+ // unrelated) won't match, so we never kill a stranger. Linux-only (that's where
248
+ // /proc + process groups work); elsewhere we just clear the registry.
249
+ function stillOurs(pid, sig) {
250
+ if (platform() !== 'linux') return false;
251
+ try {
252
+ const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
253
+ return typeof sig === 'string' && sig.length > 0 && cmd.includes(sig);
254
+ } catch {
255
+ return false; // process gone / unreadable
256
+ }
257
+ }
258
+
259
+ /** Reap preview process groups left behind by a previously-crashed daemon.
260
+ * Call once at daemon startup, before spawning workers. */
261
+ export function reapOrphanPreviews(log) {
262
+ const list = readRegistry();
263
+ if (list.length === 0) return;
264
+ let killed = 0;
265
+ for (const { pid, sig } of list) {
266
+ if (!stillOurs(pid, sig)) continue;
267
+ try {
268
+ process.kill(-pid, 'SIGKILL'); // whole group
269
+ killed++;
270
+ } catch {
271
+ try {
272
+ process.kill(pid, 'SIGKILL');
273
+ killed++;
274
+ } catch {
275
+ /* already gone */
276
+ }
277
+ }
278
+ }
279
+ writeRegistry([]);
280
+ if (killed) log?.(`reaped ${killed} orphaned preview process${killed === 1 ? '' : 'es'} from a previous run.`);
281
+ }
282
+
207
283
  /**
208
284
  * Start the dev server + tunnel for one worktree. Resolves { url, kind, stop }
209
285
  * once the tunnel URL is captured, or null if it can't come up. stop() kills
@@ -214,16 +290,37 @@ const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
214
290
  * theirs is taken, and tunneling to the guess then 502s. We fall back to the
215
291
  * configured port only if the server never announces one.
216
292
  */
217
- export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs = 180_000 }) {
293
+ export async function startPreview({
294
+ worktree,
295
+ kind,
296
+ cmd,
297
+ port,
298
+ env: extraEnv,
299
+ hostHeader,
300
+ log,
301
+ timeoutMs = 180_000,
302
+ }) {
218
303
  const cf = await ensureCloudflared(log);
219
304
  if (!cf) return null; // fall back to captured evidence
305
+ // Host the origin sees. Default 'localhost' (what a local browser sends) so
306
+ // dev servers that validate Host — Vite server.allowedHosts, webpack, Next's
307
+ // allowedDevOrigins — accept the tunnel. `hostHeader: false` in preview.json
308
+ // disables the rewrite for apps that route on their real public Host.
309
+ const hostRewrite =
310
+ hostHeader === false ? null : typeof hostHeader === 'string' && hostHeader ? hostHeader : 'localhost';
220
311
  return new Promise((resolve) => {
221
312
  // We SIGKILL the dev server's whole group on teardown, which skips a tool's
222
313
  // graceful cleanup — some dev servers (e.g. vinext) leave a singleton
223
314
  // dev-lock behind and then REFUSE to start next time. Disable known locks so
224
315
  // a reused/uncleaned worktree still previews. Harmless to tools that ignore
225
- // these vars; BROWSER=none stops any auto-open.
226
- const env = { ...process.env, VINEXT_NO_DEV_LOCK: '1', BROWSER: 'none' };
316
+ // these vars; BROWSER=none stops any auto-open. A repo's preview.json `env`
317
+ // is layered last, so it can override any of these.
318
+ const env = {
319
+ ...process.env,
320
+ VINEXT_NO_DEV_LOCK: '1',
321
+ BROWSER: 'none',
322
+ ...(extraEnv && typeof extraEnv === 'object' ? extraEnv : {}),
323
+ };
227
324
  // detached so each gets its own process group — `bun run dev` via a shell
228
325
  // spawns a grandchild dev server that would otherwise SURVIVE a kill of the
229
326
  // shell. We kill the whole group instead. stdout/stderr piped so we can read
@@ -235,6 +332,9 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
235
332
  stdio: ['ignore', 'pipe', 'pipe'],
236
333
  env,
237
334
  });
335
+ // Track for orphan reaping: the shell's cmdline stays `sh -c <cmd>`, so `cmd`
336
+ // is a safe signature to re-verify against later.
337
+ recordPreviewPid(server.pid, cmd);
238
338
 
239
339
  let settled = false;
240
340
  let tunnel = null;
@@ -254,6 +354,8 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
254
354
  }
255
355
  };
256
356
  const stop = () => {
357
+ forgetPreviewPid(server.pid);
358
+ forgetPreviewPid(tunnel?.pid);
257
359
  killGroup(server);
258
360
  killGroup(tunnel);
259
361
  };
@@ -274,16 +376,13 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
274
376
  tunnelStarted = true;
275
377
  clearTimeout(bindTimer);
276
378
  log?.(`preview: dev server on :${p} — opening the tunnel…`);
277
- // --http-host-header localhost: send the origin the Host it expects. Vite
278
- // (5+) rejects any Host it doesn't recognize (server.allowedHosts), and the
279
- // tunnel's public hostname isn't in that list "Blocked request". Rewriting
280
- // the Host to localhost — what a local browser sends anyway — passes the
281
- // check with zero repo config, and is harmless to servers that don't check.
282
- tunnel = spawn(
283
- cf,
284
- ['tunnel', '--url', `http://localhost:${p}`, '--http-host-header', 'localhost'],
285
- { detached: true, stdio: ['ignore', 'pipe', 'pipe'] },
286
- );
379
+ // --http-host-header: send the origin the Host it expects (default
380
+ // localhost see hostRewrite above). Passes Vite/webpack/Next host checks
381
+ // with zero repo config; skipped when preview.json sets hostHeader:false.
382
+ const args = ['tunnel', '--url', `http://localhost:${p}`];
383
+ if (hostRewrite) args.push('--http-host-header', hostRewrite);
384
+ tunnel = spawn(cf, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
385
+ recordPreviewPid(tunnel.pid, 'cloudflared'); // signature for orphan reaping
287
386
  const onTunnel = (d) => {
288
387
  const m = TUNNEL_RE.exec(d.toString());
289
388
  if (m) finish({ url: m[0], kind, stop });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.14.0",
3
+ "version": "0.17.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": {