flowviant 0.34.0 → 0.34.2

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
@@ -42,11 +42,37 @@ import { runWorker, runStaticFleet } from './lib/single.mjs';
42
42
  import { runLogin } from './lib/login.mjs';
43
43
  import { preflight } from './lib/preflight.mjs';
44
44
 
45
- // `flowviant login` — device auth (recommended): approve a code in the
46
- // app, the credential is stored locally, then plain `flowviant` just runs.
45
+ // `flowviant login` — device auth (recommended): approve a code in the app, the
46
+ // credential is stored locally, and then we KEEP GOING into the daemon.
47
+ //
48
+ // It used to print "Now just run: npx flowviant" and exit. Everything about that
49
+ // was technically correct and practically a dead end: the line scrolled past in
50
+ // a terminal the user had already stopped reading (they were in the browser,
51
+ // typing a code), and the app told them their machine would "come online
52
+ // shortly" — which it never did, because nothing was running. The product
53
+ // promise is install once; a second command you have to notice is not that.
54
+ //
55
+ // `--no-start` for scripts and CI, which want the credential and not a
56
+ // long-running process.
47
57
  if (process.argv[2] === 'login') {
48
- await runLogin();
49
- process.exit(0);
58
+ const noStart = process.argv.includes('--no-start');
59
+ await runLogin({ thenStart: !noStart });
60
+ if (noStart) process.exit(0);
61
+ // Re-exec as a plain `flowviant` rather than falling through. config.mjs reads
62
+ // the credential at IMPORT time — which was before the login we just did — so
63
+ // this process still has an empty FLEET_TOKEN and would exit with "no
64
+ // credential found" seconds after saving one. Same shape as the self-update
65
+ // re-exec: stay alive as a thin proxy so the user's shell keeps one foreground
66
+ // process.
67
+ const { spawn } = await import('node:child_process');
68
+ const child = spawn(process.execPath, [process.argv[1]], {
69
+ stdio: 'inherit',
70
+ env: process.env,
71
+ });
72
+ // AWAIT it. Registering an exit handler and falling through would run the rest
73
+ // of this file in the parent — which has no credential — and print "no
74
+ // credential found" over the daemon that just started in the child.
75
+ process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
50
76
  }
51
77
 
52
78
  // `flowviant update` — install the latest published version now. The daemon also
package/bin/lib/login.mjs CHANGED
@@ -45,7 +45,7 @@ async function post(url, body) {
45
45
  return j.data ?? j;
46
46
  }
47
47
 
48
- export async function runLogin() {
48
+ export async function runLogin({ thenStart = false } = {}) {
49
49
  console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
50
50
  let start;
51
51
  try {
@@ -72,7 +72,14 @@ export async function runLogin() {
72
72
  if (poll.status === 'approved') {
73
73
  store({ fleetToken: poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
74
74
  ok('connected — credential saved to ~/.flowviant/credentials.json');
75
- console.log(`\n Now just run: ${c.bold('npx flowviant')}\n`);
75
+ // The daemon starts right here unless the caller opted out; telling
76
+ // someone to run a second command was the step that got missed, since by
77
+ // this point they are looking at the browser, not this terminal.
78
+ console.log(
79
+ thenStart
80
+ ? `\n ${c.dim('starting your agent — leave this running')}\n`
81
+ : `\n Now run: ${c.bold('npx flowviant')}\n`
82
+ );
76
83
  return;
77
84
  }
78
85
  if (poll.status === 'expired') {
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { spawn, execFileSync } from 'node:child_process';
31
+ import { createServer } from 'node:net';
31
32
  import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, readdirSync, rmSync } from 'node:fs';
32
33
  import { join } from 'node:path';
33
34
  import { homedir, platform, arch } from 'node:os';
@@ -216,6 +217,33 @@ const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
216
217
  // Where a dev server announces it bound — "Local: http://localhost:3001/".
217
218
  const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
218
219
 
220
+ /**
221
+ * A port nobody is on right now — bind :0, read what the kernel handed us, let
222
+ * it go. Inherently a RACE (someone could take it in the gap), which is fine:
223
+ * this is a hint passed as $PORT, and the tunnel still aims at whatever the
224
+ * server ACTUALLY announces. Worst case we lose the hint and land back on
225
+ * today's behaviour.
226
+ *
227
+ * Why this exists: previews only survived concurrency by luck. vite/next hop to
228
+ * the next free port when theirs is taken, so two tasks in one repo happened to
229
+ * work. Anything that binds a FIXED port and exits on EADDRINUSE — the `api`
230
+ * preview kind, an explicit `port` in preview.json, a plain app.listen(3000) —
231
+ * had its second preview die outright, reported as "dev server exited before it
232
+ * was reachable" with the real cause buried in the tail. That's not an edge
233
+ * case on a machine running up to MAX_CONCURRENT tasks, and it stops being one
234
+ * at all once the box is shared.
235
+ */
236
+ async function freePort() {
237
+ return new Promise((resolve) => {
238
+ const srv = createServer();
239
+ srv.once('error', () => resolve(null)); // no port to be had — fall through
240
+ srv.listen(0, '127.0.0.1', () => {
241
+ const p = srv.address()?.port ?? null;
242
+ srv.close(() => resolve(p));
243
+ });
244
+ });
245
+ }
246
+
219
247
  // ── Orphan reaping ─────────────────────────────────────────────────────────
220
248
  // Preview children (dev server + tunnel) are detached so we can kill the whole
221
249
  // group — but that also means they SURVIVE an ungraceful daemon death (SIGKILL,
@@ -307,6 +335,10 @@ export async function startPreview({
307
335
  }) {
308
336
  const cf = await ensureCloudflared(log);
309
337
  if (!cf) return null; // fall back to captured evidence
338
+ // Ask for a port nobody's on, and TELL the dev server about it (below) rather
339
+ // than hoping its framework hops. Null if we couldn't get one — everything
340
+ // downstream then behaves exactly as before.
341
+ const bindPort = await freePort();
310
342
  // Host the origin sees. Default 'localhost' (what a local browser sends) so
311
343
  // dev servers that validate Host — Vite server.allowedHosts, webpack, Next's
312
344
  // allowedDevOrigins — accept the tunnel. `hostHeader: false` in preview.json
@@ -320,10 +352,18 @@ export async function startPreview({
320
352
  // a reused/uncleaned worktree still previews. Harmless to tools that ignore
321
353
  // these vars; BROWSER=none stops any auto-open. A repo's preview.json `env`
322
354
  // is layered last, so it can override any of these.
355
+ // PORT is a HINT, deliberately: honoured by Next, Remix, Nuxt, CRA and any
356
+ // conventional `app.listen(process.env.PORT)`, ignored by Vite (which uses
357
+ // server.port and hops on its own) and overridden by an explicit --port in
358
+ // the dev script. All three outcomes are fine — the tunnel aims at the port
359
+ // the server ANNOUNCES, not at what we asked for, so a disregarded hint
360
+ // costs nothing and an honoured one is what makes two concurrent previews
361
+ // of one repo possible. Placed BEFORE extraEnv so preview.json still wins.
323
362
  const env = {
324
363
  ...process.env,
325
364
  VINEXT_NO_DEV_LOCK: '1',
326
365
  BROWSER: 'none',
366
+ ...(bindPort ? { PORT: String(bindPort) } : null),
327
367
  ...(extraEnv && typeof extraEnv === 'object' ? extraEnv : {}),
328
368
  };
329
369
  // detached so each gets its own process group — `bun run dev` via a shell
@@ -444,9 +484,11 @@ export async function startPreview({
444
484
  finish(null);
445
485
  });
446
486
 
447
- // If the server never prints a URL we recognize (quiet server), tunnel to the
448
- // configured port as a last resort.
449
- bindTimer = setTimeout(() => void openTunnel(port), 30_000);
487
+ // If the server never prints a URL we recognize (quiet server), guess. Prefer
488
+ // the port we HANDED it over the one we inferred from its framework: a server
489
+ // quiet enough to reach this line is usually a plain node/express one, and
490
+ // those are exactly the ones that read $PORT.
491
+ bindTimer = setTimeout(() => void openTunnel(bindPort ?? port), 30_000);
450
492
  timer = setTimeout(() => {
451
493
  log?.(
452
494
  `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.34.0",
3
+ "version": "0.34.2",
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": {