@tpsdev-ai/flair 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1434 -288
  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/AdminPrincipals.js +10 -1
  15. package/dist/resources/Agent.js +105 -11
  16. package/dist/resources/AgentSeed.js +10 -2
  17. package/dist/resources/MemoryBootstrap.js +7 -8
  18. package/dist/resources/MemoryUsage.js +18 -0
  19. package/dist/resources/Presence.js +8 -1
  20. package/dist/resources/SemanticSearch.js +17 -45
  21. package/dist/resources/abstention.js +1 -1
  22. package/dist/resources/agent-admin.js +149 -0
  23. package/dist/resources/agent-auth.js +98 -5
  24. package/dist/resources/auth-middleware.js +92 -19
  25. package/dist/resources/embeddings-boot.js +10 -12
  26. package/dist/resources/embeddings-provider.js +10 -7
  27. package/dist/resources/health.js +24 -19
  28. package/dist/resources/in-process.js +234 -0
  29. package/dist/resources/mcp-handler.js +14 -4
  30. package/dist/resources/mcp-tools.js +23 -17
  31. package/dist/resources/migration-boot.js +80 -10
  32. package/dist/resources/migrations/data-dir.js +205 -0
  33. package/dist/resources/migrations/progress.js +33 -0
  34. package/dist/resources/migrations/runner.js +29 -2
  35. package/dist/resources/migrations/state.js +13 -2
  36. package/dist/resources/models-dir.js +18 -9
  37. package/dist/resources/presence-internal.js +6 -1
  38. package/dist/resources/record-owner-guard.js +149 -0
  39. package/dist/resources/semantic-retrieval-core.js +5 -4
  40. package/dist/src/lib/scheduler-platform.js +128 -0
  41. package/dist/src/lib/xml-escape.js +54 -0
  42. package/dist/src/rem/scheduler.js +35 -87
  43. package/docs/deploying-on-fabric.md +267 -0
  44. package/docs/deployment.md +5 -0
  45. package/docs/embedding-in-a-harper-app.md +303 -0
  46. package/docs/federation.md +61 -4
  47. package/docs/integrations.md +3 -0
  48. package/docs/mcp-clients.md +16 -7
  49. package/docs/quickstart.md +80 -54
  50. package/docs/releasing.md +72 -38
  51. package/docs/supply-chain-policy.md +36 -0
  52. package/docs/troubleshooting.md +24 -0
  53. package/docs/upgrade.md +99 -4
  54. package/package.json +1 -11
  55. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  56. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  57. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  58. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  59. package/dist/resources/rerank-provider.js +0 -569
  60. 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,45 @@ 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
+ * The "create" rung is load-bearing rather than hypothetical (flair#928).
504
+ * `init`'s `--port` used to carry a commander default of DEFAULT_PORT, so
505
+ * `opts.port` was ALWAYS set there and this function returned on the first rung
506
+ * — including when re-initialising an instance already serving a different
507
+ * port, which was silently renumbered to DEFAULT_PORT. Commander cannot tell
508
+ * "the user passed the default" from "the user passed nothing", so the default
509
+ * was the bug. It is gone; a bare `init` now falls through to the ladder, and
510
+ * only a directory no instance has ever been served from reaches DEFAULT_PORT.
511
+ *
512
+ * Nothing is copied, migrated or written here. Harper's config already IS the
513
+ * per-instance record, so there is no second file to keep in step — which is
514
+ * the whole reason the port is read from it. The per-user file stays exactly as
515
+ * it is for the default install that has always relied on it.
516
+ */
517
+ function resolveHttpPort(opts, mode = "address") {
301
518
  if (opts.port !== undefined && opts.port !== null) {
302
519
  const n = Number(opts.port);
303
520
  if (!isNaN(n) && n > 0)
@@ -309,7 +526,28 @@ function resolveHttpPort(opts) {
309
526
  if (m)
310
527
  return Number(m[1]);
311
528
  }
312
- return readPortFromConfig() ?? DEFAULT_PORT;
529
+ const dataDir = instanceDataDir(opts);
530
+ // 1. Harper's own config for this instance. Once Harper has written one it is
531
+ // the only answer — it is maintained by the process that binds the socket.
532
+ const harperPort = readPortFromHarperConfig(dataDir);
533
+ if (harperPort !== null)
534
+ return harperPort;
535
+ // 2. Harper has written nothing here. For the DEFAULT install the per-user
536
+ // file is about this instance by definition — a single-instance install is
537
+ // the overwhelmingly common case and its recorded port is correct — so it
538
+ // stays the answer for existing installs. Read, never rewritten.
539
+ if (isDefaultDataDir(dataDir)) {
540
+ return readPortFromConfig() ?? DEFAULT_PORT;
541
+ }
542
+ // 3. A non-default directory Harper has never written to. `init` may go on to
543
+ // create the instance; nothing else may guess.
544
+ if (mode === "create")
545
+ return DEFAULT_PORT;
546
+ throw new Error(`cannot determine which port ${dataDir} serves: Harper has written no config there `
547
+ + `(no ${HARPER_CONFIG_FILENAMES.join(" or ")}), so that directory does not hold an instance yet. `
548
+ + `Falling back to ~/.flair/config.yaml would answer with the port of a DIFFERENT instance — that file records one port `
549
+ + `for the whole user, not one per data directory. `
550
+ + `Pass --port <port> to say which port that instance serves, or run 'flair init --data-dir ${dataDir} --port <port>' to create it.`);
313
551
  }
314
552
  // Unified base URL resolution. Precedence:
315
553
  // --target > --url > FLAIR_TARGET env > FLAIR_URL env > http://127.0.0.1:<resolveHttpPort>
@@ -331,6 +569,15 @@ function resolveAgentIdOrEnv(opts) {
331
569
  return opts.agent || process.env.FLAIR_AGENT_ID || null;
332
570
  }
333
571
  // Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
572
+ //
573
+ // Deliberately NOT routed through Harper's per-instance config the way
574
+ // resolveHttpPort is (flair#914). The last rung couples the ops port to the HTTP
575
+ // port the CALLER named, and that coupling is what makes `flair init --port N`
576
+ // land its ops API at N-1 on a host that already runs an instance. A Harper rung
577
+ // above it would answer that call with the EXISTING instance's ops port instead,
578
+ // which is the bug flair#914 fixed pointing the other way. The ops port becomes
579
+ // per-instance when the lifecycle commands grow `--data-dir` and there is a
580
+ // named instance to attribute it to.
334
581
  function resolveOpsPort(opts) {
335
582
  if (opts.opsPort !== undefined && opts.opsPort !== null) {
336
583
  const n = Number(opts.opsPort);
@@ -812,6 +1059,36 @@ function writeConfig(port, opsPort, opsBind, path = configPath()) {
812
1059
  body += `opsBind: ${resolvedOpsBind}\n`;
813
1060
  writeFileSync(path, body);
814
1061
  }
1062
+ /**
1063
+ * Persist the coordinates of the instance served from `dataDir` (flair#914).
1064
+ *
1065
+ * Harper records its OWN coordinates — `rootPath`, `http.port`,
1066
+ * `operationsApi.network.port` — into `<dataDir>/harper-config.yaml` on every
1067
+ * boot, and that is what `resolveHttpPort` reads. So there is nothing
1068
+ * instance-local for flair to write: the instance already describes itself, and
1069
+ * a second copy is a drift source, not a record (flair#937).
1070
+ *
1071
+ * What remains is the per-user file, and the GUARD on it is the whole of the
1072
+ * flair#914 fix. `init` used to write `~/.flair/config.yaml` unconditionally, so
1073
+ * `flair init --data-dir X --port P` overwrote whatever port the DEFAULT install
1074
+ * had recorded, and every later lookup answered with P. Writing it only for the
1075
+ * default install is what makes one instance's `init` unable to renumber
1076
+ * another's.
1077
+ *
1078
+ * The guard lives HERE rather than at the call sites: a caller that forgets it
1079
+ * reintroduces flair#914 silently, and there is no test that would notice at the
1080
+ * call site it was forgotten in.
1081
+ *
1082
+ * It keeps the readers that have no `--data-dir` of their own
1083
+ * (`readPortFromConfig` — `flair uninstall`, the `rem` scheduler, `api()`,
1084
+ * doctor's config line) correct for the one instance they are able to address.
1085
+ * Those read Harper's config directly when the lifecycle commands grow the flag,
1086
+ * and this file goes with them.
1087
+ */
1088
+ function persistDefaultInstallCoordinates(dataDir, port, opsPort, opsBind) {
1089
+ if (isDefaultDataDir(dataDir))
1090
+ writeConfig(port, opsPort, opsBind);
1091
+ }
815
1092
  function privKeyPath(agentId, keysDir) {
816
1093
  return join(keysDir, `${agentId}.key`);
817
1094
  }
@@ -822,30 +1099,135 @@ function flairPackageDir() {
822
1099
  // dist/cli.js → package root (one level up from dist/)
823
1100
  return join(import.meta.dirname ?? __dirname, "..");
824
1101
  }
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 = [];
1102
+ /**
1103
+ * Harper npm package names this build knows about, newest-convention first.
1104
+ *
1105
+ * flair#870: flair depends on the BARE `harper` package name. The scoped
1106
+ * `@harperfast/harper` name is kept as a fallback so an in-place upgrade over
1107
+ * a pre-#870 install (whose node_modules still holds the scoped copy, and
1108
+ * whose Harper is the one currently serving the data dir) keeps booting until
1109
+ * the next clean install. Bare name is tried first so a tree that has BOTH
1110
+ * resolves to the one flair actually declares.
1111
+ *
1112
+ * This list is a FALLBACK, not the authority — see declaredHarperPackageNames.
1113
+ */
1114
+ const KNOWN_HARPER_PACKAGE_NAMES = ["harper", "@harperfast/harper"];
1115
+ /**
1116
+ * The Harper package name(s) the install at `packageRoot` actually declares,
1117
+ * read fresh off disk on every call.
1118
+ *
1119
+ * flair#905: `flair upgrade` replaces this package tree while the CLI is
1120
+ * executing out of it, so anything the running code "knows" about the tree
1121
+ * describes the tree that WAS there, not the one that is. That is exactly how
1122
+ * the 0.29.0 → 0.30.0 upgrade broke: 0.30.0 renamed its Harper dependency
1123
+ * `@harperfast/harper` → `harper` (flair#870), 0.29.0's resolver only ever
1124
+ * probed the scoped name, and the post-swap tree no longer had it — so the
1125
+ * restart reported "Harper binary not found" against an install that was
1126
+ * perfectly intact. Deriving the name from the package.json sitting at
1127
+ * `packageRoot` reads the POST-swap truth instead of a compiled-in guess, so a
1128
+ * future rename cannot break the same way.
1129
+ *
1130
+ * Matches `harper` and `@scope/harper` only — never `harper-fabric-embeddings`
1131
+ * or any other `harper`-prefixed dependency.
1132
+ */
1133
+ export function declaredHarperPackageNames(packageRoot) {
1134
+ try {
1135
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8"));
1136
+ return Object.keys(pkg.dependencies ?? {}).filter((n) => /^(@[^/]+\/)?harper$/.test(n));
1137
+ }
1138
+ catch {
1139
+ return [];
1140
+ }
1141
+ }
1142
+ /**
1143
+ * Locate a Harper binary under `roots`, and report every path that was tried.
1144
+ *
1145
+ * The `searched` list is not decoration: "Harper binary not found" with no
1146
+ * paths is unactionable, and the message it replaced named the wrong remedy
1147
+ * (flair#905). Callers surface these paths verbatim.
1148
+ */
1149
+ export function resolveHarperBin(roots) {
1150
+ const searched = [];
840
1151
  for (const root of roots) {
841
- for (const segs of pkgSegments) {
842
- candidates.push(join(root, "node_modules", ...segs, "dist", "bin", "harper.js"));
1152
+ const names = [...declaredHarperPackageNames(root), ...KNOWN_HARPER_PACKAGE_NAMES];
1153
+ for (const name of names) {
1154
+ const candidate = join(root, "node_modules", ...name.split("/"), "dist", "bin", "harper.js");
1155
+ if (searched.includes(candidate))
1156
+ continue;
1157
+ searched.push(candidate);
1158
+ if (existsSync(candidate))
1159
+ return { path: candidate, searched };
843
1160
  }
844
1161
  }
845
- for (const c of candidates)
846
- if (existsSync(c))
847
- return c;
848
- return null;
1162
+ return { path: null, searched };
1163
+ }
1164
+ /**
1165
+ * Roots under which a Harper install is looked for: this package's own
1166
+ * directory (dist/cli.js → ../node_modules/...) then the caller's cwd.
1167
+ *
1168
+ * Recomputed per call rather than captured at module load — see
1169
+ * declaredHarperPackageNames for why anything cached across a package swap is
1170
+ * a bug waiting to happen.
1171
+ */
1172
+ function harperSearchRoots() {
1173
+ return [flairPackageDir(), process.cwd()];
1174
+ }
1175
+ /**
1176
+ * The operator-facing message for "we looked for Harper and it isn't there".
1177
+ *
1178
+ * flair#905: the text this replaces was `Harper binary not found. Run 'flair
1179
+ * init' first.` — which is wrong twice over on an initialised instance. It
1180
+ * names no path, so there is nothing to check; and `flair init` cannot fix a
1181
+ * missing Harper (init resolves the same binary the same way) while it CAN be
1182
+ * mistaken for "re-provision my instance" by someone reading it at 3am. A
1183
+ * missing binary is an incomplete package tree, so the remedy is a reinstall.
1184
+ */
1185
+ export function harperBinNotFoundMessage(searched) {
1186
+ return [
1187
+ "Harper binary not found — this Flair package tree looks incomplete.",
1188
+ " Searched:",
1189
+ ...searched.map((p) => ` ${p}`),
1190
+ " Reinstall the package: npm install -g @tpsdev-ai/flair@latest",
1191
+ " Your data in ~/.flair is untouched — `flair init` will NOT fix this (it resolves the same binary).",
1192
+ ].join("\n");
1193
+ }
1194
+ function harperBin() {
1195
+ return resolveHarperBin(harperSearchRoots()).path;
1196
+ }
1197
+ /**
1198
+ * Parse `lsof -ti` output into PIDs safe to signal as "the thing on this port".
1199
+ *
1200
+ * Two filters, both load-bearing (flair#800, extended by flair#905):
1201
+ *
1202
+ * A bare `lsof -ti :<port>` matches CLIENT sockets as well as the listening
1203
+ * server — including the calling process's OWN keep-alive connections, left by
1204
+ * anything that has spoken HTTP to that port in this run (the version-handshake
1205
+ * nudge on every command, a health probe, a caller's `fetch`). `-sTCP:LISTEN`
1206
+ * on the command narrows it to the server; this function is the second half:
1207
+ * never return our own PID, whatever lsof said. flair#800 was that exact
1208
+ * self-SIGTERM in `flair upgrade`'s stop step; the same unfiltered pattern
1209
+ * survived in `flair stop`, `flair uninstall` and `flair doctor`, where it can
1210
+ * kill the caller, kill an unrelated client, or tell an operator to `kill` the
1211
+ * PID of the shell they are typing into.
1212
+ */
1213
+ export function parseListeningPids(lsofOutput, selfPid) {
1214
+ return (lsofOutput ?? "")
1215
+ .trim()
1216
+ .split("\n")
1217
+ .map((s) => Number(s.trim()))
1218
+ .filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== selfPid);
1219
+ }
1220
+ /**
1221
+ * PIDs LISTENING on `port`, never this process. Empty when nothing is there or
1222
+ * lsof is unavailable — callers treat that as "not running".
1223
+ */
1224
+ function listeningPidsOnPort(port, exec) {
1225
+ try {
1226
+ return parseListeningPids(exec(`lsof -ti :${port} -sTCP:LISTEN`), process.pid);
1227
+ }
1228
+ catch {
1229
+ return [];
1230
+ }
849
1231
  }
850
1232
  // ─── Helpers ─────────────────────────────────────────────────────────────────
851
1233
  function b64(bytes) {
@@ -1991,51 +2373,26 @@ async function runSoulWizard(agentId) {
1991
2373
  return entries.filter(([, v]) => v.trim().length > 0);
1992
2374
  }
1993
2375
  // ─── 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
- }
2376
+ // This CLI's own version. Resolution lives in src/lib/mcp-spec.ts so that the
2377
+ // client-wiring code (src/install/clients.ts) shares ONE definition of both
2378
+ // the version and the MCP spec derived from it — see flair#907 for what
2379
+ // happened when the pin lived next to a single call site.
2380
+ const __pkgVersion = flairCliVersion();
2381
+ // mcpServerSpec now lives in src/lib/mcp-spec.ts alongside the version
2382
+ // resolution it depends on, so every writer shares it. Re-exported here
2383
+ // because it is part of this module's public surface (tests and callers
2384
+ // import it from src/cli.ts).
2385
+ export { mcpServerSpec };
2037
2386
  const program = new Command();
2038
2387
  program.name("flair").version(__pkgVersion, "-v, --version");
2388
+ // flair#926: an option declared on a parent is consumed by the PARENT even when
2389
+ // it appears after a subcommand name, so a subcommand must NOT redeclare one —
2390
+ // the duplicate never receives a value, it only makes the flag look local.
2391
+ // Removing those duplicates would have hidden working flags from the
2392
+ // subcommand's help, so the help is taught to show inherited options instead.
2393
+ // This is commander's own answer to the problem, and it applies to every
2394
+ // subcommand at once rather than one hand-maintained list of exceptions.
2395
+ program.configureHelp({ showGlobalOptions: true });
2039
2396
  // ─── CLI↔server version handshake (flair#695 §B) ────────────────────────────
2040
2397
  // Every command invocation gets a cheap, cached (~60s), short-timeout check
2041
2398
  // of the running server's version against this CLI's own — catches the
@@ -2084,7 +2441,12 @@ program
2084
2441
  .description("One-command Flair setup — bootstrap the instance, register an agent, and wire MCP clients")
2085
2442
  .option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
2086
2443
  .option("--agent <id>", "Alias for --agent-id")
2087
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
2444
+ // No commander default (flair#928). A default here is indistinguishable from
2445
+ // the user typing it, so a BARE `flair init` used to state DEFAULT_PORT and
2446
+ // renumber an instance already serving a custom one. Absent means absent, and
2447
+ // resolveHttpPort's "create" ladder supplies DEFAULT_PORT for a genuinely new
2448
+ // instance — which is the only case that ever wanted one.
2449
+ .option("--port <port>", "Harper HTTP port (default: this instance's current port, or 19926 for a new one)")
2088
2450
  .option("--ops-port <port>", "Harper operations API port")
2089
2451
  .option("--ops-bind <addr>", "Harper ops API bind address (env: FLAIR_OPS_BIND; default: 127.0.0.1 loopback-only for single-host — pass e.g. 0.0.0.0 for multi-host/Fabric remote admin)")
2090
2452
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
@@ -2284,11 +2646,26 @@ program
2284
2646
  return;
2285
2647
  }
2286
2648
  // ── Local init (full one-command setup) ──
2287
- const httpPort = resolveHttpPort(opts);
2288
- const opsPort = resolveOpsPort(opts);
2289
- const opsBindHost = resolveOpsBindHost(opts);
2290
2649
  const keysDir = opts.keysDir ?? defaultKeysDir();
2291
2650
  const dataDir = opts.dataDir ?? defaultDataDir();
2651
+ // "create" mode (flair#914): init ESTABLISHES an instance, so a data
2652
+ // directory with no recorded port is a new instance taking the default,
2653
+ // not the hard error every other caller gets — otherwise `flair init
2654
+ // --data-dir <new>` could never succeed. `dataDir` is resolved first so
2655
+ // this is never asked before the instance is known.
2656
+ //
2657
+ // flair#928: `--port` deliberately carries NO commander default, so a bare
2658
+ // `init` reaches the ladder below instead of restating DEFAULT_PORT and
2659
+ // renumbering an instance that already serves a custom port. `init` is
2660
+ // `flair doctor`'s standing remedy and is recommended in ten places, so the
2661
+ // command handed to an operator whose install is already wrong must not be
2662
+ // the one that moves their port.
2663
+ const httpPort = resolveHttpPort(opts, "create");
2664
+ // The already-resolved port is handed to the ops resolver rather than
2665
+ // letting it re-resolve — its last rung is `resolveHttpPort(opts) - 1`,
2666
+ // which would ask the same question again in "address" mode.
2667
+ const opsPort = resolveOpsPort({ ...opts, port: httpPort });
2668
+ const opsBindHost = resolveOpsBindHost(opts);
2292
2669
  // Resolve MCP client selection (union of init's auto-wire + the multi-client
2293
2670
  // detection/wiring that the front-door command provides). `--no-mcp` sets
2294
2671
  // opts.mcp === false (commander negates the flag). Validate an explicit
@@ -2439,11 +2816,14 @@ program
2439
2816
  }
2440
2817
  mkdirSync(dataDir, { recursive: true });
2441
2818
  // Detect whether Harper has already been installed in this data dir.
2442
- // harper-config.yaml is created during install — its presence means
2819
+ // Harper's config is created during install — its presence means
2443
2820
  // install already ran. Re-running install against an existing data dir
2444
2821
  // 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"));
2822
+ // database before the env is initialized). Goes through
2823
+ // harperConfigPath so an install predating Harper's config-file rename
2824
+ // (harperdb-config.yaml) is still recognised as installed rather than
2825
+ // re-installed over.
2826
+ const alreadyInstalled = harperConfigPath(dataDir) !== null;
2447
2827
  const opsSocket = join(dataDir, "operations-server");
2448
2828
  // authorizeLocal: false (flair#654) — a credential-less loopback ops-API
2449
2829
  // request is no longer auto-authorized as super_user. Every ops-API
@@ -2591,40 +2971,19 @@ program
2591
2971
  // backend on every KeepAlive restart in-process. See that file's
2592
2972
  // header (flair#694) for why this replaced the old HARPER_CONFIG
2593
2973
  // 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>`;
2974
+ const plist = buildLaunchdPlist({
2975
+ label,
2976
+ execPath: process.execPath,
2977
+ harperBinPath,
2978
+ workingDirectory: flairPackageDir(),
2979
+ dataDir,
2980
+ modelsDir,
2981
+ setConfig,
2982
+ adminUser,
2983
+ adminPass,
2984
+ httpPort,
2985
+ opsNetworkPort: opsNetworkPortValue(opsBindHost, opsPort),
2986
+ });
2628
2987
  writeFileSync(plistPath, plist);
2629
2988
  console.log("Launchd service registered ✓");
2630
2989
  }
@@ -2638,7 +2997,12 @@ program
2638
2997
  // survives an `--ops-bind` choice past the next restart. It also runs on
2639
2998
  // the already-running path (where init skips the spawn entirely), which is
2640
2999
  // what makes doctor's `flair init && flair restart` remedy actually apply.
2641
- writeConfig(httpPort, opsPort, opsBindHost);
3000
+ // flair#914: written ONLY when this init is about the default install, so a
3001
+ // second instance can no longer overwrite the first's recorded port. This
3002
+ // instance's own port needs no write here — Harper has just recorded it in
3003
+ // <dataDir>/harper-config.yaml, which is what resolveHttpPort reads (see
3004
+ // persistDefaultInstallCoordinates).
3005
+ persistDefaultInstallCoordinates(dataDir, httpPort, opsPort, opsBindHost);
2642
3006
  if (agentId) {
2643
3007
  // Generate or reuse keypair
2644
3008
  mkdirSync(keysDir, { recursive: true });
@@ -2759,13 +3123,35 @@ program
2759
3123
  // skips wiring entirely; `--client <name>` targets one client; the
2760
3124
  // default (no flag) wires every detected client.
2761
3125
  const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
3126
+ // `wired` is the load-bearing field (flair#906): it separates "a config
3127
+ // file was actually written" from "we printed something and moved on".
3128
+ // Both outcomes used to be pushed here indistinguishably as far as the
3129
+ // user was concerned, so `--client all` reported success for a client it
3130
+ // had not wired.
2762
3131
  const wiringResults = [];
3132
+ // Human-readable labels + the clients `--client all` passed over because
3133
+ // they aren't installed, so the closing summary can account for every
3134
+ // client the user asked for rather than only the ones we tried.
3135
+ const clientLabels = new Map();
3136
+ const skippedUndetected = [];
2763
3137
  if (!noMcp && clientOpt !== "none") {
3138
+ // A spec we cannot pin is a security property quietly downgraded, so
3139
+ // say so BEFORE writing it and again in the summary below (flair#907).
3140
+ // stderr: this must survive `flair init | tee`, and it is a warning,
3141
+ // not part of the command's normal output.
3142
+ const pinWarning = unpinnedSpecWarning();
3143
+ if (pinWarning) {
3144
+ console.error("");
3145
+ for (const line of pinWarning.split("\n"))
3146
+ console.error(` ⚠ ${line}`);
3147
+ }
2764
3148
  // Determine which clients to wire.
2765
3149
  let clients = detectClients();
2766
3150
  if (selectedClients.length > 0) {
2767
3151
  clients = clients.filter(c => selectedClients.includes(c.id));
2768
3152
  }
3153
+ for (const c of clients)
3154
+ clientLabels.set(c.id, c.label);
2769
3155
  const detected = clients.filter(c => c.detected);
2770
3156
  if (!clientOpt) {
2771
3157
  if (detected.length === 0) {
@@ -2780,6 +3166,15 @@ program
2780
3166
  : selectedClients.length > 0
2781
3167
  ? selectedClients
2782
3168
  : clients.filter(c => c.detected).map(c => c.id);
3169
+ // `--client all` is a promise about every client, so the ones it
3170
+ // passed over have to be accounted for too — silently omitting them
3171
+ // is how "all" reports success for work it never did (flair#906).
3172
+ if (clientOpt === "all") {
3173
+ for (const c of clients) {
3174
+ if (!c.detected)
3175
+ skippedUndetected.push(c.label);
3176
+ }
3177
+ }
2783
3178
  for (const clientId of toWire) {
2784
3179
  if (clientId === "claude-code") {
2785
3180
  // Claude Code gets real auto-wiring into ~/.claude.json (zero-install
@@ -2797,32 +3192,45 @@ program
2797
3192
  // provenance.claimed.client = "claude-code".
2798
3193
  env: { ...mcpEnv, FLAIR_CLIENT: "claude-code" },
2799
3194
  };
3195
+ // ~/.claude.json exists once Claude Code has been RUN, not once it
3196
+ // is installed — so gating the write on it skipped every user who
3197
+ // installed Claude Code and Flair in the same sitting (flair#906).
3198
+ // The file is Claude Code's own and creating it with a single
3199
+ // `mcpServers` key is exactly what `claude mcp add` does, so an
3200
+ // absent file is created rather than turned into a printed snippet
3201
+ // the user has to notice and act on.
2800
3202
  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
- }
3203
+ const claudeJsonExisted = existsSync(claudeJsonPath);
3204
+ const claudeJson = claudeJsonExisted
3205
+ ? JSON.parse(readFileSync(claudeJsonPath, "utf-8"))
3206
+ : {};
3207
+ const existing = claudeJson.mcpServers?.flair;
3208
+ if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
3209
+ console.log(` ✓ Claude Code already wired in ~/.claude.json`);
3210
+ wiringResults.push({ client: "claude-code", message: "already wired", wired: true });
2815
3211
  }
2816
3212
  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)" });
3213
+ claudeJson.mcpServers = claudeJson.mcpServers || {};
3214
+ claudeJson.mcpServers.flair = flairMcpConfig;
3215
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
3216
+ const how = claudeJsonExisted ? "wired in ~/.claude.json" : "wired in ~/.claude.json (created)";
3217
+ console.log(` ✓ Claude Code ${how} (restart Claude Code to pick it up)`);
3218
+ wiringResults.push({
3219
+ client: "claude-code",
3220
+ message: claudeJsonExisted ? "wired ~/.claude.json" : "created and wired ~/.claude.json",
3221
+ wired: true,
3222
+ });
2820
3223
  }
2821
3224
  }
2822
- catch {
3225
+ catch (err) {
3226
+ // Only a genuine read/parse/write failure lands here now (bad
3227
+ // permissions, malformed existing JSON) — never merely "the file
3228
+ // does not exist yet".
3229
+ const reason = err instanceof Error ? err.message : String(err);
3230
+ console.log(` ${render.icons.warn} Claude Code: could not write ~/.claude.json (${reason})`);
2823
3231
  console.log(` MCP config (add manually to ~/.claude.json):`);
2824
3232
  console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
2825
- wiringResults.push({ client: "claude-code", message: "snippet printed" });
3233
+ wiringResults.push({ client: "claude-code", message: `snippet printed (${reason})`, wired: false });
2826
3234
  }
2827
3235
  // ── CLAUDE.md bootstrap line (flair#597) ──────────────────────────
2828
3236
  // The MCP block alone isn't a working setup — Claude Code also needs
@@ -2866,7 +3274,7 @@ program
2866
3274
  break;
2867
3275
  default: result = { ok: false, message: `Unknown client: ${clientId}` };
2868
3276
  }
2869
- wiringResults.push({ client: clientId, message: result.message });
3277
+ wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
2870
3278
  console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
2871
3279
  }
2872
3280
  }
@@ -2944,6 +3352,34 @@ program
2944
3352
  console.log(" Use --skip-smoke to bypass.");
2945
3353
  }
2946
3354
  }
3355
+ // ── MCP wiring summary (flair#906) ───────────────────────────────────
3356
+ // LAST thing init prints, deliberately. Every fact below was already
3357
+ // available mid-run, but a client that was not wired appeared only as a
3358
+ // snippet in the middle of a wall of output, after a success line — so
3359
+ // the user's next action was to open their client and find nothing
3360
+ // there, with no reason to suspect init. A client the user asked for and
3361
+ // did NOT get must still be on screen when the command finishes.
3362
+ if (!noMcp && clientOpt !== "none") {
3363
+ // The pin warning repeats here for the same reason the summary exists:
3364
+ // a warning only in scrollback is a warning the user acts on never.
3365
+ const summaryLines = renderWiringSummary(wiringResults, {
3366
+ labels: clientLabels,
3367
+ skippedUndetected,
3368
+ rewireHint: `flair init --agent ${agentId} --client all`,
3369
+ unpinned: unpinnedSpecWarning() !== null,
3370
+ });
3371
+ for (const line of summaryLines) {
3372
+ const icon = line.level === "ok" ? render.icons.ok :
3373
+ line.level === "error" ? render.icons.error :
3374
+ line.level === "warn" ? render.icons.warn :
3375
+ line.level === "muted" ? render.icons.bullet :
3376
+ null;
3377
+ if (line.level === "heading")
3378
+ console.log(`\n ${line.text}`);
3379
+ else
3380
+ console.log(` ${icon} ${line.text}`);
3381
+ }
3382
+ }
2947
3383
  }
2948
3384
  else {
2949
3385
  const httpUrl = `http://127.0.0.1:${httpPort}`;
@@ -3182,7 +3618,8 @@ agent
3182
3618
  }
3183
3619
  if (out.defaultTrustTier)
3184
3620
  console.log(render.kv("trust tier", String(out.defaultTrustTier)));
3185
- if (out.admin)
3621
+ // flair#941 — read the authority, not the mirror. See `principal show`.
3622
+ if (agentRecordIsAdmin(out))
3186
3623
  console.log(render.kv("admin", render.wrap(render.c.magenta, "yes")));
3187
3624
  if (out.runtime)
3188
3625
  console.log(render.kv("runtime", String(out.runtime)));
@@ -4334,6 +4771,23 @@ mcp
4334
4771
  // ─── flair principal ─────────────────────────────────────────────────────────
4335
4772
  // 1.0 identity management. The Principal model extends Agent — this is the
4336
4773
  // preferred CLI surface for managing identities going forward.
4774
+ /**
4775
+ * The exact `role` value that denotes a flair administrator, and the predicate
4776
+ * that reads it.
4777
+ *
4778
+ * DUPLICATED FROM resources/agent-admin.ts on purpose — the same deliberate
4779
+ * copy as the federation crypto helpers above: src/cli.ts must not import from
4780
+ * resources/, because those imports don't survive npm packaging. The two must
4781
+ * stay in sync.
4782
+ *
4783
+ * flair#941: `role` is the authority and `admin` is its mirror. The CLI used to
4784
+ * both write and display ONLY the mirror, so `principal add --admin` created a
4785
+ * principal the gate refuses and `principal show` printed "admin: yes" for it.
4786
+ */
4787
+ const ADMIN_ROLE = "admin";
4788
+ function agentRecordIsAdmin(record) {
4789
+ return record?.role === ADMIN_ROLE;
4790
+ }
4337
4791
  const principal = program.command("principal").description("Manage principals (humans and agents)");
4338
4792
  principal
4339
4793
  .command("add <id>")
@@ -4403,6 +4857,11 @@ principal
4403
4857
  status: "active",
4404
4858
  publicKey: pubKeyB64url,
4405
4859
  defaultTrustTier: trustTier,
4860
+ // flair#941 — write BOTH. This is an ops-API upsert, so the Agent
4861
+ // resource's reconciliation never runs; writing only the `admin` mirror
4862
+ // is what made `--admin` a no-op at the gate for every principal this
4863
+ // command has ever created.
4864
+ role: isAdmin ? ADMIN_ROLE : "agent",
4406
4865
  admin: isAdmin,
4407
4866
  runtime: runtime ?? null,
4408
4867
  createdAt: new Date().toISOString(),
@@ -4455,7 +4914,10 @@ principal
4455
4914
  table: "Agent",
4456
4915
  operator: "and",
4457
4916
  conditions,
4458
- get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "admin", "runtime", "createdAt"],
4917
+ // `role` is the authority behind admin status (flair#941); the
4918
+ // projection used to omit it, so this listing could only ever report
4919
+ // the mirror.
4920
+ get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "role", "admin", "runtime", "createdAt"],
4459
4921
  }),
4460
4922
  });
4461
4923
  if (!res.ok) {
@@ -4489,7 +4951,14 @@ principal
4489
4951
  {
4490
4952
  label: "admin",
4491
4953
  key: "admin",
4492
- format: (v) => (v ? render.wrap(render.c.red, "yes") : render.wrap(render.c.dim, "no")),
4954
+ // Report the status the gate will apply, and flag a record whose two
4955
+ // fields disagree rather than picking a side silently (flair#941).
4956
+ format: (_v, row) => {
4957
+ const isAdmin = agentRecordIsAdmin(row);
4958
+ const mismatch = isAdmin !== (row.admin === true);
4959
+ const base = isAdmin ? render.wrap(render.c.red, "yes") : render.wrap(render.c.dim, "no");
4960
+ return mismatch ? `${base} ${render.wrap(render.c.yellow, "(!)")}` : base;
4961
+ },
4493
4962
  },
4494
4963
  {
4495
4964
  label: "status",
@@ -4531,8 +5000,13 @@ principal
4531
5000
  }
4532
5001
  if (result.defaultTrustTier)
4533
5002
  console.log(render.kv("trust tier", String(result.defaultTrustTier)));
4534
- if (result.admin)
5003
+ // flair#941 — read the authority, not the mirror, and say so when the two
5004
+ // disagree (only reachable via a raw table write).
5005
+ if (agentRecordIsAdmin(result))
4535
5006
  console.log(render.kv("admin", render.wrap(render.c.red, "yes")));
5007
+ if (agentRecordIsAdmin(result) !== (result.admin === true)) {
5008
+ console.log(render.kv("admin", render.wrap(render.c.yellow, `record is inconsistent (role=${result.role ?? "unset"}, admin=${result.admin ?? "unset"}) — re-issue the grant to repair`)));
5009
+ }
4536
5010
  if (result.runtime)
4537
5011
  console.log(render.kv("runtime", String(result.runtime)));
4538
5012
  if (result.email)
@@ -4924,13 +5398,56 @@ function signRequestBody(body, secretKey) {
4924
5398
  const signBodyFresh = signRequestBody;
4925
5399
  // ─── flair federation ────────────────────────────────────────────────────────
4926
5400
  const federation = program.command("federation").description("Manage federation (hub-and-spoke sync)");
5401
+ /**
5402
+ * The most recent CONTACT with any peer (max of peer.lastSyncAt), or null.
5403
+ *
5404
+ * Contact, not merge: a sync that reaches the peer and legitimately has
5405
+ * nothing to send still proves the driver ran. This is half of what lets
5406
+ * `federation status` tell "nothing is driving sync" apart from "sync is
5407
+ * running, the peer is unreachable" (flair#922) — the other half is whether
5408
+ * the service manager has a driver loaded.
5409
+ *
5410
+ * Returns null on ANY failure. A driver verdict is a diagnostic aid; it must
5411
+ * never be the reason `federation status` fails.
5412
+ */
5413
+ async function latestPeerContact(opts) {
5414
+ try {
5415
+ const target = resolveTarget(opts);
5416
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
5417
+ const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
5418
+ let best = null;
5419
+ for (const p of peers ?? []) {
5420
+ if (!p?.lastSyncAt)
5421
+ continue;
5422
+ const t = Date.parse(p.lastSyncAt);
5423
+ if (Number.isFinite(t) && (best === null || t > best))
5424
+ best = t;
5425
+ }
5426
+ return best === null ? null : new Date(best).toISOString();
5427
+ }
5428
+ catch {
5429
+ return null;
5430
+ }
5431
+ }
5432
+ /**
5433
+ * True when the CLI is pointed at the instance running on THIS machine.
5434
+ *
5435
+ * The scheduler check is inherently local — launchctl/systemctl only know
5436
+ * about jobs on the host the CLI is running on. Reporting "no driver" while
5437
+ * `--target` points at someone else's hub would be a confident claim about a
5438
+ * machine we cannot see, so the driver block is suppressed for remote targets.
5439
+ */
5440
+ function driverCheckAppliesTo(opts) {
5441
+ const target = resolveTarget(opts);
5442
+ return !target || isLocalBase(target.replace(/\/$/, ""));
5443
+ }
4927
5444
  federation
4928
5445
  .command("status")
4929
5446
  .description("Show federation status and peer connections")
4930
5447
  .option("--port <port>", "Harper HTTP port")
4931
5448
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
4932
5449
  .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)")
5450
+ .option("--json", "Emit JSON {instance, peers, driver} (also: pipe + FLAIR_OUTPUT=json)")
4934
5451
  .action(async (opts) => {
4935
5452
  const target = resolveTarget(opts);
4936
5453
  const baseUrl = target ? target.replace(/\/$/, "") : undefined;
@@ -4938,8 +5455,41 @@ federation
4938
5455
  try {
4939
5456
  const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
4940
5457
  const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
5458
+ // Driver state (flair#922). Computed from the LOCAL service manager, so
5459
+ // it is only meaningful when the CLI is pointed at the local instance.
5460
+ let driver = null;
5461
+ let assessment = null;
5462
+ if (driverCheckAppliesTo(opts)) {
5463
+ try {
5464
+ const { schedulerStatus, assessDriver } = await import("./federation/scheduler.js");
5465
+ driver = schedulerStatus();
5466
+ let lastSyncAt = null;
5467
+ for (const p of peers ?? []) {
5468
+ if (!p?.lastSyncAt)
5469
+ continue;
5470
+ const t = Date.parse(p.lastSyncAt);
5471
+ if (Number.isFinite(t) && (lastSyncAt === null || t > Date.parse(lastSyncAt))) {
5472
+ lastSyncAt = new Date(t).toISOString();
5473
+ }
5474
+ }
5475
+ assessment = assessDriver({
5476
+ installed: driver.installed,
5477
+ active: driver.active,
5478
+ intervalSeconds: driver.intervalSeconds,
5479
+ lastSyncAt,
5480
+ now: Date.now(),
5481
+ });
5482
+ }
5483
+ catch {
5484
+ // An unsupported platform (neither darwin nor linux) or an
5485
+ // unreadable unit must not take down `federation status` — the peer
5486
+ // table is still the primary output.
5487
+ driver = null;
5488
+ assessment = null;
5489
+ }
5490
+ }
4941
5491
  if (mode === "json") {
4942
- console.log(render.asJSON({ instance, peers }));
5492
+ console.log(render.asJSON({ instance, peers, driver, driverAssessment: assessment }));
4943
5493
  return;
4944
5494
  }
4945
5495
  const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
@@ -4951,6 +5501,26 @@ federation
4951
5501
  console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
4952
5502
  return;
4953
5503
  }
5504
+ // Print the driver line BEFORE the per-peer table: "is anything running
5505
+ // sync at all" is the question that decides how to read everything
5506
+ // below it.
5507
+ if (assessment) {
5508
+ const icon = assessment.verdict === "driving" || assessment.verdict === "external-driver"
5509
+ ? render.icons.ok
5510
+ : assessment.verdict === "unknown"
5511
+ ? render.icons.info
5512
+ : render.icons.warn;
5513
+ const color = assessment.verdict === "driving" || assessment.verdict === "external-driver"
5514
+ ? render.c.green
5515
+ : assessment.verdict === "unknown"
5516
+ ? render.c.dim
5517
+ : render.c.yellow;
5518
+ console.log();
5519
+ console.log(`${icon} ${render.wrap(color, assessment.headline)}`);
5520
+ console.log(` ${render.wrap(render.c.dim, assessment.detail)}`);
5521
+ if (assessment.remedy)
5522
+ console.log(` ${render.wrap(render.c.cyan, assessment.remedy)}`);
5523
+ }
4954
5524
  const now = Date.now();
4955
5525
  const formatPeerAge = (iso, refNow, staleAfterMs) => {
4956
5526
  if (!iso)
@@ -5012,7 +5582,16 @@ federation
5012
5582
  });
5013
5583
  if (haveStale) {
5014
5584
  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'.")}`);
5585
+ // The staleness warning used to fire identically whether sync was
5586
+ // running and the peer was unreachable, or nothing had run sync since
5587
+ // the day the spoke was paired (flair#922). Those need opposite
5588
+ // actions, so the remedy is now chosen by the driver verdict instead
5589
+ // of always pointing at SyncLog.
5590
+ const noDriver = assessment?.verdict === "no-driver" || assessment?.verdict === "driver-inactive";
5591
+ const remedy = noDriver
5592
+ ? "Nothing is driving sync — see the driver line above. Run 'flair federation sync enable'."
5593
+ : "Check skippedReasons in SyncLog or run 'flair federation sync'.";
5594
+ 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
5595
  }
5017
5596
  const haveContactButNoMerge = peers.some((p) => {
5018
5597
  if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
@@ -5655,21 +6234,172 @@ export async function runFederationSyncOnce(opts) {
5655
6234
  return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
5656
6235
  }
5657
6236
  }
5658
- federation
6237
+ const federationSync = federation
5659
6238
  .command("sync")
5660
- .description("Push local changes to the hub")
6239
+ .description("Push local changes to the hub (one-shot). Subcommands manage the scheduled driver.")
5661
6240
  .option("--port <port>", "Harper HTTP port")
5662
6241
  .option("--admin-pass <pass>", "Admin password")
6242
+ .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
6243
  .option("--ops-port <port>", "Harper operations API port")
5664
6244
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
5665
6245
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
5666
6246
  .action(async (opts) => {
6247
+ // --admin-pass-file resolves into the same `adminPass` slot the inline
6248
+ // flag uses, so the scheduler never has to embed a secret in a unit file.
6249
+ // readAdminPassFileSecure() refuses a file that is not owner-only.
6250
+ if (!opts.adminPass && opts.adminPassFile) {
6251
+ try {
6252
+ opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
6253
+ }
6254
+ catch (err) {
6255
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
6256
+ process.exit(1);
6257
+ }
6258
+ }
5667
6259
  const r = await runFederationSyncOnce(opts);
5668
6260
  if (r.error) {
5669
6261
  console.error(`Error: ${r.error.message}`);
5670
6262
  process.exit(1);
5671
6263
  }
5672
6264
  });
6265
+ // ─── flair federation sync enable | disable | status ────────────────────────
6266
+ // The supervised driver (flair#922). Federation had no automatic driver at
6267
+ // all: `sync` is one-shot and `watch` is a foreground loop that dies with its
6268
+ // terminal, so every operator paired a spoke, saw one successful sync, and
6269
+ // then silently stopped syncing.
6270
+ //
6271
+ // Shape deliberately mirrors `flair rem nightly enable|disable|status` —
6272
+ // same verbs, same platform coverage (launchd on macOS, systemd --user timer
6273
+ // on Linux), same "never claim success before activation succeeded" rule.
6274
+ // Strategy is a PERIODIC ONE-SHOT rather than a supervised long-lived
6275
+ // watcher; the reasoning is in src/federation/scheduler.ts's header.
6276
+ federationSync
6277
+ .command("enable")
6278
+ .description("Install the sync driver (launchd on macOS, systemd timer on Linux)")
6279
+ .option("--interval <seconds>", `Seconds between syncs (default ${FEDERATION_SYNC_DEFAULT_INTERVAL})`, String(FEDERATION_SYNC_DEFAULT_INTERVAL))
6280
+ // Deliberately NOT `--no-admin-pass-file`: commander treats a `--no-x` flag
6281
+ // as the negation of `--x`, and declaring both on one command makes the
6282
+ // POSITIVE option silently parse to undefined — `--admin-pass-file /path`
6283
+ // would be accepted and dropped, producing a driver that fails auth every
6284
+ // cycle with no error anywhere. Verified against commander 14.
6285
+ .option("--no-credentials", "Do not wire any credential file into the unit")
6286
+ // `--admin-pass-file` and `--target` are NOT redeclared here (flair#926).
6287
+ // The parent `flair federation sync` owns both, and commander matches an
6288
+ // option against the parent's list before dispatching — so a duplicate
6289
+ // declaration here never receives a value, it only makes the option LOOK
6290
+ // local. Both flags still work on this command; they arrive via
6291
+ // optsWithGlobals() below and are listed under "Global Options" in --help.
6292
+ .addHelpText("after", "\nCredentials:\n"
6293
+ + " --admin-pass-file defaults to ~/.flair/admin-pass when that file exists.\n"
6294
+ + " The PATH is stored in the unit — never the password.\n")
6295
+ .action(async (_opts, cmd) => {
6296
+ // optsWithGlobals(), NOT the action's first argument: `--admin-pass-file`
6297
+ // and `--target` are declared on the PARENT (`flair federation sync`), and
6298
+ // commander binds their values there. The subcommand's own opts() has no
6299
+ // entry for them at all. Reading only the local opts silently dropped
6300
+ // `--admin-pass-file <path>` here (flair#923), which installed a driver
6301
+ // that failed auth every cycle with no error anywhere. Verified against
6302
+ // commander 14; test/unit/cli-option-collisions.test.ts pins the rule.
6303
+ const opts = cmd.optsWithGlobals();
6304
+ const intervalSeconds = Number(opts.interval);
6305
+ if (!Number.isFinite(intervalSeconds)) {
6306
+ console.error(`Error: --interval must be a number of seconds (got: ${opts.interval})`);
6307
+ process.exit(1);
6308
+ }
6309
+ // Default to the canonical admin-pass file when it is actually there.
6310
+ // Silently wiring a path that does not exist would produce a driver that
6311
+ // runs and fails auth every interval — the failure mode this whole issue
6312
+ // is about, in a new costume.
6313
+ let adminPassFile;
6314
+ if (opts.credentials === false) {
6315
+ adminPassFile = undefined;
6316
+ }
6317
+ else if (typeof opts.adminPassFile === "string" && opts.adminPassFile) {
6318
+ adminPassFile = opts.adminPassFile;
6319
+ }
6320
+ else {
6321
+ const fallback = defaultAdminPassPath();
6322
+ adminPassFile = existsSync(fallback) ? fallback : undefined;
6323
+ }
6324
+ const { enableScheduler, formatEnableReport } = await import("./federation/scheduler.js");
6325
+ try {
6326
+ const r = enableScheduler({ intervalSeconds, adminPassFile, target: opts.target });
6327
+ const { lines, ok } = formatEnableReport(r, { adminPassFile, target: opts.target });
6328
+ for (const line of lines)
6329
+ console.log(line);
6330
+ if (!ok)
6331
+ process.exit(1);
6332
+ }
6333
+ catch (err) {
6334
+ console.error(`Error: ${err.message}`);
6335
+ process.exit(1);
6336
+ }
6337
+ });
6338
+ federationSync
6339
+ .command("disable")
6340
+ .description("Remove the sync driver (peers and sync history are preserved)")
6341
+ .option("--remove-shim", "Also delete the ~/.flair/bin/flair-federation-sync shim")
6342
+ .action(async (opts) => {
6343
+ const { disableScheduler } = await import("./federation/scheduler.js");
6344
+ try {
6345
+ const r = disableScheduler({ removeShim: !!opts.removeShim });
6346
+ if (r.removed.length === 0) {
6347
+ console.log(`(Federation sync driver was not installed on ${r.platform})`);
6348
+ return;
6349
+ }
6350
+ console.log(`✅ Federation sync driver disabled (${r.platform})`);
6351
+ console.log(` Removed:`);
6352
+ for (const p of r.removed)
6353
+ console.log(` ${p}`);
6354
+ if (r.unloadResult && r.unloadResult.code !== 0) {
6355
+ console.log(` Unload: ${r.unloadCommand.join(" ")} → code ${r.unloadResult.code}`);
6356
+ if (r.unloadResult.stderr)
6357
+ console.log(` stderr: ${r.unloadResult.stderr.trim()}`);
6358
+ }
6359
+ console.log(`\nPeers, keys and sync history are untouched. Nothing will sync until you`);
6360
+ console.log(`re-enable the driver or run \`flair federation sync\` by hand.`);
6361
+ }
6362
+ catch (err) {
6363
+ console.error(`Error: ${err.message}`);
6364
+ process.exit(1);
6365
+ }
6366
+ });
6367
+ federationSync
6368
+ .command("status")
6369
+ .description("Show whether a sync driver is installed and genuinely active")
6370
+ // `--port` and `--target` are NOT redeclared here (flair#926) — the parent
6371
+ // `flair federation sync` owns them and commander binds them there. They
6372
+ // still work on this command, via optsWithGlobals() below.
6373
+ .option("--json", "Emit JSON")
6374
+ .action(async (_opts, cmd) => {
6375
+ // See the comment on `enable` above: `--target`/`--port` live on the
6376
+ // parent, so commander binds them there and only optsWithGlobals() sees
6377
+ // them.
6378
+ const opts = cmd.optsWithGlobals();
6379
+ const { schedulerStatus, formatStatusReport, assessDriver } = await import("./federation/scheduler.js");
6380
+ try {
6381
+ const s = schedulerStatus();
6382
+ const lastSyncAt = await latestPeerContact(opts);
6383
+ const a = assessDriver({
6384
+ installed: s.installed,
6385
+ active: s.active,
6386
+ intervalSeconds: s.intervalSeconds,
6387
+ lastSyncAt,
6388
+ now: Date.now(),
6389
+ });
6390
+ if (render.resolveOutputMode(opts) === "json") {
6391
+ console.log(render.asJSON({ driver: s, assessment: a, lastSyncAt }));
6392
+ return;
6393
+ }
6394
+ const { lines } = formatStatusReport(s, a);
6395
+ for (const line of lines)
6396
+ console.log(line);
6397
+ }
6398
+ catch (err) {
6399
+ console.error(`Error: ${err.message}`);
6400
+ process.exit(1);
6401
+ }
6402
+ });
5673
6403
  export async function runFederationWatch(opts) {
5674
6404
  const intervalMs = Math.max(5, parseFloat(opts.interval) || 30) * 1000;
5675
6405
  let stopped = false;
@@ -7786,6 +8516,7 @@ export function resolveFabricCredentials(opts) {
7786
8516
  async function runFabricUpgrade(opts) {
7787
8517
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
7788
8518
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
8519
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
7789
8520
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
7790
8521
  let fabricUser;
7791
8522
  let fabricPassword;
@@ -7814,13 +8545,21 @@ async function runFabricUpgrade(opts) {
7814
8545
  const upgradeOpts = {
7815
8546
  target: opts.target,
7816
8547
  project: opts.project,
7817
- version: opts.version,
8548
+ // flair#926: `--flair-version`, never `opts.version` — that attribute name
8549
+ // belongs to the program's `-v, --version` and never reaches this action.
8550
+ version: opts.flairVersion,
7818
8551
  harperVersion: opts.harperVersion,
7819
8552
  fabricUser,
7820
8553
  fabricPassword,
7821
8554
  check,
7822
8555
  restart: opts.restart !== false,
7823
8556
  replicated: opts.replicated !== false,
8557
+ // flair#878 — previously unreachable from this command; see
8558
+ // FabricUpgradeOptions.
8559
+ deployRetries: Number(opts.deployRetries ?? 0),
8560
+ ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
8561
+ convergenceCheck: opts.convergenceCheck !== false,
8562
+ convergenceTimeoutMs: opts.convergenceTimeout != null ? Number(opts.convergenceTimeout) : undefined,
7824
8563
  };
7825
8564
  console.log(`${green("→")} Upgrading Fabric Flair at ${upgradeOpts.target}`);
7826
8565
  if (check)
@@ -7847,6 +8586,20 @@ async function runFabricUpgrade(opts) {
7847
8586
  console.log(`\n${green("✓")} already up to date.`);
7848
8587
  return;
7849
8588
  }
8589
+ if (result.convergedAfterReplicationError) {
8590
+ // flair#878: this deploy is a SUCCESS that harper's own exit code called
8591
+ // a failure. Say both halves out loud — an operator who saw the
8592
+ // replication error scroll past needs to know it resolved, and an
8593
+ // operator reading only this line needs to know it happened at all.
8594
+ console.log(`\n${yellow("⚠")} harper reported a peer-replication failure during this upgrade, but the component ` +
8595
+ `tree on every named peer node matched the origin when checked afterwards — replication converged ` +
8596
+ `on its own. Harper replicates components asynchronously, so a replication error at deploy time is ` +
8597
+ `a snapshot, not a verdict.`);
8598
+ }
8599
+ if (result.replicationWarning) {
8600
+ console.log(`\n${yellow("⚠")} Deployed to the ORIGIN NODE ONLY — peer replication did not converge and ` +
8601
+ `--ignore-replication-errors was set. The peer will need to catch up via federation sync or a later deploy.`);
8602
+ }
7850
8603
  console.log(`\n${green("✓")} Fabric upgrade complete.`);
7851
8604
  // ── Post-upgrade fleet sweep (flair#636) ────────────────────────────────
7852
8605
  // "deploy complete" from harper's own CLI means "origin took it" — this
@@ -7878,6 +8631,15 @@ async function runFabricUpgrade(opts) {
7878
8631
  if (hint.includes("401") || hint.includes("unauthoriz")) {
7879
8632
  console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
7880
8633
  }
8634
+ // flair#878: harper's own replication error tells the operator to "pass
8635
+ // ignore_replication_errors: true" — until now there was no way to do that
8636
+ // through `flair upgrade`. Name the flag that actually does it, and the
8637
+ // one that turns off the retry that can make things worse.
8638
+ if (hint.includes("peer replication") || hint.includes("ignore_replication_errors")) {
8639
+ console.error(dim(" hint: --ignore-replication-errors accepts an origin-only upgrade (the peer catches up via federation sync or a later deploy)"));
8640
+ console.error(dim(" hint: --convergence-timeout <ms> waits longer for asynchronous replication before giving up (default 180000)"));
8641
+ console.error(dim(" hint: --deploy-retries defaults to 0 — a retry can turn a transient replication warning into a hard install failure (flair#878)"));
8642
+ }
7881
8643
  process.exit(1);
7882
8644
  }
7883
8645
  }
@@ -8069,6 +8831,22 @@ export const UPGRADE_SNAPSHOT_NUDGE_LINES = [
8069
8831
  const snapshotCmd = program
8070
8832
  .command("snapshot")
8071
8833
  .description("Physical ~/.flair/data snapshots (byte-exact tar.gz, local-only — see `flair backup`/`flair restore` for the logical JSON export/import)");
8834
+ /**
8835
+ * `resolveHttpPort` for a command that takes `--data-dir`, reported as a
8836
+ * message rather than a stack trace (flair#914).
8837
+ *
8838
+ * The throw is a refusal to guess which instance a directory is, and a refusal
8839
+ * has to tell the operator what to pass instead — a stack trace does not.
8840
+ */
8841
+ function resolveHttpPortForDataDir(opts) {
8842
+ try {
8843
+ return resolveHttpPort(opts);
8844
+ }
8845
+ catch (err) {
8846
+ console.error(`❌ ${err?.message ?? err}`);
8847
+ process.exit(1);
8848
+ }
8849
+ }
8072
8850
  snapshotCmd
8073
8851
  .command("create")
8074
8852
  .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 +8854,16 @@ snapshotCmd
8076
8854
  .option("--port <port>", "Harper HTTP port (used to quiesce Flair around the snapshot)")
8077
8855
  .action(async (opts) => {
8078
8856
  const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
8079
- const port = resolveHttpPort(opts);
8857
+ // Existence first, THEN the port. A directory that isn't there has a more
8858
+ // specific diagnosis than "it doesn't say which port it serves", and the
8859
+ // caller should get the one that names the actual problem (flair#914).
8080
8860
  if (!existsSync(dataDir)) {
8081
8861
  console.error(`Error: data directory does not exist: ${dataDir}`);
8082
8862
  process.exit(1);
8083
8863
  }
8864
+ // flair#914: the port of the instance NAMED here, never the per-user
8865
+ // file's — refuses rather than guessing when that directory has no record.
8866
+ const port = resolveHttpPortForDataDir(opts);
8084
8867
  console.log(`Snapshotting ${dataDir}...`);
8085
8868
  console.log("(Flair will be briefly stopped for a point-in-time-consistent copy, then restarted.)");
8086
8869
  // Same consistency requirement as the upgrade path's snapshot step: a
@@ -8091,7 +8874,9 @@ snapshotCmd
8091
8874
  // point-in-time-consistent guarantee, not a weaker one.
8092
8875
  let stoppedForSnapshot = false;
8093
8876
  try {
8094
- await stopFlairProcess(port);
8877
+ // `dataDir`, not the default (flair#902) — quiesce the instance this
8878
+ // command was pointed at, never whichever one owns ~/.flair/data.
8879
+ await stopFlairProcess(port, dataDir);
8095
8880
  stoppedForSnapshot = true;
8096
8881
  const snapshot = await createDataSnapshot(dataDir);
8097
8882
  const removed = pruneOldSnapshots();
@@ -8104,14 +8889,14 @@ snapshotCmd
8104
8889
  console.error(`❌ snapshot failed: ${err.message}`);
8105
8890
  if (stoppedForSnapshot) {
8106
8891
  try {
8107
- await startFlairProcess(port);
8892
+ await startFlairProcess(port, dataDir);
8108
8893
  }
8109
8894
  catch { /* best effort — surface the original snapshot error, not this */ }
8110
8895
  }
8111
8896
  process.exit(1);
8112
8897
  }
8113
8898
  try {
8114
- await startFlairProcess(port);
8899
+ await startFlairProcess(port, dataDir);
8115
8900
  }
8116
8901
  catch (err) {
8117
8902
  console.error(`❌ the snapshot succeeded but Flair failed to restart: ${err.message}`);
@@ -8169,7 +8954,9 @@ snapshotCmd
8169
8954
  process.exit(1);
8170
8955
  }
8171
8956
  const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
8172
- const port = resolveHttpPort(opts);
8957
+ // flair#914: the port of the instance NAMED here, never the per-user
8958
+ // file's — refuses rather than guessing when that directory has no record.
8959
+ const port = resolveHttpPortForDataDir(opts);
8173
8960
  console.log("This will STOP Flair, DELETE the current data directory, and replace it with:");
8174
8961
  console.log(` snapshot: ${snapshotPath}`);
8175
8962
  console.log(` target: ${dataDir}`);
@@ -8187,32 +8974,62 @@ snapshotCmd
8187
8974
  }
8188
8975
  }
8189
8976
  try {
8190
- await stopFlairProcess(port);
8977
+ // `dataDir`, not the default (flair#902) — the whole point of this
8978
+ // command's --data-dir is that it may name a scratch directory, and
8979
+ // stopping the default instance instead is how a cautious inspect-a-
8980
+ // snapshot-somewhere-else took production down.
8981
+ await stopFlairProcess(port, dataDir);
8191
8982
  }
8192
8983
  catch (err) {
8193
8984
  console.error(`❌ failed to stop Flair: ${err.message}`);
8194
8985
  process.exit(1);
8195
8986
  }
8987
+ // Validate the archive BEFORE the destructive rmSync below. Restore
8988
+ // accepts snapshots this CLI did not create — copied off another machine,
8989
+ // downloaded, handed over during a migration — so the archive is untrusted
8990
+ // input, and a hostile one must not cost the operator their data directory
8991
+ // on its way to being refused.
8992
+ try {
8993
+ await validateSnapshotArchive({ file: snapshotPath, targetDir: dataDir });
8994
+ }
8995
+ catch (err) {
8996
+ console.error(`❌ ${err.message}`);
8997
+ console.error(` ${dataDir} was NOT modified.`);
8998
+ process.exit(1);
8999
+ }
8196
9000
  try {
8197
9001
  rmSync(dataDir, { recursive: true, force: true });
8198
9002
  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 });
9003
+ // extractSnapshotSafely keeps preservePaths: true — load-bearing for
9004
+ // symlink TARGET fidelity, mirroring createDataSnapshot while doing
9005
+ // the entry-path containment that flag disables. See
9006
+ // src/lib/safe-snapshot-extract.ts for why the flag cannot simply be
9007
+ // dropped. No `follow` option, so symlinks extract as symlinks (never
9008
+ // their targets' contents), and file modes extract exactly as stored.
9009
+ await extractSnapshotSafely({ file: snapshotPath, targetDir: dataDir });
8208
9010
  }
8209
9011
  catch (err) {
8210
9012
  console.error(`❌ restore failed: ${err.message}`);
8211
9013
  console.error(` ${dataDir} may be partially restored or empty — do not start Flair until this is resolved.`);
8212
9014
  process.exit(1);
8213
9015
  }
9016
+ // flair#914: a snapshot is a byte-exact copy of a data directory, so it
9017
+ // carries the SOURCE instance's harper-config.yaml, and the extract just
9018
+ // wrote it over this instance's. Between here and the boot below, that file
9019
+ // names the SOURCE's port — but nothing re-resolves in that window: `port`
9020
+ // was resolved before the extract and is handed to startFlairProcess
9021
+ // explicitly, and Harper rewrites http.port / operationsApi.network.port
9022
+ // from that spawn's environment as it boots. So the directory is
9023
+ // self-describing again the moment it is serving, without flair writing into
9024
+ // Harper's config to make it so.
9025
+ //
9026
+ // The port is a property of the instance, not of the data it serves. That
9027
+ // is also what keeps a snapshot from somewhere else out of the business of
9028
+ // naming ports on this host: restoring one to look at it cannot hand the
9029
+ // restored directory a port it did not have — the boot immediately below is
9030
+ // what settles the question, on this host's terms.
8214
9031
  try {
8215
- await startFlairProcess(port);
9032
+ await startFlairProcess(port, dataDir);
8216
9033
  }
8217
9034
  catch (err) {
8218
9035
  console.error(`❌ restore succeeded but Flair failed to restart: ${err.message}`);
@@ -8240,12 +9057,27 @@ program
8240
9057
  .option("--fabric-user <user>", "Fabric admin username — for --target (env: FABRIC_USER preferred; inline leaks to shell history)")
8241
9058
  .option("--fabric-password <pass>", "Fabric admin password — for --target (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
8242
9059
  .option("--fabric-password-file <path>", "Read the Fabric admin password from a file (chmod 600) — for --target")
8243
- .option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
9060
+ // NOT `--version` (flair#926). The program declares `-v, --version`, and
9061
+ // commander matches an option against the PARENT's list before dispatching to
9062
+ // the subcommand — so `flair upgrade --target X --version 1.2.3` printed the
9063
+ // CLI's own version and exited 0, never running the Fabric upgrade at all.
9064
+ // A colliding name is normally recoverable via optsWithGlobals(); this one is
9065
+ // not, because commander's version listener exits the process. The name had
9066
+ // to change. `--harper-version` below is the symmetry this follows.
9067
+ .option("--flair-version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
8244
9068
  .option("--harper-version <semver>", "Pin harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
8245
9069
  .option("--project <name>", "Fabric component name for --target", "flair")
8246
9070
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
8247
9071
  .option("--yes", "Skip the confirmation prompt for --target")
8248
9072
  .option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
9073
+ // ── flair#878 ─────────────────────────────────────────────────────────────
9074
+ // These existed on `flair deploy` but stopped at the upgrade boundary, so
9075
+ // harper's own remedy ("pass ignore_replication_errors: true") was not
9076
+ // actually reachable through `flair upgrade --target`.
9077
+ .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")
9078
+ .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)")
9079
+ .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)")
9080
+ .option("--convergence-timeout <ms>", "For --target: how long to wait for peer replication to converge before reporting a replication failure (default: 180000)")
8249
9081
  .action(async (opts) => {
8250
9082
  // ── Fabric-upgrade branch ───────────────────────────────────────────────
8251
9083
  if (opts.target) {
@@ -8381,6 +9213,13 @@ program
8381
9213
  // `opts` — safe to call this early.
8382
9214
  const { restart: shouldRestart, verify: shouldVerify, deprecatedRestartFlagUsed } = resolveUpgradeRestartVerify(opts);
8383
9215
  const upgradePort = resolveHttpPort({});
9216
+ // The instance this upgrade is about, named once next to its port
9217
+ // (flair#902). `flair upgrade` has no --data-dir, so this IS the default
9218
+ // install — but stop/start/restart now take the directory explicitly, so
9219
+ // the choice is made here in the open rather than assumed inside them.
9220
+ // A default that happens to be right is the same defect waiting for the
9221
+ // next caller.
9222
+ const upgradeDataDir = defaultDataDir();
8384
9223
  // Hoisted so the pre-flight check (below) and the post-restart/rollback
8385
9224
  // verification steps (further down) all target the same URL — upgrade
8386
9225
  // never restarts Flair onto a different port.
@@ -8463,8 +9302,7 @@ program
8463
9302
  // the exact same mechanism as a standalone command for anyone who wants
8464
9303
  // one without wrapping it around an upgrade.
8465
9304
  const flairIsUpgrading = npmUpgrades.some((u) => u.pkg === "@tpsdev-ai/flair");
8466
- const snapshotDataDir = defaultDataDir();
8467
- const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, existsSync(snapshotDataDir));
9305
+ const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, existsSync(upgradeDataDir));
8468
9306
  let snapshotPath = null;
8469
9307
  if (snapshotDecision === "nudge") {
8470
9308
  // Non-blocking nudge only — never prompt/block here, this must stay
@@ -8477,7 +9315,7 @@ program
8477
9315
  console.log(render.wrap(render.c.dim, line));
8478
9316
  }
8479
9317
  else if (snapshotDecision === "no-data") {
8480
- console.log(`\n(no data directory at ${snapshotDataDir} yet — nothing to snapshot)`);
9318
+ console.log(`\n(no data directory at ${upgradeDataDir} yet — nothing to snapshot)`);
8481
9319
  }
8482
9320
  else if (snapshotDecision === "snapshot") {
8483
9321
  console.log("\nSnapshotting data before upgrade...");
@@ -8496,9 +9334,9 @@ program
8496
9334
  // the server being up).
8497
9335
  let stoppedForSnapshot = false;
8498
9336
  try {
8499
- await stopFlairProcess(upgradePort);
9337
+ await stopFlairProcess(upgradePort, upgradeDataDir);
8500
9338
  stoppedForSnapshot = true;
8501
- const snapshot = await createDataSnapshot(snapshotDataDir);
9339
+ const snapshot = await createDataSnapshot(upgradeDataDir);
8502
9340
  snapshotPath = snapshot.path;
8503
9341
  const removed = pruneOldSnapshots();
8504
9342
  console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
@@ -8512,14 +9350,14 @@ program
8512
9350
  console.error(" Aborting upgrade — no packages were changed. Omit --snapshot to proceed without one (not recommended).");
8513
9351
  if (stoppedForSnapshot) {
8514
9352
  try {
8515
- await startFlairProcess(upgradePort);
9353
+ await startFlairProcess(upgradePort, upgradeDataDir);
8516
9354
  }
8517
9355
  catch { /* best effort — surface the original snapshot error, not this */ }
8518
9356
  }
8519
9357
  process.exit(1);
8520
9358
  }
8521
9359
  try {
8522
- await startFlairProcess(upgradePort);
9360
+ await startFlairProcess(upgradePort, upgradeDataDir);
8523
9361
  }
8524
9362
  catch (err) {
8525
9363
  console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
@@ -8595,15 +9433,118 @@ program
8595
9433
  console.log("\nRestarting Flair...");
8596
9434
  const port = upgradePort;
8597
9435
  // baseUrl was hoisted above (pre-flight, fix #1) — same URL, no redeclaration.
9436
+ /**
9437
+ * Roll @tpsdev-ai/flair back to `toVersion`, restart on it, re-verify, and
9438
+ * exit. Shared by the two ways an upgrade can fail after the package swap:
9439
+ * the restart itself (flair#905) and post-restart verification (flair#635).
9440
+ *
9441
+ * flair#905 found the restart leg wired straight to `process.exit(1)` — so
9442
+ * `docs/upgrade.md`'s "install → restart → verify → rollback-on-failure, in
9443
+ * one step" was only ever true for the verify leg. An upgrade that installed
9444
+ * new packages and then failed to start them left the operator on the new
9445
+ * version with nothing running and no rollback, which is the one outcome the
9446
+ * whole transaction exists to prevent.
9447
+ */
9448
+ const rollbackTo = async (toVersion, reason) => {
9449
+ console.log(`\nRolling back @tpsdev-ai/flair to ${toVersion}...`);
9450
+ try {
9451
+ execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
9452
+ }
9453
+ catch (err) {
9454
+ console.error(`❌ rollback install failed: ${err.message}`);
9455
+ console.error(` Flair is currently on the FAILED version (${expectedFlairVersion ?? "unknown"}) and is NOT running.`);
9456
+ console.error(` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
9457
+ process.exit(1);
9458
+ }
9459
+ // Same post-swap rule as the upgrade restart above: the rolled-back
9460
+ // version's own CLI is the thing that knows how to start it.
9461
+ const rolledBackCli = resolveInstalledFlairCli(flairPackageDir(), toVersion);
9462
+ try {
9463
+ await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
9464
+ }
9465
+ catch (err) {
9466
+ console.error(`❌ rollback restart failed: ${err.message}`);
9467
+ console.error(` @tpsdev-ai/flair@${toVersion} is installed but NOT running. Start it with: flair start`);
9468
+ console.error(" Then check: flair status");
9469
+ process.exit(1);
9470
+ }
9471
+ const rollbackVerify = await probeInstance(baseUrl, {
9472
+ expectVersion: toVersion,
9473
+ timeoutMs: STARTUP_TIMEOUT_MS,
9474
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
9475
+ });
9476
+ const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
9477
+ if (rollbackVerdict.kind === "rolled-back") {
9478
+ console.error(`❌ upgrade failed and was rolled back to @tpsdev-ai/flair@${toVersion} (running, verified).`);
9479
+ console.error(` Original failure: ${reason}`);
9480
+ process.exit(1);
9481
+ }
9482
+ console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
9483
+ // flair#741 fix #3: this is the exact incident report — a 403 from a
9484
+ // responding, healthy server (credentials-only failure) was printed as
9485
+ // "state UNKNOWN — do not assume data integrity" for BOTH the upgrade
9486
+ // verify AND the rollback re-verify, because the same missing-auth-
9487
+ // material condition rejects both. Reserve the UNKNOWN/do-not-assume
9488
+ // text for failures where the instance's real state genuinely can't be
9489
+ // determined (connection refused, timeout, 5xx) — a credential-only
9490
+ // failure here means the rollback likely landed fine and the checker
9491
+ // simply can't prove it.
9492
+ if (isCredentialOnlyFailure(rollbackVerify)) {
9493
+ console.error(" The instance is up and responding — the verifier could not authenticate (credentials, not the rollback, are the problem).");
9494
+ console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key, then check: flair doctor");
9495
+ }
9496
+ else {
9497
+ console.error(" Instance state is UNKNOWN — do not assume data integrity.");
9498
+ }
9499
+ // This double-failure isn't auto-recoverable yet (flair#637) — but if a
9500
+ // pre-upgrade snapshot landed, point at the CONCRETE path instead of
9501
+ // just the issue number, so recovery doesn't start with a GitHub search.
9502
+ if (snapshotPath) {
9503
+ console.error(` A pre-upgrade snapshot is available: ${snapshotPath}`);
9504
+ console.error(` Restore: flair snapshot restore "${snapshotPath}" (or see docs/upgrade.md#downgrade).`);
9505
+ }
9506
+ else {
9507
+ 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).");
9508
+ console.error(" Check `flair snapshot list` for a manual one, or restore from a `flair backup` JSON export. See docs/upgrade.md#downgrade.");
9509
+ }
9510
+ process.exit(1);
9511
+ };
9512
+ // flair#905: hand the restart to the CLI that was just installed, resolved
9513
+ // from disk AFTER the swap. `null` (flair itself wasn't swapped, or the new
9514
+ // tree can't be verified) falls back to an in-process restart, announced.
9515
+ const flairWasSwapped = flairIsUpgrading && !flairInstallFailed;
9516
+ let newCli = null;
9517
+ if (flairWasSwapped) {
9518
+ const resolved = resolveInstalledFlairCli(flairPackageDir(), expectedFlairVersion);
9519
+ if (resolved.ok === false) {
9520
+ console.error(`warning: could not verify the newly installed CLI (${resolved.reason}) — restarting with this process's own code instead.`);
9521
+ }
9522
+ else {
9523
+ newCli = { cliPath: resolved.cliPath, version: resolved.version };
9524
+ }
9525
+ }
9526
+ let restartWasDelegated = false;
8598
9527
  try {
8599
- await restartFlair(port);
9528
+ restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
8600
9529
  }
8601
9530
  catch (err) {
8602
9531
  console.error(`❌ restart failed: ${err.message}`);
8603
- console.error(" Flair may be partially down. Check: flair doctor");
9532
+ console.error(" Flair is NOT running. Your data in ~/.flair was not touched by this upgrade.");
9533
+ if (flairWasSwapped && previousFlairVersion) {
9534
+ await rollbackTo(previousFlairVersion, `restart failed: ${err.message}`);
9535
+ }
9536
+ // Not reached when a rollback ran — rollbackTo always exits. Say WHICH of
9537
+ // the two "no rollback" cases this is; "nothing to roll back" is not the
9538
+ // same statement as "we don't know what to roll back to".
9539
+ console.error(flairWasSwapped
9540
+ ? " Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown."
9541
+ : " Nothing to roll back: @tpsdev-ai/flair itself was not changed by this upgrade.");
9542
+ console.error(" Start it with: flair start — then check: flair status");
8604
9543
  process.exit(1);
8605
9544
  }
8606
- console.log("✅ Flair restarted");
9545
+ // The delegated `flair restart` printed its own success line; don't say it twice.
9546
+ if (!restartWasDelegated)
9547
+ console.log("✅ Flair restarted");
8607
9548
  if (!shouldVerify) {
8608
9549
  console.log(" (--no-verify: skipping post-restart verification)");
8609
9550
  return;
@@ -8646,63 +9587,7 @@ program
8646
9587
  console.error(" Check the instance now: flair doctor");
8647
9588
  process.exit(1);
8648
9589
  }
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);
9590
+ await rollbackTo(verdict.toVersion, verdict.reason);
8706
9591
  });
8707
9592
  // ─── flair stop ───────────────────────────────────────────────────────────────
8708
9593
  program
@@ -8729,14 +9614,19 @@ program
8729
9614
  }
8730
9615
  }
8731
9616
  }
8732
- // Fallback: find process by port
9617
+ // Fallback: find process by port. Listening sockets only, never our own
9618
+ // PID — see parseListeningPids (flair#800/flair#905): this used to SIGTERM
9619
+ // every process holding ANY socket on the port, so `flair stop` could kill
9620
+ // itself (leaving Flair running) or kill an unrelated client of it.
8733
9621
  try {
8734
9622
  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);
9623
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
9624
+ if (pids.length > 0) {
8738
9625
  for (const pid of pids) {
8739
- process.kill(Number(pid), "SIGTERM");
9626
+ try {
9627
+ process.kill(pid, "SIGTERM");
9628
+ }
9629
+ catch { /* already gone */ }
8740
9630
  }
8741
9631
  console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
8742
9632
  }
@@ -8793,11 +9683,12 @@ program
8793
9683
  }
8794
9684
  }
8795
9685
  // 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.");
9686
+ const harper = resolveHarperBin(harperSearchRoots());
9687
+ if (!harper.path) {
9688
+ console.error(`❌ ${harperBinNotFoundMessage(harper.searched)}`);
8799
9689
  process.exit(1);
8800
9690
  }
9691
+ const bin = harper.path;
8801
9692
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
8802
9693
  // flair#670/#863: this fallback path (no launchd plist) sets no
8803
9694
  // HARPER_SET_CONFIG, so the ops bind has to be re-asserted explicitly on
@@ -8831,6 +9722,81 @@ program
8831
9722
  }
8832
9723
  });
8833
9724
  // ─── flair restart ────────────────────────────────────────────────────────────
9725
+ /**
9726
+ * Refuse to act on a launchd service that belongs to a DIFFERENT data
9727
+ * directory than the one the command is operating on (flair#902).
9728
+ *
9729
+ * `resolveLaunchdLabel`'s instance-scoped label is a hash of the data dir,
9730
+ * so it can never address another instance — but its pre-flair#693 legacy
9731
+ * fallback CAN: `ai.tpsdev.flair` is a single global label, returned for
9732
+ * ANY data dir whenever that plist exists. On a host that still has one,
9733
+ * `flair snapshot restore --data-dir <scratch>` would resolve the legacy
9734
+ * service and stop whatever install it actually belongs to.
9735
+ *
9736
+ * The plist records the instance it was written for (`ROOTPATH`), so the
9737
+ * check is exact. Refuses ONLY on positive contradiction: a plist with no
9738
+ * ROOTPATH (hand-written, or some other writer's) is no evidence and is
9739
+ * left alone rather than blocking a legitimate stop.
9740
+ *
9741
+ * Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD. Only the
9742
+ * extracted ROOTPATH path ever reaches a message.
9743
+ */
9744
+ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9745
+ let declared = null;
9746
+ try {
9747
+ const raw = readFileSync(plistPath, "utf-8");
9748
+ const m = raw.match(/<key>ROOTPATH<\/key>\s*<string>([^<]*)<\/string>/);
9749
+ // The plist stores this XML-escaped (buildLaunchdPlist), so a data dir
9750
+ // containing `&` is on disk as `&amp;`. Decode before comparing, or the
9751
+ // path would never equal itself and this guard would refuse a legitimate
9752
+ // stop/start on any instance whose path contains an escaped character.
9753
+ declared = m ? unescapeXml(m[1]) : null;
9754
+ }
9755
+ catch {
9756
+ return; // unreadable — no evidence, don't block
9757
+ }
9758
+ if (declared === null)
9759
+ return;
9760
+ if (resolve(declared) === resolve(dataDir))
9761
+ return;
9762
+ throw new Error(`refusing to ${action} launchd service ${label}: its plist (${plistPath}) is registered to data directory ` +
9763
+ `${resolve(declared)}, not ${resolve(dataDir)} — that is a different Flair instance. ` +
9764
+ `Re-run with --data-dir ${resolve(declared)} to act on that one, or run ` +
9765
+ `'flair init --data-dir ${resolve(dataDir)}' to register a service for this one.`);
9766
+ }
9767
+ /**
9768
+ * Refuse a port-based SIGTERM that cannot be attributed to `dataDir`
9769
+ * (flair#902).
9770
+ *
9771
+ * The port fallback below identifies its target by port number and nothing
9772
+ * else, so `--data-dir <scratch>` with a port that scratch instance does not
9773
+ * serve signals whichever instance DOES serve it. That is the whole of this
9774
+ * bug on Linux, where there is no launchd path at all.
9775
+ *
9776
+ * Scoped deliberately to a non-default data dir. For the default install the
9777
+ * port genuinely is that instance's port by every convention in this CLI
9778
+ * (`writeConfig`/`readPortFromConfig`), and the only evidence available here
9779
+ * — `<dataDir>/hdb.pid` vs the listening PIDs — is not something we can
9780
+ * require without risking a false refusal on a working install whose PID file
9781
+ * is missing or whose listener is a worker. So the default path keeps today's
9782
+ * behavior exactly, and the residual gap is stated rather than papered over:
9783
+ * a default-dir port stop is still unattributed. The new refusal can only
9784
+ * fire for a caller that explicitly named another data dir — the case that is
9785
+ * wrong today whenever the port does not match.
9786
+ */
9787
+ function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
9788
+ if (resolve(dataDir) === defaultDataDir())
9789
+ return;
9790
+ const expected = readHarperPid(dataDir);
9791
+ if (expected !== null && listeningPids.includes(expected))
9792
+ return;
9793
+ const why = expected === null
9794
+ ? `no hdb.pid under that directory, so it does not look like a running instance`
9795
+ : `its recorded PID ${expected} is not the process listening on ${port}`;
9796
+ throw new Error(`refusing to stop the process listening on port ${port}: it cannot be attributed to ${resolve(dataDir)} (${why}). ` +
9797
+ `Stopping by port alone would signal a different Flair instance. ` +
9798
+ `Pass --port with the port ${resolve(dataDir)} actually serves, or omit --data-dir to operate on the default install.`);
9799
+ }
8834
9800
  /**
8835
9801
  * Stop the local Flair (Harper) process — launchd `stop` on darwin when a
8836
9802
  * plist is present (falling back on failure), otherwise a manual SIGTERM by
@@ -8839,19 +9805,34 @@ program
8839
9805
  * start without duplicating this logic — `restartFlair` is now just
8840
9806
  * `stopFlairProcess` followed by `startFlairProcess`.
8841
9807
  *
9808
+ * `dataDir` is REQUIRED and names the instance to stop — it is not a
9809
+ * convenience parameter. It used to be resolved internally from
9810
+ * `defaultDataDir()`, which meant `flair snapshot create|restore --data-dir
9811
+ * <elsewhere>` stopped the DEFAULT instance and reported success
9812
+ * (flair#902). A port alone does not identify an instance to launchd; the
9813
+ * data dir does, via `resolveLaunchdLabel`.
9814
+ *
8842
9815
  * Idempotent-ish: stopping an already-stopped instance is a harmless no-op
8843
9816
  * on both paths (launchctl stop on an unloaded/idle service, or an empty
8844
9817
  * `lsof` match).
9818
+ *
9819
+ * Throws when the resolved target provably belongs to a different instance
9820
+ * — see assertLaunchdServiceOwnedBy / assertPortInstanceOwnedBy. Callers
9821
+ * already treat a failed stop as fatal, which is the point: refusing beats
9822
+ * quiescing the wrong install.
8845
9823
  */
8846
- async function stopFlairProcess(port) {
9824
+ async function stopFlairProcess(port, dataDir) {
8847
9825
  if (process.platform === "darwin") {
8848
- const dataDir = defaultDataDir();
8849
9826
  // resolveLaunchdLabel (flair#693) finds whichever label this data dir
8850
9827
  // is currently registered under (new instance-scoped, or a
8851
9828
  // pre-flair#693 legacy install) — stop only needs to operate on
8852
9829
  // whichever exists, no migration.
8853
9830
  const { label, plistPath } = resolveLaunchdLabel(dataDir);
8854
9831
  if (existsSync(plistPath)) {
9832
+ // Outside the try below: an ownership refusal must NOT degrade into
9833
+ // the port-based fallback, which would go on to signal by port the
9834
+ // very instance we just refused to touch.
9835
+ assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "stop");
8855
9836
  try {
8856
9837
  const { execSync } = await import("node:child_process");
8857
9838
  // Ensure the service is loaded (init writes the plist but doesn't load it)
@@ -8879,45 +9860,55 @@ async function stopFlairProcess(port) {
8879
9860
  }
8880
9861
  // Port-based stop (Linux, or macOS fallback when no launchd plist)
8881
9862
  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));
9863
+ const { execSync } = await import("node:child_process");
9864
+ // -sTCP:LISTEN plus a self-PID guard, both inside listeningPidsOnPort: a bare
9865
+ // `lsof -ti :port` also matches CLIENT sockets referencing the port, including
9866
+ // THIS CLI's own keep-alive connections left by the credential pre-flight's
9867
+ // probeInstance() HTTP calls (flair#741). Without the filter, the upgrade
9868
+ // path SIGTERM'd its own process mid-restart "Stopping..." then death
9869
+ // (exit 143) before "Starting..." ever ran, leaving the server down
9870
+ // (flair#800, deterministic on the Linux/non-launchd default path).
9871
+ // flair#905 moved both halves into that one helper because the same
9872
+ // unfiltered pattern had survived in `flair stop`, `flair uninstall` and
9873
+ // `flair doctor` — one guarded resolver is what keeps the next site honest.
9874
+ // It returns [] when lsof matches nothing, which is this path's "not running".
9875
+ const targets = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
9876
+ if (targets.length === 0)
9877
+ return;
9878
+ // Deliberately outside any catch: a refusal must reach the caller, not be
9879
+ // swallowed as "not running" and reported as a successful stop.
9880
+ assertPortInstanceOwnedBy(port, dataDir, targets);
9881
+ for (const target of targets) {
9882
+ try {
9883
+ process.kill(target, "SIGTERM");
8905
9884
  }
9885
+ catch { }
8906
9886
  }
8907
- catch { /* not running */ }
9887
+ // Wait briefly for shutdown
9888
+ await new Promise((r) => setTimeout(r, 2000));
8908
9889
  }
8909
9890
  /**
8910
9891
  * Start the local Flair (Harper) process — launchd `start` on darwin when a
8911
9892
  * plist is present (falling back on failure), otherwise a direct spawn.
8912
9893
  * Counterpart to `stopFlairProcess`; see that function's doc comment.
9894
+ *
9895
+ * `dataDir` is REQUIRED for the same reason it is on `stopFlairProcess`
9896
+ * (flair#902): it, not the port, is what identifies the instance. This
9897
+ * function resolved it internally from `defaultDataDir()` too, so the
9898
+ * snapshot commands' restart leg brought the DEFAULT instance back up after
9899
+ * operating on a `--data-dir` elsewhere.
8913
9900
  */
8914
- async function startFlairProcess(port) {
8915
- const dataDir = defaultDataDir();
9901
+ async function startFlairProcess(port, dataDir) {
8916
9902
  if (process.platform === "darwin") {
8917
9903
  // resolveLaunchdLabel (flair#693) finds whichever label this data dir
8918
9904
  // is currently registered under before we attempt anything.
8919
- const { plistPath } = resolveLaunchdLabel(dataDir);
9905
+ const { label, plistPath } = resolveLaunchdLabel(dataDir);
8920
9906
  if (existsSync(plistPath)) {
9907
+ // Same ownership gate as the stop path (flair#902), and outside the
9908
+ // try for the same reason: starting the wrong service would then wait
9909
+ // for health on `port`, see the OTHER instance answer, and report
9910
+ // success.
9911
+ assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "start");
8921
9912
  try {
8922
9913
  const { execSync } = await import("node:child_process");
8923
9914
  ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
@@ -8931,10 +9922,11 @@ async function startFlairProcess(port) {
8931
9922
  }
8932
9923
  }
8933
9924
  console.log("Starting...");
8934
- const bin = harperBin();
8935
- if (!bin) {
8936
- throw new Error("Harper binary not found. Run 'flair init' first.");
9925
+ const harper = resolveHarperBin(harperSearchRoots());
9926
+ if (!harper.path) {
9927
+ throw new Error(harperBinNotFoundMessage(harper.searched));
8937
9928
  }
9929
+ const bin = harper.path;
8938
9930
  // Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
8939
9931
  // Without this, `flair init --admin-pass X` (which only exports HDB_*
8940
9932
  // to the initial Harper spawn) followed by `flair restart` would silently
@@ -8981,28 +9973,119 @@ async function startFlairProcess(port) {
8981
9973
  * Throws on failure instead of calling process.exit — callers decide how to
8982
9974
  * react (`flair restart` exits 1; `flair upgrade` treats a failed restart as
8983
9975
  * an upgrade failure and may attempt a rollback).
9976
+ *
9977
+ * Takes `dataDir` explicitly (flair#902) so the instance being bounced is
9978
+ * named at the call site rather than assumed from `defaultDataDir()` two
9979
+ * frames down.
8984
9980
  */
8985
- async function restartFlair(port) {
8986
- await stopFlairProcess(port);
8987
- await startFlairProcess(port);
9981
+ async function restartFlair(port, dataDir) {
9982
+ await stopFlairProcess(port, dataDir);
9983
+ await startFlairProcess(port, dataDir);
8988
9984
  // Bust the version-handshake cache so the next preAction nudge re-fetches
8989
9985
  // the LIVE version instead of the pre-restart cached one (the false
8990
9986
  // "server is running <old>" users hit for up to 60s post-upgrade+restart).
8991
9987
  // Same (rootPath, serverUrl) key the preAction hook computes (~line 2189)
8992
- // — must match exactly, or this busts the wrong cache file.
9988
+ // — must match exactly, or this busts the wrong cache file. Deliberately
9989
+ // NOT `dataDir`: the key is whatever the preAction hook computed for THIS
9990
+ // process, and using the restarted instance's dir instead would bust a
9991
+ // different cache file (or none) and leave the stale entry in place.
8993
9992
  try {
8994
9993
  invalidateHandshakeCache(process.env.ROOTPATH ?? defaultDataDir(), `http://127.0.0.1:${port}`);
8995
9994
  }
8996
9995
  catch { /* best-effort — never fail a restart over cache cleanup */ }
8997
9996
  }
9997
+ export function resolveInstalledFlairCli(packageRoot, expectVersion, deps = {}) {
9998
+ const exists = deps.exists ?? existsSync;
9999
+ const read = deps.read ?? ((p) => readFileSync(p, "utf-8"));
10000
+ const cliPath = join(packageRoot, "dist", "cli.js");
10001
+ if (!exists(cliPath))
10002
+ return { ok: false, reason: `no dist/cli.js at ${cliPath}` };
10003
+ let version;
10004
+ try {
10005
+ version = JSON.parse(read(join(packageRoot, "package.json"))).version ?? "";
10006
+ }
10007
+ catch (err) {
10008
+ return { ok: false, reason: `could not read ${join(packageRoot, "package.json")}: ${err?.message ?? err}` };
10009
+ }
10010
+ if (!version)
10011
+ return { ok: false, reason: `${join(packageRoot, "package.json")} declares no version` };
10012
+ if (expectVersion && version !== expectVersion) {
10013
+ return { ok: false, reason: `${packageRoot} holds ${version}, expected ${expectVersion}` };
10014
+ }
10015
+ return { ok: true, cliPath, version };
10016
+ }
10017
+ /**
10018
+ * Restart Flair after a package swap, through the newly installed CLI when one
10019
+ * could be located and in-process otherwise.
10020
+ *
10021
+ * The in-process fallback is deliberate and is NOT a silent one: it announces
10022
+ * which path it took and why. A missing/unverifiable new CLI means the swap
10023
+ * itself is suspect, and refusing to restart at all would turn a recoverable
10024
+ * upgrade into a guaranteed outage — but a fallback nobody can see in the
10025
+ * output is how "it restarted fine" and "it restarted with the wrong code"
10026
+ * become indistinguishable after the fact.
10027
+ *
10028
+ * Delegation is limited to the DEFAULT data directory, and that limit is not
10029
+ * incidental. The child is `flair restart`, which has no `--data-dir` and
10030
+ * therefore restarts `defaultDataDir()` — handing it a `dataDir` that is not
10031
+ * the default would restart a different instance and then wait for health on
10032
+ * `port` and watch the wrong one answer. That is flair#902 exactly, and the
10033
+ * required `dataDir` parameter here exists so the condition is checkable rather
10034
+ * than assumed. Today the upgrade path only ever operates on the default
10035
+ * install, so this never triggers; if that changes, `flair restart` needs a
10036
+ * `--data-dir` before this may delegate.
10037
+ *
10038
+ * Throws on failure; the caller owns the rollback decision. Returns whether the
10039
+ * restart was delegated, so the caller can leave the success line to whichever
10040
+ * process actually printed one.
10041
+ */
10042
+ async function restartAfterUpgrade(port, dataDir, newCliArg) {
10043
+ let newCli = newCliArg;
10044
+ if (newCli && resolve(dataDir) !== defaultDataDir()) {
10045
+ console.error(`warning: not delegating the restart to ${newCli.cliPath} — it would restart ${defaultDataDir()}, ` +
10046
+ `not ${resolve(dataDir)}. Restarting with this process's own code instead.`);
10047
+ newCli = null;
10048
+ }
10049
+ if (!newCli) {
10050
+ await restartFlair(port, dataDir);
10051
+ return false;
10052
+ }
10053
+ console.log(` (restarting via the newly installed CLI: ${newCli.cliPath} @ ${newCli.version})`);
10054
+ const { spawnSync } = await import("node:child_process");
10055
+ const res = spawnSync(process.execPath, [newCli.cliPath, "restart", "--port", String(port)], {
10056
+ encoding: "utf-8",
10057
+ // The child runs the same stop→start→waitForHealth sequence this process
10058
+ // would have; give it the full startup budget plus slack for the stop leg
10059
+ // rather than killing a restart that is merely slow.
10060
+ timeout: STARTUP_TIMEOUT_MS * 3,
10061
+ env: process.env,
10062
+ });
10063
+ if (res.stdout)
10064
+ process.stdout.write(res.stdout);
10065
+ if (res.stderr)
10066
+ process.stderr.write(res.stderr);
10067
+ if (res.error) {
10068
+ throw new Error(`could not run the newly installed CLI (${newCli.cliPath}): ${res.error.message}`);
10069
+ }
10070
+ if (res.status !== 0) {
10071
+ const detail = (res.stderr ?? "").trim().split("\n").filter(Boolean).pop();
10072
+ throw new Error(`the newly installed CLI (@tpsdev-ai/flair@${newCli.version}) failed to restart Flair` +
10073
+ `${res.signal ? ` (killed by ${res.signal})` : ` (exit ${res.status})`}` +
10074
+ `${detail ? `: ${detail}` : ""}`);
10075
+ }
10076
+ return true;
10077
+ }
8998
10078
  program
8999
10079
  .command("restart")
9000
10080
  .description("Restart the Flair (Harper) instance")
9001
10081
  .option("--port <port>", "Harper HTTP port")
9002
10082
  .action(async (opts) => {
9003
10083
  const port = resolveHttpPort(opts);
10084
+ // Explicit, not defaulted inside restartFlair (flair#902): `flair
10085
+ // restart` has no --data-dir, so the default install IS what it means —
10086
+ // and saying so here is what keeps that true when someone adds one.
9004
10087
  try {
9005
- await restartFlair(port);
10088
+ await restartFlair(port, defaultDataDir());
9006
10089
  console.log("✅ Flair restarted");
9007
10090
  }
9008
10091
  catch (err) {
@@ -9041,14 +10124,16 @@ program
9041
10124
  if (removedAny)
9042
10125
  console.log("✅ Launchd service removed");
9043
10126
  }
9044
- // Kill any process still on the port (covers direct-start, no-service, or failed unload)
10127
+ // Kill any process still on the port (covers direct-start, no-service, or
10128
+ // failed unload). Listening sockets only, never our own PID — see
10129
+ // parseListeningPids (flair#800/flair#905).
9045
10130
  try {
9046
10131
  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")) {
10132
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
10133
+ if (pids.length > 0) {
10134
+ for (const pid of pids) {
9050
10135
  try {
9051
- process.kill(Number(pid.trim()), "SIGTERM");
10136
+ process.kill(pid, "SIGTERM");
9052
10137
  }
9053
10138
  catch { }
9054
10139
  }
@@ -9463,8 +10548,10 @@ program
9463
10548
  .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
10549
  .option("--verify-timeout <ms>", "Milliseconds to wait for the served API to settle after harper's post-deploy restart before verifying (default: 300000)")
9465
10550
  .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)")
10551
+ .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")
10552
+ .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)")
10553
+ .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)")
10554
+ .option("--convergence-timeout <ms>", "How long to wait for peer replication to converge before reporting a replication failure (default: 180000)")
9468
10555
  .option("--no-fleet-verify", "Skip the automatic post-deploy fleet convergence sweep (default: sweep runs — see flair#636)")
9469
10556
  .action(async (opts) => {
9470
10557
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
@@ -9499,8 +10586,10 @@ program
9499
10586
  verify: opts.verify !== false,
9500
10587
  verifyResources: opts.verifyResource?.length ? opts.verifyResource : undefined,
9501
10588
  verifyTimeoutMs: Number(opts.verifyTimeout ?? 300_000),
9502
- deployRetries: Number(opts.deployRetries ?? 2),
10589
+ deployRetries: Number(opts.deployRetries ?? 0),
9503
10590
  ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
10591
+ convergenceCheck: opts.convergenceCheck !== false,
10592
+ convergenceTimeoutMs: opts.convergenceTimeout != null ? Number(opts.convergenceTimeout) : undefined,
9504
10593
  onProgress: (msg) => console.log(dim(` ${msg}`)),
9505
10594
  };
9506
10595
  const errors = validateDeployOptions(deployOpts);
@@ -9525,6 +10614,11 @@ program
9525
10614
  console.log(dim(` package root: ${result.packageRoot}`));
9526
10615
  return;
9527
10616
  }
10617
+ if (result.convergedAfterReplicationError) {
10618
+ // flair#878: harper's exit code said failure; the per-node component
10619
+ // comparison said otherwise. Both halves get said out loud.
10620
+ 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.`);
10621
+ }
9528
10622
  if (result.replicationWarning) {
9529
10623
  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
10624
  }
@@ -9579,8 +10673,9 @@ program
9579
10673
  if (hint?.includes("did not settle")) {
9580
10674
  console.error(dim(" hint: Harper may still be restarting — check Fabric Studio, or retry with a longer --verify-timeout"));
9581
10675
  }
9582
- if (hint?.includes("peer replication failed after")) {
10676
+ if (hint?.includes("peer replication")) {
9583
10677
  console.error(dim(" hint: pass --ignore-replication-errors to accept an origin-only deploy, or re-run once the peer link recovers"));
10678
+ console.error(dim(" hint: --convergence-timeout <ms> waits longer for asynchronous replication before giving up (default 180000)"));
9584
10679
  }
9585
10680
  process.exit(1);
9586
10681
  }
@@ -9789,7 +10884,10 @@ program
9789
10884
  console.log(` ${render.wrap(render.c.dim, "Would update config to port")} ${discoveredPort}`);
9790
10885
  }
9791
10886
  else {
9792
- writeConfig(discoveredPort);
10887
+ // dataDir0 is defaultDataDir() — `flair doctor` has no
10888
+ // --data-dir, so the default install is what it means, and
10889
+ // saying so keeps that true when it grows one (flair#914).
10890
+ persistDefaultInstallCoordinates(dataDir0, discoveredPort);
9793
10891
  console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
9794
10892
  fixed++;
9795
10893
  }
@@ -9813,8 +10911,13 @@ program
9813
10911
  // Check if something else grabbed the port
9814
10912
  try {
9815
10913
  const { execSync } = await import("node:child_process");
9816
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
9817
- if (lsof) {
10914
+ // Listening sockets only, never our own PID doctor has already
10915
+ // probed this port over HTTP, so a bare lsof reports doctor's own
10916
+ // process as the squatter and tells the operator to kill it
10917
+ // (flair#905; see parseListeningPids).
10918
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
10919
+ if (pids.length > 0) {
10920
+ const lsof = pids.join(" ");
9818
10921
  console.log(` ${render.icons.error} Nothing responding on port ${port} ${render.wrap(render.c.dim, `(port occupied by PID ${lsof})`)}`);
9819
10922
  console.log(` ${render.wrap(render.c.dim, "Fix:")} kill ${lsof} && flair restart`);
9820
10923
  }
@@ -9936,9 +11039,8 @@ program
9936
11039
  // (see its doc comment) now reuses the existing password instead, so this
9937
11040
  // remedy is safe to follow on a working install.
9938
11041
  try {
9939
- const harperConfigPath = join(defaultDataDir(), "harper-config.yaml");
9940
- if (existsSync(harperConfigPath)) {
9941
- const harperConfig = parseYaml(readFileSync(harperConfigPath, "utf-8")) || {};
11042
+ const harperConfig = readHarperConfig(defaultDataDir());
11043
+ if (harperConfig) {
9942
11044
  const opsPortValue = harperConfig?.operationsApi?.network?.port;
9943
11045
  const bind = detectOpsApiAllInterfacesBind(opsPortValue);
9944
11046
  if (bind.allInterfaces) {
@@ -10074,6 +11176,16 @@ program
10074
11176
  else {
10075
11177
  let claudeCodeAgentId;
10076
11178
  let anyKnownAgentId;
11179
+ // `doctor --fix` writes client configs through the same wire functions
11180
+ // init does, so it owes the user the same warning when the spec it would
11181
+ // write cannot be pinned (flair#907).
11182
+ if (autoFix) {
11183
+ const pinWarning = unpinnedSpecWarning();
11184
+ if (pinWarning) {
11185
+ for (const line of pinWarning.split("\n"))
11186
+ console.log(` ${render.icons.warn} ${line}`);
11187
+ }
11188
+ }
10077
11189
  for (const client of detectedClients) {
10078
11190
  const block = readClientMcpBlock(client.id, homedir());
10079
11191
  if (client.id === "claude-code" && block.agentId)
@@ -10392,9 +11504,30 @@ program
10392
11504
  if (migBlock.cyclePhase === "pre-hash") {
10393
11505
  console.log(`${indent}${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
10394
11506
  }
11507
+ // flair#812: the boot trigger sets `scheduled` synchronously at
11508
+ // module load, so `idle` means resources/migration-boot.js never
11509
+ // loaded in the serving process — NO migration will ever run on
11510
+ // this instance, which is precisely the failure that went unnoticed
11511
+ // because a skipped cycle looked identical to a clean one.
11512
+ if (migBlock.cyclePhase === "idle") {
11513
+ 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.`);
11514
+ issues++;
11515
+ }
11516
+ // A cycle that reached a terminal phase carrying an error explains
11517
+ // itself here rather than only in the process log — the reason
11518
+ // string names the paths tried and the remedy.
11519
+ if (migBlock.lastCycleError) {
11520
+ console.log(`${indent}${render.icons.error} Last migration cycle did not complete: ${migBlock.lastCycleError}`);
11521
+ issues++;
11522
+ }
10395
11523
  for (const m of migBlock.migrations) {
10396
11524
  if (m.state === "completed") {
10397
- console.log(`${indent}${render.icons.ok} ${m.id}: completed`);
11525
+ // flair#812: a `reason` on a COMPLETED migration means the
11526
+ // runner short-circuited it from the (hand-editable) state file
11527
+ // rather than verifying the corpus this boot. Print it, so an
11528
+ // unverified claim is never rendered as a verified one.
11529
+ const note = m.reason ? ` ${render.wrap(render.c.dim, `(${m.reason})`)}` : "";
11530
+ console.log(`${indent}${render.icons.ok} ${m.id}: completed${note}`);
10398
11531
  }
10399
11532
  else if (m.state === "halted" || m.state === "failed") {
10400
11533
  console.log(`${indent}${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
@@ -11473,6 +12606,17 @@ sessionSnapshot
11473
12606
  process.exit(1);
11474
12607
  }
11475
12608
  mkdirSync(targetDir, { recursive: true, mode: 0o700 });
12609
+ // Deliberately NOT preservePaths, and deliberately NOT routed through
12610
+ // extractSnapshotSafely. --snapshot is an operator-supplied path, so
12611
+ // provenance here is no more controlled than the data-dir restore's — the
12612
+ // difference is the flag, not the trust. node-tar's defaults keep their
12613
+ // own containment: leading "/" stripped from entry paths, ".." entries
12614
+ // dropped, and no writing through a symlink (including one created
12615
+ // earlier in the same archive). Verified against the pinned tar (7.5.20)
12616
+ // on all four cases, each contained, with a benign control entry landing
12617
+ // to prove the archives parsed. Add `preservePaths` here and that
12618
+ // containment disappears — this call would then need extractSnapshotSafely,
12619
+ // exactly as the data-dir restore does. See src/lib/safe-snapshot-extract.ts.
11476
12620
  await tarExtract({ file: snapshotPath, cwd: targetDir });
11477
12621
  console.log(targetDir);
11478
12622
  console.error(` extracted to: ${targetDir}`);
@@ -13989,6 +15133,8 @@ if (import.meta.main) {
13989
15133
  await runCli();
13990
15134
  }
13991
15135
  // ─── 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,
15136
+ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost,
15137
+ // Harper's own config — the per-instance port record (flair#914)
15138
+ 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
15139
  // launchd label (flair#693)
13994
15140
  LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };