flowviant 0.54.2 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +144 -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/fleet.mjs +15 -0
- package/bin/lib/login.mjs +21 -23
- package/bin/lib/mcp-cli.mjs +3 -2
- package/package.json +1 -1
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,117 @@ 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
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
237
|
+
const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
|
|
238
|
+
|
|
239
|
+
/** Re-exec a plain `flowviant` after an inline login — the login command's own
|
|
240
|
+
* pattern: config.mjs read the store at IMPORT time, before the credential
|
|
241
|
+
* existed, so this process cannot serve; the child can. */
|
|
242
|
+
async function reexecAfterLogin() {
|
|
243
|
+
await runLogin({ thenStart: false });
|
|
244
|
+
const { spawn } = await import('node:child_process');
|
|
245
|
+
const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
|
|
246
|
+
process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function listLines(entries, { projectLabel }) {
|
|
250
|
+
return entries
|
|
251
|
+
.map(
|
|
252
|
+
(e, i) =>
|
|
253
|
+
` ${i + 1}. ${projectLabel(e)}` +
|
|
254
|
+
(e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet')
|
|
255
|
+
)
|
|
256
|
+
.join('\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
194
259
|
if (!FLEET_TOKEN) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
260
|
+
if (CREDENTIAL.error) {
|
|
261
|
+
console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
if (CREDENTIAL.choices?.length && interactive) {
|
|
265
|
+
const creds = await import('./lib/credentials.mjs');
|
|
266
|
+
const { choices, repoRoot } = CREDENTIAL;
|
|
267
|
+
console.log(
|
|
268
|
+
CREDENTIAL.reason === 'outside-repo'
|
|
269
|
+
? 'flowviant is not inside a git repo, and more than one project is connected here.'
|
|
270
|
+
: CREDENTIAL.reason === 'multiple-bound'
|
|
271
|
+
? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
|
|
272
|
+
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
273
|
+
);
|
|
274
|
+
console.log(listLines(choices, creds));
|
|
275
|
+
console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
|
|
276
|
+
const rl = (await import('node:readline/promises')).createInterface({
|
|
277
|
+
input: process.stdin,
|
|
278
|
+
output: process.stdout,
|
|
279
|
+
});
|
|
280
|
+
const raw = (await rl.question(`Which project should this daemon serve? [1-${choices.length + 1}] `)).trim();
|
|
281
|
+
rl.close();
|
|
282
|
+
const n = Number.parseInt(raw, 10);
|
|
283
|
+
if (n === choices.length + 1) await reexecAfterLogin();
|
|
284
|
+
const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
|
|
285
|
+
if (!picked) {
|
|
286
|
+
console.error('nothing chosen — nothing started.');
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
// An answered question is consent: adopt it, and BIND it to this repo so
|
|
290
|
+
// the next start needs no prompt. Repointing is legitimate and said aloud.
|
|
291
|
+
if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
|
|
292
|
+
console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
|
|
293
|
+
}
|
|
294
|
+
adoptStoredCredential(picked);
|
|
295
|
+
creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
|
|
296
|
+
console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
|
|
297
|
+
} else if (CREDENTIAL.choices?.length) {
|
|
298
|
+
const creds = await import('./lib/credentials.mjs');
|
|
299
|
+
console.error(
|
|
300
|
+
'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
|
|
301
|
+
listLines(CREDENTIAL.choices, creds) +
|
|
302
|
+
'\nPick one with `--project <name|id>`, bind this repo by running `flowviant` here in a terminal once,\n' +
|
|
303
|
+
'or connect this repo to its own project with `flowviant login`.'
|
|
304
|
+
);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
} else {
|
|
307
|
+
console.error(
|
|
308
|
+
'error: no credential found. Easiest:\n' +
|
|
309
|
+
' flowviant login (approve in the app — recommended)\n' +
|
|
310
|
+
'Or set:\n' +
|
|
311
|
+
' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
|
|
312
|
+
);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
} else if (!externalToken && CREDENTIAL.needsConfirm && interactive) {
|
|
316
|
+
// ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
|
|
317
|
+
// yes binds and every later start is silent. This is the exact question
|
|
318
|
+
// whose absence had a calendar checkout serving skadooble.
|
|
319
|
+
const creds = await import('./lib/credentials.mjs');
|
|
320
|
+
const label = creds.projectLabel(CREDENTIAL.entry);
|
|
321
|
+
const rl = (await import('node:readline/promises')).createInterface({
|
|
322
|
+
input: process.stdin,
|
|
323
|
+
output: process.stdout,
|
|
324
|
+
});
|
|
325
|
+
const raw = (
|
|
326
|
+
await rl.question(`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `)
|
|
327
|
+
).trim().toLowerCase();
|
|
328
|
+
rl.close();
|
|
329
|
+
if (raw === '' || raw === 'y' || raw === 'yes') {
|
|
330
|
+
creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
|
|
331
|
+
} else {
|
|
332
|
+
console.error(
|
|
333
|
+
`nothing started. Connect this repo to its own project with \`flowviant login\`, ` +
|
|
334
|
+
`or see what is stored with \`flowviant projects\`.`
|
|
335
|
+
);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
202
338
|
}
|
|
203
339
|
|
|
204
340
|
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/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,
|
|
@@ -322,6 +324,14 @@ export async function runFleetDaemon() {
|
|
|
322
324
|
const repoRoot = repoRootOrDie();
|
|
323
325
|
const baseRef = detectBaseRef(repoRoot);
|
|
324
326
|
info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
|
|
327
|
+
// WHICH PROJECT, before anything connects — the roster names it again a few
|
|
328
|
+
// seconds later with the server's word, but "which project is this daemon
|
|
329
|
+
// about to serve" must not require a network round trip to answer. Only when
|
|
330
|
+
// the credential came from the STORE: a --fleet/env token names no project
|
|
331
|
+
// until the roster does.
|
|
332
|
+
if (CREDENTIAL?.entry) {
|
|
333
|
+
info(`serves · ${projectLabel(CREDENTIAL.entry)} ${c.dim(`(${CREDENTIAL.entry.projectId.slice(0, 8)}…)`)}`);
|
|
334
|
+
}
|
|
325
335
|
info(`repo · ${repoRoot}`);
|
|
326
336
|
info(`base · ${baseRef}`);
|
|
327
337
|
info(`server · ${FLEET_URL}`);
|
|
@@ -1305,6 +1315,11 @@ export async function runFleetDaemon() {
|
|
|
1305
1315
|
`${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
|
|
1306
1316
|
);
|
|
1307
1317
|
note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
|
|
1318
|
+
// Remember the NAME beside the stored credential, so the picker and
|
|
1319
|
+
// `flowviant projects` can say "skadooble" instead of an id — a
|
|
1320
|
+
// credential saved before the server sent names backfills here. No-op
|
|
1321
|
+
// for a --fleet/env token (nothing stored to annotate).
|
|
1322
|
+
setStoredProjectName(roster.project.id, roster.project.name);
|
|
1308
1323
|
}
|
|
1309
1324
|
}
|
|
1310
1325
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|