flowviant 0.12.0 → 0.14.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/config.mjs +1 -1
- package/bin/lib/live.mjs +24 -0
- package/bin/lib/preview.mjs +60 -28
- package/bin/lib/update.mjs +21 -1
- package/package.json +1 -1
package/bin/lib/config.mjs
CHANGED
|
@@ -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.
|
|
7
|
+
export const VERSION = '0.14.0';
|
|
8
8
|
|
|
9
9
|
// Credential stored by `flowviant login` (device auth) — the no-token,
|
|
10
10
|
// no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
|
package/bin/lib/live.mjs
CHANGED
|
@@ -54,6 +54,23 @@ async function registerLiveTarget(intentId, kind, url) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// The preview's tunnel is going down (replaced by another task's, or the daemon
|
|
58
|
+
// is stopping/restarting) — tell Flowviant to drop the link so it doesn't keep
|
|
59
|
+
// offering a dead URL that 530s. Best-effort + short timeout so teardown is snappy.
|
|
60
|
+
const LIVE_TARGET_CLEAR_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target-clear');
|
|
61
|
+
function clearLiveTarget(intentId, kind) {
|
|
62
|
+
return fetch(LIVE_TARGET_CLEAR_URL, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
66
|
+
'User-Agent': USER_AGENT,
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
},
|
|
69
|
+
signal: AbortSignal.timeout(5_000),
|
|
70
|
+
body: JSON.stringify({ intentId, kind }),
|
|
71
|
+
}).catch(() => {});
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
// Safe mode's curated toolset. Bash is scoped to the specific CLIs the agent
|
|
58
75
|
// needs (git/gh/npm/bun) — NOT bare `Bash`, which would auto-approve arbitrary
|
|
59
76
|
// shell (rm -rf, curl|sh, reading ~/.ssh) and defeat the point of safe mode.
|
|
@@ -598,6 +615,7 @@ export async function runLiveWorker({
|
|
|
598
615
|
// kept up while it's in review (a gated agent parks, so it lives until review
|
|
599
616
|
// resolves). Replaced when the next task finishes; torn down on shutdown.
|
|
600
617
|
let preview = null;
|
|
618
|
+
let previewTarget = null; // { intentId, kind } of the currently-registered link
|
|
601
619
|
const stopPreview = () => {
|
|
602
620
|
if (preview) {
|
|
603
621
|
try {
|
|
@@ -607,6 +625,11 @@ export async function runLiveWorker({
|
|
|
607
625
|
}
|
|
608
626
|
preview = null;
|
|
609
627
|
}
|
|
628
|
+
// Drop the app-side link so it stops offering a now-dead tunnel (530).
|
|
629
|
+
if (previewTarget) {
|
|
630
|
+
void clearLiveTarget(previewTarget.intentId, previewTarget.kind);
|
|
631
|
+
previewTarget = null;
|
|
632
|
+
}
|
|
610
633
|
// Detached preview children (dev server + tunnel) survive process exit, so
|
|
611
634
|
// the daemon's SIGINT teardown needs a handle to stop them — clear it here
|
|
612
635
|
// once they're down.
|
|
@@ -645,6 +668,7 @@ export async function runLiveWorker({
|
|
|
645
668
|
if (preview) {
|
|
646
669
|
onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
|
|
647
670
|
await registerLiveTarget(intentId, kind, preview.url);
|
|
671
|
+
previewTarget = { intentId, kind }; // so teardown can drop the link
|
|
648
672
|
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
649
673
|
}
|
|
650
674
|
};
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -201,11 +201,18 @@ async function ensureCloudflared(log) {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
204
|
+
// Where a dev server announces it bound — "Local: http://localhost:3001/".
|
|
205
|
+
const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
|
|
204
206
|
|
|
205
207
|
/**
|
|
206
208
|
* Start the dev server + tunnel for one worktree. Resolves { url, kind, stop }
|
|
207
209
|
* once the tunnel URL is captured, or null if it can't come up. stop() kills
|
|
208
210
|
* both the server and the tunnel.
|
|
211
|
+
*
|
|
212
|
+
* The tunnel target is the port the server ACTUALLY bound (read from its
|
|
213
|
+
* output), not the guessed one — vite/vinext/next hop to the next free port when
|
|
214
|
+
* theirs is taken, and tunneling to the guess then 502s. We fall back to the
|
|
215
|
+
* configured port only if the server never announces one.
|
|
209
216
|
*/
|
|
210
217
|
export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs = 180_000 }) {
|
|
211
218
|
const cf = await ensureCloudflared(log);
|
|
@@ -219,11 +226,8 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
219
226
|
const env = { ...process.env, VINEXT_NO_DEV_LOCK: '1', BROWSER: 'none' };
|
|
220
227
|
// detached so each gets its own process group — `bun run dev` via a shell
|
|
221
228
|
// spawns a grandchild dev server that would otherwise SURVIVE a kill of the
|
|
222
|
-
// shell
|
|
223
|
-
//
|
|
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.
|
|
229
|
+
// shell. We kill the whole group instead. stdout/stderr piped so we can read
|
|
230
|
+
// the bound port and surface failures.
|
|
227
231
|
const server = spawn(cmd, {
|
|
228
232
|
cwd: worktree,
|
|
229
233
|
shell: true,
|
|
@@ -231,21 +235,14 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
231
235
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
232
236
|
env,
|
|
233
237
|
});
|
|
234
|
-
|
|
238
|
+
|
|
239
|
+
let settled = false;
|
|
240
|
+
let tunnel = null;
|
|
241
|
+
let tunnelStarted = false;
|
|
235
242
|
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
243
|
const tail = () => out.trim().split('\n').slice(-15).join('\n');
|
|
242
|
-
const tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${port}`], {
|
|
243
|
-
detached: true,
|
|
244
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
245
|
-
});
|
|
246
|
-
let settled = false;
|
|
247
244
|
const killGroup = (child) => {
|
|
248
|
-
if (!child
|
|
245
|
+
if (!child?.pid) return;
|
|
249
246
|
try {
|
|
250
247
|
process.kill(-child.pid, 'SIGKILL'); // negative pid = the whole group
|
|
251
248
|
} catch {
|
|
@@ -260,24 +257,55 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
260
257
|
killGroup(server);
|
|
261
258
|
killGroup(tunnel);
|
|
262
259
|
};
|
|
260
|
+
let bindTimer;
|
|
261
|
+
let timer;
|
|
263
262
|
const finish = (val) => {
|
|
264
263
|
if (settled) return;
|
|
265
264
|
settled = true;
|
|
266
265
|
clearTimeout(timer);
|
|
266
|
+
clearTimeout(bindTimer);
|
|
267
267
|
if (!val) stop();
|
|
268
268
|
resolve(val);
|
|
269
269
|
};
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
|
|
271
|
+
// Open the tunnel once we know the real port (detected or fallback).
|
|
272
|
+
const openTunnel = (p) => {
|
|
273
|
+
if (tunnelStarted || settled) return;
|
|
274
|
+
tunnelStarted = true;
|
|
275
|
+
clearTimeout(bindTimer);
|
|
276
|
+
log?.(`preview: dev server on :${p} — opening the tunnel…`);
|
|
277
|
+
// --http-host-header localhost: send the origin the Host it expects. Vite
|
|
278
|
+
// (5+) rejects any Host it doesn't recognize (server.allowedHosts), and the
|
|
279
|
+
// tunnel's public hostname isn't in that list → "Blocked request". Rewriting
|
|
280
|
+
// the Host to localhost — what a local browser sends anyway — passes the
|
|
281
|
+
// check with zero repo config, and is harmless to servers that don't check.
|
|
282
|
+
tunnel = spawn(
|
|
283
|
+
cf,
|
|
284
|
+
['tunnel', '--url', `http://localhost:${p}`, '--http-host-header', 'localhost'],
|
|
285
|
+
{ detached: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
286
|
+
);
|
|
287
|
+
const onTunnel = (d) => {
|
|
288
|
+
const m = TUNNEL_RE.exec(d.toString());
|
|
289
|
+
if (m) finish({ url: m[0], kind, stop });
|
|
290
|
+
};
|
|
291
|
+
tunnel.stdout.on('data', onTunnel);
|
|
292
|
+
tunnel.stderr.on('data', onTunnel);
|
|
293
|
+
tunnel.on('error', () => finish(null));
|
|
294
|
+
tunnel.on('close', () => finish(null));
|
|
273
295
|
};
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
296
|
+
|
|
297
|
+
const onServer = (d) => {
|
|
298
|
+
const s = d.toString();
|
|
299
|
+
out = (out + s).slice(-4000);
|
|
300
|
+
if (!tunnelStarted) {
|
|
301
|
+
const m = BIND_RE.exec(s);
|
|
302
|
+
if (m) openTunnel(Number(m[1]));
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
server.stdout.on('data', onServer);
|
|
306
|
+
server.stderr.on('data', onServer);
|
|
307
|
+
// A dev server that exits before it's reachable (crash on boot, a singleton
|
|
308
|
+
// lock refusing to start) is the loud failure mode — surface its output.
|
|
281
309
|
server.on('exit', (code) => {
|
|
282
310
|
if (settled) return;
|
|
283
311
|
log?.(
|
|
@@ -287,7 +315,11 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
287
315
|
);
|
|
288
316
|
finish(null);
|
|
289
317
|
});
|
|
290
|
-
|
|
318
|
+
|
|
319
|
+
// If the server never prints a URL we recognize (quiet server), tunnel to the
|
|
320
|
+
// configured port as a last resort.
|
|
321
|
+
bindTimer = setTimeout(() => openTunnel(port), 30_000);
|
|
322
|
+
timer = setTimeout(() => {
|
|
291
323
|
log?.(
|
|
292
324
|
`preview tunnel did not come up in ${Math.round(timeoutMs / 1000)}s — skipping.${
|
|
293
325
|
tail() ? `\n last dev-server output:\n${tail()}` : ''
|
package/bin/lib/update.mjs
CHANGED
|
@@ -110,8 +110,28 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
|
|
|
110
110
|
}
|
|
111
111
|
return false;
|
|
112
112
|
}
|
|
113
|
+
// Loop guard: the server can announce a version before it's published. npm is
|
|
114
|
+
// the source of truth — only install if npm ACTUALLY has something newer than
|
|
115
|
+
// us, else `npm i -g @latest` reinstalls our own version and we'd re-exec
|
|
116
|
+
// forever.
|
|
117
|
+
let published = null;
|
|
113
118
|
try {
|
|
114
|
-
|
|
119
|
+
published = execFileSync('npm', ['view', 'flowviant', 'version'], {
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
122
|
+
}).trim();
|
|
123
|
+
} catch {
|
|
124
|
+
/* offline / npm hiccup — treat as "can't confirm", skip this poll */
|
|
125
|
+
}
|
|
126
|
+
if (!published || cmpVersion(published, cur) <= 0) {
|
|
127
|
+
if (naggedFor !== target) {
|
|
128
|
+
naggedFor = target;
|
|
129
|
+
note(`update ${target} announced but npm still serves ${published ?? '?'} — waiting for the publish.`);
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
note(`flowviant ${cur} → ${published}: self-updating…`);
|
|
115
135
|
installLatest();
|
|
116
136
|
ok('updated — restarting into the new version.');
|
|
117
137
|
reexec(teardown);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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": {
|