flowviant 0.99.0 → 0.101.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 +18 -12
- package/bin/cli.mjs +108 -25
- package/bin/lib/config.mjs +12 -8
- package/bin/lib/credentials.mjs +2 -1
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/fleet.mjs +22 -2
- package/bin/lib/instance.mjs +14 -0
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/login.mjs +34 -10
- package/bin/lib/machines.mjs +8 -7
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/tty.mjs +2 -2
- package/bin/lib/uninstall.mjs +286 -0
- package/bin/lib/update.mjs +116 -25
- package/bin/lib/workAgentTurns.mjs +4 -1
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Run your own coding CLIs as build agents for [Flowviant](https://flowviant.com) — [Claude Code](https://claude.com/claude-code), Codex or Antigravity, on your own credentials. This daemon holds your sessions, keeps a worktree per tab, and ships branches on your word. Flowviant never sees your Claude, Codex or GitHub logins.
|
|
4
4
|
|
|
5
|
-
```
|
|
6
|
-
|
|
5
|
+
```sh
|
|
6
|
+
curl -fsSL https://api.flowviant.com/install.sh | sh
|
|
7
|
+
flowviant # approve the code in Flowviant → connected
|
|
7
8
|
```
|
|
8
9
|
|
|
9
10
|
Login keeps going straight into the daemon — there is no second command to run.
|
|
@@ -21,40 +22,43 @@ Because it drives the CLIs you're already logged into, **the cost is yours** (yo
|
|
|
21
22
|
On the machine that runs the daemon:
|
|
22
23
|
|
|
23
24
|
- **at least one coding CLI** installed and signed in — [Claude Code](https://claude.com/claude-code) (`claude`), Codex (`codex`) or Antigravity (`agy`)
|
|
24
|
-
- **git
|
|
25
|
+
- **git** (Node 20+ is needed only for a Node-based install)
|
|
25
26
|
- **[GitHub CLI](https://cli.github.com)** (`gh`) — optional; the daemon offers to fetch an isolated copy, and `flowviant gh-auth` signs it in
|
|
26
27
|
- run it from inside the git repository you want worked
|
|
27
28
|
|
|
28
29
|
## Connecting
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
Download the standalone binary, then start it in your checkout:
|
|
31
32
|
|
|
32
|
-
```
|
|
33
|
-
|
|
33
|
+
```sh
|
|
34
|
+
curl -fsSL https://api.flowviant.com/install.sh | sh
|
|
35
|
+
flowviant
|
|
34
36
|
```
|
|
35
37
|
|
|
36
|
-
It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `
|
|
38
|
+
It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `flowviant` just runs. The installer prints a PATH hint if needed; `FLOWVIANT_INSTALL_DIR` changes its destination. It verifies SHA-256 and never uses sudo.
|
|
37
39
|
|
|
38
40
|
Prefer an explicit token? Create a machine credential in the app and pass it directly:
|
|
39
41
|
|
|
40
42
|
```bash
|
|
41
|
-
FLOWVIANT_MACHINE_TOKEN=fva_…
|
|
43
|
+
FLOWVIANT_MACHINE_TOKEN=fva_… flowviant # FLOWVIANT_FLEET still works
|
|
42
44
|
```
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
With Node already installed, a global install also gives you the same command:
|
|
45
47
|
|
|
46
48
|
```bash
|
|
47
49
|
npm i -g flowviant
|
|
48
50
|
flowviant login
|
|
49
51
|
```
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
The binary and global npm installs update when idle. The binary fetches the release manifest and verifies the new executable before replacing itself. The global install refreshes from npm before restarting. `flowviant update` checks on demand. `FLOWVIANT_NO_UPDATE=1` makes updates manual.
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
## Uninstall
|
|
56
|
+
|
|
57
|
+
`flowviant uninstall` stops local daemons and removes installed copies. It keeps logins and local state in `~/.flowviant`, so reinstalling resumes them. Use `flowviant uninstall --purge` to disconnect this box from stored projects and delete that local data too. Add `--yes` to skip confirmation.
|
|
54
58
|
|
|
55
59
|
## Machines on this box
|
|
56
60
|
|
|
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 `
|
|
61
|
+
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 `flowviant` there refuses to start until one of them goes.
|
|
58
62
|
|
|
59
63
|
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
64
|
|
|
@@ -128,6 +132,8 @@ The posture is reported on every poll and shown in the project's
|
|
|
128
132
|
Settings → Machine section, so the team can see whether the box runs the
|
|
129
133
|
guarded toolset or full permissions.
|
|
130
134
|
|
|
135
|
+
With Node: `npx flowviant@latest login` runs without a global install and updates by restarting through the package runner when idle.
|
|
136
|
+
|
|
131
137
|
## License
|
|
132
138
|
|
|
133
139
|
MIT — see [LICENSE](./LICENSE).
|
package/bin/cli.mjs
CHANGED
|
@@ -70,9 +70,31 @@
|
|
|
70
70
|
* env (the box keypair, the uplink scrubber, the env-comparison scan) + vault
|
|
71
71
|
* (the knowledge wiki, not secrets), resources, deploy, shot.
|
|
72
72
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
// Resolve --dir before config reads the project bound to cwd. A tray process
|
|
74
|
+
// starts outside the checkout; its explicit folder is the daemon's checkout.
|
|
75
|
+
const dirAt = process.argv.indexOf('--dir');
|
|
76
|
+
const noCheckoutCommand = new Set(['--version', '-v', 'version', 'status', 'stop', 'uninstall', 'machines', 'projects', 'update', 'shot', 'clean', 'gh-auth', 'mcp']);
|
|
77
|
+
if (dirAt >= 0 && !noCheckoutCommand.has(process.argv[2])) {
|
|
78
|
+
const dir = process.argv[dirAt + 1];
|
|
79
|
+
try {
|
|
80
|
+
if (!dir || dir.startsWith('--')) throw new Error('missing directory');
|
|
81
|
+
process.chdir(dir);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (process.argv.includes('--json') && process.argv[2] === 'login')
|
|
84
|
+
process.stdout.write(`${JSON.stringify({ event: 'error', message: `Cannot use directory ${dir ?? ''}: ${e.message}` })}\n`);
|
|
85
|
+
else console.error(`error: cannot use directory ${dir ?? ''}: ${e.message}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const { FLEET_TOKEN, CREDENTIAL, VERSION, adoptStoredCredential } = await import('./lib/config.mjs');
|
|
90
|
+
const { runFleetDaemon } = await import('./lib/fleet.mjs');
|
|
91
|
+
const { runLogin } = await import('./lib/login.mjs');
|
|
92
|
+
const { launchCommand, terminalCommand } = await import('./lib/launchCommand.mjs');
|
|
93
|
+
|
|
94
|
+
if (process.argv.includes('--remote') && !(process.argv[2] === 'status' && process.argv.includes('--json'))) {
|
|
95
|
+
console.error('error: --remote requires status --json');
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
76
98
|
|
|
77
99
|
// `flowviant login` — device auth (recommended): approve a code in the app, the
|
|
78
100
|
// credential is stored locally, and then we KEEP GOING into the daemon.
|
|
@@ -86,9 +108,17 @@ import { runLogin } from './lib/login.mjs';
|
|
|
86
108
|
//
|
|
87
109
|
// `--no-start` for scripts and CI, which want the credential and not a
|
|
88
110
|
// long-running process.
|
|
111
|
+
// `flowviant --version` prints and exits — the first thing a person runs after
|
|
112
|
+
// a curl install, and it must never start a daemon.
|
|
113
|
+
if (process.argv[2] === '--version' || process.argv[2] === '-v' || process.argv[2] === 'version') {
|
|
114
|
+
console.log(VERSION);
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
89
118
|
if (process.argv[2] === 'login') {
|
|
90
119
|
const noStart = process.argv.includes('--no-start');
|
|
91
|
-
const
|
|
120
|
+
const json = process.argv.includes('--json');
|
|
121
|
+
const login = await runLogin({ thenStart: !noStart, json, dir: process.cwd() });
|
|
92
122
|
// A login the person CANCELLED at the second-project question saved nothing,
|
|
93
123
|
// so there is no credential for the child to serve — starting it would end
|
|
94
124
|
// in "no credential found" over a choice they just made on purpose.
|
|
@@ -101,7 +131,8 @@ if (process.argv[2] === 'login') {
|
|
|
101
131
|
// re-exec: stay alive as a thin proxy so the user's shell keeps one foreground
|
|
102
132
|
// process.
|
|
103
133
|
const { spawn } = await import('node:child_process');
|
|
104
|
-
const
|
|
134
|
+
const startArgs = process.argv[1]?.startsWith('/$bunfs/') ? [] : [process.argv[1]];
|
|
135
|
+
const child = spawn(process.execPath, [...startArgs, ...(json ? ['--json-events'] : [])], {
|
|
105
136
|
stdio: 'inherit',
|
|
106
137
|
env: process.env,
|
|
107
138
|
});
|
|
@@ -111,11 +142,18 @@ if (process.argv[2] === 'login') {
|
|
|
111
142
|
process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
|
|
112
143
|
}
|
|
113
144
|
|
|
145
|
+
if (process.argv[2] === 'status' && process.argv.includes('--json')) {
|
|
146
|
+
const { desktopStatus, desktopStatusRemote } = await import('./lib/desktopContract.mjs');
|
|
147
|
+
const status = process.argv.includes('--remote') ? await desktopStatusRemote() : desktopStatus();
|
|
148
|
+
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
114
152
|
// `flowviant update` — install the latest published version now. The daemon also
|
|
115
153
|
// self-updates on its own (at startup + when idle); this is the manual path.
|
|
116
154
|
if (process.argv[2] === 'update') {
|
|
117
155
|
const { runUpdateCommand } = await import('./lib/update.mjs');
|
|
118
|
-
runUpdateCommand();
|
|
156
|
+
await runUpdateCommand();
|
|
119
157
|
process.exit(0);
|
|
120
158
|
}
|
|
121
159
|
|
|
@@ -192,11 +230,32 @@ if (process.argv[2] === 'shot') {
|
|
|
192
230
|
// daemon is running on this machine." is the answer the asker came for, not an
|
|
193
231
|
// error. Non-zero only when something was alive and could not be stopped.
|
|
194
232
|
if (process.argv[2] === 'stop') {
|
|
195
|
-
const { stopAllDaemons } = await import('./lib/instance.mjs');
|
|
196
|
-
|
|
233
|
+
const { stopAllDaemons, stopDaemonFor } = await import('./lib/instance.mjs');
|
|
234
|
+
let result;
|
|
235
|
+
if (process.argv.includes('--project')) {
|
|
236
|
+
const { matchStoredProject } = await import('./lib/credentials.mjs');
|
|
237
|
+
const at = process.argv.indexOf('--project');
|
|
238
|
+
const match = matchStoredProject(process.argv[at + 1]);
|
|
239
|
+
if (match.error) { console.error(`error: ${match.error}`); process.exit(1); }
|
|
240
|
+
result = stopDaemonFor(match.entry.fleetToken, { log: (m) => console.log(m) });
|
|
241
|
+
} else result = stopAllDaemons({ log: (m) => console.log(m) });
|
|
242
|
+
const { failed } = result;
|
|
197
243
|
process.exit(failed > 0 ? 1 : 0);
|
|
198
244
|
}
|
|
199
245
|
|
|
246
|
+
if (process.argv[2] === 'uninstall') {
|
|
247
|
+
const { runUninstall } = await import('./lib/uninstall.mjs');
|
|
248
|
+
const { askWithTimeout } = await import('./lib/tty.mjs');
|
|
249
|
+
const result = await runUninstall({
|
|
250
|
+
others: process.argv.includes('--others'),
|
|
251
|
+
purge: process.argv.includes('--purge'),
|
|
252
|
+
yes: process.argv.includes('--yes'),
|
|
253
|
+
json: process.argv.includes('--json'),
|
|
254
|
+
prompt: (query) => askWithTimeout(query, undefined, process.stderr),
|
|
255
|
+
});
|
|
256
|
+
process.exit(result.ok ? 0 : 1);
|
|
257
|
+
}
|
|
258
|
+
|
|
200
259
|
// `flowviant projects` — every project this box has a credential for, which
|
|
201
260
|
// repo each is bound to, and which the legacy mirror points at. Needs no
|
|
202
261
|
// network: it reads the store, which is the exact thing a confused person is
|
|
@@ -206,7 +265,7 @@ if (process.argv[2] === 'projects') {
|
|
|
206
265
|
const { listStoredProjects, projectLabel } = await import('./lib/credentials.mjs');
|
|
207
266
|
const entries = listStoredProjects();
|
|
208
267
|
if (entries.length === 0) {
|
|
209
|
-
console.log(
|
|
268
|
+
console.log(`no projects connected on this machine yet — run \`${terminalCommand('login')}\` inside a repo.`);
|
|
210
269
|
process.exit(0);
|
|
211
270
|
}
|
|
212
271
|
for (const e of entries) {
|
|
@@ -227,8 +286,8 @@ if (process.argv[2] === 'projects') {
|
|
|
227
286
|
);
|
|
228
287
|
}
|
|
229
288
|
console.log(
|
|
230
|
-
|
|
231
|
-
'
|
|
289
|
+
`\n \`${launchCommand()}\` picks by the repo it is started in; \`--project <name|id>\` overrides;\n` +
|
|
290
|
+
` \`${terminalCommand('login')}\` in a new repo connects another project.`
|
|
232
291
|
);
|
|
233
292
|
process.exit(0);
|
|
234
293
|
}
|
|
@@ -267,20 +326,27 @@ if (process.argv[2] === 'projects') {
|
|
|
267
326
|
// is the one exit that can be 1, when the daemon it had to stop would not.
|
|
268
327
|
if (process.argv[2] === 'machines') {
|
|
269
328
|
const creds = await import('./lib/credentials.mjs');
|
|
329
|
+
const machineJson = process.argv.includes('--json');
|
|
270
330
|
const forgetAt = process.argv.indexOf('--forget');
|
|
271
331
|
if (forgetAt >= 0) {
|
|
332
|
+
if (machineJson && !process.argv.includes('--yes')) {
|
|
333
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
272
336
|
// THE LOCAL-ONLY WRITE. Named explicitly, matched by full id or a ≥6
|
|
273
337
|
// prefix, and AMBIGUITY REFUSES rather than guessing — this deletes a
|
|
274
338
|
// credential, and two projects that look alike is the case that produced
|
|
275
339
|
// the command.
|
|
276
340
|
const res = creds.forgetStoredProject(process.argv[forgetAt + 1]);
|
|
277
341
|
if (res.error) {
|
|
342
|
+
if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: res.error })}\n`); process.exit(1); }
|
|
278
343
|
console.error(`error: ${res.error}`);
|
|
279
344
|
process.exit(1);
|
|
280
345
|
}
|
|
346
|
+
if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: true, action: 'forget', projectId: res.entry.projectId })}\n`); process.exit(0); }
|
|
281
347
|
console.log(
|
|
282
348
|
`forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box.\n` +
|
|
283
|
-
|
|
349
|
+
` Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
|
|
284
350
|
);
|
|
285
351
|
process.exit(0);
|
|
286
352
|
}
|
|
@@ -289,22 +355,28 @@ if (process.argv[2] === 'machines') {
|
|
|
289
355
|
const { FLEET_URL } = await import('./lib/config.mjs');
|
|
290
356
|
const removeAt = process.argv.indexOf('--remove');
|
|
291
357
|
if (removeAt >= 0) {
|
|
358
|
+
if (machineJson && !process.argv.includes('--yes')) {
|
|
359
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
292
362
|
// THE SCRIPTED DISCONNECT — the menu's first verb, named by id for a
|
|
293
363
|
// terminal nobody is sitting at. Same matcher and the same refusal of an
|
|
294
364
|
// ambiguous prefix as --forget: a name two projects share is exactly the
|
|
295
365
|
// input this command exists to untangle, and it must not pick one.
|
|
296
366
|
const m = creds.matchStoredProject(process.argv[removeAt + 1]);
|
|
297
367
|
if (m.error) {
|
|
298
|
-
|
|
368
|
+
if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: m.error })}\n`); process.exit(1); }
|
|
369
|
+
console.error(`error: ${m.error}. \`${terminalCommand('machines')}\` lists what is stored.`);
|
|
299
370
|
process.exit(1);
|
|
300
371
|
}
|
|
301
|
-
const res = await disconnectHere(m.entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL) }));
|
|
372
|
+
const res = await disconnectHere(m.entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log: machineJson ? () => {} : undefined }), { log: machineJson ? () => {} : undefined });
|
|
373
|
+
if (machineJson) process.stdout.write(`${JSON.stringify({ ok: res.ok, action: 'disconnect', projectId: m.entry.projectId })}\n`);
|
|
302
374
|
process.exit(res.ok ? 0 : 1);
|
|
303
375
|
}
|
|
304
376
|
|
|
305
377
|
let entries = creds.listStoredProjects();
|
|
306
378
|
if (entries.length === 0) {
|
|
307
|
-
console.log(
|
|
379
|
+
console.log(`no projects connected on this machine yet — run \`${terminalCommand('login')}\` inside a repo.`);
|
|
308
380
|
process.exit(0);
|
|
309
381
|
}
|
|
310
382
|
// OUR OWN BOX ID, so the listing can mark "← this box".
|
|
@@ -391,7 +463,7 @@ if (process.argv[2] === 'machines') {
|
|
|
391
463
|
console.log(
|
|
392
464
|
res.error
|
|
393
465
|
? ` ${res.error}`
|
|
394
|
-
: ` forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box. Nothing was stopped or deleted anywhere else —
|
|
466
|
+
: ` forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box. Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
|
|
395
467
|
);
|
|
396
468
|
}
|
|
397
469
|
entries = creds.listStoredProjects();
|
|
@@ -466,7 +538,7 @@ async function reexecAfterLogin() {
|
|
|
466
538
|
const login = await runLogin({ thenStart: false });
|
|
467
539
|
if (!login?.saved) process.exit(1); // refused: the directory already serves another project
|
|
468
540
|
const { spawn } = await import('node:child_process');
|
|
469
|
-
const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
|
|
541
|
+
const child = spawn(process.execPath, process.argv[1]?.startsWith('/$bunfs/') ? [] : [process.argv[1]], { stdio: 'inherit', env: process.env });
|
|
470
542
|
process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
|
|
471
543
|
}
|
|
472
544
|
|
|
@@ -493,7 +565,7 @@ let afterLock = null;
|
|
|
493
565
|
|
|
494
566
|
if (!FLEET_TOKEN) {
|
|
495
567
|
if (CREDENTIAL.error) {
|
|
496
|
-
console.error(`error: ${CREDENTIAL.error}.
|
|
568
|
+
console.error(`error: ${CREDENTIAL.error}. \`${terminalCommand('projects')}\` lists what is stored.`);
|
|
497
569
|
process.exit(1);
|
|
498
570
|
}
|
|
499
571
|
// A DIRECTORY SERVES ONE PROJECT (2026-09-23, the owner, verbatim: "a
|
|
@@ -521,7 +593,7 @@ if (!FLEET_TOKEN) {
|
|
|
521
593
|
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
522
594
|
);
|
|
523
595
|
|
|
524
|
-
const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (
|
|
596
|
+
const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (${terminalCommand('login')})`;
|
|
525
597
|
// WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
|
|
526
598
|
// refuses to serve a project the repo PATH did not name (the skadooble law);
|
|
527
599
|
// this only decides which row the cursor starts on, using the repo's folder
|
|
@@ -605,14 +677,14 @@ if (!FLEET_TOKEN) {
|
|
|
605
677
|
console.error(
|
|
606
678
|
'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
|
|
607
679
|
listLines(CREDENTIAL.choices, creds) +
|
|
608
|
-
|
|
609
|
-
|
|
680
|
+
`\nPick one with \`--project <name|id>\`, bind this repo by running \`${terminalCommand()}\` here in a terminal once,\n` +
|
|
681
|
+
`or connect this repo to its own project with \`${terminalCommand('login')}\`.`
|
|
610
682
|
);
|
|
611
683
|
process.exit(1);
|
|
612
684
|
} else {
|
|
613
685
|
console.error(
|
|
614
686
|
'error: no credential found. Easiest:\n' +
|
|
615
|
-
'
|
|
687
|
+
` ${terminalCommand('login')} (approve in the app — recommended)\n` +
|
|
616
688
|
'Or set:\n' +
|
|
617
689
|
' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
|
|
618
690
|
);
|
|
@@ -652,18 +724,29 @@ if (!FLEET_TOKEN) {
|
|
|
652
724
|
if (raw === null) {
|
|
653
725
|
console.log(
|
|
654
726
|
`\n could not read an answer — serving ${label} for this run ` +
|
|
655
|
-
`without tying it to this repo. Run \`
|
|
727
|
+
`without tying it to this repo. Run \`${terminalCommand()}\` here and answer to make it stick.`
|
|
656
728
|
);
|
|
657
729
|
} else if (raw === '' || raw === 'y' || raw === 'yes') {
|
|
658
730
|
// After the lock, for the reason the picker's bind gives above.
|
|
659
731
|
afterLock = () => creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
|
|
660
732
|
} else {
|
|
661
733
|
console.error(
|
|
662
|
-
`nothing started. Connect this repo to its own project with
|
|
663
|
-
`or see what is stored with
|
|
734
|
+
`nothing started. Connect this repo to its own project with \`${terminalCommand('login')}\`, ` +
|
|
735
|
+
`or see what is stored with \`${terminalCommand('projects')}\`.`
|
|
664
736
|
);
|
|
665
737
|
process.exit(1);
|
|
666
738
|
}
|
|
667
739
|
}
|
|
668
740
|
|
|
741
|
+
if (process.argv.includes('--json-events') && dirAt >= 0 && CREDENTIAL?.entry) {
|
|
742
|
+
const { detectRepoRoot, listStoredProjects, boundElsewhere } = await import('./lib/credentials.mjs');
|
|
743
|
+
const repo = detectRepoRoot();
|
|
744
|
+
const entry = CREDENTIAL.entry;
|
|
745
|
+
if (!repo || entry.repoRoot !== repo || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
|
|
746
|
+
console.error('error: this project is not connected to this repository. Run `flowviant login` for the selected folder.');
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const { installDaemonLogging } = await import('./lib/desktopContract.mjs');
|
|
751
|
+
installDaemonLogging(CREDENTIAL?.entry?.projectId, { jsonEvents: process.argv.includes('--json-events') });
|
|
669
752
|
await runFleetDaemon({ afterLock });
|
package/bin/lib/config.mjs
CHANGED
|
@@ -11,14 +11,18 @@ import { cpus, hostname, totalmem } from 'node:os';
|
|
|
11
11
|
// '0.28.0' across every release through 0.28.6, so the startup banner, the
|
|
12
12
|
// User-Agent the server version-gates on, and the self-update check all reported
|
|
13
13
|
// a stale version (and the "update available" nag never cleared).
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
// The compile script substitutes this constant into the binary. npm installs
|
|
15
|
+
// still read their own package.json exactly as before (including on Node 20).
|
|
16
|
+
export const VERSION = typeof __FLOWVIANT_BUILD_VERSION__ === 'string'
|
|
17
|
+
? __FLOWVIANT_BUILD_VERSION__
|
|
18
|
+
: (() => {
|
|
19
|
+
try {
|
|
20
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
21
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version || '0.0.0';
|
|
22
|
+
} catch {
|
|
23
|
+
return '0.0.0';
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
22
26
|
|
|
23
27
|
// The model EVERY daemon Claude turn runs on — pinned so autonomous work never
|
|
24
28
|
// inherits your interactive `~/.claude/settings.json` default. That matters: a
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from
|
|
|
49
49
|
import { homedir } from 'node:os';
|
|
50
50
|
import { join } from 'node:path';
|
|
51
51
|
import { printable } from './printable.mjs';
|
|
52
|
+
import { terminalCommand } from './launchCommand.mjs';
|
|
52
53
|
|
|
53
54
|
const CRED_DIR = join(homedir(), '.flowviant');
|
|
54
55
|
const CRED_FILE = join(CRED_DIR, 'credentials.json');
|
|
@@ -281,7 +282,7 @@ export function directoryTakenRefusal(bound, repoRoot, { incoming } = {}) {
|
|
|
281
282
|
`${head}\n` +
|
|
282
283
|
'A directory serves one project — two projects must not edit it at the same time.\n' +
|
|
283
284
|
'Delete the project you do not mean in Flowviant first (project settings → General → Delete project),\n' +
|
|
284
|
-
|
|
285
|
+
`or disconnect this box from it with \`${terminalCommand('machines')}\`, then run this again.`
|
|
285
286
|
);
|
|
286
287
|
}
|
|
287
288
|
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** Local, read-only desktop contract and bounded daemon logs. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { format } from 'node:util';
|
|
7
|
+
import { FLEET_URL, MACHINE_HOST, USER_AGENT, VERSION } from './config.mjs';
|
|
8
|
+
import { listStoredProjects } from './credentials.mjs';
|
|
9
|
+
import { readStoredPubB64 } from './env.mjs';
|
|
10
|
+
import { daemonRunningFor } from './instance.mjs';
|
|
11
|
+
import { runningViaNpx } from './launchCommand.mjs';
|
|
12
|
+
import { boxesUrlFrom, fetchBoxesFor } from './machines.mjs';
|
|
13
|
+
import { detectRuntimes } from './runtimes.mjs';
|
|
14
|
+
import { runningCompiledBinary } from './update.mjs';
|
|
15
|
+
|
|
16
|
+
export const MAX_DAEMON_LOG_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
const store = () => join(homedir(), '.flowviant');
|
|
18
|
+
const key = (projectId) => createHash('sha256').update(String(projectId)).digest('hex').slice(0, 16);
|
|
19
|
+
export const daemonLogPath = (projectId) => join(store(), `daemon-${key(projectId)}.log`);
|
|
20
|
+
export const daemonStatePath = (projectId) => join(store(), `daemon-${key(projectId)}.state.json`);
|
|
21
|
+
|
|
22
|
+
function readState(projectId) {
|
|
23
|
+
try {
|
|
24
|
+
const value = JSON.parse(readFileSync(daemonStatePath(projectId), 'utf8'));
|
|
25
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
26
|
+
} catch { return {}; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeDaemonState(projectId, change) {
|
|
30
|
+
if (!projectId) return;
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(store(), { recursive: true });
|
|
33
|
+
const path = daemonStatePath(projectId);
|
|
34
|
+
const current = readState(projectId);
|
|
35
|
+
const next = { ...current, ...change, pid: process.pid };
|
|
36
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
37
|
+
writeFileSync(tmp, JSON.stringify(next), { mode: 0o600 });
|
|
38
|
+
renameSync(tmp, path);
|
|
39
|
+
} catch { /* a local readout must never interrupt a daemon */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setRuntimeLimit(projectId, runtime, words) {
|
|
43
|
+
if (!projectId || !runtime) return false;
|
|
44
|
+
const state = readState(projectId);
|
|
45
|
+
const limits = { ...(state.limits ?? {}) };
|
|
46
|
+
if (words == null) {
|
|
47
|
+
if (!Object.hasOwn(limits, runtime)) return false;
|
|
48
|
+
delete limits[runtime];
|
|
49
|
+
} else {
|
|
50
|
+
if (limits[runtime] === words) return false;
|
|
51
|
+
limits[runtime] = words;
|
|
52
|
+
}
|
|
53
|
+
writeDaemonState(projectId, { limits });
|
|
54
|
+
emitMachineEvent({ event: words == null ? 'limit-cleared' : 'limit-hit', runtime, ...(words == null ? {} : { message: words }) });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function installChannel() {
|
|
59
|
+
return runningCompiledBinary() ? 'binary' : runningViaNpx() ? 'npx' : 'npm-g';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readState, pidFor = lockPid } = {}) {
|
|
63
|
+
return {
|
|
64
|
+
schema: 1,
|
|
65
|
+
version: VERSION,
|
|
66
|
+
installChannel: installChannel(),
|
|
67
|
+
projects: entries.map((entry) => {
|
|
68
|
+
const running = runningFor(entry.fleetToken);
|
|
69
|
+
const state = stateFor(entry.projectId);
|
|
70
|
+
const liveState = running === true && state.pid != null && state.pid === pidFor(entry.fleetToken) ? state : null;
|
|
71
|
+
return {
|
|
72
|
+
id: entry.projectId,
|
|
73
|
+
name: entry.name ?? null,
|
|
74
|
+
dir: entry.repoRoot ?? null,
|
|
75
|
+
running,
|
|
76
|
+
holder: liveState?.holder ?? null,
|
|
77
|
+
lastPoll: state.lastPoll ?? null,
|
|
78
|
+
logFile: daemonLogPath(entry.projectId),
|
|
79
|
+
runtimes: runtimes.map((rt) => ({
|
|
80
|
+
id: rt.id,
|
|
81
|
+
installed: rt.installed,
|
|
82
|
+
version: rt.version ?? null,
|
|
83
|
+
dispatchable: rt.dispatchable,
|
|
84
|
+
...(liveState?.limits?.[rt.id] ? { parkedByLimit: true, message: liveState.limits[rt.id] } : {}),
|
|
85
|
+
})),
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
// A box with no projects still has installed CLIs.
|
|
89
|
+
runtimes: runtimes.map((rt) => ({ id: rt.id, installed: rt.installed, version: rt.version ?? null, dispatchable: rt.dispatchable })),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const REMOTE_TIMEOUT_MS = 4_000;
|
|
94
|
+
|
|
95
|
+
async function remoteRead(read, timeoutMs) {
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
let timer;
|
|
98
|
+
try {
|
|
99
|
+
// Abort the request and bound body parsing even if a fetch ignores its signal.
|
|
100
|
+
return await Promise.race([
|
|
101
|
+
read(controller.signal),
|
|
102
|
+
new Promise((resolve) => {
|
|
103
|
+
timer = setTimeout(() => {
|
|
104
|
+
controller.abort();
|
|
105
|
+
resolve({ error: `timed out after ${timeoutMs}ms` });
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
}),
|
|
108
|
+
]);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return { error: String(error?.message ?? error) };
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function liveAgent(row) {
|
|
117
|
+
if (!row || typeof row !== 'object' || typeof row.id !== 'string' || !row.id ||
|
|
118
|
+
typeof row.name !== 'string' || !row.name || typeof row.status !== 'string' ||
|
|
119
|
+
typeof row.runtime !== 'string' || !row.runtime ||
|
|
120
|
+
!['delivered', 'total'].every((field) => Number.isInteger(row[field]) && row[field] >= 0) ||
|
|
121
|
+
!['asks', 'parked'].every((field) => typeof row[field] === 'boolean') ||
|
|
122
|
+
!(row.since === null || typeof row.since === 'string')) return null;
|
|
123
|
+
const { id, name, status, runtime, delivered, total, asks, parked, since } = row;
|
|
124
|
+
return { id, name, status, runtime, delivered, total, asks, parked, since };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function fetchLiveAgentsFor(entry, { url, fetchImpl, signal }) {
|
|
128
|
+
const res = await fetchImpl(url, {
|
|
129
|
+
headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
|
|
130
|
+
signal,
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
133
|
+
const body = await res.json();
|
|
134
|
+
const data = body?.data ?? body;
|
|
135
|
+
if (!data || !Array.isArray(data.agents)) return { error: 'unexpected answer shape' };
|
|
136
|
+
const pressure = data.pressure;
|
|
137
|
+
return {
|
|
138
|
+
agents: data.agents.map(liveAgent).filter(Boolean),
|
|
139
|
+
pressure: pressure && typeof pressure.reason === 'string' && typeof pressure.at === 'string'
|
|
140
|
+
? { reason: pressure.reason, at: pressure.at } : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function desktopStatusRemote({ entries = listStoredProjects(), runtimes = detectRuntimes(),
|
|
145
|
+
runningFor = daemonRunningFor, stateFor = readState, pidFor = lockPid,
|
|
146
|
+
fetchImpl = fetch, envpub = readStoredPubB64(), fleetUrl = FLEET_URL, host = MACHINE_HOST,
|
|
147
|
+
now = () => new Date().toISOString(), timeoutMs = REMOTE_TIMEOUT_MS, log = console.error } = {}) {
|
|
148
|
+
const status = desktopStatus({ entries, runtimes, runningFor, stateFor, pidFor });
|
|
149
|
+
const boxesUrl = boxesUrlFrom(fleetUrl);
|
|
150
|
+
const liveUrl = String(fleetUrl).replace(/\/agents\/?$/, '/live-agents');
|
|
151
|
+
const remotes = await Promise.all(entries.map(async (entry) => {
|
|
152
|
+
if (!entry.fleetToken) return null;
|
|
153
|
+
const [boxes, live] = await Promise.all([
|
|
154
|
+
remoteRead((signal) => fetchBoxesFor(entry, { url: boxesUrl, envpub, fetchImpl, signal }), timeoutMs),
|
|
155
|
+
remoteRead((signal) => fetchLiveAgentsFor(entry, { url: liveUrl, fetchImpl, signal }), timeoutMs),
|
|
156
|
+
]);
|
|
157
|
+
const boxesOk = Array.isArray(boxes.boxes);
|
|
158
|
+
const liveOk = Array.isArray(live.agents);
|
|
159
|
+
if (!boxesOk) {
|
|
160
|
+
const reason = boxes.error ?? (boxes.unsupported ? 'HTTP 404' : boxes.rejected ? 'credential rejected' : 'unexpected answer shape');
|
|
161
|
+
log(`status --remote ${entry.projectId} boxes: ${reason}`);
|
|
162
|
+
}
|
|
163
|
+
if (!liveOk) log(`status --remote ${entry.projectId} live-agents: ${live.error ?? 'unexpected answer shape'}`);
|
|
164
|
+
if (!boxesOk && !liveOk) return null;
|
|
165
|
+
const mine = boxesOk ? boxes.boxes.find((box) => box?.boxId === boxes.me) : null;
|
|
166
|
+
const serving = boxesOk ? boxes.boxes.find((box) => box?.role === 'serving') : null;
|
|
167
|
+
return {
|
|
168
|
+
at: now(),
|
|
169
|
+
boxName: boxesOk ? (typeof mine?.boxName === 'string' && mine.boxName ? mine.boxName : host || null) : null,
|
|
170
|
+
role: mine?.role === 'serving' || mine?.role === 'inactive' ? mine.role : null,
|
|
171
|
+
servingBoxName: typeof serving?.boxName === 'string' ? serving.boxName : null,
|
|
172
|
+
daemonVersion: typeof mine?.daemonVersion === 'string' ? mine.daemonVersion : null,
|
|
173
|
+
latest: boxesOk ? boxes.latest : null,
|
|
174
|
+
agents: liveOk ? live.agents : null,
|
|
175
|
+
pressure: liveOk ? live.pressure : null,
|
|
176
|
+
};
|
|
177
|
+
}));
|
|
178
|
+
status.projects.forEach((project, index) => { project.remote = remotes[index]; });
|
|
179
|
+
return status;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function lockPid(token) {
|
|
183
|
+
try {
|
|
184
|
+
const hash = createHash('sha256').update(String(token || 'anon')).digest('hex').slice(0, 12);
|
|
185
|
+
return JSON.parse(readFileSync(join(store(), `daemon-${hash}.lock`), 'utf8')).pid;
|
|
186
|
+
} catch { return null; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let events = false;
|
|
190
|
+
let eventWriter = process.stdout.write.bind(process.stdout);
|
|
191
|
+
export function enableMachineEvents(write = eventWriter) { events = true; eventWriter = write; }
|
|
192
|
+
export function emitMachineEvent(value) {
|
|
193
|
+
if (events) eventWriter(`${JSON.stringify(value)}\n`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** All human output stays on its existing stream in terminal mode. In desktop
|
|
197
|
+
* mode stdout is reserved for JSONL and those same lines go to stderr. */
|
|
198
|
+
export function installDaemonLogging(projectId, { jsonEvents = false } = {}) {
|
|
199
|
+
if (!projectId) return;
|
|
200
|
+
if (jsonEvents) {
|
|
201
|
+
enableMachineEvents();
|
|
202
|
+
process.stdout.write = (...args) => process.stderr.write(...args);
|
|
203
|
+
}
|
|
204
|
+
const original = { log: console.log, info: console.info, warn: console.warn, error: console.error };
|
|
205
|
+
const logPath = daemonLogPath(projectId);
|
|
206
|
+
for (const method of Object.keys(original)) {
|
|
207
|
+
console[method] = (...args) => {
|
|
208
|
+
const line = format(...args);
|
|
209
|
+
try {
|
|
210
|
+
mkdirSync(store(), { recursive: true });
|
|
211
|
+
const encoded = Buffer.from(`${line}\n`);
|
|
212
|
+
const tail = Buffer.from('\n[truncated]\n');
|
|
213
|
+
const chunk = encoded.length > MAX_DAEMON_LOG_BYTES
|
|
214
|
+
? Buffer.concat([encoded.subarray(0, MAX_DAEMON_LOG_BYTES - tail.length), tail])
|
|
215
|
+
: encoded;
|
|
216
|
+
let size = 0;
|
|
217
|
+
try { size = statSync(logPath).size; } catch { /* new file */ }
|
|
218
|
+
if (size + chunk.length > MAX_DAEMON_LOG_BYTES) {
|
|
219
|
+
try { renameSync(logPath, `${logPath}.1`); } catch { /* no old log */ }
|
|
220
|
+
}
|
|
221
|
+
appendFileSync(logPath, chunk, { mode: 0o600 });
|
|
222
|
+
} catch { /* logging must never stop service */ }
|
|
223
|
+
if (jsonEvents) process.stderr.write(`${line}\n`);
|
|
224
|
+
else original[method](...args);
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|