flowviant 0.94.0 → 0.96.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 +22 -0
- package/bin/cli.mjs +149 -42
- package/bin/lib/credentials.mjs +94 -0
- package/bin/lib/instance.mjs +98 -65
- package/bin/lib/login.mjs +20 -2
- package/bin/lib/machines.mjs +216 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,8 +41,30 @@ Prefer an explicit token? Create a machine credential in the app and pass it dir
|
|
|
41
41
|
FLOWVIANT_MACHINE_TOKEN=fva_… npx flowviant@latest # FLOWVIANT_FLEET still works
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Prefer a plain command to `npx`? Install it once and every `npx flowviant@latest` in this README is just `flowviant`:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm i -g flowviant
|
|
48
|
+
flowviant login
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Either way is fine and both stay current on their own (below). npx is the zero-install door; a global install is what you want on a box you keep running, so the daemon, `flowviant login` and `flowviant machines` are ordinary commands.
|
|
52
|
+
|
|
44
53
|
Launch with `@latest` so each start pulls the newest published version — a bare `npx flowviant` can reuse a stale cache. A running daemon also keeps itself current: from **0.58.0** it restarts itself through `npx flowviant@latest` when a new version ships, so npx launches stay up to date the same way a global install does. (Before 0.58.0 that was only true of a global install — under npx the daemon printed a notice and stayed put, which is how machines ended up sitting several releases back.) It only ever restarts when no turn is running. `FLOWVIANT_NO_UPDATE=1` makes it nag-only; `flowviant update` updates now.
|
|
45
54
|
|
|
55
|
+
## Machines on this box
|
|
56
|
+
|
|
57
|
+
One box can serve several projects — one daemon per repository directory, each connected with its own `flowviant login` run inside that repo. `flowviant machines` lists every project connected on this box with the id and date it was connected, and under each one every computer that has polled it (the app's Home lists the same across your whole account). Two projects bound to one repository are named out loud at the foot of the listing, because that is the one shape that reads as "the same project connected twice" and is not — and a directory serves one project, so `npx flowviant` there refuses to start until one of them goes.
|
|
58
|
+
|
|
59
|
+
On a terminal the listing is a menu: ↑/↓ over the projects, enter for what you can do about one, esc to leave. Two verbs, each acting on **this box's own connection** and nothing else:
|
|
60
|
+
|
|
61
|
+
- **disconnect this box from it** — stops the daemon serving that project here (that one credential's daemon, never the others), removes this box from the project's machines list in the app, and forgets the credential here. `flowviant machines --remove <project id>` is the same thing for a script.
|
|
62
|
+
- **forget it here only** — the credential is dropped from `~/.flowviant/credentials.json` and nothing else happens; the app keeps listing this box until it goes quiet. `--forget <project id>` for a script.
|
|
63
|
+
|
|
64
|
+
Stopping or removing a daemon on **another** computer is done in the app (project settings → Machines), where the person pressing it can see what they are changing.
|
|
65
|
+
|
|
66
|
+
**A directory serves one project.** `flowviant login` refuses to bind a second project to a repository that is already connected to one, and a store that already holds two projects for one repository refuses to start there — both name the projects and the remedy: delete the project you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it with `flowviant machines`, then try again.
|
|
67
|
+
|
|
46
68
|
## Sessions
|
|
47
69
|
|
|
48
70
|
Each tab in the Workbench is a persistent Claude session with its own worktree, and it stays where you left it — the branch outlives the tab. The daemon runs each turn in event mode and relays what the CLI is printing (thinking, reads, greps, commands) back to the tab, reports the worktree's branch and diffstat after every turn, and fetches a commit's patch when you click a sha in the app.
|
package/bin/cli.mjs
CHANGED
|
@@ -41,9 +41,15 @@
|
|
|
41
41
|
* Ambiguity is a picker on a TTY and a worded refusal headless — never a
|
|
42
42
|
* guess. `flowviant projects` lists what is stored; `--project <name|id>`
|
|
43
43
|
* picks without a prompt. `flowviant machines` (0.91.0) asks the server which
|
|
44
|
-
* BOXES have polled each of those credentials — the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* BOXES have polled each of those credentials — the answer to "am I running
|
|
45
|
+
* duplicate or redundant daemons?", which the store alone cannot give because
|
|
46
|
+
* a daemon on another computer is invisible from here; since 0.95.0 it says
|
|
47
|
+
* out loud when two projects are bound to one repo, and on a terminal it is a
|
|
48
|
+
* menu (↑/↓, enter) whose verbs disconnect THIS box from a project or forget
|
|
49
|
+
* a credential here — `--remove <id>` / `--forget <id>` for a script. A
|
|
50
|
+
* DIRECTORY SERVES ONE PROJECT: login refuses to bind a second project to a
|
|
51
|
+
* repo, and a store that already holds two for one repo refuses to start
|
|
52
|
+
* until one is deleted in the app or disconnected here.
|
|
47
53
|
*
|
|
48
54
|
* Env:
|
|
49
55
|
* FLOWVIANT_MACHINE_TOKEN the machine credential (or use `flowviant login`);
|
|
@@ -82,7 +88,11 @@ import { runLogin } from './lib/login.mjs';
|
|
|
82
88
|
// long-running process.
|
|
83
89
|
if (process.argv[2] === 'login') {
|
|
84
90
|
const noStart = process.argv.includes('--no-start');
|
|
85
|
-
await runLogin({ thenStart: !noStart });
|
|
91
|
+
const login = await runLogin({ thenStart: !noStart });
|
|
92
|
+
// A login the person CANCELLED at the second-project question saved nothing,
|
|
93
|
+
// so there is no credential for the child to serve — starting it would end
|
|
94
|
+
// in "no credential found" over a choice they just made on purpose.
|
|
95
|
+
if (!login?.saved) process.exit(1); // refused: the directory already serves another project
|
|
86
96
|
if (noStart) process.exit(0);
|
|
87
97
|
// Re-exec as a plain `flowviant` rather than falling through. config.mjs reads
|
|
88
98
|
// the credential at IMPORT time — which was before the login we just did — so
|
|
@@ -224,16 +234,19 @@ if (process.argv[2] === 'projects') {
|
|
|
224
234
|
}
|
|
225
235
|
|
|
226
236
|
// ── `flowviant machines` — every project connected on THIS box, and every box
|
|
227
|
-
// that has polled each of them.
|
|
237
|
+
// that has polled each of them; and, on a terminal, a menu over them.
|
|
228
238
|
//
|
|
229
239
|
// A THIRD COMMAND, and the owner's ruling it amends is his own: the terminal
|
|
230
240
|
// surface is `npx flowviant` and `npx flowviant login`, full stop — which is
|
|
231
241
|
// what deleted the old terminal flag for moving a machine — and he asked for
|
|
232
|
-
// this one directly ("the CLI gets a command to list connections").
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
242
|
+
// this one directly ("the CLI gets a command to list connections"). It shipped
|
|
243
|
+
// VIEW-ONLY, and on 2026-09-23 he asked for the rest: "it seems to be only a
|
|
244
|
+
// read cmd but I want to be able to interact with it and use arrow keys to
|
|
245
|
+
// select the machines or to remove them." So: the listing, then — only where a
|
|
246
|
+
// person can drive one — a menu. Its verbs act on THIS BOX'S OWN CONNECTIONS
|
|
247
|
+
// (stop the daemon here, leave the project's list in the app, forget the
|
|
248
|
+
// credential here) and never on another computer's daemon; machines.mjs
|
|
249
|
+
// carries the argument.
|
|
237
250
|
//
|
|
238
251
|
// The question is the owner's, verbatim: "is there a way to view ALL the
|
|
239
252
|
// connected flowviants? because im not sure if i have any duplicate or
|
|
@@ -250,14 +263,16 @@ if (process.argv[2] === 'projects') {
|
|
|
250
263
|
//
|
|
251
264
|
// EXIT 0 whatever it finds, including nothing: the listing is the answer the
|
|
252
265
|
// asker came for, and a non-zero code over "you have no projects connected"
|
|
253
|
-
// would make this command unusable in anything that checks one.
|
|
266
|
+
// would make this command unusable in anything that checks one. `--remove`
|
|
267
|
+
// is the one exit that can be 1, when the daemon it had to stop would not.
|
|
254
268
|
if (process.argv[2] === 'machines') {
|
|
255
269
|
const creds = await import('./lib/credentials.mjs');
|
|
256
270
|
const forgetAt = process.argv.indexOf('--forget');
|
|
257
271
|
if (forgetAt >= 0) {
|
|
258
|
-
// THE
|
|
259
|
-
// AMBIGUITY REFUSES rather than guessing — this deletes a
|
|
260
|
-
// two projects that look alike is the case that produced
|
|
272
|
+
// THE LOCAL-ONLY WRITE. Named explicitly, matched by full id or a ≥6
|
|
273
|
+
// prefix, and AMBIGUITY REFUSES rather than guessing — this deletes a
|
|
274
|
+
// credential, and two projects that look alike is the case that produced
|
|
275
|
+
// the command.
|
|
261
276
|
const res = creds.forgetStoredProject(process.argv[forgetAt + 1]);
|
|
262
277
|
if (res.error) {
|
|
263
278
|
console.error(`error: ${res.error}`);
|
|
@@ -269,16 +284,29 @@ if (process.argv[2] === 'machines') {
|
|
|
269
284
|
);
|
|
270
285
|
process.exit(0);
|
|
271
286
|
}
|
|
287
|
+
const { boxesUrlFrom, leaveUrlFrom, fetchBoxesFor, renderMachines, disconnectHere, realDisconnectDeps, MACHINES_FOOTER, MACHINES_FLAGS_FOOTER } =
|
|
288
|
+
await import('./lib/machines.mjs');
|
|
289
|
+
const { FLEET_URL } = await import('./lib/config.mjs');
|
|
290
|
+
const removeAt = process.argv.indexOf('--remove');
|
|
291
|
+
if (removeAt >= 0) {
|
|
292
|
+
// THE SCRIPTED DISCONNECT — the menu's first verb, named by id for a
|
|
293
|
+
// terminal nobody is sitting at. Same matcher and the same refusal of an
|
|
294
|
+
// ambiguous prefix as --forget: a name two projects share is exactly the
|
|
295
|
+
// input this command exists to untangle, and it must not pick one.
|
|
296
|
+
const m = creds.matchStoredProject(process.argv[removeAt + 1]);
|
|
297
|
+
if (m.error) {
|
|
298
|
+
console.error(`error: ${m.error}. \`flowviant machines\` lists what is stored.`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
const res = await disconnectHere(m.entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL) }));
|
|
302
|
+
process.exit(res.ok ? 0 : 1);
|
|
303
|
+
}
|
|
272
304
|
|
|
273
|
-
|
|
305
|
+
let entries = creds.listStoredProjects();
|
|
274
306
|
if (entries.length === 0) {
|
|
275
307
|
console.log('no projects connected on this machine yet — run `flowviant login` inside a repo.');
|
|
276
308
|
process.exit(0);
|
|
277
309
|
}
|
|
278
|
-
const { boxesUrlFrom, fetchBoxesFor, renderMachines, MACHINES_FOOTER } = await import(
|
|
279
|
-
'./lib/machines.mjs'
|
|
280
|
-
);
|
|
281
|
-
const { FLEET_URL } = await import('./lib/config.mjs');
|
|
282
310
|
// OUR OWN BOX ID, so the listing can mark "← this box".
|
|
283
311
|
//
|
|
284
312
|
// READ, NEVER CREATED. This called `ensureKeypair()` until 2026-09-19, which
|
|
@@ -299,16 +327,83 @@ if (process.argv[2] === 'machines') {
|
|
|
299
327
|
/* unreadable keypair — nothing is marked, and nothing is claimed */
|
|
300
328
|
}
|
|
301
329
|
const url = boxesUrlFrom(FLEET_URL);
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
330
|
+
const listing = async () => {
|
|
331
|
+
const results = {};
|
|
332
|
+
// SEQUENTIAL, not a fan-out: this is a handful of credentials on somebody's
|
|
333
|
+
// laptop, and a parallel burst against the API buys nothing a person
|
|
334
|
+
// waiting two seconds can perceive.
|
|
335
|
+
for (const e of entries) {
|
|
336
|
+
results[e.projectId] = await fetchBoxesFor(e, { url, envpub: me });
|
|
337
|
+
}
|
|
338
|
+
console.log('');
|
|
339
|
+
for (const line of renderMachines(entries, results)) console.log(line);
|
|
340
|
+
console.log('');
|
|
341
|
+
};
|
|
342
|
+
await listing();
|
|
343
|
+
|
|
344
|
+
// THE MENU, only where a person can drive one. `canPrompt()` is the same
|
|
345
|
+
// gate the start path keeps — a backgrounded job or a pipe gets the listing
|
|
346
|
+
// and the flags, never a prompt that stops the process — and `menuSupported`
|
|
347
|
+
// is whether ↑/↓ can be read at all. Without both this is the command it was.
|
|
348
|
+
const { canPrompt, menuSupported, selectMenu, askWithTimeout } = await import('./lib/tty.mjs');
|
|
349
|
+
if (!(canPrompt() && menuSupported())) {
|
|
350
|
+
for (const line of MACHINES_FOOTER) console.log(line);
|
|
351
|
+
for (const line of MACHINES_FLAGS_FOOTER) console.log(line);
|
|
352
|
+
process.exit(0);
|
|
353
|
+
}
|
|
354
|
+
const deps = await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL) });
|
|
355
|
+
for (;;) {
|
|
356
|
+
// A ROW PER PROJECT connected on this box — the things this box can act on.
|
|
357
|
+
// The other computers under each project are printed above for the
|
|
358
|
+
// duplicate to be visible, and are not rows here: stopping or removing
|
|
359
|
+
// THEM is the app's verb, and a menu row that answers "not from here" is
|
|
360
|
+
// a control wired to a refusal.
|
|
361
|
+
console.log(' projects connected on this box — enter one for what you can do about it:');
|
|
362
|
+
const rowLabel = (e) => creds.projectRowLabel(e, entries) + (e.repoRoot ? ` — ${e.repoRoot}` : ' — not tied to a repo');
|
|
363
|
+
const pick = await selectMenu({ options: [...entries.map(rowLabel), 'done'] });
|
|
364
|
+
if (pick.cancelled || pick.unsupported || pick.index === entries.length) break;
|
|
365
|
+
const picked = entries[pick.index];
|
|
366
|
+
const who = creds.projectRowLabel(picked, entries);
|
|
367
|
+
// TWO VERBS, each row saying its whole consequence — the app's ⋯ menu on a
|
|
368
|
+
// machine row says the same two things, and a menu whose rows are verbs
|
|
369
|
+
// with the outcome in a manual somewhere is one you have to be sure about
|
|
370
|
+
// before you can use it.
|
|
371
|
+
const action = await selectMenu({
|
|
372
|
+
options: [
|
|
373
|
+
`disconnect this box from ${who} — stops its daemon here, removes this box from its machines list in the app, forgets its credential here`,
|
|
374
|
+
`forget ${who} here only — nothing is stopped; the app keeps listing this box until it goes quiet`,
|
|
375
|
+
'back',
|
|
376
|
+
],
|
|
377
|
+
});
|
|
378
|
+
if (action.cancelled || action.unsupported || action.index === 2) continue;
|
|
379
|
+
// ONE CONFIRM, in words, because both verbs delete a credential and the
|
|
380
|
+
// arrow keys make a slip cheap. A read that failed (stdin gone) is "no".
|
|
381
|
+
const verb = action.index === 0 ? 'Disconnect this box from' : 'Forget';
|
|
382
|
+
const answer = await askWithTimeout(` ${verb} ${who}? [y/N] `);
|
|
383
|
+
if (!/^y(es)?$/i.test(answer ?? '')) {
|
|
384
|
+
console.log(' left as it was.\n');
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (action.index === 0) {
|
|
388
|
+
await disconnectHere(picked, deps, { log: (m) => console.log(` ${m}`) });
|
|
389
|
+
} else {
|
|
390
|
+
const res = creds.forgetStoredProject(picked.projectId);
|
|
391
|
+
console.log(
|
|
392
|
+
res.error
|
|
393
|
+
? ` ${res.error}`
|
|
394
|
+
: ` forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box. Nothing was stopped or deleted anywhere else — \`flowviant login\` connects it again.`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
entries = creds.listStoredProjects();
|
|
398
|
+
if (entries.length === 0) {
|
|
399
|
+
console.log('\n no projects are connected on this box now.\n');
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
// THE LISTING AGAIN, re-asked: the server's rows are the only true account
|
|
403
|
+
// of what the leave did, and a menu redrawn over a stale listing would be
|
|
404
|
+
// this command asserting the outcome it hoped for.
|
|
405
|
+
await listing();
|
|
308
406
|
}
|
|
309
|
-
console.log('');
|
|
310
|
-
for (const line of renderMachines(entries, results)) console.log(line);
|
|
311
|
-
console.log('');
|
|
312
407
|
for (const line of MACHINES_FOOTER) console.log(line);
|
|
313
408
|
process.exit(0);
|
|
314
409
|
}
|
|
@@ -368,7 +463,8 @@ const externalToken =
|
|
|
368
463
|
* pattern: config.mjs read the store at IMPORT time, before the credential
|
|
369
464
|
* existed, so this process cannot serve; the child can. */
|
|
370
465
|
async function reexecAfterLogin() {
|
|
371
|
-
await runLogin({ thenStart: false });
|
|
466
|
+
const login = await runLogin({ thenStart: false });
|
|
467
|
+
if (!login?.saved) process.exit(1); // refused: the directory already serves another project
|
|
372
468
|
const { spawn } = await import('node:child_process');
|
|
373
469
|
const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
|
|
374
470
|
process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
|
|
@@ -378,7 +474,9 @@ async function reexecAfterLogin() {
|
|
|
378
474
|
// the connected date to the COLLIDING rows only — production held two projects
|
|
379
475
|
// both called "BRIF AI", both bound to the same checkout, so this picker
|
|
380
476
|
// offered two identical lines and the only way to answer was to guess. See
|
|
381
|
-
// credentials.mjs for why the suffix is not on every row.
|
|
477
|
+
// credentials.mjs for why the suffix is not on every row. (That exact case is
|
|
478
|
+
// a refusal now rather than a picker — a directory serves one project — but
|
|
479
|
+
// two same-named projects can still meet in the no-match picker.)
|
|
382
480
|
function listLines(entries, { projectRowLabel }) {
|
|
383
481
|
return entries
|
|
384
482
|
.map(
|
|
@@ -394,6 +492,20 @@ if (!FLEET_TOKEN) {
|
|
|
394
492
|
console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
|
|
395
493
|
process.exit(1);
|
|
396
494
|
}
|
|
495
|
+
// A DIRECTORY SERVES ONE PROJECT (2026-09-23, the owner, verbatim: "a
|
|
496
|
+
// directory cannot have more than one project on flowviant. if one already
|
|
497
|
+
// exists, it would warn the user and ask them to delete the project on
|
|
498
|
+
// flowviant first. because 2 projects shouldnt be able to edit a directory
|
|
499
|
+
// at the same time."). This used to be a PICKER — two projects bound to one
|
|
500
|
+
// checkout, choose which this daemon serves — which is a control offering to
|
|
501
|
+
// do the forbidden thing politely. It is a refusal now, on a TTY and
|
|
502
|
+
// headless alike, naming both projects and the remedy. `credentialRefusal`
|
|
503
|
+
// is the same sentence login prints when it declines to bind a second one.
|
|
504
|
+
if (CREDENTIAL.reason === 'multiple-bound') {
|
|
505
|
+
const { directoryTakenRefusal } = await import('./lib/credentials.mjs');
|
|
506
|
+
console.error(directoryTakenRefusal(CREDENTIAL.choices, CREDENTIAL.repoRoot));
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
397
509
|
if (CREDENTIAL.choices?.length && interactive) {
|
|
398
510
|
const creds = await import('./lib/credentials.mjs');
|
|
399
511
|
const { originSlug } = await import('./lib/git.mjs');
|
|
@@ -402,9 +514,7 @@ if (!FLEET_TOKEN) {
|
|
|
402
514
|
console.log(
|
|
403
515
|
CREDENTIAL.reason === 'outside-repo'
|
|
404
516
|
? 'flowviant is not inside a git repo, and more than one project is connected here.'
|
|
405
|
-
:
|
|
406
|
-
? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
|
|
407
|
-
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
517
|
+
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
408
518
|
);
|
|
409
519
|
|
|
410
520
|
const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`;
|
|
@@ -413,16 +523,13 @@ if (!FLEET_TOKEN) {
|
|
|
413
523
|
// this only decides which row the cursor starts on, using the repo's folder
|
|
414
524
|
// name and its github repo-name against the stored project names. A unique
|
|
415
525
|
// match becomes ONE keypress; a wrong guess costs nothing, because the human
|
|
416
|
-
// still confirms.
|
|
417
|
-
//
|
|
526
|
+
// still confirms. (Two projects bound to THIS repo never reach here: that
|
|
527
|
+
// is a refusal above, since 2026-09-23.)
|
|
418
528
|
const slug = repoRoot ? originSlug(repoRoot) : null;
|
|
419
|
-
const likely =
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
repoBasename: repoRoot ? basename(repoRoot) : null,
|
|
424
|
-
repoSlugName: slug ? slug.split('/')[1] : null,
|
|
425
|
-
});
|
|
529
|
+
const likely = creds.likelyChoiceIndex(choices, {
|
|
530
|
+
repoBasename: repoRoot ? basename(repoRoot) : null,
|
|
531
|
+
repoSlugName: slug ? slug.split('/')[1] : null,
|
|
532
|
+
});
|
|
426
533
|
|
|
427
534
|
// Bounded like the confirm below, and for the same reason — but silence
|
|
428
535
|
// means something DIFFERENT here and the difference is load-bearing. There
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -172,6 +172,97 @@ export function projectRowLabel(e, entries) {
|
|
|
172
172
|
return `${label} (id ${String(e?.projectId ?? '').slice(0, 8)}…${when})`;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* WHICH STORED PROJECTS SHARE ONE REPO — the duplicate the owner met and could
|
|
177
|
+
* not name (2026-09-23).
|
|
178
|
+
*
|
|
179
|
+
* `npx flowviant machines` printed "BRIF AI" twice, and he read it as two
|
|
180
|
+
* daemons or two "machine profiles" on one box for one project: *"when I go to
|
|
181
|
+
* BRIF AI to disconnect the duplicate, I only see one."* It was two PROJECTS
|
|
182
|
+
* with one name, both bound to one checkout — and the id-on-every-row rule
|
|
183
|
+
* (2026-09-19) had not made that legible, because two hex prefixes say
|
|
184
|
+
* "different" without saying "same repo". A project's Machines page shows its
|
|
185
|
+
* own credential's boxes, so neither page could show the other project at
|
|
186
|
+
* all.
|
|
187
|
+
*
|
|
188
|
+
* So the collision is stated as a FACT ABOUT THE REPO: every group of two or
|
|
189
|
+
* more entries bound to one directory, compared by realpath the way every
|
|
190
|
+
* other path here is. Pure over the entries; the listing prints each group as
|
|
191
|
+
* one line and the picker's row labels already carry the id and date.
|
|
192
|
+
*
|
|
193
|
+
* ONE REPO SERVES ONE PROJECT is the owner's own model ("i can have 1 daemon in
|
|
194
|
+
* one directory, 1 in another as a differentiator of projects"), which is why
|
|
195
|
+
* this is worth a sentence and an unbound duplicate is not: two projects with
|
|
196
|
+
* no repo are two things nothing has decided about yet.
|
|
197
|
+
*/
|
|
198
|
+
export function repoCollisions(entries) {
|
|
199
|
+
const groups = [];
|
|
200
|
+
for (const e of entries) {
|
|
201
|
+
if (!e?.repoRoot) continue;
|
|
202
|
+
const g = groups.find((x) => samePath(x.repoRoot, e.repoRoot));
|
|
203
|
+
if (g) g.entries.push(e);
|
|
204
|
+
else groups.push({ repoRoot: e.repoRoot, entries: [e] });
|
|
205
|
+
}
|
|
206
|
+
return groups.filter((g) => g.entries.length > 1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* THE OTHER PROJECTS ALREADY BOUND TO THIS REPO — what `flowviant login` asks
|
|
211
|
+
* about before it saves a second one (2026-09-23).
|
|
212
|
+
*
|
|
213
|
+
* The owner: *"im not sure how it even allowed me to run npx flowviant login
|
|
214
|
+
* twice and init a daemon twice on the same project/repository/directory in
|
|
215
|
+
* the first place."* Login binds the approved project to the repo it was run
|
|
216
|
+
* in and never looked at what was bound there already, so a second project —
|
|
217
|
+
* approved in a browser tab that happened to be open on a different project of
|
|
218
|
+
* the same name — landed beside the first without a word. The store's own
|
|
219
|
+
* resolution then turned every start into a picker between two identical
|
|
220
|
+
* rows.
|
|
221
|
+
*
|
|
222
|
+
* Excludes the project being logged into: re-running login for the SAME
|
|
223
|
+
* project is an upsert and asks nothing.
|
|
224
|
+
*/
|
|
225
|
+
export function boundElsewhere(entries, repoRoot, projectId) {
|
|
226
|
+
if (!repoRoot) return [];
|
|
227
|
+
return entries.filter((e) => e.projectId !== projectId && samePath(e.repoRoot, repoRoot));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* THE REFUSAL, in one place, because two commands print it (2026-09-23).
|
|
232
|
+
*
|
|
233
|
+
* The owner, verbatim: "a directory cannot have more than one project on
|
|
234
|
+
* flowviant. if one already exists, it would warn the user and ask them to
|
|
235
|
+
* delete the project on flowviant first. because 2 projects shouldnt be able
|
|
236
|
+
* to edit a directory at the same time." So `flowviant login` refuses to bind
|
|
237
|
+
* a second project to a repo, and a start in a repo the store already holds
|
|
238
|
+
* two projects for refuses to serve either — the picker that used to stand
|
|
239
|
+
* there was a control offering to do the forbidden thing politely.
|
|
240
|
+
*
|
|
241
|
+
* It names every project on the directory, the rule, and the remedy in the
|
|
242
|
+
* owner's own order: delete the project in Flowviant first. The disconnect
|
|
243
|
+
* this box can do on its own (`flowviant machines`) is named second, for the
|
|
244
|
+
* case where the project must survive somewhere else — it satisfies the same
|
|
245
|
+
* rule, because a project this box no longer serves cannot edit the directory
|
|
246
|
+
* from here.
|
|
247
|
+
*/
|
|
248
|
+
export function directoryTakenRefusal(bound, repoRoot, { incoming } = {}) {
|
|
249
|
+
// Every project in the sentence is labelled against the WHOLE set, the
|
|
250
|
+
// incoming one included: a refusal reading "already connected to BRIF AI,
|
|
251
|
+
// so BRIF AI was not connected" is the puzzle it exists to end.
|
|
252
|
+
const all = [...bound, ...(incoming ? [incoming] : [])];
|
|
253
|
+
const names = bound.map((e) => projectRowLabel(e, all));
|
|
254
|
+
const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0];
|
|
255
|
+
const head = incoming
|
|
256
|
+
? `${repoRoot} is already connected to ${list}, so ${projectRowLabel(incoming, all)} was not connected here.`
|
|
257
|
+
: `${repoRoot} is connected to ${list}.`;
|
|
258
|
+
return (
|
|
259
|
+
`${head}\n` +
|
|
260
|
+
'A directory serves one project — two projects must not edit it at the same time.\n' +
|
|
261
|
+
'Delete the project you do not mean in Flowviant first (project settings → General → Delete project),\n' +
|
|
262
|
+
'or disconnect this box from it with `flowviant machines`, then run this again.'
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
175
266
|
/** Collapse a name or slug for loose comparison: "My Project", "my-project"
|
|
176
267
|
* and "myproject" all become "myproject". */
|
|
177
268
|
function normalizeName(s) {
|
|
@@ -332,6 +423,9 @@ export function matchStoredProject(ref) {
|
|
|
332
423
|
* this returns. Shapes:
|
|
333
424
|
* { entry, source: 'project-flag' | 'repo' | 'only', needsConfirm? }
|
|
334
425
|
* { choices, reason: 'no-match' | 'multiple-bound' | 'outside-repo', repoRoot }
|
|
426
|
+
* (`multiple-bound` is a REFUSAL in cli.mjs since 2026-09-23 — a directory
|
|
427
|
+
* serves one project — and never a picker; the shape is kept because it
|
|
428
|
+
* names the projects the refusal has to print)
|
|
335
429
|
* { none: true }
|
|
336
430
|
* { error }
|
|
337
431
|
*
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -780,6 +780,101 @@ function makeRelease(path) {
|
|
|
780
780
|
return release;
|
|
781
781
|
}
|
|
782
782
|
|
|
783
|
+
/**
|
|
784
|
+
* ONE LOCK FILE, EXAMINED AND — IF IT NAMES A LIVE DAEMON WE CAN IDENTIFY —
|
|
785
|
+
* STOPPED. The per-file half of `stopAllDaemons`, split out (0.95.0) so that
|
|
786
|
+
* `stopDaemonFor` can run the identical ritual on ONE credential's lock: the
|
|
787
|
+
* `machines` command's disconnect stops the daemon serving the project being
|
|
788
|
+
* disconnected and must not touch the daemons serving anything else on the
|
|
789
|
+
* box, which a sweep of every lock cannot promise. Same tri-state, same
|
|
790
|
+
* sentences, same refusal to guess — there is one copy of this on purpose,
|
|
791
|
+
* for the reason `standDown` gives about SIGKILLs.
|
|
792
|
+
*
|
|
793
|
+
* `tally` is mutated in place: `running` counts locks naming a process we
|
|
794
|
+
* believe is, or might be, a daemon; the rest are what the caller reports.
|
|
795
|
+
*/
|
|
796
|
+
function stopLock(path, log, tally) {
|
|
797
|
+
const holder = readHolder(path);
|
|
798
|
+
if (!holder) return; // absent, truncated, half-written — no claim to answer
|
|
799
|
+
const where = holder.repoRoot ? ` in ${holder.repoRoot}` : '';
|
|
800
|
+
const what = holder.version ? ` ${holder.version}` : '';
|
|
801
|
+
|
|
802
|
+
if (!alive(holder.pid)) {
|
|
803
|
+
log(`pid ${holder.pid}${where} is already gone — nothing to stop.`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
// Us. standDown refuses this too, but reaching it would print a stand-down
|
|
807
|
+
// line and then a failure for the one pid we are certain is not a daemon.
|
|
808
|
+
if (holder.pid === process.pid) return;
|
|
809
|
+
|
|
810
|
+
const identified = stillTheHolder(holder);
|
|
811
|
+
if (identified === false) {
|
|
812
|
+
// A live pid, but measured NOT to be the process that wrote this lock. The
|
|
813
|
+
// daemon is gone; the number was handed to something else. Not counted as
|
|
814
|
+
// running, and deliberately not counted as a failure either.
|
|
815
|
+
log(`pid ${holder.pid} is no longer the daemon that took this lock — nothing signalled.`);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
tally.running++;
|
|
819
|
+
if (identified === null) {
|
|
820
|
+
tally.unconfirmed++;
|
|
821
|
+
tally.failed++;
|
|
822
|
+
// NAMES BOTH CAUSES, because null has two and we did not measure which:
|
|
823
|
+
// the lock may carry no witness to match against (neither `entry` nor
|
|
824
|
+
// `startedAt` — a hand-edited or half-written file), or this host may hide
|
|
825
|
+
// the process from us (hidepid=2, a pid namespace, an image with no `ps`).
|
|
826
|
+
// "no readable /proc" alone is what takeover says, and said HERE it would
|
|
827
|
+
// assert a diagnosis nobody established — over a live process we are about
|
|
828
|
+
// to tell someone to kill.
|
|
829
|
+
// `kill` is not a command on Windows, where platform() is 'win32' and
|
|
830
|
+
// processStartedAt has no implementation at all — so EVERY lock lands in
|
|
831
|
+
// this branch and the whole command is a no-op that exits 1. Say that
|
|
832
|
+
// once, in the platform's own vocabulary, rather than handing someone a
|
|
833
|
+
// remedy their shell does not have.
|
|
834
|
+
const byHand =
|
|
835
|
+
platform() === 'win32'
|
|
836
|
+
? `taskkill /PID ${holder.pid} /F`
|
|
837
|
+
: `kill ${holder.pid}`;
|
|
838
|
+
log(
|
|
839
|
+
`could not confirm that pid ${holder.pid} is still a flowviant daemon — its lock carries ` +
|
|
840
|
+
`nothing to match it against, or this host hides the process from us` +
|
|
841
|
+
`${platform() === 'win32' ? ' (identifying a process is not implemented on Windows)' : ''}` +
|
|
842
|
+
` — so it was NOT signalled. Stop it by hand: ${byHand}`
|
|
843
|
+
);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const bad = standDown(holder, path, log);
|
|
848
|
+
if (bad) {
|
|
849
|
+
tally.failed++;
|
|
850
|
+
log(`could not stop daemon pid ${holder.pid}${where}: ${bad.failed}`);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
tally.stopped++;
|
|
854
|
+
log(`stopped daemon${what} pid ${holder.pid}${where}.`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* STOP THE DAEMON SERVING ONE CREDENTIAL — `flowviant machines` disconnecting
|
|
859
|
+
* this box from one project (0.95.0).
|
|
860
|
+
*
|
|
861
|
+
* `stopAllDaemons` is the right shape for a person who does not know what is
|
|
862
|
+
* running; this is for a person who has just NAMED a project in a menu and
|
|
863
|
+
* expects exactly that project's daemon to stop. The lock is keyed on the
|
|
864
|
+
* credential (`instanceLockPath`), so the file to examine is known without a
|
|
865
|
+
* sweep, and a daemon serving a sibling project in another directory is never
|
|
866
|
+
* examined, let alone signalled.
|
|
867
|
+
*
|
|
868
|
+
* NO LOCK IS NOT AN ERROR: a project connected here but not currently running
|
|
869
|
+
* is the ordinary case for a disconnect, and the caller says "nothing was
|
|
870
|
+
* running" rather than failing. Returns the same tally `stopAllDaemons` does.
|
|
871
|
+
*/
|
|
872
|
+
export function stopDaemonFor(fleetToken, { log = (m) => console.log(m) } = {}) {
|
|
873
|
+
const tally = { stopped: 0, unconfirmed: 0, failed: 0, running: 0 };
|
|
874
|
+
stopLock(instanceLockPath(fleetToken), log, tally);
|
|
875
|
+
return tally;
|
|
876
|
+
}
|
|
877
|
+
|
|
783
878
|
/**
|
|
784
879
|
* STOP EVERY FLOWVIANT DAEMON ON THIS MACHINE — `flowviant stop`.
|
|
785
880
|
*
|
|
@@ -828,71 +923,9 @@ function makeRelease(path) {
|
|
|
828
923
|
* exit code.
|
|
829
924
|
*/
|
|
830
925
|
export function stopAllDaemons({ log = (m) => console.log(m) } = {}) {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
let running = 0; // locks naming a process we believe is, or might be, a daemon
|
|
835
|
-
|
|
836
|
-
for (const path of lockFiles()) {
|
|
837
|
-
const holder = readHolder(path);
|
|
838
|
-
if (!holder) continue; // absent, truncated, half-written — no claim to answer
|
|
839
|
-
const where = holder.repoRoot ? ` in ${holder.repoRoot}` : '';
|
|
840
|
-
const what = holder.version ? ` ${holder.version}` : '';
|
|
841
|
-
|
|
842
|
-
if (!alive(holder.pid)) {
|
|
843
|
-
log(`pid ${holder.pid}${where} is already gone — nothing to stop.`);
|
|
844
|
-
continue;
|
|
845
|
-
}
|
|
846
|
-
// Us. standDown refuses this too, but reaching it would print a stand-down
|
|
847
|
-
// line and then a failure for the one pid we are certain is not a daemon.
|
|
848
|
-
if (holder.pid === process.pid) continue;
|
|
849
|
-
|
|
850
|
-
const identified = stillTheHolder(holder);
|
|
851
|
-
if (identified === false) {
|
|
852
|
-
// A live pid, but measured NOT to be the process that wrote this lock. The
|
|
853
|
-
// daemon is gone; the number was handed to something else. Not counted as
|
|
854
|
-
// running, and deliberately not counted as a failure either.
|
|
855
|
-
log(`pid ${holder.pid} is no longer the daemon that took this lock — nothing signalled.`);
|
|
856
|
-
continue;
|
|
857
|
-
}
|
|
858
|
-
running++;
|
|
859
|
-
if (identified === null) {
|
|
860
|
-
unconfirmed++;
|
|
861
|
-
failed++;
|
|
862
|
-
// NAMES BOTH CAUSES, because null has two and we did not measure which:
|
|
863
|
-
// the lock may carry no witness to match against (neither `entry` nor
|
|
864
|
-
// `startedAt` — a hand-edited or half-written file), or this host may hide
|
|
865
|
-
// the process from us (hidepid=2, a pid namespace, an image with no `ps`).
|
|
866
|
-
// "no readable /proc" alone is what takeover says, and said HERE it would
|
|
867
|
-
// assert a diagnosis nobody established — over a live process we are about
|
|
868
|
-
// to tell someone to kill.
|
|
869
|
-
// `kill` is not a command on Windows, where platform() is 'win32' and
|
|
870
|
-
// processStartedAt has no implementation at all — so EVERY lock lands in
|
|
871
|
-
// this branch and the whole command is a no-op that exits 1. Say that
|
|
872
|
-
// once, in the platform's own vocabulary, rather than handing someone a
|
|
873
|
-
// remedy their shell does not have.
|
|
874
|
-
const byHand =
|
|
875
|
-
platform() === 'win32'
|
|
876
|
-
? `taskkill /PID ${holder.pid} /F`
|
|
877
|
-
: `kill ${holder.pid}`;
|
|
878
|
-
log(
|
|
879
|
-
`could not confirm that pid ${holder.pid} is still a flowviant daemon — its lock carries ` +
|
|
880
|
-
`nothing to match it against, or this host hides the process from us` +
|
|
881
|
-
`${platform() === 'win32' ? ' (identifying a process is not implemented on Windows)' : ''}` +
|
|
882
|
-
` — so it was NOT signalled. Stop it by hand: ${byHand}`
|
|
883
|
-
);
|
|
884
|
-
continue;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
const bad = standDown(holder, path, log);
|
|
888
|
-
if (bad) {
|
|
889
|
-
failed++;
|
|
890
|
-
log(`could not stop daemon pid ${holder.pid}${where}: ${bad.failed}`);
|
|
891
|
-
continue;
|
|
892
|
-
}
|
|
893
|
-
stopped++;
|
|
894
|
-
log(`stopped daemon${what} pid ${holder.pid}${where}.`);
|
|
895
|
-
}
|
|
926
|
+
const tally = { stopped: 0, unconfirmed: 0, failed: 0, running: 0 };
|
|
927
|
+
for (const path of lockFiles()) stopLock(path, log, tally);
|
|
928
|
+
const { stopped, unconfirmed, failed, running } = tally;
|
|
896
929
|
|
|
897
930
|
// WHAT WE ACTUALLY MEASURED IS LOCKS, so that is what this says. The old
|
|
898
931
|
// sentence — "no flowviant daemon is running on this machine" — was asserted
|
package/bin/lib/login.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
|
|
10
|
-
import { saveLogin, detectRepoRoot, projectLabel } from './credentials.mjs';
|
|
10
|
+
import { saveLogin, detectRepoRoot, projectLabel, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
|
|
11
11
|
import { c, info, ok, warn, fail } from './ui.mjs';
|
|
12
12
|
import { sleep } from './claude.mjs';
|
|
13
13
|
|
|
@@ -73,6 +73,24 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
73
73
|
name: typeof poll.projectName === 'string' && poll.projectName ? poll.projectName : null,
|
|
74
74
|
repoRoot,
|
|
75
75
|
};
|
|
76
|
+
// A DIRECTORY SERVES ONE PROJECT (2026-09-23). The owner, verbatim: "a
|
|
77
|
+
// directory cannot have more than one project on flowviant. if one
|
|
78
|
+
// already exists, it would warn the user and ask them to delete the
|
|
79
|
+
// project on flowviant first. because 2 projects shouldnt be able to
|
|
80
|
+
// edit a directory at the same time." And his earlier question — "im not
|
|
81
|
+
// sure how it even allowed me to run npx flowviant login twice … on the
|
|
82
|
+
// same repository" — was answered by the fact that login never looked.
|
|
83
|
+
// It looks now, and REFUSES: the approval in the app minted or reused
|
|
84
|
+
// the project's credential either way, and a credential nobody holds is
|
|
85
|
+
// a row, not a machine. The refusal names both projects and the remedy
|
|
86
|
+
// (`directoryTakenRefusal`, shared with the daemon's own start).
|
|
87
|
+
const clash = boundElsewhere(listStoredProjects(), repoRoot, entry.projectId);
|
|
88
|
+
if (clash.length > 0) {
|
|
89
|
+
console.log('');
|
|
90
|
+
warn(directoryTakenRefusal(clash, repoRoot, { incoming: entry }));
|
|
91
|
+
console.log('');
|
|
92
|
+
return { saved: false };
|
|
93
|
+
}
|
|
76
94
|
saveLogin(entry);
|
|
77
95
|
ok(
|
|
78
96
|
`connected to ${c.bold(projectLabel(entry))}` +
|
|
@@ -86,7 +104,7 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
86
104
|
? `\n ${c.dim('starting your agent — leave this running')}\n`
|
|
87
105
|
: `\n Now run: ${c.bold('npx flowviant')}\n`
|
|
88
106
|
);
|
|
89
|
-
return;
|
|
107
|
+
return { saved: true };
|
|
90
108
|
}
|
|
91
109
|
if (poll.status === 'expired') {
|
|
92
110
|
warn('that code expired — run `flowviant login` again.');
|
package/bin/lib/machines.mjs
CHANGED
|
@@ -7,10 +7,30 @@
|
|
|
7
7
|
* The terminal surface is `npx flowviant` and `npx flowviant login`, full stop.
|
|
8
8
|
* That is the owner's own ruling and it is what deleted the terminal flag that
|
|
9
9
|
* used to move a project's machine. This amends it BY HIS OWN REQUEST — "the CLI gets a command to list connections" —
|
|
10
|
-
* and it
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* and it shipped in the narrowest way the ruling could survive: VIEW-ONLY,
|
|
11
|
+
* starting nothing, stopping nothing, moving nothing, the one write behind an
|
|
12
|
+
* explicit `--forget`.
|
|
13
|
+
*
|
|
14
|
+
* ── AND IT GREW VERBS, ON HIS SECOND REQUEST (2026-09-23, 0.95.0) ──
|
|
15
|
+
*
|
|
16
|
+
* Verbatim: *"when i run npx flowviant machines, it just exits out of cli, it
|
|
17
|
+
* seems to be only a read cmd but I want to be able to interact with it and
|
|
18
|
+
* use arrow keys to select the machines or to remove them"* — and, of the
|
|
19
|
+
* duplicate it showed him, *"there should be a cli to remove it as well as a
|
|
20
|
+
* ui on flowviant to remove it."* So on a terminal that can draw a menu the
|
|
21
|
+
* listing is followed by one: ↑/↓ over the projects connected on THIS box,
|
|
22
|
+
* enter for a project's two verbs, esc to leave. Piped, or on a terminal with
|
|
23
|
+
* no raw mode, it is exactly the listing it was, and `--remove <id>` /
|
|
24
|
+
* `--forget <id>` are the same two verbs for a script.
|
|
25
|
+
*
|
|
26
|
+
* THE VERBS ACT ON THIS BOX'S OWN CONNECTIONS AND NOTHING ELSE. "Disconnect"
|
|
27
|
+
* stops the daemon serving that project HERE (one credential's lock, never a
|
|
28
|
+
* sweep), tells the app this box has left that project's machines list, and
|
|
29
|
+
* forgets the credential here — three things this box is entitled to do about
|
|
30
|
+
* itself. It does not stop, remove or move a daemon on another computer: those
|
|
31
|
+
* rows are printed under each project so the duplicate is visible, and the
|
|
32
|
+
* footer still says where THOSE verbs live. Decisions about other machines
|
|
33
|
+
* stay in the app, which is the ruling this amends and not the one it breaks.
|
|
14
34
|
*
|
|
15
35
|
* The question it answers is the owner's, verbatim: "is there a way to view ALL
|
|
16
36
|
* the connected flowviants? because im not sure if i have any duplicate or
|
|
@@ -19,7 +39,12 @@
|
|
|
19
39
|
* both named "BRIF AI" bound to the same checkout — which is why `npx
|
|
20
40
|
* flowviant` there offered two identical picker rows. THE PROJECT ID IS
|
|
21
41
|
* THEREFORE ALWAYS PRINTED: it is the only thing that tells those two apart,
|
|
22
|
-
* and the whole listing is worth less without it.
|
|
42
|
+
* and the whole listing is worth less without it. **And since 0.95.0 the
|
|
43
|
+
* collision is SAID, not left to be noticed**: the id told him the rows were
|
|
44
|
+
* different projects, and he still read them as one project connected twice
|
|
45
|
+
* ("BRIF AI appears twice meaning I likely have 2 daemon or 'machine
|
|
46
|
+
* profiles' on my machine for the same BRIF AI project"). `renderCollisions`
|
|
47
|
+
* prints one line per repo that two projects are bound to, naming both.
|
|
23
48
|
*
|
|
24
49
|
* ── IT IS A RELAY ──
|
|
25
50
|
*
|
|
@@ -47,6 +72,7 @@
|
|
|
47
72
|
*/
|
|
48
73
|
|
|
49
74
|
import { USER_AGENT } from './config.mjs';
|
|
75
|
+
import { projectLabel, repoCollisions } from './credentials.mjs';
|
|
50
76
|
|
|
51
77
|
/** The boxes read, derived from the roster URL the way the diffstat post is —
|
|
52
78
|
* one place configures the API base and everything else is a suffix swap. */
|
|
@@ -54,6 +80,13 @@ export function boxesUrlFrom(fleetUrl) {
|
|
|
54
80
|
return String(fleetUrl).replace(/\/agents\/?$/, '/boxes');
|
|
55
81
|
}
|
|
56
82
|
|
|
83
|
+
/** Where this box tells the server it has left a project — the boxes read's
|
|
84
|
+
* own path with a verb on the end, so a server that has the read and not the
|
|
85
|
+
* verb answers 404 and the caller says "older server" rather than guessing. */
|
|
86
|
+
export function leaveUrlFrom(fleetUrl) {
|
|
87
|
+
return `${boxesUrlFrom(fleetUrl)}/leave`;
|
|
88
|
+
}
|
|
89
|
+
|
|
57
90
|
/** The one answer `agoFrom` gives that is a PHRASE rather than a duration, so
|
|
58
91
|
* it is the one answer that must not take an " ago" after it. Named because
|
|
59
92
|
* two callers have to agree about it and a bare string literal is how they
|
|
@@ -224,9 +257,45 @@ export function renderMachines(entries, results, { now = Date.now() } = {}) {
|
|
|
224
257
|
}
|
|
225
258
|
for (const b of boxes) lines.push(renderBox(b, { me: r.me, latest: r.latest }, now));
|
|
226
259
|
}
|
|
260
|
+
const collisions = renderCollisions(entries);
|
|
261
|
+
if (collisions.length) lines.push('', ...collisions);
|
|
227
262
|
return lines;
|
|
228
263
|
}
|
|
229
264
|
|
|
265
|
+
/** A project as ONE phrase for a sentence that names two of them — the name,
|
|
266
|
+
* the id, and the connected date, which is the fact a person can match
|
|
267
|
+
* against their own memory ("I set that one up last month"). */
|
|
268
|
+
export function projectPhrase(e) {
|
|
269
|
+
const connected = connectedOn(e?.savedAt);
|
|
270
|
+
return `${projectLabel(e)} (${String(e?.projectId ?? '').slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* TWO PROJECTS ON ONE REPO, SAID OUT LOUD (2026-09-23).
|
|
275
|
+
*
|
|
276
|
+
* The owner ran this command, saw "BRIF AI" twice, and concluded he had two
|
|
277
|
+
* daemons for one project. The id on each row said they were different
|
|
278
|
+
* projects; nothing said they were bound to the SAME checkout, which is the
|
|
279
|
+
* fact that makes it a duplicate rather than two rows. So the listing ends
|
|
280
|
+
* with one line per such repo, naming every project on it in full, the rule
|
|
281
|
+
* (a directory serves one project — the owner's 2026-09-23 ruling, which made
|
|
282
|
+
* the start there a refusal), and the remedy in his order: delete the project
|
|
283
|
+
* in Flowviant, or disconnect this box from it (the menu below, or the flags).
|
|
284
|
+
*
|
|
285
|
+
* Pure over the entries. An unbound project collides with nothing.
|
|
286
|
+
*/
|
|
287
|
+
export function renderCollisions(entries) {
|
|
288
|
+
return repoCollisions(entries).map((g) => {
|
|
289
|
+
const names = g.entries.map(projectPhrase);
|
|
290
|
+
const list = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
291
|
+
return (
|
|
292
|
+
` ! ${g.entries.length} projects are connected for ${g.repoRoot}: ${list} — ` +
|
|
293
|
+
'a directory serves one project, and `npx flowviant` there refuses to start until it does. ' +
|
|
294
|
+
'Delete the one you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it.'
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
230
299
|
/**
|
|
231
300
|
* THE FOOTER. Two sentences, both of which exist because this listing is the
|
|
232
301
|
* moment somebody is confused — the same reason `flowviant projects` names its
|
|
@@ -245,6 +314,15 @@ export const MACHINES_FOOTER = [
|
|
|
245
314
|
' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
|
|
246
315
|
];
|
|
247
316
|
|
|
317
|
+
/** THE SCRIPT'S VERBS, said only when there was no menu to offer them in — a
|
|
318
|
+
* terminal that could draw the menu was offered the same two things by name,
|
|
319
|
+
* and repeating the flags under it would be a manual under a control. */
|
|
320
|
+
export const MACHINES_FLAGS_FOOTER = [
|
|
321
|
+
' `flowviant machines --remove <id>` disconnects this box from a project: stops its daemon here,',
|
|
322
|
+
' removes this box from that project’s machines list in the app, and forgets its credential here.',
|
|
323
|
+
' `--forget <id>` forgets the credential here only.',
|
|
324
|
+
];
|
|
325
|
+
|
|
248
326
|
/**
|
|
249
327
|
* Ask one credential for its boxes. Never throws: every outcome is a shape the
|
|
250
328
|
* renderer knows, because one dead project must not end the listing.
|
|
@@ -294,3 +372,136 @@ export function otherBoxesLine(boxes, me, now = Date.now()) {
|
|
|
294
372
|
const more = others.length - parts.length;
|
|
295
373
|
return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
|
|
296
374
|
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* TELL THE SERVER THIS BOX HAS LEFT ONE PROJECT — `POST /fleet/boxes/leave`
|
|
378
|
+
* (0.95.0). Never throws; every outcome is a shape the caller can say in words.
|
|
379
|
+
*
|
|
380
|
+
* It is THIS box removing ITSELF: the body carries our own `envpub`, the same
|
|
381
|
+
* label every poll carries, and the server deletes our registry row for that
|
|
382
|
+
* credential outright — no stand-down, because the daemon that would take one
|
|
383
|
+
* has just been stopped by the same hand, and a stand-down waits on a poll
|
|
384
|
+
* that is not coming. If we were the box serving the project, the server says
|
|
385
|
+
* so (`wasHolder`) and the project has no machine until another one polls.
|
|
386
|
+
*
|
|
387
|
+
* A 404 is an OLDER SERVER (the read exists, the verb does not) and is kept
|
|
388
|
+
* apart from a 401/403, which is a credential the app has already killed: the
|
|
389
|
+
* first means "the row goes stale on its own, or remove it in the app", the
|
|
390
|
+
* second means "there was nothing to leave". Collapsing them is how a working
|
|
391
|
+
* credential gets described as dead.
|
|
392
|
+
*/
|
|
393
|
+
export async function leaveBoxFor(entry, { url, envpub, fetchImpl = fetch } = {}) {
|
|
394
|
+
if (!envpub) return { skipped: true };
|
|
395
|
+
try {
|
|
396
|
+
const res = await fetchImpl(url, {
|
|
397
|
+
method: 'POST',
|
|
398
|
+
headers: {
|
|
399
|
+
Authorization: `Bearer ${entry.fleetToken}`,
|
|
400
|
+
'User-Agent': USER_AGENT,
|
|
401
|
+
'Content-Type': 'application/json',
|
|
402
|
+
},
|
|
403
|
+
body: JSON.stringify({ envpub }),
|
|
404
|
+
signal: AbortSignal.timeout(15_000),
|
|
405
|
+
});
|
|
406
|
+
if (res.status === 401 || res.status === 403) return { rejected: true };
|
|
407
|
+
if (res.status === 404) return { unsupported: true };
|
|
408
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
409
|
+
const body = await res.json();
|
|
410
|
+
const data = body?.data;
|
|
411
|
+
if (!data || typeof data.removed !== 'boolean') return { error: 'unexpected answer shape' };
|
|
412
|
+
return { removed: data.removed, wasHolder: Boolean(data.wasHolder) };
|
|
413
|
+
} catch (e) {
|
|
414
|
+
return { error: String(e?.message ?? e) };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* DISCONNECT THIS BOX FROM ONE PROJECT — the three steps, in the only order
|
|
420
|
+
* that is safe, with every outcome said as it happens (0.95.0).
|
|
421
|
+
*
|
|
422
|
+
* 1. STOP the daemon serving that credential HERE. One lock, identified
|
|
423
|
+
* before it is signalled, through the same stand-down `flowviant stop`
|
|
424
|
+
* uses — never a sweep, because the person named ONE project. A daemon
|
|
425
|
+
* that is alive and could not be stopped ABORTS the disconnect: forgetting
|
|
426
|
+
* a credential under a running daemon changes nothing about the daemon
|
|
427
|
+
* (it holds the token in memory) and telling the server we have left while
|
|
428
|
+
* it keeps polling is a row that comes back in ten seconds wearing the
|
|
429
|
+
* name we just said was gone.
|
|
430
|
+
* 2. LEAVE in the app — see `leaveBoxFor`. Skipped, and said, when this box
|
|
431
|
+
* has no identity file: a box that never ran a daemon never polled, so
|
|
432
|
+
* the app has no row to remove.
|
|
433
|
+
* 3. FORGET the credential here. Last, so a failure in 1 or 2 leaves the
|
|
434
|
+
* store able to try again.
|
|
435
|
+
*
|
|
436
|
+
* `deps` is injected so the ORDER and the SENTENCES are provable without a
|
|
437
|
+
* daemon, a socket or a credential file; `realDisconnectDeps` builds the live
|
|
438
|
+
* set. Returns `{ ok }`, false only when step 1 refused.
|
|
439
|
+
*/
|
|
440
|
+
export async function disconnectHere(entry, deps, { log = (m) => console.log(m) } = {}) {
|
|
441
|
+
const who = projectPhrase(entry);
|
|
442
|
+
const tally = deps.stopDaemon(entry.fleetToken);
|
|
443
|
+
if (tally.failed > 0) {
|
|
444
|
+
log(
|
|
445
|
+
`not disconnected from ${who}: a daemon for it is still running here and was not stopped. ` +
|
|
446
|
+
'Stop it first (the line above says how), then run this again.'
|
|
447
|
+
);
|
|
448
|
+
return { ok: false };
|
|
449
|
+
}
|
|
450
|
+
if (tally.running === 0) log(`no daemon for ${who} was running here.`);
|
|
451
|
+
|
|
452
|
+
const left = await deps.leave(entry);
|
|
453
|
+
if (left.skipped) {
|
|
454
|
+
log('this box has never run a daemon, so the app has no row of it to remove.');
|
|
455
|
+
} else if (left.rejected) {
|
|
456
|
+
log(`the app had already disconnected or deleted ${who} — nothing to leave.`);
|
|
457
|
+
} else if (left.unsupported) {
|
|
458
|
+
log(
|
|
459
|
+
'the app does not take a leave from a machine yet (older server) — its row goes quiet on its own, ' +
|
|
460
|
+
'or remove it in project settings → Machines.'
|
|
461
|
+
);
|
|
462
|
+
} else if (left.error) {
|
|
463
|
+
log(
|
|
464
|
+
`could not tell the app this box has left ${who} (${left.error}) — its row goes quiet on its own, ` +
|
|
465
|
+
'or remove it in project settings → Machines.'
|
|
466
|
+
);
|
|
467
|
+
} else if (!left.removed) {
|
|
468
|
+
log(`the app was not listing this box on ${who}.`);
|
|
469
|
+
} else {
|
|
470
|
+
log(
|
|
471
|
+
`removed this box from ${who}’s machines list in the app.` +
|
|
472
|
+
(left.wasHolder ? ' It was the machine serving that project, which has none until another polls.' : '')
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const gone = deps.forget(entry.projectId);
|
|
477
|
+
if (gone.error) {
|
|
478
|
+
log(`could not forget the credential here: ${gone.error}`);
|
|
479
|
+
return { ok: false };
|
|
480
|
+
}
|
|
481
|
+
log(`forgot ${who}’s credential on this box. \`flowviant login\` in its repo connects it again.`);
|
|
482
|
+
return { ok: true };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* THE LIVE DEPENDENCIES for `disconnectHere`, built lazily: `instance.mjs`
|
|
487
|
+
* and `env.mjs` are imported here and not at the top so that rendering the
|
|
488
|
+
* listing — the piped, scripted, most common use — loads nothing that reads a
|
|
489
|
+
* lock directory or a keypair. `readStoredPubB64` READS the key and never
|
|
490
|
+
* mints one, the rule the `machines` branch in cli.mjs is pinned to.
|
|
491
|
+
*/
|
|
492
|
+
export async function realDisconnectDeps({ url, log = (m) => console.log(m) } = {}) {
|
|
493
|
+
const { stopDaemonFor } = await import('./instance.mjs');
|
|
494
|
+
const { forgetStoredProject } = await import('./credentials.mjs');
|
|
495
|
+
let envpub = null;
|
|
496
|
+
try {
|
|
497
|
+
const env = await import('./env.mjs');
|
|
498
|
+
envpub = env.readStoredPubB64();
|
|
499
|
+
} catch {
|
|
500
|
+
/* unreadable keypair — the leave is skipped and said */
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
stopDaemon: (fleetToken) => stopDaemonFor(fleetToken, { log: (m) => log(` ${m}`) }),
|
|
504
|
+
leave: (entry) => leaveBoxFor(entry, { url, envpub }),
|
|
505
|
+
forget: (projectId) => forgetStoredProject(projectId),
|
|
506
|
+
};
|
|
507
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.96.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {
|