flowviant 0.71.1 → 0.73.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/README.md +1 -1
- package/bin/cli.mjs +68 -15
- package/bin/lib/authproxy.mjs +154 -6
- package/bin/lib/credentials.mjs +34 -0
- package/bin/lib/fleet.mjs +18 -0
- package/bin/lib/listeners.mjs +34 -1
- package/bin/lib/preview.mjs +5 -1
- package/bin/lib/tty.mjs +123 -0
- package/bin/lib/work.mjs +60 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Nothing starts work except you opening a tab and typing in it.
|
|
|
51
51
|
|
|
52
52
|
## Sharing a preview
|
|
53
53
|
|
|
54
|
-
You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed) behind a **mandatory password gate**. Flowviant stores only the tunnel URL; your browser talks to it directly.
|
|
54
|
+
You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed) behind a **mandatory password gate**. Flowviant stores only the tunnel URL; your browser talks to it directly. Since 0.72.0 the gate also lets the Workbench embed the share in a frame: it rewrites the response's frame policy to permit exactly the app's origin, and a framed sign-in gets a partitioned cookie so it works where third-party cookies are blocked.
|
|
55
55
|
|
|
56
56
|
The daemon never executes anything the repository declares. An earlier version read a `.flowviant/preview.json` from the branch and spawned the command it named — that start path was removed in 0.53.0 and is not coming back; see the header of `bin/lib/preview.mjs` for exactly what it did, so nobody rebuilds it.
|
|
57
57
|
|
package/bin/cli.mjs
CHANGED
|
@@ -244,7 +244,7 @@ if (process.argv[2] === 'env') {
|
|
|
244
244
|
// two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
|
|
245
245
|
// kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
|
|
246
246
|
// stopped process runs no timers). See tty.mjs.
|
|
247
|
-
const { canPrompt, askWithTimeout } = await import('./lib/tty.mjs');
|
|
247
|
+
const { canPrompt, askWithTimeout, selectMenu, menuSupported } = await import('./lib/tty.mjs');
|
|
248
248
|
const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
|
|
249
249
|
|
|
250
250
|
/** How long the one-time binding confirm waits before serving unbound. A person
|
|
@@ -285,6 +285,8 @@ if (!FLEET_TOKEN) {
|
|
|
285
285
|
}
|
|
286
286
|
if (CREDENTIAL.choices?.length && interactive) {
|
|
287
287
|
const creds = await import('./lib/credentials.mjs');
|
|
288
|
+
const { originSlug } = await import('./lib/git.mjs');
|
|
289
|
+
const { basename } = await import('node:path');
|
|
288
290
|
const { choices, repoRoot } = CREDENTIAL;
|
|
289
291
|
console.log(
|
|
290
292
|
CREDENTIAL.reason === 'outside-repo'
|
|
@@ -293,27 +295,78 @@ if (!FLEET_TOKEN) {
|
|
|
293
295
|
? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
|
|
294
296
|
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
295
297
|
);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
|
|
299
|
+
const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`;
|
|
300
|
+
// WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
|
|
301
|
+
// refuses to serve a project the repo PATH did not name (the skadooble law);
|
|
302
|
+
// this only decides which row the cursor starts on, using the repo's folder
|
|
303
|
+
// name and its github repo-name against the stored project names. A unique
|
|
304
|
+
// match becomes ONE keypress; a wrong guess costs nothing, because the human
|
|
305
|
+
// still confirms. `multiple-bound` gets no hint — every choice already names
|
|
306
|
+
// this repo, so nothing distinguishes them.
|
|
307
|
+
const slug = repoRoot ? originSlug(repoRoot) : null;
|
|
308
|
+
const likely =
|
|
309
|
+
CREDENTIAL.reason === 'multiple-bound'
|
|
310
|
+
? -1
|
|
311
|
+
: creds.likelyChoiceIndex(choices, {
|
|
312
|
+
repoBasename: repoRoot ? basename(repoRoot) : null,
|
|
313
|
+
repoSlugName: slug ? slug.split('/')[1] : null,
|
|
314
|
+
});
|
|
315
|
+
|
|
298
316
|
// Bounded like the confirm below, and for the same reason — but silence
|
|
299
317
|
// means something DIFFERENT here and the difference is load-bearing. There
|
|
300
318
|
// is a real ambiguity to resolve; serving a guess is the skadooble bug.
|
|
301
319
|
// So no answer REFUSES, which is exactly what this branch already does
|
|
302
320
|
// headless, and the message says how to answer without being present.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
321
|
+
let chosen = null; // 0-based into [...choices, login]
|
|
322
|
+
if (menuSupported()) {
|
|
323
|
+
const rowLabel = (e) =>
|
|
324
|
+
creds.projectLabel(e) + (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
|
|
325
|
+
const options = [...choices.map(rowLabel), loginLabel];
|
|
326
|
+
// Say WHY the cursor starts where it does — "intuitive" made visible.
|
|
327
|
+
if (likely >= 0) options[likely] += ' ← looks like this repo';
|
|
328
|
+
const res = await selectMenu({
|
|
329
|
+
options,
|
|
330
|
+
defaultIndex: likely >= 0 ? likely : 0,
|
|
331
|
+
timeoutMs: PICK_TIMEOUT_MS,
|
|
332
|
+
});
|
|
333
|
+
if (res.timedOut) {
|
|
334
|
+
console.error(
|
|
335
|
+
`\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
|
|
336
|
+
`Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
|
|
337
|
+
);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
if (res.cancelled) {
|
|
341
|
+
console.error('nothing chosen — nothing started.');
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
if (!res.unsupported) chosen = res.index;
|
|
345
|
+
}
|
|
346
|
+
if (chosen === null) {
|
|
347
|
+
// Numeric fallback — a pipe, or a terminal without raw mode. Empty Enter
|
|
348
|
+
// takes the likely default when there is one, so it is one keystroke here
|
|
349
|
+
// too.
|
|
350
|
+
console.log(listLines(choices, creds));
|
|
351
|
+
console.log(` ${choices.length + 1}. ${loginLabel}`);
|
|
352
|
+
const hint = likely >= 0 ? ` (enter for ${creds.projectLabel(choices[likely])})` : '';
|
|
353
|
+
const raw = await askWithTimeout(
|
|
354
|
+
`Which project should this daemon serve? [1-${choices.length + 1}]${hint} `,
|
|
355
|
+
PICK_TIMEOUT_MS
|
|
311
356
|
);
|
|
312
|
-
|
|
357
|
+
if (raw === null) {
|
|
358
|
+
console.error(
|
|
359
|
+
`\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
|
|
360
|
+
`Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
|
|
361
|
+
);
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
const n = raw === '' && likely >= 0 ? likely + 1 : Number.parseInt(raw, 10);
|
|
365
|
+
chosen = Number.isInteger(n) ? n - 1 : -1;
|
|
313
366
|
}
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
const picked =
|
|
367
|
+
|
|
368
|
+
if (chosen === choices.length) await reexecAfterLogin();
|
|
369
|
+
const picked = chosen >= 0 ? choices[chosen] : undefined;
|
|
317
370
|
if (!picked) {
|
|
318
371
|
console.error('nothing chosen — nothing started.');
|
|
319
372
|
process.exit(1);
|
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -32,10 +32,45 @@
|
|
|
32
32
|
* - NEVER 302 A WEBSOCKET UPGRADE. Browsers do not follow 3xx on an upgrade,
|
|
33
33
|
* they fail the connection — HMR would break in a way that looks like a dead
|
|
34
34
|
* dev server. A cookie-less upgrade stays a 401.
|
|
35
|
-
* -
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
35
|
+
* - THE FRAME IS THE APP'S AND ONLY THE APP'S (0.72.0, and this reverses the
|
|
36
|
+
* old "cannot be embedded, the Workbench must not try" rule deliberately —
|
|
37
|
+
* the Workbench now DOES frame the share, so both halves are re-argued
|
|
38
|
+
* here). Two changes, one per blocker:
|
|
39
|
+
* (a) THE COOKIE. `SameSite=Lax` is kept for a top-level visit — it is the
|
|
40
|
+
* CSRF boundary and works in every browser ever shipped. A FRAMED
|
|
41
|
+
* navigation (`Sec-Fetch-Dest: iframe`) instead gets `SameSite=None;
|
|
42
|
+
* Partitioned` (CHIPS): partitioning keys the cookie to the top-level
|
|
43
|
+
* site that framed it, so evil.com framing this hostname gets its OWN
|
|
44
|
+
* empty jar, never the viewer's grant — which is why None here is not
|
|
45
|
+
* the CSRF hole it would be unpartitioned. Branching on Sec-Fetch-Dest
|
|
46
|
+
* rather than always sending both attributes matters: Safari 18.5–26.1
|
|
47
|
+
* DROPPED any Set-Cookie carrying `Partitioned` outright, so stamping it
|
|
48
|
+
* unconditionally would have broken the working top-level path on those
|
|
49
|
+
* builds. A browser too old to send Sec-Fetch-Dest gets Lax and the
|
|
50
|
+
* frame fails exactly as it always did — Open remains the escape.
|
|
51
|
+
* AND THE PARTITIONING CLAIM HAS A BACKSTOP, because one browser class
|
|
52
|
+
* breaks it: Chromium 76–113 (and same-vintage webviews/forks) sends
|
|
53
|
+
* Sec-Fetch-Dest — so it takes this branch — while ignoring the
|
|
54
|
+
* `Partitioned` attribute it has never heard of, storing a PLAIN
|
|
55
|
+
* unpartitioned SameSite=None cookie: the exact CSRF hole the sentence
|
|
56
|
+
* above says partitioning prevents. So `crossSiteAbuse` refuses a
|
|
57
|
+
* GRANT-authenticated request that Fetch Metadata marks cross-site
|
|
58
|
+
* unless it is a top-level GET/HEAD navigation (the Open link, the
|
|
59
|
+
* frame's own src). Every browser in the vulnerable class sends
|
|
60
|
+
* Sec-Fetch-Site (it shipped alongside Dest), a browser sending
|
|
61
|
+
* neither never got a None cookie in the first place, and the password
|
|
62
|
+
* path is exempt — Basic auth is never attached cross-site by a
|
|
63
|
+
* browser, so curl and Playwright feel nothing.
|
|
64
|
+
* (b) THE FRAME POLICY. In grant mode the forwarded response's frame policy
|
|
65
|
+
* is REWRITTEN to `frame-ancestors 'self' <app origin>` (origin taken
|
|
66
|
+
* from `authorizeUrl`, the one app fact this gate already holds). That
|
|
67
|
+
* permits exactly ONE cross-origin framer — the app the viewer is
|
|
68
|
+
* already authenticated to — instead of switching clickjacking
|
|
69
|
+
* protection off for everyone; enforced `frame-ancestors` outranks
|
|
70
|
+
* `X-Frame-Options` in every current engine, and the stripped XFO is
|
|
71
|
+
* the belt-and-braces for the rest. Password-only mode rewrites
|
|
72
|
+
* NOTHING: there is no app origin to allow and no cookie that could
|
|
73
|
+
* authenticate a frame, so the origin's own policy stands verbatim.
|
|
39
74
|
*
|
|
40
75
|
* Three things this file gets wrong easily, all of them fixed here and all of
|
|
41
76
|
* them worth keeping fixed:
|
|
@@ -155,6 +190,95 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
155
190
|
|
|
156
191
|
const authed = (req) => credential(req) === 'ok';
|
|
157
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The CSRF backstop for the partitioned cookie (header rule 4a): a browser
|
|
195
|
+
* old enough to ignore `Partitioned` while honouring `SameSite=None` will
|
|
196
|
+
* attach the grant to cross-site requests, so a request that FETCH METADATA
|
|
197
|
+
* says is cross-site is refused unless it is a plain navigation GET/HEAD —
|
|
198
|
+
* the two shapes this product legitimately serves cross-site (the Open
|
|
199
|
+
* link, the Workbench frame's own src; a rendered attacker frame is then
|
|
200
|
+
* stopped by the frame-ancestors rewrite). Applies ONLY to grant-cookie
|
|
201
|
+
* auth: a password in an Authorization header is never attached cross-site
|
|
202
|
+
* by a browser, and clients that send no Sec-Fetch headers never held a
|
|
203
|
+
* None cookie, so absence stays permitted.
|
|
204
|
+
*/
|
|
205
|
+
const crossSiteAbuse = (req) => {
|
|
206
|
+
if (String(req.headers['sec-fetch-site'] || '').toLowerCase() !== 'cross-site') return false;
|
|
207
|
+
const mode = String(req.headers['sec-fetch-mode'] || '').toLowerCase();
|
|
208
|
+
// Cross-site fetch/XHR/img/script with the cookie — nothing legitimate
|
|
209
|
+
// looks like this: the framed app's own subresources are same-origin.
|
|
210
|
+
if (mode && mode !== 'navigate') return true;
|
|
211
|
+
// A cross-site POST navigation is the classic auto-submitted CSRF form.
|
|
212
|
+
return !(req.method === 'GET' || req.method === 'HEAD');
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** True when THIS request authenticated with the password rather than the
|
|
216
|
+
* cookie — the credential class CSRF cannot ride. */
|
|
217
|
+
const viaPassword = (req) => sameSecret(req.headers['authorization'], expected);
|
|
218
|
+
|
|
219
|
+
/** The one app fact this gate holds: the origin allowed to frame us. Derived
|
|
220
|
+
* from `authorizeUrl` (server-built from PUBLIC_APP_URL) rather than a new
|
|
221
|
+
* wire field, so an older server changes nothing and no floor is needed. */
|
|
222
|
+
let appOrigin = null;
|
|
223
|
+
try {
|
|
224
|
+
if (grants) appOrigin = new URL(authorizeUrl).origin;
|
|
225
|
+
} catch {
|
|
226
|
+
appOrigin = null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Replace the origin's frame policy with ours: `frame-ancestors 'self'
|
|
231
|
+
* <app origin>`. Rules that must survive any edit:
|
|
232
|
+
* - REWRITE the directive inside an existing enforced CSP, never append a
|
|
233
|
+
* second policy beside it — multiple CSP headers intersect, so an origin
|
|
234
|
+
* `frame-ancestors 'none'` would still win over anything we added.
|
|
235
|
+
* - APPEND a policy holding only our directive when the origin stated no
|
|
236
|
+
* frame-ancestors at all — a public tunnel hostname deserves a frame
|
|
237
|
+
* policy even when localhost never needed one.
|
|
238
|
+
* - LEAVE Report-Only alone: browsers ignore frame-ancestors there, and
|
|
239
|
+
* rewriting a report channel would be editing the driver's telemetry.
|
|
240
|
+
* - DELETE X-Frame-Options: an enforced frame-ancestors makes every current
|
|
241
|
+
* engine ignore it anyway; deleting is for the stragglers.
|
|
242
|
+
*/
|
|
243
|
+
const framePolicy = () => `frame-ancestors 'self' ${appOrigin}`;
|
|
244
|
+
const rewriteFramePolicy = (headers) => {
|
|
245
|
+
if (!grants || !appOrigin) return headers;
|
|
246
|
+
const out = { ...headers };
|
|
247
|
+
delete out['x-frame-options'];
|
|
248
|
+
let sawDirective = false;
|
|
249
|
+
// Node joins duplicate response headers with ', ', so one string can hold
|
|
250
|
+
// SEVERAL policies (comma-separated), each holding several directives
|
|
251
|
+
// (semicolon-separated). Split on BOTH levels or replacing a directive
|
|
252
|
+
// eats the tail of a neighbouring policy — CSP source lists never contain
|
|
253
|
+
// a comma, so the outer split is safe.
|
|
254
|
+
const rewriteOne = (v) =>
|
|
255
|
+
String(v)
|
|
256
|
+
.split(',')
|
|
257
|
+
.map((policy) =>
|
|
258
|
+
policy
|
|
259
|
+
.split(';')
|
|
260
|
+
.map((part) => {
|
|
261
|
+
if (/^\s*frame-ancestors(\s|$)/i.test(part)) {
|
|
262
|
+
sawDirective = true;
|
|
263
|
+
return ` ${framePolicy()}`;
|
|
264
|
+
}
|
|
265
|
+
return part;
|
|
266
|
+
})
|
|
267
|
+
.join(';')
|
|
268
|
+
)
|
|
269
|
+
.join(',');
|
|
270
|
+
const csp = out['content-security-policy'];
|
|
271
|
+
if (csp !== undefined) {
|
|
272
|
+
out['content-security-policy'] = Array.isArray(csp) ? csp.map(rewriteOne) : rewriteOne(csp);
|
|
273
|
+
}
|
|
274
|
+
if (!sawDirective) {
|
|
275
|
+
const existing = out['content-security-policy'];
|
|
276
|
+
if (existing === undefined) out['content-security-policy'] = framePolicy();
|
|
277
|
+
else out['content-security-policy'] = Array.isArray(existing) ? [...existing, framePolicy()] : [existing, framePolicy()];
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
};
|
|
281
|
+
|
|
158
282
|
// The gate credential is OURS and stops here. Everything else is passed
|
|
159
283
|
// through untouched: the origin is the driver's own dev server and rewriting
|
|
160
284
|
// its request would be us editing their app's input.
|
|
@@ -234,12 +358,18 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
234
358
|
if (!r.ok) return challenge(res);
|
|
235
359
|
const to = safeRelative(u.searchParams.get('to'));
|
|
236
360
|
const maxAge = Math.max(0, r.payload.exp - Math.floor(Date.now() / 1000));
|
|
361
|
+
// FRAMED means Partitioned (header rule 4a). Sec-Fetch-Dest survives the
|
|
362
|
+
// whole redirect chain (it describes the navigation, not the hop), so the
|
|
363
|
+
// callback sees `iframe` exactly when the Workbench is the one asking.
|
|
364
|
+
const dest = String(req.headers['sec-fetch-dest'] || '').toLowerCase();
|
|
365
|
+
const framed = dest === 'iframe' || dest === 'frame' || dest === 'embed' || dest === 'object';
|
|
366
|
+
const site = framed ? 'SameSite=None; Partitioned' : 'SameSite=Lax';
|
|
237
367
|
// The immediate 302 to a clean path is MANDATORY, not cosmetic: it takes
|
|
238
368
|
// `?g=` out of the address bar, out of the Referer every subresource would
|
|
239
369
|
// carry, and out of browser history. It cannot take it out of cloudflared's
|
|
240
370
|
// access log — which is why the grant is short-lived and share-bound.
|
|
241
371
|
res.writeHead(302, {
|
|
242
|
-
'Set-Cookie': `${GRANT_COOKIE}=${r.raw}; Path=/; Secure; HttpOnly;
|
|
372
|
+
'Set-Cookie': `${GRANT_COOKIE}=${r.raw}; Path=/; Secure; HttpOnly; ${site}; Max-Age=${maxAge}`,
|
|
243
373
|
Location: to,
|
|
244
374
|
...noStore,
|
|
245
375
|
});
|
|
@@ -262,8 +392,18 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
262
392
|
if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
|
|
263
393
|
return challenge(res);
|
|
264
394
|
}
|
|
395
|
+
// 4. The grant is a cookie, and a cookie can be ridden (header rule 4a's
|
|
396
|
+
// backstop). Never a bounce — the caller HAS a credential; the request
|
|
397
|
+
// SHAPE is what is refused.
|
|
398
|
+
if (grants && crossSiteAbuse(req) && !viaPassword(req)) {
|
|
399
|
+
res.writeHead(403, { 'Content-Type': 'text/plain', ...noStore });
|
|
400
|
+
res.end('cross-site request refused');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
265
403
|
const proxyReq = request(forwardOpts(req), (proxyRes) => {
|
|
266
|
-
|
|
404
|
+
// The one RESPONSE rewrite this gate performs (header rule 4b): the
|
|
405
|
+
// frame policy. Everything else is the driver's own app talking.
|
|
406
|
+
res.writeHead(proxyRes.statusCode || 502, rewriteFramePolicy(proxyRes.headers));
|
|
267
407
|
proxyRes.pipe(res);
|
|
268
408
|
});
|
|
269
409
|
proxyReq.on('error', () => {
|
|
@@ -296,6 +436,14 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
296
436
|
socket.destroy();
|
|
297
437
|
return;
|
|
298
438
|
}
|
|
439
|
+
// The same cross-site backstop as the request path: a cookie-ridden
|
|
440
|
+
// cross-site handshake (Sec-Fetch-Mode: websocket) is refused; the framed
|
|
441
|
+
// app's own HMR socket is same-origin and never trips it.
|
|
442
|
+
if (grants && crossSiteAbuse(req) && !viaPassword(req)) {
|
|
443
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
444
|
+
socket.destroy();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
299
447
|
const proxyReq = request(forwardOpts(req));
|
|
300
448
|
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
301
449
|
const headerLines = Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`);
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -136,6 +136,40 @@ export function projectLabel(e) {
|
|
|
136
136
|
return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** Collapse a name or slug for loose comparison: "My Project", "my-project"
|
|
140
|
+
* and "myproject" all become "myproject". */
|
|
141
|
+
function normalizeName(s) {
|
|
142
|
+
return String(s ?? '')
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
.replace(/[^a-z0-9]/g, '');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* WHICH stored project most likely goes with this repo — a HINT for the
|
|
149
|
+
* picker's default, and DELIBERATELY not a licence to serve it. `resolveStored
|
|
150
|
+
* Credential` matches by repo PATH and refuses to guess past that, because
|
|
151
|
+
* serving a project the path did not name is the "it said skadooble in my
|
|
152
|
+
* calendar repo" surprise this whole file exists to prevent. A name that
|
|
153
|
+
* matches the repo's folder or its github repo-name is softer evidence — good
|
|
154
|
+
* enough to put the cursor on that row so the answer is one keypress, never
|
|
155
|
+
* good enough to skip the human. Two names can collide (a project "api" and a
|
|
156
|
+
* repo "api"), which is exactly why this only pre-selects.
|
|
157
|
+
*
|
|
158
|
+
* Returns the index of a UNIQUE match, or -1 when nothing matches or more than
|
|
159
|
+
* one does — an ambiguous hint is not a hint, and a default that is as likely
|
|
160
|
+
* wrong as right is worse than starting at the top.
|
|
161
|
+
*/
|
|
162
|
+
export function likelyChoiceIndex(choices, { repoBasename, repoSlugName } = {}) {
|
|
163
|
+
const wants = new Set([normalizeName(repoBasename), normalizeName(repoSlugName)].filter(Boolean));
|
|
164
|
+
if (wants.size === 0) return -1;
|
|
165
|
+
const hits = [];
|
|
166
|
+
choices.forEach((e, i) => {
|
|
167
|
+
const n = normalizeName(e?.name);
|
|
168
|
+
if (n && wants.has(n)) hits.push(i);
|
|
169
|
+
});
|
|
170
|
+
return hits.length === 1 ? hits[0] : -1;
|
|
171
|
+
}
|
|
172
|
+
|
|
139
173
|
function mutate(fn) {
|
|
140
174
|
const f = readFile() ?? {};
|
|
141
175
|
if (!f.projects || typeof f.projects !== 'object') f.projects = {};
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -831,6 +831,24 @@ export async function runFleetDaemon() {
|
|
|
831
831
|
getBaseRef,
|
|
832
832
|
getMcpUrl: () => mcpUrl,
|
|
833
833
|
getLeaseTtl: () => leaseTtlSeconds,
|
|
834
|
+
/**
|
|
835
|
+
* "THE REPO JUST CHANGED — look again."
|
|
836
|
+
*
|
|
837
|
+
* `maybeReportRepoState` is on its own 60s wall clock and nothing ever
|
|
838
|
+
* reset it, so every Flowviant action that alters the branch/worktree
|
|
839
|
+
* picture — a ship deleting the merged branch, retirement removing a
|
|
840
|
+
* directory and pruning, a tab being cut — left the rail's Repository block
|
|
841
|
+
* listing things that no longer exist for up to a minute. It is the same
|
|
842
|
+
* rule the diffstat just learned: an action that changes what the machine
|
|
843
|
+
* would measure must cause a new measurement.
|
|
844
|
+
*
|
|
845
|
+
* A callback rather than an export because work.mjs is imported BY this
|
|
846
|
+
* file, so it cannot import back. Clearing the timestamp is enough — the
|
|
847
|
+
* next reconcile does the scan, on the beat it already runs.
|
|
848
|
+
*/
|
|
849
|
+
onRepoChanged: () => {
|
|
850
|
+
repoStateScanAt = 0;
|
|
851
|
+
},
|
|
834
852
|
});
|
|
835
853
|
workShutdown = shutdownWork; // teardown can now reach the live session CLIs
|
|
836
854
|
|
package/bin/lib/listeners.mjs
CHANGED
|
@@ -58,6 +58,26 @@ import { platform } from 'node:os';
|
|
|
58
58
|
import { rssBytes } from './processes.mjs';
|
|
59
59
|
import { sep } from 'node:path';
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* INFRASTRUCTURE CHILDREN ARE NOT LISTENERS. cloudflared opens its own local
|
|
63
|
+
* metrics socket, and its cwd is inherited from the daemon — inside the
|
|
64
|
+
* checkout — so a live share put a `cloudflared` row in the repo place's list:
|
|
65
|
+
* Flowviant's own plumbing offered back to the operator as a thing to share or
|
|
66
|
+
* stop. Same rule as the `process.pid` exclusion below: a process that IS
|
|
67
|
+
* Flowviant is not a process Flowviant reports. `preview.mjs` registers each
|
|
68
|
+
* tunnel child here as it spawns it (that import direction already exists;
|
|
69
|
+
* the reverse would be a cycle). In-process is enough: a PEER daemon's tunnel
|
|
70
|
+
* lives in a different checkout, so cwd attribution already excludes it, and
|
|
71
|
+
* a crashed daemon's orphan is reaped at the next startup.
|
|
72
|
+
*/
|
|
73
|
+
const infraPids = new Set();
|
|
74
|
+
export const noteInfraPid = (pid) => {
|
|
75
|
+
if (Number.isInteger(pid) && pid > 0) infraPids.add(pid);
|
|
76
|
+
};
|
|
77
|
+
export const forgetInfraPid = (pid) => {
|
|
78
|
+
infraPids.delete(pid);
|
|
79
|
+
};
|
|
80
|
+
|
|
61
81
|
/** A box with more processes than this is not one we walk per sweep. The scan
|
|
62
82
|
* is one readlink per pid and runs every reconcile; this is the runaway
|
|
63
83
|
* bound, not a capacity statement. */
|
|
@@ -261,6 +281,15 @@ function scanLinux(worktree) {
|
|
|
261
281
|
continue; // not ours, or gone
|
|
262
282
|
}
|
|
263
283
|
if (!inside(cwd)) continue;
|
|
284
|
+
// NEVER THE DAEMON ITSELF. `startAuthProxy` binds a loopback port IN THIS
|
|
285
|
+
// PROCESS to gate a preview, and the daemon's own cwd is inside the
|
|
286
|
+
// checkout — so the gate was attributed to the repo place and surfaced as
|
|
287
|
+
// a listener the operator could click Stop on. Stopping it would take down
|
|
288
|
+
// the door in front of a live share, and it is not a thing anybody
|
|
289
|
+
// started: it is us. Same rule `processes.mjs` keeps by never reporting the
|
|
290
|
+
// group leader, for the same reason — a process that IS Flowviant is not a
|
|
291
|
+
// process Flowviant reports.
|
|
292
|
+
if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
|
|
264
293
|
|
|
265
294
|
let fds;
|
|
266
295
|
try {
|
|
@@ -334,8 +363,12 @@ function scanDarwin(worktree) {
|
|
|
334
363
|
} catch {
|
|
335
364
|
/* no memory on this box — rows simply carry no rss */
|
|
336
365
|
}
|
|
366
|
+
const self = String(process.pid);
|
|
337
367
|
for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
|
|
338
|
-
|
|
368
|
+
// The daemon's own preview gate is not something the driver started — see
|
|
369
|
+
// the linux branch for the whole argument. Nor its cloudflared children.
|
|
370
|
+
if (line.startsWith('p'))
|
|
371
|
+
pid = line.slice(1) === self || infraPids.has(Number(line.slice(1))) ? null : line.slice(1);
|
|
339
372
|
else if (line.startsWith('n') && pid) {
|
|
340
373
|
const m = /:(\d+)$/.exec(line.slice(1));
|
|
341
374
|
if (!m) continue;
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
import { join } from 'node:path';
|
|
47
47
|
import { homedir, platform, arch } from 'node:os';
|
|
48
48
|
import { startAuthProxy } from './authproxy.mjs';
|
|
49
|
-
import { isListening } from './listeners.mjs';
|
|
49
|
+
import { forgetInfraPid, isListening, noteInfraPid } from './listeners.mjs';
|
|
50
50
|
|
|
51
51
|
// ── cloudflared: pinned, verified, or not fetched at all ───────────────────
|
|
52
52
|
|
|
@@ -389,6 +389,7 @@ export async function openTunnel({
|
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
forgetPreviewPid(tunnel.pid);
|
|
392
|
+
forgetInfraPid(tunnel.pid);
|
|
392
393
|
}
|
|
393
394
|
};
|
|
394
395
|
|
|
@@ -428,6 +429,9 @@ export async function openTunnel({
|
|
|
428
429
|
// word would let a recycled pid land on an operator's own unrelated
|
|
429
430
|
// cloudflared and group-SIGKILL it.
|
|
430
431
|
recordPreviewPid(tunnel.pid, `--url http://localhost:${gate.port}`);
|
|
432
|
+
// Keep our own plumbing out of the listeners measurement (see listeners.mjs:
|
|
433
|
+
// cloudflared's metrics socket lives in the checkout's cwd).
|
|
434
|
+
noteInfraPid(tunnel.pid);
|
|
431
435
|
|
|
432
436
|
return new Promise((resolve) => {
|
|
433
437
|
let settled = false;
|
package/bin/lib/tty.mjs
CHANGED
|
@@ -109,3 +109,126 @@ export async function askWithTimeout(query, timeoutMs) {
|
|
|
109
109
|
process.off('SIGTTOU', noop);
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Can we draw an arrow-navigable menu? Raw mode is what turns ↑/↓ into
|
|
115
|
+
* keystrokes we receive one at a time; without `setRawMode` (a pipe, a
|
|
116
|
+
* terminal that refuses raw input) there is nothing to drive, and the caller
|
|
117
|
+
* falls back to the numeric prompt rather than drawing a menu nobody can move.
|
|
118
|
+
* `canPrompt()` still gates whether we ask AT ALL — this only decides HOW.
|
|
119
|
+
*/
|
|
120
|
+
export function menuSupported() {
|
|
121
|
+
return Boolean(
|
|
122
|
+
process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The menu's key handling, PURE so it can be tested without a terminal. Given a
|
|
128
|
+
* keypress and the current cursor, returns exactly one intent:
|
|
129
|
+
* { index } move the highlight (arrows wrap; k/j vim-style; g/G ends)
|
|
130
|
+
* { choose } take a row (Enter takes the highlight; 1–9 jump-and-take)
|
|
131
|
+
* { cancel } Esc, q, or Ctrl-C — nothing chosen
|
|
132
|
+
* null a key we ignore
|
|
133
|
+
* A number past the end is ignored, not clamped: pressing 9 in a 3-row list
|
|
134
|
+
* must not silently select row 3.
|
|
135
|
+
*/
|
|
136
|
+
export function menuKey(key, { index, count }) {
|
|
137
|
+
if (key === '\x03' || key === '\x1b' || key === 'q' || key === 'Q') return { cancel: true };
|
|
138
|
+
if (key === '\r' || key === '\n') return { choose: index };
|
|
139
|
+
if (key === '\x1b[A' || key === 'k') return { index: (index - 1 + count) % count };
|
|
140
|
+
if (key === '\x1b[B' || key === 'j') return { index: (index + 1) % count };
|
|
141
|
+
if (key === '\x1b[H' || key === 'g') return { index: 0 };
|
|
142
|
+
if (key === '\x1b[F' || key === 'G') return { index: count - 1 };
|
|
143
|
+
if (/^[1-9]$/.test(key)) {
|
|
144
|
+
const n = Number(key) - 1;
|
|
145
|
+
return n < count ? { choose: n } : null;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const MENU_FOOTER = '↑/↓ move · enter select · 1–9 jump · esc cancel';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* An arrow-navigable picker for the start path. Resolves one of:
|
|
154
|
+
* { index } the row taken
|
|
155
|
+
* { cancelled } Esc/q/Ctrl-C
|
|
156
|
+
* { timedOut } nobody drove it within timeoutMs
|
|
157
|
+
* { unsupported } no raw mode — the caller uses the numeric prompt instead
|
|
158
|
+
*
|
|
159
|
+
* Held to this file's one law — no start-path prompt may hang the daemon — the
|
|
160
|
+
* same way `askWithTimeout` is: it keeps the timeout, installs the
|
|
161
|
+
* SIGTTIN/SIGTTOU no-ops that keep that timer alive, restores the terminal in
|
|
162
|
+
* every exit, and refuses (returns `unsupported`) rather than half-drawing when
|
|
163
|
+
* raw mode is not really there. The caller still gates on `canPrompt()` before
|
|
164
|
+
* ever reaching here.
|
|
165
|
+
*/
|
|
166
|
+
export async function selectMenu({ options, defaultIndex = 0, timeoutMs }) {
|
|
167
|
+
if (!menuSupported()) return { unsupported: true };
|
|
168
|
+
const stdin = process.stdin;
|
|
169
|
+
const out = process.stdout;
|
|
170
|
+
const count = options.length;
|
|
171
|
+
if (count === 0) return { unsupported: true };
|
|
172
|
+
let index = Math.min(Math.max(defaultIndex | 0, 0), count - 1);
|
|
173
|
+
|
|
174
|
+
const width = Math.max(24, (out.columns || 80) - 2);
|
|
175
|
+
const clip = (s) => (s.length > width ? s.slice(0, width - 1) + '…' : s);
|
|
176
|
+
const frame = () =>
|
|
177
|
+
options
|
|
178
|
+
.map((o, i) => (i === index ? `\x1b[7m> ${clip(o)}\x1b[0m` : ` ${clip(o)}`))
|
|
179
|
+
.join('\n') +
|
|
180
|
+
'\n' +
|
|
181
|
+
`\x1b[2m ${MENU_FOOTER}\x1b[0m`;
|
|
182
|
+
const lineCount = count + 1; // rows + footer
|
|
183
|
+
|
|
184
|
+
let rendered = false;
|
|
185
|
+
const draw = () => {
|
|
186
|
+
if (rendered) out.write(`\x1b[${lineCount}A`); // back to the top of our block
|
|
187
|
+
out.write('\r\x1b[0J'); // clear from here to the end of the screen
|
|
188
|
+
out.write(frame() + '\n');
|
|
189
|
+
rendered = true;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const noop = () => {};
|
|
193
|
+
return await new Promise((resolve) => {
|
|
194
|
+
let done = false;
|
|
195
|
+
const finish = (result) => {
|
|
196
|
+
if (done) return;
|
|
197
|
+
done = true;
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
stdin.removeListener('data', onData);
|
|
200
|
+
try {
|
|
201
|
+
stdin.setRawMode(false);
|
|
202
|
+
} catch {
|
|
203
|
+
/* already restored / gone */
|
|
204
|
+
}
|
|
205
|
+
stdin.pause();
|
|
206
|
+
process.off('SIGTTIN', noop);
|
|
207
|
+
process.off('SIGTTOU', noop);
|
|
208
|
+
out.write('\x1b[?25h'); // show the cursor again
|
|
209
|
+
resolve(result);
|
|
210
|
+
};
|
|
211
|
+
const onData = (buf) => {
|
|
212
|
+
const action = menuKey(buf.toString('utf8'), { index, count });
|
|
213
|
+
if (!action) return;
|
|
214
|
+
if (action.cancel) return finish({ cancelled: true });
|
|
215
|
+
if (action.choose !== undefined) return finish({ index: action.choose });
|
|
216
|
+
if (action.index !== undefined) {
|
|
217
|
+
index = action.index;
|
|
218
|
+
draw();
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
process.on('SIGTTIN', noop);
|
|
222
|
+
process.on('SIGTTOU', noop);
|
|
223
|
+
const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs);
|
|
224
|
+
try {
|
|
225
|
+
stdin.setRawMode(true);
|
|
226
|
+
} catch {
|
|
227
|
+
return finish({ unsupported: true });
|
|
228
|
+
}
|
|
229
|
+
stdin.resume();
|
|
230
|
+
out.write('\x1b[?25l'); // hide the cursor while we own the block
|
|
231
|
+
draw();
|
|
232
|
+
stdin.on('data', onData);
|
|
233
|
+
});
|
|
234
|
+
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -107,7 +107,15 @@ function brainFor(job) {
|
|
|
107
107
|
return out;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
export function createWorkManager({
|
|
110
|
+
export function createWorkManager({
|
|
111
|
+
repoRoot,
|
|
112
|
+
baseDir,
|
|
113
|
+
getBaseRef,
|
|
114
|
+
getMcpUrl,
|
|
115
|
+
getLeaseTtl,
|
|
116
|
+
/** "The repo picture changed — look again." See the caller in fleet.mjs. */
|
|
117
|
+
onRepoChanged = () => {},
|
|
118
|
+
}) {
|
|
111
119
|
/**
|
|
112
120
|
* WHERE SHIP LANDS, read fresh every time rather than captured at startup.
|
|
113
121
|
*
|
|
@@ -319,6 +327,19 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
319
327
|
// The report has landed, so the idempotency path no longer needs the
|
|
320
328
|
// branch to exist. See `sweepMergedSessionBranch`.
|
|
321
329
|
sweepMergedSessionBranch(sessionId);
|
|
330
|
+
// AND THE DIFFSTAT IS NOW WRONG BY DEFINITION. A ship folds base in,
|
|
331
|
+
// merges the tip out and deletes the branch, so a fresh measurement reads
|
|
332
|
+
// `ahead: 0` with an empty diffstat — and without this the rail keeps
|
|
333
|
+
// rendering the ENTIRE pre-ship diff while the transcript two hundred
|
|
334
|
+
// pixels away says "Shipped to main". The ship button re-arms over a
|
|
335
|
+
// branch that is already merged. Same rule the kill path just learned:
|
|
336
|
+
// an action that changes what the machine would measure must cause a new
|
|
337
|
+
// measurement, and the 60s sweep is not that.
|
|
338
|
+
void reportPlaceWorktrees(sessionId).catch(() => {});
|
|
339
|
+
// …and the REPO picture changed too: the session branch is gone and base
|
|
340
|
+
// moved. Without this the Repository block keeps counting a branch the
|
|
341
|
+
// ship just deleted.
|
|
342
|
+
onRepoChanged();
|
|
322
343
|
}
|
|
323
344
|
return r;
|
|
324
345
|
};
|
|
@@ -591,11 +612,43 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
591
612
|
...(title ? { title } : {}),
|
|
592
613
|
};
|
|
593
614
|
};
|
|
594
|
-
/** One session, now
|
|
615
|
+
/** One session, now. */
|
|
595
616
|
const reportSessionWorktree = async (sessionId) => {
|
|
596
617
|
const r = sessionWorktreeReport(sessionId);
|
|
597
618
|
if (r) await postWorktrees([r]);
|
|
598
619
|
};
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* …AND EVERY OTHER TAB STANDING IN THE SAME DIRECTORY.
|
|
623
|
+
*
|
|
624
|
+
* One tab is not one directory any more. Since tabs moved into the driver's
|
|
625
|
+
* own folder, every tab a person owns resolves to the SAME place — so a turn
|
|
626
|
+
* in tab A changed the directory tab B is also describing, and only tab A was
|
|
627
|
+
* re-measured. Tab B went on rendering its pre-turn `+A −D` for up to a
|
|
628
|
+
* minute, which makes the tab strip visibly disagree with itself about one
|
|
629
|
+
* directory. That is the "why are two tabs showing one dev server" confusion
|
|
630
|
+
* the places readout exists to END, arriving through the diffstat instead.
|
|
631
|
+
*
|
|
632
|
+
* It fires on EVERY turn, every ship and every stop, which is what made this
|
|
633
|
+
* the most-hit instance of the rule and the least visible: nothing is wrong
|
|
634
|
+
* on the tab you are looking at.
|
|
635
|
+
*
|
|
636
|
+
* Bounded by the live set the roster last handed us, and the reports go in
|
|
637
|
+
* ONE post — the endpoint is already batched, and a tab per request would
|
|
638
|
+
* turn a five-tab place into five round trips on every settle.
|
|
639
|
+
*/
|
|
640
|
+
const reportPlaceWorktrees = async (sessionId) => {
|
|
641
|
+
const place = placeOf(sessionId);
|
|
642
|
+
const ids = [sessionId];
|
|
643
|
+
// `worktreeSeen` is the roster's own live set, pruned to `activeWorkSessions`
|
|
644
|
+
// on every sweep — so this can never report a tab that has closed, and it
|
|
645
|
+
// needs no second source of truth about which tabs exist.
|
|
646
|
+
for (const id of worktreeSeen) {
|
|
647
|
+
if (id !== sessionId && placeOf(id) === place) ids.push(id);
|
|
648
|
+
}
|
|
649
|
+
const reports = ids.map(sessionWorktreeReport).filter(Boolean);
|
|
650
|
+
if (reports.length) await postWorktrees(reports);
|
|
651
|
+
};
|
|
599
652
|
/** Every live session, throttled — called from the reconcile loop. */
|
|
600
653
|
/**
|
|
601
654
|
* A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
|
|
@@ -1154,7 +1207,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
1154
1207
|
*/
|
|
1155
1208
|
const remeasureAfterKill = async (sessionId) => {
|
|
1156
1209
|
try {
|
|
1157
|
-
await
|
|
1210
|
+
await reportPlaceWorktrees(sessionId);
|
|
1158
1211
|
} catch {
|
|
1159
1212
|
/* the 60s sweep still carries it — this only makes it prompt */
|
|
1160
1213
|
}
|
|
@@ -1444,6 +1497,9 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
1444
1497
|
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
1445
1498
|
} catch {
|
|
1446
1499
|
git(['worktree', 'prune'], repoRoot);
|
|
1500
|
+
// A directory and a branch just stopped existing. The Repository block
|
|
1501
|
+
// would otherwise keep listing both until its own 60s scan came round.
|
|
1502
|
+
onRepoChanged();
|
|
1447
1503
|
try {
|
|
1448
1504
|
// The branch may already exist (a retired directory's work) — attach.
|
|
1449
1505
|
git(['worktree', 'add', wt, branch], repoRoot);
|
|
@@ -2535,7 +2591,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
2535
2591
|
// written half a file, and the tab should show that honestly). NOT
|
|
2536
2592
|
// awaited: this runs inside the session's chain, and a slow POST
|
|
2537
2593
|
// would delay the next turn of that tab behind a readout.
|
|
2538
|
-
void
|
|
2594
|
+
void reportPlaceWorktrees(job.sessionId).catch(() => {});
|
|
2539
2595
|
}
|
|
2540
2596
|
});
|
|
2541
2597
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|