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.
- package/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- 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
|
+
}
|
package/bin/lib/uninstall.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
184
|
-
|
|
185
|
-
const daemon = daemons.find((d) => !o.procAvailable || !d.paths.length || d.paths.some((p) =>
|
|
186
|
-
|
|
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
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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) {
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/bin/lib/update.mjs
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 =
|
|
237
|
-
|
|
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
|
-
|
|
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.`);
|