flowviant 0.102.2 → 0.104.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.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,68 @@
1
+ /**
2
+ * THE CROSS-PROCESS TURN LOCK — the one reading of "is a CLI of ours already
3
+ * working in this directory".
4
+ *
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). It is read by the session
6
+ * lane before a spawn and by the ship executor before a merge, and it is pure
7
+ * over the disk and the kernel: no manager state, so it needs no factory and
8
+ * is tested directly (turnLock.test.mjs). The lock FILE's path is the
9
+ * caller's (`sessionMetaPath(wt, 'flowviant-turn.lock')`); what a pid in it
10
+ * means is this module's.
11
+ */
12
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
13
+ import { processStartTime } from './procRegistry.mjs';
14
+
15
+ /**
16
+ * The spawn lock: the pid of the CLI currently live in this worktree. A
17
+ * restarted daemon must not put a second Claude into a directory the orphan
18
+ * of its previous life is still editing — two CLIs appending to one held
19
+ * conversation is exactly the incoherence workChains prevents in-process,
20
+ * and the lock extends that guarantee across a restart. A dead pid is a
21
+ * stale lock (removed here); a live one means "come back next poll".
22
+ *
23
+ * …AND A PID IS ONLY THE HOLDER WHILE IT IS STILL THE SAME PROCESS.
24
+ *
25
+ * The lock outlives a reboot or a daemon killed mid-turn, and pids are
26
+ * recycled. Two readings treated a stranger as the holder and wedged every
27
+ * turn and ship in the place until somebody deleted the file by hand: EPERM
28
+ * was read as "alive, just not ours" — but the lock only ever holds a CLI this
29
+ * daemon spawned under its own uid, so a pid we may not signal is by
30
+ * definition not ours, i.e. stale — and a recycled pid of our OWN uid
31
+ * answered signal 0 like the CLI it replaced. The lock now records the
32
+ * process's start time beside the pid (`pid:start`); a live pid whose start
33
+ * time differs is a different process. A lock with no start time (written by
34
+ * an older daemon) keeps the old signal-0 reading, minus EPERM.
35
+ */
36
+ export const turnLockedByLivePid = (lockPath) => {
37
+ if (!lockPath || !existsSync(lockPath)) return false;
38
+ let pid = 0;
39
+ let start = null;
40
+ try {
41
+ const raw = readFileSync(lockPath, 'utf8').trim();
42
+ const cut = raw.indexOf(':');
43
+ pid = Number(cut < 0 ? raw : raw.slice(0, cut));
44
+ start = cut < 0 ? null : raw.slice(cut + 1) || null;
45
+ } catch {
46
+ /* unreadable — treat as stale */
47
+ }
48
+ if (Number.isInteger(pid) && pid > 0) {
49
+ let alive = false;
50
+ try {
51
+ process.kill(pid, 0);
52
+ alive = true; // signal 0 delivered — a process of OUR uid holds the pid
53
+ } catch {
54
+ /* ESRCH: gone. EPERM: another uid's process — never our CLI. Stale. */
55
+ }
56
+ if (alive) {
57
+ const now = start ? processStartTime(pid) : null;
58
+ // Unmeasurable start time is "cannot tell", which keeps the lock.
59
+ if (!start || !now || now === start) return true;
60
+ }
61
+ }
62
+ try {
63
+ rmSync(lockPath, { force: true }); // dead holder — clear the stale lock
64
+ } catch {
65
+ /* best-effort */
66
+ }
67
+ return false;
68
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A TURN'S PROFILE — the one name a caller gives `runTurn` for the posture a
3
+ * CLI turn runs under, and what that name means before any adapter sees it.
4
+ *
5
+ * Split out (SOLID F047) because `runTurn` took five independent posture
6
+ * switches — `wikiPerm`, `readOnly`, `planPerm`, `planMode`, `posture` — and
7
+ * resolved them through two separate precedence chains (one for the adapter's
8
+ * profile name, one for Claude's permission list). A caller could say two
9
+ * things at once (a plan turn that is also read-only) and the answer was
10
+ * whichever branch came first. Now a caller names ONE profile; a name this
11
+ * table does not know, or any of the retired switches, fails the turn in
12
+ * words instead of falling through to the build posture.
13
+ *
14
+ * The profiles:
15
+ * - build — the default: the operator's own build posture;
16
+ * - wiki — the cartographer: writes only the vault;
17
+ * - consult — read-only (the scratch planner, the AI pre-review);
18
+ * - plan — the capture chat: read-only plus its control plane;
19
+ * - plan-mode — a Workbench plan turn: Claude Code's own
20
+ * `--permission-mode plan`, REPLACING the build posture, no MCP.
21
+ * Claude only. The adapter sees `build` (argv is `perm`);
22
+ * - design / research — a non-code card's posture (agentTaskKinds.mjs),
23
+ * refused on any runtime that does not DECLARE it, because every
24
+ * other adapter's fallback is its build branch.
25
+ *
26
+ * Claude's permission list for each name lives beside the lists themselves
27
+ * (`claudePermFor`, claudePosture.mjs); this module owns the vocabulary and the
28
+ * refusals, and imports only the card-kind table.
29
+ */
30
+ import { KIND_POSTURES } from './agentTaskKinds.mjs';
31
+
32
+ const plain = (adapterProfile) => Object.freeze({ adapterProfile, onlyOn: null, strictMcp: false });
33
+
34
+ export const TURN_PROFILES = Object.freeze({
35
+ build: plain('build'),
36
+ wiki: plain('wiki'),
37
+ consult: plain('consult'),
38
+ plan: plain('plan'),
39
+ 'plan-mode': Object.freeze({
40
+ adapterProfile: 'build',
41
+ // Plan mode is Claude's own flag. The other adapters build argv from the
42
+ // profile name and never read `perm`, so on them it would be a BUILD turn
43
+ // wearing the word "plan".
44
+ onlyOn: (rt) => (rt.id === 'claude' ? null : `plan mode runs on Claude Code only — not '${rt.label}'`),
45
+ strictMcp: true,
46
+ }),
47
+ // The kind postures: fenced to "write only the artifacts directory", and a
48
+ // runtime that does not declare one would run its build branch instead.
49
+ ...Object.fromEntries(
50
+ KIND_POSTURES.map((name) => [
51
+ name,
52
+ Object.freeze({
53
+ adapterProfile: name,
54
+ onlyOn: (rt) => ((rt.profiles ?? []).includes(name) ? null : `a ${name} card runs on Claude Code only — not '${rt.label}'`),
55
+ strictMcp: false,
56
+ }),
57
+ ])
58
+ ),
59
+ });
60
+
61
+ /** The switches `profile` replaced. Passing one is a caller nobody updated —
62
+ * and silently ignoring it would run that caller's turn as a BUILD turn. */
63
+ export const RETIRED_POSTURE_KEYS = Object.freeze(['wikiPerm', 'readOnly', 'planPerm', 'planMode', 'posture']);
64
+
65
+ /**
66
+ * `{ name, adapterProfile, onlyOn, strictMcp }` for a turn's options, or
67
+ * `{ error }` in words. Absent `profile` is build.
68
+ */
69
+ export function resolveTurnProfile(opts = {}) {
70
+ const retired = RETIRED_POSTURE_KEYS.filter((k) => opts[k] !== undefined);
71
+ if (retired.length) return { error: `the turn named a retired posture switch (${retired.join(', ')}) — name one profile instead` };
72
+ const name = opts.profile ?? 'build';
73
+ if (typeof name !== 'string' || !Object.hasOwn(TURN_PROFILES, name)) {
74
+ return { error: `unknown turn profile '${String(name).slice(0, 40)}'` };
75
+ }
76
+ return { name, ...TURN_PROFILES[name] };
77
+ }
@@ -1,13 +1,15 @@
1
1
  import * as nodeFs from 'node:fs';
2
2
  import { execFile as nodeExecFile } from 'node:child_process';
3
3
  import { homedir } from 'node:os';
4
- import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
4
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { runningCompiledBinary } from './update.mjs';
7
7
  import { VERSION } from './config.mjs';
8
+ import { parseVersion } from './versionOrder.mjs';
9
+ import { copyKind, exactBlock, inside } from './uninstallCopies.mjs';
10
+ import { disconnectShortfall, removeOnMachinesPage } from './machineDisconnect.mjs';
8
11
 
9
12
  const execFileAsync = promisify(nodeExecFile);
10
- const inside = (path, dir) => path === dir || path.startsWith(`${dir}${sep}`);
11
13
  const errorWords = (error) => String(error?.stderr || error?.message || error).trim();
12
14
 
13
15
  function defaults(options = {}) {
@@ -55,11 +57,6 @@ function compiledExecutable(fs, path) {
55
57
  return magic === 0x7f454c46 || [0xfeedfacf, 0xcffaedfe, 0xfeedface, 0xcefaedfe, 0xcafebabe].includes(magic);
56
58
  } catch { return false; }
57
59
  }
58
- function exactBlock(dir, rc) {
59
- const line = basename(rc) === 'flowviant.fish'
60
- ? `fish_add_path "${dir}"` : `export PATH="${dir}:$PATH"`;
61
- return `\n# flowviant\n${line}\n`;
62
- }
63
60
 
64
61
  function liveDaemons(o) {
65
62
  const found = [];
@@ -170,7 +167,8 @@ export async function buildUninstallPlan(options = {}) {
170
167
  try {
171
168
  const output = await o.execFile(copy.path, ['--version'], { timeout: 5000 });
172
169
  const version = String(output.stdout).trim();
173
- if (/^\d+\.\d+\.\d+/.test(version)) copy.version = version;
170
+ // The same rule that orders versions decides what IS one (versionOrder.mjs).
171
+ if (parseVersion(version)) copy.version = version;
174
172
  } catch { /* A foreign or older binary may not answer. */ }
175
173
  }
176
174
  for (let i = copies.length - 1; i >= 0; i--) {
@@ -179,31 +177,19 @@ export async function buildUninstallPlan(options = {}) {
179
177
  }
180
178
  const daemons = liveDaemons(o);
181
179
  for (const copy of copies) {
180
+ // Every discovered record carries a kind the strategy table knows —
181
+ // `copyKind` throws on one it does not, so a new kind cannot be planned
182
+ // without saying what protects it and how it goes.
183
+ const kind = copyKind(copy.kind);
182
184
  const source = real(o.fs, copy.path);
183
- copy.current = copy.kind === 'path-line' ? false :
184
- (copy.kind === 'binary' ? source === own : inside(own, source));
185
- const daemon = daemons.find((d) => !o.procAvailable || !d.paths.length || d.paths.some((p) =>
186
- copy.kind === 'binary' ? p === source : inside(p, source)));
187
- if (daemon && copy.kind !== 'path-line') copy.runningPid = daemon.pid;
185
+ copy.current = kind.current(source, own);
186
+ if (!kind.runsFrom) continue;
187
+ const daemon = daemons.find((d) => !o.procAvailable || !d.paths.length || d.paths.some((p) => kind.runsFrom(p, source)));
188
+ if (daemon) copy.runningPid = daemon.pid;
188
189
  }
189
190
  return { copies, daemons, home: o.home };
190
191
  }
191
192
 
192
- function removePathBlock(o, path, block) {
193
- const before = o.fs.readFileSync(path);
194
- const at = before.indexOf(Buffer.from(block));
195
- if (at < 0) return false;
196
- const after = Buffer.concat([before.subarray(0, at), before.subarray(at + Buffer.byteLength(block))]);
197
- const mode = o.fs.statSync(path).mode & 0o7777;
198
- const temp = `${path}.${process.pid}.flowviant-tmp`;
199
- try {
200
- o.fs.writeFileSync(temp, after, { mode });
201
- o.fs.chmodSync(temp, mode);
202
- o.fs.renameSync(temp, path);
203
- } finally { try { o.fs.rmSync(temp, { force: true }); } catch { /* Renamed. */ } }
204
- return true;
205
- }
206
-
207
193
  export async function runUninstall(options = {}) {
208
194
  const o = defaults(options);
209
195
  const { others = false, purge = false, yes = false, json = false } = options;
@@ -231,32 +217,54 @@ export async function runUninstall(options = {}) {
231
217
  if (tally.failed) result.errors.push(`${tally.failed} daemon(s) could not be stopped`);
232
218
  } catch (e) { result.errors.push(`stopping daemons: ${errorWords(e)}`); }
233
219
  }
220
+ /**
221
+ * THE PURGE READS THE DISCONNECT'S OUTCOME, NEVER ITS SENTENCES.
222
+ *
223
+ * This used to match four phrases in the log lines to decide whether the
224
+ * app had been told, and then deleted `~/.flowviant` anyway — so a leave the
225
+ * app never heard destroyed the only credential that could tell it. Now a
226
+ * RETRYABLE shortfall (a daemon that would not stop, a leave the network or
227
+ * the app refused, a credential that could not be forgotten, or a disconnect
228
+ * that threw) keeps the local state, and the disconnect is asked to keep
229
+ * that project's credential after a failed leave, so running the purge again
230
+ * can finish it. An older server with no leave route is reported and does
231
+ * not hold the purge: asking it again changes nothing.
232
+ */
233
+ const unfinished = [];
234
234
  if (purge && !others) {
235
235
  try {
236
236
  const projects = o.projects || (await import('./credentials.mjs')).listStoredProjects;
237
237
  const disconnect = o.disconnect || (async (entry, log) => {
238
- const { disconnectHere, realDisconnectDeps, leaveUrlFrom } = await import('./machines.mjs');
238
+ const { disconnectHere, realDisconnectDeps } = await import('./machineDisconnect.mjs');
239
+ const { leaveUrlFrom } = await import('./machines.mjs');
239
240
  const { FLEET_URL } = await import('./config.mjs');
240
- return disconnectHere(entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log }), { log });
241
+ return disconnectHere(entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log }), { log, forgetAfterFailedLeave: false });
241
242
  });
242
243
  for (const entry of await projects()) {
243
244
  try {
244
- const messages = [];
245
- const res = await disconnect(entry, (m) => { messages.push(m); line(m); });
246
- const failure = messages.find((m) => /could not tell the app|does not take a leave|not disconnected|could not forget/.test(m));
247
- if (!res.ok || failure) result.errors.push(`registry removal for ${entry.projectId}: ${failure || messages.at(-1) || 'failed'}`);
248
- } catch (e) { result.errors.push(`registry removal for ${entry.projectId}: ${errorWords(e)}`); }
245
+ const short = disconnectShortfall(await disconnect(entry, line));
246
+ if (short) {
247
+ result.errors.push(`registry removal for ${entry.projectId}: ${short.reason}`);
248
+ if (short.retryable) unfinished.push(entry.projectId);
249
+ }
250
+ } catch (e) {
251
+ result.errors.push(`registry removal for ${entry.projectId}: ${errorWords(e)}`);
252
+ unfinished.push(entry.projectId);
253
+ }
249
254
  }
250
- } catch (e) { result.errors.push(`reading projects: ${errorWords(e)}`); }
255
+ } catch (e) {
256
+ result.errors.push(`reading projects: ${errorWords(e)}`);
257
+ unfinished.push(null);
258
+ }
251
259
  }
252
260
 
253
261
  for (const c of plan.copies) {
254
262
  const item = { kind: c.kind, path: c.path };
263
+ const kind = copyKind(c.kind);
255
264
  let reason = c.skipReason;
256
265
  if (others && c.current) reason = 'running copy';
257
266
  if (others && c.runningPid) reason = `a daemon is running from it (pid ${c.runningPid})`;
258
- if (others && c.kind === 'path-line' && plan.daemons.length && !o.procAvailable) reason = 'a daemon may be using this copy';
259
- if (others && c.kind === 'path-line' && plan.copies.some((copy) => copy.kind === 'binary' && copy.current && c.block === exactBlock(dirname(copy.path), c.path))) reason = 'needed by the running binary';
267
+ if (others) reason = kind.othersSkip(c, plan, o) ?? reason;
260
268
  if (reason) {
261
269
  result.skipped.push({ ...item, reason });
262
270
  // The copy doing the work is not news; every other skip is.
@@ -264,16 +272,18 @@ export async function runUninstall(options = {}) {
264
272
  continue;
265
273
  }
266
274
  try {
267
- if (c.kind === 'npm-global') await o.execFile('npm',
268
- c.npmPrefix ? ['uninstall', '-g', '--prefix', c.npmPrefix, 'flowviant'] : ['uninstall', '-g', 'flowviant'],
269
- { timeout: 120000 });
270
- else if (c.kind === 'path-line') removePathBlock(o, c.path, c.block);
271
- else o.fs.rmSync(c.removal, { recursive: c.kind === 'npx-cache', force: true });
275
+ await kind.remove(c, o);
272
276
  result.removed.push(item);
273
- if (!json) line(c.kind === 'path-line' ? `removed the flowviant PATH line from ${c.path}` : `removed ${c.path}`);
277
+ if (!json) line(kind.removedLine(c));
274
278
  } catch (e) { result.errors.push(`${c.path}: ${errorWords(e)}`); }
275
279
  }
276
- if (purge && !others) {
280
+ if (purge && !others && unfinished.length) {
281
+ result.kept.push(join(o.home, '.flowviant'));
282
+ // Every retryable shortfall lands here (a daemon that would not stop, a
283
+ // leave or a forget that failed, projects that could not be read); the
284
+ // `error:` lines below name which, so this line claims none of them.
285
+ if (!json) line(`Kept ~/.flowviant: the purge did not finish for every project (see the errors below). Reinstall and run \`flowviant uninstall --purge\` again to finish, or ${removeOnMachinesPage('this box')}.`);
286
+ } else if (purge && !others) {
277
287
  try { o.fs.rmSync(join(o.home, '.flowviant'), { recursive: true, force: true }); }
278
288
  catch (e) { result.errors.push(`local purge: ${errorWords(e)}`); }
279
289
  } else {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * WHAT EACH KIND OF INSTALLED COPY MEANS TO UNINSTALL — one entry per kind,
3
+ * holding the three questions uninstall asks of a copy: is it the copy this
4
+ * process runs from, does a live daemon run from it, and how is it removed
5
+ * (plus the words a person reads about it).
6
+ *
7
+ * Split out of uninstall.mjs (SOLID F058): discovery, protection and removal
8
+ * each branched on the kind string separately, so a new kind of install was
9
+ * three coordinated edits and a missed one removed a copy a daemon was
10
+ * running from. `buildUninstallPlan` still owns DISCOVERY (where copies are);
11
+ * this table owns what a copy of each kind IS. A record whose kind has no
12
+ * entry here is refused at planning, never guessed at.
13
+ */
14
+ import { basename, dirname, sep } from 'node:path';
15
+
16
+ export const inside = (path, dir) => path === dir || path.startsWith(`${dir}${sep}`);
17
+
18
+ /** The installer's PATH block for `dir` in rc file `rc`, byte for byte. */
19
+ export function exactBlock(dir, rc) {
20
+ const line = basename(rc) === 'flowviant.fish' ? `fish_add_path "${dir}"` : `export PATH="${dir}:$PATH"`;
21
+ return `\n# flowviant\n${line}\n`;
22
+ }
23
+
24
+ function removePathBlock(o, path, block) {
25
+ const before = o.fs.readFileSync(path);
26
+ const at = before.indexOf(Buffer.from(block));
27
+ if (at < 0) return false;
28
+ const after = Buffer.concat([before.subarray(0, at), before.subarray(at + Buffer.byteLength(block))]);
29
+ const mode = o.fs.statSync(path).mode & 0o7777;
30
+ const temp = `${path}.${process.pid}.flowviant-tmp`;
31
+ try {
32
+ o.fs.writeFileSync(temp, after, { mode });
33
+ o.fs.chmodSync(temp, mode);
34
+ o.fs.renameSync(temp, path);
35
+ } finally {
36
+ try {
37
+ o.fs.rmSync(temp, { force: true });
38
+ } catch {
39
+ /* Renamed. */
40
+ }
41
+ }
42
+ return true;
43
+ }
44
+
45
+ /**
46
+ * The strategies. For each kind:
47
+ * - `label`: how `doctor` names it;
48
+ * - `current(source, own)`: is this the copy this process runs from;
49
+ * - `runsFrom(daemonPath, source)`: does a daemon whose command names
50
+ * `daemonPath` run from this copy — null for a kind no daemon can run from;
51
+ * - `othersSkip(copy, plan, o)`: an extra reason `--others` must leave it
52
+ * (beyond "current" and "a daemon runs from it", which every kind obeys);
53
+ * - `remove(copy, o)`: the removal itself (may be async);
54
+ * - `removedLine(copy)`: what a person reads after it;
55
+ * - `removeHint`: what `doctor` adds after "uninstall --others" when that
56
+ * removal can need more than the command ('' when it cannot).
57
+ */
58
+ export const COPY_KINDS = Object.freeze({
59
+ binary: {
60
+ removeHint: '',
61
+ label: 'binary',
62
+ current: (source, own) => source === own,
63
+ runsFrom: (p, source) => p === source,
64
+ othersSkip: () => null,
65
+ remove: (c, o) => o.fs.rmSync(c.removal, { recursive: false, force: true }),
66
+ removedLine: (c) => `removed ${c.path}`,
67
+ },
68
+ 'npm-global': {
69
+ removeHint: ' (if npm refuses, `sudo npm uninstall -g flowviant`)',
70
+ label: 'npm global',
71
+ current: (source, own) => inside(own, source),
72
+ runsFrom: (p, source) => inside(p, source),
73
+ othersSkip: () => null,
74
+ remove: (c, o) =>
75
+ o.execFile('npm', c.npmPrefix ? ['uninstall', '-g', '--prefix', c.npmPrefix, 'flowviant'] : ['uninstall', '-g', 'flowviant'], {
76
+ timeout: 120000,
77
+ }),
78
+ removedLine: (c) => `removed ${c.path}`,
79
+ },
80
+ 'npx-cache': {
81
+ removeHint: '',
82
+ label: 'npx cache',
83
+ current: (source, own) => inside(own, source),
84
+ runsFrom: (p, source) => inside(p, source),
85
+ othersSkip: () => null,
86
+ remove: (c, o) => o.fs.rmSync(c.removal, { recursive: true, force: true }),
87
+ removedLine: (c) => `removed ${c.path}`,
88
+ },
89
+ 'path-line': {
90
+ removeHint: '',
91
+ label: 'PATH line',
92
+ // An rc file line is never "the copy this process runs from", and no
93
+ // daemon runs from it — but the binary it puts on PATH may be running.
94
+ current: () => false,
95
+ runsFrom: null,
96
+ othersSkip: (c, plan, o) => {
97
+ if (plan.copies.some((copy) => copy.kind === 'binary' && copy.current && c.block === exactBlock(dirname(copy.path), c.path)))
98
+ return 'needed by the running binary';
99
+ if (plan.daemons.length && !o.procAvailable) return 'a daemon may be using this copy';
100
+ return null;
101
+ },
102
+ remove: (c, o) => removePathBlock(o, c.path, c.block),
103
+ removedLine: (c) => `removed the flowviant PATH line from ${c.path}`,
104
+ },
105
+ });
106
+
107
+ /** The strategy for a copy's kind. Throws on a kind with no entry. */
108
+ export function copyKind(kind) {
109
+ const k = Object.hasOwn(COPY_KINDS, kind) ? COPY_KINDS[kind] : null;
110
+ if (!k) throw new Error(`unknown installed-copy kind '${kind}'`);
111
+ return k;
112
+ }
@@ -39,18 +39,14 @@ import { pipeline } from 'node:stream/promises';
39
39
  import { Readable } from 'node:stream';
40
40
  import { runningViaNpx, terminalCommand } from './launchCommand.mjs';
41
41
  import { VERSION } from './config.mjs';
42
+ import { compareVersions, versionBelow } from './versionOrder.mjs';
42
43
  import { note, ok, warn } from './ui.mjs';
43
44
 
44
- /** Compare x.y.z version strings → -1 | 0 | 1 (missing parts read as 0). */
45
- export function cmpVersion(a, b) {
46
- const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
47
- const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
48
- for (let i = 0; i < 3; i++) {
49
- const d = (pa[i] ?? 0) - (pb[i] ?? 0);
50
- if (d !== 0) return d > 0 ? 1 : -1;
51
- }
52
- return 0;
53
- }
45
+ /*
46
+ * Version order lives in versionOrder.mjs. Every decision here treats an
47
+ * UNKNOWN comparison (a version that does not parse) as "nothing to do": it
48
+ * never installs, never restarts and never nags on a version it cannot read.
49
+ */
54
50
 
55
51
  /** Bun marks compiled entrypoints with a virtual /$bunfs/ script path. */
56
52
  export function runningCompiledBinary() {
@@ -79,7 +75,7 @@ export async function installBinaryUpdate({
79
75
  !Number.isSafeInteger(file?.bytes) || file.bytes <= 0) {
80
76
  throw new Error('invalid release manifest');
81
77
  }
82
- if (minimumVersion && cmpVersion(version, minimumVersion) < 0) {
78
+ if (minimumVersion && versionBelow(version, minimumVersion)) {
83
79
  throw new Error(`release manifest still serves ${version}; waiting for ${minimumVersion}`);
84
80
  }
85
81
  const binaryResponse = await fetchImpl(`${origin}/dl/${version}/${file.name}`);
@@ -180,7 +176,10 @@ function reexec(teardown, { viaNpx = false, viaBinary = false, target = null } =
180
176
  export function updateRestartFailed(target) {
181
177
  const attempted = process.env.FLOWVIANT_UPDATE_TARGET;
182
178
  if (!attempted) return false;
183
- return cmpVersion(VERSION, attempted) < 0 && cmpVersion(target ?? attempted, attempted) <= 0;
179
+ // Unknown on either comparison is "cannot tell", which is not a failure.
180
+ const nowVsAttempted = compareVersions(VERSION, attempted);
181
+ const targetVsAttempted = compareVersions(target ?? attempted, attempted);
182
+ return nowVsAttempted === -1 && targetVsAttempted !== null && targetVsAttempted <= 0;
184
183
  }
185
184
 
186
185
  /** Install @latest globally. Throws on failure (EACCES without sudo, offline…). */
@@ -227,16 +226,28 @@ let naggedFor = null;
227
226
  let lastInstallFailAt = 0;
228
227
  const INSTALL_RETRY_MS = 15 * 60_000;
229
228
 
229
+ /**
230
+ * WHICH VERSION A {latest, min} SIGNAL ASKS FOR. Only a signal the running
231
+ * version is measurably below counts (an unreadable one is unknown, never
232
+ * below); latest wins when it counts, else min. An unreadable latest never
233
+ * becomes the target — it would feed an install a minimum nothing can meet.
234
+ * `{ target: null }` = nothing to do.
235
+ */
236
+ export function versionSignalTarget(cur, { latest, min }) {
237
+ const belowMin = Boolean(min) && versionBelow(cur, min);
238
+ const belowLatest = Boolean(latest) && versionBelow(cur, latest);
239
+ if (!belowMin && !belowLatest) return { target: null, belowMin: false };
240
+ return { target: belowLatest ? latest : min, belowMin };
241
+ }
242
+
230
243
  /**
231
244
  * React to the server's {latest, min} signal from a roster poll.
232
245
  * @returns true if it kicked off a self-update + re-exec (caller must stop).
233
246
  */
234
247
  export async function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, teardown }) {
235
248
  const cur = VERSION;
236
- const belowMin = min && cmpVersion(cur, min) < 0;
237
- const belowLatest = latest && cmpVersion(cur, latest) < 0;
238
- if (!belowMin && !belowLatest) return false; // current — nothing to do
239
- const target = latest || min;
249
+ const { target, belowMin } = versionSignalTarget(cur, { latest, min });
250
+ if (!target) return false; // current, or nothing readable — nothing to do
240
251
  const npx = runningViaNpx();
241
252
  const binary = runningCompiledBinary();
242
253
  const wantInstall = belowMin || autoUpdate;
@@ -340,7 +351,9 @@ export async function handleVersionSignal({ latest, min, autoUpdate, safeToUpdat
340
351
  } catch {
341
352
  /* offline / npm hiccup — treat as "can't confirm", skip this poll */
342
353
  }
343
- if (!published || cmpVersion(published, cur) <= 0) {
354
+ // An unreadable answer from npm is "can't confirm", like no answer at all.
355
+ const publishedOrder = published ? compareVersions(published, cur) : null;
356
+ if (publishedOrder === null || publishedOrder <= 0) {
344
357
  if (naggedFor !== target) {
345
358
  naggedFor = target;
346
359
  note(`update ${target} announced but npm still serves ${published ?? '?'} — waiting for the publish.`);