flowviant 0.51.2 → 0.53.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.
@@ -1,163 +1,68 @@
1
1
  /**
2
- * Live preview (live mode). When a task opens its PR and parks for review, the
3
- * daemon starts the branch's dev server IN THE AGENT'S WORKTREE and opens a
4
- * cloudflared quick tunnel to it, so the reviewer can drive the real running
5
- * change in Flowviant (broker-not-host: Flowviant only stores the tunnel URL;
6
- * the reviewer's browser talks to it directly).
2
+ * Put a password-gated public URL in front of a dev server the DRIVER is
3
+ * already running in their own worktree.
7
4
  *
8
- * Zero-config where possible: the preview config is read from
9
- * `.flowviant/preview.json` if present, otherwise INFERRED from package.json
10
- * (framework port). cloudflared is AUTO-FETCHED if it isn't installed. No
11
- * cloudflared / no inferable config no live preview, and review falls back to
12
- * the captured evidence the agent attached (never a hard failure).
5
+ * This file used to be the other half of a deleted feature: a dispatch run
6
+ * parked for review, and the daemon started the branch's dev server from a
7
+ * repo-declared command and tunnelled it. That whole start path is GONE
8
+ * (2026-08-21) and is not coming back. What it did, stated plainly so nobody
9
+ * rebuilds it: read `.flowviant/preview.json` a file the BRANCH controls —
10
+ * or infer a command from package.json, then `spawn(cmd, {shell: true})` with
11
+ * `env: {...process.env}`, which ran `npm install` and its lifecycle scripts
12
+ * and handed the resulting internet-exposed process the daemon's own
13
+ * FLOWVIANT_FLEET credential. One click behind a button, and a hostile branch
14
+ * owns the machine.
13
15
  *
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
- * "auth": true // optional gate the public tunnel
24
- * // behind a generated password (shown
25
- * // in the app); off by default
26
- * },
27
- * "api": { "cmd": "<start api>", "port": 8787 } }
16
+ * The replacement inverts the direction. The human runs their dev server
17
+ * themselves, exactly as they would in a terminal; `listeners.mjs` NOTICES it;
18
+ * and this file only ever wraps a port that has already been measured inside
19
+ * that session's worktree. Flowviant executes nothing the repo wrote.
20
+ *
21
+ * Two invariants that must survive any edit here:
22
+ * - THE GATE IS MANDATORY. `startAuthProxy` returning null aborts the share.
23
+ * There is no un-gated path, no config key that disables it, and no log line
24
+ * that shrugs and tunnels anyway.
25
+ * - WE ONLY EXECUTE WHAT WE VERIFIED. An auto-fetched cloudflared is pinned to
26
+ * a version and checked against a hardcoded SHA-256 before it is made
27
+ * executable. TLS alone is not integrity for a binary that runs on the
28
+ * machine holding the repo, the git credentials and the decrypted env vault.
28
29
  */
29
30
 
30
31
  import { spawn, execFileSync } from 'node:child_process';
31
- import { createServer } from 'node:net';
32
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, readdirSync, rmSync } from 'node:fs';
32
+ import { createHash } from 'node:crypto';
33
+ import {
34
+ readFileSync,
35
+ writeFileSync,
36
+ renameSync,
37
+ existsSync,
38
+ mkdirSync,
39
+ chmodSync,
40
+ openSync,
41
+ closeSync,
42
+ statSync,
43
+ rmSync,
44
+ unlinkSync,
45
+ } from 'node:fs';
33
46
  import { join } from 'node:path';
34
47
  import { homedir, platform, arch } from 'node:os';
35
48
  import { startAuthProxy } from './authproxy.mjs';
49
+ import { isListening } from './listeners.mjs';
36
50
 
37
- // ── Config: explicit file, else infer from package.json ────────────────────
38
-
39
- function readPreviewConfig(repoRoot) {
40
- const p = join(repoRoot, '.flowviant', 'preview.json');
41
- if (!existsSync(p)) return null;
42
- try {
43
- return JSON.parse(readFileSync(p, 'utf8'));
44
- } catch {
45
- return null;
46
- }
47
- }
48
-
49
- // Framework → conventional dev-server port. If we can't identify one, we don't
50
- // guess — an explicit .flowviant/preview.json is the escape hatch.
51
- const FRAMEWORK_PORTS = [
52
- { re: /\bvite\b/, port: 5173 },
53
- { re: /\bnext\b/, port: 3000 },
54
- { re: /react-scripts/, port: 3000 },
55
- { re: /\bastro\b/, port: 4321 },
56
- { re: /\bnuxt\b/, port: 3000 },
57
- { re: /\bremix\b/, port: 3000 },
58
- { re: /\bsvelte/, port: 5173 },
59
- { re: /\bgatsby\b/, port: 8000 },
60
- { re: /\bexpo\b/, port: 8081 },
61
- { re: /@angular\/|\bng serve\b/, port: 4200 },
62
- { re: /vue-cli-service/, port: 8080 },
63
- ];
64
-
65
- // A repo whose ROOT is a library/monorepo often keeps its web app in a subdir,
66
- // so the root package.json has no dev server at all. Search these (plus every
67
- // child of apps/ and packages/) so a nested frontend previews with ZERO config.
68
- const SUBDIR_CANDIDATES = [
69
- 'web', 'webapp', 'frontend', 'client', 'ui', 'site', 'www', 'app', 'dashboard',
70
- ];
71
- const SUBDIR_PARENTS = ['apps', 'packages'];
72
-
73
- function pkgManager(dir) {
74
- if (existsSync(join(dir, 'bun.lock')) || existsSync(join(dir, 'bun.lockb'))) return 'bun';
75
- if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
76
- if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
77
- return 'npm';
78
- }
79
-
80
- // An explicit port baked into the dev script (PORT=3005 …, -p 3005, --port 3005,
81
- // --port=3005) overrides the framework default — otherwise the tunnel would
82
- // target the wrong port and never connect.
83
- function portFromScript(s) {
84
- const m = String(s).match(/(?:PORT=|(?:^|\s)-p[=\s]+|--port[=\s]+)(\d{2,5})\b/);
85
- return m ? Number(m[1]) : null;
86
- }
87
-
88
- // Infer {script, port} from one directory's package.json, or null if it has no
89
- // dev/start script or no framework we can map to a port.
90
- function inferFromDir(absDir) {
91
- const pkgPath = join(absDir, 'package.json');
92
- if (!existsSync(pkgPath)) return null;
93
- let pkg;
94
- try {
95
- pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
96
- } catch {
97
- return null;
98
- }
99
- const scripts = pkg.scripts || {};
100
- const script = scripts.dev ? 'dev' : scripts.start ? 'start' : null;
101
- if (!script) return null;
102
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
103
- const hay = `${scripts[script]} ${Object.keys(deps).join(' ')}`.toLowerCase();
104
- const fw = FRAMEWORK_PORTS.find((f) => f.re.test(hay));
105
- if (!fw) return null; // can't safely guess the port
106
- return { script, port: portFromScript(scripts[script]) ?? fw.port };
107
- }
108
-
109
- // The ordered dirs to probe: root, then the common frontend names, then every
110
- // child of apps/ and packages/. Relative to repoRoot ('' = root).
111
- function candidateDirs(repoRoot) {
112
- const dirs = ['', ...SUBDIR_CANDIDATES];
113
- for (const parent of SUBDIR_PARENTS) {
114
- const p = join(repoRoot, parent);
115
- try {
116
- for (const e of readdirSync(p, { withFileTypes: true })) {
117
- if (e.isDirectory()) dirs.push(`${parent}/${e.name}`);
118
- }
119
- } catch {
120
- /* no such parent dir */
121
- }
122
- }
123
- return dirs;
124
- }
125
-
126
- function buildConfig(repoRoot, rel, hit) {
127
- // Prefer the app dir's own package manager if it has a lockfile, else the repo
128
- // root's (monorepos install from the root).
129
- const abs = rel ? join(repoRoot, rel) : repoRoot;
130
- const hasOwnLock = ['bun.lock', 'bun.lockb', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json'].some(
131
- (f) => existsSync(join(abs, f)),
132
- );
133
- const pm = pkgManager(hasOwnLock ? abs : repoRoot);
134
- const install = pm === 'npm' ? 'npm install' : `${pm} install`;
135
- const run = pm === 'yarn' ? `yarn ${hit.script}` : `${pm} run ${hit.script}`;
136
- const inner = `${install} && ${run}`;
137
- // Subdir apps run from their own folder (shell:true honors the cd prefix).
138
- const cmd = rel ? `cd ${rel} && ${inner}` : inner;
139
- return { ui: { cmd, port: hit.port }, dir: rel || '.' };
140
- }
141
-
142
- // Infer a preview config by probing the root and likely frontend subdirs.
143
- function inferPreviewConfig(repoRoot) {
144
- for (const rel of candidateDirs(repoRoot)) {
145
- const hit = inferFromDir(rel ? join(repoRoot, rel) : repoRoot);
146
- if (hit) return buildConfig(repoRoot, rel, hit);
147
- }
148
- return null;
149
- }
150
-
151
- /** The preview config for a repo — explicit file wins, else inferred from the
152
- * root or a nested frontend. Carries `dir` (relative) so callers can say where
153
- * it found the app. */
154
- export function loadPreviewConfig(repoRoot) {
155
- const explicit = readPreviewConfig(repoRoot);
156
- if (explicit) return explicit;
157
- return inferPreviewConfig(repoRoot);
158
- }
51
+ // ── cloudflared: pinned, verified, or not fetched at all ───────────────────
159
52
 
160
- // ── cloudflared: use if installed, else auto-fetch ─────────────────────────
53
+ /**
54
+ * Pinned deliberately. `releases/latest/download/...` meant every machine
55
+ * fetched whatever was newest at the moment it happened to need one, which is
56
+ * both unverifiable and irreproducible. Bumping this is a release act: download
57
+ * the assets, hash them, replace both the tag and the digests.
58
+ */
59
+ const CF_VERSION = '2026.8.2';
60
+ const CF_SHA256 = {
61
+ 'linux-amd64': 'fcfb02b575a52ca1af2e3267af4e1517bcdeb30ac48c834c69abaed3c0576ad2',
62
+ 'linux-arm64': '7747d94570fb390cf47dcb4f9555c193c6355cda9793f0d878d9049e5d6a7790',
63
+ 'darwin-amd64': 'f1727723c586500e2092368ae21871b3df7ddfd2cb097f22d81bee4a9c458bb4',
64
+ 'darwin-arm64': '9042c2c5d8b2de78e60f313d5fb31b6c5c1cebde787a3caf1f2c9588084ac442',
65
+ };
161
66
 
162
67
  function onPath() {
163
68
  try {
@@ -168,88 +73,130 @@ function onPath() {
168
73
  }
169
74
  }
170
75
 
171
- /** Resolve a cloudflared binary: PATH → cached fetch → download. Returns the
172
- * command/path to run, or null if unavailable (Windows/macOS auto-fetch is
173
- * skipped — those install cleanly via brew/winget). */
76
+ /**
77
+ * Resolve a cloudflared binary: PATH previously fetched → download.
78
+ *
79
+ * A cloudflared already on PATH is used as-is and NOT checksummed: the operator
80
+ * installed it (brew, apt, winget) and that is their trust decision, not ours.
81
+ * What we verify is what WE fetch and chmod +x, which is the only case where
82
+ * Flowviant is the one introducing an executable to the machine.
83
+ *
84
+ * Returns { bin } or { error } — the error is the machine's own sentence, meant
85
+ * to be relayed verbatim rather than replaced with a Flowviant-authored one.
86
+ */
174
87
  async function ensureCloudflared(log) {
175
- if (onPath()) return 'cloudflared';
88
+ if (onPath()) return { bin: 'cloudflared' };
89
+
176
90
  const os = platform();
177
- const dir = join(homedir(), '.flowviant', 'bin');
178
- const bin = join(dir, os === 'win32' ? 'cloudflared.exe' : 'cloudflared');
179
- if (existsSync(bin)) return bin;
180
91
  const a = arch() === 'arm64' ? 'arm64' : 'amd64';
92
+ const key = `${os === 'darwin' ? 'darwin' : 'linux'}-${a}`;
93
+ const dir = join(homedir(), '.flowviant', 'bin');
94
+ // Version-stamped, so a pin bump fetches rather than reusing the old binary.
95
+ const bin = join(dir, `cloudflared-${CF_VERSION}${os === 'win32' ? '.exe' : ''}`);
96
+ if (existsSync(bin)) return { bin };
97
+
98
+ const want = CF_SHA256[key];
99
+ if (!want) {
100
+ return {
101
+ error: `cloudflared is not installed, and this machine (${os}/${a}) has no pinned build to fetch. Install cloudflared and try again.`,
102
+ };
103
+ }
104
+
181
105
  try {
182
106
  mkdirSync(dir, { recursive: true });
107
+ const asset = os === 'darwin' ? `cloudflared-darwin-${a}.tgz` : `cloudflared-linux-${a}`;
108
+ const url = `https://github.com/cloudflare/cloudflared/releases/download/${CF_VERSION}/${asset}`;
109
+ log?.(`fetching cloudflared ${CF_VERSION} (${key})…`);
110
+ const res = await fetch(url, { redirect: 'follow' });
111
+ if (!res.ok) throw new Error(`http ${res.status}`);
112
+ const buf = Buffer.from(await res.arrayBuffer());
113
+
114
+ // Verify BEFORE anything becomes executable, and before extraction — a
115
+ // tarball is code too.
116
+ const got = createHash('sha256').update(buf).digest('hex');
117
+ if (got !== want) {
118
+ return {
119
+ error: `refused to install cloudflared ${CF_VERSION}: the download did not match its pinned checksum (expected ${want.slice(0, 12)}…, got ${got.slice(0, 12)}…). Install cloudflared yourself if you trust this network.`,
120
+ };
121
+ }
122
+
183
123
  if (os === 'darwin') {
184
- // macOS ships a .tgz (not a raw binary) — download it and extract the
185
- // single `cloudflared` executable with the system tar (always on macOS).
186
- const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${a}.tgz`;
187
- log?.(`fetching cloudflared (darwin-${a}) to enable live previews…`);
188
- const res = await fetch(url, { redirect: 'follow' });
189
- if (!res.ok) throw new Error(`http ${res.status}`);
190
- const tgz = join(dir, 'cloudflared.tgz');
191
- writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
124
+ const tgz = join(dir, `cloudflared-${CF_VERSION}.tgz`);
125
+ writeFileSync(tgz, buf);
192
126
  execFileSync('tar', ['-xzf', tgz, '-C', dir], { stdio: 'ignore' });
193
127
  rmSync(tgz, { force: true });
194
- if (!existsSync(bin)) throw new Error('archive did not contain cloudflared');
195
- chmodSync(bin, 0o755);
196
- return bin;
128
+ const extracted = join(dir, 'cloudflared');
129
+ if (!existsSync(extracted)) throw new Error('archive did not contain cloudflared');
130
+ renameSync(extracted, bin);
131
+ } else {
132
+ writeFileSync(bin, buf);
197
133
  }
198
- // linux + windows ship a raw single-file binary.
199
- const osName = os === 'win32' ? 'windows' : 'linux';
200
- const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${osName}-${a}${
201
- os === 'win32' ? '.exe' : ''
202
- }`;
203
- log?.(`fetching cloudflared (${osName}-${a}) to enable live previews…`);
204
- const res = await fetch(url, { redirect: 'follow' });
205
- if (!res.ok) throw new Error(`http ${res.status}`);
206
- writeFileSync(bin, Buffer.from(await res.arrayBuffer()));
207
- if (os !== 'win32') chmodSync(bin, 0o755);
208
- return bin;
134
+ chmodSync(bin, 0o755);
135
+ return { bin };
209
136
  } catch (e) {
210
- const hint = os === 'darwin' ? ' (or `brew install cloudflared`)' : '';
211
- log?.(`could not fetch cloudflared (${e.message}) — install it manually${hint} to enable live previews.`);
212
- return null;
137
+ return { error: `could not fetch cloudflared (${e.message}). Install it and try again.` };
213
138
  }
214
139
  }
215
140
 
216
141
  const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
217
- // Where a dev server announces it bound — "Local: http://localhost:3001/".
218
- const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
219
142
 
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
- });
143
+ // ── Orphan reaping ─────────────────────────────────────────────────────────
144
+ // cloudflared is detached so we can kill its whole group which also means it
145
+ // SURVIVES an ungraceful daemon death (SIGKILL, crash, box sleep), leaving a
146
+ // public hostname pointed at a worktree with nobody minding it. We record each
147
+ // group's pid + a signature and reap ours at the next start.
148
+ //
149
+ // The registry is a read-modify-write over one file in a directory that TWO
150
+ // daemons can legitimately share (the 0.51.2 instance lock is keyed on a
151
+ // CREDENTIAL, so two daemons serving two different projects are fine and both
152
+ // write here). It was unlocked, written back when previews were serial. A lost
153
+ // entry is precisely the case reaping exists for.
154
+
155
+ const FLOWVIANT_DIR = join(homedir(), '.flowviant');
156
+ const PREVIEW_REGISTRY = join(FLOWVIANT_DIR, 'previews.json');
157
+ const REGISTRY_LOCK = join(FLOWVIANT_DIR, 'previews.lock');
158
+ const LOCK_STALE_MS = 15_000;
159
+
160
+ /** Best-effort exclusive lock. Returns a release function; on failure returns
161
+ * null and the caller proceeds unlocked — losing an entry is bad, but refusing
162
+ * to record one at all is worse. */
163
+ function acquireLock() {
164
+ try {
165
+ mkdirSync(FLOWVIANT_DIR, { recursive: true });
166
+ } catch {
167
+ return null;
168
+ }
169
+ for (let i = 0; i < 30; i++) {
170
+ try {
171
+ const fd = openSync(REGISTRY_LOCK, 'wx');
172
+ closeSync(fd);
173
+ return () => {
174
+ try {
175
+ unlinkSync(REGISTRY_LOCK);
176
+ } catch {
177
+ /* already released */
178
+ }
179
+ };
180
+ } catch {
181
+ // Held — unless it was left behind by something that died holding it.
182
+ try {
183
+ if (Date.now() - statSync(REGISTRY_LOCK).mtimeMs > LOCK_STALE_MS) {
184
+ unlinkSync(REGISTRY_LOCK);
185
+ continue;
186
+ }
187
+ } catch {
188
+ continue;
189
+ }
190
+ // Spin briefly. This lock is held for one file write.
191
+ const until = Date.now() + 20;
192
+ while (Date.now() < until) {
193
+ /* busy-wait: 20ms, 30 times, then give up entirely */
194
+ }
195
+ }
196
+ }
197
+ return null;
245
198
  }
246
199
 
247
- // ── Orphan reaping ─────────────────────────────────────────────────────────
248
- // Preview children (dev server + tunnel) are detached so we can kill the whole
249
- // group — but that also means they SURVIVE an ungraceful daemon death (SIGKILL,
250
- // crash, box sleep), leaking ports/memory. We record each spawned group's pid +
251
- // a signature; on the next daemon start we reap any that are still ours.
252
- const PREVIEW_REGISTRY = join(homedir(), '.flowviant', 'previews.json');
253
200
  function readRegistry() {
254
201
  try {
255
202
  const v = JSON.parse(readFileSync(PREVIEW_REGISTRY, 'utf8'));
@@ -258,25 +205,42 @@ function readRegistry() {
258
205
  return [];
259
206
  }
260
207
  }
208
+
209
+ /** Atomic: write a sibling temp file and rename over the target, so a reader
210
+ * never sees a half-written array. */
261
211
  function writeRegistry(list) {
262
212
  try {
263
- mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
264
- writeFileSync(PREVIEW_REGISTRY, JSON.stringify(list));
213
+ mkdirSync(FLOWVIANT_DIR, { recursive: true });
214
+ const tmp = `${PREVIEW_REGISTRY}.${process.pid}.tmp`;
215
+ writeFileSync(tmp, JSON.stringify(list));
216
+ renameSync(tmp, PREVIEW_REGISTRY);
265
217
  } catch {
266
218
  /* best-effort */
267
219
  }
268
220
  }
221
+
222
+ function mutateRegistry(fn) {
223
+ const release = acquireLock();
224
+ try {
225
+ writeRegistry(fn(readRegistry()));
226
+ } finally {
227
+ release?.();
228
+ }
229
+ }
230
+
269
231
  function recordPreviewPid(pid, sig) {
270
232
  if (!pid) return;
271
- writeRegistry([...readRegistry(), { pid, sig }]);
233
+ mutateRegistry((list) => [...list, { pid, sig }]);
272
234
  }
235
+
273
236
  function forgetPreviewPid(pid) {
274
237
  if (!pid) return;
275
- writeRegistry(readRegistry().filter((e) => e.pid !== pid));
238
+ mutateRegistry((list) => list.filter((e) => e.pid !== pid));
276
239
  }
240
+
277
241
  // Only kill a pid we can VERIFY is still one of ours — its /proc cmdline must
278
- // still contain the signature we stored. A reused pid (belonging to something
279
- // unrelated) won't match, so we never kill a stranger. Linux-only (that's where
242
+ // still contain the signature we stored. A reused pid belonging to something
243
+ // unrelated won't match, so we never kill a stranger. Linux-only (that's where
280
244
  // /proc + process groups work); elsewhere we just clear the registry.
281
245
  function stillOurs(pid, sig) {
282
246
  if (platform() !== 'linux') return false;
@@ -288,8 +252,8 @@ function stillOurs(pid, sig) {
288
252
  }
289
253
  }
290
254
 
291
- /** Reap preview process groups left behind by a previously-crashed daemon.
292
- * Call once at daemon startup, before spawning workers. */
255
+ /** Reap tunnel process groups left behind by a previously-crashed daemon.
256
+ * Call once at daemon startup, before any work begins. */
293
257
  export function reapOrphanPreviews(log) {
294
258
  const list = readRegistry();
295
259
  if (list.length === 0) return;
@@ -308,194 +272,134 @@ export function reapOrphanPreviews(log) {
308
272
  }
309
273
  }
310
274
  }
311
- writeRegistry([]);
312
- if (killed) log?.(`reaped ${killed} orphaned preview process${killed === 1 ? '' : 'es'} from a previous run.`);
275
+ mutateRegistry(() => []);
276
+ if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
313
277
  }
314
278
 
279
+ // ── The one thing this file does ───────────────────────────────────────────
280
+
281
+ /** How long we wait for cloudflared to hand us a hostname. */
282
+ const TUNNEL_TIMEOUT_MS = 60_000;
283
+ /** Bytes of cloudflared output kept for the failure sentence. */
284
+ const TAIL_BYTES = 2000;
285
+
315
286
  /**
316
- * Start the dev server + tunnel for one worktree. Resolves { url, kind, stop }
317
- * once the tunnel URL is captured, or null if it can't come up. stop() kills
318
- * both the server and the tunnel.
287
+ * Gate `port` behind a password and publish it on a quick tunnel.
319
288
  *
320
- * The tunnel target is the port the server ACTUALLY bound (read from its
321
- * output), not the guessed one vite/vinext/next hop to the next free port when
322
- * theirs is taken, and tunneling to the guess then 502s. We fall back to the
323
- * configured port only if the server never announces one.
289
+ * Resolves { url, user, password, stop } on success, or { error } — a sentence
290
+ * from this machine, to be relayed as-is. It never resolves a URL without a
291
+ * password, and it never returns a tunnel whose origin was not listening when
292
+ * we checked.
293
+ *
294
+ * `onDead` fires if the ORIGIN stops answering while the tunnel is up:
295
+ * cloudflared happily outlives a dead dev server and the gate answers a dead
296
+ * origin with 502, so without this the product would report "live" over a 502 —
297
+ * Flowviant asserting a state it never measured.
324
298
  */
325
- export async function startPreview({
326
- worktree,
327
- kind,
328
- cmd,
329
- port,
330
- env: extraEnv,
331
- hostHeader,
332
- auth,
333
- log,
334
- timeoutMs = 180_000,
335
- }) {
299
+ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
300
+ // Re-validate at the machine. The server checked this port against the last
301
+ // report; reports are up to a minute old and a dev server is a process a
302
+ // human can stop at any moment.
303
+ if (!(await isListening(port))) {
304
+ return { error: `nothing is listening on port ${port} in this worktree any more.` };
305
+ }
306
+
336
307
  const cf = await ensureCloudflared(log);
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();
342
- // Host the origin sees. Default 'localhost' (what a local browser sends) so
343
- // dev servers that validate Host — Vite server.allowedHosts, webpack, Next's
344
- // allowedDevOrigins — accept the tunnel. `hostHeader: false` in preview.json
345
- // disables the rewrite for apps that route on their real public Host.
346
- const hostRewrite =
347
- hostHeader === false ? null : typeof hostHeader === 'string' && hostHeader ? hostHeader : 'localhost';
348
- return new Promise((resolve) => {
349
- // We SIGKILL the dev server's whole group on teardown, which skips a tool's
350
- // graceful cleanup — some dev servers (e.g. vinext) leave a singleton
351
- // dev-lock behind and then REFUSE to start next time. Disable known locks so
352
- // a reused/uncleaned worktree still previews. Harmless to tools that ignore
353
- // these vars; BROWSER=none stops any auto-open. A repo's preview.json `env`
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.
362
- const env = {
363
- ...process.env,
364
- VINEXT_NO_DEV_LOCK: '1',
365
- BROWSER: 'none',
366
- ...(bindPort ? { PORT: String(bindPort) } : null),
367
- ...(extraEnv && typeof extraEnv === 'object' ? extraEnv : {}),
368
- };
369
- // detached so each gets its own process group — `bun run dev` via a shell
370
- // spawns a grandchild dev server that would otherwise SURVIVE a kill of the
371
- // shell. We kill the whole group instead. stdout/stderr piped so we can read
372
- // the bound port and surface failures.
373
- const server = spawn(cmd, {
374
- cwd: worktree,
375
- shell: true,
376
- detached: true,
377
- stdio: ['ignore', 'pipe', 'pipe'],
378
- env,
379
- });
380
- // Track for orphan reaping: the shell's cmdline stays `sh -c <cmd>`, so `cmd`
381
- // is a safe signature to re-verify against later.
382
- recordPreviewPid(server.pid, cmd);
308
+ if (cf.error) return { error: cf.error };
383
309
 
384
- let settled = false;
385
- let tunnel = null;
386
- let tunnelStarted = false;
387
- let authProxy = null; // opt-in basic-auth proxy in front of the dev server
388
- let out = '';
389
- const tail = () => out.trim().split('\n').slice(-15).join('\n');
390
- const killGroup = (child) => {
391
- if (!child?.pid) return;
310
+ let stopped = false;
311
+ let gate = null;
312
+ let tunnel = null;
313
+ let probe = null;
314
+
315
+ const stop = () => {
316
+ if (stopped) return;
317
+ stopped = true;
318
+ if (probe) clearInterval(probe);
319
+ try {
320
+ gate?.stop();
321
+ } catch {
322
+ /* best-effort */
323
+ }
324
+ if (tunnel?.pid) {
392
325
  try {
393
- process.kill(-child.pid, 'SIGKILL'); // negative pid = the whole group
326
+ process.kill(-tunnel.pid, 'SIGKILL'); // the whole detached group
394
327
  } catch {
395
328
  try {
396
- child.kill('SIGKILL');
329
+ tunnel.kill('SIGKILL');
397
330
  } catch {
398
- /* gone */
331
+ /* already gone */
399
332
  }
400
333
  }
401
- };
402
- const stop = () => {
403
- forgetPreviewPid(server.pid);
404
- forgetPreviewPid(tunnel?.pid);
405
- try {
406
- authProxy?.stop();
407
- } catch {
408
- /* already closed */
409
- }
410
- killGroup(server);
411
- killGroup(tunnel);
412
- };
413
- let bindTimer;
414
- let timer;
415
- const finish = (val) => {
334
+ forgetPreviewPid(tunnel.pid);
335
+ }
336
+ };
337
+
338
+ // The gate comes up FIRST and the tunnel points at it, never at the origin —
339
+ // so there is no window in which the public hostname is un-gated.
340
+ gate = await startAuthProxy({ targetPort: port, log, onAbuse: () => stop() });
341
+ if (!gate) {
342
+ return { error: 'could not start the password gate for this preview, so nothing was published.' };
343
+ }
344
+
345
+ const args = ['tunnel', '--url', `http://localhost:${gate.port}`];
346
+ // Send the origin the Host it expects. Vite and Next reject a Host they do
347
+ // not recognise, so without this the tunnel resolves and then 403s.
348
+ args.push('--http-host-header', 'localhost');
349
+
350
+ tunnel = spawn(cf.bin, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
351
+ recordPreviewPid(tunnel.pid, 'cloudflared');
352
+
353
+ return new Promise((resolve) => {
354
+ let settled = false;
355
+ let tail = '';
356
+ const finish = (v) => {
416
357
  if (settled) return;
417
358
  settled = true;
418
359
  clearTimeout(timer);
419
- clearTimeout(bindTimer);
420
- if (!val) stop();
421
- resolve(val);
360
+ if (v.error) stop();
361
+ resolve(v);
422
362
  };
423
363
 
424
- // Open the tunnel once we know the real port (detected or fallback). When
425
- // auth is opted in, put the password proxy in front and tunnel to THAT.
426
- const openTunnel = async (p) => {
427
- if (tunnelStarted || settled) return;
428
- tunnelStarted = true;
429
- clearTimeout(bindTimer);
430
- let tunnelPort = p;
431
- if (auth) {
432
- authProxy = await startAuthProxy({ targetPort: p, log });
433
- if (settled) {
434
- authProxy?.stop(); // torn down while the proxy was coming up — don't leak it
435
- return;
436
- }
437
- if (authProxy) tunnelPort = authProxy.port;
438
- else log?.('auth proxy failed to start — tunneling WITHOUT a password.');
439
- }
440
- log?.(`preview: dev server on :${p} — opening the tunnel…`);
441
- // --http-host-header: send the origin the Host it expects (default
442
- // localhost — see hostRewrite above). Passes Vite/webpack/Next host checks
443
- // with zero repo config; skipped when preview.json sets hostHeader:false.
444
- const args = ['tunnel', '--url', `http://localhost:${tunnelPort}`];
445
- if (hostRewrite) args.push('--http-host-header', hostRewrite);
446
- tunnel = spawn(cf, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
447
- recordPreviewPid(tunnel.pid, 'cloudflared'); // signature for orphan reaping
448
- const onTunnel = (d) => {
449
- const m = TUNNEL_RE.exec(d.toString());
450
- if (m) {
451
- finish({
452
- url: m[0],
453
- kind,
454
- stop,
455
- auth: authProxy ? { user: authProxy.user, password: authProxy.password } : undefined,
456
- });
457
- }
458
- };
459
- tunnel.stdout.on('data', onTunnel);
460
- tunnel.stderr.on('data', onTunnel);
461
- tunnel.on('error', () => finish(null));
462
- tunnel.on('close', () => finish(null));
463
- };
364
+ const timer = setTimeout(
365
+ () => finish({ error: `cloudflared did not return a URL within ${TUNNEL_TIMEOUT_MS / 1000}s.${tailSentence()}` }),
366
+ TUNNEL_TIMEOUT_MS,
367
+ );
464
368
 
465
- const onServer = (d) => {
369
+ // cloudflared's own words. Both `error` and `close` used to resolve null
370
+ // with nothing captured, which made a throttled or blocked tunnel
371
+ // indistinguishable from silence — and silence is the one thing this
372
+ // product is not allowed to turn into a state.
373
+ const tailSentence = () => (tail.trim() ? ` cloudflared said: ${tail.trim().split('\n').slice(-3).join(' ')}` : '');
374
+
375
+ const onOut = (d) => {
466
376
  const s = d.toString();
467
- out = (out + s).slice(-4000);
468
- if (!tunnelStarted) {
469
- const m = BIND_RE.exec(s);
470
- if (m) void openTunnel(Number(m[1]));
471
- }
377
+ tail = (tail + s).slice(-TAIL_BYTES);
378
+ const m = TUNNEL_RE.exec(s);
379
+ if (!m) return;
380
+
381
+ // Watch the ORIGIN, not the tunnel. A dead dev server behind a live
382
+ // hostname is the failure a viewer cannot diagnose.
383
+ probe = setInterval(async () => {
384
+ if (stopped) return;
385
+ if (!(await isListening(port))) {
386
+ const dead = onDead;
387
+ stop();
388
+ try {
389
+ dead?.();
390
+ } catch {
391
+ /* the caller's teardown is best-effort */
392
+ }
393
+ }
394
+ }, probeMs);
395
+ if (probe.unref) probe.unref();
396
+
397
+ finish({ url: m[0], user: gate.user, password: gate.password, stop });
472
398
  };
473
- server.stdout.on('data', onServer);
474
- server.stderr.on('data', onServer);
475
- // A dev server that exits before it's reachable (crash on boot, a singleton
476
- // lock refusing to start) is the loud failure mode surface its output.
477
- server.on('exit', (code) => {
478
- if (settled) return;
479
- log?.(
480
- `preview dev server exited (code ${code}) before it was reachable — no preview.${
481
- tail() ? `\n dev server said:\n${tail()}` : ''
482
- }`,
483
- );
484
- finish(null);
485
- });
486
-
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);
492
- timer = setTimeout(() => {
493
- log?.(
494
- `preview tunnel did not come up in ${Math.round(timeoutMs / 1000)}s — skipping.${
495
- tail() ? `\n last dev-server output:\n${tail()}` : ''
496
- }`,
497
- );
498
- finish(null);
499
- }, timeoutMs);
399
+
400
+ tunnel.stdout.on('data', onOut);
401
+ tunnel.stderr.on('data', onOut);
402
+ tunnel.on('error', (e) => finish({ error: `could not run cloudflared (${e.message}).` }));
403
+ tunnel.on('close', () => finish({ error: `cloudflared exited before publishing a URL.${tailSentence()}` }));
500
404
  });
501
405
  }