@tpsdev-ai/flair 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1355 -281
  3. package/dist/deploy.js +212 -24
  4. package/dist/fabric-upgrade.js +16 -1
  5. package/dist/federation/scheduler.js +500 -0
  6. package/dist/install/clients.js +111 -53
  7. package/dist/lib/mcp-spec.js +128 -0
  8. package/dist/lib/safe-snapshot-extract.js +231 -0
  9. package/dist/lib/scheduler-platform.js +128 -0
  10. package/dist/lib/xml-escape.js +54 -0
  11. package/dist/rem/scheduler.js +35 -87
  12. package/dist/rem/snapshot.js +13 -0
  13. package/dist/replication-convergence.js +505 -0
  14. package/dist/resources/MemoryBootstrap.js +7 -8
  15. package/dist/resources/SemanticSearch.js +17 -45
  16. package/dist/resources/abstention.js +1 -1
  17. package/dist/resources/embeddings-boot.js +10 -12
  18. package/dist/resources/embeddings-provider.js +10 -7
  19. package/dist/resources/health.js +24 -19
  20. package/dist/resources/in-process.js +225 -0
  21. package/dist/resources/mcp-tools.js +23 -17
  22. package/dist/resources/migration-boot.js +80 -10
  23. package/dist/resources/migrations/data-dir.js +205 -0
  24. package/dist/resources/migrations/progress.js +33 -0
  25. package/dist/resources/migrations/runner.js +29 -2
  26. package/dist/resources/migrations/state.js +13 -2
  27. package/dist/resources/models-dir.js +18 -9
  28. package/dist/resources/semantic-retrieval-core.js +5 -4
  29. package/dist/src/lib/scheduler-platform.js +128 -0
  30. package/dist/src/lib/xml-escape.js +54 -0
  31. package/dist/src/rem/scheduler.js +35 -87
  32. package/docs/deploying-on-fabric.md +267 -0
  33. package/docs/deployment.md +5 -0
  34. package/docs/embedding-in-a-harper-app.md +299 -0
  35. package/docs/federation.md +61 -4
  36. package/docs/integrations.md +3 -0
  37. package/docs/mcp-clients.md +16 -7
  38. package/docs/quickstart.md +80 -54
  39. package/docs/releasing.md +72 -38
  40. package/docs/supply-chain-policy.md +36 -0
  41. package/docs/troubleshooting.md +24 -0
  42. package/docs/upgrade.md +98 -3
  43. package/package.json +1 -11
  44. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  45. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  46. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  47. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  48. package/dist/resources/rerank-provider.js +0 -569
  49. package/docs/rerank-provisioning.md +0 -101
package/dist/cli.js CHANGED
@@ -17,12 +17,20 @@ import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache }
17
17
  import { probeInstance } from "./probe.js";
18
18
  import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
19
19
  import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
20
- import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
20
+ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
21
+ import { flairCliVersion, mcpServerSpec, unpinnedSpecWarning } from "./lib/mcp-spec.js";
21
22
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
22
23
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
23
24
  import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, inferSoleAgentId, fixCommandAgentHint, describeAgentGateFinding, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
24
25
  import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
25
- import { readSecretFileSecure, readAdminPassFileSecure, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
26
+ import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
27
+ import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
28
+ import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
29
+ // Value-only static import so `--interval`'s advertised default cannot drift
30
+ // from the one the scheduler actually validates against. The module itself is
31
+ // still loaded lazily at call time (the `await import()`s below) for the
32
+ // functions — this pulls in nothing but node builtins.
33
+ import { DEFAULT_INTERVAL_SECONDS as FEDERATION_SYNC_DEFAULT_INTERVAL } from "./federation/scheduler.js";
26
34
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
27
35
  // src/ into resources/, which don't survive npm packaging (see also
28
36
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -170,6 +178,55 @@ function launchdLabel(dataDir) {
170
178
  function launchdPlistPath(label, launchAgentsDir = defaultLaunchAgentsDir()) {
171
179
  return join(launchAgentsDir, `${label}.plist`);
172
180
  }
181
+ /**
182
+ * Build the launchd plist for a Flair instance.
183
+ *
184
+ * Extracted from the `init` command so the XML escaping is unit-testable
185
+ * without touching real launchd or ~/Library/LaunchAgents. EVERY interpolated
186
+ * value goes through escapeXml() — including ones that look safe today (the
187
+ * label is a hash, the ports are numbers), because "this field can't contain
188
+ * a special character" is exactly the assumption that rots when a field's
189
+ * source changes. A future key added to this template that skips escapeXml()
190
+ * is the bug reappearing.
191
+ *
192
+ * Never log the return value: it embeds HDB_ADMIN_PASSWORD.
193
+ */
194
+ export function buildLaunchdPlist(opts) {
195
+ const e = escapeXml;
196
+ return `<?xml version="1.0" encoding="UTF-8"?>
197
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
198
+ <plist version="1.0">
199
+ <dict>
200
+ <key>Label</key><string>${e(opts.label)}</string>
201
+ <key>ProgramArguments</key>
202
+ <array>
203
+ <string>${e(opts.execPath)}</string>
204
+ <string>${e(opts.harperBinPath)}</string>
205
+ <string>run</string>
206
+ <string>.</string>
207
+ </array>
208
+ <key>WorkingDirectory</key><string>${e(opts.workingDirectory)}</string>
209
+ <key>EnvironmentVariables</key>
210
+ <dict>
211
+ <key>ROOTPATH</key><string>${e(opts.dataDir)}</string>
212
+ <key>FLAIR_MODELS_DIR</key><string>${e(opts.modelsDir)}</string>
213
+ <key>HARPER_SET_CONFIG</key><string>${e(opts.setConfig)}</string>
214
+ <key>DEFAULTS_MODE</key><string>dev</string>
215
+ <key>HDB_ADMIN_USERNAME</key><string>${e(opts.adminUser)}</string>
216
+ <key>HDB_ADMIN_PASSWORD</key><string>${e(opts.adminPass)}</string>
217
+ <key>THREADS_COUNT</key><string>1</string>
218
+ <key>NODE_HOSTNAME</key><string>localhost</string>
219
+ <key>HTTP_PORT</key><string>${e(String(opts.httpPort))}</string>
220
+ <key>OPERATIONSAPI_NETWORK_PORT</key><string>${e(opts.opsNetworkPort)}</string>
221
+ <key>LOCAL_STUDIO</key><string>false</string>
222
+ </dict>
223
+ <key>RunAtLoad</key><true/>
224
+ <key>KeepAlive</key><true/>
225
+ <key>StandardOutPath</key><string>${e(join(opts.dataDir, "log", "launchd-stdout.log"))}</string>
226
+ <key>StandardErrorPath</key><string>${e(join(opts.dataDir, "log", "launchd-stderr.log"))}</string>
227
+ </dict>
228
+ </plist>`;
229
+ }
173
230
  /**
174
231
  * Which launchd label an existing installation for `dataDir` is actually
175
232
  * registered under right now. Prefers the new instance-scoped label if its
@@ -248,6 +305,15 @@ function configPath() {
248
305
  return ymlPath;
249
306
  return yamlPath;
250
307
  }
308
+ /**
309
+ * `~/.flair/config.yaml`'s `port:` — the PER-USER file, which describes the
310
+ * default install and only the default install (flair#914).
311
+ *
312
+ * Kept for the commands that have no `--data-dir` of their own and therefore
313
+ * genuinely mean the default instance, and as the source the instance-local
314
+ * migration reads from. Anything that resolves a port for a NAMED instance
315
+ * must go through `resolveHttpPort`, not this.
316
+ */
251
317
  function readPortFromConfig() {
252
318
  try {
253
319
  const p = configPath();
@@ -261,6 +327,121 @@ function readPortFromConfig() {
261
327
  catch { /* ignore */ }
262
328
  return null;
263
329
  }
330
+ // ─── Harper's own config: where an instance's port actually lives (flair#914) ─
331
+ //
332
+ // `~/.flair/config.yaml` records ONE port for the whole user. `flair init
333
+ // --data-dir X --port P` wrote it unconditionally, so a second instance
334
+ // overwrote the first's recorded port and `resolveHttpPort()` then answered a
335
+ // per-instance question from a file that cannot tell instances apart — with no
336
+ // data dir as input at all.
337
+ //
338
+ // This is the dual of flair#902 (fixed in flair#910): that one resolved the
339
+ // INSTANCE from the wrong directory; this one resolved the PORT from a file
340
+ // that has no notion of instances. The fix is the same shape flair#910
341
+ // established — the data directory IS the instance identity, and everything
342
+ // else derives from it.
343
+ //
344
+ // The port is read from HARPER'S config, not a Flair-owned file beside it.
345
+ // Harper writes `<dataDir>/harper-config.yaml` at install (configUtils'
346
+ // `createConfigFile`) and rewrites it from its environment on EVERY boot, so it
347
+ // records `rootPath`, `http.port` and `operationsApi.network.port` for the
348
+ // instance served from this directory — written by the process that actually
349
+ // binds the sockets. That is what makes it authoritative.
350
+ //
351
+ // A Flair-owned file recording the same three facts (the `flair-instance.yaml`
352
+ // this replaces) was a fourth artefact duplicating the first, with nothing to
353
+ // reconcile the two. It does not stay true: boot an existing data directory on
354
+ // a different port and Harper updates its own config while the Flair copy keeps
355
+ // the old number — a silent wrong answer that looks authoritative. Measured, not
356
+ // theorised.
357
+ //
358
+ // The layering this restores: Harper core owns the data root and the ports, the
359
+ // Flair component owns memory semantics, local wrappers own convenience.
360
+ //
361
+ // Everything here is a PLAIN FILE READ. Resolution must work while the instance
362
+ // is DOWN — locating a stopped instance is most of what `flair start`, `stop`
363
+ // and `doctor` are for — so it must never require the process it is trying to
364
+ // find.
365
+ //
366
+ // Enumeration (`flair instances`) is deliberately NOT accommodated here. If it
367
+ // ever lands it is a DERIVED registry written by a scan, never by `init`, so
368
+ // that it can be stale without being wrong.
369
+ /**
370
+ * Harper's config filenames, in Harper's own resolution order — current name
371
+ * first, pre-rename legacy name second (harper `bin/run.js` and
372
+ * `config/configUtils.js` both fall back this way, and `docs/rem.md` documents
373
+ * the pair). An install predating the rename still serves from the legacy name,
374
+ * so reading only the current one would make a working instance unaddressable.
375
+ */
376
+ const HARPER_CONFIG_FILENAMES = ["harper-config.yaml", "harperdb-config.yaml"];
377
+ /** Harper's config file for `dataDir`, or null when Harper has written none there yet. */
378
+ function harperConfigPath(dataDir) {
379
+ const dir = resolve(dataDir);
380
+ for (const name of HARPER_CONFIG_FILENAMES) {
381
+ const p = join(dir, name);
382
+ if (existsSync(p))
383
+ return p;
384
+ }
385
+ return null;
386
+ }
387
+ /**
388
+ * Parse Harper's config for `dataDir`. Returns null when absent or unreadable —
389
+ * a malformed config is Harper's problem to report, and resolution falling back
390
+ * to its next rung beats a crash in an unrelated command.
391
+ *
392
+ * Parsed as YAML, never regex-matched. The values wanted here are NESTED
393
+ * (`http.port`, `operationsApi.network.port`), so line-anchored matching on a
394
+ * bare key name would find `port:` under whichever block came first — wrong
395
+ * answer, not just an ugly one. It is also how the predecessor earned a
396
+ * blocking `detect-non-literal-regexp` SAST finding for building a RegExp from
397
+ * a key name.
398
+ */
399
+ function readHarperConfig(dataDir) {
400
+ try {
401
+ const p = harperConfigPath(dataDir);
402
+ if (!p)
403
+ return null;
404
+ const parsed = parseYaml(readFileSync(p, "utf-8"));
405
+ return parsed && typeof parsed === "object" ? parsed : null;
406
+ }
407
+ catch { /* ignore — see doc comment */ }
408
+ return null;
409
+ }
410
+ /**
411
+ * A Harper port config value as a number.
412
+ *
413
+ * Harper accepts both a bare port (`9926`, all interfaces) and the
414
+ * host-qualified `host:port` form flair writes for the ops API (`127.0.0.1:9925`
415
+ * — see `opsNetworkPortValue`). Both name the same port; only the bind differs,
416
+ * and `detectOpsApiAllInterfacesBind` is what reads the host half. Splits on the
417
+ * LAST colon so an IPv6 literal (`[::1]:9925`) keeps its port.
418
+ */
419
+ export function harperPortValue(value) {
420
+ if (value === undefined || value === null)
421
+ return null;
422
+ if (typeof value === "number")
423
+ return Number.isInteger(value) && value > 0 ? value : null;
424
+ const str = String(value).trim();
425
+ if (str === "")
426
+ return null;
427
+ const lastColon = str.lastIndexOf(":");
428
+ const portPart = lastColon > 0 ? str.slice(lastColon + 1) : str;
429
+ if (!/^\d+$/.test(portPart))
430
+ return null;
431
+ const n = Number(portPart);
432
+ return n > 0 ? n : null;
433
+ }
434
+ /** This instance's HTTP port, from Harper's own config in its data directory. */
435
+ function readPortFromHarperConfig(dataDir) {
436
+ return harperPortValue(readHarperConfig(dataDir)?.http?.port);
437
+ }
438
+ /** Which instance a command is addressing: `--data-dir` if given, else the default install. */
439
+ function instanceDataDir(opts) {
440
+ return opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
441
+ }
442
+ function isDefaultDataDir(dataDir) {
443
+ return resolve(dataDir) === resolve(defaultDataDir());
444
+ }
264
445
  /**
265
446
  * Read the persisted ops-API bind host from ~/.flair/config.yaml (flair#863).
266
447
  * Line-anchored so it can't be satisfied by some other key that merely ends
@@ -295,9 +476,46 @@ function readOpsPortFromConfig(path = configPath()) {
295
476
  catch { /* ignore */ }
296
477
  return null;
297
478
  }
298
- // Unified port resolution: --port flag > FLAIR_URL env > config file > default
299
- // Every command that talks to Harper MUST use these helpers.
300
- function resolveHttpPort(opts) {
479
+ /**
480
+ * Unified port resolution: `--port` flag > `FLAIR_URL` env > Harper's own config
481
+ * in the instance's data directory > (default install only) the per-user config
482
+ * > default.
483
+ *
484
+ * Every command that talks to Harper MUST use this helper.
485
+ *
486
+ * `opts.dataDir` names the instance (flair#914). Commands without a
487
+ * `--data-dir` flag do not carry one and therefore mean the default install,
488
+ * which is what they have always meant.
489
+ *
490
+ * `mode` distinguishes ADDRESSING an instance that is expected to exist from
491
+ * CREATING one:
492
+ *
493
+ * - `"address"` (default) — a non-default data directory Harper has never
494
+ * written a config into is a hard error. That case is precisely the bug
495
+ * this fixes: falling back to the per-user file would answer with some
496
+ * OTHER instance's port, which is a silent wrong answer wearing a
497
+ * migration. The default install keeps today's behaviour exactly.
498
+ * - `"create"` — `flair init`, which is establishing the instance rather
499
+ * than looking one up. An unknown instance takes DEFAULT_PORT, the same as
500
+ * a clean install has always done, instead of the refusal that would make
501
+ * `flair init --data-dir <new>` impossible.
502
+ *
503
+ * Note that `init`'s own `--port` option carries a commander default of
504
+ * DEFAULT_PORT, so in practice `opts.port` is always set there and this
505
+ * function returns on the first rung — including when re-initialising an
506
+ * instance that already serves a different port, which is silently renumbered
507
+ * to DEFAULT_PORT. That is pre-existing (it is equally true of the default
508
+ * install on a custom port, where re-running init is `flair doctor`'s standing
509
+ * remedy) and is tracked separately; the "create" rung is what this function
510
+ * would answer if that default were removed, and is what keeps the mode
511
+ * distinction honest rather than hypothetical.
512
+ *
513
+ * Nothing is copied, migrated or written here. Harper's config already IS the
514
+ * per-instance record, so there is no second file to keep in step — which is
515
+ * the whole reason the port is read from it. The per-user file stays exactly as
516
+ * it is for the default install that has always relied on it.
517
+ */
518
+ function resolveHttpPort(opts, mode = "address") {
301
519
  if (opts.port !== undefined && opts.port !== null) {
302
520
  const n = Number(opts.port);
303
521
  if (!isNaN(n) && n > 0)
@@ -309,7 +527,28 @@ function resolveHttpPort(opts) {
309
527
  if (m)
310
528
  return Number(m[1]);
311
529
  }
312
- return readPortFromConfig() ?? DEFAULT_PORT;
530
+ const dataDir = instanceDataDir(opts);
531
+ // 1. Harper's own config for this instance. Once Harper has written one it is
532
+ // the only answer — it is maintained by the process that binds the socket.
533
+ const harperPort = readPortFromHarperConfig(dataDir);
534
+ if (harperPort !== null)
535
+ return harperPort;
536
+ // 2. Harper has written nothing here. For the DEFAULT install the per-user
537
+ // file is about this instance by definition — a single-instance install is
538
+ // the overwhelmingly common case and its recorded port is correct — so it
539
+ // stays the answer for existing installs. Read, never rewritten.
540
+ if (isDefaultDataDir(dataDir)) {
541
+ return readPortFromConfig() ?? DEFAULT_PORT;
542
+ }
543
+ // 3. A non-default directory Harper has never written to. `init` may go on to
544
+ // create the instance; nothing else may guess.
545
+ if (mode === "create")
546
+ return DEFAULT_PORT;
547
+ throw new Error(`cannot determine which port ${dataDir} serves: Harper has written no config there `
548
+ + `(no ${HARPER_CONFIG_FILENAMES.join(" or ")}), so that directory does not hold an instance yet. `
549
+ + `Falling back to ~/.flair/config.yaml would answer with the port of a DIFFERENT instance — that file records one port `
550
+ + `for the whole user, not one per data directory. `
551
+ + `Pass --port <port> to say which port that instance serves, or run 'flair init --data-dir ${dataDir} --port <port>' to create it.`);
313
552
  }
314
553
  // Unified base URL resolution. Precedence:
315
554
  // --target > --url > FLAIR_TARGET env > FLAIR_URL env > http://127.0.0.1:<resolveHttpPort>
@@ -331,6 +570,15 @@ function resolveAgentIdOrEnv(opts) {
331
570
  return opts.agent || process.env.FLAIR_AGENT_ID || null;
332
571
  }
333
572
  // Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
573
+ //
574
+ // Deliberately NOT routed through Harper's per-instance config the way
575
+ // resolveHttpPort is (flair#914). The last rung couples the ops port to the HTTP
576
+ // port the CALLER named, and that coupling is what makes `flair init --port N`
577
+ // land its ops API at N-1 on a host that already runs an instance. A Harper rung
578
+ // above it would answer that call with the EXISTING instance's ops port instead,
579
+ // which is the bug flair#914 fixed pointing the other way. The ops port becomes
580
+ // per-instance when the lifecycle commands grow `--data-dir` and there is a
581
+ // named instance to attribute it to.
334
582
  function resolveOpsPort(opts) {
335
583
  if (opts.opsPort !== undefined && opts.opsPort !== null) {
336
584
  const n = Number(opts.opsPort);
@@ -812,6 +1060,36 @@ function writeConfig(port, opsPort, opsBind, path = configPath()) {
812
1060
  body += `opsBind: ${resolvedOpsBind}\n`;
813
1061
  writeFileSync(path, body);
814
1062
  }
1063
+ /**
1064
+ * Persist the coordinates of the instance served from `dataDir` (flair#914).
1065
+ *
1066
+ * Harper records its OWN coordinates — `rootPath`, `http.port`,
1067
+ * `operationsApi.network.port` — into `<dataDir>/harper-config.yaml` on every
1068
+ * boot, and that is what `resolveHttpPort` reads. So there is nothing
1069
+ * instance-local for flair to write: the instance already describes itself, and
1070
+ * a second copy is a drift source, not a record (flair#937).
1071
+ *
1072
+ * What remains is the per-user file, and the GUARD on it is the whole of the
1073
+ * flair#914 fix. `init` used to write `~/.flair/config.yaml` unconditionally, so
1074
+ * `flair init --data-dir X --port P` overwrote whatever port the DEFAULT install
1075
+ * had recorded, and every later lookup answered with P. Writing it only for the
1076
+ * default install is what makes one instance's `init` unable to renumber
1077
+ * another's.
1078
+ *
1079
+ * The guard lives HERE rather than at the call sites: a caller that forgets it
1080
+ * reintroduces flair#914 silently, and there is no test that would notice at the
1081
+ * call site it was forgotten in.
1082
+ *
1083
+ * It keeps the readers that have no `--data-dir` of their own
1084
+ * (`readPortFromConfig` — `flair uninstall`, the `rem` scheduler, `api()`,
1085
+ * doctor's config line) correct for the one instance they are able to address.
1086
+ * Those read Harper's config directly when the lifecycle commands grow the flag,
1087
+ * and this file goes with them.
1088
+ */
1089
+ function persistDefaultInstallCoordinates(dataDir, port, opsPort, opsBind) {
1090
+ if (isDefaultDataDir(dataDir))
1091
+ writeConfig(port, opsPort, opsBind);
1092
+ }
815
1093
  function privKeyPath(agentId, keysDir) {
816
1094
  return join(keysDir, `${agentId}.key`);
817
1095
  }
@@ -822,30 +1100,135 @@ function flairPackageDir() {
822
1100
  // dist/cli.js → package root (one level up from dist/)
823
1101
  return join(import.meta.dirname ?? __dirname, "..");
824
1102
  }
825
- function harperBin() {
826
- // Resolve relative to this file's location (dist/cli.js ../node_modules/...).
827
- //
828
- // flair#870: flair depends on the BARE `harper` package name. The scoped
829
- // `@harperfast/harper` segments are kept as a fallback so an in-place upgrade
830
- // over a pre-#870 install (whose node_modules still holds the scoped copy,
831
- // and whose Harper is the one currently serving the data dir) keeps booting
832
- // until the next clean install. Bare name is tried first so a tree that has
833
- // BOTH resolves to the one flair actually declares.
834
- const roots = [
835
- join(import.meta.dirname ?? __dirname, ".."),
836
- process.cwd(),
837
- ];
838
- const pkgSegments = [["harper"], ["@harperfast", "harper"]];
839
- const candidates = [];
1103
+ /**
1104
+ * Harper npm package names this build knows about, newest-convention first.
1105
+ *
1106
+ * flair#870: flair depends on the BARE `harper` package name. The scoped
1107
+ * `@harperfast/harper` name is kept as a fallback so an in-place upgrade over
1108
+ * a pre-#870 install (whose node_modules still holds the scoped copy, and
1109
+ * whose Harper is the one currently serving the data dir) keeps booting until
1110
+ * the next clean install. Bare name is tried first so a tree that has BOTH
1111
+ * resolves to the one flair actually declares.
1112
+ *
1113
+ * This list is a FALLBACK, not the authority — see declaredHarperPackageNames.
1114
+ */
1115
+ const KNOWN_HARPER_PACKAGE_NAMES = ["harper", "@harperfast/harper"];
1116
+ /**
1117
+ * The Harper package name(s) the install at `packageRoot` actually declares,
1118
+ * read fresh off disk on every call.
1119
+ *
1120
+ * flair#905: `flair upgrade` replaces this package tree while the CLI is
1121
+ * executing out of it, so anything the running code "knows" about the tree
1122
+ * describes the tree that WAS there, not the one that is. That is exactly how
1123
+ * the 0.29.0 → 0.30.0 upgrade broke: 0.30.0 renamed its Harper dependency
1124
+ * `@harperfast/harper` → `harper` (flair#870), 0.29.0's resolver only ever
1125
+ * probed the scoped name, and the post-swap tree no longer had it — so the
1126
+ * restart reported "Harper binary not found" against an install that was
1127
+ * perfectly intact. Deriving the name from the package.json sitting at
1128
+ * `packageRoot` reads the POST-swap truth instead of a compiled-in guess, so a
1129
+ * future rename cannot break the same way.
1130
+ *
1131
+ * Matches `harper` and `@scope/harper` only — never `harper-fabric-embeddings`
1132
+ * or any other `harper`-prefixed dependency.
1133
+ */
1134
+ export function declaredHarperPackageNames(packageRoot) {
1135
+ try {
1136
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8"));
1137
+ return Object.keys(pkg.dependencies ?? {}).filter((n) => /^(@[^/]+\/)?harper$/.test(n));
1138
+ }
1139
+ catch {
1140
+ return [];
1141
+ }
1142
+ }
1143
+ /**
1144
+ * Locate a Harper binary under `roots`, and report every path that was tried.
1145
+ *
1146
+ * The `searched` list is not decoration: "Harper binary not found" with no
1147
+ * paths is unactionable, and the message it replaced named the wrong remedy
1148
+ * (flair#905). Callers surface these paths verbatim.
1149
+ */
1150
+ export function resolveHarperBin(roots) {
1151
+ const searched = [];
840
1152
  for (const root of roots) {
841
- for (const segs of pkgSegments) {
842
- candidates.push(join(root, "node_modules", ...segs, "dist", "bin", "harper.js"));
1153
+ const names = [...declaredHarperPackageNames(root), ...KNOWN_HARPER_PACKAGE_NAMES];
1154
+ for (const name of names) {
1155
+ const candidate = join(root, "node_modules", ...name.split("/"), "dist", "bin", "harper.js");
1156
+ if (searched.includes(candidate))
1157
+ continue;
1158
+ searched.push(candidate);
1159
+ if (existsSync(candidate))
1160
+ return { path: candidate, searched };
843
1161
  }
844
1162
  }
845
- for (const c of candidates)
846
- if (existsSync(c))
847
- return c;
848
- return null;
1163
+ return { path: null, searched };
1164
+ }
1165
+ /**
1166
+ * Roots under which a Harper install is looked for: this package's own
1167
+ * directory (dist/cli.js → ../node_modules/...) then the caller's cwd.
1168
+ *
1169
+ * Recomputed per call rather than captured at module load — see
1170
+ * declaredHarperPackageNames for why anything cached across a package swap is
1171
+ * a bug waiting to happen.
1172
+ */
1173
+ function harperSearchRoots() {
1174
+ return [flairPackageDir(), process.cwd()];
1175
+ }
1176
+ /**
1177
+ * The operator-facing message for "we looked for Harper and it isn't there".
1178
+ *
1179
+ * flair#905: the text this replaces was `Harper binary not found. Run 'flair
1180
+ * init' first.` — which is wrong twice over on an initialised instance. It
1181
+ * names no path, so there is nothing to check; and `flair init` cannot fix a
1182
+ * missing Harper (init resolves the same binary the same way) while it CAN be
1183
+ * mistaken for "re-provision my instance" by someone reading it at 3am. A
1184
+ * missing binary is an incomplete package tree, so the remedy is a reinstall.
1185
+ */
1186
+ export function harperBinNotFoundMessage(searched) {
1187
+ return [
1188
+ "Harper binary not found — this Flair package tree looks incomplete.",
1189
+ " Searched:",
1190
+ ...searched.map((p) => ` ${p}`),
1191
+ " Reinstall the package: npm install -g @tpsdev-ai/flair@latest",
1192
+ " Your data in ~/.flair is untouched — `flair init` will NOT fix this (it resolves the same binary).",
1193
+ ].join("\n");
1194
+ }
1195
+ function harperBin() {
1196
+ return resolveHarperBin(harperSearchRoots()).path;
1197
+ }
1198
+ /**
1199
+ * Parse `lsof -ti` output into PIDs safe to signal as "the thing on this port".
1200
+ *
1201
+ * Two filters, both load-bearing (flair#800, extended by flair#905):
1202
+ *
1203
+ * A bare `lsof -ti :<port>` matches CLIENT sockets as well as the listening
1204
+ * server — including the calling process's OWN keep-alive connections, left by
1205
+ * anything that has spoken HTTP to that port in this run (the version-handshake
1206
+ * nudge on every command, a health probe, a caller's `fetch`). `-sTCP:LISTEN`
1207
+ * on the command narrows it to the server; this function is the second half:
1208
+ * never return our own PID, whatever lsof said. flair#800 was that exact
1209
+ * self-SIGTERM in `flair upgrade`'s stop step; the same unfiltered pattern
1210
+ * survived in `flair stop`, `flair uninstall` and `flair doctor`, where it can
1211
+ * kill the caller, kill an unrelated client, or tell an operator to `kill` the
1212
+ * PID of the shell they are typing into.
1213
+ */
1214
+ export function parseListeningPids(lsofOutput, selfPid) {
1215
+ return (lsofOutput ?? "")
1216
+ .trim()
1217
+ .split("\n")
1218
+ .map((s) => Number(s.trim()))
1219
+ .filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== selfPid);
1220
+ }
1221
+ /**
1222
+ * PIDs LISTENING on `port`, never this process. Empty when nothing is there or
1223
+ * lsof is unavailable — callers treat that as "not running".
1224
+ */
1225
+ function listeningPidsOnPort(port, exec) {
1226
+ try {
1227
+ return parseListeningPids(exec(`lsof -ti :${port} -sTCP:LISTEN`), process.pid);
1228
+ }
1229
+ catch {
1230
+ return [];
1231
+ }
849
1232
  }
850
1233
  // ─── Helpers ─────────────────────────────────────────────────────────────────
851
1234
  function b64(bytes) {
@@ -1991,49 +2374,16 @@ async function runSoulWizard(agentId) {
1991
2374
  return entries.filter(([, v]) => v.trim().length > 0);
1992
2375
  }
1993
2376
  // ─── Program ─────────────────────────────────────────────────────────────────
1994
- // Read version from package.json at the package root
1995
- const __pkgDir = join(import.meta.dirname ?? __dirname, "..");
1996
- const __pkgVersion = (() => {
1997
- try {
1998
- return JSON.parse(readFileSync(join(__pkgDir, "package.json"), "utf-8")).version;
1999
- }
2000
- catch {
2001
- return "unknown";
2002
- }
2003
- })();
2004
- /**
2005
- * The `@tpsdev-ai/flair-mcp` spec written into a client's MCP config.
2006
- *
2007
- * PINNED, deliberately. A bare `npx -y @tpsdev-ai/flair-mcp` re-resolves to
2008
- * whatever is currently published on EVERY agent session — so a single bad
2009
- * publish (stolen credentials, a malicious commit that clears review, or a
2010
- * compromised dependency of the MCP package) reaches every wired user
2011
- * silently, with no lockfile and no review step in the path. The postmark-mcp
2012
- * incident was exactly this shape: a legitimate publish by the legitimate
2013
- * owner, propagating for 16 days before anyone noticed. Worse, a yank does
2014
- * not help — unpinned clients keep resolving latest.
2015
- *
2016
- * Our publish side is already hardened (OIDC staged publish, human 2FA at the
2017
- * release gate), but that defends against credential theft, not against a bad
2018
- * version being published legitimately. The consumer side is where that gap
2019
- * closes, and pinning is what closes it: a wired client keeps running the
2020
- * exact version that was current when it was wired, and moving forward
2021
- * becomes a deliberate act.
2022
- *
2023
- * flair and flair-mcp ship in version lockstep from this monorepo, so the
2024
- * running CLI's own version is the correct pin. `flair init` re-run rewires
2025
- * to the then-current version; see the upgrade-rewire follow-up issue for
2026
- * making `flair upgrade` do the same.
2027
- *
2028
- * Falls back to the unpinned spec only when the version can't be read, which
2029
- * is the same condition under which `--version` reports "unknown" — a broken
2030
- * install, where a working MCP wiring matters more than a precise pin.
2031
- */
2032
- export function mcpServerSpec(version = __pkgVersion) {
2033
- return version && version !== "unknown"
2034
- ? `@tpsdev-ai/flair-mcp@${version}`
2035
- : "@tpsdev-ai/flair-mcp";
2036
- }
2377
+ // This CLI's own version. Resolution lives in src/lib/mcp-spec.ts so that the
2378
+ // client-wiring code (src/install/clients.ts) shares ONE definition of both
2379
+ // the version and the MCP spec derived from it — see flair#907 for what
2380
+ // happened when the pin lived next to a single call site.
2381
+ const __pkgVersion = flairCliVersion();
2382
+ // mcpServerSpec now lives in src/lib/mcp-spec.ts alongside the version
2383
+ // resolution it depends on, so every writer shares it. Re-exported here
2384
+ // because it is part of this module's public surface (tests and callers
2385
+ // import it from src/cli.ts).
2386
+ export { mcpServerSpec };
2037
2387
  const program = new Command();
2038
2388
  program.name("flair").version(__pkgVersion, "-v, --version");
2039
2389
  // ─── CLI↔server version handshake (flair#695 §B) ────────────────────────────
@@ -2284,11 +2634,21 @@ program
2284
2634
  return;
2285
2635
  }
2286
2636
  // ── Local init (full one-command setup) ──
2287
- const httpPort = resolveHttpPort(opts);
2288
- const opsPort = resolveOpsPort(opts);
2289
- const opsBindHost = resolveOpsBindHost(opts);
2290
2637
  const keysDir = opts.keysDir ?? defaultKeysDir();
2291
2638
  const dataDir = opts.dataDir ?? defaultDataDir();
2639
+ // "create" mode (flair#914): init ESTABLISHES an instance, so a data
2640
+ // directory with no recorded port is a new instance taking the default,
2641
+ // not the hard error every other caller gets — otherwise `flair init
2642
+ // --data-dir <new>` could never succeed. `dataDir` is resolved first so
2643
+ // this is never asked before the instance is known. (`--port` carries a
2644
+ // commander default, so this usually returns on the flag rung; see
2645
+ // resolveHttpPort's doc comment.)
2646
+ const httpPort = resolveHttpPort(opts, "create");
2647
+ // The already-resolved port is handed to the ops resolver rather than
2648
+ // letting it re-resolve — its last rung is `resolveHttpPort(opts) - 1`,
2649
+ // which would ask the same question again in "address" mode.
2650
+ const opsPort = resolveOpsPort({ ...opts, port: httpPort });
2651
+ const opsBindHost = resolveOpsBindHost(opts);
2292
2652
  // Resolve MCP client selection (union of init's auto-wire + the multi-client
2293
2653
  // detection/wiring that the front-door command provides). `--no-mcp` sets
2294
2654
  // opts.mcp === false (commander negates the flag). Validate an explicit
@@ -2439,11 +2799,14 @@ program
2439
2799
  }
2440
2800
  mkdirSync(dataDir, { recursive: true });
2441
2801
  // Detect whether Harper has already been installed in this data dir.
2442
- // harper-config.yaml is created during install — its presence means
2802
+ // Harper's config is created during install — its presence means
2443
2803
  // install already ran. Re-running install against an existing data dir
2444
2804
  // crashes in Harper v5 beta.6+ (checkForExistingInstall queries the
2445
- // database before the env is initialized).
2446
- const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
2805
+ // database before the env is initialized). Goes through
2806
+ // harperConfigPath so an install predating Harper's config-file rename
2807
+ // (harperdb-config.yaml) is still recognised as installed rather than
2808
+ // re-installed over.
2809
+ const alreadyInstalled = harperConfigPath(dataDir) !== null;
2447
2810
  const opsSocket = join(dataDir, "operations-server");
2448
2811
  // authorizeLocal: false (flair#654) — a credential-less loopback ops-API
2449
2812
  // request is no longer auto-authorized as super_user. Every ops-API
@@ -2591,40 +2954,19 @@ program
2591
2954
  // backend on every KeepAlive restart in-process. See that file's
2592
2955
  // header (flair#694) for why this replaced the old HARPER_CONFIG
2593
2956
  // plist line.
2594
- const escapeXml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
2595
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
2596
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2597
- <plist version="1.0">
2598
- <dict>
2599
- <key>Label</key><string>${label}</string>
2600
- <key>ProgramArguments</key>
2601
- <array>
2602
- <string>${process.execPath}</string>
2603
- <string>${harperBinPath}</string>
2604
- <string>run</string>
2605
- <string>.</string>
2606
- </array>
2607
- <key>WorkingDirectory</key><string>${flairPackageDir()}</string>
2608
- <key>EnvironmentVariables</key>
2609
- <dict>
2610
- <key>ROOTPATH</key><string>${dataDir}</string>
2611
- <key>FLAIR_MODELS_DIR</key><string>${modelsDir}</string>
2612
- <key>HARPER_SET_CONFIG</key><string>${escapeXml(setConfig)}</string>
2613
- <key>DEFAULTS_MODE</key><string>dev</string>
2614
- <key>HDB_ADMIN_USERNAME</key><string>${adminUser}</string>
2615
- <key>HDB_ADMIN_PASSWORD</key><string>${adminPass}</string>
2616
- <key>THREADS_COUNT</key><string>1</string>
2617
- <key>NODE_HOSTNAME</key><string>localhost</string>
2618
- <key>HTTP_PORT</key><string>${httpPort}</string>
2619
- <key>OPERATIONSAPI_NETWORK_PORT</key><string>${escapeXml(opsNetworkPortValue(opsBindHost, opsPort))}</string>
2620
- <key>LOCAL_STUDIO</key><string>false</string>
2621
- </dict>
2622
- <key>RunAtLoad</key><true/>
2623
- <key>KeepAlive</key><true/>
2624
- <key>StandardOutPath</key><string>${join(dataDir, "log", "launchd-stdout.log")}</string>
2625
- <key>StandardErrorPath</key><string>${join(dataDir, "log", "launchd-stderr.log")}</string>
2626
- </dict>
2627
- </plist>`;
2957
+ const plist = buildLaunchdPlist({
2958
+ label,
2959
+ execPath: process.execPath,
2960
+ harperBinPath,
2961
+ workingDirectory: flairPackageDir(),
2962
+ dataDir,
2963
+ modelsDir,
2964
+ setConfig,
2965
+ adminUser,
2966
+ adminPass,
2967
+ httpPort,
2968
+ opsNetworkPort: opsNetworkPortValue(opsBindHost, opsPort),
2969
+ });
2628
2970
  writeFileSync(plistPath, plist);
2629
2971
  console.log("Launchd service registered ✓");
2630
2972
  }
@@ -2638,7 +2980,12 @@ program
2638
2980
  // survives an `--ops-bind` choice past the next restart. It also runs on
2639
2981
  // the already-running path (where init skips the spawn entirely), which is
2640
2982
  // what makes doctor's `flair init && flair restart` remedy actually apply.
2641
- writeConfig(httpPort, opsPort, opsBindHost);
2983
+ // flair#914: written ONLY when this init is about the default install, so a
2984
+ // second instance can no longer overwrite the first's recorded port. This
2985
+ // instance's own port needs no write here — Harper has just recorded it in
2986
+ // <dataDir>/harper-config.yaml, which is what resolveHttpPort reads (see
2987
+ // persistDefaultInstallCoordinates).
2988
+ persistDefaultInstallCoordinates(dataDir, httpPort, opsPort, opsBindHost);
2642
2989
  if (agentId) {
2643
2990
  // Generate or reuse keypair
2644
2991
  mkdirSync(keysDir, { recursive: true });
@@ -2759,13 +3106,35 @@ program
2759
3106
  // skips wiring entirely; `--client <name>` targets one client; the
2760
3107
  // default (no flag) wires every detected client.
2761
3108
  const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
3109
+ // `wired` is the load-bearing field (flair#906): it separates "a config
3110
+ // file was actually written" from "we printed something and moved on".
3111
+ // Both outcomes used to be pushed here indistinguishably as far as the
3112
+ // user was concerned, so `--client all` reported success for a client it
3113
+ // had not wired.
2762
3114
  const wiringResults = [];
3115
+ // Human-readable labels + the clients `--client all` passed over because
3116
+ // they aren't installed, so the closing summary can account for every
3117
+ // client the user asked for rather than only the ones we tried.
3118
+ const clientLabels = new Map();
3119
+ const skippedUndetected = [];
2763
3120
  if (!noMcp && clientOpt !== "none") {
3121
+ // A spec we cannot pin is a security property quietly downgraded, so
3122
+ // say so BEFORE writing it and again in the summary below (flair#907).
3123
+ // stderr: this must survive `flair init | tee`, and it is a warning,
3124
+ // not part of the command's normal output.
3125
+ const pinWarning = unpinnedSpecWarning();
3126
+ if (pinWarning) {
3127
+ console.error("");
3128
+ for (const line of pinWarning.split("\n"))
3129
+ console.error(` ⚠ ${line}`);
3130
+ }
2764
3131
  // Determine which clients to wire.
2765
3132
  let clients = detectClients();
2766
3133
  if (selectedClients.length > 0) {
2767
3134
  clients = clients.filter(c => selectedClients.includes(c.id));
2768
3135
  }
3136
+ for (const c of clients)
3137
+ clientLabels.set(c.id, c.label);
2769
3138
  const detected = clients.filter(c => c.detected);
2770
3139
  if (!clientOpt) {
2771
3140
  if (detected.length === 0) {
@@ -2780,6 +3149,15 @@ program
2780
3149
  : selectedClients.length > 0
2781
3150
  ? selectedClients
2782
3151
  : clients.filter(c => c.detected).map(c => c.id);
3152
+ // `--client all` is a promise about every client, so the ones it
3153
+ // passed over have to be accounted for too — silently omitting them
3154
+ // is how "all" reports success for work it never did (flair#906).
3155
+ if (clientOpt === "all") {
3156
+ for (const c of clients) {
3157
+ if (!c.detected)
3158
+ skippedUndetected.push(c.label);
3159
+ }
3160
+ }
2783
3161
  for (const clientId of toWire) {
2784
3162
  if (clientId === "claude-code") {
2785
3163
  // Claude Code gets real auto-wiring into ~/.claude.json (zero-install
@@ -2797,32 +3175,45 @@ program
2797
3175
  // provenance.claimed.client = "claude-code".
2798
3176
  env: { ...mcpEnv, FLAIR_CLIENT: "claude-code" },
2799
3177
  };
3178
+ // ~/.claude.json exists once Claude Code has been RUN, not once it
3179
+ // is installed — so gating the write on it skipped every user who
3180
+ // installed Claude Code and Flair in the same sitting (flair#906).
3181
+ // The file is Claude Code's own and creating it with a single
3182
+ // `mcpServers` key is exactly what `claude mcp add` does, so an
3183
+ // absent file is created rather than turned into a printed snippet
3184
+ // the user has to notice and act on.
2800
3185
  try {
2801
- if (existsSync(claudeJsonPath)) {
2802
- const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
2803
- const existing = claudeJson.mcpServers?.flair;
2804
- if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
2805
- console.log(` ✓ Claude Code already wired in ~/.claude.json`);
2806
- wiringResults.push({ client: "claude-code", message: "already wired" });
2807
- }
2808
- else {
2809
- claudeJson.mcpServers = claudeJson.mcpServers || {};
2810
- claudeJson.mcpServers.flair = flairMcpConfig;
2811
- writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
2812
- console.log(` ✓ Claude Code wired in ~/.claude.json (restart Claude Code to pick it up)`);
2813
- wiringResults.push({ client: "claude-code", message: "wired ~/.claude.json" });
2814
- }
3186
+ const claudeJsonExisted = existsSync(claudeJsonPath);
3187
+ const claudeJson = claudeJsonExisted
3188
+ ? JSON.parse(readFileSync(claudeJsonPath, "utf-8"))
3189
+ : {};
3190
+ const existing = claudeJson.mcpServers?.flair;
3191
+ if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
3192
+ console.log(` ✓ Claude Code already wired in ~/.claude.json`);
3193
+ wiringResults.push({ client: "claude-code", message: "already wired", wired: true });
2815
3194
  }
2816
3195
  else {
2817
- console.log(` MCP config (add to ~/.claude.json):`);
2818
- console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
2819
- wiringResults.push({ client: "claude-code", message: "snippet printed (no ~/.claude.json)" });
3196
+ claudeJson.mcpServers = claudeJson.mcpServers || {};
3197
+ claudeJson.mcpServers.flair = flairMcpConfig;
3198
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
3199
+ const how = claudeJsonExisted ? "wired in ~/.claude.json" : "wired in ~/.claude.json (created)";
3200
+ console.log(` ✓ Claude Code ${how} (restart Claude Code to pick it up)`);
3201
+ wiringResults.push({
3202
+ client: "claude-code",
3203
+ message: claudeJsonExisted ? "wired ~/.claude.json" : "created and wired ~/.claude.json",
3204
+ wired: true,
3205
+ });
2820
3206
  }
2821
3207
  }
2822
- catch {
3208
+ catch (err) {
3209
+ // Only a genuine read/parse/write failure lands here now (bad
3210
+ // permissions, malformed existing JSON) — never merely "the file
3211
+ // does not exist yet".
3212
+ const reason = err instanceof Error ? err.message : String(err);
3213
+ console.log(` ${render.icons.warn} Claude Code: could not write ~/.claude.json (${reason})`);
2823
3214
  console.log(` MCP config (add manually to ~/.claude.json):`);
2824
3215
  console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
2825
- wiringResults.push({ client: "claude-code", message: "snippet printed" });
3216
+ wiringResults.push({ client: "claude-code", message: `snippet printed (${reason})`, wired: false });
2826
3217
  }
2827
3218
  // ── CLAUDE.md bootstrap line (flair#597) ──────────────────────────
2828
3219
  // The MCP block alone isn't a working setup — Claude Code also needs
@@ -2866,7 +3257,7 @@ program
2866
3257
  break;
2867
3258
  default: result = { ok: false, message: `Unknown client: ${clientId}` };
2868
3259
  }
2869
- wiringResults.push({ client: clientId, message: result.message });
3260
+ wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
2870
3261
  console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
2871
3262
  }
2872
3263
  }
@@ -2944,6 +3335,34 @@ program
2944
3335
  console.log(" Use --skip-smoke to bypass.");
2945
3336
  }
2946
3337
  }
3338
+ // ── MCP wiring summary (flair#906) ───────────────────────────────────
3339
+ // LAST thing init prints, deliberately. Every fact below was already
3340
+ // available mid-run, but a client that was not wired appeared only as a
3341
+ // snippet in the middle of a wall of output, after a success line — so
3342
+ // the user's next action was to open their client and find nothing
3343
+ // there, with no reason to suspect init. A client the user asked for and
3344
+ // did NOT get must still be on screen when the command finishes.
3345
+ if (!noMcp && clientOpt !== "none") {
3346
+ // The pin warning repeats here for the same reason the summary exists:
3347
+ // a warning only in scrollback is a warning the user acts on never.
3348
+ const summaryLines = renderWiringSummary(wiringResults, {
3349
+ labels: clientLabels,
3350
+ skippedUndetected,
3351
+ rewireHint: `flair init --agent ${agentId} --client all`,
3352
+ unpinned: unpinnedSpecWarning() !== null,
3353
+ });
3354
+ for (const line of summaryLines) {
3355
+ const icon = line.level === "ok" ? render.icons.ok :
3356
+ line.level === "error" ? render.icons.error :
3357
+ line.level === "warn" ? render.icons.warn :
3358
+ line.level === "muted" ? render.icons.bullet :
3359
+ null;
3360
+ if (line.level === "heading")
3361
+ console.log(`\n ${line.text}`);
3362
+ else
3363
+ console.log(` ${icon} ${line.text}`);
3364
+ }
3365
+ }
2947
3366
  }
2948
3367
  else {
2949
3368
  const httpUrl = `http://127.0.0.1:${httpPort}`;
@@ -4924,13 +5343,56 @@ function signRequestBody(body, secretKey) {
4924
5343
  const signBodyFresh = signRequestBody;
4925
5344
  // ─── flair federation ────────────────────────────────────────────────────────
4926
5345
  const federation = program.command("federation").description("Manage federation (hub-and-spoke sync)");
5346
+ /**
5347
+ * The most recent CONTACT with any peer (max of peer.lastSyncAt), or null.
5348
+ *
5349
+ * Contact, not merge: a sync that reaches the peer and legitimately has
5350
+ * nothing to send still proves the driver ran. This is half of what lets
5351
+ * `federation status` tell "nothing is driving sync" apart from "sync is
5352
+ * running, the peer is unreachable" (flair#922) — the other half is whether
5353
+ * the service manager has a driver loaded.
5354
+ *
5355
+ * Returns null on ANY failure. A driver verdict is a diagnostic aid; it must
5356
+ * never be the reason `federation status` fails.
5357
+ */
5358
+ async function latestPeerContact(opts) {
5359
+ try {
5360
+ const target = resolveTarget(opts);
5361
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
5362
+ const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
5363
+ let best = null;
5364
+ for (const p of peers ?? []) {
5365
+ if (!p?.lastSyncAt)
5366
+ continue;
5367
+ const t = Date.parse(p.lastSyncAt);
5368
+ if (Number.isFinite(t) && (best === null || t > best))
5369
+ best = t;
5370
+ }
5371
+ return best === null ? null : new Date(best).toISOString();
5372
+ }
5373
+ catch {
5374
+ return null;
5375
+ }
5376
+ }
5377
+ /**
5378
+ * True when the CLI is pointed at the instance running on THIS machine.
5379
+ *
5380
+ * The scheduler check is inherently local — launchctl/systemctl only know
5381
+ * about jobs on the host the CLI is running on. Reporting "no driver" while
5382
+ * `--target` points at someone else's hub would be a confident claim about a
5383
+ * machine we cannot see, so the driver block is suppressed for remote targets.
5384
+ */
5385
+ function driverCheckAppliesTo(opts) {
5386
+ const target = resolveTarget(opts);
5387
+ return !target || isLocalBase(target.replace(/\/$/, ""));
5388
+ }
4927
5389
  federation
4928
5390
  .command("status")
4929
5391
  .description("Show federation status and peer connections")
4930
5392
  .option("--port <port>", "Harper HTTP port")
4931
5393
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
4932
5394
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
4933
- .option("--json", "Emit JSON {instance, peers} (also: pipe + FLAIR_OUTPUT=json)")
5395
+ .option("--json", "Emit JSON {instance, peers, driver} (also: pipe + FLAIR_OUTPUT=json)")
4934
5396
  .action(async (opts) => {
4935
5397
  const target = resolveTarget(opts);
4936
5398
  const baseUrl = target ? target.replace(/\/$/, "") : undefined;
@@ -4938,8 +5400,41 @@ federation
4938
5400
  try {
4939
5401
  const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
4940
5402
  const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
5403
+ // Driver state (flair#922). Computed from the LOCAL service manager, so
5404
+ // it is only meaningful when the CLI is pointed at the local instance.
5405
+ let driver = null;
5406
+ let assessment = null;
5407
+ if (driverCheckAppliesTo(opts)) {
5408
+ try {
5409
+ const { schedulerStatus, assessDriver } = await import("./federation/scheduler.js");
5410
+ driver = schedulerStatus();
5411
+ let lastSyncAt = null;
5412
+ for (const p of peers ?? []) {
5413
+ if (!p?.lastSyncAt)
5414
+ continue;
5415
+ const t = Date.parse(p.lastSyncAt);
5416
+ if (Number.isFinite(t) && (lastSyncAt === null || t > Date.parse(lastSyncAt))) {
5417
+ lastSyncAt = new Date(t).toISOString();
5418
+ }
5419
+ }
5420
+ assessment = assessDriver({
5421
+ installed: driver.installed,
5422
+ active: driver.active,
5423
+ intervalSeconds: driver.intervalSeconds,
5424
+ lastSyncAt,
5425
+ now: Date.now(),
5426
+ });
5427
+ }
5428
+ catch {
5429
+ // An unsupported platform (neither darwin nor linux) or an
5430
+ // unreadable unit must not take down `federation status` — the peer
5431
+ // table is still the primary output.
5432
+ driver = null;
5433
+ assessment = null;
5434
+ }
5435
+ }
4941
5436
  if (mode === "json") {
4942
- console.log(render.asJSON({ instance, peers }));
5437
+ console.log(render.asJSON({ instance, peers, driver, driverAssessment: assessment }));
4943
5438
  return;
4944
5439
  }
4945
5440
  const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
@@ -4951,6 +5446,26 @@ federation
4951
5446
  console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
4952
5447
  return;
4953
5448
  }
5449
+ // Print the driver line BEFORE the per-peer table: "is anything running
5450
+ // sync at all" is the question that decides how to read everything
5451
+ // below it.
5452
+ if (assessment) {
5453
+ const icon = assessment.verdict === "driving" || assessment.verdict === "external-driver"
5454
+ ? render.icons.ok
5455
+ : assessment.verdict === "unknown"
5456
+ ? render.icons.info
5457
+ : render.icons.warn;
5458
+ const color = assessment.verdict === "driving" || assessment.verdict === "external-driver"
5459
+ ? render.c.green
5460
+ : assessment.verdict === "unknown"
5461
+ ? render.c.dim
5462
+ : render.c.yellow;
5463
+ console.log();
5464
+ console.log(`${icon} ${render.wrap(color, assessment.headline)}`);
5465
+ console.log(` ${render.wrap(render.c.dim, assessment.detail)}`);
5466
+ if (assessment.remedy)
5467
+ console.log(` ${render.wrap(render.c.cyan, assessment.remedy)}`);
5468
+ }
4954
5469
  const now = Date.now();
4955
5470
  const formatPeerAge = (iso, refNow, staleAfterMs) => {
4956
5471
  if (!iso)
@@ -5012,7 +5527,16 @@ federation
5012
5527
  });
5013
5528
  if (haveStale) {
5014
5529
  console.log();
5015
- console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, "Check skippedReasons in SyncLog or run 'flair federation sync'.")}`);
5530
+ // The staleness warning used to fire identically whether sync was
5531
+ // running and the peer was unreachable, or nothing had run sync since
5532
+ // the day the spoke was paired (flair#922). Those need opposite
5533
+ // actions, so the remedy is now chosen by the driver verdict instead
5534
+ // of always pointing at SyncLog.
5535
+ const noDriver = assessment?.verdict === "no-driver" || assessment?.verdict === "driver-inactive";
5536
+ const remedy = noDriver
5537
+ ? "Nothing is driving sync — see the driver line above. Run 'flair federation sync enable'."
5538
+ : "Check skippedReasons in SyncLog or run 'flair federation sync'.";
5539
+ console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, remedy)}`);
5016
5540
  }
5017
5541
  const haveContactButNoMerge = peers.some((p) => {
5018
5542
  if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
@@ -5655,21 +6179,164 @@ export async function runFederationSyncOnce(opts) {
5655
6179
  return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
5656
6180
  }
5657
6181
  }
5658
- federation
6182
+ const federationSync = federation
5659
6183
  .command("sync")
5660
- .description("Push local changes to the hub")
6184
+ .description("Push local changes to the hub (one-shot). Subcommands manage the scheduled driver.")
5661
6185
  .option("--port <port>", "Harper HTTP port")
5662
6186
  .option("--admin-pass <pass>", "Admin password")
6187
+ .option("--admin-pass-file <path>", "Read the admin password from a file (e.g. ~/.flair/admin-pass). Preferred for launchd/cron — keeps the secret out of ps and shell history.")
5663
6188
  .option("--ops-port <port>", "Harper operations API port")
5664
6189
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
5665
6190
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
5666
6191
  .action(async (opts) => {
6192
+ // --admin-pass-file resolves into the same `adminPass` slot the inline
6193
+ // flag uses, so the scheduler never has to embed a secret in a unit file.
6194
+ // readAdminPassFileSecure() refuses a file that is not owner-only.
6195
+ if (!opts.adminPass && opts.adminPassFile) {
6196
+ try {
6197
+ opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
6198
+ }
6199
+ catch (err) {
6200
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
6201
+ process.exit(1);
6202
+ }
6203
+ }
5667
6204
  const r = await runFederationSyncOnce(opts);
5668
6205
  if (r.error) {
5669
6206
  console.error(`Error: ${r.error.message}`);
5670
6207
  process.exit(1);
5671
6208
  }
5672
6209
  });
6210
+ // ─── flair federation sync enable | disable | status ────────────────────────
6211
+ // The supervised driver (flair#922). Federation had no automatic driver at
6212
+ // all: `sync` is one-shot and `watch` is a foreground loop that dies with its
6213
+ // terminal, so every operator paired a spoke, saw one successful sync, and
6214
+ // then silently stopped syncing.
6215
+ //
6216
+ // Shape deliberately mirrors `flair rem nightly enable|disable|status` —
6217
+ // same verbs, same platform coverage (launchd on macOS, systemd --user timer
6218
+ // on Linux), same "never claim success before activation succeeded" rule.
6219
+ // Strategy is a PERIODIC ONE-SHOT rather than a supervised long-lived
6220
+ // watcher; the reasoning is in src/federation/scheduler.ts's header.
6221
+ federationSync
6222
+ .command("enable")
6223
+ .description("Install the sync driver (launchd on macOS, systemd timer on Linux)")
6224
+ .option("--interval <seconds>", `Seconds between syncs (default ${FEDERATION_SYNC_DEFAULT_INTERVAL})`, String(FEDERATION_SYNC_DEFAULT_INTERVAL))
6225
+ .option("--admin-pass-file <path>", "Path to a 0600 file holding the admin password (default ~/.flair/admin-pass when it exists). The PATH is stored in the unit — never the password.")
6226
+ // Deliberately NOT `--no-admin-pass-file`: commander treats a `--no-x` flag
6227
+ // as the negation of `--x`, and declaring both on one command makes the
6228
+ // POSITIVE option silently parse to undefined — `--admin-pass-file /path`
6229
+ // would be accepted and dropped, producing a driver that fails auth every
6230
+ // cycle with no error anywhere. Verified against commander 14.
6231
+ .option("--no-credentials", "Do not wire any credential file into the unit")
6232
+ .option("--target <url>", "Remote Flair URL to sync (default: the local instance)")
6233
+ .action(async (_opts, cmd) => {
6234
+ // optsWithGlobals(), NOT the action's first argument: `--admin-pass-file`
6235
+ // and `--target` are declared on BOTH this subcommand and its parent
6236
+ // (`flair federation sync`), and when a parent declares the same option
6237
+ // name commander binds the value to the PARENT — the subcommand's own
6238
+ // opts come back undefined. Reading only the local opts silently dropped
6239
+ // `--admin-pass-file <path>` here, which would have installed a driver
6240
+ // that failed auth every cycle with no error anywhere. Verified against
6241
+ // commander 14.
6242
+ const opts = cmd.optsWithGlobals();
6243
+ const intervalSeconds = Number(opts.interval);
6244
+ if (!Number.isFinite(intervalSeconds)) {
6245
+ console.error(`Error: --interval must be a number of seconds (got: ${opts.interval})`);
6246
+ process.exit(1);
6247
+ }
6248
+ // Default to the canonical admin-pass file when it is actually there.
6249
+ // Silently wiring a path that does not exist would produce a driver that
6250
+ // runs and fails auth every interval — the failure mode this whole issue
6251
+ // is about, in a new costume.
6252
+ let adminPassFile;
6253
+ if (opts.credentials === false) {
6254
+ adminPassFile = undefined;
6255
+ }
6256
+ else if (typeof opts.adminPassFile === "string" && opts.adminPassFile) {
6257
+ adminPassFile = opts.adminPassFile;
6258
+ }
6259
+ else {
6260
+ const fallback = defaultAdminPassPath();
6261
+ adminPassFile = existsSync(fallback) ? fallback : undefined;
6262
+ }
6263
+ const { enableScheduler, formatEnableReport } = await import("./federation/scheduler.js");
6264
+ try {
6265
+ const r = enableScheduler({ intervalSeconds, adminPassFile, target: opts.target });
6266
+ const { lines, ok } = formatEnableReport(r, { adminPassFile, target: opts.target });
6267
+ for (const line of lines)
6268
+ console.log(line);
6269
+ if (!ok)
6270
+ process.exit(1);
6271
+ }
6272
+ catch (err) {
6273
+ console.error(`Error: ${err.message}`);
6274
+ process.exit(1);
6275
+ }
6276
+ });
6277
+ federationSync
6278
+ .command("disable")
6279
+ .description("Remove the sync driver (peers and sync history are preserved)")
6280
+ .option("--remove-shim", "Also delete the ~/.flair/bin/flair-federation-sync shim")
6281
+ .action(async (opts) => {
6282
+ const { disableScheduler } = await import("./federation/scheduler.js");
6283
+ try {
6284
+ const r = disableScheduler({ removeShim: !!opts.removeShim });
6285
+ if (r.removed.length === 0) {
6286
+ console.log(`(Federation sync driver was not installed on ${r.platform})`);
6287
+ return;
6288
+ }
6289
+ console.log(`✅ Federation sync driver disabled (${r.platform})`);
6290
+ console.log(` Removed:`);
6291
+ for (const p of r.removed)
6292
+ console.log(` ${p}`);
6293
+ if (r.unloadResult && r.unloadResult.code !== 0) {
6294
+ console.log(` Unload: ${r.unloadCommand.join(" ")} → code ${r.unloadResult.code}`);
6295
+ if (r.unloadResult.stderr)
6296
+ console.log(` stderr: ${r.unloadResult.stderr.trim()}`);
6297
+ }
6298
+ console.log(`\nPeers, keys and sync history are untouched. Nothing will sync until you`);
6299
+ console.log(`re-enable the driver or run \`flair federation sync\` by hand.`);
6300
+ }
6301
+ catch (err) {
6302
+ console.error(`Error: ${err.message}`);
6303
+ process.exit(1);
6304
+ }
6305
+ });
6306
+ federationSync
6307
+ .command("status")
6308
+ .description("Show whether a sync driver is installed and genuinely active")
6309
+ .option("--port <port>", "Harper HTTP port")
6310
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
6311
+ .option("--json", "Emit JSON")
6312
+ .action(async (_opts, cmd) => {
6313
+ // See the comment on `enable` above: `--target`/`--port` are declared on
6314
+ // the parent too, so commander binds them there.
6315
+ const opts = cmd.optsWithGlobals();
6316
+ const { schedulerStatus, formatStatusReport, assessDriver } = await import("./federation/scheduler.js");
6317
+ try {
6318
+ const s = schedulerStatus();
6319
+ const lastSyncAt = await latestPeerContact(opts);
6320
+ const a = assessDriver({
6321
+ installed: s.installed,
6322
+ active: s.active,
6323
+ intervalSeconds: s.intervalSeconds,
6324
+ lastSyncAt,
6325
+ now: Date.now(),
6326
+ });
6327
+ if (render.resolveOutputMode(opts) === "json") {
6328
+ console.log(render.asJSON({ driver: s, assessment: a, lastSyncAt }));
6329
+ return;
6330
+ }
6331
+ const { lines } = formatStatusReport(s, a);
6332
+ for (const line of lines)
6333
+ console.log(line);
6334
+ }
6335
+ catch (err) {
6336
+ console.error(`Error: ${err.message}`);
6337
+ process.exit(1);
6338
+ }
6339
+ });
5673
6340
  export async function runFederationWatch(opts) {
5674
6341
  const intervalMs = Math.max(5, parseFloat(opts.interval) || 30) * 1000;
5675
6342
  let stopped = false;
@@ -7786,6 +8453,7 @@ export function resolveFabricCredentials(opts) {
7786
8453
  async function runFabricUpgrade(opts) {
7787
8454
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
7788
8455
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
8456
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
7789
8457
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
7790
8458
  let fabricUser;
7791
8459
  let fabricPassword;
@@ -7821,6 +8489,12 @@ async function runFabricUpgrade(opts) {
7821
8489
  check,
7822
8490
  restart: opts.restart !== false,
7823
8491
  replicated: opts.replicated !== false,
8492
+ // flair#878 — previously unreachable from this command; see
8493
+ // FabricUpgradeOptions.
8494
+ deployRetries: Number(opts.deployRetries ?? 0),
8495
+ ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
8496
+ convergenceCheck: opts.convergenceCheck !== false,
8497
+ convergenceTimeoutMs: opts.convergenceTimeout != null ? Number(opts.convergenceTimeout) : undefined,
7824
8498
  };
7825
8499
  console.log(`${green("→")} Upgrading Fabric Flair at ${upgradeOpts.target}`);
7826
8500
  if (check)
@@ -7847,6 +8521,20 @@ async function runFabricUpgrade(opts) {
7847
8521
  console.log(`\n${green("✓")} already up to date.`);
7848
8522
  return;
7849
8523
  }
8524
+ if (result.convergedAfterReplicationError) {
8525
+ // flair#878: this deploy is a SUCCESS that harper's own exit code called
8526
+ // a failure. Say both halves out loud — an operator who saw the
8527
+ // replication error scroll past needs to know it resolved, and an
8528
+ // operator reading only this line needs to know it happened at all.
8529
+ console.log(`\n${yellow("⚠")} harper reported a peer-replication failure during this upgrade, but the component ` +
8530
+ `tree on every named peer node matched the origin when checked afterwards — replication converged ` +
8531
+ `on its own. Harper replicates components asynchronously, so a replication error at deploy time is ` +
8532
+ `a snapshot, not a verdict.`);
8533
+ }
8534
+ if (result.replicationWarning) {
8535
+ console.log(`\n${yellow("⚠")} Deployed to the ORIGIN NODE ONLY — peer replication did not converge and ` +
8536
+ `--ignore-replication-errors was set. The peer will need to catch up via federation sync or a later deploy.`);
8537
+ }
7850
8538
  console.log(`\n${green("✓")} Fabric upgrade complete.`);
7851
8539
  // ── Post-upgrade fleet sweep (flair#636) ────────────────────────────────
7852
8540
  // "deploy complete" from harper's own CLI means "origin took it" — this
@@ -7878,6 +8566,15 @@ async function runFabricUpgrade(opts) {
7878
8566
  if (hint.includes("401") || hint.includes("unauthoriz")) {
7879
8567
  console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
7880
8568
  }
8569
+ // flair#878: harper's own replication error tells the operator to "pass
8570
+ // ignore_replication_errors: true" — until now there was no way to do that
8571
+ // through `flair upgrade`. Name the flag that actually does it, and the
8572
+ // one that turns off the retry that can make things worse.
8573
+ if (hint.includes("peer replication") || hint.includes("ignore_replication_errors")) {
8574
+ console.error(dim(" hint: --ignore-replication-errors accepts an origin-only upgrade (the peer catches up via federation sync or a later deploy)"));
8575
+ console.error(dim(" hint: --convergence-timeout <ms> waits longer for asynchronous replication before giving up (default 180000)"));
8576
+ console.error(dim(" hint: --deploy-retries defaults to 0 — a retry can turn a transient replication warning into a hard install failure (flair#878)"));
8577
+ }
7881
8578
  process.exit(1);
7882
8579
  }
7883
8580
  }
@@ -8069,6 +8766,22 @@ export const UPGRADE_SNAPSHOT_NUDGE_LINES = [
8069
8766
  const snapshotCmd = program
8070
8767
  .command("snapshot")
8071
8768
  .description("Physical ~/.flair/data snapshots (byte-exact tar.gz, local-only — see `flair backup`/`flair restore` for the logical JSON export/import)");
8769
+ /**
8770
+ * `resolveHttpPort` for a command that takes `--data-dir`, reported as a
8771
+ * message rather than a stack trace (flair#914).
8772
+ *
8773
+ * The throw is a refusal to guess which instance a directory is, and a refusal
8774
+ * has to tell the operator what to pass instead — a stack trace does not.
8775
+ */
8776
+ function resolveHttpPortForDataDir(opts) {
8777
+ try {
8778
+ return resolveHttpPort(opts);
8779
+ }
8780
+ catch (err) {
8781
+ console.error(`❌ ${err?.message ?? err}`);
8782
+ process.exit(1);
8783
+ }
8784
+ }
8072
8785
  snapshotCmd
8073
8786
  .command("create")
8074
8787
  .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)")
@@ -8076,11 +8789,16 @@ snapshotCmd
8076
8789
  .option("--port <port>", "Harper HTTP port (used to quiesce Flair around the snapshot)")
8077
8790
  .action(async (opts) => {
8078
8791
  const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
8079
- const port = resolveHttpPort(opts);
8792
+ // Existence first, THEN the port. A directory that isn't there has a more
8793
+ // specific diagnosis than "it doesn't say which port it serves", and the
8794
+ // caller should get the one that names the actual problem (flair#914).
8080
8795
  if (!existsSync(dataDir)) {
8081
8796
  console.error(`Error: data directory does not exist: ${dataDir}`);
8082
8797
  process.exit(1);
8083
8798
  }
8799
+ // flair#914: the port of the instance NAMED here, never the per-user
8800
+ // file's — refuses rather than guessing when that directory has no record.
8801
+ const port = resolveHttpPortForDataDir(opts);
8084
8802
  console.log(`Snapshotting ${dataDir}...`);
8085
8803
  console.log("(Flair will be briefly stopped for a point-in-time-consistent copy, then restarted.)");
8086
8804
  // Same consistency requirement as the upgrade path's snapshot step: a
@@ -8091,7 +8809,9 @@ snapshotCmd
8091
8809
  // point-in-time-consistent guarantee, not a weaker one.
8092
8810
  let stoppedForSnapshot = false;
8093
8811
  try {
8094
- await stopFlairProcess(port);
8812
+ // `dataDir`, not the default (flair#902) — quiesce the instance this
8813
+ // command was pointed at, never whichever one owns ~/.flair/data.
8814
+ await stopFlairProcess(port, dataDir);
8095
8815
  stoppedForSnapshot = true;
8096
8816
  const snapshot = await createDataSnapshot(dataDir);
8097
8817
  const removed = pruneOldSnapshots();
@@ -8104,14 +8824,14 @@ snapshotCmd
8104
8824
  console.error(`❌ snapshot failed: ${err.message}`);
8105
8825
  if (stoppedForSnapshot) {
8106
8826
  try {
8107
- await startFlairProcess(port);
8827
+ await startFlairProcess(port, dataDir);
8108
8828
  }
8109
8829
  catch { /* best effort — surface the original snapshot error, not this */ }
8110
8830
  }
8111
8831
  process.exit(1);
8112
8832
  }
8113
8833
  try {
8114
- await startFlairProcess(port);
8834
+ await startFlairProcess(port, dataDir);
8115
8835
  }
8116
8836
  catch (err) {
8117
8837
  console.error(`❌ the snapshot succeeded but Flair failed to restart: ${err.message}`);
@@ -8169,7 +8889,9 @@ snapshotCmd
8169
8889
  process.exit(1);
8170
8890
  }
8171
8891
  const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
8172
- const port = resolveHttpPort(opts);
8892
+ // flair#914: the port of the instance NAMED here, never the per-user
8893
+ // file's — refuses rather than guessing when that directory has no record.
8894
+ const port = resolveHttpPortForDataDir(opts);
8173
8895
  console.log("This will STOP Flair, DELETE the current data directory, and replace it with:");
8174
8896
  console.log(` snapshot: ${snapshotPath}`);
8175
8897
  console.log(` target: ${dataDir}`);
@@ -8187,32 +8909,62 @@ snapshotCmd
8187
8909
  }
8188
8910
  }
8189
8911
  try {
8190
- await stopFlairProcess(port);
8912
+ // `dataDir`, not the default (flair#902) — the whole point of this
8913
+ // command's --data-dir is that it may name a scratch directory, and
8914
+ // stopping the default instance instead is how a cautious inspect-a-
8915
+ // snapshot-somewhere-else took production down.
8916
+ await stopFlairProcess(port, dataDir);
8191
8917
  }
8192
8918
  catch (err) {
8193
8919
  console.error(`❌ failed to stop Flair: ${err.message}`);
8194
8920
  process.exit(1);
8195
8921
  }
8922
+ // Validate the archive BEFORE the destructive rmSync below. Restore
8923
+ // accepts snapshots this CLI did not create — copied off another machine,
8924
+ // downloaded, handed over during a migration — so the archive is untrusted
8925
+ // input, and a hostile one must not cost the operator their data directory
8926
+ // on its way to being refused.
8927
+ try {
8928
+ await validateSnapshotArchive({ file: snapshotPath, targetDir: dataDir });
8929
+ }
8930
+ catch (err) {
8931
+ console.error(`❌ ${err.message}`);
8932
+ console.error(` ${dataDir} was NOT modified.`);
8933
+ process.exit(1);
8934
+ }
8196
8935
  try {
8197
8936
  rmSync(dataDir, { recursive: true, force: true });
8198
8937
  mkdirSync(dataDir, { recursive: true, mode: 0o700 });
8199
- // preservePaths mirrors createDataSnapshot's own preservePaths: true —
8200
- // restores absolute symlink targets verbatim instead of node-tar's
8201
- // default of stripping the leading "/" on extraction. No `follow`
8202
- // option, so symlinks extract as symlinks (never their targets'
8203
- // contents), and file modes extract exactly as stored — the archive
8204
- // itself already only contains what createDataSnapshot's filter chose
8205
- // to include (in-bounds symlinks, regular files/dirs only), so restore
8206
- // needs no re-filtering of its own.
8207
- await tarExtract({ file: snapshotPath, cwd: dataDir, preservePaths: true });
8938
+ // extractSnapshotSafely keeps preservePaths: true — load-bearing for
8939
+ // symlink TARGET fidelity, mirroring createDataSnapshot while doing
8940
+ // the entry-path containment that flag disables. See
8941
+ // src/lib/safe-snapshot-extract.ts for why the flag cannot simply be
8942
+ // dropped. No `follow` option, so symlinks extract as symlinks (never
8943
+ // their targets' contents), and file modes extract exactly as stored.
8944
+ await extractSnapshotSafely({ file: snapshotPath, targetDir: dataDir });
8208
8945
  }
8209
8946
  catch (err) {
8210
8947
  console.error(`❌ restore failed: ${err.message}`);
8211
8948
  console.error(` ${dataDir} may be partially restored or empty — do not start Flair until this is resolved.`);
8212
8949
  process.exit(1);
8213
8950
  }
8951
+ // flair#914: a snapshot is a byte-exact copy of a data directory, so it
8952
+ // carries the SOURCE instance's harper-config.yaml, and the extract just
8953
+ // wrote it over this instance's. Between here and the boot below, that file
8954
+ // names the SOURCE's port — but nothing re-resolves in that window: `port`
8955
+ // was resolved before the extract and is handed to startFlairProcess
8956
+ // explicitly, and Harper rewrites http.port / operationsApi.network.port
8957
+ // from that spawn's environment as it boots. So the directory is
8958
+ // self-describing again the moment it is serving, without flair writing into
8959
+ // Harper's config to make it so.
8960
+ //
8961
+ // The port is a property of the instance, not of the data it serves. That
8962
+ // is also what keeps a snapshot from somewhere else out of the business of
8963
+ // naming ports on this host: restoring one to look at it cannot hand the
8964
+ // restored directory a port it did not have — the boot immediately below is
8965
+ // what settles the question, on this host's terms.
8214
8966
  try {
8215
- await startFlairProcess(port);
8967
+ await startFlairProcess(port, dataDir);
8216
8968
  }
8217
8969
  catch (err) {
8218
8970
  console.error(`❌ restore succeeded but Flair failed to restart: ${err.message}`);
@@ -8246,6 +8998,14 @@ program
8246
8998
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
8247
8999
  .option("--yes", "Skip the confirmation prompt for --target")
8248
9000
  .option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
9001
+ // ── flair#878 ─────────────────────────────────────────────────────────────
9002
+ // These existed on `flair deploy` but stopped at the upgrade boundary, so
9003
+ // harper's own remedy ("pass ignore_replication_errors: true") was not
9004
+ // actually reachable through `flair upgrade --target`.
9005
+ .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")
9006
+ .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)")
9007
+ .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)")
9008
+ .option("--convergence-timeout <ms>", "For --target: how long to wait for peer replication to converge before reporting a replication failure (default: 180000)")
8249
9009
  .action(async (opts) => {
8250
9010
  // ── Fabric-upgrade branch ───────────────────────────────────────────────
8251
9011
  if (opts.target) {
@@ -8381,6 +9141,13 @@ program
8381
9141
  // `opts` — safe to call this early.
8382
9142
  const { restart: shouldRestart, verify: shouldVerify, deprecatedRestartFlagUsed } = resolveUpgradeRestartVerify(opts);
8383
9143
  const upgradePort = resolveHttpPort({});
9144
+ // The instance this upgrade is about, named once next to its port
9145
+ // (flair#902). `flair upgrade` has no --data-dir, so this IS the default
9146
+ // install — but stop/start/restart now take the directory explicitly, so
9147
+ // the choice is made here in the open rather than assumed inside them.
9148
+ // A default that happens to be right is the same defect waiting for the
9149
+ // next caller.
9150
+ const upgradeDataDir = defaultDataDir();
8384
9151
  // Hoisted so the pre-flight check (below) and the post-restart/rollback
8385
9152
  // verification steps (further down) all target the same URL — upgrade
8386
9153
  // never restarts Flair onto a different port.
@@ -8463,8 +9230,7 @@ program
8463
9230
  // the exact same mechanism as a standalone command for anyone who wants
8464
9231
  // one without wrapping it around an upgrade.
8465
9232
  const flairIsUpgrading = npmUpgrades.some((u) => u.pkg === "@tpsdev-ai/flair");
8466
- const snapshotDataDir = defaultDataDir();
8467
- const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, existsSync(snapshotDataDir));
9233
+ const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, existsSync(upgradeDataDir));
8468
9234
  let snapshotPath = null;
8469
9235
  if (snapshotDecision === "nudge") {
8470
9236
  // Non-blocking nudge only — never prompt/block here, this must stay
@@ -8477,7 +9243,7 @@ program
8477
9243
  console.log(render.wrap(render.c.dim, line));
8478
9244
  }
8479
9245
  else if (snapshotDecision === "no-data") {
8480
- console.log(`\n(no data directory at ${snapshotDataDir} yet — nothing to snapshot)`);
9246
+ console.log(`\n(no data directory at ${upgradeDataDir} yet — nothing to snapshot)`);
8481
9247
  }
8482
9248
  else if (snapshotDecision === "snapshot") {
8483
9249
  console.log("\nSnapshotting data before upgrade...");
@@ -8496,9 +9262,9 @@ program
8496
9262
  // the server being up).
8497
9263
  let stoppedForSnapshot = false;
8498
9264
  try {
8499
- await stopFlairProcess(upgradePort);
9265
+ await stopFlairProcess(upgradePort, upgradeDataDir);
8500
9266
  stoppedForSnapshot = true;
8501
- const snapshot = await createDataSnapshot(snapshotDataDir);
9267
+ const snapshot = await createDataSnapshot(upgradeDataDir);
8502
9268
  snapshotPath = snapshot.path;
8503
9269
  const removed = pruneOldSnapshots();
8504
9270
  console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
@@ -8512,14 +9278,14 @@ program
8512
9278
  console.error(" Aborting upgrade — no packages were changed. Omit --snapshot to proceed without one (not recommended).");
8513
9279
  if (stoppedForSnapshot) {
8514
9280
  try {
8515
- await startFlairProcess(upgradePort);
9281
+ await startFlairProcess(upgradePort, upgradeDataDir);
8516
9282
  }
8517
9283
  catch { /* best effort — surface the original snapshot error, not this */ }
8518
9284
  }
8519
9285
  process.exit(1);
8520
9286
  }
8521
9287
  try {
8522
- await startFlairProcess(upgradePort);
9288
+ await startFlairProcess(upgradePort, upgradeDataDir);
8523
9289
  }
8524
9290
  catch (err) {
8525
9291
  console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
@@ -8595,15 +9361,118 @@ program
8595
9361
  console.log("\nRestarting Flair...");
8596
9362
  const port = upgradePort;
8597
9363
  // baseUrl was hoisted above (pre-flight, fix #1) — same URL, no redeclaration.
9364
+ /**
9365
+ * Roll @tpsdev-ai/flair back to `toVersion`, restart on it, re-verify, and
9366
+ * exit. Shared by the two ways an upgrade can fail after the package swap:
9367
+ * the restart itself (flair#905) and post-restart verification (flair#635).
9368
+ *
9369
+ * flair#905 found the restart leg wired straight to `process.exit(1)` — so
9370
+ * `docs/upgrade.md`'s "install → restart → verify → rollback-on-failure, in
9371
+ * one step" was only ever true for the verify leg. An upgrade that installed
9372
+ * new packages and then failed to start them left the operator on the new
9373
+ * version with nothing running and no rollback, which is the one outcome the
9374
+ * whole transaction exists to prevent.
9375
+ */
9376
+ const rollbackTo = async (toVersion, reason) => {
9377
+ console.log(`\nRolling back @tpsdev-ai/flair to ${toVersion}...`);
9378
+ try {
9379
+ execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
9380
+ }
9381
+ catch (err) {
9382
+ console.error(`❌ rollback install failed: ${err.message}`);
9383
+ console.error(` Flair is currently on the FAILED version (${expectedFlairVersion ?? "unknown"}) and is NOT running.`);
9384
+ console.error(` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
9385
+ process.exit(1);
9386
+ }
9387
+ // Same post-swap rule as the upgrade restart above: the rolled-back
9388
+ // version's own CLI is the thing that knows how to start it.
9389
+ const rolledBackCli = resolveInstalledFlairCli(flairPackageDir(), toVersion);
9390
+ try {
9391
+ await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
9392
+ }
9393
+ catch (err) {
9394
+ console.error(`❌ rollback restart failed: ${err.message}`);
9395
+ console.error(` @tpsdev-ai/flair@${toVersion} is installed but NOT running. Start it with: flair start`);
9396
+ console.error(" Then check: flair status");
9397
+ process.exit(1);
9398
+ }
9399
+ const rollbackVerify = await probeInstance(baseUrl, {
9400
+ expectVersion: toVersion,
9401
+ timeoutMs: STARTUP_TIMEOUT_MS,
9402
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
9403
+ });
9404
+ const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
9405
+ if (rollbackVerdict.kind === "rolled-back") {
9406
+ console.error(`❌ upgrade failed and was rolled back to @tpsdev-ai/flair@${toVersion} (running, verified).`);
9407
+ console.error(` Original failure: ${reason}`);
9408
+ process.exit(1);
9409
+ }
9410
+ console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
9411
+ // flair#741 fix #3: this is the exact incident report — a 403 from a
9412
+ // responding, healthy server (credentials-only failure) was printed as
9413
+ // "state UNKNOWN — do not assume data integrity" for BOTH the upgrade
9414
+ // verify AND the rollback re-verify, because the same missing-auth-
9415
+ // material condition rejects both. Reserve the UNKNOWN/do-not-assume
9416
+ // text for failures where the instance's real state genuinely can't be
9417
+ // determined (connection refused, timeout, 5xx) — a credential-only
9418
+ // failure here means the rollback likely landed fine and the checker
9419
+ // simply can't prove it.
9420
+ if (isCredentialOnlyFailure(rollbackVerify)) {
9421
+ console.error(" The instance is up and responding — the verifier could not authenticate (credentials, not the rollback, are the problem).");
9422
+ console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key, then check: flair doctor");
9423
+ }
9424
+ else {
9425
+ console.error(" Instance state is UNKNOWN — do not assume data integrity.");
9426
+ }
9427
+ // This double-failure isn't auto-recoverable yet (flair#637) — but if a
9428
+ // pre-upgrade snapshot landed, point at the CONCRETE path instead of
9429
+ // just the issue number, so recovery doesn't start with a GitHub search.
9430
+ if (snapshotPath) {
9431
+ console.error(` A pre-upgrade snapshot is available: ${snapshotPath}`);
9432
+ console.error(` Restore: flair snapshot restore "${snapshotPath}" (or see docs/upgrade.md#downgrade).`);
9433
+ }
9434
+ else {
9435
+ 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).");
9436
+ console.error(" Check `flair snapshot list` for a manual one, or restore from a `flair backup` JSON export. See docs/upgrade.md#downgrade.");
9437
+ }
9438
+ process.exit(1);
9439
+ };
9440
+ // flair#905: hand the restart to the CLI that was just installed, resolved
9441
+ // from disk AFTER the swap. `null` (flair itself wasn't swapped, or the new
9442
+ // tree can't be verified) falls back to an in-process restart, announced.
9443
+ const flairWasSwapped = flairIsUpgrading && !flairInstallFailed;
9444
+ let newCli = null;
9445
+ if (flairWasSwapped) {
9446
+ const resolved = resolveInstalledFlairCli(flairPackageDir(), expectedFlairVersion);
9447
+ if (resolved.ok === false) {
9448
+ console.error(`warning: could not verify the newly installed CLI (${resolved.reason}) — restarting with this process's own code instead.`);
9449
+ }
9450
+ else {
9451
+ newCli = { cliPath: resolved.cliPath, version: resolved.version };
9452
+ }
9453
+ }
9454
+ let restartWasDelegated = false;
8598
9455
  try {
8599
- await restartFlair(port);
9456
+ restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
8600
9457
  }
8601
9458
  catch (err) {
8602
9459
  console.error(`❌ restart failed: ${err.message}`);
8603
- console.error(" Flair may be partially down. Check: flair doctor");
9460
+ console.error(" Flair is NOT running. Your data in ~/.flair was not touched by this upgrade.");
9461
+ if (flairWasSwapped && previousFlairVersion) {
9462
+ await rollbackTo(previousFlairVersion, `restart failed: ${err.message}`);
9463
+ }
9464
+ // Not reached when a rollback ran — rollbackTo always exits. Say WHICH of
9465
+ // the two "no rollback" cases this is; "nothing to roll back" is not the
9466
+ // same statement as "we don't know what to roll back to".
9467
+ console.error(flairWasSwapped
9468
+ ? " Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown."
9469
+ : " Nothing to roll back: @tpsdev-ai/flair itself was not changed by this upgrade.");
9470
+ console.error(" Start it with: flair start — then check: flair status");
8604
9471
  process.exit(1);
8605
9472
  }
8606
- console.log("✅ Flair restarted");
9473
+ // The delegated `flair restart` printed its own success line; don't say it twice.
9474
+ if (!restartWasDelegated)
9475
+ console.log("✅ Flair restarted");
8607
9476
  if (!shouldVerify) {
8608
9477
  console.log(" (--no-verify: skipping post-restart verification)");
8609
9478
  return;
@@ -8646,63 +9515,7 @@ program
8646
9515
  console.error(" Check the instance now: flair doctor");
8647
9516
  process.exit(1);
8648
9517
  }
8649
- console.log(`\nRolling back @tpsdev-ai/flair to ${verdict.toVersion}...`);
8650
- try {
8651
- execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${verdict.toVersion}`], { stdio: "pipe" });
8652
- }
8653
- catch (err) {
8654
- console.error(`❌ rollback install failed: ${err.message}`);
8655
- console.error(` Flair is currently running the FAILED version (${expectedFlairVersion ?? "unknown"}). Manual intervention required.`);
8656
- process.exit(1);
8657
- }
8658
- try {
8659
- await restartFlair(port);
8660
- }
8661
- catch (err) {
8662
- console.error(`❌ rollback restart failed: ${err.message}`);
8663
- console.error(" Instance state is UNKNOWN — it may be down entirely. Check: flair doctor");
8664
- process.exit(1);
8665
- }
8666
- const rollbackVerify = await probeInstance(baseUrl, {
8667
- expectVersion: verdict.toVersion,
8668
- timeoutMs: STARTUP_TIMEOUT_MS,
8669
- authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
8670
- });
8671
- const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
8672
- if (rollbackVerdict.kind === "rolled-back") {
8673
- console.error(`❌ upgrade failed verification and was rolled back to @tpsdev-ai/flair@${verdict.toVersion}.`);
8674
- console.error(` Original failure: ${verdict.reason}`);
8675
- process.exit(1);
8676
- }
8677
- console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
8678
- // flair#741 fix #3: this is the exact incident report — a 403 from a
8679
- // responding, healthy server (credentials-only failure) was printed as
8680
- // "state UNKNOWN — do not assume data integrity" for BOTH the upgrade
8681
- // verify AND the rollback re-verify, because the same missing-auth-
8682
- // material condition rejects both. Reserve the UNKNOWN/do-not-assume
8683
- // text for failures where the instance's real state genuinely can't be
8684
- // determined (connection refused, timeout, 5xx) — a credential-only
8685
- // failure here means the rollback likely landed fine and the checker
8686
- // simply can't prove it.
8687
- if (isCredentialOnlyFailure(rollbackVerify)) {
8688
- console.error(" The instance is up and responding — the verifier could not authenticate (credentials, not the rollback, are the problem).");
8689
- console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key, then check: flair doctor");
8690
- }
8691
- else {
8692
- console.error(" Instance state is UNKNOWN — do not assume data integrity.");
8693
- }
8694
- // This double-failure isn't auto-recoverable yet (flair#637) — but if a
8695
- // pre-upgrade snapshot landed, point at the CONCRETE path instead of
8696
- // just the issue number, so recovery doesn't start with a GitHub search.
8697
- if (snapshotPath) {
8698
- console.error(` A pre-upgrade snapshot is available: ${snapshotPath}`);
8699
- console.error(` Restore: flair snapshot restore "${snapshotPath}" (or see docs/upgrade.md#downgrade).`);
8700
- }
8701
- else {
8702
- 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).");
8703
- console.error(" Check `flair snapshot list` for a manual one, or restore from a `flair backup` JSON export. See docs/upgrade.md#downgrade.");
8704
- }
8705
- process.exit(1);
9518
+ await rollbackTo(verdict.toVersion, verdict.reason);
8706
9519
  });
8707
9520
  // ─── flair stop ───────────────────────────────────────────────────────────────
8708
9521
  program
@@ -8729,14 +9542,19 @@ program
8729
9542
  }
8730
9543
  }
8731
9544
  }
8732
- // Fallback: find process by port
9545
+ // Fallback: find process by port. Listening sockets only, never our own
9546
+ // PID — see parseListeningPids (flair#800/flair#905): this used to SIGTERM
9547
+ // every process holding ANY socket on the port, so `flair stop` could kill
9548
+ // itself (leaving Flair running) or kill an unrelated client of it.
8733
9549
  try {
8734
9550
  const { execSync } = await import("node:child_process");
8735
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
8736
- if (lsof) {
8737
- const pids = lsof.split("\n").map(p => p.trim()).filter(Boolean);
9551
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
9552
+ if (pids.length > 0) {
8738
9553
  for (const pid of pids) {
8739
- process.kill(Number(pid), "SIGTERM");
9554
+ try {
9555
+ process.kill(pid, "SIGTERM");
9556
+ }
9557
+ catch { /* already gone */ }
8740
9558
  }
8741
9559
  console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
8742
9560
  }
@@ -8793,11 +9611,12 @@ program
8793
9611
  }
8794
9612
  }
8795
9613
  // Direct start (Linux, or macOS fallback when no launchd plist)
8796
- const bin = harperBin();
8797
- if (!bin) {
8798
- console.error("❌ Harper binary not found. Run 'flair init' first.");
9614
+ const harper = resolveHarperBin(harperSearchRoots());
9615
+ if (!harper.path) {
9616
+ console.error(`❌ ${harperBinNotFoundMessage(harper.searched)}`);
8799
9617
  process.exit(1);
8800
9618
  }
9619
+ const bin = harper.path;
8801
9620
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
8802
9621
  // flair#670/#863: this fallback path (no launchd plist) sets no
8803
9622
  // HARPER_SET_CONFIG, so the ops bind has to be re-asserted explicitly on
@@ -8831,6 +9650,81 @@ program
8831
9650
  }
8832
9651
  });
8833
9652
  // ─── flair restart ────────────────────────────────────────────────────────────
9653
+ /**
9654
+ * Refuse to act on a launchd service that belongs to a DIFFERENT data
9655
+ * directory than the one the command is operating on (flair#902).
9656
+ *
9657
+ * `resolveLaunchdLabel`'s instance-scoped label is a hash of the data dir,
9658
+ * so it can never address another instance — but its pre-flair#693 legacy
9659
+ * fallback CAN: `ai.tpsdev.flair` is a single global label, returned for
9660
+ * ANY data dir whenever that plist exists. On a host that still has one,
9661
+ * `flair snapshot restore --data-dir <scratch>` would resolve the legacy
9662
+ * service and stop whatever install it actually belongs to.
9663
+ *
9664
+ * The plist records the instance it was written for (`ROOTPATH`), so the
9665
+ * check is exact. Refuses ONLY on positive contradiction: a plist with no
9666
+ * ROOTPATH (hand-written, or some other writer's) is no evidence and is
9667
+ * left alone rather than blocking a legitimate stop.
9668
+ *
9669
+ * Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD. Only the
9670
+ * extracted ROOTPATH path ever reaches a message.
9671
+ */
9672
+ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9673
+ let declared = null;
9674
+ try {
9675
+ const raw = readFileSync(plistPath, "utf-8");
9676
+ const m = raw.match(/<key>ROOTPATH<\/key>\s*<string>([^<]*)<\/string>/);
9677
+ // The plist stores this XML-escaped (buildLaunchdPlist), so a data dir
9678
+ // containing `&` is on disk as `&amp;`. Decode before comparing, or the
9679
+ // path would never equal itself and this guard would refuse a legitimate
9680
+ // stop/start on any instance whose path contains an escaped character.
9681
+ declared = m ? unescapeXml(m[1]) : null;
9682
+ }
9683
+ catch {
9684
+ return; // unreadable — no evidence, don't block
9685
+ }
9686
+ if (declared === null)
9687
+ return;
9688
+ if (resolve(declared) === resolve(dataDir))
9689
+ return;
9690
+ throw new Error(`refusing to ${action} launchd service ${label}: its plist (${plistPath}) is registered to data directory ` +
9691
+ `${resolve(declared)}, not ${resolve(dataDir)} — that is a different Flair instance. ` +
9692
+ `Re-run with --data-dir ${resolve(declared)} to act on that one, or run ` +
9693
+ `'flair init --data-dir ${resolve(dataDir)}' to register a service for this one.`);
9694
+ }
9695
+ /**
9696
+ * Refuse a port-based SIGTERM that cannot be attributed to `dataDir`
9697
+ * (flair#902).
9698
+ *
9699
+ * The port fallback below identifies its target by port number and nothing
9700
+ * else, so `--data-dir <scratch>` with a port that scratch instance does not
9701
+ * serve signals whichever instance DOES serve it. That is the whole of this
9702
+ * bug on Linux, where there is no launchd path at all.
9703
+ *
9704
+ * Scoped deliberately to a non-default data dir. For the default install the
9705
+ * port genuinely is that instance's port by every convention in this CLI
9706
+ * (`writeConfig`/`readPortFromConfig`), and the only evidence available here
9707
+ * — `<dataDir>/hdb.pid` vs the listening PIDs — is not something we can
9708
+ * require without risking a false refusal on a working install whose PID file
9709
+ * is missing or whose listener is a worker. So the default path keeps today's
9710
+ * behavior exactly, and the residual gap is stated rather than papered over:
9711
+ * a default-dir port stop is still unattributed. The new refusal can only
9712
+ * fire for a caller that explicitly named another data dir — the case that is
9713
+ * wrong today whenever the port does not match.
9714
+ */
9715
+ function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
9716
+ if (resolve(dataDir) === defaultDataDir())
9717
+ return;
9718
+ const expected = readHarperPid(dataDir);
9719
+ if (expected !== null && listeningPids.includes(expected))
9720
+ return;
9721
+ const why = expected === null
9722
+ ? `no hdb.pid under that directory, so it does not look like a running instance`
9723
+ : `its recorded PID ${expected} is not the process listening on ${port}`;
9724
+ throw new Error(`refusing to stop the process listening on port ${port}: it cannot be attributed to ${resolve(dataDir)} (${why}). ` +
9725
+ `Stopping by port alone would signal a different Flair instance. ` +
9726
+ `Pass --port with the port ${resolve(dataDir)} actually serves, or omit --data-dir to operate on the default install.`);
9727
+ }
8834
9728
  /**
8835
9729
  * Stop the local Flair (Harper) process — launchd `stop` on darwin when a
8836
9730
  * plist is present (falling back on failure), otherwise a manual SIGTERM by
@@ -8839,19 +9733,34 @@ program
8839
9733
  * start without duplicating this logic — `restartFlair` is now just
8840
9734
  * `stopFlairProcess` followed by `startFlairProcess`.
8841
9735
  *
9736
+ * `dataDir` is REQUIRED and names the instance to stop — it is not a
9737
+ * convenience parameter. It used to be resolved internally from
9738
+ * `defaultDataDir()`, which meant `flair snapshot create|restore --data-dir
9739
+ * <elsewhere>` stopped the DEFAULT instance and reported success
9740
+ * (flair#902). A port alone does not identify an instance to launchd; the
9741
+ * data dir does, via `resolveLaunchdLabel`.
9742
+ *
8842
9743
  * Idempotent-ish: stopping an already-stopped instance is a harmless no-op
8843
9744
  * on both paths (launchctl stop on an unloaded/idle service, or an empty
8844
9745
  * `lsof` match).
9746
+ *
9747
+ * Throws when the resolved target provably belongs to a different instance
9748
+ * — see assertLaunchdServiceOwnedBy / assertPortInstanceOwnedBy. Callers
9749
+ * already treat a failed stop as fatal, which is the point: refusing beats
9750
+ * quiescing the wrong install.
8845
9751
  */
8846
- async function stopFlairProcess(port) {
9752
+ async function stopFlairProcess(port, dataDir) {
8847
9753
  if (process.platform === "darwin") {
8848
- const dataDir = defaultDataDir();
8849
9754
  // resolveLaunchdLabel (flair#693) finds whichever label this data dir
8850
9755
  // is currently registered under (new instance-scoped, or a
8851
9756
  // pre-flair#693 legacy install) — stop only needs to operate on
8852
9757
  // whichever exists, no migration.
8853
9758
  const { label, plistPath } = resolveLaunchdLabel(dataDir);
8854
9759
  if (existsSync(plistPath)) {
9760
+ // Outside the try below: an ownership refusal must NOT degrade into
9761
+ // the port-based fallback, which would go on to signal by port the
9762
+ // very instance we just refused to touch.
9763
+ assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "stop");
8855
9764
  try {
8856
9765
  const { execSync } = await import("node:child_process");
8857
9766
  // Ensure the service is loaded (init writes the plist but doesn't load it)
@@ -8879,45 +9788,55 @@ async function stopFlairProcess(port) {
8879
9788
  }
8880
9789
  // Port-based stop (Linux, or macOS fallback when no launchd plist)
8881
9790
  console.log("Stopping...");
8882
- try {
8883
- const { execSync } = await import("node:child_process");
8884
- // -sTCP:LISTEN: match the LISTENING server only a bare `lsof -ti :port`
8885
- // also matches CLIENT sockets referencing the port, including THIS CLI's
8886
- // own keep-alive connections left by the credential pre-flight's
8887
- // probeInstance() HTTP calls (flair#741). Without the filter, the upgrade
8888
- // path SIGTERM'd its own process mid-restart "Stopping..." then death
8889
- // (exit 143) before "Starting..." ever ran, leaving the server down
8890
- // (flair#800, deterministic on the Linux/non-launchd default path).
8891
- const lsof = execSync(`lsof -ti :${port} -sTCP:LISTEN`, { encoding: "utf-8" }).trim();
8892
- if (lsof) {
8893
- for (const pid of lsof.split("\n")) {
8894
- const target = Number(pid.trim());
8895
- // Belt-and-suspenders: never SIGTERM ourselves, whatever lsof says.
8896
- if (!Number.isFinite(target) || target === process.pid)
8897
- continue;
8898
- try {
8899
- process.kill(target, "SIGTERM");
8900
- }
8901
- catch { }
8902
- }
8903
- // Wait briefly for shutdown
8904
- await new Promise(r => setTimeout(r, 2000));
9791
+ const { execSync } = await import("node:child_process");
9792
+ // -sTCP:LISTEN plus a self-PID guard, both inside listeningPidsOnPort: a bare
9793
+ // `lsof -ti :port` also matches CLIENT sockets referencing the port, including
9794
+ // THIS CLI's own keep-alive connections left by the credential pre-flight's
9795
+ // probeInstance() HTTP calls (flair#741). Without the filter, the upgrade
9796
+ // path SIGTERM'd its own process mid-restart "Stopping..." then death
9797
+ // (exit 143) before "Starting..." ever ran, leaving the server down
9798
+ // (flair#800, deterministic on the Linux/non-launchd default path).
9799
+ // flair#905 moved both halves into that one helper because the same
9800
+ // unfiltered pattern had survived in `flair stop`, `flair uninstall` and
9801
+ // `flair doctor` — one guarded resolver is what keeps the next site honest.
9802
+ // It returns [] when lsof matches nothing, which is this path's "not running".
9803
+ const targets = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
9804
+ if (targets.length === 0)
9805
+ return;
9806
+ // Deliberately outside any catch: a refusal must reach the caller, not be
9807
+ // swallowed as "not running" and reported as a successful stop.
9808
+ assertPortInstanceOwnedBy(port, dataDir, targets);
9809
+ for (const target of targets) {
9810
+ try {
9811
+ process.kill(target, "SIGTERM");
8905
9812
  }
9813
+ catch { }
8906
9814
  }
8907
- catch { /* not running */ }
9815
+ // Wait briefly for shutdown
9816
+ await new Promise((r) => setTimeout(r, 2000));
8908
9817
  }
8909
9818
  /**
8910
9819
  * Start the local Flair (Harper) process — launchd `start` on darwin when a
8911
9820
  * plist is present (falling back on failure), otherwise a direct spawn.
8912
9821
  * Counterpart to `stopFlairProcess`; see that function's doc comment.
9822
+ *
9823
+ * `dataDir` is REQUIRED for the same reason it is on `stopFlairProcess`
9824
+ * (flair#902): it, not the port, is what identifies the instance. This
9825
+ * function resolved it internally from `defaultDataDir()` too, so the
9826
+ * snapshot commands' restart leg brought the DEFAULT instance back up after
9827
+ * operating on a `--data-dir` elsewhere.
8913
9828
  */
8914
- async function startFlairProcess(port) {
8915
- const dataDir = defaultDataDir();
9829
+ async function startFlairProcess(port, dataDir) {
8916
9830
  if (process.platform === "darwin") {
8917
9831
  // resolveLaunchdLabel (flair#693) finds whichever label this data dir
8918
9832
  // is currently registered under before we attempt anything.
8919
- const { plistPath } = resolveLaunchdLabel(dataDir);
9833
+ const { label, plistPath } = resolveLaunchdLabel(dataDir);
8920
9834
  if (existsSync(plistPath)) {
9835
+ // Same ownership gate as the stop path (flair#902), and outside the
9836
+ // try for the same reason: starting the wrong service would then wait
9837
+ // for health on `port`, see the OTHER instance answer, and report
9838
+ // success.
9839
+ assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "start");
8921
9840
  try {
8922
9841
  const { execSync } = await import("node:child_process");
8923
9842
  ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
@@ -8931,10 +9850,11 @@ async function startFlairProcess(port) {
8931
9850
  }
8932
9851
  }
8933
9852
  console.log("Starting...");
8934
- const bin = harperBin();
8935
- if (!bin) {
8936
- throw new Error("Harper binary not found. Run 'flair init' first.");
9853
+ const harper = resolveHarperBin(harperSearchRoots());
9854
+ if (!harper.path) {
9855
+ throw new Error(harperBinNotFoundMessage(harper.searched));
8937
9856
  }
9857
+ const bin = harper.path;
8938
9858
  // Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
8939
9859
  // Without this, `flair init --admin-pass X` (which only exports HDB_*
8940
9860
  // to the initial Harper spawn) followed by `flair restart` would silently
@@ -8981,28 +9901,119 @@ async function startFlairProcess(port) {
8981
9901
  * Throws on failure instead of calling process.exit — callers decide how to
8982
9902
  * react (`flair restart` exits 1; `flair upgrade` treats a failed restart as
8983
9903
  * an upgrade failure and may attempt a rollback).
9904
+ *
9905
+ * Takes `dataDir` explicitly (flair#902) so the instance being bounced is
9906
+ * named at the call site rather than assumed from `defaultDataDir()` two
9907
+ * frames down.
8984
9908
  */
8985
- async function restartFlair(port) {
8986
- await stopFlairProcess(port);
8987
- await startFlairProcess(port);
9909
+ async function restartFlair(port, dataDir) {
9910
+ await stopFlairProcess(port, dataDir);
9911
+ await startFlairProcess(port, dataDir);
8988
9912
  // Bust the version-handshake cache so the next preAction nudge re-fetches
8989
9913
  // the LIVE version instead of the pre-restart cached one (the false
8990
9914
  // "server is running <old>" users hit for up to 60s post-upgrade+restart).
8991
9915
  // Same (rootPath, serverUrl) key the preAction hook computes (~line 2189)
8992
- // — must match exactly, or this busts the wrong cache file.
9916
+ // — must match exactly, or this busts the wrong cache file. Deliberately
9917
+ // NOT `dataDir`: the key is whatever the preAction hook computed for THIS
9918
+ // process, and using the restarted instance's dir instead would bust a
9919
+ // different cache file (or none) and leave the stale entry in place.
8993
9920
  try {
8994
9921
  invalidateHandshakeCache(process.env.ROOTPATH ?? defaultDataDir(), `http://127.0.0.1:${port}`);
8995
9922
  }
8996
9923
  catch { /* best-effort — never fail a restart over cache cleanup */ }
8997
9924
  }
9925
+ export function resolveInstalledFlairCli(packageRoot, expectVersion, deps = {}) {
9926
+ const exists = deps.exists ?? existsSync;
9927
+ const read = deps.read ?? ((p) => readFileSync(p, "utf-8"));
9928
+ const cliPath = join(packageRoot, "dist", "cli.js");
9929
+ if (!exists(cliPath))
9930
+ return { ok: false, reason: `no dist/cli.js at ${cliPath}` };
9931
+ let version;
9932
+ try {
9933
+ version = JSON.parse(read(join(packageRoot, "package.json"))).version ?? "";
9934
+ }
9935
+ catch (err) {
9936
+ return { ok: false, reason: `could not read ${join(packageRoot, "package.json")}: ${err?.message ?? err}` };
9937
+ }
9938
+ if (!version)
9939
+ return { ok: false, reason: `${join(packageRoot, "package.json")} declares no version` };
9940
+ if (expectVersion && version !== expectVersion) {
9941
+ return { ok: false, reason: `${packageRoot} holds ${version}, expected ${expectVersion}` };
9942
+ }
9943
+ return { ok: true, cliPath, version };
9944
+ }
9945
+ /**
9946
+ * Restart Flair after a package swap, through the newly installed CLI when one
9947
+ * could be located and in-process otherwise.
9948
+ *
9949
+ * The in-process fallback is deliberate and is NOT a silent one: it announces
9950
+ * which path it took and why. A missing/unverifiable new CLI means the swap
9951
+ * itself is suspect, and refusing to restart at all would turn a recoverable
9952
+ * upgrade into a guaranteed outage — but a fallback nobody can see in the
9953
+ * output is how "it restarted fine" and "it restarted with the wrong code"
9954
+ * become indistinguishable after the fact.
9955
+ *
9956
+ * Delegation is limited to the DEFAULT data directory, and that limit is not
9957
+ * incidental. The child is `flair restart`, which has no `--data-dir` and
9958
+ * therefore restarts `defaultDataDir()` — handing it a `dataDir` that is not
9959
+ * the default would restart a different instance and then wait for health on
9960
+ * `port` and watch the wrong one answer. That is flair#902 exactly, and the
9961
+ * required `dataDir` parameter here exists so the condition is checkable rather
9962
+ * than assumed. Today the upgrade path only ever operates on the default
9963
+ * install, so this never triggers; if that changes, `flair restart` needs a
9964
+ * `--data-dir` before this may delegate.
9965
+ *
9966
+ * Throws on failure; the caller owns the rollback decision. Returns whether the
9967
+ * restart was delegated, so the caller can leave the success line to whichever
9968
+ * process actually printed one.
9969
+ */
9970
+ async function restartAfterUpgrade(port, dataDir, newCliArg) {
9971
+ let newCli = newCliArg;
9972
+ if (newCli && resolve(dataDir) !== defaultDataDir()) {
9973
+ console.error(`warning: not delegating the restart to ${newCli.cliPath} — it would restart ${defaultDataDir()}, ` +
9974
+ `not ${resolve(dataDir)}. Restarting with this process's own code instead.`);
9975
+ newCli = null;
9976
+ }
9977
+ if (!newCli) {
9978
+ await restartFlair(port, dataDir);
9979
+ return false;
9980
+ }
9981
+ console.log(` (restarting via the newly installed CLI: ${newCli.cliPath} @ ${newCli.version})`);
9982
+ const { spawnSync } = await import("node:child_process");
9983
+ const res = spawnSync(process.execPath, [newCli.cliPath, "restart", "--port", String(port)], {
9984
+ encoding: "utf-8",
9985
+ // The child runs the same stop→start→waitForHealth sequence this process
9986
+ // would have; give it the full startup budget plus slack for the stop leg
9987
+ // rather than killing a restart that is merely slow.
9988
+ timeout: STARTUP_TIMEOUT_MS * 3,
9989
+ env: process.env,
9990
+ });
9991
+ if (res.stdout)
9992
+ process.stdout.write(res.stdout);
9993
+ if (res.stderr)
9994
+ process.stderr.write(res.stderr);
9995
+ if (res.error) {
9996
+ throw new Error(`could not run the newly installed CLI (${newCli.cliPath}): ${res.error.message}`);
9997
+ }
9998
+ if (res.status !== 0) {
9999
+ const detail = (res.stderr ?? "").trim().split("\n").filter(Boolean).pop();
10000
+ throw new Error(`the newly installed CLI (@tpsdev-ai/flair@${newCli.version}) failed to restart Flair` +
10001
+ `${res.signal ? ` (killed by ${res.signal})` : ` (exit ${res.status})`}` +
10002
+ `${detail ? `: ${detail}` : ""}`);
10003
+ }
10004
+ return true;
10005
+ }
8998
10006
  program
8999
10007
  .command("restart")
9000
10008
  .description("Restart the Flair (Harper) instance")
9001
10009
  .option("--port <port>", "Harper HTTP port")
9002
10010
  .action(async (opts) => {
9003
10011
  const port = resolveHttpPort(opts);
10012
+ // Explicit, not defaulted inside restartFlair (flair#902): `flair
10013
+ // restart` has no --data-dir, so the default install IS what it means —
10014
+ // and saying so here is what keeps that true when someone adds one.
9004
10015
  try {
9005
- await restartFlair(port);
10016
+ await restartFlair(port, defaultDataDir());
9006
10017
  console.log("✅ Flair restarted");
9007
10018
  }
9008
10019
  catch (err) {
@@ -9041,14 +10052,16 @@ program
9041
10052
  if (removedAny)
9042
10053
  console.log("✅ Launchd service removed");
9043
10054
  }
9044
- // Kill any process still on the port (covers direct-start, no-service, or failed unload)
10055
+ // Kill any process still on the port (covers direct-start, no-service, or
10056
+ // failed unload). Listening sockets only, never our own PID — see
10057
+ // parseListeningPids (flair#800/flair#905).
9045
10058
  try {
9046
10059
  const { execSync } = await import("node:child_process");
9047
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
9048
- if (lsof) {
9049
- for (const pid of lsof.split("\n")) {
10060
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
10061
+ if (pids.length > 0) {
10062
+ for (const pid of pids) {
9050
10063
  try {
9051
- process.kill(Number(pid.trim()), "SIGTERM");
10064
+ process.kill(pid, "SIGTERM");
9052
10065
  }
9053
10066
  catch { }
9054
10067
  }
@@ -9463,8 +10476,10 @@ program
9463
10476
  .option("--no-verify", "Skip post-deploy served-API verification (default: verify — on by design, so the CLI can't report success on an empty/broken deploy)")
9464
10477
  .option("--verify-timeout <ms>", "Milliseconds to wait for the served API to settle after harper's post-deploy restart before verifying (default: 300000)")
9465
10478
  .option("--verify-resource <name>", "Resource to verify is serving after deploy (repeatable; default: derived from the deployed package's dist/resources)", (val, prev) => [...prev, val], [])
9466
- .option("--deploy-retries <n>", "Retry the full harper deploy this many times on a detected flaky peer-replication failure ONLYa normal deploy failure (auth, bad package, ...) never retries (default: 2; 0 disables)", "2")
9467
- .option("--ignore-replication-errors", "If peer replication is still failing once retries are exhausted, treat it as a non-fatal warning and succeed with an origin-only deploy (the peer catches up via federation sync or a later deploy)")
10479
+ .option("--deploy-retries <n>", "Retry the full harper deploy this many times, ONLY when peer replication is positively observed not to converge (default: 0 retrying re-runs harper's component install and can escalate a transient replication warning into a hard ENOTEMPTY install failure; see flair#878)", "0")
10480
+ .option("--ignore-replication-errors", "If peer replication still hasn't converged, treat it as a non-fatal warning and succeed with an origin-only deploy (the peer catches up via federation sync or a later deploy)")
10481
+ .option("--no-convergence-check", "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)")
10482
+ .option("--convergence-timeout <ms>", "How long to wait for peer replication to converge before reporting a replication failure (default: 180000)")
9468
10483
  .option("--no-fleet-verify", "Skip the automatic post-deploy fleet convergence sweep (default: sweep runs — see flair#636)")
9469
10484
  .action(async (opts) => {
9470
10485
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
@@ -9499,8 +10514,10 @@ program
9499
10514
  verify: opts.verify !== false,
9500
10515
  verifyResources: opts.verifyResource?.length ? opts.verifyResource : undefined,
9501
10516
  verifyTimeoutMs: Number(opts.verifyTimeout ?? 300_000),
9502
- deployRetries: Number(opts.deployRetries ?? 2),
10517
+ deployRetries: Number(opts.deployRetries ?? 0),
9503
10518
  ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
10519
+ convergenceCheck: opts.convergenceCheck !== false,
10520
+ convergenceTimeoutMs: opts.convergenceTimeout != null ? Number(opts.convergenceTimeout) : undefined,
9504
10521
  onProgress: (msg) => console.log(dim(` ${msg}`)),
9505
10522
  };
9506
10523
  const errors = validateDeployOptions(deployOpts);
@@ -9525,6 +10542,11 @@ program
9525
10542
  console.log(dim(` package root: ${result.packageRoot}`));
9526
10543
  return;
9527
10544
  }
10545
+ if (result.convergedAfterReplicationError) {
10546
+ // flair#878: harper's exit code said failure; the per-node component
10547
+ // comparison said otherwise. Both halves get said out loud.
10548
+ console.log(`\n${yellow("⚠")} harper reported a peer-replication failure, but every named peer node's component tree matched the origin when checked afterwards — replication converged on its own.`);
10549
+ }
9528
10550
  if (result.replicationWarning) {
9529
10551
  console.log(`\n${yellow("⚠")} Flair ${result.version} deployed to the ORIGIN NODE ONLY — peer replication did not complete (see warning above). The peer will catch up via federation sync or a later deploy.`);
9530
10552
  }
@@ -9579,8 +10601,9 @@ program
9579
10601
  if (hint?.includes("did not settle")) {
9580
10602
  console.error(dim(" hint: Harper may still be restarting — check Fabric Studio, or retry with a longer --verify-timeout"));
9581
10603
  }
9582
- if (hint?.includes("peer replication failed after")) {
10604
+ if (hint?.includes("peer replication")) {
9583
10605
  console.error(dim(" hint: pass --ignore-replication-errors to accept an origin-only deploy, or re-run once the peer link recovers"));
10606
+ console.error(dim(" hint: --convergence-timeout <ms> waits longer for asynchronous replication before giving up (default 180000)"));
9584
10607
  }
9585
10608
  process.exit(1);
9586
10609
  }
@@ -9789,7 +10812,10 @@ program
9789
10812
  console.log(` ${render.wrap(render.c.dim, "Would update config to port")} ${discoveredPort}`);
9790
10813
  }
9791
10814
  else {
9792
- writeConfig(discoveredPort);
10815
+ // dataDir0 is defaultDataDir() — `flair doctor` has no
10816
+ // --data-dir, so the default install is what it means, and
10817
+ // saying so keeps that true when it grows one (flair#914).
10818
+ persistDefaultInstallCoordinates(dataDir0, discoveredPort);
9793
10819
  console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
9794
10820
  fixed++;
9795
10821
  }
@@ -9813,8 +10839,13 @@ program
9813
10839
  // Check if something else grabbed the port
9814
10840
  try {
9815
10841
  const { execSync } = await import("node:child_process");
9816
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
9817
- if (lsof) {
10842
+ // Listening sockets only, never our own PID doctor has already
10843
+ // probed this port over HTTP, so a bare lsof reports doctor's own
10844
+ // process as the squatter and tells the operator to kill it
10845
+ // (flair#905; see parseListeningPids).
10846
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
10847
+ if (pids.length > 0) {
10848
+ const lsof = pids.join(" ");
9818
10849
  console.log(` ${render.icons.error} Nothing responding on port ${port} ${render.wrap(render.c.dim, `(port occupied by PID ${lsof})`)}`);
9819
10850
  console.log(` ${render.wrap(render.c.dim, "Fix:")} kill ${lsof} && flair restart`);
9820
10851
  }
@@ -9936,9 +10967,8 @@ program
9936
10967
  // (see its doc comment) now reuses the existing password instead, so this
9937
10968
  // remedy is safe to follow on a working install.
9938
10969
  try {
9939
- const harperConfigPath = join(defaultDataDir(), "harper-config.yaml");
9940
- if (existsSync(harperConfigPath)) {
9941
- const harperConfig = parseYaml(readFileSync(harperConfigPath, "utf-8")) || {};
10970
+ const harperConfig = readHarperConfig(defaultDataDir());
10971
+ if (harperConfig) {
9942
10972
  const opsPortValue = harperConfig?.operationsApi?.network?.port;
9943
10973
  const bind = detectOpsApiAllInterfacesBind(opsPortValue);
9944
10974
  if (bind.allInterfaces) {
@@ -10074,6 +11104,16 @@ program
10074
11104
  else {
10075
11105
  let claudeCodeAgentId;
10076
11106
  let anyKnownAgentId;
11107
+ // `doctor --fix` writes client configs through the same wire functions
11108
+ // init does, so it owes the user the same warning when the spec it would
11109
+ // write cannot be pinned (flair#907).
11110
+ if (autoFix) {
11111
+ const pinWarning = unpinnedSpecWarning();
11112
+ if (pinWarning) {
11113
+ for (const line of pinWarning.split("\n"))
11114
+ console.log(` ${render.icons.warn} ${line}`);
11115
+ }
11116
+ }
10077
11117
  for (const client of detectedClients) {
10078
11118
  const block = readClientMcpBlock(client.id, homedir());
10079
11119
  if (client.id === "claude-code" && block.agentId)
@@ -10392,9 +11432,30 @@ program
10392
11432
  if (migBlock.cyclePhase === "pre-hash") {
10393
11433
  console.log(`${indent}${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
10394
11434
  }
11435
+ // flair#812: the boot trigger sets `scheduled` synchronously at
11436
+ // module load, so `idle` means resources/migration-boot.js never
11437
+ // loaded in the serving process — NO migration will ever run on
11438
+ // this instance, which is precisely the failure that went unnoticed
11439
+ // because a skipped cycle looked identical to a clean one.
11440
+ if (migBlock.cyclePhase === "idle") {
11441
+ console.log(`${indent}${render.icons.error} Migration boot cycle never fired on this instance — no migration will run until this is resolved. Check the instance log for [flair-migrations] and confirm the running build ships dist/resources/migration-boot.js.`);
11442
+ issues++;
11443
+ }
11444
+ // A cycle that reached a terminal phase carrying an error explains
11445
+ // itself here rather than only in the process log — the reason
11446
+ // string names the paths tried and the remedy.
11447
+ if (migBlock.lastCycleError) {
11448
+ console.log(`${indent}${render.icons.error} Last migration cycle did not complete: ${migBlock.lastCycleError}`);
11449
+ issues++;
11450
+ }
10395
11451
  for (const m of migBlock.migrations) {
10396
11452
  if (m.state === "completed") {
10397
- console.log(`${indent}${render.icons.ok} ${m.id}: completed`);
11453
+ // flair#812: a `reason` on a COMPLETED migration means the
11454
+ // runner short-circuited it from the (hand-editable) state file
11455
+ // rather than verifying the corpus this boot. Print it, so an
11456
+ // unverified claim is never rendered as a verified one.
11457
+ const note = m.reason ? ` ${render.wrap(render.c.dim, `(${m.reason})`)}` : "";
11458
+ console.log(`${indent}${render.icons.ok} ${m.id}: completed${note}`);
10398
11459
  }
10399
11460
  else if (m.state === "halted" || m.state === "failed") {
10400
11461
  console.log(`${indent}${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
@@ -11473,6 +12534,17 @@ sessionSnapshot
11473
12534
  process.exit(1);
11474
12535
  }
11475
12536
  mkdirSync(targetDir, { recursive: true, mode: 0o700 });
12537
+ // Deliberately NOT preservePaths, and deliberately NOT routed through
12538
+ // extractSnapshotSafely. --snapshot is an operator-supplied path, so
12539
+ // provenance here is no more controlled than the data-dir restore's — the
12540
+ // difference is the flag, not the trust. node-tar's defaults keep their
12541
+ // own containment: leading "/" stripped from entry paths, ".." entries
12542
+ // dropped, and no writing through a symlink (including one created
12543
+ // earlier in the same archive). Verified against the pinned tar (7.5.20)
12544
+ // on all four cases, each contained, with a benign control entry landing
12545
+ // to prove the archives parsed. Add `preservePaths` here and that
12546
+ // containment disappears — this call would then need extractSnapshotSafely,
12547
+ // exactly as the data-dir restore does. See src/lib/safe-snapshot-extract.ts.
11476
12548
  await tarExtract({ file: snapshotPath, cwd: targetDir });
11477
12549
  console.log(targetDir);
11478
12550
  console.error(` extracted to: ${targetDir}`);
@@ -13989,6 +15061,8 @@ if (import.meta.main) {
13989
15061
  await runCli();
13990
15062
  }
13991
15063
  // ─── Exported for testing ─────────────────────────────────────────────────────
13992
- export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
15064
+ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost,
15065
+ // Harper's own config — the per-instance port record (flair#914)
15066
+ harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
13993
15067
  // launchd label (flair#693)
13994
15068
  LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };