@tpsdev-ai/flair 0.53.0 → 0.54.1

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