flowviant 0.101.2 → 0.102.2
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 +19 -0
- package/bin/cli.mjs +65 -1
- package/bin/lib/help.mjs +197 -0
- package/bin/lib/loginPath.mjs +116 -0
- package/bin/lib/views.mjs +218 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,25 @@ flowviant login
|
|
|
52
52
|
|
|
53
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.
|
|
54
54
|
|
|
55
|
+
## Commands
|
|
56
|
+
|
|
57
|
+
`flowviant help` lists every command, and `flowviant help <command>` (or `<command> --help`) shows one command's options. A word that isn't a command is refused with the nearest match; it never starts the daemon.
|
|
58
|
+
|
|
59
|
+
| Command | What it does |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `flowviant` / `flowviant start` | Serve this repo's project here |
|
|
62
|
+
| `flowviant login` | Connect this repo to a project (approve the link it prints) |
|
|
63
|
+
| `flowviant stop` | Stop every flowviant daemon on this computer |
|
|
64
|
+
| `flowviant status` | Each project connected here: repo, running/serving, live agents |
|
|
65
|
+
| `flowviant logs [-f]` | This repo's daemon log (`-f` follows it) |
|
|
66
|
+
| `flowviant open` | Open this project's board in the browser |
|
|
67
|
+
| `flowviant projects` | Every stored login and the repo it serves |
|
|
68
|
+
| `flowviant machines` | Every computer serving your projects; a menu on a terminal |
|
|
69
|
+
| `flowviant doctor` | Check git, a coding CLI, the connection and the daemon |
|
|
70
|
+
| `flowviant update` | Install the newest version now |
|
|
71
|
+
| `flowviant clean` | Delete kept agent worktrees |
|
|
72
|
+
| `flowviant uninstall` | Remove every copy (`--purge` for logins too) |
|
|
73
|
+
|
|
55
74
|
## Uninstall
|
|
56
75
|
|
|
57
76
|
`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.
|
package/bin/cli.mjs
CHANGED
|
@@ -75,10 +75,14 @@
|
|
|
75
75
|
// is that the login ended; wait for stdout to drain before exiting.
|
|
76
76
|
const flushStdout = () => new Promise((resolve) => process.stdout.write('', resolve));
|
|
77
77
|
|
|
78
|
+
// `flowviant start` is the spelled-out form of a bare `flowviant`; everything
|
|
79
|
+
// below reads the start path from argv, so the word is simply dropped.
|
|
80
|
+
if (process.argv[2] === 'start' && !process.argv.slice(3).some((arg) => arg === '--help' || arg === '-h')) process.argv.splice(2, 1);
|
|
81
|
+
|
|
78
82
|
// Resolve --dir before config reads the project bound to cwd. A tray process
|
|
79
83
|
// starts outside the checkout; its explicit folder is the daemon's checkout.
|
|
80
84
|
const dirAt = process.argv.indexOf('--dir');
|
|
81
|
-
const noCheckoutCommand = new Set(['--version', '-v', 'version', 'status', 'stop', 'uninstall', 'machines', 'projects', 'update', 'shot', 'clean', 'gh-auth', 'mcp']);
|
|
85
|
+
const noCheckoutCommand = new Set(['--version', '-v', 'version', 'status', 'stop', 'uninstall', 'machines', 'projects', 'update', 'shot', 'clean', 'gh-auth', 'mcp', 'help', '--help', '-h']);
|
|
82
86
|
if (dirAt >= 0 && !noCheckoutCommand.has(process.argv[2])) {
|
|
83
87
|
const dir = process.argv[dirAt + 1];
|
|
84
88
|
try {
|
|
@@ -121,6 +125,22 @@ if (process.argv[2] === '--version' || process.argv[2] === '-v' || process.argv[
|
|
|
121
125
|
process.exit(0);
|
|
122
126
|
}
|
|
123
127
|
|
|
128
|
+
// `flowviant help`, `--help`/`-h`, and `<command> --help`. Before any command
|
|
129
|
+
// runs, so asking about one never runs it (bin/lib/help.mjs has the table).
|
|
130
|
+
{
|
|
131
|
+
const asksHelp = (arg) => arg === '--help' || arg === '-h';
|
|
132
|
+
const first = process.argv[2];
|
|
133
|
+
if (first === 'help' || asksHelp(first) || process.argv.slice(3).some(asksHelp)) {
|
|
134
|
+
const { findCommand, renderCommandHelp, renderHelp, unknownCommandMessage } = await import('./lib/help.mjs');
|
|
135
|
+
const topic = first === 'help' ? process.argv[3] : asksHelp(first) ? undefined : first?.startsWith('-') ? 'start' : first;
|
|
136
|
+
if (!topic) { console.log(renderHelp(VERSION)); process.exit(0); }
|
|
137
|
+
const cmd = findCommand(topic);
|
|
138
|
+
if (!cmd) { console.error(unknownCommandMessage(topic)); process.exit(1); }
|
|
139
|
+
console.log(renderCommandHelp(cmd));
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
if (process.argv[2] === 'login') {
|
|
125
145
|
const noStart = process.argv.includes('--no-start');
|
|
126
146
|
const json = process.argv.includes('--json');
|
|
@@ -150,12 +170,43 @@ if (process.argv[2] === 'login') {
|
|
|
150
170
|
}
|
|
151
171
|
|
|
152
172
|
if (process.argv[2] === 'status' && process.argv.includes('--json')) {
|
|
173
|
+
// The tray polls this every 30 s: the fixed per-user bin dirs only, no shell.
|
|
174
|
+
await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess({ shell: false });
|
|
153
175
|
const { desktopStatus, desktopStatusRemote } = await import('./lib/desktopContract.mjs');
|
|
154
176
|
const status = process.argv.includes('--remote') ? await desktopStatusRemote() : desktopStatus();
|
|
155
177
|
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
156
178
|
process.exit(0);
|
|
157
179
|
}
|
|
158
180
|
|
|
181
|
+
// `flowviant status` — the same facts, for a person.
|
|
182
|
+
if (process.argv[2] === 'status') {
|
|
183
|
+
await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess();
|
|
184
|
+
const { runStatus } = await import('./lib/views.mjs');
|
|
185
|
+
await runStatus();
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// `flowviant logs [-f]` — this repo's daemon log.
|
|
190
|
+
if (process.argv[2] === 'logs') {
|
|
191
|
+
const { runLogs } = await import('./lib/views.mjs');
|
|
192
|
+
await runLogs(process.argv);
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// `flowviant open` — this repo's board, in the browser.
|
|
197
|
+
if (process.argv[2] === 'open') {
|
|
198
|
+
const { runOpen } = await import('./lib/views.mjs');
|
|
199
|
+
runOpen(process.argv);
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// `flowviant doctor` — what this computer needs, checked.
|
|
204
|
+
if (process.argv[2] === 'doctor') {
|
|
205
|
+
await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess();
|
|
206
|
+
const { runDoctor } = await import('./lib/views.mjs');
|
|
207
|
+
process.exit((await runDoctor()) > 0 ? 1 : 0);
|
|
208
|
+
}
|
|
209
|
+
|
|
159
210
|
// `flowviant update` — install the latest published version now. The daemon also
|
|
160
211
|
// self-updates on its own (at startup + when idle); this is the manual path.
|
|
161
212
|
if (process.argv[2] === 'update') {
|
|
@@ -505,6 +556,19 @@ if (process.argv[2] === 'mcp') {
|
|
|
505
556
|
process.exit(0);
|
|
506
557
|
}
|
|
507
558
|
|
|
559
|
+
// A WORD THAT IS NOT A COMMAND IS REFUSED, not served. Every word used to fall
|
|
560
|
+
// through to here, so `flowviant hlep` started the daemon. Flags still reach
|
|
561
|
+
// the start path (`--project`, `--dir`, `--json-events`, …).
|
|
562
|
+
if (process.argv[2] && !process.argv[2].startsWith('-')) {
|
|
563
|
+
const { unknownCommandMessage } = await import('./lib/help.mjs');
|
|
564
|
+
console.error(unknownCommandMessage(process.argv[2]));
|
|
565
|
+
process.exit(1);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Find the CLIs where the person's own terminal would: the Windows tray starts
|
|
569
|
+
// this through `wsl.exe --`, which reads no shell startup file (loginPath.mjs).
|
|
570
|
+
await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess();
|
|
571
|
+
|
|
508
572
|
// ── WHICH PROJECT THIS START SERVES — said, asked, or refused; never guessed.
|
|
509
573
|
//
|
|
510
574
|
// The store holds many projects since 0.55.0 and resolution is BY REPO
|
package/bin/lib/help.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flowviant help` — what you can type, in one screen.
|
|
3
|
+
*
|
|
4
|
+
* There was no help at all, and a bare word fell through to STARTING the
|
|
5
|
+
* daemon: `flowviant --help`, `flowviant status` and a typo like
|
|
6
|
+
* `flowviant hlep` all started serving. The owner, 2026-09-25: "everytime i
|
|
7
|
+
* type flowviant it just starts, but what if i wanted to do --help or some cmd
|
|
8
|
+
* to see what cmds are there?" So this table is the list, `help <command>`
|
|
9
|
+
* and `<command> --help` print one entry, and an unknown word is refused with
|
|
10
|
+
* the nearest command instead of being served.
|
|
11
|
+
*
|
|
12
|
+
* THE TABLE IS PINNED TO THE DISPATCH: help.test.mjs reads every
|
|
13
|
+
* `process.argv[2] === '<name>'` in cli.mjs and fails if one is missing here
|
|
14
|
+
* (or listed here and not dispatched). `hidden` rows are real commands a
|
|
15
|
+
* person rarely types (an agent calls `shot`; `mcp` manages connectors from a
|
|
16
|
+
* script); they answer `help <name>` but stay off the main screen.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { c } from './ui.mjs';
|
|
20
|
+
|
|
21
|
+
export const COMMANDS = [
|
|
22
|
+
{
|
|
23
|
+
name: 'start',
|
|
24
|
+
usage: 'flowviant [start] [--project <name|id>]',
|
|
25
|
+
summary: "Serve this repo's project: run agents and Workbench tabs here. Plain `flowviant` does the same.",
|
|
26
|
+
details: [
|
|
27
|
+
'Run it inside the repository you connected. The first run in a new repo asks you to approve this computer.',
|
|
28
|
+
'--project <name|id> serve that stored project instead of the one this repo is bound to',
|
|
29
|
+
],
|
|
30
|
+
group: 'run',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'login',
|
|
34
|
+
usage: 'flowviant login [--no-start]',
|
|
35
|
+
summary: 'Connect this repo to a project: approve the code in your browser, then start serving.',
|
|
36
|
+
details: ['--no-start save the connection without starting the daemon'],
|
|
37
|
+
group: 'run',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'stop',
|
|
41
|
+
usage: 'flowviant stop [--project <name|id>]',
|
|
42
|
+
summary: 'Stop every flowviant daemon on this computer (or just one project’s).',
|
|
43
|
+
group: 'run',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'status',
|
|
47
|
+
usage: 'flowviant status',
|
|
48
|
+
summary: 'Each project connected here: its repo, whether it is running and serving, and its live agents.',
|
|
49
|
+
details: ['--json the machine-readable form the Windows tray reads'],
|
|
50
|
+
group: 'look',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'logs',
|
|
54
|
+
usage: 'flowviant logs [-f] [--project <name|id>]',
|
|
55
|
+
summary: "The daemon's log for this repo's project.",
|
|
56
|
+
details: ['-f, --follow keep printing new lines as they are written', '-n <lines> how many lines to show first (default 80)'],
|
|
57
|
+
group: 'look',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'open',
|
|
61
|
+
usage: 'flowviant open [--project <name|id>]',
|
|
62
|
+
summary: "Open this project's board in your browser (prints the link too).",
|
|
63
|
+
group: 'look',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'projects',
|
|
67
|
+
usage: 'flowviant projects',
|
|
68
|
+
summary: 'Every project this computer has a login for, and the repo each one serves.',
|
|
69
|
+
group: 'look',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'machines',
|
|
73
|
+
usage: 'flowviant machines [--remove <id> | --forget <id>] [--yes]',
|
|
74
|
+
summary: 'Every computer that serves your projects. On a terminal, a menu to disconnect or forget one here.',
|
|
75
|
+
group: 'look',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'doctor',
|
|
79
|
+
usage: 'flowviant doctor',
|
|
80
|
+
summary: 'Check what this computer needs: git, a signed-in CLI, a connection to Flowviant, a running daemon.',
|
|
81
|
+
group: 'look',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'update',
|
|
85
|
+
usage: 'flowviant update',
|
|
86
|
+
summary: 'Install the newest flowviant now. It also updates itself when idle.',
|
|
87
|
+
group: 'manage',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'clean',
|
|
91
|
+
usage: 'flowviant clean',
|
|
92
|
+
summary: 'Delete the kept agent worktrees under ~/.flowviant/worktrees. Stop the daemon first.',
|
|
93
|
+
group: 'manage',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'uninstall',
|
|
97
|
+
usage: 'flowviant uninstall [--purge] [--yes]',
|
|
98
|
+
summary: 'Remove every flowviant copy on this computer. Logins are kept unless you add --purge.',
|
|
99
|
+
details: ['--purge also disconnect this computer from its projects and delete ~/.flowviant', '--yes do not ask first'],
|
|
100
|
+
group: 'manage',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'gh-auth',
|
|
104
|
+
usage: 'flowviant gh-auth',
|
|
105
|
+
summary: 'Sign in the GitHub CLI flowviant uses to open pull requests.',
|
|
106
|
+
group: 'manage',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'version',
|
|
110
|
+
usage: 'flowviant --version',
|
|
111
|
+
summary: 'Print the installed version.',
|
|
112
|
+
group: 'manage',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'help',
|
|
116
|
+
usage: 'flowviant help [command]',
|
|
117
|
+
summary: 'This list, or one command in detail.',
|
|
118
|
+
group: 'manage',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'shot',
|
|
122
|
+
usage: 'flowviant shot <url> [--out <file>]',
|
|
123
|
+
summary: 'Screenshot a running page with a headless browser (agents use it to see their change).',
|
|
124
|
+
hidden: true,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'mcp',
|
|
128
|
+
usage: 'flowviant mcp <subcommand>',
|
|
129
|
+
summary: "Manage the project's MCP connectors from a script.",
|
|
130
|
+
hidden: true,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const GROUPS = [
|
|
135
|
+
['run', 'Run'],
|
|
136
|
+
['look', 'Look'],
|
|
137
|
+
['manage', 'Manage'],
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const ALIASES = { '--version': 'version', '-v': 'version', '--help': 'help', '-h': 'help' };
|
|
141
|
+
|
|
142
|
+
export function findCommand(name) {
|
|
143
|
+
const key = ALIASES[name] ?? name;
|
|
144
|
+
return COMMANDS.find((cmd) => cmd.name === key) ?? null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The main screen: every visible command, grouped, one line each. */
|
|
148
|
+
export function renderHelp(version) {
|
|
149
|
+
const width = Math.max(...COMMANDS.filter((cmd) => !cmd.hidden).map((cmd) => cmd.name.length));
|
|
150
|
+
const lines = [`${c.bold('flowviant')} ${c.dim(version ? `v${version}` : '')}`.trimEnd(), '', `Usage: flowviant [command] [options]`, ''];
|
|
151
|
+
for (const [group, title] of GROUPS) {
|
|
152
|
+
lines.push(c.bold(title));
|
|
153
|
+
for (const cmd of COMMANDS.filter((row) => row.group === group && !row.hidden)) {
|
|
154
|
+
lines.push(` ${cmd.name.padEnd(width)} ${cmd.summary}`);
|
|
155
|
+
}
|
|
156
|
+
lines.push('');
|
|
157
|
+
}
|
|
158
|
+
lines.push(`Run ${c.cyan('flowviant help <command>')} for one command's options. Everything else happens in the app: https://app.flowviant.com`);
|
|
159
|
+
return lines.join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One command, with its options. */
|
|
163
|
+
export function renderCommandHelp(cmd) {
|
|
164
|
+
const lines = [`${c.bold(cmd.usage)}`, '', cmd.summary];
|
|
165
|
+
if (cmd.details?.length) lines.push('', ...cmd.details.map((line) => ` ${line}`));
|
|
166
|
+
return lines.join('\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Edit distance with a swapped pair counting once ("hlep" is one slip from
|
|
170
|
+
* "help"), for "did you mean". Small inputs only. */
|
|
171
|
+
function distance(a, b) {
|
|
172
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
173
|
+
for (let i = 1; i <= a.length; i++) {
|
|
174
|
+
for (let j = 1; j <= b.length; j++) {
|
|
175
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
176
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
177
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return d[a.length][b.length];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The nearest real command to a mistyped word, or null when nothing is close. */
|
|
184
|
+
export function nearestCommand(word) {
|
|
185
|
+
const w = String(word ?? '').toLowerCase();
|
|
186
|
+
let best = null;
|
|
187
|
+
for (const cmd of COMMANDS) {
|
|
188
|
+
const d = cmd.name.startsWith(w) && w.length >= 2 ? 0 : distance(w, cmd.name);
|
|
189
|
+
if (d <= Math.max(1, Math.floor(cmd.name.length / 3)) && (!best || d < best.d)) best = { name: cmd.name, d };
|
|
190
|
+
}
|
|
191
|
+
return best?.name ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function unknownCommandMessage(word) {
|
|
195
|
+
const near = nearestCommand(word);
|
|
196
|
+
return `flowviant: '${word}' is not a command.${near ? ` Did you mean \`flowviant ${near}\`?` : ''}\nRun \`flowviant help\` to see them all.`;
|
|
197
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PATH A PERSON'S TERMINAL HAS, for a daemon nobody started from one.
|
|
3
|
+
*
|
|
4
|
+
* Every CLI is found by name on PATH (`claude --version`, `codex`, `agy`,
|
|
5
|
+
* `gh`). A terminal's PATH comes from the shell's startup files; the Windows
|
|
6
|
+
* tray starts the daemon with `wsl.exe -d <distro> -- <binary>`, which reads
|
|
7
|
+
* none of them. That PATH is the distro's bare default, and it misses exactly
|
|
8
|
+
* where the CLIs usually live: `~/.local/bin` (Claude Code's own installer,
|
|
9
|
+
* put on PATH by ~/.profile) and version managers set up in ~/.bashrc (nvm,
|
|
10
|
+
* volta, bun). So a machine whose `claude` works in the owner's WSL terminal
|
|
11
|
+
* measured "not installed" under the tray (2026-09-25).
|
|
12
|
+
*
|
|
13
|
+
* So the daemon asks the person's login shell for its PATH once, and keeps
|
|
14
|
+
* every entry it already had FIRST (a terminal start is unchanged) with the
|
|
15
|
+
* shell's additions after it; then the usual per-user bin directories that
|
|
16
|
+
* exist. Best-effort and bounded: a shell that hangs or prints nothing costs a
|
|
17
|
+
* few seconds once, and nothing is ever removed from PATH.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
21
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
22
|
+
import { homedir, platform } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const MARK = '__FLOWVIANT_PATH__=';
|
|
26
|
+
|
|
27
|
+
/** The PATH an interactive login shell ends up with, or null. */
|
|
28
|
+
export function loginShellPath({ env = process.env, exec = execFileSync, timeoutMs = 4000 } = {}) {
|
|
29
|
+
const shell = env.SHELL && env.SHELL.startsWith('/') ? env.SHELL : '/bin/bash';
|
|
30
|
+
try {
|
|
31
|
+
// -i so ~/.bashrc runs (nvm lives there, behind the "not interactive?
|
|
32
|
+
// return" guard); -l so ~/.profile runs (~/.local/bin lives there). The
|
|
33
|
+
// marker line survives whatever a startup file prints.
|
|
34
|
+
const out = String(exec(shell, ['-ilc', `printf '\\n${MARK}%s\\n' "$PATH"`], {
|
|
35
|
+
env: { ...env, TERM: 'dumb' },
|
|
36
|
+
timeout: timeoutMs,
|
|
37
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
38
|
+
}));
|
|
39
|
+
const line = out.split('\n').reverse().find((l) => l.startsWith(MARK));
|
|
40
|
+
const value = line?.slice(MARK.length).trim();
|
|
41
|
+
return value ? value : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Per-user bin directories CLIs install into, that exist on this box. */
|
|
48
|
+
export function userBinDirs({ home = homedir(), exists = existsSync, list = readdirSync } = {}) {
|
|
49
|
+
const dirs = ['.local/bin', '.npm-global/bin', '.bun/bin', '.volta/bin', '.cargo/bin'].map((d) => join(home, d));
|
|
50
|
+
// nvm: the newest installed node's bin (nvm's own "default" needs its shell
|
|
51
|
+
// function to resolve; the newest is the usual answer and only a fallback).
|
|
52
|
+
try {
|
|
53
|
+
const versions = list(join(home, '.nvm/versions/node'))
|
|
54
|
+
.filter((v) => /^v\d+\.\d+\.\d+$/.test(v))
|
|
55
|
+
.sort((a, b) => {
|
|
56
|
+
const pa = a.slice(1).split('.').map(Number);
|
|
57
|
+
const pb = b.slice(1).split('.').map(Number);
|
|
58
|
+
return pb[0] - pa[0] || pb[1] - pa[1] || pb[2] - pa[2];
|
|
59
|
+
});
|
|
60
|
+
if (versions[0]) dirs.push(join(home, '.nvm/versions/node', versions[0], 'bin'));
|
|
61
|
+
} catch { /* no nvm */ }
|
|
62
|
+
return dirs.filter((d) => exists(d));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Current entries first, then any the others add. Order-preserving, deduped. */
|
|
66
|
+
export function mergePath(current, ...more) {
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const part of [current, ...more].flatMap((p) => (Array.isArray(p) ? p : String(p ?? '').split(':')))) {
|
|
70
|
+
if (!part || seen.has(part)) continue;
|
|
71
|
+
seen.add(part);
|
|
72
|
+
out.push(part);
|
|
73
|
+
}
|
|
74
|
+
return out.join(':');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Widen process.env.PATH in place. `shell: false` skips the login shell and
|
|
79
|
+
* only adds the fixed per-user directories (for the tray's frequent status
|
|
80
|
+
* polls, where a shell start every 30 s is not worth it).
|
|
81
|
+
*/
|
|
82
|
+
export function adoptLoginPath({ shell = true, env = process.env, ...deps } = {}) {
|
|
83
|
+
if (platform() === 'win32' || env.FLOWVIANT_KEEP_PATH === '1') return env.PATH;
|
|
84
|
+
const login = shell ? loginShellPath({ env, ...deps }) : null;
|
|
85
|
+
env.PATH = mergePath(env.PATH, login, userBinDirs(deps));
|
|
86
|
+
return env.PATH;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* adoptLoginPath, for THIS process and every child it will start.
|
|
91
|
+
*
|
|
92
|
+
* Under Bun (the compiled binary the curl install and the tray ship) child
|
|
93
|
+
* processes are looked up and started with the environment the process
|
|
94
|
+
* STARTED with unless a spawn passes `env` — a later `process.env.PATH = …` is
|
|
95
|
+
* invisible to them (measured 2026-09-25: node found ~/.local/bin/claude after
|
|
96
|
+
* the change, the compiled binary did not). So when the PATH grew, the
|
|
97
|
+
* binary restarts itself once with it: a thin proxy, the same shape as the
|
|
98
|
+
* self-update re-exec, stdio inherited, exit code and signals passed through.
|
|
99
|
+
* Node needs none of that and adopts in place.
|
|
100
|
+
*/
|
|
101
|
+
export async function adoptLoginPathForProcess(opts = {}) {
|
|
102
|
+
if (process.env.FLOWVIANT_PATH_ADOPTED === '1') return;
|
|
103
|
+
const before = process.env.PATH;
|
|
104
|
+
const after = adoptLoginPath(opts);
|
|
105
|
+
if (!process.versions.bun || after === before) return;
|
|
106
|
+
const compiled = process.argv[1]?.startsWith('/$bunfs/');
|
|
107
|
+
const child = spawn(process.execPath, process.argv.slice(compiled ? 2 : 1), {
|
|
108
|
+
stdio: 'inherit',
|
|
109
|
+
env: { ...process.env, FLOWVIANT_PATH_ADOPTED: '1' },
|
|
110
|
+
});
|
|
111
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
112
|
+
process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
|
|
113
|
+
}
|
|
114
|
+
const code = await new Promise((resolve) => child.on('exit', (status, signal) => resolve(status ?? (signal ? 1 : 0))));
|
|
115
|
+
process.exit(code);
|
|
116
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal's read-only views: `status`, `logs`, `open` and `doctor`.
|
|
3
|
+
*
|
|
4
|
+
* They decide nothing. Every decision is made in the app (the app repo's
|
|
5
|
+
* CLAUDE.md), so each of these only SHOWS what this computer knows, with the
|
|
6
|
+
* command or page that acts on it. Everything unmeasured renders nothing: a
|
|
7
|
+
* status line for a server that did not answer is left out, never guessed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
11
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
|
|
12
|
+
import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
|
|
13
|
+
import { detectRepoRoot, listStoredProjects, projectLabel, resolveStoredCredential } from './credentials.mjs';
|
|
14
|
+
import { daemonLogPath, desktopStatusRemote, installChannel } from './desktopContract.mjs';
|
|
15
|
+
import { daemonRunningFor } from './instance.mjs';
|
|
16
|
+
import { agoFrom } from './machines.mjs';
|
|
17
|
+
import { detectRuntimes } from './runtimes.mjs';
|
|
18
|
+
import { c } from './ui.mjs';
|
|
19
|
+
|
|
20
|
+
const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
|
|
21
|
+
|
|
22
|
+
function olderVersion(version, latest) {
|
|
23
|
+
const parse = (v) => /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? ''))?.slice(1, 4).map(Number);
|
|
24
|
+
const a = parse(version);
|
|
25
|
+
const b = parse(latest);
|
|
26
|
+
if (!a || !b) return false;
|
|
27
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i];
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function agentLine(agent, now) {
|
|
32
|
+
if (agent.asks) return `${agent.name} · ${c.yellow('asks you a question')}`;
|
|
33
|
+
const since = agent.status === 'working' && !agent.parked && agent.since ? Date.parse(agent.since) : NaN;
|
|
34
|
+
const minutes = Number.isFinite(since) ? Math.max(0, Math.floor((now - since) / 60_000)) : null;
|
|
35
|
+
const took = minutes == null ? '' : ` ${minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`}`;
|
|
36
|
+
const word = agent.parked ? 'parked' : agent.status;
|
|
37
|
+
const cards = `${agent.delivered} of ${agent.total} ${agent.total === 1 ? 'card' : 'cards'}${agent.delivered ? ' delivered' : ''}`;
|
|
38
|
+
return `${agent.name} · ${word === 'working' ? c.blue(`working${took}`) : word} · ${cards}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `flowviant status`, rendered from the same document `status --json --remote` prints. */
|
|
42
|
+
export function renderStatus(status, { now = Date.now() } = {}) {
|
|
43
|
+
const lines = [`${c.bold('flowviant')} ${c.dim(`v${status.version} · ${status.installChannel}`)}`, ''];
|
|
44
|
+
if (status.projects.length === 0) {
|
|
45
|
+
lines.push('No project is connected on this computer yet.', `Run ${c.cyan('flowviant login')} inside your repository.`, '');
|
|
46
|
+
}
|
|
47
|
+
for (const project of status.projects) {
|
|
48
|
+
const remote = project.remote ?? null;
|
|
49
|
+
const label = (key, value) => ` ${c.dim(key.padEnd(8))} ${value}`;
|
|
50
|
+
lines.push(`${c.bold(project.name ?? project.id)} ${c.dim(`${project.id.slice(0, 8)}…`)}`);
|
|
51
|
+
lines.push(label('repo', project.dir ?? c.dim('not bound yet — the first start in its repo binds it')));
|
|
52
|
+
const role = remote?.role ?? null;
|
|
53
|
+
if (project.running === false) {
|
|
54
|
+
lines.push(label('here', `not running — run ${c.cyan('flowviant')} in that repo`));
|
|
55
|
+
} else if (project.running === true) {
|
|
56
|
+
const serves = role === 'serving' ? c.green('serving')
|
|
57
|
+
: role === 'inactive' ? `inactive${remote?.servingBoxName ? ` — ${remote.servingBoxName} serves this project` : ''}`
|
|
58
|
+
: project.holder === 'serving' ? c.green('serving') : null;
|
|
59
|
+
lines.push(label('here', serves ? `running · ${serves}` : 'running'));
|
|
60
|
+
}
|
|
61
|
+
const heard = agoFrom(project.lastPoll, now);
|
|
62
|
+
if (heard) lines.push(label('polled', heard));
|
|
63
|
+
const agents = remote?.agents ?? null;
|
|
64
|
+
if (agents && agents.length > 0) {
|
|
65
|
+
agents.forEach((agent, i) => lines.push(label(i === 0 ? 'agents' : '', agentLine(agent, now))));
|
|
66
|
+
}
|
|
67
|
+
if (remote?.pressure?.reason) lines.push(label('waiting', `Waiting for the machine — ${remote.pressure.reason}.`));
|
|
68
|
+
for (const rt of project.runtimes ?? []) {
|
|
69
|
+
if (rt.parkedByLimit && rt.message) lines.push(label('limit', `${rt.id} hit its limit: “${rt.message}”`));
|
|
70
|
+
}
|
|
71
|
+
const version = remote?.daemonVersion ?? status.version;
|
|
72
|
+
if (remote?.latest && olderVersion(version, remote.latest)) {
|
|
73
|
+
lines.push(label('update', `flowviant ${remote.latest} is out; this computer runs ${version}. It updates itself when idle.`));
|
|
74
|
+
}
|
|
75
|
+
lines.push('');
|
|
76
|
+
}
|
|
77
|
+
const clis = (status.runtimes ?? []).map((rt) => `${rt.id} ${rt.installed ? (rt.version ?? 'installed') : c.dim('not installed')}`);
|
|
78
|
+
if (clis.length) lines.push(`${c.dim('CLIs')} ${clis.join(' · ')}`);
|
|
79
|
+
return lines.join('\n').trimEnd();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runStatus() {
|
|
83
|
+
console.log(renderStatus(await desktopStatusRemote({ log: () => {} })));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Which stored project a `logs`/`open` means: `--project`, else this repo's,
|
|
87
|
+
* else the only one. Several and none are answered in words, never guessed. */
|
|
88
|
+
export function resolveProjectForView(argv = process.argv, cwd = process.cwd()) {
|
|
89
|
+
const resolved = resolveStoredCredential(argv, cwd);
|
|
90
|
+
if (resolved.entry) return { entry: resolved.entry };
|
|
91
|
+
if (resolved.error) return { message: `error: ${resolved.error}` };
|
|
92
|
+
const entries = listStoredProjects();
|
|
93
|
+
if (entries.length === 0) return { message: `No project is connected on this computer yet. Run \`flowviant login\` inside your repository.` };
|
|
94
|
+
return {
|
|
95
|
+
message: `Several projects are connected here, and this folder is not one of their repos. Pick one with --project:\n${
|
|
96
|
+
entries.map((e) => ` ${projectLabel(e)} ${c.dim(e.projectId)}`).join('\n')}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The last `count` lines of a file, read from its end. */
|
|
101
|
+
export function tailLines(path, count) {
|
|
102
|
+
const size = statSync(path).size;
|
|
103
|
+
const want = Math.min(size, 256 * 1024);
|
|
104
|
+
const buf = Buffer.alloc(want);
|
|
105
|
+
const fd = openSync(path, 'r');
|
|
106
|
+
try { readSync(fd, buf, 0, want, size - want); } finally { closeSync(fd); }
|
|
107
|
+
const lines = buf.toString('utf8').split('\n');
|
|
108
|
+
if (lines.at(-1) === '') lines.pop();
|
|
109
|
+
if (want < size) lines.shift(); // the first line is cut mid-way
|
|
110
|
+
return { text: lines.slice(-count).join('\n'), size };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function runLogs(argv = process.argv) {
|
|
114
|
+
const which = resolveProjectForView(argv);
|
|
115
|
+
if (!which.entry) { console.error(which.message); process.exit(1); }
|
|
116
|
+
const path = daemonLogPath(which.entry.projectId);
|
|
117
|
+
const at = argv.indexOf('-n');
|
|
118
|
+
const count = at >= 0 && Number(argv[at + 1]) > 0 ? Math.floor(Number(argv[at + 1])) : 80;
|
|
119
|
+
const follow = argv.includes('-f') || argv.includes('--follow');
|
|
120
|
+
if (!existsSync(path)) {
|
|
121
|
+
console.log(`No log yet for ${projectLabel(which.entry)}. It starts when the daemon first runs: ${c.cyan('flowviant')} in its repo.`);
|
|
122
|
+
if (!follow) return;
|
|
123
|
+
} else {
|
|
124
|
+
const { text } = tailLines(path, count);
|
|
125
|
+
if (text) console.log(text);
|
|
126
|
+
}
|
|
127
|
+
if (!follow) return;
|
|
128
|
+
// Poll rather than fs.watch: the daemon rotates the file (a rename), and a
|
|
129
|
+
// watch on the old inode would go quiet exactly then.
|
|
130
|
+
let offset = existsSync(path) ? statSync(path).size : 0;
|
|
131
|
+
for (;;) {
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
133
|
+
if (!existsSync(path)) continue;
|
|
134
|
+
const size = statSync(path).size;
|
|
135
|
+
if (size < offset) offset = 0; // rotated
|
|
136
|
+
if (size === offset) continue;
|
|
137
|
+
const buf = Buffer.alloc(size - offset);
|
|
138
|
+
const fd = openSync(path, 'r');
|
|
139
|
+
try { readSync(fd, buf, 0, buf.length, offset); } finally { closeSync(fd); }
|
|
140
|
+
process.stdout.write(buf);
|
|
141
|
+
offset = size;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The command that opens a URL in this computer's browser. Inside WSL that is
|
|
146
|
+
* Windows' own browser, reached through explorer.exe. */
|
|
147
|
+
export function browserCommand(url, { platform = process.platform, env = process.env } = {}) {
|
|
148
|
+
if (platform === 'darwin') return ['open', [url]];
|
|
149
|
+
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]];
|
|
150
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return ['explorer.exe', [url]];
|
|
151
|
+
return ['xdg-open', [url]];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function runOpen(argv = process.argv) {
|
|
155
|
+
const which = resolveProjectForView(argv);
|
|
156
|
+
if (!which.entry) { console.error(which.message); process.exit(1); }
|
|
157
|
+
const url = `${APP_URL}/projects/${encodeURIComponent(which.entry.projectId)}/board`;
|
|
158
|
+
console.log(url);
|
|
159
|
+
const [cmd, args] = browserCommand(url);
|
|
160
|
+
try {
|
|
161
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
162
|
+
child.on('error', () => {}); // no browser here: the printed link is the answer
|
|
163
|
+
child.unref();
|
|
164
|
+
} catch { /* the link above is enough */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One doctor line: ✓ measured fine, ✗ measured wrong (with what to do), · unknown. */
|
|
168
|
+
function check(state, text, fix) {
|
|
169
|
+
const mark = state === true ? c.green('✓') : state === false ? c.red('✗') : c.dim('·');
|
|
170
|
+
return `${mark} ${text}${state === false && fix ? `\n ${c.dim(fix)}` : ''}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runDoctor({ cwd = process.cwd(), fetchImpl = fetch } = {}) {
|
|
174
|
+
const lines = [`${c.bold('flowviant')} ${c.dim(`v${VERSION} · ${installChannel()}`)}`, ''];
|
|
175
|
+
let failed = 0;
|
|
176
|
+
const add = (state, text, fix) => { if (state === false) failed++; lines.push(check(state, text, fix)); };
|
|
177
|
+
|
|
178
|
+
let git = null;
|
|
179
|
+
try { git = String(execFileSync('git', ['--version'], { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })).trim(); } catch { /* absent */ }
|
|
180
|
+
add(Boolean(git), git ?? 'git is not installed', 'Install git; agents work in git worktrees.');
|
|
181
|
+
|
|
182
|
+
const installed = detectRuntimes().filter((rt) => rt.installed);
|
|
183
|
+
add(installed.length > 0,
|
|
184
|
+
installed.length ? `CLIs here: ${installed.map((rt) => `${rt.id}${rt.version ? ` ${rt.version}` : ''}`).join(', ')}` : 'No coding CLI found (Claude Code, Codex or Antigravity)',
|
|
185
|
+
'Install Claude Code or Codex on this computer and sign in to it; agents run through its login.');
|
|
186
|
+
|
|
187
|
+
const repo = detectRepoRoot(cwd);
|
|
188
|
+
add(Boolean(repo), repo ? `This folder is the repo ${repo}` : 'This folder is not inside a git repository',
|
|
189
|
+
'Run flowviant inside the repository you want agents to work in.');
|
|
190
|
+
|
|
191
|
+
const resolved = resolveStoredCredential(process.argv, cwd);
|
|
192
|
+
const entry = resolved.entry ?? null;
|
|
193
|
+
add(entry ? true : repo ? false : null,
|
|
194
|
+
entry ? `Connected to ${projectLabel(entry)}` : 'This repo is not connected to a project',
|
|
195
|
+
'Run `flowviant login` here and approve it in your browser.');
|
|
196
|
+
|
|
197
|
+
const health = String(FLEET_URL).replace(/\/api\/.*$/, '/health');
|
|
198
|
+
let reach = null;
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetchImpl(health, { headers: { 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(5000) });
|
|
201
|
+
reach = res.ok ? true : `HTTP ${res.status}`;
|
|
202
|
+
} catch (error) { reach = String(error?.message ?? error); }
|
|
203
|
+
add(reach === true, reach === true ? 'Flowviant is reachable' : `Flowviant is not reachable (${reach})`,
|
|
204
|
+
'Check this computer’s internet connection or proxy.');
|
|
205
|
+
|
|
206
|
+
if (entry) {
|
|
207
|
+
const running = daemonRunningFor(entry.fleetToken);
|
|
208
|
+
// No lock file is UNKNOWN (an older daemon runs without one), so only a
|
|
209
|
+
// dead lock holder is said to be "not running".
|
|
210
|
+
add(running === true ? true : null,
|
|
211
|
+
running === true ? 'The daemon is running for this project'
|
|
212
|
+
: running === false ? 'The daemon is not running — start it with `flowviant`'
|
|
213
|
+
: 'No daemon has registered for this project here — start one with `flowviant`');
|
|
214
|
+
}
|
|
215
|
+
lines.push('', failed ? `${failed} thing${failed === 1 ? '' : 's'} to fix.` : 'Nothing to fix.');
|
|
216
|
+
console.log(lines.join('\n'));
|
|
217
|
+
return failed;
|
|
218
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.102.2",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|