flowviant 0.93.0 → 0.95.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.
@@ -0,0 +1,531 @@
1
+ /**
2
+ * PROJECT KNOWLEDGE, daemon side (2026-09-22, 0.94.0) — the library a person
3
+ * keeps for their own Claude, materialised where a CLI can read it.
4
+ *
5
+ * The owner asked whether the Workbench could be "a clone of claude projects":
6
+ * files uploaded once and a standing Instructions text, read by every turn on
7
+ * the project. The server holds the bytes; the roster carries a MANIFEST
8
+ * (`knowledge: { rev, instructions, files: [{ id, name, bytes, sha256 }] }`);
9
+ * this module makes `<checkout>/.flowviant/knowledge/` match it, and the turn
10
+ * prompts name that directory. Nothing here reads a file's CONTENTS for any
11
+ * purpose but writing it — the only brain is the CLI, reading off a disk.
12
+ *
13
+ * ── ONE COPY PER BOX, IN THE CHECKOUT ──
14
+ *
15
+ * Not per worktree. Every session, capture and agent turn on this box runs in
16
+ * some directory under the same user, and every one of them can read an
17
+ * ABSOLUTE path; a copy per worktree would be a copy per agent, fifty megabytes
18
+ * times however many agents are open, and N copies that can each go stale on
19
+ * their own. The prompt hands the absolute path.
20
+ *
21
+ * It is never committed: its paths (`FLOWVIANT_OWN_PATHS` — never the whole
22
+ * `.flowviant/`, 2026-09-23) are written to the exclude file git actually
23
+ * reads (`excludeInWorktree`, which resolves `--git-common-dir`, so one call
24
+ * covers the checkout and every linked worktree alike). An untracked
25
+ * `.flowviant/` in the checkout would make the operator's own `git status`
26
+ * dirty and, for a tab standing in the checkout, show the library in the
27
+ * rail's diffstat as session changes.
28
+ *
29
+ * ── THE THREE STATES OF THE ROSTER KEY ──
30
+ *
31
+ * ABSENT — an older server, a project that never had knowledge, or a daemon
32
+ * below the floor (it never sees this code). LEAVE THE DIRECTORY ALONE. An
33
+ * absence is what an older server says on every poll; letting it mean
34
+ * "delete the library" would wipe it on a server rollback.
35
+ * `{ rev, files: [], instructions: null }` — the project HAD knowledge and
36
+ * the person emptied it. The server sends this for as long as the
37
+ * project's rev is above zero (forever after the first write), so emptying
38
+ * is SAID, never inferred. The directory is removed, and with it the
39
+ * prompt paragraph — which is rendered only while the directory exists.
40
+ * non-empty — sync to it.
41
+ *
42
+ * ── WHAT A SYNC DOES ──
43
+ *
44
+ * When `rev` differs from the last one materialised (held in memory AND in a
45
+ * marker file beside the directory, so a restart does not re-sync a library
46
+ * that is already on disk): download each file whose sha256 differs from the
47
+ * local copy — a local copy somebody edited by hand is restored, because the
48
+ * library is the server's and the directory is its mirror; delete every
49
+ * regular file the manifest no longer names; write or remove INSTRUCTIONS.md.
50
+ *
51
+ * A FAILED DOWNLOAD DOES NOT ADVANCE THE REV. The next poll tries again — but
52
+ * only the files that still differ, and on a widening backoff, so a server
53
+ * that 500s one file cannot make this box re-download fifty megabytes every
54
+ * ten seconds. A file refused on SIZE is not a failure to retry: it is
55
+ * permanent for that rev, skipped with a warning, and the rest of the library
56
+ * is written.
57
+ */
58
+
59
+ import {
60
+ existsSync,
61
+ lstatSync,
62
+ mkdirSync,
63
+ readdirSync,
64
+ readFileSync,
65
+ renameSync,
66
+ rmSync,
67
+ unlinkSync,
68
+ writeFileSync,
69
+ } from 'node:fs';
70
+ import { createHash } from 'node:crypto';
71
+ import { join } from 'node:path';
72
+
73
+ /** Relative to the checkout, under `.flowviant/` beside the turn uploads and
74
+ * the artifacts — see `FLOWVIANT_OWN_PATHS` for why that is NOT one exclude
75
+ * line covering all three. */
76
+ export const KNOWLEDGE_DIR = '.flowviant/knowledge';
77
+
78
+ /**
79
+ * THE PATHS UNDER `.flowviant/` THAT ARE OURS, and the exclude file names these
80
+ * and nothing wider (2026-09-23).
81
+ *
82
+ * The first cut excluded `/.flowviant/` whole, from every place a turn spawns
83
+ * in — the attachment fetch's line, which until this release was written only
84
+ * in repos somebody had attached a file in. But `.flowviant/` is not only ours:
85
+ * the repo DECLARES `.flowviant/check.json` and `.flowviant/deploy.json` there,
86
+ * committed like any other file. An exclude line never hides a TRACKED file,
87
+ * so an existing one was safe — and a NEW one was not: an agent asked to "add a
88
+ * check command" writes `.flowviant/check.json`, its `git add -A` skips it as
89
+ * ignored, the commit lands without it, and `git status` shows nothing to
90
+ * explain why. Writing that line into every worktree on every turn would have
91
+ * made the product's own configuration uncommittable by the product's own
92
+ * agents. So the exclude names the four things this daemon writes there, and a
93
+ * repo's own `.flowviant/*.json` stays ordinary, committable work.
94
+ *
95
+ * (A repo that already carries the old `/.flowviant/` line keeps it — it is in
96
+ * the user's own exclude file, and removing a line we cannot prove we alone
97
+ * wrote is not ours to do.)
98
+ */
99
+ export const FLOWVIANT_OWN_PATHS = [
100
+ '.flowviant/knowledge/',
101
+ '.flowviant/knowledge.rev*',
102
+ '.flowviant/artifacts/',
103
+ '.flowviant/uploads/',
104
+ ];
105
+ /** The person's standing brief, written from the manifest's `instructions`.
106
+ * A RESERVED name: a library FILE called this gets a suffix, because the
107
+ * prompt tells the CLI to read this one first and a stranger's file must
108
+ * never be able to stand in for the person's own words. */
109
+ export const INSTRUCTIONS_FILE = 'INSTRUCTIONS.md';
110
+ /** The rev last materialised — OUTSIDE the directory the prompt hands the CLI,
111
+ * so the agent listing its library sees only the library. */
112
+ const MARKER = '.flowviant/knowledge.rev';
113
+ /** The server's per-file ceiling (`KNOWLEDGE_FILE_MAX_BYTES`), re-checked here:
114
+ * this is somebody's disk, and one place checking is one deploy away from
115
+ * zero places — the attachment fetch's own rule. */
116
+ export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
117
+ /** The manifest is capped at the server (25 files); a manifest longer than
118
+ * this is not one the product produced, and is cut rather than trusted. */
119
+ const MAX_FILES = 64;
120
+
121
+ /**
122
+ * `safeUploadName`, verbatim in effect (work.mjs): keeps the extension, drops
123
+ * every path separator, never starts with a dot or a dash. Re-applied here
124
+ * although the server did it, because this string becomes a path on this box.
125
+ */
126
+ export function safeKnowledgeName(raw) {
127
+ const base = String(raw ?? '')
128
+ .split(/[\\/]/)
129
+ .pop()
130
+ .replace(/[^A-Za-z0-9._-]/g, '_')
131
+ .replace(/^[.-]+/, '')
132
+ .slice(0, 80);
133
+ return base || 'file';
134
+ }
135
+
136
+ /**
137
+ * The LOCAL name each manifest entry is written under, in manifest order.
138
+ * Collisions get a numeric suffix (`spec.md`, `spec-2.md`) — two files a
139
+ * person uploaded with one name are two files, and silently writing one over
140
+ * the other loses one. Deterministic in the manifest's order, so the same
141
+ * manifest always yields the same names and a re-sync never renames a file.
142
+ * `INSTRUCTIONS.md` is reserved (compared case-insensitively — macOS and
143
+ * Windows disks are).
144
+ */
145
+ export function planKnowledgeNames(files) {
146
+ const taken = new Set([INSTRUCTIONS_FILE.toLowerCase()]);
147
+ const out = [];
148
+ for (const f of files) {
149
+ const name = safeKnowledgeName(f.name);
150
+ const dot = name.lastIndexOf('.');
151
+ const stem = dot > 0 ? name.slice(0, dot) : name;
152
+ const ext = dot > 0 ? name.slice(dot) : '';
153
+ let candidate = name;
154
+ for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${stem}-${n}${ext}`;
155
+ taken.add(candidate.toLowerCase());
156
+ out.push({ ...f, local: candidate });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ const sha256Of = (buf) => createHash('sha256').update(buf).digest('hex');
162
+
163
+ /** A regular file's sha256, or null for anything else (missing, a directory, a
164
+ * symlink — a symlink is never followed, so it is never "the same file"). */
165
+ function localSha(path) {
166
+ try {
167
+ const st = lstatSync(path);
168
+ if (!st.isFile()) return null;
169
+ return sha256Of(readFileSync(path));
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /** Write through a temp name and rename, so a CLI reading the library mid-sync
176
+ * sees the old file or the new one, never half of one. Anything already at
177
+ * the path that is NOT a regular file (a symlink planted there) is removed
178
+ * first — `writeFileSync` would otherwise follow it and write wherever it
179
+ * pointed. */
180
+ function writeAtomic(path, data) {
181
+ try {
182
+ const st = lstatSync(path);
183
+ if (!st.isFile()) rmSync(path, { recursive: true, force: true });
184
+ } catch {
185
+ /* absent — the ordinary case */
186
+ }
187
+ const tmp = `${path}.fvtmp`;
188
+ // THE TEMP NAME IS CHECKED TOO (2026-09-23). The first cut guarded the
189
+ // destination and then wrote straight through `<name>.fvtmp` — a link planted
190
+ // THERE would have been followed, the library's bytes written wherever it
191
+ // pointed, and the link renamed into place. Unlinked first, then created
192
+ // EXCLUSIVELY (`wx`): if anything reappears at the name between the two, the
193
+ // write throws into the caller's catch rather than following it.
194
+ rmSync(tmp, { recursive: true, force: true });
195
+ writeFileSync(tmp, data, { flag: 'wx' });
196
+ renameSync(tmp, path);
197
+ }
198
+
199
+ /**
200
+ * Is `<checkout>/.flowviant` a real directory, or absent? Anything else — a
201
+ * symlink, a regular file — REFUSES the sync rather than being touched.
202
+ *
203
+ * `.flowviant/` is not only ours: a repo declares `.flowviant/check.json` and
204
+ * `.flowviant/deploy.json` there, and an operator may have arranged it however
205
+ * they like. Deleting a file or a link at that path to make room for a library
206
+ * would be Flowviant destroying something it did not create; following a link
207
+ * would land downloads, and the emptied library's `rm -r`, wherever it points.
208
+ * So the parent is checked, never repaired.
209
+ */
210
+ function flowviantDirOk(checkoutDir) {
211
+ try {
212
+ const st = lstatSync(join(checkoutDir, '.flowviant'));
213
+ return st.isDirectory() && !st.isSymbolicLink();
214
+ } catch {
215
+ return true; // absent — ours to create
216
+ }
217
+ }
218
+
219
+ /** The library directory, as a real directory. The PARENT is checked by the
220
+ * caller; the library path itself is OURS, so a symlink or a file planted
221
+ * there is removed (unlinked — the link, never its target) rather than
222
+ * followed. */
223
+ function ensureDir(checkoutDir) {
224
+ const dir = join(checkoutDir, KNOWLEDGE_DIR);
225
+ try {
226
+ const st = lstatSync(dir);
227
+ if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(dir);
228
+ } catch {
229
+ /* absent */
230
+ }
231
+ mkdirSync(dir, { recursive: true });
232
+ return dir;
233
+ }
234
+
235
+ export function readKnowledgeMarker(checkoutDir) {
236
+ try {
237
+ const n = Number(readFileSync(join(checkoutDir, MARKER), 'utf8').trim());
238
+ return Number.isInteger(n) && n >= 0 ? n : null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ function writeKnowledgeMarker(checkoutDir, rev) {
245
+ try {
246
+ mkdirSync(join(checkoutDir, '.flowviant'), { recursive: true });
247
+ writeAtomic(join(checkoutDir, MARKER), `${rev}\n`);
248
+ } catch {
249
+ /* a lost marker costs one re-sync after a restart — every file already on
250
+ disk matches by sha and is not downloaded again */
251
+ }
252
+ }
253
+
254
+ /** Does this manifest look like one the server produced? Anything else is
255
+ * ignored whole rather than half-applied. */
256
+ export function isKnowledgeManifest(k) {
257
+ return (
258
+ !!k &&
259
+ typeof k === 'object' &&
260
+ Number.isInteger(k.rev) &&
261
+ k.rev >= 0 &&
262
+ (k.instructions === null || k.instructions === undefined || typeof k.instructions === 'string') &&
263
+ Array.isArray(k.files)
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Make `<checkoutDir>/.flowviant/knowledge/` match `manifest`.
269
+ *
270
+ * `fetchFile(id)` returns the file's bytes as a Buffer, or throws. Injected so
271
+ * the sync is testable against a temp directory and a fake server; the daemon
272
+ * hands the `/fleet/knowledge/:id` fetch.
273
+ *
274
+ * @returns {{ ok: boolean, wrote: string[], removed: string[], refused: string[], failed: string[] }}
275
+ * `ok` is false when any download FAILED (to be retried); a file REFUSED for
276
+ * size is permanent for this rev and does not clear `ok`.
277
+ */
278
+ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
279
+ const result = { ok: true, wrote: [], removed: [], refused: [], failed: [] };
280
+ const dirPath = join(checkoutDir, KNOWLEDGE_DIR);
281
+ const files = (manifest.files ?? [])
282
+ .filter((f) => f && typeof f.id === 'string' && /^[0-9a-f-]{8,64}$/i.test(f.id))
283
+ .slice(0, MAX_FILES);
284
+ const instructions =
285
+ typeof manifest.instructions === 'string' && manifest.instructions.trim()
286
+ ? manifest.instructions
287
+ : null;
288
+
289
+ if (!flowviantDirOk(checkoutDir)) {
290
+ // Not a failure to retry every poll — nothing changes until a person
291
+ // changes it — but not a success either: the rev must not advance over a
292
+ // library that was never written.
293
+ result.ok = false;
294
+ result.failed.push('.flowviant (not a directory — left untouched)');
295
+ return result;
296
+ }
297
+
298
+ // EMPTIED: the directory goes, and the prompt paragraph with it.
299
+ if (files.length === 0 && !instructions) {
300
+ if (existsSync(dirPath)) {
301
+ try {
302
+ const st = lstatSync(dirPath);
303
+ if (st.isSymbolicLink()) unlinkSync(dirPath);
304
+ else rmSync(dirPath, { recursive: true, force: true });
305
+ result.removed.push(KNOWLEDGE_DIR);
306
+ } catch {
307
+ result.ok = false;
308
+ }
309
+ }
310
+ return result;
311
+ }
312
+
313
+ const dir = ensureDir(checkoutDir);
314
+ const planned = planKnowledgeNames(files);
315
+ const keep = new Set(planned.map((p) => p.local));
316
+ if (instructions) keep.add(INSTRUCTIONS_FILE);
317
+
318
+ for (const f of planned) {
319
+ const path = join(dir, f.local);
320
+ if (Number(f.bytes) > maxBytes) {
321
+ // Permanent for this rev, and NOT a reason to delete the rest. Any stale
322
+ // local copy under this name goes, because it is not the file the
323
+ // manifest now names.
324
+ result.refused.push(f.local);
325
+ keep.delete(f.local);
326
+ continue;
327
+ }
328
+ if (typeof f.sha256 === 'string' && localSha(path) === f.sha256.toLowerCase()) continue;
329
+ try {
330
+ const buf = await fetchFile(f.id);
331
+ if (!Buffer.isBuffer(buf) || buf.byteLength > maxBytes) {
332
+ result.refused.push(f.local);
333
+ keep.delete(f.local);
334
+ continue;
335
+ }
336
+ // The bytes must BE the file the manifest names. A mismatch is a
337
+ // failure to retry, never a file to trust.
338
+ if (typeof f.sha256 === 'string' && sha256Of(buf) !== f.sha256.toLowerCase()) {
339
+ throw new Error('sha256 mismatch');
340
+ }
341
+ writeAtomic(path, buf);
342
+ result.wrote.push(f.local);
343
+ } catch {
344
+ result.ok = false;
345
+ result.failed.push(f.local);
346
+ // Keep whatever copy is there: a stale file is better than a missing one
347
+ // until the retry lands.
348
+ }
349
+ }
350
+
351
+ if (instructions) {
352
+ const path = join(dir, INSTRUCTIONS_FILE);
353
+ const body = instructions.endsWith('\n') ? instructions : `${instructions}\n`;
354
+ let same = false;
355
+ try {
356
+ same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === body;
357
+ } catch {
358
+ /* absent */
359
+ }
360
+ if (!same) {
361
+ writeAtomic(path, body);
362
+ result.wrote.push(INSTRUCTIONS_FILE);
363
+ }
364
+ }
365
+
366
+ // Everything the manifest no longer names — files, stray temp names, and
367
+ // anything that is not a regular file — leaves.
368
+ let entries = [];
369
+ try {
370
+ entries = readdirSync(dir);
371
+ } catch {
372
+ entries = [];
373
+ }
374
+ for (const name of entries) {
375
+ if (keep.has(name)) continue;
376
+ try {
377
+ rmSync(join(dir, name), { recursive: true, force: true });
378
+ result.removed.push(name);
379
+ } catch {
380
+ /* best-effort; the next sync tries again */
381
+ }
382
+ }
383
+ return result;
384
+ }
385
+
386
+ /**
387
+ * The absolute library path, or null when there is nothing there for a turn
388
+ * to read. The prompt paragraph is rendered only when this is non-null, so a
389
+ * project with no library costs the prompt nothing.
390
+ */
391
+ export function knowledgeDirFor(checkoutDir) {
392
+ if (!checkoutDir || !flowviantDirOk(checkoutDir)) return null;
393
+ const dir = join(checkoutDir, KNOWLEDGE_DIR);
394
+ try {
395
+ if (!lstatSync(dir).isDirectory()) return null;
396
+ return readdirSync(dir).length > 0 ? dir : null;
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
401
+
402
+ /** Does the manifest name something the disk no longer has a directory for?
403
+ * An EMPTIED manifest over no directory is the synced state, not a loss. */
404
+ function libraryMissing(checkoutDir, manifest) {
405
+ const named =
406
+ (Array.isArray(manifest.files) && manifest.files.length > 0) ||
407
+ (typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '');
408
+ if (!named) return false;
409
+ try {
410
+ lstatSync(join(checkoutDir, KNOWLEDGE_DIR));
411
+ return false;
412
+ } catch {
413
+ return true;
414
+ }
415
+ }
416
+
417
+ /**
418
+ * The per-process driver: called with every roster's `knowledge` key.
419
+ *
420
+ * Holds the last-materialised rev in memory (seeded from the marker, so a
421
+ * restart does not re-sync), serialises syncs (a slow download must never be
422
+ * overlapped by the next poll's), and backs off after a failure: 30s, 60s,
423
+ * 120s … capped at ten minutes. Never throws into the poll loop.
424
+ */
425
+ export function createKnowledgeSync({
426
+ checkoutDir,
427
+ fetchFile,
428
+ onExclude = () => {},
429
+ log = () => {},
430
+ now = () => Date.now(),
431
+ }) {
432
+ let rev = readKnowledgeMarker(checkoutDir);
433
+ let busy = false;
434
+ let failures = 0;
435
+ let retryAt = 0;
436
+ /** The rev the backoff belongs to. A NEW rev is a new library and is tried
437
+ * at once — waiting out a backoff earned by a file the person has since
438
+ * removed would hold their next upload hostage to the last one's failure. */
439
+ let failedRev = null;
440
+
441
+ return {
442
+ /** The rev last fully materialised, or null. */
443
+ get rev() {
444
+ return rev;
445
+ },
446
+ /** @returns {Promise<null | ReturnType<typeof syncKnowledge>>} null when
447
+ * nothing ran (absent key, same rev, busy, or backing off). */
448
+ async onRoster(knowledge) {
449
+ if (knowledge === undefined || knowledge === null) return null; // ABSENT: leave it alone
450
+ if (!isKnowledgeManifest(knowledge)) return null;
451
+ /**
452
+ * THE SAME REV IS NOT ALWAYS THE SAME DISK (2026-09-23). The marker says
453
+ * which rev was written; it cannot say the directory is still there. An
454
+ * `rm -r .flowviant/knowledge` — by hand, or by an agent tidying its
455
+ * checkout — left the marker naming the current rev, so every poll after
456
+ * it matched and returned, the prompt paragraph vanished with the
457
+ * directory (`knowledgeDirFor` answers null), and every turn ran without
458
+ * the library until somebody happened to edit it. A manifest that names
459
+ * something over a directory that does not exist is re-synced; files
460
+ * already on disk match by hash and are not fetched again.
461
+ */
462
+ if (knowledge.rev === rev && !libraryMissing(checkoutDir, knowledge)) return null;
463
+ if (busy) return null;
464
+ if (knowledge.rev === failedRev && now() < retryAt) return null;
465
+ busy = true;
466
+ try {
467
+ try {
468
+ onExclude(checkoutDir);
469
+ } catch {
470
+ /* a convenience, never a gate */
471
+ }
472
+ const r = await syncKnowledge({ checkoutDir, manifest: knowledge, fetchFile });
473
+ if (r.ok) {
474
+ rev = knowledge.rev;
475
+ failures = 0;
476
+ retryAt = 0;
477
+ failedRev = null;
478
+ writeKnowledgeMarker(checkoutDir, rev);
479
+ if (r.wrote.length || r.removed.length) {
480
+ log(
481
+ `knowledge · synced rev ${rev}` +
482
+ (r.wrote.length ? ` · ${r.wrote.length} written` : '') +
483
+ (r.removed.length ? ` · ${r.removed.length} removed` : '')
484
+ );
485
+ }
486
+ } else {
487
+ failures = knowledge.rev === failedRev ? failures + 1 : 1;
488
+ failedRev = knowledge.rev;
489
+ retryAt = now() + Math.min(600_000, 30_000 * 2 ** (failures - 1));
490
+ log(`knowledge · ${r.failed.length} file(s) did not download — retrying`);
491
+ }
492
+ if (r.refused.length) {
493
+ log(`knowledge · skipped over the size cap: ${r.refused.join(', ')}`);
494
+ }
495
+ return r;
496
+ } catch (e) {
497
+ failures = knowledge.rev === failedRev ? failures + 1 : 1;
498
+ failedRev = knowledge.rev;
499
+ retryAt = now() + Math.min(600_000, 30_000 * 2 ** (failures - 1));
500
+ log(`knowledge · sync failed: ${e?.message ?? e}`);
501
+ return null;
502
+ } finally {
503
+ busy = false;
504
+ }
505
+ },
506
+ };
507
+ }
508
+
509
+ /**
510
+ * The `/fleet/knowledge/:id` download, in the attachment fetch's own shape:
511
+ * the machine credential as a bearer, a 60s ceiling, and the size checked on
512
+ * the header AND on the bytes (a lying header must not decide the cap). The
513
+ * URL is derived from the roster URL the way every `/fleet/*` path is, so a
514
+ * self-hosted `FLOWVIANT_FLEET_URL` is honoured.
515
+ */
516
+ export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
517
+ const base = String(fleetUrl).replace(/\/agents\/?$/, '/knowledge');
518
+ return async (id) => {
519
+ const res = await fetch(`${base}/${encodeURIComponent(id)}`, {
520
+ headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
521
+ signal: AbortSignal.timeout(60_000),
522
+ });
523
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
524
+ if (Number(res.headers.get('content-length') ?? '0') > maxBytes) {
525
+ // Returned rather than thrown: over the cap is a REFUSAL, permanent for
526
+ // this rev, not a failure the backoff should keep retrying.
527
+ return Buffer.alloc(maxBytes + 1);
528
+ }
529
+ return Buffer.from(await res.arrayBuffer());
530
+ };
531
+ }
package/bin/lib/login.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
10
- import { saveLogin, detectRepoRoot, projectLabel } from './credentials.mjs';
10
+ import { saveLogin, detectRepoRoot, projectLabel, listStoredProjects, boundElsewhere } from './credentials.mjs';
11
11
  import { c, info, ok, warn, fail } from './ui.mjs';
12
12
  import { sleep } from './claude.mjs';
13
13
 
@@ -27,6 +27,58 @@ async function post(url, body) {
27
27
  return j.data ?? j;
28
28
  }
29
29
 
30
+ /**
31
+ * THE THREE ANSWERS to "this repo is already connected to another project",
32
+ * as menu rows — pure, so the wording is provable. Each row says its whole
33
+ * consequence, the same rule the `machines` menu keeps: a verb whose outcome
34
+ * lives in a manual is one you have to be sure about before you can use it.
35
+ *
36
+ * keep both stay; `npx flowviant` in this repo asks which to serve.
37
+ * replace this repo serves the NEW project; this box is disconnected from
38
+ * the old one (its daemon here stopped, its row left in the app,
39
+ * its credential forgotten here) — the whole disconnect, because a
40
+ * half of it leaves a daemon running on a credential the store no
41
+ * longer names.
42
+ * cancel nothing is saved. The approval in the app minted or reused the
43
+ * project's credential either way; a credential nobody holds is a
44
+ * row, not a machine.
45
+ */
46
+ export function secondProjectOptions(clash, entry, repoRoot) {
47
+ const olds = clash.map(projectLabel).join(' and ');
48
+ const now = projectLabel(entry);
49
+ return [
50
+ `keep both — every \`npx flowviant\` in ${repoRoot} asks whether to serve ${olds} or ${now}`,
51
+ `replace — ${repoRoot} serves ${now} from now on; this box is disconnected from ${olds} (its daemon here stopped, its credential forgotten here)`,
52
+ `cancel — save nothing; ${repoRoot} stays connected to ${olds}`,
53
+ ];
54
+ }
55
+
56
+ const DECISIONS = ['keep', 'replace', 'cancel'];
57
+
58
+ /** Ask, on whatever this terminal can draw. No terminal to ask on: KEEP, and
59
+ * say so — a headless login must not hang, and refusing would strand a
60
+ * runner that had just been approved in the app. */
61
+ async function secondProjectDecision({ clash, entry, repoRoot }) {
62
+ const { canPrompt, menuSupported, selectMenu, askWithTimeout } = await import('./tty.mjs');
63
+ const olds = clash.map(projectLabel).join(' and ');
64
+ console.log('');
65
+ warn(`${repoRoot} is already connected to ${olds} on this box.`);
66
+ if (!canPrompt()) {
67
+ console.log(` keeping both — every \`npx flowviant\` there will ask which to serve; \`flowviant machines\` removes one.\n`);
68
+ return 'keep';
69
+ }
70
+ const options = secondProjectOptions(clash, entry, repoRoot);
71
+ if (menuSupported()) {
72
+ const res = await selectMenu({ options });
73
+ if (res.cancelled) return 'cancel';
74
+ if (!res.unsupported) return DECISIONS[res.index];
75
+ }
76
+ console.log(options.map((o, i) => ` ${i + 1}. ${o}`).join('\n'));
77
+ const raw = await askWithTimeout(' Which? [1-3] ');
78
+ const n = Number.parseInt(raw ?? '', 10);
79
+ return DECISIONS[n - 1] ?? 'cancel';
80
+ }
81
+
30
82
  export async function runLogin({ thenStart = false } = {}) {
31
83
  console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
32
84
  let start;
@@ -73,6 +125,29 @@ export async function runLogin({ thenStart = false } = {}) {
73
125
  name: typeof poll.projectName === 'string' && poll.projectName ? poll.projectName : null,
74
126
  repoRoot,
75
127
  };
128
+ // A SECOND PROJECT ON A REPO IS A QUESTION, NEVER A SILENT SAVE
129
+ // (2026-09-23). The owner: "im not sure how it even allowed me to run
130
+ // npx flowviant login twice and init a daemon twice on the same
131
+ // project/repository/directory in the first place." It allowed it
132
+ // because it never looked: the approval names a project, this repo was
133
+ // already bound to a different one, and both landed in the store — after
134
+ // which every start here was a picker between two rows that read alike.
135
+ // Asked while the person is still at the terminal; headless, the save
136
+ // goes ahead and the fact is printed, because a CI runner cannot answer
137
+ // and a login that silently refused would be worse than one that said.
138
+ const clash = boundElsewhere(listStoredProjects(), repoRoot, entry.projectId);
139
+ if (clash.length > 0) {
140
+ const decision = await secondProjectDecision({ clash, entry, repoRoot });
141
+ if (decision === 'cancel') {
142
+ console.log(`\n nothing saved — ${repoRoot} stays connected to ${clash.map(projectLabel).join(' and ')}.\n`);
143
+ return { saved: false };
144
+ }
145
+ if (decision === 'replace') {
146
+ const { disconnectHere, realDisconnectDeps, leaveUrlFrom } = await import('./machines.mjs');
147
+ const deps = await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log: (m) => console.log(` ${m}`) });
148
+ for (const old of clash) await disconnectHere(old, deps, { log: (m) => console.log(` ${m}`) });
149
+ }
150
+ }
76
151
  saveLogin(entry);
77
152
  ok(
78
153
  `connected to ${c.bold(projectLabel(entry))}` +
@@ -86,7 +161,7 @@ export async function runLogin({ thenStart = false } = {}) {
86
161
  ? `\n ${c.dim('starting your agent — leave this running')}\n`
87
162
  : `\n Now run: ${c.bold('npx flowviant')}\n`
88
163
  );
89
- return;
164
+ return { saved: true };
90
165
  }
91
166
  if (poll.status === 'expired') {
92
167
  warn('that code expired — run `flowviant login` again.');