flowviant 0.10.0 → 0.12.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
@@ -4,9 +4,14 @@
4
4
  *
5
5
  * Three modes, picked by which env var is set:
6
6
  *
7
- * FLOWVIANT_TOKEN=fva_… npx flowviant # 1 worker, current checkout
8
- * FLOWVIANT_TOKENS=a,b,c npx flowviant # static fleet, 1 worktree each
9
- * FLOWVIANT_FLEET=fft_… npx flowviant # FLEET DAEMON (recommended)
7
+ * FLOWVIANT_TOKEN=fva_… npx flowviant@latest # 1 worker, current checkout
8
+ * FLOWVIANT_TOKENS=a,b,c npx flowviant@latest # static fleet, 1 worktree each
9
+ * FLOWVIANT_FLEET=fft_… npx flowviant@latest # FLEET DAEMON (recommended)
10
+ *
11
+ * Launch with `@latest` so each start pulls the newest published version (bare
12
+ * `npx flowviant` can reuse a stale cache). A running daemon also self-updates
13
+ * on its own — at startup and when idle — so it stays current without restarts
14
+ * (FLOWVIANT_NO_UPDATE=1 makes it nag-only; `flowviant update` updates now).
10
15
  *
11
16
  * Fleet daemon: install ONCE with a fleet credential, then manage everything
12
17
  * from Flowviant. The daemon polls GET /api/v2/fleet/agents, reconciles one
@@ -44,6 +49,28 @@ if (process.argv[2] === 'login') {
44
49
  process.exit(0);
45
50
  }
46
51
 
52
+ // `flowviant update` — install the latest published version now. The daemon also
53
+ // self-updates on its own (at startup + when idle); this is the manual path.
54
+ if (process.argv[2] === 'update') {
55
+ const { runUpdateCommand } = await import('./lib/update.mjs');
56
+ runUpdateCommand();
57
+ process.exit(0);
58
+ }
59
+
60
+ // `flowviant gh-auth` — sign in the gh CLI (incl. a copy we bundled into
61
+ // ~/.flowviant/bin), so the isolated install doesn't need gh on your global PATH.
62
+ if (process.argv[2] === 'gh-auth') {
63
+ const { addLocalBinToPath } = await import('./lib/install.mjs');
64
+ const { execFileSync } = await import('node:child_process');
65
+ addLocalBinToPath();
66
+ try {
67
+ execFileSync('gh', ['auth', 'login'], { stdio: 'inherit' });
68
+ } catch {
69
+ console.error('gh not found — run `flowviant` once to install it, or see https://cli.github.com');
70
+ }
71
+ process.exit(0);
72
+ }
73
+
47
74
  // `flowviant clean` — reclaim the persistent worktrees (~/.flowviant/worktrees).
48
75
  // They're kept across runs so in-flight work survives Ctrl+C; this is the drain.
49
76
  // Repos self-heal: the daemon runs `git worktree prune` if a stale registration
@@ -93,7 +120,7 @@ async function main() {
93
120
  ? '» safe mode: restricted toolset (unset FLOWVIANT_SAFE for full autonomy).'
94
121
  : '» unattended mode: permission prompts skipped so the agent runs hands-off.'
95
122
  );
96
- preflight({ needGit: tokens.length > 1 });
123
+ await preflight({ needGit: tokens.length > 1 });
97
124
  if (tokens.length === 1) {
98
125
  console.log(`» flowviant → ${MCP_URL} (1 worker · token fva_…${tokens[0].slice(-4)})`);
99
126
  await runWorker({ token: tokens[0], cwd: process.cwd(), label: '' });
@@ -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.10.0';
7
+ export const VERSION = '0.12.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.
@@ -35,6 +35,11 @@ export const RECONCILE_SECONDS = Number(process.env.RECONCILE_SECONDS || 10);
35
35
  // so a long-lived daemon never silently 401s on an expired token.
36
36
  export const REFRESH_BEFORE_SECONDS = Number(process.env.REFRESH_BEFORE_SECONDS || 3600);
37
37
  export const SAFE = process.env.FLOWVIANT_SAFE === '1';
38
+ // Self-update: a running daemon updates itself to the latest published version
39
+ // (at startup + when idle) and re-execs. On by default; FLOWVIANT_NO_UPDATE=1
40
+ // keeps it nag-only (it still tells you to update, never installs). Below the
41
+ // server's MIN version it updates regardless, since live mode won't work.
42
+ export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
38
43
  // Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
39
44
  // streams into the task channel, injectable mid-task, blocker-parks in place,
40
45
  // delivery card on complete, branch preview tunnels. The legacy poll/sentinel
package/bin/lib/fleet.mjs CHANGED
@@ -22,7 +22,9 @@ import {
22
22
  RECONCILE_SECONDS,
23
23
  REFRESH_BEFORE_SECONDS,
24
24
  LIVE,
25
+ AUTO_UPDATE,
25
26
  } from './config.mjs';
27
+ import { handleVersionSignal } from './update.mjs';
26
28
  import {
27
29
  git,
28
30
  resetWorktree,
@@ -173,7 +175,7 @@ export async function runFleetDaemon() {
173
175
  info(`base · ${baseRef}`);
174
176
  info(`server · ${FLEET_URL}`);
175
177
  console.log('');
176
- preflight({ needGit: true });
178
+ await preflight({ needGit: true });
177
179
 
178
180
  // Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
179
181
  // so Ctrl+C mid-task never loses local work. Keyed per repo path; each
@@ -411,6 +413,20 @@ export async function runFleetDaemon() {
411
413
  }
412
414
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
413
415
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
416
+ // Keep the daemon current. Safe = no worker mid-task (true at startup, since
417
+ // no workers are spawned yet). If it self-updates it re-execs into the new
418
+ // version and this process becomes a proxy — stop the loop.
419
+ if (roster.daemon) {
420
+ const safeToUpdate = [...workers.values()].every((w) => w.state.child == null);
421
+ const updating = handleVersionSignal({
422
+ latest: roster.daemon.latest,
423
+ min: roster.daemon.min,
424
+ autoUpdate: AUTO_UPDATE,
425
+ safeToUpdate,
426
+ teardown,
427
+ });
428
+ if (updating) return;
429
+ }
414
430
  processMergeJobs(roster.mergeJobs);
415
431
  processCleanupJobs(roster.cleanupJobs);
416
432
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Consent-based prerequisite installers. cloudflared is fetched silently (no
3
+ * login, isolated), but claude + gh are auth-bearing CLIs you likely manage
4
+ * yourself — so we DETECT-FIRST and only install on your explicit yes, never
5
+ * silently and never clobbering an existing install. Interactive login stays
6
+ * yours (`claude` sign-in, `flowviant gh-auth`).
7
+ *
8
+ * gh is dropped into ~/.flowviant/bin (like cloudflared) rather than a system
9
+ * path, so there's nothing to conflict with; addLocalBinToPath() puts that dir
10
+ * on PATH so the daemon (and `flowviant gh-auth`) find it.
11
+ */
12
+
13
+ import { execFileSync } from 'node:child_process';
14
+ import { writeFileSync, existsSync, mkdirSync, chmodSync, rmSync, cpSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { homedir, platform, arch } from 'node:os';
17
+
18
+ export const LOCAL_BIN = join(homedir(), '.flowviant', 'bin');
19
+
20
+ /** Put ~/.flowviant/bin first on PATH so bundled binaries (gh, cloudflared)
21
+ * resolve by bare name for this process and everything it spawns. Idempotent. */
22
+ export function addLocalBinToPath() {
23
+ const sep = platform() === 'win32' ? ';' : ':';
24
+ const parts = (process.env.PATH || '').split(sep);
25
+ if (!parts.includes(LOCAL_BIN)) {
26
+ process.env.PATH = `${LOCAL_BIN}${sep}${process.env.PATH || ''}`;
27
+ }
28
+ }
29
+
30
+ /** TTY-guarded y/N. Non-interactive (no TTY) never auto-installs → returns false
31
+ * so a headless/cron run just prints the manual instructions instead. */
32
+ export async function promptYesNo(question, defaultYes) {
33
+ if (!process.stdin.isTTY) return false;
34
+ const { createInterface } = await import('node:readline');
35
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
36
+ const answer = await new Promise((res) =>
37
+ rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `, res),
38
+ );
39
+ rl.close();
40
+ const a = answer.trim().toLowerCase();
41
+ if (!a) return defaultYes;
42
+ return a === 'y' || a === 'yes';
43
+ }
44
+
45
+ /** Install Claude Code via its official npm package (npm is present — you got
46
+ * here through node). Lands on PATH; you still sign in by running `claude`. */
47
+ export function installClaude(log) {
48
+ try {
49
+ log?.('installing Claude Code (npm i -g @anthropic-ai/claude-code)…');
50
+ execFileSync('npm', ['install', '-g', '@anthropic-ai/claude-code'], { stdio: 'inherit' });
51
+ return true;
52
+ } catch (e) {
53
+ log?.(`could not install Claude Code automatically (${e?.message ?? e}). Install: https://claude.com/claude-code`);
54
+ return false;
55
+ }
56
+ }
57
+
58
+ async function latestGhVersion() {
59
+ const res = await fetch('https://api.github.com/repos/cli/cli/releases/latest', {
60
+ headers: { 'User-Agent': 'flowviant', Accept: 'application/vnd.github+json' },
61
+ redirect: 'follow',
62
+ });
63
+ if (!res.ok) throw new Error(`gh release lookup failed (http ${res.status})`);
64
+ const tag = (await res.json())?.tag_name;
65
+ if (!tag) throw new Error('no gh release tag');
66
+ return String(tag).replace(/^v/, '');
67
+ }
68
+
69
+ /** Fetch the gh release archive into ~/.flowviant/bin and extract the binary —
70
+ * the cloudflared pattern: isolated, no sudo, nothing to conflict with. Returns
71
+ * the binary path or null (falls back to manual instructions). */
72
+ export async function installGh(log) {
73
+ const os = platform();
74
+ const a = arch() === 'arm64' ? 'arm64' : 'amd64';
75
+ const osName = os === 'darwin' ? 'macOS' : os === 'win32' ? 'windows' : 'linux';
76
+ const ext = os === 'linux' ? 'tar.gz' : 'zip';
77
+ const binName = os === 'win32' ? 'gh.exe' : 'gh';
78
+ const dest = join(LOCAL_BIN, binName);
79
+ let archive;
80
+ let innerDir;
81
+ try {
82
+ mkdirSync(LOCAL_BIN, { recursive: true });
83
+ const ver = await latestGhVersion();
84
+ const stem = `gh_${ver}_${osName}_${a}`;
85
+ const url = `https://github.com/cli/cli/releases/download/v${ver}/${stem}.${ext}`;
86
+ log?.(`fetching gh ${ver} (${osName}-${a})…`);
87
+ const res = await fetch(url, { redirect: 'follow' });
88
+ if (!res.ok) throw new Error(`http ${res.status}`);
89
+ archive = join(LOCAL_BIN, `${stem}.${ext}`);
90
+ writeFileSync(archive, Buffer.from(await res.arrayBuffer()));
91
+ // linux ships tar.gz (GNU tar); mac + windows ship zip (bsdtar reads zip).
92
+ execFileSync('tar', [os === 'linux' ? '-xzf' : '-xf', archive, '-C', LOCAL_BIN], {
93
+ stdio: 'ignore',
94
+ });
95
+ innerDir = join(LOCAL_BIN, stem);
96
+ const inner = join(innerDir, 'bin', binName);
97
+ if (!existsSync(inner)) throw new Error('gh binary not found in the archive');
98
+ cpSync(inner, dest);
99
+ if (os !== 'win32') chmodSync(dest, 0o755);
100
+ return dest;
101
+ } catch (e) {
102
+ log?.(
103
+ `could not install gh automatically (${e?.message ?? e}). Install from https://cli.github.com, then run: gh auth login`,
104
+ );
105
+ return null;
106
+ } finally {
107
+ if (archive) rmSync(archive, { force: true });
108
+ if (innerDir) rmSync(innerDir, { recursive: true, force: true });
109
+ }
110
+ }
package/bin/lib/live.mjs CHANGED
@@ -74,8 +74,11 @@ const SAFE_TOOLS = [
74
74
  // rides in the seed message below, so this degrades gracefully if the preset
75
75
  // shape shifts between SDK versions.
76
76
  const SYSTEM_LIVE = `You are a Flowviant build agent working ONE task inside a live, shared task
77
- channel. START by stating your approach in plain language (a short plan) BEFORE
78
- you touch any code the whole team watches this channel and may redirect you.
77
+ channel. START by stating your approach as a SHORT MARKDOWN LIST one numbered
78
+ line per step, not a dense paragraph BEFORE you touch any code; the whole team
79
+ watches this channel and may redirect you. Everything you post here renders as
80
+ Markdown for humans, so write for them: short lists, \`code\` for identifiers and
81
+ paths, **bold** for the key point — never a wall of run-on text.
79
82
  A human teammate may message you mid-task; treat any injected "The human
80
83
  answered…" or teammate line as a new instruction and adapt. There is NO terminal
81
84
  and NO interactive prompt — your only channel to a human is the flowviant MCP
@@ -106,7 +109,7 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
106
109
  ? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
107
110
  : []),
108
111
  ``,
109
- `${transcript ? 'Continue' : 'Begin'}. Post a short plan first, then: report_progress as you go; 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.`,
112
+ `${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; 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.`,
110
113
  ].join('\n');
111
114
  }
112
115
 
@@ -616,14 +619,21 @@ export async function runLiveWorker({
616
619
  const entry = kind ? cfg[kind] : null;
617
620
  if (!entry || !intentId) {
618
621
  // Say WHY there's no preview instead of skipping silently — this was a
619
- // real "where's my preview?" support case.
622
+ // real "where's my preview?" support case. We search the root, common
623
+ // frontend dirs, and apps/* + packages/*, so if nothing matched either
624
+ // there's no runnable web app or it needs an explicit config.
620
625
  info(
621
626
  `${label} ${c.dim(
622
- 'no live preview: add .flowviant/preview.json ({"ui":{"cmd":"npm run dev","port":5173}}) — the framework could not be inferred from package.json.'
627
+ '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}}.'
623
628
  )}`
624
629
  );
625
630
  return;
626
631
  }
632
+ // Zero-config win: when we found the app in a subdir, say where, so it's
633
+ // clear what's being served (and how to pin it if the guess is wrong).
634
+ if (cfg.dir && cfg.dir !== '.') {
635
+ info(`${label} ${c.dim(`live preview: detected a frontend at ${cfg.dir}/ (port ${entry.port})`)}`);
636
+ }
627
637
  info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
628
638
  preview = await startPreview({
629
639
  worktree: cwd,
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { ok, warn, info, c } from './ui.mjs';
9
+ import { addLocalBinToPath, promptYesNo, installClaude, installGh } from './install.mjs';
9
10
 
10
11
  function present(cmd) {
11
12
  try {
@@ -25,21 +26,48 @@ function ghAuthed() {
25
26
  }
26
27
  }
27
28
 
28
- /** Prints a checklist. Returns false only if a *fatal* prereq (claude, or git
29
- * when worktrees are used) is missing callers may warn-and-continue. */
30
- export function preflight({ needGit = true } = {}) {
31
- const claude = present('claude');
32
- const gh = present('gh');
29
+ /** Prints a checklist and, when a missing prereq is auto-installable, OFFERS to
30
+ * install it (consent-based, never silent). Returns false only if a *fatal*
31
+ * prereq (claude, or git when worktrees are used) is still missing after. */
32
+ export async function preflight({ needGit = true } = {}) {
33
+ addLocalBinToPath(); // find a gh/cloudflared we bundled on a previous run
34
+ let claude = present('claude');
35
+ let gh = present('gh');
33
36
  const node18 = Number(process.versions.node.split('.')[0]) >= 18;
34
37
  const git = needGit ? present('git') : true;
35
38
 
36
39
  info('checking your setup (this tool drives these — it never sees their logins):');
37
- claude
38
- ? ok(`claude installed ${c.dim('· must be signed in run `claude` once if you haven’t')}`)
39
- : warn('claude NOT found install Claude Code: https://claude.com/claude-code');
40
- gh && ghAuthed()
41
- ? ok('gh authenticated')
42
- : warn(gh ? 'gh not signed in — run: gh auth login' : 'gh NOT found — install it + run: gh auth login (needed to open PRs)');
40
+
41
+ // claude fatal. Offer the official npm install (no-default: it's bigger and
42
+ // account-coupled, so we don't push it).
43
+ if (claude) {
44
+ ok(`claude installed ${c.dim('· must be signed in — run `claude` once if you haven’t')}`);
45
+ } else {
46
+ warn('claude NOT found — Claude Code is required.');
47
+ if (await promptYesNo('Install Claude Code now?', false)) {
48
+ if (installClaude((m) => info(m))) claude = present('claude');
49
+ }
50
+ claude
51
+ ? ok('claude installed — run `claude` once to sign in')
52
+ : warn('install Claude Code manually: https://claude.com/claude-code');
53
+ }
54
+
55
+ // gh — needed to open PRs. Offer to fetch the isolated binary (yes-default:
56
+ // low-risk, no login carried by the install itself).
57
+ if (gh && ghAuthed()) {
58
+ ok('gh authenticated');
59
+ } else if (gh) {
60
+ warn('gh not signed in — run: gh auth login');
61
+ } else {
62
+ warn('gh NOT found — needed to open PRs.');
63
+ if (await promptYesNo('Install GitHub CLI (gh) now?', true)) {
64
+ if (await installGh((m) => info(m))) gh = present('gh');
65
+ }
66
+ gh
67
+ ? ok('gh installed to ~/.flowviant/bin — authenticate with: flowviant gh-auth')
68
+ : warn('install gh manually: https://cli.github.com, then run: gh auth login');
69
+ }
70
+
43
71
  if (needGit) (git ? ok('git installed') : warn('git NOT found — install git'));
44
72
  node18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} — need 18+`);
45
73
  console.log('');
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { spawn, execFileSync } from 'node:child_process';
20
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, readdirSync, rmSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { homedir, platform, arch } from 'node:os';
23
23
 
@@ -45,17 +45,37 @@ const FRAMEWORK_PORTS = [
45
45
  { re: /\bsvelte/, port: 5173 },
46
46
  { re: /\bgatsby\b/, port: 8000 },
47
47
  { re: /\bexpo\b/, port: 8081 },
48
+ { re: /@angular\/|\bng serve\b/, port: 4200 },
49
+ { re: /vue-cli-service/, port: 8080 },
48
50
  ];
49
51
 
50
- function pkgManager(repoRoot) {
51
- if (existsSync(join(repoRoot, 'bun.lock')) || existsSync(join(repoRoot, 'bun.lockb'))) return 'bun';
52
- if (existsSync(join(repoRoot, 'pnpm-lock.yaml'))) return 'pnpm';
53
- if (existsSync(join(repoRoot, 'yarn.lock'))) return 'yarn';
52
+ // A repo whose ROOT is a library/monorepo often keeps its web app in a subdir,
53
+ // so the root package.json has no dev server at all. Search these (plus every
54
+ // child of apps/ and packages/) so a nested frontend previews with ZERO config.
55
+ const SUBDIR_CANDIDATES = [
56
+ 'web', 'webapp', 'frontend', 'client', 'ui', 'site', 'www', 'app', 'dashboard',
57
+ ];
58
+ const SUBDIR_PARENTS = ['apps', 'packages'];
59
+
60
+ function pkgManager(dir) {
61
+ if (existsSync(join(dir, 'bun.lock')) || existsSync(join(dir, 'bun.lockb'))) return 'bun';
62
+ if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
63
+ if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
54
64
  return 'npm';
55
65
  }
56
66
 
57
- function inferPreviewConfig(repoRoot) {
58
- const pkgPath = join(repoRoot, 'package.json');
67
+ // An explicit port baked into the dev script (PORT=3005 …, -p 3005, --port 3005,
68
+ // --port=3005) overrides the framework default — otherwise the tunnel would
69
+ // target the wrong port and never connect.
70
+ function portFromScript(s) {
71
+ const m = String(s).match(/(?:PORT=|(?:^|\s)-p[=\s]+|--port[=\s]+)(\d{2,5})\b/);
72
+ return m ? Number(m[1]) : null;
73
+ }
74
+
75
+ // Infer {script, port} from one directory's package.json, or null if it has no
76
+ // dev/start script or no framework we can map to a port.
77
+ function inferFromDir(absDir) {
78
+ const pkgPath = join(absDir, 'package.json');
59
79
  if (!existsSync(pkgPath)) return null;
60
80
  let pkg;
61
81
  try {
@@ -70,15 +90,58 @@ function inferPreviewConfig(repoRoot) {
70
90
  const hay = `${scripts[script]} ${Object.keys(deps).join(' ')}`.toLowerCase();
71
91
  const fw = FRAMEWORK_PORTS.find((f) => f.re.test(hay));
72
92
  if (!fw) return null; // can't safely guess the port
73
- const pm = pkgManager(repoRoot);
93
+ return { script, port: portFromScript(scripts[script]) ?? fw.port };
94
+ }
95
+
96
+ // The ordered dirs to probe: root, then the common frontend names, then every
97
+ // child of apps/ and packages/. Relative to repoRoot ('' = root).
98
+ function candidateDirs(repoRoot) {
99
+ const dirs = ['', ...SUBDIR_CANDIDATES];
100
+ for (const parent of SUBDIR_PARENTS) {
101
+ const p = join(repoRoot, parent);
102
+ try {
103
+ for (const e of readdirSync(p, { withFileTypes: true })) {
104
+ if (e.isDirectory()) dirs.push(`${parent}/${e.name}`);
105
+ }
106
+ } catch {
107
+ /* no such parent dir */
108
+ }
109
+ }
110
+ return dirs;
111
+ }
112
+
113
+ function buildConfig(repoRoot, rel, hit) {
114
+ // Prefer the app dir's own package manager if it has a lockfile, else the repo
115
+ // root's (monorepos install from the root).
116
+ const abs = rel ? join(repoRoot, rel) : repoRoot;
117
+ const hasOwnLock = ['bun.lock', 'bun.lockb', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json'].some(
118
+ (f) => existsSync(join(abs, f)),
119
+ );
120
+ const pm = pkgManager(hasOwnLock ? abs : repoRoot);
74
121
  const install = pm === 'npm' ? 'npm install' : `${pm} install`;
75
- const run = pm === 'yarn' ? `yarn ${script}` : `${pm} run ${script}`;
76
- return { ui: { cmd: `${install} && ${run}`, port: fw.port } };
122
+ const run = pm === 'yarn' ? `yarn ${hit.script}` : `${pm} run ${hit.script}`;
123
+ const inner = `${install} && ${run}`;
124
+ // Subdir apps run from their own folder (shell:true honors the cd prefix).
125
+ const cmd = rel ? `cd ${rel} && ${inner}` : inner;
126
+ return { ui: { cmd, port: hit.port }, dir: rel || '.' };
127
+ }
128
+
129
+ // Infer a preview config by probing the root and likely frontend subdirs.
130
+ function inferPreviewConfig(repoRoot) {
131
+ for (const rel of candidateDirs(repoRoot)) {
132
+ const hit = inferFromDir(rel ? join(repoRoot, rel) : repoRoot);
133
+ if (hit) return buildConfig(repoRoot, rel, hit);
134
+ }
135
+ return null;
77
136
  }
78
137
 
79
- /** The preview config for a repo — explicit file wins, else inferred. */
138
+ /** The preview config for a repo — explicit file wins, else inferred from the
139
+ * root or a nested frontend. Carries `dir` (relative) so callers can say where
140
+ * it found the app. */
80
141
  export function loadPreviewConfig(repoRoot) {
81
- return readPreviewConfig(repoRoot) ?? inferPreviewConfig(repoRoot);
142
+ const explicit = readPreviewConfig(repoRoot);
143
+ if (explicit) return explicit;
144
+ return inferPreviewConfig(repoRoot);
82
145
  }
83
146
 
84
147
  // ── cloudflared: use if installed, else auto-fetch ─────────────────────────
@@ -101,27 +164,38 @@ async function ensureCloudflared(log) {
101
164
  const dir = join(homedir(), '.flowviant', 'bin');
102
165
  const bin = join(dir, os === 'win32' ? 'cloudflared.exe' : 'cloudflared');
103
166
  if (existsSync(bin)) return bin;
104
- // Raw single-file binaries exist for linux + windows; macOS ships a tarball,
105
- // so point mac users at brew instead of unpacking here.
106
- if (os === 'darwin') {
107
- log?.('cloudflared not found — install it (`brew install cloudflared`) to enable live previews.');
108
- return null;
109
- }
110
- const osName = os === 'win32' ? 'windows' : 'linux';
111
167
  const a = arch() === 'arm64' ? 'arm64' : 'amd64';
112
- const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${osName}-${a}${
113
- os === 'win32' ? '.exe' : ''
114
- }`;
115
- log?.(`fetching cloudflared (${osName}-${a}) to enable live previews…`);
116
168
  try {
169
+ mkdirSync(dir, { recursive: true });
170
+ if (os === 'darwin') {
171
+ // macOS ships a .tgz (not a raw binary) — download it and extract the
172
+ // single `cloudflared` executable with the system tar (always on macOS).
173
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${a}.tgz`;
174
+ log?.(`fetching cloudflared (darwin-${a}) to enable live previews…`);
175
+ const res = await fetch(url, { redirect: 'follow' });
176
+ if (!res.ok) throw new Error(`http ${res.status}`);
177
+ const tgz = join(dir, 'cloudflared.tgz');
178
+ writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
179
+ execFileSync('tar', ['-xzf', tgz, '-C', dir], { stdio: 'ignore' });
180
+ rmSync(tgz, { force: true });
181
+ if (!existsSync(bin)) throw new Error('archive did not contain cloudflared');
182
+ chmodSync(bin, 0o755);
183
+ return bin;
184
+ }
185
+ // linux + windows ship a raw single-file binary.
186
+ const osName = os === 'win32' ? 'windows' : 'linux';
187
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${osName}-${a}${
188
+ os === 'win32' ? '.exe' : ''
189
+ }`;
190
+ log?.(`fetching cloudflared (${osName}-${a}) to enable live previews…`);
117
191
  const res = await fetch(url, { redirect: 'follow' });
118
192
  if (!res.ok) throw new Error(`http ${res.status}`);
119
- mkdirSync(dir, { recursive: true });
120
193
  writeFileSync(bin, Buffer.from(await res.arrayBuffer()));
121
194
  if (os !== 'win32') chmodSync(bin, 0o755);
122
195
  return bin;
123
196
  } catch (e) {
124
- log?.(`could not fetch cloudflared (${e.message}) install it manually to enable live previews.`);
197
+ const hint = os === 'darwin' ? ' (or `brew install cloudflared`)' : '';
198
+ log?.(`could not fetch cloudflared (${e.message}) — install it manually${hint} to enable live previews.`);
125
199
  return null;
126
200
  }
127
201
  }
@@ -133,20 +207,38 @@ const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
133
207
  * once the tunnel URL is captured, or null if it can't come up. stop() kills
134
208
  * both the server and the tunnel.
135
209
  */
136
- export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs = 90_000 }) {
210
+ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs = 180_000 }) {
137
211
  const cf = await ensureCloudflared(log);
138
212
  if (!cf) return null; // fall back to captured evidence
139
213
  return new Promise((resolve) => {
214
+ // We SIGKILL the dev server's whole group on teardown, which skips a tool's
215
+ // graceful cleanup — some dev servers (e.g. vinext) leave a singleton
216
+ // dev-lock behind and then REFUSE to start next time. Disable known locks so
217
+ // a reused/uncleaned worktree still previews. Harmless to tools that ignore
218
+ // these vars; BROWSER=none stops any auto-open.
219
+ const env = { ...process.env, VINEXT_NO_DEV_LOCK: '1', BROWSER: 'none' };
140
220
  // detached so each gets its own process group — `bun run dev` via a shell
141
221
  // spawns a grandchild dev server that would otherwise SURVIVE a kill of the
142
222
  // shell, keep port bound, and get silently re-fronted by the NEXT task's
143
223
  // tunnel (reviewer sees the wrong branch). We kill the whole group instead.
224
+ // stdout/stderr are piped (not ignored) so we can show WHY a preview didn't
225
+ // come up — a swallowed "another dev server is already running" was a real
226
+ // dead-end.
144
227
  const server = spawn(cmd, {
145
228
  cwd: worktree,
146
229
  shell: true,
147
230
  detached: true,
148
- stdio: ['ignore', 'ignore', 'ignore'],
231
+ stdio: ['ignore', 'pipe', 'pipe'],
232
+ env,
149
233
  });
234
+ // Ring buffer of the dev server's recent output, surfaced on failure.
235
+ let out = '';
236
+ const capture = (d) => {
237
+ out = (out + d.toString()).slice(-4000);
238
+ };
239
+ server.stdout.on('data', capture);
240
+ server.stderr.on('data', capture);
241
+ const tail = () => out.trim().split('\n').slice(-15).join('\n');
150
242
  const tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${port}`], {
151
243
  detached: true,
152
244
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -183,8 +275,24 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
183
275
  tunnel.stderr.on('data', onData);
184
276
  tunnel.on('error', () => finish(null));
185
277
  tunnel.on('close', () => finish(null));
278
+ // The dev server exiting BEFORE the tunnel came up is the loud failure mode
279
+ // (crash on boot, or a singleton lock refusing to start) — surface its
280
+ // output instead of a silent timeout.
281
+ server.on('exit', (code) => {
282
+ if (settled) return;
283
+ log?.(
284
+ `preview dev server exited (code ${code}) before it was reachable — no preview.${
285
+ tail() ? `\n dev server said:\n${tail()}` : ''
286
+ }`,
287
+ );
288
+ finish(null);
289
+ });
186
290
  const timer = setTimeout(() => {
187
- log?.('preview tunnel did not come up in time — skipping (captured evidence still applies).');
291
+ log?.(
292
+ `preview tunnel did not come up in ${Math.round(timeoutMs / 1000)}s — skipping.${
293
+ tail() ? `\n last dev-server output:\n${tail()}` : ''
294
+ }`,
295
+ );
188
296
  finish(null);
189
297
  }, timeoutMs);
190
298
  });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Self-update — keep a long-running daemon current without babysitting it.
3
+ *
4
+ * A daemon runs for hours/days from one launch, so "latest at launch" (even with
5
+ * `npx flowviant@latest`) doesn't help a process that's already up when a new
6
+ * version ships. The server reports {latest, min} on every roster poll; the
7
+ * daemon compares its own VERSION and, at a SAFE boundary (startup or idle —
8
+ * never mid-task), self-updates + re-execs. Below `min` it updates regardless
9
+ * (older protocol is known-broken); otherwise it honors AUTO_UPDATE. When it
10
+ * can't install (npx cache, or auto off) it nags with the exact command.
11
+ */
12
+
13
+ import { execFileSync, spawn } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { VERSION } from './config.mjs';
16
+ import { note, ok, warn } from './ui.mjs';
17
+
18
+ /** Compare x.y.z version strings → -1 | 0 | 1 (missing parts read as 0). */
19
+ export function cmpVersion(a, b) {
20
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
21
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
22
+ for (let i = 0; i < 3; i++) {
23
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
24
+ if (d !== 0) return d > 0 ? 1 : -1;
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ /**
30
+ * npx runs from a per-invocation cache dir. `npm i -g` would install to a
31
+ * DIFFERENT location than the one executing, so re-execing our own path would
32
+ * loop on the stale cached copy. Detect npx and skip the install (nag instead —
33
+ * relaunching with `npx flowviant@latest` is the npx-native update).
34
+ */
35
+ export function runningViaNpx() {
36
+ const ua = process.env.npm_config_user_agent || '';
37
+ const argv1 = process.argv[1] || '';
38
+ let self = '';
39
+ try {
40
+ self = fileURLToPath(import.meta.url);
41
+ } catch {
42
+ /* non-file URL — ignore */
43
+ }
44
+ return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
45
+ }
46
+
47
+ /**
48
+ * Replace this process with a fresh one running the just-installed version.
49
+ * `npm i -g` overwrote the global package in place, so re-running argv[1] loads
50
+ * the NEW code. We tear down first (idle-gated, so nothing's mid-task) and keep
51
+ * this process alive only as a thin proxy waiting on the child, so the user's
52
+ * shell stays attached to one foreground process.
53
+ */
54
+ function reexec(teardown) {
55
+ try {
56
+ teardown?.();
57
+ } catch {
58
+ /* best-effort */
59
+ }
60
+ const child = spawn(process.execPath, process.argv.slice(1), {
61
+ stdio: 'inherit',
62
+ env: process.env,
63
+ });
64
+ child.on('exit', (code) => process.exit(code ?? 0));
65
+ }
66
+
67
+ /** Install @latest globally. Throws on failure (EACCES without sudo, offline…). */
68
+ function installLatest() {
69
+ execFileSync('npm', ['install', '-g', 'flowviant@latest'], { stdio: 'inherit' });
70
+ }
71
+
72
+ /** `flowviant update` — explicit, manual update. Does not re-exec into a daemon
73
+ * (the user ran a one-shot command); it installs and tells them to relaunch. */
74
+ export function runUpdateCommand() {
75
+ if (runningViaNpx()) {
76
+ note('running via npx — just relaunch with `npx flowviant@latest` to get the newest.');
77
+ return;
78
+ }
79
+ try {
80
+ note(`updating flowviant (currently ${VERSION})…`);
81
+ installLatest();
82
+ ok('updated. Relaunch `flowviant` to run the new version.');
83
+ } catch (e) {
84
+ warn(`update failed (${e?.message ?? e}). Try: npm i -g flowviant@latest`);
85
+ }
86
+ }
87
+
88
+ // Nag at most once per target version, so a poll every ~10s doesn't spam.
89
+ let naggedFor = null;
90
+
91
+ /**
92
+ * React to the server's {latest, min} signal from a roster poll.
93
+ * @returns true if it kicked off a self-update + re-exec (caller must stop).
94
+ */
95
+ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, teardown }) {
96
+ const cur = VERSION;
97
+ const belowMin = min && cmpVersion(cur, min) < 0;
98
+ const belowLatest = latest && cmpVersion(cur, latest) < 0;
99
+ if (!belowMin && !belowLatest) return false; // current — nothing to do
100
+ const target = latest || min;
101
+ const npx = runningViaNpx();
102
+ const wantInstall = belowMin || autoUpdate;
103
+
104
+ if (wantInstall && !npx) {
105
+ if (!safeToUpdate) {
106
+ // Outdated but an agent is mid-task — wait for idle. Nag once meanwhile.
107
+ if (naggedFor !== target) {
108
+ naggedFor = target;
109
+ note(`flowviant ${cur} → ${target} available — self-updating once agents go idle.`);
110
+ }
111
+ return false;
112
+ }
113
+ try {
114
+ note(`flowviant ${cur} → ${target}: self-updating…`);
115
+ installLatest();
116
+ ok('updated — restarting into the new version.');
117
+ reexec(teardown);
118
+ return true;
119
+ } catch (e) {
120
+ warn(`self-update failed (${e?.message ?? e}) — update manually: npm i -g flowviant@latest`);
121
+ naggedFor = target; // don't retry-spam a failing install every poll
122
+ return false;
123
+ }
124
+ }
125
+
126
+ // Can't or won't auto-install → nag once per target version.
127
+ if (naggedFor !== target) {
128
+ naggedFor = target;
129
+ const how = npx ? 'relaunch with `npx flowviant@latest`' : 'run `npm i -g flowviant@latest`';
130
+ if (belowMin) {
131
+ warn(`flowviant ${cur} is below the minimum ${min} — live mode may not work. Update: ${how}.`);
132
+ } else {
133
+ note(`flowviant ${cur} → ${latest} available. Update: ${how}.`);
134
+ }
135
+ }
136
+ return false;
137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.10.0",
3
+ "version": "0.12.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": {