@tpsdev-ai/flair 0.53.0 → 0.54.2

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 (97) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1791 -15648
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1665 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +931 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1628 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/engine-version.js +12 -4
  43. package/dist/fabric-npm-install.js +87 -0
  44. package/dist/fabric-upgrade.js +30 -15
  45. package/dist/federation-verify.js +498 -0
  46. package/dist/fleet-verify.js +144 -21
  47. package/dist/install/clients.js +167 -0
  48. package/dist/lib/auth-resolve.js +76 -1
  49. package/dist/lib/daemon-liveness.js +131 -2
  50. package/dist/lib/doctor-config-path.js +61 -0
  51. package/dist/lib/doctor-federation-driver.js +189 -0
  52. package/dist/lib/doctor-run.js +40 -0
  53. package/dist/lib/entity-vocab-cli.js +3 -3
  54. package/dist/lib/federation-pair-identity.js +47 -0
  55. package/dist/lib/launchd-repair.js +5 -4
  56. package/dist/lib/npm-registry.js +578 -0
  57. package/dist/lib/ops-api-bind.js +115 -0
  58. package/dist/lib/owned-pins.js +219 -0
  59. package/dist/lib/uninstall-purge.js +218 -0
  60. package/dist/rem/restore.js +8 -10
  61. package/dist/resources/AgentReadPosition.js +74 -0
  62. package/dist/resources/Federation.js +8 -2
  63. package/dist/resources/Memory.js +4 -3
  64. package/dist/resources/MemoryBootstrap.js +41 -25
  65. package/dist/resources/MemoryCandidate.js +5 -6
  66. package/dist/resources/OrgEventCatchup.js +126 -47
  67. package/dist/resources/agent-read-position-lib.js +83 -0
  68. package/dist/resources/agent-read-position.js +120 -0
  69. package/dist/resources/embeddings-boot.js +32 -0
  70. package/dist/resources/federation-peer-liveness.js +73 -0
  71. package/dist/resources/health.js +68 -19
  72. package/dist/resources/mcp-tools.js +48 -279
  73. package/dist/resources/memory-visibility.js +3 -3
  74. package/dist/resources/migration-boot.js +59 -18
  75. package/dist/resources/migrations/embedding-stamp.js +20 -1
  76. package/dist/resources/migrations/recheck.js +43 -0
  77. package/dist/resources/migrations/runner.js +6 -1
  78. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  79. package/dist/resources/migrations/visibility-backfill.js +2 -2
  80. package/dist/resources/org-event-catchup-lib.js +47 -0
  81. package/dist/resources/record-owner-guard.js +1 -0
  82. package/dist/resources/tool-descriptors/index.js +669 -0
  83. package/dist/stamp-migration-verify.js +163 -0
  84. package/dist/stamp-outstanding.js +144 -0
  85. package/dist/version-check.js +29 -8
  86. package/docs/api-reference.md +4 -2
  87. package/docs/deploying-on-fabric.md +11 -10
  88. package/docs/deployment.md +3 -1
  89. package/docs/federation.md +19 -0
  90. package/docs/hosted-on-fabric.md +3 -3
  91. package/docs/quickstart.md +2 -1
  92. package/docs/releasing.md +20 -6
  93. package/docs/spoke-bringup.md +10 -5
  94. package/docs/standalone-local.md +3 -1
  95. package/docs/upgrade.md +25 -6
  96. package/package.json +4 -4
  97. package/schemas/agent.graphql +15 -0
@@ -0,0 +1,1628 @@
1
+ import { detectWiredFlairMcp, isNodeKeyId } from "../doctor-client.js";
2
+ import { UPGRADE_SNAPSHOT_ROOT, fetchDeclaredHarperVersion, readInstalledHarperVersion } from "../engine-version.js";
3
+ import { fabricUpgrade } from "../fabric-upgrade.js";
4
+ import { renderFleetSweepTable, sweepFleet } from "../fleet-verify.js";
5
+ import { resolveNpmGlobalPrefix } from "../install/global-bin-path.js";
6
+ import { defaultKeysDir } from "../lib/auth-resolve.js";
7
+ import { renderVerifiedSummary } from "../lib/doctor-run.js";
8
+ import { isDetached, renderDetachedWarning } from "../lib/launchd-management.js";
9
+ import { FLAIR_MCP_PACKAGE, clearFlairCliVersionCache } from "../lib/mcp-spec.js";
10
+ import { createRegistryNoticePrinter, fetchLatestVersion, isStrictSemver } from "../lib/npm-registry.js";
11
+ import { ownedPinRefreshShouldReport, refreshOwnedPins } from "../lib/owned-pins.js";
12
+ import { extractSnapshotSafely, validateSnapshotArchive } from "../lib/safe-snapshot-extract.js";
13
+ import { collectUpgradeExecPathWarning, findFlairPackageDir, resolveNpmGlobalFlairPackage, resolveServingFlairPackage } from "../lib/upgrade-exec-path.js";
14
+ import { applyPlainTreeUpgrade, decidePlainTreeRollback, discardPlainTreePrevious, findSystemdUnitsForTree, formatPlainTreeBanner, formatPlainTreePlan, formatPlainTreeScopeFooter, planPlainTreeUpgrade, resolvePlainTreeListingTarget, resolvePlainTreeTarget, restartSystemdUnits, restorePlainTreePrevious } from "../lib/upgrade-plain-tree.js";
15
+ import { probeInstance } from "../probe.js";
16
+ import * as render from "../render.js";
17
+ import { FLAIR_PKG_NAME, primeVersionCheckCache } from "../version-check.js";
18
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join, resolve, sep } from "node:path";
21
+ import { create as tarCreate } from "tar";
22
+ let cli;
23
+ /** Bind the cli-locals this module depends on. */
24
+ export function bindCli(fns) {
25
+ cli = fns;
26
+ }
27
+ function decideAfterRollbackVerify(...args) {
28
+ return cli.decideAfterRollbackVerify(...args);
29
+ }
30
+ function decideAfterVerify(...args) {
31
+ return cli.decideAfterVerify(...args);
32
+ }
33
+ function defaultDataDir(...args) {
34
+ return cli.defaultDataDir(...args);
35
+ }
36
+ function doctorRunAfterUpgrade(...args) {
37
+ return cli.doctorRunAfterUpgrade(...args);
38
+ }
39
+ function flairPackageDir(...args) {
40
+ return cli.flairPackageDir(...args);
41
+ }
42
+ function fleetSweepCallerExitMessage(...args) {
43
+ return cli.fleetSweepCallerExitMessage(...args);
44
+ }
45
+ function humanBytes(...args) {
46
+ return cli.humanBytes(...args);
47
+ }
48
+ function isCredentialOnlyFailure(...args) {
49
+ return cli.isCredentialOnlyFailure(...args);
50
+ }
51
+ function observeLaunchdManagement(...args) {
52
+ return cli.observeLaunchdManagement(...args);
53
+ }
54
+ function printVerifiedSummary(...args) {
55
+ return cli.printVerifiedSummary(...args);
56
+ }
57
+ function probeBinVersion(...args) {
58
+ return cli.probeBinVersion(...args);
59
+ }
60
+ function probeLibVersion(...args) {
61
+ return cli.probeLibVersion(...args);
62
+ }
63
+ function probeOpenclawPluginVersion(...args) {
64
+ return cli.probeOpenclawPluginVersion(...args);
65
+ }
66
+ function relativeTime(...args) {
67
+ return cli.relativeTime(...args);
68
+ }
69
+ function resolveAgentIdOrEnv(...args) {
70
+ return cli.resolveAgentIdOrEnv(...args);
71
+ }
72
+ function resolveFabricCredentials(...args) {
73
+ return cli.resolveFabricCredentials(...args);
74
+ }
75
+ function resolveFlairMcpFinding(...args) {
76
+ return cli.resolveFlairMcpFinding(...args);
77
+ }
78
+ function resolveHttpPort(...args) {
79
+ return cli.resolveHttpPort(...args);
80
+ }
81
+ function resolveInstalledFlairCli(...args) {
82
+ return cli.resolveInstalledFlairCli(...args);
83
+ }
84
+ function resolveInstanceServingPid(...args) {
85
+ return cli.resolveInstanceServingPid(...args);
86
+ }
87
+ function resolveUpgradeRestartVerify(...args) {
88
+ return cli.resolveUpgradeRestartVerify(...args);
89
+ }
90
+ function restartAfterUpgrade(...args) {
91
+ return cli.restartAfterUpgrade(...args);
92
+ }
93
+ function shouldPrintUpgradeLine(...args) {
94
+ return cli.shouldPrintUpgradeLine(...args);
95
+ }
96
+ function shouldRunFleetVerify(...args) {
97
+ return cli.shouldRunFleetVerify(...args);
98
+ }
99
+ function startFlairProcess(...args) {
100
+ return cli.startFlairProcess(...args);
101
+ }
102
+ function stopFlairProcess(...args) {
103
+ return cli.stopFlairProcess(...args);
104
+ }
105
+ function upgradeStatusSuffix(...args) {
106
+ return cli.upgradeStatusSuffix(...args);
107
+ }
108
+ function verifyAuthedGet(...args) {
109
+ return cli.verifyAuthedGet(...args);
110
+ }
111
+ async function runFabricUpgrade(opts) {
112
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
113
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
114
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
115
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
116
+ let fabricUser;
117
+ let fabricPassword;
118
+ let credWarnings = [];
119
+ try {
120
+ ({ fabricUser, fabricPassword, warnings: credWarnings } = resolveFabricCredentials(opts));
121
+ }
122
+ catch (err) {
123
+ console.error(red(`Error: ${err.message}`));
124
+ process.exit(1);
125
+ }
126
+ const check = opts.check ?? false;
127
+ // Creds are not required for --check (read-only registry + best-effort GET),
128
+ // but ARE required to actually deploy.
129
+ if (!check && !(fabricUser && fabricPassword)) {
130
+ console.error(red("flair upgrade --target: credentials required to deploy"));
131
+ console.error(" set FABRIC_USER + FABRIC_PASSWORD env (safest), or pass --fabric-user + --fabric-password-file <path>");
132
+ console.error(" inline --fabric-user/--fabric-password also work but leak to shell history — avoid on shared/multi-user hosts");
133
+ console.error(" or use --check to preview the plan without credentials");
134
+ process.exit(1);
135
+ }
136
+ // Never log the credential VALUES — only the flag names, via the
137
+ // resolver's own warning strings.
138
+ for (const w of credWarnings)
139
+ console.error(dim(w));
140
+ const upgradeOpts = {
141
+ target: opts.target,
142
+ project: opts.project,
143
+ // flair#926: `--flair-version`, never `opts.version` — that attribute name
144
+ // belongs to the program's `-v, --version` and never reaches this action.
145
+ version: opts.flairVersion,
146
+ harperVersion: opts.harperVersion,
147
+ fabricUser,
148
+ fabricPassword,
149
+ check,
150
+ restart: opts.restart !== false,
151
+ replicated: opts.replicated !== false,
152
+ // flair#878 — previously unreachable from this command; see
153
+ // FabricUpgradeOptions.
154
+ deployRetries: Number(opts.deployRetries ?? 0),
155
+ ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
156
+ convergenceCheck: opts.convergenceCheck !== false,
157
+ convergenceTimeoutMs: opts.convergenceTimeout != null ? Number(opts.convergenceTimeout) : undefined,
158
+ };
159
+ console.log(`${green("→")} Upgrading Fabric Flair at ${upgradeOpts.target}`);
160
+ if (check)
161
+ console.log(dim(" (--check: plan only, no deploy)"));
162
+ try {
163
+ // For a real (non-check) run, confirm first unless --yes. Building the plan
164
+ // up front would double the registry round-trips; the plan prints inside
165
+ // fabricUpgrade. We confirm BEFORE invoking when interactive and not --yes.
166
+ if (!check && !opts.yes && process.stdin.isTTY) {
167
+ const { createInterface } = await import("node:readline");
168
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
169
+ const answer = await new Promise((res) => rl.question(`Deploy a fresh ${green("@tpsdev-ai/flair")} to ${upgradeOpts.target}? [y/N] `, (a) => { rl.close(); res(a); }));
170
+ if (!/^y(es)?$/i.test(answer.trim())) {
171
+ console.log("Aborted.");
172
+ return;
173
+ }
174
+ }
175
+ const result = await fabricUpgrade(upgradeOpts);
176
+ if (check) {
177
+ console.log(`\n${green("✓")} check complete — run without --check to deploy.`);
178
+ return;
179
+ }
180
+ if (result.plan.upToDate && !result.deployed) {
181
+ console.log(`\n${green("✓")} already up to date.`);
182
+ return;
183
+ }
184
+ if (result.convergedAfterReplicationError) {
185
+ // flair#878: this deploy is a SUCCESS that harper's own exit code called
186
+ // a failure. Say both halves out loud — an operator who saw the
187
+ // replication error scroll past needs to know it resolved, and an
188
+ // operator reading only this line needs to know it happened at all.
189
+ console.log(`\n${yellow("⚠")} harper reported a peer-replication failure during this upgrade, but the component ` +
190
+ `tree on every named peer node matched the origin when checked afterwards — replication converged ` +
191
+ `on its own. Harper replicates components asynchronously, so a replication error at deploy time is ` +
192
+ `a snapshot, not a verdict.`);
193
+ }
194
+ if (result.replicationWarning) {
195
+ console.log(`\n${yellow("⚠")} Deployed to the ORIGIN NODE ONLY — peer replication did not converge and ` +
196
+ `--ignore-replication-errors was set. The peer will need to catch up via federation sync or a later deploy.`);
197
+ }
198
+ console.log(`\n${green("✓")} Fabric upgrade complete.`);
199
+ // ── Post-upgrade fleet sweep (flair#636) ────────────────────────────────
200
+ // "deploy complete" from harper's own CLI means "origin took it" — this
201
+ // confirms every known federation peer actually converged on the version
202
+ // we just deployed, instead of trusting a single boolean. Skippable with
203
+ // --no-fleet-verify. fabricUser/fabricPassword are guaranteed set here —
204
+ // the !check branch above already required both.
205
+ if (!shouldRunFleetVerify(opts)) {
206
+ console.log(dim("(--no-fleet-verify: skipping post-upgrade fleet sweep)"));
207
+ }
208
+ else {
209
+ console.log(`\n${green("→")} Fleet verify`);
210
+ const sweep = await sweepFleet({
211
+ target: upgradeOpts.target,
212
+ fabricUser: fabricUser,
213
+ fabricPassword: fabricPassword,
214
+ expectVersion: result.plan.targetVersion,
215
+ });
216
+ console.log(renderFleetSweepTable(sweep));
217
+ const upgradeSweepFail = fleetSweepCallerExitMessage(sweep);
218
+ if (upgradeSweepFail) {
219
+ console.error(red(`\n✗ ${upgradeSweepFail}`));
220
+ process.exit(sweep.exitCode);
221
+ }
222
+ }
223
+ }
224
+ catch (err) {
225
+ console.error(red(`\n✗ fabric upgrade failed: ${err.message}`));
226
+ const hint = err.message?.toLowerCase() ?? "";
227
+ if (hint.includes("401") || hint.includes("unauthoriz")) {
228
+ console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
229
+ }
230
+ // flair#878: harper's own replication error tells the operator to "pass
231
+ // ignore_replication_errors: true" — until now there was no way to do that
232
+ // through `flair upgrade`. Name the flag that actually does it, and the
233
+ // one that turns off the retry that can make things worse.
234
+ if (hint.includes("peer replication") || hint.includes("ignore_replication_errors")) {
235
+ console.error(dim(" hint: --ignore-replication-errors accepts an origin-only upgrade (the peer catches up via federation sync or a later deploy)"));
236
+ console.error(dim(" hint: --convergence-timeout <ms> waits longer for asynchronous replication before giving up (default 180000)"));
237
+ console.error(dim(" hint: --deploy-retries defaults to 0 — a retry can turn a transient replication warning into a hard install failure (flair#878)"));
238
+ }
239
+ process.exit(1);
240
+ }
241
+ }
242
+ // ─── Pre-upgrade data snapshot (flair#637) ─────────────────────────────────
243
+ // `flair upgrade` used to swap @tpsdev-ai/flair's own package with no backup
244
+ // of ~/.flair/data — if an upgrade broke something past the package level
245
+ // (schema/data, not just code), there was no tested way back. This is cheap
246
+ // insurance: a timestamped tar.gz of the whole data directory taken right
247
+ // before the package swap, with a keep-last-3 retention policy.
248
+ //
249
+ // Native-backup alternative considered and rejected: Harper ships a
250
+ // `get_backup` operation (harper's dataLayer/getBackup.ts,
251
+ // wired in server/serverHelpers/serverUtilities.ts, documented in
252
+ // components/mcp/tools/schemas/operationDescriptions.ts) that streams a
253
+ // live backup over the running HTTP operations API. It's available in this
254
+ // OSS tier (no license/tier gate found in operation_authorization.ts — just
255
+ // `requires_su`), but it backs up ONE database/table at a time
256
+ // (GetBackupObject requires `schema`/`table`, or defaults to a single "data"
257
+ // database) — not the whole `~/.flair/data` tree: no config, no
258
+ // users/roles, no keys, no other schemas. Using it here would mean
259
+ // enumerating every schema/table and making N authenticated HTTP calls
260
+ // against a server this same command is about to take down — for a LESS
261
+ // complete result than a plain recursive file copy, and one that can't run
262
+ // at all once the server is stopped (it's an operations-API call, not a
263
+ // standalone filesystem utility). Rejected in favor of the file-level
264
+ // snapshot below. See docs/upgrade.md for the restore procedure this
265
+ // produces.
266
+ // UPGRADE_SNAPSHOT_ROOT is defined in engine-version.ts (the module that owns the path)
267
+ // and imported from there for all callers.
268
+ const UPGRADE_SNAPSHOT_RETAIN = 3;
269
+ function upgradeSnapshotFileName() {
270
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
271
+ return `flair-data-${ts}.tar.gz`;
272
+ }
273
+ /**
274
+ * Snapshot `dataDir` (normally ~/.flair/data) into a timestamped tar.gz
275
+ * under ~/.flair/upgrade-snapshots/.
276
+ *
277
+ * Consistency: the caller is expected to have stopped Flair first (a
278
+ * running Harper's data dir can be mid-write, and a plain file copy of a
279
+ * live database directory isn't guaranteed point-in-time consistent —
280
+ * Harper 5.x's engine is RocksDB, verified from the .sst/WAL/MANIFEST
281
+ * layout under database/*, and a torn WAL/SST set won't open) — this
282
+ * function itself doesn't stop anything, it just archives whatever is on
283
+ * disk right now.
284
+ *
285
+ * Preserves file modes exactly — deliberately NOT using tar's `portable`
286
+ * option (used elsewhere in this file for the deploy tarball and session
287
+ * snapshots), which flattens every entry's mode to a umask-based "reasonable
288
+ * default" and would turn 0600 key/admin-pass files into whatever that
289
+ * default is. Never follows symlinks out of `dataDir`: node-tar already
290
+ * archives symlinks as symlinks by default (no `follow` option set here),
291
+ * and the filter below additionally skips any symlink whose resolved target
292
+ * falls outside `dataDir`, plus any non-regular file (sockets, FIFOs, device
293
+ * nodes — e.g. a stale `operations-server` domain socket left behind by a
294
+ * prior run) that tar can't meaningfully archive anyway.
295
+ *
296
+ * Throws on any failure — `flair upgrade` treats a snapshot failure as
297
+ * abort-the-upgrade by default (safe default; --no-snapshot is the opt-out
298
+ * for hosts that can't spare the time/disk).
299
+ *
300
+ * `snapshotRoot` defaults to UPGRADE_SNAPSHOT_ROOT (~/.flair/upgrade-snapshots)
301
+ * but is an explicit parameter — not read from homedir() internally — so
302
+ * unit tests can point it at a throwaway temp dir instead of this machine's
303
+ * real ~/.flair (test/unit/upgrade-data-snapshot.test.ts).
304
+ */
305
+ export async function createDataSnapshot(dataDir, snapshotRoot = UPGRADE_SNAPSHOT_ROOT) {
306
+ mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 });
307
+ const snapshotPath = join(snapshotRoot, upgradeSnapshotFileName());
308
+ // realpath, not just resolve() — on macOS (and some Linux distros) the
309
+ // system temp dir itself sits behind a symlink (/tmp -> /private/tmp), so
310
+ // a plain lexical resolve() of `dataDir` would never equal the realpath()
311
+ // of a symlink target genuinely INSIDE it, misclassifying every in-bounds
312
+ // symlink as an escape.
313
+ const resolvedDataDir = realpathSync(resolve(dataDir));
314
+ const filter = (entryPath) => {
315
+ // entryPath is relative to `cwd` (dataDir) per tar's create() contract.
316
+ const abs = resolve(resolvedDataDir, entryPath);
317
+ let st;
318
+ try {
319
+ st = lstatSync(abs);
320
+ }
321
+ catch {
322
+ return false; // vanished between readdir and stat — skip, don't crash the snapshot
323
+ }
324
+ if (st.isSocket() || st.isFIFO() || st.isCharacterDevice() || st.isBlockDevice()) {
325
+ console.error(` (skipping non-regular file in snapshot: ${entryPath})`);
326
+ return false;
327
+ }
328
+ if (st.isSymbolicLink()) {
329
+ let real;
330
+ try {
331
+ real = realpathSync(abs);
332
+ }
333
+ catch {
334
+ console.error(` (skipping broken symlink in snapshot: ${entryPath})`);
335
+ return false;
336
+ }
337
+ const withinDataDir = real === resolvedDataDir || real.startsWith(resolvedDataDir + sep);
338
+ if (!withinDataDir) {
339
+ console.error(` (skipping symlink pointing outside the data dir: ${entryPath})`);
340
+ return false;
341
+ }
342
+ }
343
+ return true;
344
+ };
345
+ // preservePaths: true — WITHOUT it, node-tar strips the leading `/` off
346
+ // any absolute symlink target it archives (found the hard way: an
347
+ // in-bounds symlink pointing at an absolute path under `dataDir` came
348
+ // back on extraction as a nonsense RELATIVE path, silently broken). Every
349
+ // entry path here is already relative (fileList is `["."]`, cwd is
350
+ // `dataDir`) — this only affects symlink target text, restoring it
351
+ // verbatim, which is exactly what a same-host restore into the original
352
+ // ~/.flair/data path needs.
353
+ await tarCreate({ gzip: true, cwd: resolvedDataDir, file: snapshotPath, filter, preservePaths: true }, ["."]);
354
+ // Owner-only — the archive can contain 0600 key/admin-pass material.
355
+ chmodSync(snapshotPath, 0o600);
356
+ return { path: snapshotPath, bytes: statSync(snapshotPath).size };
357
+ }
358
+ /**
359
+ * Keep only the newest `retain` upgrade snapshots, deleting older ones.
360
+ * Best-effort: a pruning failure is logged, not thrown — it must never
361
+ * un-succeed an upgrade whose snapshot already landed safely on disk.
362
+ * Returns the paths removed.
363
+ *
364
+ * `snapshotRoot` is explicit for the same testability reason as
365
+ * `createDataSnapshot` above.
366
+ */
367
+ export function pruneOldSnapshots(retain = UPGRADE_SNAPSHOT_RETAIN, snapshotRoot = UPGRADE_SNAPSHOT_ROOT) {
368
+ if (!existsSync(snapshotRoot))
369
+ return [];
370
+ const removed = [];
371
+ try {
372
+ const files = readdirSync(snapshotRoot)
373
+ .filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
374
+ .map((f) => join(snapshotRoot, f))
375
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
376
+ for (const stale of files.slice(retain)) {
377
+ try {
378
+ rmSync(stale, { force: true });
379
+ removed.push(stale);
380
+ }
381
+ catch (err) {
382
+ console.error(` (could not prune old snapshot ${stale}: ${err.message})`);
383
+ }
384
+ }
385
+ }
386
+ catch (err) {
387
+ console.error(` (snapshot retention check failed: ${err.message})`);
388
+ }
389
+ return removed;
390
+ }
391
+ export function decideUpgradeSnapshotAction(flairIsUpgrading, snapshotRequested, hasDataDir, engineVersionChanging, engineSnapshotOptOut) {
392
+ if (!flairIsUpgrading)
393
+ return "not-upgrading";
394
+ // Engine version change forces a snapshot unless explicitly opted out.
395
+ if (engineVersionChanging && hasDataDir && !engineSnapshotOptOut)
396
+ return "engine-version-change";
397
+ if (!snapshotRequested)
398
+ return hasDataDir ? "nudge" : "not-upgrading";
399
+ return hasDataDir ? "snapshot" : "no-data";
400
+ }
401
+ /**
402
+ * The exact non-blocking recommendation nudge printed when `flair upgrade`
403
+ * runs without --snapshot (the default) and a data dir exists to snapshot.
404
+ * Exported as a constant — not inlined in two places — so the CLI output and
405
+ * its unit test assertion can't drift apart. Modeled on Harper's own
406
+ * upgrade prompt ("if you have not created a backup of your data, we
407
+ * recommend you cancel and back up before proceeding") but informational,
408
+ * never blocking: this must stay safe for non-interactive/scripted upgrades.
409
+ */
410
+ export const UPGRADE_SNAPSHOT_NUDGE_LINES = [
411
+ "No pre-upgrade snapshot will be taken.",
412
+ "To capture one first: `flair snapshot create` (physical) or `flair backup` (logical export), or re-run with --snapshot.",
413
+ ];
414
+ /**
415
+ * Run the stop → snapshot → prune → restart dance for a pre-upgrade snapshot.
416
+ * Extracted from the upgrade action so the --snapshot and engine-version-change
417
+ * branches share the same mechanism (flair#1047).
418
+ *
419
+ * On snapshot failure: aborts the upgrade (process.exit(1)), restarting Flair
420
+ * first if it was stopped. On restart-after-snapshot failure: also exits.
421
+ */
422
+ async function runUpgradeSnapshot(port, dataDir) {
423
+ // Consistency: a running Harper's data dir can be mid-write, and a
424
+ // plain file copy of a live database directory isn't guaranteed
425
+ // point-in-time consistent (Harper 5.x = RocksDB: WAL/SST/MANIFEST
426
+ // can tear under a live copy). Stopping first — then immediately
427
+ // restarting the OLD version, before any package changes — gives a
428
+ // quiesced, safe-to-copy directory with only a brief blip, even for
429
+ // --no-restart (the snapshot's correctness doesn't depend on
430
+ // whether the caller wants a restart AFTER the upgrade — those are
431
+ // orthogonal). See docs/upgrade.md for the native-backup alternative
432
+ // considered and rejected (Harper's `get_backup` op backs up one
433
+ // table/schema at a time over the running HTTP API — not the whole
434
+ // data dir — and rejecting it here means this path never depends on
435
+ // the server being up).
436
+ let stoppedForSnapshot = false;
437
+ let snapshotPath = null;
438
+ try {
439
+ await stopFlairProcess(port, dataDir);
440
+ stoppedForSnapshot = true;
441
+ const snapshot = await createDataSnapshot(dataDir);
442
+ snapshotPath = snapshot.path;
443
+ const removed = pruneOldSnapshots();
444
+ console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
445
+ console.log(` Restore: flair snapshot restore "${snapshotPath}"`);
446
+ if (removed.length > 0) {
447
+ console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
448
+ }
449
+ }
450
+ catch (err) {
451
+ console.error(`❌ snapshot failed: ${err.message}`);
452
+ console.error(" Aborting upgrade — no packages were changed.");
453
+ if (stoppedForSnapshot) {
454
+ try {
455
+ await startFlairProcess(port, dataDir);
456
+ }
457
+ catch { /* best effort — surface the original snapshot error, not this */ }
458
+ }
459
+ process.exit(1);
460
+ }
461
+ try {
462
+ await startFlairProcess(port, dataDir);
463
+ }
464
+ catch (err) {
465
+ console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
466
+ console.error(` The snapshot itself succeeded (${snapshotPath}) — no packages were changed. Check: flair doctor`);
467
+ process.exit(1);
468
+ }
469
+ }
470
+ function resolveHttpPortForDataDir(opts) {
471
+ try {
472
+ return resolveHttpPort(opts);
473
+ }
474
+ catch (err) {
475
+ console.error(`❌ ${err?.message ?? err}`);
476
+ process.exit(1);
477
+ }
478
+ }
479
+ export function register(program) {
480
+ const STARTUP_TIMEOUT_MS = cli.STARTUP_TIMEOUT_MS;
481
+ // ─── flair upgrade --target <fabric> ────────────────────────────────────────
482
+ //
483
+ // One-command upgrade of a Flair instance DEPLOYED to a Harper Fabric cluster.
484
+ // Mirrors `flair deploy`'s credential handling (FABRIC_USER/FABRIC_PASSWORD env
485
+ // fallbacks, password-via-flag warning, --fabric-password-file — see
486
+ // resolveFabricCredentials above) and NEVER prints credentials. The
487
+ // version-resolution + harper pin + reuse of deploy() lives in
488
+ // src/fabric-upgrade.ts; this wrapper only does CLI plumbing + the confirm.
489
+ // ─── flair snapshot ─────────────────────────────────────────────────────────
490
+ // Explicit, first-class surface for the physical data-dir snapshot mechanism
491
+ // above (createDataSnapshot / pruneOldSnapshots / UPGRADE_SNAPSHOT_ROOT).
492
+ // Added alongside the opt-in rewrite of `flair upgrade`'s snapshot trigger
493
+ // (2026-07-08) so taking one is a real command, not just a side effect of
494
+ // upgrading with --snapshot.
495
+ //
496
+ // Deliberately NOT named/shaped like `flair backup` / `flair restore`
497
+ // (further below) — those are a LOGICAL export/import of Agent/Memory/Soul
498
+ // records as JSON over the HTTP API, portable across hosts and versions.
499
+ // `flair snapshot` is a PHYSICAL, byte-exact tar.gz of the whole
500
+ // ~/.flair/data directory (RocksDB files, keys, config, admin-pass — every
501
+ // byte, same host, same version) taken with Flair stopped for consistency.
502
+ // Different mechanism, different restore procedure, different failure
503
+ // modes — hence its own namespace (`snapshot create|list|restore`) instead
504
+ // of overloading the JSON one. Mirrors the `rem snapshot` / `session
505
+ // snapshot` subcommand idiom used elsewhere in this file.
506
+ const snapshotCmd = program
507
+ .command("snapshot")
508
+ .description("Physical ~/.flair/data snapshots (byte-exact tar.gz, local-only — see `flair backup`/`flair restore` for the logical JSON export/import)");
509
+ /**
510
+ * `resolveHttpPort` for a command that takes `--data-dir`, reported as a
511
+ * message rather than a stack trace (flair#914).
512
+ *
513
+ * The throw is a refusal to guess which instance a directory is, and a refusal
514
+ * has to tell the operator what to pass instead — a stack trace does not.
515
+ */
516
+ snapshotCmd
517
+ .command("create")
518
+ .description("Take a physical snapshot of the Flair data directory now (briefly stops Flair for a consistent copy — use `flair backup` for a no-downtime logical export)")
519
+ .option("--data-dir <path>", "Data directory to snapshot (default: ~/.flair/data)")
520
+ .option("--port <port>", "Harper HTTP port (used to quiesce Flair around the snapshot)")
521
+ .action(async (opts) => {
522
+ const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
523
+ // Existence first, THEN the port. A directory that isn't there has a more
524
+ // specific diagnosis than "it doesn't say which port it serves", and the
525
+ // caller should get the one that names the actual problem (flair#914).
526
+ if (!existsSync(dataDir)) {
527
+ console.error(`Error: data directory does not exist: ${dataDir}`);
528
+ process.exit(1);
529
+ }
530
+ // flair#914: the port of the instance NAMED here, never the per-user
531
+ // file's — refuses rather than guessing when that directory has no record.
532
+ const port = resolveHttpPortForDataDir(opts);
533
+ console.log(`Snapshotting ${dataDir}...`);
534
+ console.log("(Flair will be briefly stopped for a point-in-time-consistent copy, then restarted.)");
535
+ // Same consistency requirement as the upgrade path's snapshot step: a
536
+ // live RocksDB directory (WAL/MANIFEST/SST) isn't safe to copy while
537
+ // Flair is running, so this stops Flair, snapshots, and restarts it —
538
+ // same stop/start helpers `flair upgrade`'s snapshot step uses, so a
539
+ // standalone `flair snapshot create` gives the exact same
540
+ // point-in-time-consistent guarantee, not a weaker one.
541
+ let stoppedForSnapshot = false;
542
+ try {
543
+ // `dataDir`, not the default (flair#902) — quiesce the instance this
544
+ // command was pointed at, never whichever one owns ~/.flair/data.
545
+ await stopFlairProcess(port, dataDir);
546
+ stoppedForSnapshot = true;
547
+ const snapshot = await createDataSnapshot(dataDir);
548
+ const removed = pruneOldSnapshots();
549
+ console.log(`✅ Snapshot: ${snapshot.path} (${humanBytes(snapshot.bytes)})`);
550
+ if (removed.length > 0) {
551
+ console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
552
+ }
553
+ }
554
+ catch (err) {
555
+ console.error(`❌ snapshot failed: ${err.message}`);
556
+ if (stoppedForSnapshot) {
557
+ try {
558
+ await startFlairProcess(port, dataDir);
559
+ }
560
+ catch { /* best effort — surface the original snapshot error, not this */ }
561
+ }
562
+ process.exit(1);
563
+ }
564
+ try {
565
+ await startFlairProcess(port, dataDir);
566
+ }
567
+ catch (err) {
568
+ console.error(`❌ the snapshot succeeded but Flair failed to restart: ${err.message}`);
569
+ console.error(" Check: flair doctor");
570
+ process.exit(1);
571
+ }
572
+ });
573
+ snapshotCmd
574
+ .command("list")
575
+ .description("List physical data snapshots under ~/.flair/upgrade-snapshots/")
576
+ .option("--json", "Output as JSON")
577
+ .action((opts) => {
578
+ if (!existsSync(UPGRADE_SNAPSHOT_ROOT)) {
579
+ if (opts.json) {
580
+ console.log("[]");
581
+ return;
582
+ }
583
+ console.log(`(no snapshots — ${UPGRADE_SNAPSHOT_ROOT} does not exist yet)`);
584
+ console.log("Run `flair snapshot create` to make one, or `flair upgrade --snapshot` to take one automatically before an upgrade.");
585
+ return;
586
+ }
587
+ const rows = readdirSync(UPGRADE_SNAPSHOT_ROOT)
588
+ .filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
589
+ .map((f) => {
590
+ const p = join(UPGRADE_SNAPSHOT_ROOT, f);
591
+ const s = statSync(p);
592
+ return { file: f, path: p, size: s.size, mtime: s.mtime.toISOString() };
593
+ })
594
+ .sort((a, b) => b.mtime.localeCompare(a.mtime));
595
+ if (opts.json) {
596
+ console.log(JSON.stringify(rows, null, 2));
597
+ return;
598
+ }
599
+ if (rows.length === 0) {
600
+ console.log("(no snapshots)");
601
+ return;
602
+ }
603
+ const fileW = Math.max(20, ...rows.map((r) => r.file.length));
604
+ console.log(` ${"file".padEnd(fileW)} size age`);
605
+ for (const r of rows) {
606
+ console.log(` ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
607
+ }
608
+ console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
609
+ });
610
+ snapshotCmd
611
+ .command("restore <path>")
612
+ .description("Restore a physical snapshot: stops Flair, replaces the data directory, restarts")
613
+ .option("--data-dir <path>", "Data directory to replace (default: ~/.flair/data)")
614
+ .option("--port <port>", "Harper HTTP port")
615
+ .option("--yes", "Skip the confirmation prompt (this destroys the current data directory)")
616
+ .action(async (snapshotArg, opts) => {
617
+ const snapshotPath = resolve(snapshotArg);
618
+ if (!existsSync(snapshotPath)) {
619
+ console.error(`Error: snapshot does not exist: ${snapshotPath}`);
620
+ process.exit(1);
621
+ }
622
+ const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
623
+ // flair#914: the port of the instance NAMED here, never the per-user
624
+ // file's — refuses rather than guessing when that directory has no record.
625
+ const port = resolveHttpPortForDataDir(opts);
626
+ console.log("This will STOP Flair, DELETE the current data directory, and replace it with:");
627
+ console.log(` snapshot: ${snapshotPath}`);
628
+ console.log(` target: ${dataDir}`);
629
+ if (!opts.yes) {
630
+ if (!process.stdin.isTTY) {
631
+ console.error("\nError: refusing to destroy the data directory in a non-interactive shell without --yes.");
632
+ process.exit(1);
633
+ }
634
+ const { createInterface } = await import("node:readline");
635
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
636
+ const answer = await new Promise((res) => rl.question(`\nDestroy ${dataDir} and restore from this snapshot? [y/N] `, (a) => { rl.close(); res(a); }));
637
+ if (!/^y(es)?$/i.test(answer.trim())) {
638
+ console.log("Aborted.");
639
+ return;
640
+ }
641
+ }
642
+ try {
643
+ // `dataDir`, not the default (flair#902) — the whole point of this
644
+ // command's --data-dir is that it may name a scratch directory, and
645
+ // stopping the default instance instead is how a cautious inspect-a-
646
+ // snapshot-somewhere-else took production down.
647
+ await stopFlairProcess(port, dataDir);
648
+ }
649
+ catch (err) {
650
+ console.error(`❌ failed to stop Flair: ${err.message}`);
651
+ process.exit(1);
652
+ }
653
+ // Validate the archive BEFORE the destructive rmSync below. Restore
654
+ // accepts snapshots this CLI did not create — copied off another machine,
655
+ // downloaded, handed over during a migration — so the archive is untrusted
656
+ // input, and a hostile one must not cost the operator their data directory
657
+ // on its way to being refused.
658
+ try {
659
+ await validateSnapshotArchive({ file: snapshotPath, targetDir: dataDir });
660
+ }
661
+ catch (err) {
662
+ console.error(`❌ ${err.message}`);
663
+ console.error(` ${dataDir} was NOT modified.`);
664
+ process.exit(1);
665
+ }
666
+ try {
667
+ rmSync(dataDir, { recursive: true, force: true });
668
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
669
+ // extractSnapshotSafely keeps preservePaths: true — load-bearing for
670
+ // symlink TARGET fidelity, mirroring createDataSnapshot — while doing
671
+ // the entry-path containment that flag disables. See
672
+ // src/lib/safe-snapshot-extract.ts for why the flag cannot simply be
673
+ // dropped. No `follow` option, so symlinks extract as symlinks (never
674
+ // their targets' contents), and file modes extract exactly as stored.
675
+ await extractSnapshotSafely({ file: snapshotPath, targetDir: dataDir });
676
+ }
677
+ catch (err) {
678
+ console.error(`❌ restore failed: ${err.message}`);
679
+ console.error(` ${dataDir} may be partially restored or empty — do not start Flair until this is resolved.`);
680
+ process.exit(1);
681
+ }
682
+ // flair#914: a snapshot is a byte-exact copy of a data directory, so it
683
+ // carries the SOURCE instance's harper-config.yaml, and the extract just
684
+ // wrote it over this instance's. Between here and the boot below, that file
685
+ // names the SOURCE's port — but nothing re-resolves in that window: `port`
686
+ // was resolved before the extract and is handed to startFlairProcess
687
+ // explicitly, and Harper rewrites http.port / operationsApi.network.port
688
+ // from that spawn's environment as it boots. So the directory is
689
+ // self-describing again the moment it is serving, without flair writing into
690
+ // Harper's config to make it so.
691
+ //
692
+ // The port is a property of the instance, not of the data it serves. That
693
+ // is also what keeps a snapshot from somewhere else out of the business of
694
+ // naming ports on this host: restoring one to look at it cannot hand the
695
+ // restored directory a port it did not have — the boot immediately below is
696
+ // what settles the question, on this host's terms.
697
+ try {
698
+ await startFlairProcess(port, dataDir);
699
+ }
700
+ catch (err) {
701
+ console.error(`❌ restore succeeded but Flair failed to restart: ${err.message}`);
702
+ console.error(" Check: flair doctor");
703
+ process.exit(1);
704
+ }
705
+ console.log(`✅ Restored ${dataDir} from ${snapshotPath}`);
706
+ console.log(" Flair restarted. Verify: flair status && flair doctor");
707
+ });
708
+ // ─── flair upgrade ────────────────────────────────────────────────────────────
709
+ program
710
+ .command("upgrade")
711
+ .description("Upgrade Flair — local packages by default, or a deployed Fabric with --target")
712
+ .option("--check", "Only check for updates / show the plan, don't install or deploy")
713
+ .option("--tree <dir>", "Upgrade this extracted package tree in place (npm pack / plain-tree lane). Default: the serving instance's packed tree when that is not the npm-global install")
714
+ .option("--restart", "[deprecated] no-op — restart now happens automatically after upgrade; use --no-restart to opt out")
715
+ .option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
716
+ .option("--no-verify", "Skip post-restart health/version/auth verification (default: verify — so a broken upgrade can't report success; see flair#635)")
717
+ .option("--snapshot", "Take a pre-upgrade ~/.flair/data snapshot before the package swap, keep-last-3 retention (default: off — see `flair snapshot create` to take one by hand, or `flair backup` for a logical export; flair#637)")
718
+ .option("--no-engine-snapshot", "Skip the pre-upgrade snapshot even when the Harper engine version is changing (flair#1047). The snapshot is automatic on engine-version changes because the tested-downgrade guarantee does not hold across engine boundaries. Opting out prints what is being given up.")
719
+ .option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
720
+ // ── Fabric upgrade (--target) ────────────────────────────────────────────
721
+ // When --target is passed, upgrade the Flair component DEPLOYED to that
722
+ // Harper Fabric URL instead of the local npm install. Reuses `flair deploy`
723
+ // under the hood with the harper pin baked in (flair#513).
724
+ .option("--target <url>", "Upgrade the Flair deployed to this Fabric URL (not the local install)")
725
+ .option("--fabric-user <user>", "Fabric admin username — for --target (env: FABRIC_USER preferred; inline leaks to shell history)")
726
+ .option("--fabric-password <pass>", "Fabric admin password — for --target (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
727
+ .option("--fabric-password-file <path>", "Read the Fabric admin password from a file (chmod 600) — for --target")
728
+ // NOT `--version` (flair#926). The program declares `-v, --version`, and
729
+ // commander matches an option against the PARENT's list before dispatching to
730
+ // the subcommand — so `flair upgrade --target X --version 1.2.3` printed the
731
+ // CLI's own version and exited 0, never running the Fabric upgrade at all.
732
+ // A colliding name is normally recoverable via optsWithGlobals(); this one is
733
+ // not, because commander's version listener exits the process. The name had
734
+ // to change. `--harper-version` below is the symmetry this follows.
735
+ .option("--flair-version <semver>", "Flair version to deploy with --target, or to pin the plain-tree tarball swap (default: latest published @tpsdev-ai/flair)")
736
+ .option("--harper-version <semver>", "Pin harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
737
+ .option("--project <name>", "Fabric component name for --target", "flair")
738
+ .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
739
+ .option("--yes", "Skip the confirmation prompt for --target")
740
+ .option("--install-hooks", "Consent to installing missing SessionStart hooks (claude-code / Codex) during upgrade. The hook executes at every session start — upgrade will not write it unprompted. Interactive runs prompt; non-interactive runs state the gap and withhold ✅ unless this flag is passed.")
741
+ .option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
742
+ // ── flair#878 ─────────────────────────────────────────────────────────────
743
+ // These existed on `flair deploy` but stopped at the upgrade boundary, so
744
+ // harper's own remedy ("pass ignore_replication_errors: true") was not
745
+ // actually reachable through `flair upgrade --target`.
746
+ .option("--deploy-retries <n>", "Retry the full harper deploy this many times for --target, ONLY when peer replication is positively observed not to converge (default: 0 — a retry can escalate a transient replication warning into a hard install failure; see flair#878)", "0")
747
+ .option("--ignore-replication-errors", "For --target: if peer replication still hasn't converged, accept an origin-only deploy instead of failing (the peer catches up via federation sync or a later deploy)")
748
+ .option("--no-convergence-check", "For --target: skip the post-replication-error convergence poll and fail on harper's error verbatim (default: poll — Harper replicates asynchronously, so its error is a snapshot, not a verdict; flair#878)")
749
+ .option("--convergence-timeout <ms>", "For --target: how long to wait for peer replication to converge before reporting a replication failure (default: 180000)")
750
+ .action(async (opts) => {
751
+ // ── Fabric-upgrade branch ───────────────────────────────────────────────
752
+ if (opts.target) {
753
+ await runFabricUpgrade(opts);
754
+ return;
755
+ }
756
+ const { execFileSync } = await import("node:child_process");
757
+ const checkOnly = opts.check ?? false;
758
+ const showAll = opts.all ?? false;
759
+ console.log("Checking for updates...\n");
760
+ // flair#1109 (a): if the serving tree (or --tree) is a packed extract,
761
+ // take the in-place tarball lane instead of upgrading a leftover
762
+ // npm-global relic. (b) still probes — and still prints — when we are
763
+ // not taking that lane (git checkout, unknown path). Detection is
764
+ // best-effort and never fails the command except an explicit --tree
765
+ // that does not name a packed install (refuse, don't silently fall through).
766
+ const upgradeServingPid = resolveInstanceServingPid(defaultDataDir(), resolveHttpPort({}));
767
+ const upgradeNpmPrefix = await resolveNpmGlobalPrefix();
768
+ let treeDecision = { kind: "skip" };
769
+ try {
770
+ treeDecision = resolvePlainTreeTarget({
771
+ treeFlag: typeof opts.tree === "string" && opts.tree.trim() !== "" ? opts.tree.trim() : null,
772
+ serving: upgradeServingPid != null ? resolveServingFlairPackage(upgradeServingPid) : null,
773
+ cli: findFlairPackageDir(flairPackageDir()),
774
+ global: resolveNpmGlobalFlairPackage(upgradeNpmPrefix, process.platform),
775
+ });
776
+ }
777
+ catch { /* treat as skip — never fail the probe */ }
778
+ if (treeDecision.kind === "refuse") {
779
+ console.error(`❌ ${treeDecision.message}`);
780
+ process.exit(1);
781
+ }
782
+ const treeLane = treeDecision.kind === "use" ? treeDecision.inspection : null;
783
+ // flair#1109 (b): print the mismatch warning only when this run will
784
+ // still treat npm-global as the install. Collect always, so the (b)
785
+ // wiring test keeps seeing the call.
786
+ try {
787
+ const execPathWarning = collectUpgradeExecPathWarning({
788
+ servingPid: upgradeServingPid,
789
+ cliPackageDir: flairPackageDir(),
790
+ npmGlobalPrefix: upgradeNpmPrefix,
791
+ });
792
+ if (execPathWarning && !treeLane) {
793
+ console.log(execPathWarning);
794
+ console.log("");
795
+ }
796
+ }
797
+ catch { /* never fail upgrade over a path probe */ }
798
+ if (treeLane) {
799
+ console.log(formatPlainTreeBanner(treeLane));
800
+ console.log("");
801
+ }
802
+ const packages = [
803
+ {
804
+ name: "@tpsdev-ai/flair",
805
+ kind: "bin",
806
+ // Same PATH-independence fix as flair-mcp below: when `flair` isn't on
807
+ // PATH (a custom npm prefix — mise/fnm/nvm/volta, or the sudo-less
808
+ // user-prefix install the README recommends), the bin probe returns
809
+ // null even though the package IS globally installed, and `flair
810
+ // upgrade` mis-reports "not detected → run npm install -g". Fall back
811
+ // to the lib probe, which require.resolves the package.json regardless
812
+ // of PATH or `--version` support. (Canary's 0.25.3 dogfooding caught
813
+ // this — the fallback existed for flair-mcp but not for flair itself.)
814
+ probe: () => probeBinVersion(execFileSync, "flair") ?? probeLibVersion("@tpsdev-ai/flair"),
815
+ },
816
+ {
817
+ name: "@tpsdev-ai/flair-mcp",
818
+ kind: "bin",
819
+ // Older flair-mcp installs (e.g. 0.10.0) either aren't on PATH or
820
+ // don't support `--version`, so the bin probe returns null even when
821
+ // the package IS globally installed. Fall back to the lib
822
+ // probe, which require.resolves the package.json from a sibling global
823
+ // install regardless of PATH or --version support. kind stays "bin" so
824
+ // it remains npm-upgradeable (npm install -g), not the openclaw path.
825
+ probe: () => probeBinVersion(execFileSync, "flair-mcp") ?? probeLibVersion("@tpsdev-ai/flair-mcp"),
826
+ },
827
+ {
828
+ name: "@tpsdev-ai/openclaw-flair",
829
+ kind: "openclaw-plugin",
830
+ probe: () => probeOpenclawPluginVersion("openclaw-flair"),
831
+ },
832
+ {
833
+ name: "@tpsdev-ai/flair-client",
834
+ kind: "lib",
835
+ probe: () => probeLibVersion("@tpsdev-ai/flair-client"),
836
+ transitive: true,
837
+ },
838
+ ];
839
+ const findings = [];
840
+ // flair#1692: name the registry (and where it came from) the moment it is
841
+ // resolved, so a redirected registry is visible to the operator before
842
+ // anything is fetched or installed. One line per distinct registry.
843
+ const noticeRegistry = createRegistryNoticePrinter();
844
+ for (const { name, probe, kind, transitive } of packages) {
845
+ if (transitive && !showAll)
846
+ continue;
847
+ try {
848
+ let registryLatest = null;
849
+ try {
850
+ // flair#1688: resolve the registry npm is configured to use for this
851
+ // package (scope mapping + .npmrc + env) instead of a hardcoded host.
852
+ // flair#1692: print it, refuse disallowed schemes, disable redirects,
853
+ // and validate the returned value as strict semver before it can be
854
+ // used as an `npm install` spec.
855
+ const lookup = await fetchLatestVersion(name, {
856
+ timeoutMs: 5000,
857
+ onRegistry: noticeRegistry,
858
+ });
859
+ if (lookup.kind === "ok") {
860
+ registryLatest = lookup.version;
861
+ }
862
+ else if (lookup.kind === "invalid") {
863
+ console.error(` ⚠ ${name}: registry returned a non-semver "latest" (${JSON.stringify(lookup.value)}) ` +
864
+ `from ${lookup.registry.url} — refusing to use it as an install spec.`);
865
+ }
866
+ else if (lookup.kind === "refused") {
867
+ console.error(lookup.message);
868
+ }
869
+ // kind === "unavailable": offline/timed out — the pin path must still work.
870
+ }
871
+ catch { /* /latest timed out or failed — pin path must still work */ }
872
+ let latest;
873
+ if (treeLane && name === FLAIR_PKG_NAME) {
874
+ // Consult registry latest, then apply --flair-version as the swap
875
+ // target. A pin still applies when /latest is unavailable; without
876
+ // that, a requested tarball swap reports up to date and does nothing.
877
+ const listing = resolvePlainTreeListingTarget({
878
+ registryLatest,
879
+ pin: typeof opts.flairVersion === "string" ? opts.flairVersion : null,
880
+ });
881
+ if (!listing)
882
+ continue;
883
+ latest = listing.version;
884
+ }
885
+ else {
886
+ if (!registryLatest)
887
+ continue;
888
+ latest = registryLatest;
889
+ }
890
+ // flair#1692: a non-semver target must never reach an install spec
891
+ // (npm treats `pkg@<url>` as a remote tarball). This also covers the
892
+ // operator pin on the plain-tree lane.
893
+ if (!isStrictSemver(latest)) {
894
+ console.error(` ⚠ ${name}: refusing non-semver install target ${JSON.stringify(latest)} — expected a version like 1.2.3.`);
895
+ continue;
896
+ }
897
+ if (name === FLAIR_PKG_NAME) {
898
+ try {
899
+ primeVersionCheckCache(latest);
900
+ }
901
+ catch { /* best-effort */ }
902
+ }
903
+ const globalProbe = probe();
904
+ let installed;
905
+ let status;
906
+ if (treeLane && name === FLAIR_PKG_NAME) {
907
+ // The serving/CLI packed tree is the install. A PATH or
908
+ // require.resolve probe would report the npm-global relic.
909
+ installed = treeLane.version;
910
+ if (installed === null)
911
+ status = "missing";
912
+ else if (installed === latest)
913
+ status = "current";
914
+ else
915
+ status = "outdated";
916
+ }
917
+ else if (name === FLAIR_MCP_PACKAGE) {
918
+ // flair-mcp is zero-install via npx (#1168) — a null global probe is
919
+ // the NORMAL state, not "missing". Resolve it from its actual wiring
920
+ // (the pin in a client MCP config / the SessionStart hook) so the
921
+ // listing is truthful and the remedy actually works (flair#1208).
922
+ const home = process.env.HOME ?? homedir();
923
+ ({ installed, status } = resolveFlairMcpFinding(globalProbe, latest, detectWiredFlairMcp(home)));
924
+ }
925
+ else {
926
+ installed = globalProbe;
927
+ if (installed === null) {
928
+ // openclaw-plugin packages are optional — if openclaw isn't
929
+ // installed, don't surface a misleading "install with npm" advice.
930
+ status = kind === "openclaw-plugin" ? "optional" : "missing";
931
+ }
932
+ else if (installed === latest) {
933
+ status = "current";
934
+ }
935
+ else {
936
+ status = "outdated";
937
+ }
938
+ }
939
+ findings.push({ name, installed, latest, status, kind });
940
+ // Suppress the line for openclaw plugins that are optional-because-
941
+ // openclaw-is-absent: on machines without openclaw the
942
+ // "○ … not installed (openclaw not detected) → … (install via …)"
943
+ // line is pure noise. Still print it when openclaw IS installed
944
+ // (current/outdated) or under --all.
945
+ if (!shouldPrintUpgradeLine(status, showAll))
946
+ continue;
947
+ const icon = status === "current" ? "✅"
948
+ : status === "outdated" ? "⬆️"
949
+ : status === "optional" ? "○"
950
+ : "❔";
951
+ const installedLabel = installed ?? (status === "optional" ? "not installed (openclaw not detected)" : "not detected");
952
+ const suffix = upgradeStatusSuffix(name, status);
953
+ console.log(` ${icon} ${name}: ${installedLabel} → ${latest}${suffix}`);
954
+ }
955
+ catch { /* skip unavailable packages */ }
956
+ }
957
+ // Scope footer: make explicit what `flair upgrade` does and
958
+ // doesn't cover, so "were the others checked?" has a one-line answer.
959
+ if (treeLane) {
960
+ console.log(`\n${formatPlainTreeScopeFooter(treeLane)}`);
961
+ }
962
+ else {
963
+ console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
964
+ }
965
+ const outdated = findings.filter((f) => f.status === "outdated");
966
+ const missing = findings.filter((f) => f.status === "missing");
967
+ // flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
968
+ // the post-upgrade pin refresh below), NEVER `npm install -g` — a global
969
+ // bin does nothing for an `npx -y -p @tpsdev-ai/flair-mcp` invocation
970
+ // (#1168/#1208). So a stale-pinned flair-mcp drives a remedy line, not the
971
+ // npm-install + restart transaction. It is kept out of npmUpgrades here and
972
+ // surfaced separately below.
973
+ const flairMcpOutdated = outdated.find((f) => f.name === FLAIR_MCP_PACKAGE) ?? null;
974
+ // openclaw plugins upgrade through `openclaw plugins install`, not `npm
975
+ // install -g` (npm-installed wouldn't connect to OpenClaw's gateway slot).
976
+ // Split outdated into npm-upgradeable vs openclaw-plugin so we can use
977
+ // the right command for each.
978
+ const npmUpgrades = outdated
979
+ .filter((f) => f.kind !== "openclaw-plugin" && f.name !== FLAIR_MCP_PACKAGE)
980
+ .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
981
+ const openclawUpgrades = outdated
982
+ .filter((f) => f.kind === "openclaw-plugin")
983
+ .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
984
+ const totalUpgrades = npmUpgrades.length + openclawUpgrades.length;
985
+ let treePlan = null;
986
+ if (treeLane) {
987
+ const flairFindingForPlan = findings.find((f) => f.name === FLAIR_PKG_NAME);
988
+ treePlan = planPlainTreeUpgrade({
989
+ treeDir: treeLane.dir,
990
+ fromVersion: treeLane.version,
991
+ toVersion: flairFindingForPlan?.latest ?? treeLane.version ?? "unknown",
992
+ systemdUnits: findSystemdUnitsForTree(treeLane.dir),
993
+ });
994
+ if (flairFindingForPlan?.status === "outdated") {
995
+ console.log("");
996
+ console.log(formatPlainTreePlan(treePlan));
997
+ }
998
+ }
999
+ if (outdated.length === 0 && missing.length === 0) {
1000
+ console.log("\n✅ Everything is up to date.");
1001
+ return;
1002
+ }
1003
+ // ONE pin-refresh implementation, two callers (flair#1324): the post-
1004
+ // install refresh below (#1135/#1167), and the stale-pin-only path — when
1005
+ // flair-mcp's wired pin is behind latest but no package needs installing,
1006
+ // `flair upgrade` refreshes the pin itself instead of advising a
1007
+ // `doctor --fix` round-trip. Only refreshes clients that are ALREADY
1008
+ // wired — never wires new ones. Best-effort: failures warn but never fail
1009
+ // the upgrade.
1010
+ async function refreshWiredMcpClientPins(targetPort) {
1011
+ const agentId = resolveAgentIdOrEnv({}) ?? (() => {
1012
+ try {
1013
+ const kd = defaultKeysDir();
1014
+ const keyFiles = readdirSync(kd).filter((f) => f.endsWith(".key"));
1015
+ // Node-scoped federation keys aren't agents (flair#1193) — never
1016
+ // pin-refresh a connector as one.
1017
+ const agentKeyFile = keyFiles.find((f) => !isNodeKeyId(f.replace(/\.key$/, ""), kd));
1018
+ return agentKeyFile ? agentKeyFile.replace(/\.key$/, "") : null;
1019
+ }
1020
+ catch {
1021
+ return null;
1022
+ }
1023
+ })();
1024
+ // flair#1485: one catalogue (listOwnedPinTargets) for every file we
1025
+ // pin — MCP client configs AND SessionStart hooks. A missing agent id
1026
+ // skips MCP only; hook re-pin reads the agent from the existing command
1027
+ // and must still run (the early return here used to leave hooks stale).
1028
+ if (!agentId) {
1029
+ console.log("\n (no agent id known — skip MCP client pin refresh; SessionStart hooks still re-pin)");
1030
+ }
1031
+ const homeDir = process.env.HOME || process.env.USERPROFILE || homedir();
1032
+ const results = refreshOwnedPins({
1033
+ homeDir,
1034
+ agentId: agentId ?? null,
1035
+ flairUrl: `http://127.0.0.1:${targetPort}`,
1036
+ });
1037
+ const noteworthy = results.filter(ownedPinRefreshShouldReport);
1038
+ if (noteworthy.length === 0)
1039
+ return;
1040
+ console.log("\n Refreshing MCP client and SessionStart hook pins...");
1041
+ for (const r of noteworthy) {
1042
+ console.log(` ${r.ok ? "✓" : "•"} ${r.message}`);
1043
+ }
1044
+ }
1045
+ // Nothing to install via npm/openclaw. What is left is advisory (packages
1046
+ // not detected) and/or a flair-mcp whose wired pin is behind latest. The
1047
+ // stale pin is `flair upgrade`'s OWN job (flair#1324): refresh it right
1048
+ // here rather than bouncing the user to `flair doctor --fix` — advice
1049
+ // that was both roundabout and, until #1324, routed every upgrading user
1050
+ // through doctor's consent hazard. Under --check, only say what a real
1051
+ // run will do. `npm install -g` remains wrong for flair-mcp either way
1052
+ // (#1168/#1208).
1053
+ if (totalUpgrades === 0) {
1054
+ if (missing.length > 0) {
1055
+ const npmMissing = missing.filter((f) => f.name !== FLAIR_MCP_PACKAGE);
1056
+ const mcpMissing = missing.some((f) => f.name === FLAIR_MCP_PACKAGE);
1057
+ console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
1058
+ if (npmMissing.length > 0) {
1059
+ console.log(` Install missing: npm install -g ${npmMissing.map((f) => f.name).join(" ")}`);
1060
+ }
1061
+ if (mcpMissing) {
1062
+ console.log(` flair-mcp is zero-install via npx — run: flair doctor --fix to wire the hook`);
1063
+ }
1064
+ }
1065
+ if (flairMcpOutdated) {
1066
+ console.log(`\n⬆️ flair-mcp is wired via npx (pinned ${flairMcpOutdated.installed} → latest ${flairMcpOutdated.latest}).`);
1067
+ if (checkOnly) {
1068
+ console.log(" Run: flair upgrade (refreshes the pin)");
1069
+ }
1070
+ else {
1071
+ await refreshWiredMcpClientPins(resolveHttpPort({}));
1072
+ }
1073
+ }
1074
+ return;
1075
+ }
1076
+ if (checkOnly) {
1077
+ const treeHint = typeof opts.tree === "string" && opts.tree.trim() !== ""
1078
+ ? ` --tree ${opts.tree.trim()}`
1079
+ : treeLane ? ` --tree ${treeLane.dir}` : "";
1080
+ console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade${treeHint}`);
1081
+ if (missing.length > 0) {
1082
+ console.log(`${missing.length} package${missing.length > 1 ? "s" : ""} not detected${missing.length > 0 ? ": " + missing.map((f) => f.name).join(", ") : ""}.`);
1083
+ }
1084
+ return;
1085
+ }
1086
+ // Hoisted here (was previously computed after install/restart) — the
1087
+ // pre-upgrade snapshot below needs to know the target port AND whether a
1088
+ // restart is coming, before any package is touched. Pure function of
1089
+ // `opts` — safe to call this early.
1090
+ const { restart: shouldRestart, verify: shouldVerify, deprecatedRestartFlagUsed } = resolveUpgradeRestartVerify(opts);
1091
+ const upgradePort = resolveHttpPort({});
1092
+ // The instance this upgrade is about, named once next to its port
1093
+ // (flair#902). `flair upgrade` has no --data-dir, so this IS the default
1094
+ // install — but stop/start/restart now take the directory explicitly, so
1095
+ // the choice is made here in the open rather than assumed inside them.
1096
+ // A default that happens to be right is the same defect waiting for the
1097
+ // next caller.
1098
+ const upgradeDataDir = defaultDataDir();
1099
+ // Hoisted so the pre-flight check (below) and the post-restart/rollback
1100
+ // verification steps (further down) all target the same URL — upgrade
1101
+ // never restarts Flair onto a different port.
1102
+ const baseUrl = `http://127.0.0.1:${upgradePort}`;
1103
+ // ── Credential pre-flight (flair#741 fix #1) ────────────────────────────
1104
+ // Post-restart verification (below) needs to authenticate against the
1105
+ // running instance. If it can't do that RIGHT NOW, against the CURRENT,
1106
+ // pre-upgrade instance, every upgrade on this machine is structurally
1107
+ // doomed before a single package is touched: post-restart verify fails
1108
+ // for the exact same credential reason, the rollback fires, and the
1109
+ // rollback's own re-verify fails identically — producing "ROLLBACK ALSO
1110
+ // FAILED VERIFICATION / state UNKNOWN" for an instance that was healthy
1111
+ // the entire time. That is exactly the flair#741 incident report (a
1112
+ // real 0.22.0→0.22.1 upgrade, healthy Flair, no ~/.flair/admin-pass, no
1113
+ // FLAIR_ADMIN_PASS). Catch it here, before any mutation, with a message
1114
+ // that says plainly: nothing was touched.
1115
+ //
1116
+ // Runs the SAME verification call (probeInstance + the agent-key-aware
1117
+ // verifyAuthedGet, fix #2) that post-restart verification uses below —
1118
+ // just against the pre-upgrade instance, with no expectVersion (there's
1119
+ // no target version to compare against yet; the question here is purely
1120
+ // "does an authenticated read work at all").
1121
+ //
1122
+ // Gated on --verify (shouldVerify): this check exists ONLY to keep
1123
+ // post-restart verification honest. A user who already opted out of
1124
+ // that verification with --no-verify has no use for a pre-flight that
1125
+ // protects it, and blocking their upgrade on a check they didn't ask
1126
+ // for would be a new, surprising failure mode of its own.
1127
+ //
1128
+ // Deliberately does NOT abort when the pre-flight instance is merely
1129
+ // UNREACHABLE (down/timeout) rather than reachable-but-unauthenticated.
1130
+ // `flair upgrade` may be the user's way of FIXING a down instance (bad
1131
+ // code on disk that a newer version resolves) — today's behavior
1132
+ // (pre-flair#741, no pre-flight at all) already lets that proceed, and
1133
+ // a new hard block here would take away a legitimate recovery path for
1134
+ // a failure mode this issue was never about. Only the specific
1135
+ // "server responded, credentials didn't work" case is structurally
1136
+ // doomed in a way a fresh install/restart can't fix on its own — so
1137
+ // only that case aborts. (If a down instance turns out to ALSO lack
1138
+ // credentials, that surfaces the normal way: post-restart verification
1139
+ // fails and rolls back, same as any other post-restart failure.)
1140
+ if (shouldVerify) {
1141
+ const preflight = await probeInstance(baseUrl, {
1142
+ // A short, bounded budget — this instance is presumed already
1143
+ // running (upgrade's normal case); doctor's probePort convention
1144
+ // (probeFlairReachable's doc comment) uses the same ~3s ballpark
1145
+ // for "is anything there at all" checks.
1146
+ timeoutMs: 3000,
1147
+ pollIntervalMs: 300,
1148
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
1149
+ });
1150
+ if (isCredentialOnlyFailure(preflight)) {
1151
+ console.error(`❌ pre-flight check failed: ${preflight.error}`);
1152
+ console.error(" Nothing has been touched — no packages were installed, no restart happened.");
1153
+ console.error(" The current instance is up and responded; the verifier just has no way to authenticate against it.");
1154
+ console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key — then re-run flair upgrade.");
1155
+ console.error(" (--no-verify skips this check too, but post-restart verification would then fail the exact same way.)");
1156
+ process.exit(1);
1157
+ }
1158
+ }
1159
+ // ── Pre-upgrade data snapshot (flair#637, opt-in as of the 2026-07-08 rewire) ──
1160
+ // Only an @tpsdev-ai/flair package swap touches the code that reads/
1161
+ // writes ~/.flair/data — an flair-mcp-only or openclaw-plugin-only
1162
+ // upgrade never runs different Harper/Flair code against the data, so
1163
+ // there's nothing at risk and nothing to snapshot.
1164
+ //
1165
+ // Decision (Nathan, 2026-07-08): the physical snapshot used to run
1166
+ // automatically on every local upgrade (opt-out via --no-snapshot). That
1167
+ // defaulted every upgrade into tarring the entire data dir (can be
1168
+ // 800MB+, keep-last-3 retention ~2.5GB) for a failure mode the
1169
+ // tested-downgrade guarantee (docs/upgrade.md, test/compat/downgrade-
1170
+ // boot.test.ts) already covers — and it diverged from Harper's own
1171
+ // upgrade CLI, which recommends a backup before proceeding but never
1172
+ // auto-tars the data directory itself. `--snapshot` is now opt-in, off
1173
+ // by default; opting out gets a non-blocking recommendation nudge
1174
+ // instead of a silent skip. The underlying mechanism (createDataSnapshot
1175
+ // / pruneOldSnapshots, the stop-snapshot-restart quiesce dance, and
1176
+ // abort-the-upgrade-on-snapshot-failure) is unchanged — only the trigger
1177
+ // moved from opt-out to opt-in. `flair snapshot create` (below) exposes
1178
+ // the exact same mechanism as a standalone command for anyone who wants
1179
+ // one without wrapping it around an upgrade.
1180
+ //
1181
+ // flair#1047: the tested-downgrade guarantee does not hold across engine
1182
+ // version boundaries — a Harper bump is the only realistic source of a
1183
+ // cross-version boot break. When the engine version is changing, the
1184
+ // snapshot is unconditional. Opting out requires --no-engine-snapshot
1185
+ // and prints what is being given up.
1186
+ const flairIsUpgrading = npmUpgrades.some((u) => u.pkg === "@tpsdev-ai/flair");
1187
+ const hasDataDir = existsSync(upgradeDataDir);
1188
+ const flairFinding = findings.find((f) => f.name === "@tpsdev-ai/flair");
1189
+ // Determine whether the engine (Harper) version is changing.
1190
+ let engineVersionChanging = false;
1191
+ let currentEngineVersion = null;
1192
+ let targetEngineVersion = null;
1193
+ if (flairIsUpgrading && hasDataDir) {
1194
+ currentEngineVersion = readInstalledHarperVersion(treeLane?.dir ?? flairPackageDir());
1195
+ const targetFlairVersion = flairFinding?.latest;
1196
+ if (targetFlairVersion && currentEngineVersion) {
1197
+ targetEngineVersion = await fetchDeclaredHarperVersion(targetFlairVersion);
1198
+ if (targetEngineVersion === null) {
1199
+ // Registry lookup failed — cannot determine the target Harper
1200
+ // version. Assume it might change (safe default) and print why.
1201
+ engineVersionChanging = true;
1202
+ console.log(render.wrap(render.c.dim, `Could not determine the target Harper version from the npm registry — forcing a pre-upgrade snapshot as a precaution.`));
1203
+ }
1204
+ else {
1205
+ engineVersionChanging = targetEngineVersion !== currentEngineVersion;
1206
+ }
1207
+ }
1208
+ else {
1209
+ // Cannot determine — assume it might change (safe default).
1210
+ engineVersionChanging = true;
1211
+ }
1212
+ }
1213
+ const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, hasDataDir, engineVersionChanging, !!opts.noEngineSnapshot);
1214
+ let snapshotPath = null;
1215
+ if (snapshotDecision === "nudge") {
1216
+ // Non-blocking nudge only — never prompt/block here, this must stay
1217
+ // safe for non-interactive/scripted upgrades. Modeled on Harper's own
1218
+ // upgrade prompt ("if you have not created a backup ... we recommend
1219
+ // you cancel and back up before proceeding") but informational, not a
1220
+ // gate.
1221
+ console.log("");
1222
+ for (const line of UPGRADE_SNAPSHOT_NUDGE_LINES)
1223
+ console.log(render.wrap(render.c.dim, line));
1224
+ }
1225
+ else if (snapshotDecision === "no-data") {
1226
+ console.log(`\n(no data directory at ${upgradeDataDir} yet — nothing to snapshot)`);
1227
+ }
1228
+ else if (snapshotDecision === "engine-version-change") {
1229
+ // Engine version is changing — snapshot is unconditional (flair#1047).
1230
+ // The operator can opt out with --no-engine-snapshot, which prints what
1231
+ // is being given up (handled in the nudge branch above).
1232
+ const fromLabel = currentEngineVersion ?? "unknown";
1233
+ const toLabel = targetEngineVersion ?? "unknown";
1234
+ console.log(`\nHarper engine version changing (${fromLabel} → ${toLabel}) — snapshotting data before upgrade...`);
1235
+ console.log(render.wrap(render.c.dim, "The tested-downgrade guarantee does not hold across engine version boundaries."));
1236
+ console.log(render.wrap(render.c.dim, "Pass --no-engine-snapshot to skip this (not recommended)."));
1237
+ await runUpgradeSnapshot(upgradePort, upgradeDataDir);
1238
+ }
1239
+ else if (snapshotDecision === "snapshot") {
1240
+ console.log("\nSnapshotting data before upgrade...");
1241
+ await runUpgradeSnapshot(upgradePort, upgradeDataDir);
1242
+ }
1243
+ // Perform upgrade. `latest` comes from the npm registry's HTTP
1244
+ // response, so CodeQL (correctly) treats it as untrusted input.
1245
+ // Use execFileSync with argv — the spec `<name>@<version>` becomes a
1246
+ // single argument to the upgrade command, no shell to inject into.
1247
+ console.log(`\nUpgrading ${totalUpgrades} package${totalUpgrades > 1 ? "s" : ""}...\n`);
1248
+ // Tracked separately (rather than inferred from findings alone) because the
1249
+ // post-restart verify/rollback step below needs to know whether @tpsdev-ai/flair's
1250
+ // OWN install actually succeeded — if it failed, the running version is still the
1251
+ // OLD one and verification should expect that, not the target we failed to reach.
1252
+ let flairInstallFailed = false;
1253
+ for (const { pkg, latest } of npmUpgrades) {
1254
+ try {
1255
+ // flair#1692 backstop: the listing validated this, but the install is
1256
+ // the point of no return. npm accepts `pkg@<url>` as a remote-tarball
1257
+ // spec, so a non-semver target must never reach this argv.
1258
+ if (!isStrictSemver(latest)) {
1259
+ console.error(` ❌ ${pkg} upgrade skipped: non-semver target ${JSON.stringify(latest)}`);
1260
+ if (pkg === FLAIR_PKG_NAME)
1261
+ flairInstallFailed = true;
1262
+ continue;
1263
+ }
1264
+ if (treePlan && pkg === FLAIR_PKG_NAME) {
1265
+ console.log(` Fetching ${pkg}@${latest} (npm pack) and swapping ${treePlan.treeDir}...`);
1266
+ await applyPlainTreeUpgrade(treePlan);
1267
+ console.log(` ✅ ${pkg}@${latest} installed (plain-tree swap; previous tree at ${treePlan.previousDir})`);
1268
+ continue;
1269
+ }
1270
+ console.log(` Installing ${pkg}@${latest}...`);
1271
+ execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
1272
+ console.log(` ✅ ${pkg}@${latest} installed`);
1273
+ }
1274
+ catch (err) {
1275
+ console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
1276
+ if (pkg === "@tpsdev-ai/flair")
1277
+ flairInstallFailed = true;
1278
+ }
1279
+ }
1280
+ for (const { pkg, latest } of openclawUpgrades) {
1281
+ // OpenClaw plugins upgrade via `openclaw plugins install --force --pin`.
1282
+ // Requires openclaw on PATH; if not, surface the manual recipe instead
1283
+ // of a confusing failure.
1284
+ try {
1285
+ execFileSync("openclaw", ["--version"], { stdio: "pipe", timeout: 2000 });
1286
+ }
1287
+ catch {
1288
+ console.error(` ❌ ${pkg} upgrade skipped: openclaw not on PATH. Install manually: openclaw plugins install ${pkg}@${latest} --force --pin`);
1289
+ continue;
1290
+ }
1291
+ try {
1292
+ console.log(` Installing ${pkg}@${latest} via openclaw...`);
1293
+ execFileSync("openclaw", ["plugins", "install", `${pkg}@${latest}`, "--force", "--pin"], { stdio: "pipe" });
1294
+ console.log(` ✅ ${pkg}@${latest} installed`);
1295
+ }
1296
+ catch (err) {
1297
+ console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
1298
+ }
1299
+ }
1300
+ // flair#1167: `npm install -g` replaced package.json in-place, so the
1301
+ // module-load-cached CLI version is stale. Clear it so mcpServerSpec()
1302
+ // resolves the NEW version for the pin refresh below.
1303
+ clearFlairCliVersionCache();
1304
+ // ── Refresh wired MCP client configs (flair#1135, flair#1167) ──────────
1305
+ // After a successful package install, the flair-mcp package on disk is
1306
+ // newer than the pinned version in wired client configs. Re-run wiring for
1307
+ // already-wired clients so the pin stays in lockstep with the installed
1308
+ // version. Runs BEFORE the restart so --no-restart and --no-verify paths
1309
+ // also get the refresh (flair#1167). Best-effort: failures warn but never
1310
+ // fail the upgrade.
1311
+ await refreshWiredMcpClientPins(upgradePort);
1312
+ // ── Restart + verify + rollback (flair#635) ─────────────────────────────
1313
+ // Decision (2026-07-08): restart is now the default post-upgrade step —
1314
+ // installing new code without restarting leaves the OLD process serving
1315
+ // while the version on disk lies about what's actually running.
1316
+ // --no-restart opts back out for the "stage now, bounce later" case.
1317
+ // --restart is kept as a deprecated no-op for old muscle memory.
1318
+ // Upgrade = install → restart → verify → (rollback on failure), one
1319
+ // transaction — never report success on a broken restart.
1320
+ const previousFlairVersion = flairFinding?.installed ?? null;
1321
+ const expectedFlairVersion = flairFinding?.status === "outdated" && !flairInstallFailed
1322
+ ? flairFinding.latest
1323
+ : flairFinding?.installed ?? null;
1324
+ // shouldRestart/shouldVerify/deprecatedRestartFlagUsed were hoisted above
1325
+ // the pre-upgrade snapshot block — it needs to know these before any
1326
+ // package is touched.
1327
+ if (deprecatedRestartFlagUsed) {
1328
+ console.error("warning: --restart is deprecated and is now a no-op — flair upgrade restarts by default. Use --no-restart to skip it.");
1329
+ }
1330
+ if (!shouldRestart) {
1331
+ console.log("\nRun: flair restart to use the new version");
1332
+ if (treePlan) {
1333
+ console.log(`Previous tree kept at ${treePlan.previousDir} until you restart and verify.`);
1334
+ }
1335
+ return;
1336
+ }
1337
+ console.log("\nRestarting Flair...");
1338
+ const port = upgradePort;
1339
+ // baseUrl was hoisted above (pre-flight, fix #1) — same URL, no redeclaration.
1340
+ /**
1341
+ * Roll @tpsdev-ai/flair back to `toVersion`, restart on it, re-verify, and
1342
+ * exit. Shared by the two ways an upgrade can fail after the package swap:
1343
+ * the restart itself (flair#905) and post-restart verification (flair#635).
1344
+ *
1345
+ * flair#905 found the restart leg wired straight to `process.exit(1)` — so
1346
+ * `docs/upgrade.md`'s "install → restart → verify → rollback-on-failure, in
1347
+ * one step" was only ever true for the verify leg. An upgrade that installed
1348
+ * new packages and then failed to start them left the operator on the new
1349
+ * version with nothing running and no rollback, which is the one outcome the
1350
+ * whole transaction exists to prevent.
1351
+ */
1352
+ const rollbackTo = async (toVersion, reason) => {
1353
+ console.log(`\nRolling back @tpsdev-ai/flair to ${toVersion}...`);
1354
+ try {
1355
+ if (treePlan) {
1356
+ const rollbackDecision = decidePlainTreeRollback(existsSync(treePlan.previousDir));
1357
+ if (rollbackDecision.kind === "restore") {
1358
+ if (!restorePlainTreePrevious(treePlan)) {
1359
+ throw new Error(`no previous tree at ${treePlan.previousDir} to restore`);
1360
+ }
1361
+ console.log(` ✅ restored previous tree from ${treePlan.previousDir}`);
1362
+ }
1363
+ else {
1364
+ console.log(` (${rollbackDecision.reason})`);
1365
+ }
1366
+ }
1367
+ else {
1368
+ execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
1369
+ }
1370
+ }
1371
+ catch (err) {
1372
+ console.error(`❌ rollback install failed: ${err.message}`);
1373
+ console.error(` Flair is currently on the FAILED version (${expectedFlairVersion ?? "unknown"}) and is NOT running.`);
1374
+ const prevExists = !!(treePlan && existsSync(treePlan.previousDir));
1375
+ console.error(treePlan
1376
+ ? (prevExists
1377
+ ? ` Recover by hand: restore ${treePlan.previousDir} to ${treePlan.treeDir} && flair start`
1378
+ : ` The live tree at ${treePlan.treeDir} was not swapped; there is no .upgrade-prev to restore. Start it with: flair start`)
1379
+ : ` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
1380
+ process.exit(1);
1381
+ }
1382
+ // flair#1053: when the engine (Harper) version changed, the pre-upgrade
1383
+ // snapshot is the ONLY way back — the old Harper cannot read data written
1384
+ // by the new one (e.g. 5.2 LZ4-compressed storage is unreadable by 5.1).
1385
+ // Restore it before restarting, or refuse loudly when none exists.
1386
+ if (engineVersionChanging) {
1387
+ if (snapshotPath) {
1388
+ console.log(`\nEngine version changed — restoring pre-upgrade snapshot before rollback...`);
1389
+ console.log(` snapshot: ${snapshotPath}`);
1390
+ console.log(` target: ${upgradeDataDir}`);
1391
+ try {
1392
+ await validateSnapshotArchive({ file: snapshotPath, targetDir: upgradeDataDir });
1393
+ rmSync(upgradeDataDir, { recursive: true, force: true });
1394
+ mkdirSync(upgradeDataDir, { recursive: true, mode: 0o700 });
1395
+ await extractSnapshotSafely({ file: snapshotPath, targetDir: upgradeDataDir });
1396
+ console.log(` ✅ snapshot restored`);
1397
+ }
1398
+ catch (err) {
1399
+ console.error(`❌ snapshot restore failed: ${err.message}`);
1400
+ console.error(` @tpsdev-ai/flair@${toVersion} is installed but the data directory could not be restored.`);
1401
+ console.error(` The snapshot itself is intact at ${snapshotPath} — restore it by hand:`);
1402
+ console.error(` flair snapshot restore "${snapshotPath}"`);
1403
+ console.error(` Then: flair start`);
1404
+ process.exit(1);
1405
+ }
1406
+ }
1407
+ else {
1408
+ // No snapshot exists — the old Harper WILL NOT BOOT against the new
1409
+ // data. Refuse loudly rather than attempting a guaranteed failure.
1410
+ console.error(`\n❌ Cannot roll back: the Harper engine version changed (${currentEngineVersion ?? "?"} → ${targetEngineVersion ?? "?"}) and no pre-upgrade snapshot exists.`);
1411
+ console.error(` The old Harper cannot read data written by the new engine — restarting without a snapshot restore would fail.`);
1412
+ console.error(` @tpsdev-ai/flair@${toVersion} is installed but NOT running.`);
1413
+ if (snapshotDecision === "nudge") {
1414
+ console.error(` A snapshot was skipped because --no-engine-snapshot was passed.`);
1415
+ console.error(` Recovery options:`);
1416
+ console.error(` 1. Re-upgrade to the version that wrote this data: npm install -g @tpsdev-ai/flair@${expectedFlairVersion ?? "latest"} && flair start`);
1417
+ console.error(` 2. Restore from a ` + "`flair backup`" + ` JSON export on a fresh data directory.`);
1418
+ }
1419
+ else {
1420
+ console.error(` No snapshot was taken (data directory may not have existed, or the snapshot step was skipped).`);
1421
+ console.error(` Recovery: re-upgrade to the version that wrote this data, or restore from a ` + "`flair backup`" + ` JSON export.`);
1422
+ }
1423
+ process.exit(1);
1424
+ }
1425
+ }
1426
+ // Same post-swap rule as the upgrade restart above: the rolled-back
1427
+ // version's own CLI is the thing that knows how to start it.
1428
+ const rolledBackRoot = treePlan?.treeDir ?? flairPackageDir();
1429
+ const rolledBackCli = resolveInstalledFlairCli(rolledBackRoot, toVersion);
1430
+ try {
1431
+ if (treePlan && treePlan.systemdUnits.length > 0) {
1432
+ console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
1433
+ restartSystemdUnits(treePlan.systemdUnits);
1434
+ }
1435
+ else {
1436
+ await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
1437
+ }
1438
+ }
1439
+ catch (err) {
1440
+ console.error(`❌ rollback restart failed: ${err.message}`);
1441
+ console.error(` @tpsdev-ai/flair@${toVersion} is installed but NOT running. Start it with: flair start`);
1442
+ console.error(" Then check: flair status");
1443
+ process.exit(1);
1444
+ }
1445
+ const rollbackVerify = await probeInstance(baseUrl, {
1446
+ expectVersion: toVersion,
1447
+ timeoutMs: STARTUP_TIMEOUT_MS,
1448
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
1449
+ });
1450
+ const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
1451
+ if (rollbackVerdict.kind === "rolled-back") {
1452
+ console.error(`❌ upgrade failed and was rolled back to @tpsdev-ai/flair@${toVersion} (running, verified).`);
1453
+ console.error(` Original failure: ${reason}`);
1454
+ process.exit(1);
1455
+ }
1456
+ console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
1457
+ // flair#741 fix #3: this is the exact incident report — a 403 from a
1458
+ // responding, healthy server (credentials-only failure) was printed as
1459
+ // "state UNKNOWN — do not assume data integrity" for BOTH the upgrade
1460
+ // verify AND the rollback re-verify, because the same missing-auth-
1461
+ // material condition rejects both. Reserve the UNKNOWN/do-not-assume
1462
+ // text for failures where the instance's real state genuinely can't be
1463
+ // determined (connection refused, timeout, 5xx) — a credential-only
1464
+ // failure here means the rollback likely landed fine and the checker
1465
+ // simply can't prove it.
1466
+ if (isCredentialOnlyFailure(rollbackVerify)) {
1467
+ console.error(" The instance is up and responding — the verifier could not authenticate (credentials, not the rollback, are the problem).");
1468
+ console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key, then check: flair doctor");
1469
+ }
1470
+ else {
1471
+ console.error(" Instance state is UNKNOWN — do not assume data integrity.");
1472
+ }
1473
+ // This double-failure isn't auto-recoverable yet (flair#637) — but if a
1474
+ // pre-upgrade snapshot landed, point at the CONCRETE path instead of
1475
+ // just the issue number, so recovery doesn't start with a GitHub search.
1476
+ if (snapshotPath) {
1477
+ console.error(` A pre-upgrade snapshot is available: ${snapshotPath}`);
1478
+ console.error(` Restore: flair snapshot restore "${snapshotPath}" (or see docs/upgrade.md#downgrade).`);
1479
+ }
1480
+ else {
1481
+ console.error(" No pre-upgrade snapshot was taken for this run (snapshot is opt-in — pass --snapshot next time, or ~/.flair/data didn't exist yet).");
1482
+ console.error(" Check `flair snapshot list` for a manual one, or restore from a `flair backup` JSON export. See docs/upgrade.md#downgrade.");
1483
+ }
1484
+ process.exit(1);
1485
+ };
1486
+ // flair#905: hand the restart to the CLI that was just installed, resolved
1487
+ // from disk AFTER the swap. `null` (flair itself wasn't swapped, or the new
1488
+ // tree can't be verified) falls back to an in-process restart, announced.
1489
+ const flairWasSwapped = flairIsUpgrading && !flairInstallFailed;
1490
+ const swappedPackageRoot = treePlan?.treeDir ?? flairPackageDir();
1491
+ let newCli = null;
1492
+ if (flairWasSwapped) {
1493
+ const resolved = resolveInstalledFlairCli(swappedPackageRoot, expectedFlairVersion);
1494
+ if (resolved.ok === false) {
1495
+ console.error(`warning: could not verify the newly installed CLI (${resolved.reason}) — restarting with this process's own code instead.`);
1496
+ }
1497
+ else {
1498
+ newCli = { cliPath: resolved.cliPath, version: resolved.version };
1499
+ }
1500
+ }
1501
+ let restartWasDelegated = false;
1502
+ try {
1503
+ if (treePlan && treePlan.systemdUnits.length > 0) {
1504
+ console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
1505
+ restartSystemdUnits(treePlan.systemdUnits);
1506
+ restartWasDelegated = true;
1507
+ console.log("✅ Flair restarted (systemd unit)");
1508
+ }
1509
+ else {
1510
+ restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
1511
+ }
1512
+ }
1513
+ catch (err) {
1514
+ console.error(`❌ restart failed: ${err.message}`);
1515
+ console.error(" Flair is NOT running. Your data in ~/.flair was not touched by this upgrade.");
1516
+ if (flairWasSwapped && previousFlairVersion) {
1517
+ await rollbackTo(previousFlairVersion, `restart failed: ${err.message}`);
1518
+ }
1519
+ // Not reached when a rollback ran — rollbackTo always exits. Say WHICH of
1520
+ // the two "no rollback" cases this is; "nothing to roll back" is not the
1521
+ // same statement as "we don't know what to roll back to".
1522
+ console.error(flairWasSwapped
1523
+ ? " Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown."
1524
+ : " Nothing to roll back: @tpsdev-ai/flair itself was not changed by this upgrade.");
1525
+ console.error(" Start it with: flair start — then check: flair status");
1526
+ process.exit(1);
1527
+ }
1528
+ // The delegated `flair restart` printed its own success line; don't say it twice.
1529
+ if (!restartWasDelegated)
1530
+ console.log("✅ Flair restarted");
1531
+ // flair#1022 — the headline defect. The restart above is allowed to fall
1532
+ // back off launchd to a plain detached spawn, and SHOULD be: a running
1533
+ // instance beats a down one. What was missing is that the fallback changes
1534
+ // whether anything brings this instance back after a reboot, and the
1535
+ // verification below made no claim about it. `healthy, authenticated,
1536
+ // running <new version>` was every word true of an instance that had just
1537
+ // been orphaned.
1538
+ //
1539
+ // Observed here rather than reported by the restart, because
1540
+ // `restartAfterUpgrade` may have delegated to the newly installed CLI in a
1541
+ // CHILD PROCESS (flair#905) — no in-process flag crosses that boundary.
1542
+ // Asking launchd is the one form of this check that is correct on both
1543
+ // paths.
1544
+ const management = observeLaunchdManagement(upgradeDataDir, port);
1545
+ const detached = isDetached(management);
1546
+ if (!shouldVerify) {
1547
+ console.log(" (--no-verify: skipping post-restart verification)");
1548
+ if (treePlan) {
1549
+ console.log(` Previous tree kept at ${treePlan.previousDir} (rollback source; not discarded without verify).`);
1550
+ }
1551
+ if (detached) {
1552
+ for (const line of renderDetachedWarning(management, "Flair is running, but NOT under launchd.")) {
1553
+ console.error(line);
1554
+ }
1555
+ }
1556
+ return;
1557
+ }
1558
+ console.log("\nVerifying...");
1559
+ // The authenticated leg reuses verifyAuthedGet (flair#741 fix #2): api()'s
1560
+ // local-credential resolution (flair#640: env > agent key when an agentId
1561
+ // is already known > ~/.flair/admin-pass file), PLUS an Ed25519 agent-key
1562
+ // fallback when none of that resolves anything — see verifyAuthedGet's
1563
+ // doc comment. probeInstance itself never resolves credentials, it just
1564
+ // calls whatever's handed to it.
1565
+ const verify = await probeInstance(baseUrl, {
1566
+ expectVersion: expectedFlairVersion ?? undefined,
1567
+ timeoutMs: STARTUP_TIMEOUT_MS,
1568
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
1569
+ });
1570
+ const verdict = decideAfterVerify(verify, previousFlairVersion);
1571
+ if (verdict.kind === "ok") {
1572
+ // flair#1439: the success marker is the doctor runner's verdict, not
1573
+ // a second, narrower notion of "verified". Launchd detach is one
1574
+ // catalog member; the Codex SessionStart hook is another. Adding a
1575
+ // doctor check widens this claim automatically.
1576
+ const run = await doctorRunAfterUpgrade({
1577
+ management,
1578
+ port,
1579
+ installHooksFlag: !!opts.installHooks,
1580
+ fromVersion: previousFlairVersion,
1581
+ toVersion: expectedFlairVersion,
1582
+ });
1583
+ printVerifiedSummary(renderVerifiedSummary(verify.version, run));
1584
+ if (treePlan)
1585
+ discardPlainTreePrevious(treePlan.previousDir);
1586
+ return;
1587
+ }
1588
+ // flair#741 follow-through: a healthy instance the verifier just couldn't
1589
+ // authenticate against. The upgrade SUCCEEDED — the new version's server is
1590
+ // up (public /Health passed); we simply couldn't read its version over the
1591
+ // authenticated /HealthDetail. Report the caveat and STOP — never roll back
1592
+ // a running instance over a credentials gap. (decideAfterVerify only
1593
+ // returns this for isCredentialOnlyFailure(verify), so the old
1594
+ // "print an honest note but roll back anyway" branch that used to sit below
1595
+ // is gone — that credentials case can no longer reach the rollback path.)
1596
+ if (verdict.kind === "healthy-unverified") {
1597
+ // Same doctor runner as the "ok" branch — an unverified version must
1598
+ // not restore the unqualified ✅ while a catalog member is failing.
1599
+ const run = await doctorRunAfterUpgrade({
1600
+ management,
1601
+ port,
1602
+ installHooksFlag: !!opts.installHooks,
1603
+ fromVersion: previousFlairVersion,
1604
+ toVersion: expectedFlairVersion,
1605
+ });
1606
+ const versionNote = expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : "";
1607
+ if (run.healthy) {
1608
+ console.log(`✅ upgrade complete: the instance is up and healthy${versionNote}.`);
1609
+ }
1610
+ else {
1611
+ printVerifiedSummary(renderVerifiedSummary(verify.version, run, { authenticated: false }));
1612
+ }
1613
+ console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
1614
+ console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
1615
+ console.log(" To enable full post-upgrade verification: set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key.");
1616
+ if (treePlan)
1617
+ discardPlainTreePrevious(treePlan.previousDir);
1618
+ return;
1619
+ }
1620
+ console.error(`❌ post-restart verification failed: ${verdict.reason}`);
1621
+ if (verdict.kind === "cannot-rollback") {
1622
+ console.error(" Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown.");
1623
+ console.error(" Check the instance now: flair doctor");
1624
+ process.exit(1);
1625
+ }
1626
+ await rollbackTo(verdict.toVersion, verdict.reason);
1627
+ });
1628
+ }