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.
- package/bin/lib/authproxy.mjs +131 -32
- package/bin/lib/claude.mjs +16 -3
- package/bin/lib/config.mjs +16 -0
- package/bin/lib/fleet.mjs +52 -16
- package/bin/lib/listeners.mjs +269 -0
- package/bin/lib/preview.mjs +294 -390
- package/bin/lib/prompts.mjs +62 -13
- package/bin/lib/runtimes.mjs +47 -0
- package/bin/lib/work.mjs +218 -4
- package/package.json +1 -1
package/bin/lib/preview.mjs
CHANGED
|
@@ -1,163 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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 {
|
|
32
|
-
import {
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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(
|
|
264
|
-
|
|
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
|
-
|
|
233
|
+
mutateRegistry((list) => [...list, { pid, sig }]);
|
|
272
234
|
}
|
|
235
|
+
|
|
273
236
|
function forgetPreviewPid(pid) {
|
|
274
237
|
if (!pid) return;
|
|
275
|
-
|
|
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
|
|
279
|
-
// unrelated
|
|
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
|
|
292
|
-
* Call once at daemon startup, before
|
|
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
|
-
|
|
312
|
-
if (killed) log?.(`reaped ${killed} orphaned preview
|
|
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
|
-
*
|
|
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
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
port
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 (
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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(-
|
|
326
|
+
process.kill(-tunnel.pid, 'SIGKILL'); // the whole detached group
|
|
394
327
|
} catch {
|
|
395
328
|
try {
|
|
396
|
-
|
|
329
|
+
tunnel.kill('SIGKILL');
|
|
397
330
|
} catch {
|
|
398
|
-
/* gone */
|
|
331
|
+
/* already gone */
|
|
399
332
|
}
|
|
400
333
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
resolve(val);
|
|
360
|
+
if (v.error) stop();
|
|
361
|
+
resolve(v);
|
|
422
362
|
};
|
|
423
363
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
}
|