@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.
- package/README.md +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1791 -15648
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1665 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +931 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1628 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/engine-version.js +12 -4
- package/dist/fabric-npm-install.js +87 -0
- package/dist/fabric-upgrade.js +30 -15
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +5 -4
- package/dist/lib/npm-registry.js +578 -0
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +48 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/resources/tool-descriptors/index.js +669 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/dist/version-check.js +29 -8
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +20 -6
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/package.json +4 -4
- 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
|
+
}
|