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 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
- console.error(
196
- 'error: no credential found. Easiest:\n' +
197
- ' flowviant login (approve in the app — recommended)\n' +
198
- 'Or set:\n' +
199
- ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
200
- );
201
- process.exit(1);
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();
@@ -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 { homedir, cpus, totalmem } from 'node:os';
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
- function readStoredCredential() {
34
- try {
35
- return JSON.parse(readFileSync(join(homedir(), '.flowviant', 'credentials.json'), 'utf8'));
36
- } catch {
37
- return null;
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
- export const FLEET_TOKEN =
171
- argFlag('--fleet') || process.env.FLOWVIANT_FLEET || stored?.fleetToken || '';
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
+ }
@@ -105,7 +105,11 @@ async function readSecretFromStdin(promptText) {
105
105
  }
106
106
 
107
107
  export async function runEnvCommand(args) {
108
- if (!FLEET_TOKEN) die('no fleet credential — run `flowviant login` first.');
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
- // THIS release (0.54.2) — reading both here is what makes retiring the
80
- // old key possible at all.
81
- store({ fleetToken: poll.machineToken ?? poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
82
- ok('connected credential saved to ~/.flowviant/credentials.json');
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.
@@ -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 — this needs the\n' +
27
- 'fleet credential the daemon uses, so it knows which project to connect.'
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.54.2",
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": {