flowviant 0.54.2 → 0.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +149 -8
- package/bin/lib/config.mjs +18 -11
- package/bin/lib/credentials.mjs +258 -0
- package/bin/lib/env-cli.mjs +5 -1
- package/bin/lib/env.mjs +133 -1
- package/bin/lib/fleet.mjs +58 -1
- package/bin/lib/login.mjs +21 -23
- package/bin/lib/mcp-cli.mjs +3 -2
- package/bin/lib/runtimes.mjs +20 -3
- package/bin/lib/update.mjs +11 -1
- package/bin/lib/work.mjs +65 -2
- package/package.json +2 -2
package/bin/cli.mjs
CHANGED
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
* odd side job the roster carries — a commit's patch, a preview share, a wiki
|
|
33
33
|
* regen. When you say ship, the daemon merges that branch into base `--no-ff`.
|
|
34
34
|
*
|
|
35
|
+
* MANY PROJECTS, ONE BOX (0.55.0): `flowviant login` in each repo stores one
|
|
36
|
+
* credential per project (~/.flowviant/credentials.json holds a map), and a
|
|
37
|
+
* bare `npx flowviant` serves the project BOUND to the repo it is started in.
|
|
38
|
+
* Ambiguity is a picker on a TTY and a worded refusal headless — never a
|
|
39
|
+
* guess. `flowviant projects` lists what is stored; `--project <name|id>`
|
|
40
|
+
* picks without a prompt.
|
|
41
|
+
*
|
|
35
42
|
* Env:
|
|
36
43
|
* FLOWVIANT_FLEET the machine credential (or use `flowviant login`).
|
|
37
44
|
* FLOWVIANT_API_URL default https://api.flowviant.com/api/v2
|
|
@@ -49,7 +56,7 @@
|
|
|
49
56
|
* git + worktreeDiff + patch; localSessions, listeners, preview + authproxy;
|
|
50
57
|
* env + env-cli + vault, resources, deploy, shot.
|
|
51
58
|
*/
|
|
52
|
-
import { FLEET_TOKEN } from './lib/config.mjs';
|
|
59
|
+
import { FLEET_TOKEN, CREDENTIAL, adoptStoredCredential } from './lib/config.mjs';
|
|
53
60
|
import { runFleetDaemon } from './lib/fleet.mjs';
|
|
54
61
|
import { runLogin } from './lib/login.mjs';
|
|
55
62
|
|
|
@@ -172,6 +179,32 @@ if (process.argv[2] === 'stop') {
|
|
|
172
179
|
process.exit(failed > 0 ? 1 : 0);
|
|
173
180
|
}
|
|
174
181
|
|
|
182
|
+
// `flowviant projects` — every project this box has a credential for, which
|
|
183
|
+
// repo each is bound to, and which the legacy mirror points at. Needs no
|
|
184
|
+
// network: it reads the store, which is the exact thing a confused person is
|
|
185
|
+
// trying to see. The remedies are named because this listing IS the moment of
|
|
186
|
+
// confusion ("why did it say skadooble?"), not documentation.
|
|
187
|
+
if (process.argv[2] === 'projects') {
|
|
188
|
+
const { listStoredProjects, projectLabel } = await import('./lib/credentials.mjs');
|
|
189
|
+
const entries = listStoredProjects();
|
|
190
|
+
if (entries.length === 0) {
|
|
191
|
+
console.log('no projects connected on this machine yet — run `flowviant login` inside a repo.');
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
for (const e of entries) {
|
|
195
|
+
console.log(
|
|
196
|
+
` ${projectLabel(e)} (${e.projectId.slice(0, 8)}…)` +
|
|
197
|
+
`${e.repoRoot ? `\n repo · ${e.repoRoot}` : '\n repo · not bound yet — first start or login in its repo binds it'}` +
|
|
198
|
+
`${e.active ? '\n what a pre-0.55.0 flowviant on this box would serve (the legacy mirror)' : ''}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
console.log(
|
|
202
|
+
'\n `npx flowviant` picks by the repo it is started in; `--project <name|id>` overrides;\n' +
|
|
203
|
+
' `flowviant login` in a new repo connects another project.'
|
|
204
|
+
);
|
|
205
|
+
process.exit(0);
|
|
206
|
+
}
|
|
207
|
+
|
|
175
208
|
// `flowviant env <import|set|show>` — the CLI half of team env sync. Values
|
|
176
209
|
// are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
|
|
177
210
|
// the browser); `show` decrypts locally — it only works on an ENROLLED machine.
|
|
@@ -191,14 +224,122 @@ if (process.argv[2] === 'env') {
|
|
|
191
224
|
process.exit(0);
|
|
192
225
|
}
|
|
193
226
|
|
|
227
|
+
// ── WHICH PROJECT THIS START SERVES — said, asked, or refused; never guessed.
|
|
228
|
+
//
|
|
229
|
+
// The store holds many projects since 0.55.0 and resolution is BY REPO
|
|
230
|
+
// (credentials.mjs). What is left here is the human half: an ambiguous store
|
|
231
|
+
// on a TTY becomes a PICKER, a single unbound credential gets ONE confirm that
|
|
232
|
+
// binds it, and a headless start with no unambiguous answer refuses in words
|
|
233
|
+
// that name every stored project and every way out. The one thing this block
|
|
234
|
+
// must never do is serve a project the resolution did not name — "it said
|
|
235
|
+
// skadooble in my calendar repo" is the confusion this exists to end.
|
|
236
|
+
// A RESTART IS NOT A PERSON. `reexec` (update.mjs) inherits stdio, so an
|
|
237
|
+
// auto-updated daemon's child sees two TTYs; without this it would stop on the
|
|
238
|
+
// binding confirm below and the machine would stay dark until somebody typed a
|
|
239
|
+
// key. Same reasoning as the headless case, and the same answer.
|
|
240
|
+
const interactive =
|
|
241
|
+
Boolean(process.stdin.isTTY && process.stdout.isTTY) && process.env.FLOWVIANT_REEXEC !== '1';
|
|
242
|
+
const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
|
|
243
|
+
|
|
244
|
+
/** Re-exec a plain `flowviant` after an inline login — the login command's own
|
|
245
|
+
* pattern: config.mjs read the store at IMPORT time, before the credential
|
|
246
|
+
* existed, so this process cannot serve; the child can. */
|
|
247
|
+
async function reexecAfterLogin() {
|
|
248
|
+
await runLogin({ thenStart: false });
|
|
249
|
+
const { spawn } = await import('node:child_process');
|
|
250
|
+
const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
|
|
251
|
+
process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function listLines(entries, { projectLabel }) {
|
|
255
|
+
return entries
|
|
256
|
+
.map(
|
|
257
|
+
(e, i) =>
|
|
258
|
+
` ${i + 1}. ${projectLabel(e)}` +
|
|
259
|
+
(e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet')
|
|
260
|
+
)
|
|
261
|
+
.join('\n');
|
|
262
|
+
}
|
|
263
|
+
|
|
194
264
|
if (!FLEET_TOKEN) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
265
|
+
if (CREDENTIAL.error) {
|
|
266
|
+
console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
if (CREDENTIAL.choices?.length && interactive) {
|
|
270
|
+
const creds = await import('./lib/credentials.mjs');
|
|
271
|
+
const { choices, repoRoot } = CREDENTIAL;
|
|
272
|
+
console.log(
|
|
273
|
+
CREDENTIAL.reason === 'outside-repo'
|
|
274
|
+
? 'flowviant is not inside a git repo, and more than one project is connected here.'
|
|
275
|
+
: CREDENTIAL.reason === 'multiple-bound'
|
|
276
|
+
? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
|
|
277
|
+
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
278
|
+
);
|
|
279
|
+
console.log(listLines(choices, creds));
|
|
280
|
+
console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
|
|
281
|
+
const rl = (await import('node:readline/promises')).createInterface({
|
|
282
|
+
input: process.stdin,
|
|
283
|
+
output: process.stdout,
|
|
284
|
+
});
|
|
285
|
+
const raw = (await rl.question(`Which project should this daemon serve? [1-${choices.length + 1}] `)).trim();
|
|
286
|
+
rl.close();
|
|
287
|
+
const n = Number.parseInt(raw, 10);
|
|
288
|
+
if (n === choices.length + 1) await reexecAfterLogin();
|
|
289
|
+
const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
|
|
290
|
+
if (!picked) {
|
|
291
|
+
console.error('nothing chosen — nothing started.');
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
// An answered question is consent: adopt it, and BIND it to this repo so
|
|
295
|
+
// the next start needs no prompt. Repointing is legitimate and said aloud.
|
|
296
|
+
if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
|
|
297
|
+
console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
|
|
298
|
+
}
|
|
299
|
+
adoptStoredCredential(picked);
|
|
300
|
+
creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
|
|
301
|
+
console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
|
|
302
|
+
} else if (CREDENTIAL.choices?.length) {
|
|
303
|
+
const creds = await import('./lib/credentials.mjs');
|
|
304
|
+
console.error(
|
|
305
|
+
'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
|
|
306
|
+
listLines(CREDENTIAL.choices, creds) +
|
|
307
|
+
'\nPick one with `--project <name|id>`, bind this repo by running `flowviant` here in a terminal once,\n' +
|
|
308
|
+
'or connect this repo to its own project with `flowviant login`.'
|
|
309
|
+
);
|
|
310
|
+
process.exit(1);
|
|
311
|
+
} else {
|
|
312
|
+
console.error(
|
|
313
|
+
'error: no credential found. Easiest:\n' +
|
|
314
|
+
' flowviant login (approve in the app — recommended)\n' +
|
|
315
|
+
'Or set:\n' +
|
|
316
|
+
' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
|
|
317
|
+
);
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
} else if (!externalToken && CREDENTIAL.needsConfirm && interactive) {
|
|
321
|
+
// ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
|
|
322
|
+
// yes binds and every later start is silent. This is the exact question
|
|
323
|
+
// whose absence had a calendar checkout serving skadooble.
|
|
324
|
+
const creds = await import('./lib/credentials.mjs');
|
|
325
|
+
const label = creds.projectLabel(CREDENTIAL.entry);
|
|
326
|
+
const rl = (await import('node:readline/promises')).createInterface({
|
|
327
|
+
input: process.stdin,
|
|
328
|
+
output: process.stdout,
|
|
329
|
+
});
|
|
330
|
+
const raw = (
|
|
331
|
+
await rl.question(`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `)
|
|
332
|
+
).trim().toLowerCase();
|
|
333
|
+
rl.close();
|
|
334
|
+
if (raw === '' || raw === 'y' || raw === 'yes') {
|
|
335
|
+
creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
|
|
336
|
+
} else {
|
|
337
|
+
console.error(
|
|
338
|
+
`nothing started. Connect this repo to its own project with \`flowviant login\`, ` +
|
|
339
|
+
`or see what is stored with \`flowviant projects\`.`
|
|
340
|
+
);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
202
343
|
}
|
|
203
344
|
|
|
204
345
|
await runFleetDaemon();
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import {
|
|
7
|
+
import { cpus, totalmem } from 'node:os';
|
|
8
8
|
|
|
9
9
|
// Read the daemon's version from its OWN package.json (always shipped in the npm
|
|
10
10
|
// tarball) — never hardcode it. The hardcoded constant drifted: it sat at
|
|
@@ -30,14 +30,11 @@ export const MODEL = process.env.FLOWVIANT_MODEL || 'opus';
|
|
|
30
30
|
|
|
31
31
|
// Credential stored by `flowviant login` (device auth) — the no-token,
|
|
32
32
|
// no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
const stored = readStoredCredential();
|
|
33
|
+
// Since 0.55.0 the store holds MANY projects and resolution is BY REPO — see
|
|
34
|
+
// credentials.mjs for the whole rule. `CREDENTIAL` carries the resolution so
|
|
35
|
+
// cli.mjs can turn an ambiguity into a picker instead of a guess.
|
|
36
|
+
import { resolveStoredCredential } from './credentials.mjs';
|
|
37
|
+
export const CREDENTIAL = resolveStoredCredential();
|
|
41
38
|
|
|
42
39
|
function argFlag(name) {
|
|
43
40
|
const i = process.argv.indexOf(name);
|
|
@@ -167,5 +164,15 @@ export const DAEMON_INSTANCE = randomBytes(12).toString('hex');
|
|
|
167
164
|
// --tokens) stood beside it and carried WORKER tokens into the pre-daemon loop;
|
|
168
165
|
// that principal owns zero tools since dispatch was deleted, and the kind can no
|
|
169
166
|
// longer be minted, so the plumbing went with the entrypoint (2026-08-19).
|
|
170
|
-
|
|
171
|
-
|
|
167
|
+
// `let`, because ES named imports are LIVE bindings: when cli.mjs answers an
|
|
168
|
+
// ambiguous store with a picker, adoptStoredCredential updates every importer
|
|
169
|
+
// before the daemon touches the network. Everything before that point (the
|
|
170
|
+
// resolution, the flags) is settled synchronously at import, as it always was.
|
|
171
|
+
export let FLEET_TOKEN =
|
|
172
|
+
argFlag('--fleet') || process.env.FLOWVIANT_FLEET || CREDENTIAL.entry?.fleetToken || '';
|
|
173
|
+
|
|
174
|
+
/** cli.mjs's picker chose. Must run BEFORE runFleetDaemon — nothing here
|
|
175
|
+
* re-authenticates a connection already made. */
|
|
176
|
+
export function adoptStoredCredential(entry) {
|
|
177
|
+
if (entry?.fleetToken) FLEET_TOKEN = entry.fleetToken;
|
|
178
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CREDENTIAL STORE — one file, MANY projects (v2, 2026-08-23).
|
|
3
|
+
*
|
|
4
|
+
* ~/.flowviant/credentials.json used to hold exactly one credential, and
|
|
5
|
+
* `flowviant login` overwrote it — so `npx flowviant` in ANY directory served
|
|
6
|
+
* whatever project was logged into last. A user standing in their calendar
|
|
7
|
+
* repo watched the banner say another project's name, and the only thing that
|
|
8
|
+
* saved them from serving the wrong repo was the instance lock's same-repo
|
|
9
|
+
* refusal. One VM is allowed to run one daemon per project (the lock is keyed
|
|
10
|
+
* per credential and the header of instance.mjs says so out loud); the store
|
|
11
|
+
* was the only thing pretending otherwise.
|
|
12
|
+
*
|
|
13
|
+
* THE FILE SHAPE, and why it is two shapes at once:
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* fleetToken, projectId, mcpUrl, // the LEGACY MIRROR
|
|
17
|
+
* projects: { // v2: every connected project
|
|
18
|
+
* [projectId]: { fleetToken, mcpUrl, name, repoRoot, savedAt }
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* The top-level trio is what every daemon before 0.55.0 reads, and the CLI is
|
|
23
|
+
* the one component a deploy cannot upgrade — so it stays, always mirroring
|
|
24
|
+
* the ACTIVE (most recently logged-in or explicitly picked) project. An old
|
|
25
|
+
* version keeps working with that project; a new one resolves by REPO.
|
|
26
|
+
*
|
|
27
|
+
* WHICH CREDENTIAL A START USES — resolution, in order:
|
|
28
|
+
* --fleet / FLOWVIANT_FLEET the operator said, verbatim (config.mjs).
|
|
29
|
+
* --project <name|id> an entry, named without a prompt.
|
|
30
|
+
* the entry BOUND to this repo repoRoot recorded at login/confirm, compared
|
|
31
|
+
* by realpath — the no-ambiguity path.
|
|
32
|
+
* one unbound entry served, with a one-time TTY confirm that
|
|
33
|
+
* binds it (headless keeps the old behaviour —
|
|
34
|
+
* a systemd restart must not hang on a prompt).
|
|
35
|
+
* anything else a CHOICE, never a guess: the CLI lists every
|
|
36
|
+
* stored project and asks (cli.mjs), or — with
|
|
37
|
+
* no TTY — refuses in words that name them all.
|
|
38
|
+
*
|
|
39
|
+
* The repoRoot binding is a SAFETY line, not bookkeeping: serving project X
|
|
40
|
+
* from a repo that is not X's checkout materializes X's session worktrees and
|
|
41
|
+
* decrypted env inside the wrong repository. Binding happens only at moments a
|
|
42
|
+
* HUMAN was present (login, an answered confirm, an explicit pick) — never
|
|
43
|
+
* silently on a headless start, where cementing the wrong repo would make the
|
|
44
|
+
* mistake permanent.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { execFileSync } from 'node:child_process';
|
|
48
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
49
|
+
import { homedir } from 'node:os';
|
|
50
|
+
import { join } from 'node:path';
|
|
51
|
+
|
|
52
|
+
const CRED_DIR = join(homedir(), '.flowviant');
|
|
53
|
+
const CRED_FILE = join(CRED_DIR, 'credentials.json');
|
|
54
|
+
|
|
55
|
+
function readFile() {
|
|
56
|
+
try {
|
|
57
|
+
const v = JSON.parse(readFileSync(CRED_FILE, 'utf8'));
|
|
58
|
+
return v && typeof v === 'object' ? v : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Atomic write (tmp + rename), 0600 — this file is credentials. */
|
|
65
|
+
function writeFile(v) {
|
|
66
|
+
mkdirSync(CRED_DIR, { recursive: true });
|
|
67
|
+
const tmp = `${CRED_FILE}.${process.pid}.tmp`;
|
|
68
|
+
writeFileSync(tmp, JSON.stringify(v, null, 2), { mode: 0o600 });
|
|
69
|
+
renameSync(tmp, CRED_FILE);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The repo root of `cwd`, realpath'd, or null when not inside a git repo. */
|
|
73
|
+
export function detectRepoRoot(cwd = process.cwd()) {
|
|
74
|
+
try {
|
|
75
|
+
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
76
|
+
cwd,
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
79
|
+
timeout: 5000,
|
|
80
|
+
}).trim();
|
|
81
|
+
return root ? realpathSync(root) : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Same directory whatever it is spelled as — the instance.mjs rule. */
|
|
88
|
+
function samePath(a, b) {
|
|
89
|
+
if (!a || !b) return false;
|
|
90
|
+
const norm = (v) => {
|
|
91
|
+
try {
|
|
92
|
+
return realpathSync(v);
|
|
93
|
+
} catch {
|
|
94
|
+
return String(v).replace(/\/+$/, '');
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
return norm(a) === norm(b);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every stored project, NORMALIZED: the v2 map, plus the legacy trio surfaced
|
|
102
|
+
* as an entry when no map row carries its token (a pre-0.55.0 file is one
|
|
103
|
+
* project with no name and no binding — real, and listed as such). `active`
|
|
104
|
+
* marks the entry the legacy mirror points at.
|
|
105
|
+
*/
|
|
106
|
+
export function listStoredProjects() {
|
|
107
|
+
const f = readFile();
|
|
108
|
+
if (!f) return [];
|
|
109
|
+
const out = new Map();
|
|
110
|
+
for (const [projectId, e] of Object.entries(f.projects ?? {})) {
|
|
111
|
+
if (!e || typeof e.fleetToken !== 'string' || !e.fleetToken) continue;
|
|
112
|
+
out.set(projectId, {
|
|
113
|
+
projectId,
|
|
114
|
+
fleetToken: e.fleetToken,
|
|
115
|
+
mcpUrl: e.mcpUrl ?? null,
|
|
116
|
+
name: typeof e.name === 'string' && e.name ? e.name : null,
|
|
117
|
+
repoRoot: typeof e.repoRoot === 'string' && e.repoRoot ? e.repoRoot : null,
|
|
118
|
+
savedAt: e.savedAt ?? null,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (typeof f.fleetToken === 'string' && f.fleetToken && typeof f.projectId === 'string' && f.projectId && !out.has(f.projectId)) {
|
|
122
|
+
out.set(f.projectId, {
|
|
123
|
+
projectId: f.projectId,
|
|
124
|
+
fleetToken: f.fleetToken,
|
|
125
|
+
mcpUrl: f.mcpUrl ?? null,
|
|
126
|
+
name: null,
|
|
127
|
+
repoRoot: null,
|
|
128
|
+
savedAt: null,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return [...out.values()].map((e) => ({ ...e, active: e.projectId === f.projectId }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What a project is CALLED on a terminal: its name, or an id you can grep. */
|
|
135
|
+
export function projectLabel(e) {
|
|
136
|
+
return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function mutate(fn) {
|
|
140
|
+
const f = readFile() ?? {};
|
|
141
|
+
if (!f.projects || typeof f.projects !== 'object') f.projects = {};
|
|
142
|
+
// Surface a legacy trio into the map before any edit, so nothing loses it.
|
|
143
|
+
if (typeof f.fleetToken === 'string' && f.fleetToken && typeof f.projectId === 'string' && f.projectId && !f.projects[f.projectId]) {
|
|
144
|
+
f.projects[f.projectId] = { fleetToken: f.fleetToken, mcpUrl: f.mcpUrl ?? null };
|
|
145
|
+
}
|
|
146
|
+
fn(f);
|
|
147
|
+
writeFile(f);
|
|
148
|
+
return f;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A fresh login: upsert the entry AND point the legacy mirror at it. */
|
|
152
|
+
export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot }) {
|
|
153
|
+
mutate((f) => {
|
|
154
|
+
f.projects[projectId] = {
|
|
155
|
+
...(f.projects[projectId] ?? {}),
|
|
156
|
+
fleetToken,
|
|
157
|
+
mcpUrl: mcpUrl ?? null,
|
|
158
|
+
...(name ? { name } : {}),
|
|
159
|
+
...(repoRoot ? { repoRoot } : {}),
|
|
160
|
+
savedAt: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
f.fleetToken = fleetToken;
|
|
163
|
+
f.projectId = projectId;
|
|
164
|
+
f.mcpUrl = mcpUrl ?? null;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** An explicit human pick: repoint the legacy mirror, and — when the pick was
|
|
169
|
+
* made standing in a repo — bind the entry there. A pick IS the confirmation;
|
|
170
|
+
* binding on anything less would cement a guess. */
|
|
171
|
+
export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
|
|
172
|
+
mutate((f) => {
|
|
173
|
+
const e = f.projects[projectId];
|
|
174
|
+
if (!e) return;
|
|
175
|
+
if (bindRepoRoot) e.repoRoot = bindRepoRoot;
|
|
176
|
+
f.fleetToken = e.fleetToken;
|
|
177
|
+
f.projectId = projectId;
|
|
178
|
+
f.mcpUrl = e.mcpUrl ?? null;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function bindStoredRepo(projectId, repoRoot) {
|
|
183
|
+
if (!repoRoot) return;
|
|
184
|
+
mutate((f) => {
|
|
185
|
+
if (f.projects[projectId]) f.projects[projectId].repoRoot = repoRoot;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The roster names the project on every poll; remember it so the picker and
|
|
190
|
+
* `flowviant projects` can say a NAME instead of an id. */
|
|
191
|
+
export function setStoredProjectName(projectId, name) {
|
|
192
|
+
if (!projectId || typeof name !== 'string' || !name) return;
|
|
193
|
+
const entries = listStoredProjects();
|
|
194
|
+
const e = entries.find((x) => x.projectId === projectId);
|
|
195
|
+
if (!e || e.name === name) return;
|
|
196
|
+
mutate((f) => {
|
|
197
|
+
if (f.projects[projectId]) f.projects[projectId].name = name;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Match `--project <ref>` against the store: exact id, id prefix (≥6), or
|
|
202
|
+
* case-insensitive name. Ambiguity is an error, never a coin flip. */
|
|
203
|
+
export function matchStoredProject(ref) {
|
|
204
|
+
const entries = listStoredProjects();
|
|
205
|
+
const q = String(ref ?? '').trim();
|
|
206
|
+
if (!q) return { error: 'empty --project value' };
|
|
207
|
+
const byId = entries.filter(
|
|
208
|
+
(e) => e.projectId === q || (q.length >= 6 && e.projectId.startsWith(q))
|
|
209
|
+
);
|
|
210
|
+
const byName = entries.filter((e) => e.name && e.name.toLowerCase() === q.toLowerCase());
|
|
211
|
+
const hits = byId.length > 0 ? byId : byName;
|
|
212
|
+
if (hits.length === 1) return { entry: hits[0] };
|
|
213
|
+
if (hits.length > 1) return { error: `"${q}" matches ${hits.length} stored projects — use the full project id` };
|
|
214
|
+
return { error: `no stored project matches "${q}"` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve which stored credential this invocation should use. PURE over the
|
|
219
|
+
* store + argv + cwd; every refusal and prompt above it is built from what
|
|
220
|
+
* this returns. Shapes:
|
|
221
|
+
* { entry, source: 'project-flag' | 'repo' | 'only', needsConfirm? }
|
|
222
|
+
* { choices, reason: 'no-match' | 'multiple-bound' | 'outside-repo', repoRoot }
|
|
223
|
+
* { none: true }
|
|
224
|
+
* { error }
|
|
225
|
+
*
|
|
226
|
+
* Deliberately NO mirror fallback once more than one project is stored: the
|
|
227
|
+
* mirror is whichever login happened last, and serving it because it was
|
|
228
|
+
* recent is exactly the calendar-says-skadooble surprise this file exists to
|
|
229
|
+
* end. Two projects means the answer is a QUESTION (or --project, or the
|
|
230
|
+
* repo binding), never recency.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveStoredCredential(argv = process.argv, cwd = process.cwd()) {
|
|
233
|
+
const i = argv.indexOf('--project');
|
|
234
|
+
const projectArg = i >= 0 ? argv[i + 1] : undefined;
|
|
235
|
+
if (projectArg !== undefined) {
|
|
236
|
+
const m = matchStoredProject(projectArg);
|
|
237
|
+
return m.entry ? { entry: m.entry, source: 'project-flag' } : { error: m.error };
|
|
238
|
+
}
|
|
239
|
+
const entries = listStoredProjects();
|
|
240
|
+
if (entries.length === 0) return { none: true };
|
|
241
|
+
const repoRoot = detectRepoRoot(cwd);
|
|
242
|
+
|
|
243
|
+
if (repoRoot) {
|
|
244
|
+
const bound = entries.filter((e) => samePath(e.repoRoot, repoRoot));
|
|
245
|
+
if (bound.length === 1) return { entry: bound[0], source: 'repo', repoRoot };
|
|
246
|
+
if (bound.length > 1) return { choices: bound, reason: 'multiple-bound', repoRoot };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ONE stored project with no binding: the pre-0.55.0 world. Serve it — a
|
|
250
|
+
// headless restart must not hang — but flag it, so a TTY start asks ONCE and
|
|
251
|
+
// binds, which is what turns "it said skadooble in my calendar repo" into a
|
|
252
|
+
// question instead of a surprise.
|
|
253
|
+
if (entries.length === 1 && !entries[0].repoRoot) {
|
|
254
|
+
return { entry: entries[0], source: 'only', needsConfirm: Boolean(repoRoot), repoRoot };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { choices: entries, reason: repoRoot ? 'no-match' : 'outside-repo', repoRoot };
|
|
258
|
+
}
|
package/bin/lib/env-cli.mjs
CHANGED
|
@@ -105,7 +105,11 @@ async function readSecretFromStdin(promptText) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export async function runEnvCommand(args) {
|
|
108
|
-
if (!FLEET_TOKEN)
|
|
108
|
+
if (!FLEET_TOKEN)
|
|
109
|
+
die(
|
|
110
|
+
'no credential resolves here — run `flowviant login`, or run this inside the ' +
|
|
111
|
+
"project's own repo / pass `--project <name|id>` (`flowviant projects` lists them)."
|
|
112
|
+
);
|
|
109
113
|
await sodium.ready;
|
|
110
114
|
await ensureKeypair();
|
|
111
115
|
const cmd = args[0];
|
package/bin/lib/env.mjs
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
mkdirSync,
|
|
35
35
|
existsSync,
|
|
36
36
|
appendFileSync,
|
|
37
|
+
chmodSync,
|
|
38
|
+
lstatSync,
|
|
37
39
|
rmSync,
|
|
38
40
|
} from 'node:fs';
|
|
39
41
|
import { execFileSync } from 'node:child_process';
|
|
@@ -123,11 +125,52 @@ export function myPubB64() {
|
|
|
123
125
|
return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
/** Query params the roster poll carries: identity
|
|
128
|
+
/** Query params the roster poll carries: identity, materialized version, and
|
|
129
|
+
* the target files we REFUSED to write.
|
|
130
|
+
*
|
|
131
|
+
* `envv` alone was a half-truth and the surface built on it said the wrong
|
|
132
|
+
* thing out loud: it is set the moment the bundle DECRYPTS, independent of
|
|
133
|
+
* whether a single byte reached a worktree, so a project whose `.env` is
|
|
134
|
+
* tracked in git got the green "on the current env" chip while every session
|
|
135
|
+
* ran on whatever stale placeholder git had checked out. The daemon knew —
|
|
136
|
+
* it warned, to a console nobody reads. `envskip` is that warning routed
|
|
137
|
+
* somewhere a human is actually looking.
|
|
138
|
+
*
|
|
139
|
+
* A daemon→server REPORT, so it needs no version floor: an older daemon
|
|
140
|
+
* simply sends no `envskip` key, which reads as "nothing to report" — and
|
|
141
|
+
* that is honest, because an older daemon genuinely is not measuring it.
|
|
142
|
+
* Bounded hard: a query string is not a log. */
|
|
127
143
|
export async function envQueryParams() {
|
|
128
144
|
await ensureKeypair();
|
|
129
145
|
const params = { envpub: myPubB64() };
|
|
130
146
|
if (bundleVersion >= 0) params.envv = String(bundleVersion);
|
|
147
|
+
// THE EMPTY STRING IS A REPORT, and it is the only thing that can ever CLEAR
|
|
148
|
+
// the surface's warning. Gating this on truthiness (which is what it did
|
|
149
|
+
// first) meant a person who followed the on-screen remedy exactly — gitignore
|
|
150
|
+
// the file, restart — sent no `envskip` at all, the server left the column
|
|
151
|
+
// alone by design, and the amber line stayed up forever telling them to fix
|
|
152
|
+
// something already fixed. Absence must keep meaning IGNORANCE, so the gate
|
|
153
|
+
// is "has a pass actually run", never "is there something to say".
|
|
154
|
+
// (fleet.mjs's query loop had to stop filtering on truthiness too — one
|
|
155
|
+
// check here is useless while a second one downstream drops the same value.)
|
|
156
|
+
if (everMaterialized) {
|
|
157
|
+
const files = [...new Set([...skippedByWorktree.values()].flat())].sort();
|
|
158
|
+
// Each path is percent-encoded BEFORE the join, because `isSafeEnvTargetFile`
|
|
159
|
+
// permits a comma in a filename and the server splits on one — unencoded,
|
|
160
|
+
// `a,b.env` would arrive as two files that do not exist. Truncation is by
|
|
161
|
+
// WHOLE ELEMENTS against a byte budget; a mid-path cut names a file nobody
|
|
162
|
+
// has, which is worse than naming fewer.
|
|
163
|
+
const parts = [];
|
|
164
|
+
let budget = 400;
|
|
165
|
+
for (const f of files) {
|
|
166
|
+
if (parts.length >= 10) break;
|
|
167
|
+
const enc = encodeURIComponent(f);
|
|
168
|
+
if (enc.length + 1 > budget) break;
|
|
169
|
+
parts.push(enc);
|
|
170
|
+
budget -= enc.length + 1;
|
|
171
|
+
}
|
|
172
|
+
params.envskip = parts.join(',');
|
|
173
|
+
}
|
|
131
174
|
return params;
|
|
132
175
|
}
|
|
133
176
|
|
|
@@ -310,6 +353,37 @@ function isIgnoredInGit(wt, relPath) {
|
|
|
310
353
|
|
|
311
354
|
// Per-worktree: the target files we last materialized THIS SESSION.
|
|
312
355
|
const lastFilesByWorktree = new Map();
|
|
356
|
+
|
|
357
|
+
/** Target files refused for a GIT reason, PER WORKTREE — reported to the
|
|
358
|
+
* server on the next poll. Per-worktree because the refusal is: both
|
|
359
|
+
* predicates (`isTrackedInGit`, `isIgnoredInGit`) run with `cwd: wt`, so
|
|
360
|
+
* ".env is refused" is a fact about ONE tree. A process-global set mixed two
|
|
361
|
+
* trees' answers together and, worse, could only ever grow.
|
|
362
|
+
*
|
|
363
|
+
* Names only — a path is not a secret, and the whole point is that a human
|
|
364
|
+
* can act on it ("gitignore apps/api/.dev.vars"). Only the two GIT causes go
|
|
365
|
+
* in here: they have a remedy the reader can carry out, and the surface names
|
|
366
|
+
* that remedy. A transient write failure is a warn, not a standing claim. */
|
|
367
|
+
const skippedByWorktree = new Map();
|
|
368
|
+
|
|
369
|
+
/** Worktrees whose most recent pass wrote everything it was asked to.
|
|
370
|
+
* `hasMaterialized` is built on THIS rather than on "a pass ran", so a pass
|
|
371
|
+
* that refused something RETRIES on the next turn — which is what lets
|
|
372
|
+
* `echo .env >> .gitignore` actually take effect without waiting for an
|
|
373
|
+
* unrelated bundle change. A pass with nothing to write counts as clean. */
|
|
374
|
+
const cleanWorktrees = new Set();
|
|
375
|
+
|
|
376
|
+
/** True once any materialization pass has completed. Distinguishes "we refused
|
|
377
|
+
* nothing" from "we have not looked", which is the whole contract of the
|
|
378
|
+
* `envskip` report — see envQueryParams. */
|
|
379
|
+
let everMaterialized = false;
|
|
380
|
+
|
|
381
|
+
/** Has this process completed a CLEAN materialization pass for this worktree?
|
|
382
|
+
* The creation-only rule (work.mjs) needs a second condition or a directory
|
|
383
|
+
* that existed before the bundle did is never revisited. */
|
|
384
|
+
export function hasMaterialized(wt) {
|
|
385
|
+
return cleanWorktrees.has(wt);
|
|
386
|
+
}
|
|
313
387
|
// Project-global union of every target file we've ever materialized — PERSISTED
|
|
314
388
|
// in the cache and seeded on load, so a file whose key was deleted while the
|
|
315
389
|
// daemon was down still gets its stale plaintext copy cleaned on the next
|
|
@@ -366,6 +440,15 @@ export function appSecretsFor(env) {
|
|
|
366
440
|
* app secrets go to the provider at deploy, deploy creds are injected only. */
|
|
367
441
|
export function materializeInto(wt) {
|
|
368
442
|
if (!wt || !existsSync(wt)) return;
|
|
443
|
+
// NEVER SYNCED IS NOT "NO SECRETS", and conflating them cost a whole session.
|
|
444
|
+
// `values` is empty both before the first bundle lands and for a project that
|
|
445
|
+
// genuinely has none; `bundleVersion < 0` is the one that means IGNORANCE.
|
|
446
|
+
// Writing nothing here and RECORDING it as materialized let a worktree
|
|
447
|
+
// created on the first poll after a restart — before handleRosterEnv had
|
|
448
|
+
// warmed the cache — sit secret-less for its entire life, because nothing
|
|
449
|
+
// re-materializes a directory that is neither fresh nor covered by a bundle
|
|
450
|
+
// CHANGE. Returning without recording is what makes the next turn retry.
|
|
451
|
+
if (bundleVersion < 0) return;
|
|
369
452
|
const byFile = new Map();
|
|
370
453
|
for (const v of values) {
|
|
371
454
|
if (v.scope !== 'app' || v.env !== 'dev') continue; // only local dev secrets hit a worktree file
|
|
@@ -382,9 +465,16 @@ export function materializeInto(wt) {
|
|
|
382
465
|
excludeInWorktree(wt, [...byFile.keys()]);
|
|
383
466
|
|
|
384
467
|
const written = [];
|
|
468
|
+
/** Refused for a GIT reason this pass — reported, and remediable. */
|
|
469
|
+
const refusedForGit = [];
|
|
470
|
+
/** Anything that did not get written, git reasons and write failures alike.
|
|
471
|
+
* Blocks the clean mark so the next turn tries again. */
|
|
472
|
+
let anyProblem = false;
|
|
385
473
|
for (const [file, list] of byFile) {
|
|
386
474
|
if (isTrackedInGit(wt, file)) {
|
|
387
475
|
warn(`env: "${file}" is tracked in git — refusing to write secrets there (gitignore it). Its keys are NOT materialized.`);
|
|
476
|
+
refusedForGit.push(file);
|
|
477
|
+
anyProblem = true;
|
|
388
478
|
continue;
|
|
389
479
|
}
|
|
390
480
|
// The load-bearing check. A materialized secret sits in a worktree whose
|
|
@@ -392,10 +482,27 @@ export function materializeInto(wt) {
|
|
|
392
482
|
// "git cannot see this file" is a precondition for writing it, not a nicety.
|
|
393
483
|
if (!isIgnoredInGit(wt, file)) {
|
|
394
484
|
warn(`env: "${file}" is not gitignored — refusing to write secrets there. Add it to .gitignore. Its keys are NOT materialized.`);
|
|
485
|
+
refusedForGit.push(file);
|
|
486
|
+
anyProblem = true;
|
|
395
487
|
continue;
|
|
396
488
|
}
|
|
397
489
|
try {
|
|
398
490
|
const abs = join(wt, file);
|
|
491
|
+
// A SYMLINK AT THE TARGET IS NOT A TARGET. `writeFileSync` follows one,
|
|
492
|
+
// so a link committed into the repo (or dropped by an agent) at the
|
|
493
|
+
// materialization path would write the project's decrypted secrets
|
|
494
|
+
// wherever it points — outside the worktree, and outside everything the
|
|
495
|
+
// check-ignore gate can reason about. `lstat`, not `stat`, and refuse.
|
|
496
|
+
// Cheap, and the whole exposure is one call away otherwise.
|
|
497
|
+
try {
|
|
498
|
+
if (lstatSync(abs).isSymbolicLink()) {
|
|
499
|
+
warn(`env: "${file}" is a symlink — refusing to write secrets through it.`);
|
|
500
|
+
anyProblem = true;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
} catch {
|
|
504
|
+
/* does not exist yet — the ordinary case */
|
|
505
|
+
}
|
|
399
506
|
mkdirSync(dirname(abs), { recursive: true });
|
|
400
507
|
const body = renderEnvFile(list);
|
|
401
508
|
// Skip an identical rewrite — otherwise every bundle bump touches the
|
|
@@ -407,9 +514,22 @@ export function materializeInto(wt) {
|
|
|
407
514
|
/* new file */
|
|
408
515
|
}
|
|
409
516
|
if (prior !== body) writeFileSync(abs, body, { mode: 0o600 });
|
|
517
|
+
// `mode` on writeFileSync applies at CREATION only — an overwrite of a
|
|
518
|
+
// file that already existed keeps whatever mode it had, so a 0644 stub
|
|
519
|
+
// committed by a teammate (or left by an older daemon) would hold
|
|
520
|
+
// plaintext secrets world-readable on a shared box. chmod every time.
|
|
521
|
+
try {
|
|
522
|
+
chmodSync(abs, 0o600);
|
|
523
|
+
} catch {
|
|
524
|
+
/* best-effort: a filesystem without modes is not a reason to refuse */
|
|
525
|
+
}
|
|
410
526
|
written.push(file);
|
|
411
527
|
} catch (e) {
|
|
528
|
+
// NOT reported as a refusal: the surface's line names a git cause and a
|
|
529
|
+
// git remedy, and a full disk is neither. It still blocks the clean mark,
|
|
530
|
+
// so the next turn retries.
|
|
412
531
|
warn(`env: could not write ${file} into worktree: ${e.message}`);
|
|
532
|
+
anyProblem = true;
|
|
413
533
|
}
|
|
414
534
|
}
|
|
415
535
|
|
|
@@ -423,6 +543,18 @@ export function materializeInto(wt) {
|
|
|
423
543
|
}
|
|
424
544
|
for (const f of written) knownTargetFiles.add(f);
|
|
425
545
|
lastFilesByWorktree.set(wt, written);
|
|
546
|
+
|
|
547
|
+
// THE PASS'S VERDICT, recorded whole and REPLACING the previous one — this is
|
|
548
|
+
// what lets a refusal clear. `refusedForGit` is recomputed from scratch every
|
|
549
|
+
// pass, so a file that gets gitignored simply is not in the next one, and the
|
|
550
|
+
// union reported on the poll shrinks. `anyProblem` (which also covers a write
|
|
551
|
+
// failure) is what decides whether this worktree gets retried on the next
|
|
552
|
+
// turn; a clean pass is remembered so we stop touching a live directory.
|
|
553
|
+
if (refusedForGit.length > 0) skippedByWorktree.set(wt, refusedForGit);
|
|
554
|
+
else skippedByWorktree.delete(wt);
|
|
555
|
+
if (anyProblem) cleanWorktrees.delete(wt);
|
|
556
|
+
else cleanWorktrees.add(wt);
|
|
557
|
+
everMaterialized = true;
|
|
426
558
|
}
|
|
427
559
|
|
|
428
560
|
/**
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
REFRESH_BEFORE_SECONDS,
|
|
32
32
|
LIVE,
|
|
33
33
|
AUTO_UPDATE,
|
|
34
|
+
CREDENTIAL,
|
|
34
35
|
} from './config.mjs';
|
|
36
|
+
import { projectLabel, setStoredProjectName } from './credentials.mjs';
|
|
35
37
|
import { handleVersionSignal } from './update.mjs';
|
|
36
38
|
import {
|
|
37
39
|
git,
|
|
@@ -67,6 +69,7 @@ import { ensureVault, syncVault } from './vault.mjs';
|
|
|
67
69
|
import {
|
|
68
70
|
envQueryParams,
|
|
69
71
|
handleRosterEnv,
|
|
72
|
+
loadCachedEnv,
|
|
70
73
|
materializeInto,
|
|
71
74
|
myPubB64,
|
|
72
75
|
scrub as envScrub,
|
|
@@ -148,9 +151,17 @@ async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds =
|
|
|
148
151
|
/* best-effort — the poll must never fail on a readout */
|
|
149
152
|
}
|
|
150
153
|
// Env-sync identity + materialized version (the Settings "env vN" chip).
|
|
154
|
+
//
|
|
155
|
+
// `!= null`, NOT truthiness. `envskip` uses the EMPTY STRING as a real
|
|
156
|
+
// report — "measured, refused nothing" — and it is the only value that can
|
|
157
|
+
// clear the surface's warning. A `if (v)` here silently dropped it, so
|
|
158
|
+
// fixing the filter in envQueryParams alone would have changed nothing.
|
|
159
|
+
// This is the same trap the skills relay eight lines up documents and
|
|
160
|
+
// sidesteps by calling `url.searchParams.set` directly; the general fix is
|
|
161
|
+
// better than a second special case.
|
|
151
162
|
try {
|
|
152
163
|
for (const [k, v] of Object.entries(await envQueryParams())) {
|
|
153
|
-
if (v) url.searchParams.set(k, v);
|
|
164
|
+
if (v != null) url.searchParams.set(k, v);
|
|
154
165
|
}
|
|
155
166
|
} catch {
|
|
156
167
|
/* env identity is best-effort — the poll must never fail on it */
|
|
@@ -322,6 +333,14 @@ export async function runFleetDaemon() {
|
|
|
322
333
|
const repoRoot = repoRootOrDie();
|
|
323
334
|
const baseRef = detectBaseRef(repoRoot);
|
|
324
335
|
info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
|
|
336
|
+
// WHICH PROJECT, before anything connects — the roster names it again a few
|
|
337
|
+
// seconds later with the server's word, but "which project is this daemon
|
|
338
|
+
// about to serve" must not require a network round trip to answer. Only when
|
|
339
|
+
// the credential came from the STORE: a --fleet/env token names no project
|
|
340
|
+
// until the roster does.
|
|
341
|
+
if (CREDENTIAL?.entry) {
|
|
342
|
+
info(`serves · ${projectLabel(CREDENTIAL.entry)} ${c.dim(`(${CREDENTIAL.entry.projectId.slice(0, 8)}…)`)}`);
|
|
343
|
+
}
|
|
325
344
|
info(`repo · ${repoRoot}`);
|
|
326
345
|
info(`base · ${baseRef}`);
|
|
327
346
|
info(`server · ${FLEET_URL}`);
|
|
@@ -385,6 +404,39 @@ export async function runFleetDaemon() {
|
|
|
385
404
|
warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
|
|
386
405
|
|
|
387
406
|
await preflight({ needGit: true });
|
|
407
|
+
|
|
408
|
+
// WARM THE ENV CACHE BEFORE THE FIRST POLL, not on the first roster tick.
|
|
409
|
+
// `handleRosterEnv` loads it, and `handleRosterEnv` runs AFTER
|
|
410
|
+
// `processWorkTurns` in the reconcile below — so on the first poll after a
|
|
411
|
+
// restart a brand-new session worktree was materialized against an EMPTY
|
|
412
|
+
// bundle, and then never revisited (creation-only, and `needSync` is false
|
|
413
|
+
// when the cache holds the version the server is already on). The turn ran
|
|
414
|
+
// with no secrets and nothing said so.
|
|
415
|
+
//
|
|
416
|
+
// This is only possible since 0.55.0: the credential store knows which
|
|
417
|
+
// project this checkout is, so the cache — which is keyed by projectId — can
|
|
418
|
+
// be found before the server has named anything. A `--fleet`/env token names
|
|
419
|
+
// no project until the roster does, so it keeps the old lazy path.
|
|
420
|
+
// Best-effort throughout: a cache miss is the ordinary first-run state.
|
|
421
|
+
//
|
|
422
|
+
// GATED ON THE STORE ACTUALLY BEING THE SOURCE. `--fleet` / `FLOWVIANT_FLEET`
|
|
423
|
+
// OVERRIDE the stored credential (config.mjs), but `CREDENTIAL` is resolved
|
|
424
|
+
// from the store regardless — so reading its projectId here would decrypt and
|
|
425
|
+
// materialize project A's cached secrets while this daemon is serving project
|
|
426
|
+
// B's token. That is the wrong project's plaintext in a worktree, which is
|
|
427
|
+
// the exact failure the repo binding exists to prevent, arriving by a
|
|
428
|
+
// different door. An external token names no project until the roster does,
|
|
429
|
+
// so it keeps the lazy path and loses nothing but one poll.
|
|
430
|
+
const externalToken =
|
|
431
|
+
process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
|
|
432
|
+
if (!externalToken && CREDENTIAL?.entry?.projectId) {
|
|
433
|
+
try {
|
|
434
|
+
await loadCachedEnv(CREDENTIAL.entry.projectId);
|
|
435
|
+
} catch {
|
|
436
|
+
/* no cache, no keypair yet, unreadable home — the roster tick retries */
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
388
440
|
// Kill any preview dev-server/tunnel groups a previously-crashed daemon left
|
|
389
441
|
// running (detached children survive an ungraceful exit) before we start fresh.
|
|
390
442
|
reapOrphanPreviews((m) => info(m));
|
|
@@ -1305,6 +1357,11 @@ export async function runFleetDaemon() {
|
|
|
1305
1357
|
`${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
|
|
1306
1358
|
);
|
|
1307
1359
|
note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
|
|
1360
|
+
// Remember the NAME beside the stored credential, so the picker and
|
|
1361
|
+
// `flowviant projects` can say "skadooble" instead of an id — a
|
|
1362
|
+
// credential saved before the server sent names backfills here. No-op
|
|
1363
|
+
// for a --fleet/env token (nothing stored to annotate).
|
|
1364
|
+
setStoredProjectName(roster.project.id, roster.project.name);
|
|
1308
1365
|
}
|
|
1309
1366
|
}
|
|
1310
1367
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
package/bin/lib/login.mjs
CHANGED
|
@@ -6,33 +6,15 @@
|
|
|
6
6
|
* plain `flowviant` just runs — no token, no env var.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
import { homedir } from 'node:os';
|
|
12
9
|
import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
|
|
10
|
+
import { saveLogin, detectRepoRoot, projectLabel } from './credentials.mjs';
|
|
13
11
|
import { c, info, ok, warn, fail } from './ui.mjs';
|
|
14
12
|
import { sleep } from './claude.mjs';
|
|
15
13
|
|
|
16
|
-
const CRED_DIR = join(homedir(), '.flowviant');
|
|
17
|
-
const CRED_FILE = join(CRED_DIR, 'credentials.json');
|
|
18
14
|
const DEVICE_START = FLEET_URL.replace(/\/agents\/?$/, '/device/start');
|
|
19
15
|
const DEVICE_POLL = FLEET_URL.replace(/\/agents\/?$/, '/device/poll');
|
|
20
16
|
const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
|
|
21
17
|
|
|
22
|
-
/** The locally-stored credential from a prior `login`, or null. Read by config. */
|
|
23
|
-
export function readStoredCredential() {
|
|
24
|
-
try {
|
|
25
|
-
return JSON.parse(readFileSync(CRED_FILE, 'utf8'));
|
|
26
|
-
} catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function store(cred) {
|
|
32
|
-
mkdirSync(CRED_DIR, { recursive: true });
|
|
33
|
-
writeFileSync(CRED_FILE, JSON.stringify(cred, null, 2), { mode: 0o600 });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
18
|
async function post(url, body) {
|
|
37
19
|
const res = await fetch(url, {
|
|
38
20
|
method: 'POST',
|
|
@@ -76,10 +58,26 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
76
58
|
if (poll.status === 'approved') {
|
|
77
59
|
// `machineToken` is the wire's new name; `fleetToken` is the one every
|
|
78
60
|
// published daemon reads. The server dual-sends until DAEMON_MIN clears
|
|
79
|
-
//
|
|
80
|
-
// old key possible at all.
|
|
81
|
-
|
|
82
|
-
|
|
61
|
+
// the release that reads the new one (0.54.2+) — reading both here is
|
|
62
|
+
// what makes retiring the old key possible at all.
|
|
63
|
+
//
|
|
64
|
+
// BOUND to the repo the login was run in: a login is the one moment we
|
|
65
|
+
// know for certain which checkout this project means, and the binding is
|
|
66
|
+
// what lets a multi-project VM resolve `npx flowviant` by DIRECTORY
|
|
67
|
+
// instead of by whichever login happened last.
|
|
68
|
+
const repoRoot = detectRepoRoot();
|
|
69
|
+
const entry = {
|
|
70
|
+
fleetToken: poll.machineToken ?? poll.fleetToken,
|
|
71
|
+
projectId: poll.projectId,
|
|
72
|
+
mcpUrl: poll.mcpUrl,
|
|
73
|
+
name: typeof poll.projectName === 'string' && poll.projectName ? poll.projectName : null,
|
|
74
|
+
repoRoot,
|
|
75
|
+
};
|
|
76
|
+
saveLogin(entry);
|
|
77
|
+
ok(
|
|
78
|
+
`connected to ${c.bold(projectLabel(entry))}` +
|
|
79
|
+
`${repoRoot ? ` for ${c.dim(repoRoot)}` : ''} — saved to ~/.flowviant/credentials.json`
|
|
80
|
+
);
|
|
83
81
|
// The daemon starts right here unless the caller opted out; telling
|
|
84
82
|
// someone to run a second command was the step that got missed, since by
|
|
85
83
|
// this point they are looking at the browser, not this terminal.
|
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -23,8 +23,9 @@ const CLI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/cli-token');
|
|
|
23
23
|
export async function runMcpCommand(args = []) {
|
|
24
24
|
if (!FLEET_TOKEN) {
|
|
25
25
|
console.error(
|
|
26
|
-
'error: no credential. Run `flowviant login` first —
|
|
27
|
-
'
|
|
26
|
+
'error: no credential resolves here. Run `flowviant login` first — or, with\n' +
|
|
27
|
+
'several projects connected on this box, run this inside the project\'s own\n' +
|
|
28
|
+
'repo or pass `--project <name|id>` (`flowviant projects` lists them).'
|
|
28
29
|
);
|
|
29
30
|
process.exit(1);
|
|
30
31
|
}
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -79,7 +79,16 @@ export function humanizeClaudeTool(name, input = {}, cwd = '') {
|
|
|
79
79
|
case 'LS':
|
|
80
80
|
return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
|
|
81
81
|
case 'Bash':
|
|
82
|
-
|
|
82
|
+
// `command` rides beside the display label, VERBATIM (capped): the label
|
|
83
|
+
// is a 60-char readout for a console and the rail, and truncation is
|
|
84
|
+
// fine there — but the admin's command audit exists to answer "what
|
|
85
|
+
// actually ran on our box", and an ellipsis is exactly where the part
|
|
86
|
+
// that matters would hide.
|
|
87
|
+
return {
|
|
88
|
+
kind: 'bash',
|
|
89
|
+
command: String(input.command ?? '').slice(0, 2000),
|
|
90
|
+
label: `$ ${oneLine(input.command, 60)}`,
|
|
91
|
+
};
|
|
83
92
|
default:
|
|
84
93
|
return null; // other tools: silent
|
|
85
94
|
}
|
|
@@ -103,7 +112,11 @@ function humanizeCodexItem(item = {}, cwd = '') {
|
|
|
103
112
|
case 'reasoning':
|
|
104
113
|
return { kind: 'think', label: oneLine(item.text) || 'thinking…' };
|
|
105
114
|
case 'command_execution':
|
|
106
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
kind: 'bash',
|
|
117
|
+
command: String(item.command ?? '').slice(0, 2000),
|
|
118
|
+
label: `$ ${oneLine(item.command, 60)}`,
|
|
119
|
+
};
|
|
107
120
|
case 'file_change': {
|
|
108
121
|
// `changes` is a list of touched paths; the daemon counts distinct files,
|
|
109
122
|
// so emit one activity per path rather than one for the batch.
|
|
@@ -207,7 +220,11 @@ function humanizeAgyTool(name, p = {}, cwd = '') {
|
|
|
207
220
|
case 'list_dir':
|
|
208
221
|
return { kind: 'list', label: `ls ${shortPath(path, cwd)}` };
|
|
209
222
|
case 'run_command':
|
|
210
|
-
return {
|
|
223
|
+
return {
|
|
224
|
+
kind: 'bash',
|
|
225
|
+
command: String(p.CommandLine ?? '').slice(0, 2000),
|
|
226
|
+
label: `$ ${oneLine(p.CommandLine, 60)}`,
|
|
227
|
+
};
|
|
211
228
|
case 'call_mcp_tool':
|
|
212
229
|
return { kind: 'tool', label: `mcp.${p.ToolName ?? ''}` };
|
|
213
230
|
default:
|
package/bin/lib/update.mjs
CHANGED
|
@@ -59,7 +59,17 @@ function reexec(teardown) {
|
|
|
59
59
|
}
|
|
60
60
|
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
61
61
|
stdio: 'inherit',
|
|
62
|
-
|
|
62
|
+
// MARK THE CHILD AS A RESTART, not as a person typing `flowviant`.
|
|
63
|
+
// stdio is inherited, so the child sees two TTYs and believes a human is
|
|
64
|
+
// watching — and 0.55.0 asks a one-time binding question on exactly that
|
|
65
|
+
// signal. An auto-update that lands while nobody is looking would then sit
|
|
66
|
+
// on `Serve this repo as X? [Y/n]` with the machine dark until someone
|
|
67
|
+
// walks past. The rule credentials.mjs already states for systemd applies
|
|
68
|
+
// verbatim here: a RESTART must not hang on a prompt. Skipping the confirm
|
|
69
|
+
// is not a widening — the daemon serves exactly the credential it was
|
|
70
|
+
// already serving one second ago, and the question gets asked the next
|
|
71
|
+
// time a human starts it by hand.
|
|
72
|
+
env: { ...process.env, FLOWVIANT_REEXEC: '1' },
|
|
63
73
|
});
|
|
64
74
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
65
75
|
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
SYSTEM_WORK_PLAIN,
|
|
49
49
|
WORK_TURN_KICKOFF_PLAIN,
|
|
50
50
|
} from './prompts.mjs';
|
|
51
|
-
import { materializeInto, excludeInWorktree, scrub as envScrub } from './env.mjs';
|
|
51
|
+
import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
|
|
52
52
|
import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
|
|
53
53
|
import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
|
|
54
54
|
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
@@ -102,6 +102,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
102
102
|
const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
|
|
103
103
|
const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
|
|
104
104
|
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
105
|
+
const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
|
|
105
106
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
106
107
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
107
108
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
@@ -899,6 +900,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
899
900
|
} catch {
|
|
900
901
|
/* best-effort */
|
|
901
902
|
}
|
|
903
|
+
} else if (!hasMaterialized(wt)) {
|
|
904
|
+
// CREATION-ONLY NEEDED A SECOND CONDITION. The rule above is right about
|
|
905
|
+
// a LIVE directory — its env belongs to the session and re-writing it
|
|
906
|
+
// mid-flight is not ours to do — but "created" and "ever given a bundle"
|
|
907
|
+
// are different events, and the gap between them is a whole daemon
|
|
908
|
+
// restart: `handleRosterEnv` (which warms the encrypted cache) runs
|
|
909
|
+
// AFTER `processWorkTurns` on the same poll, so a worktree made on the
|
|
910
|
+
// first turn after a restart was materialized against an EMPTY bundle
|
|
911
|
+
// and, being neither fresh nor covered by a bundle CHANGE, never
|
|
912
|
+
// revisited. `materializeInto` now declines to record a pass it made in
|
|
913
|
+
// ignorance (bundleVersion < 0), so this branch is what retries it —
|
|
914
|
+
// once, on the next turn, and never again after it succeeds. Idempotent
|
|
915
|
+
// by construction: identical bodies are not rewritten, so nothing
|
|
916
|
+
// hot-restarts a dev server the driver is watching.
|
|
917
|
+
try {
|
|
918
|
+
materializeInto(wt);
|
|
919
|
+
} catch {
|
|
920
|
+
/* best-effort */
|
|
921
|
+
}
|
|
902
922
|
}
|
|
903
923
|
return { wt, fresh };
|
|
904
924
|
};
|
|
@@ -1584,6 +1604,45 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1584
1604
|
let seenThreadId = null; // codex's conversation id, off thread.started
|
|
1585
1605
|
const spawned = []; // this turn's children, for the teardown registry
|
|
1586
1606
|
const narrator = makeNarrator(job.sessionId, job.id);
|
|
1607
|
+
|
|
1608
|
+
// THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
|
|
1609
|
+
// to the server verbatim so an admin can read what actually ran on
|
|
1610
|
+
// this box. Same events the narrator renders and forgets; this is
|
|
1611
|
+
// the durable copy, and it carries ONLY commands — no prose, no
|
|
1612
|
+
// thinking, no file reads (the session stays private; what executed
|
|
1613
|
+
// on the shared machine is the machine's own fact to relay).
|
|
1614
|
+
// Flushed mid-turn every 25 so a long turn is not one giant loss on
|
|
1615
|
+
// a kill, and again at settle. Best-effort: a failed post drops the
|
|
1616
|
+
// batch rather than blocking the turn — the surface says it is the
|
|
1617
|
+
// machine's report, not a syscall trace.
|
|
1618
|
+
const auditBatch = [];
|
|
1619
|
+
const flushAudit = () => {
|
|
1620
|
+
if (auditBatch.length === 0) return;
|
|
1621
|
+
const commands = auditBatch.splice(0, auditBatch.length);
|
|
1622
|
+
void fetch(SESSION_COMMANDS_URL, {
|
|
1623
|
+
method: 'POST',
|
|
1624
|
+
headers: {
|
|
1625
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1626
|
+
'User-Agent': USER_AGENT,
|
|
1627
|
+
'Content-Type': 'application/json',
|
|
1628
|
+
},
|
|
1629
|
+
signal: AbortSignal.timeout(30_000),
|
|
1630
|
+
body: JSON.stringify({
|
|
1631
|
+
sessionId: job.sessionId,
|
|
1632
|
+
turnId: job.id,
|
|
1633
|
+
runtime: rt.id,
|
|
1634
|
+
cwd: dir.wt,
|
|
1635
|
+
commands,
|
|
1636
|
+
}),
|
|
1637
|
+
}).catch(() => {
|
|
1638
|
+
/* best-effort — the audit records what reached it */
|
|
1639
|
+
});
|
|
1640
|
+
};
|
|
1641
|
+
const auditCommand = (a) => {
|
|
1642
|
+
if (a?.kind !== 'bash' || !a.command) return;
|
|
1643
|
+
auditBatch.push({ command: a.command, at: new Date().toISOString() });
|
|
1644
|
+
if (auditBatch.length >= 25) flushAudit();
|
|
1645
|
+
};
|
|
1587
1646
|
try {
|
|
1588
1647
|
// Files first, then the message that references them: the agent
|
|
1589
1648
|
// must be able to open what it is being told about. Only the ones
|
|
@@ -1627,7 +1686,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1627
1686
|
// posted once. Every line goes to the narrator above, throttled.
|
|
1628
1687
|
streamJson: true,
|
|
1629
1688
|
answerFromResult: true,
|
|
1630
|
-
onActivity: (a) =>
|
|
1689
|
+
onActivity: (a) => {
|
|
1690
|
+
narrator.line(a?.label);
|
|
1691
|
+
auditCommand(a);
|
|
1692
|
+
},
|
|
1631
1693
|
// What this CLI says it can be asked for by name. Harvested off
|
|
1632
1694
|
// the init event the stream already carries — no probe, no scan,
|
|
1633
1695
|
// no extra spawn — and reported on the next roster poll so the
|
|
@@ -1683,6 +1745,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1683
1745
|
// is cleared server-side at settle — clearing it here would race
|
|
1684
1746
|
// the settle and blank the tab a beat before the reply lands.
|
|
1685
1747
|
narrator.stop();
|
|
1748
|
+
flushAudit();
|
|
1686
1749
|
for (const ch of spawned) workChildren.delete(ch);
|
|
1687
1750
|
if (lockPath) {
|
|
1688
1751
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.55.1",
|
|
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": {
|
|
7
7
|
"flowviant": "bin/cli.mjs"
|