@tpsdev-ai/flair 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +194 -377
  2. package/dist/cli-shim.cjs +1 -1
  3. package/dist/cli.js +1567 -366
  4. package/dist/deploy.js +241 -43
  5. package/dist/fabric-upgrade.js +77 -24
  6. package/dist/federation/scheduler.js +500 -0
  7. package/dist/fleet-verify.js +3 -3
  8. package/dist/install/clients.js +111 -53
  9. package/dist/lib/mcp-enable.js +2 -2
  10. package/dist/lib/mcp-spec.js +128 -0
  11. package/dist/lib/safe-snapshot-extract.js +231 -0
  12. package/dist/lib/scheduler-platform.js +128 -0
  13. package/dist/lib/xml-escape.js +54 -0
  14. package/dist/rem/scheduler.js +212 -55
  15. package/dist/rem/snapshot.js +13 -0
  16. package/dist/replication-convergence.js +505 -0
  17. package/dist/resources/A2AAdapter.js +1 -1
  18. package/dist/resources/Admin.js +1 -1
  19. package/dist/resources/AdminConnectors.js +1 -1
  20. package/dist/resources/AdminDashboard.js +1 -1
  21. package/dist/resources/AdminIdp.js +1 -1
  22. package/dist/resources/AdminInstance.js +1 -1
  23. package/dist/resources/AdminMemory.js +1 -1
  24. package/dist/resources/AdminPrincipals.js +1 -1
  25. package/dist/resources/Agent.js +1 -1
  26. package/dist/resources/AgentCard.js +1 -1
  27. package/dist/resources/AgentSeed.js +1 -1
  28. package/dist/resources/AttentionQuery.js +1 -1
  29. package/dist/resources/Credential.js +1 -1
  30. package/dist/resources/Federation.js +1 -1
  31. package/dist/resources/Instance.js +1 -1
  32. package/dist/resources/Integration.js +1 -1
  33. package/dist/resources/MCPClientMetadata.js +1 -1
  34. package/dist/resources/Memory.js +1 -1
  35. package/dist/resources/MemoryBootstrap.js +8 -9
  36. package/dist/resources/MemoryCandidate.js +168 -0
  37. package/dist/resources/MemoryConsolidate.js +1 -1
  38. package/dist/resources/MemoryDedupStats.js +1 -1
  39. package/dist/resources/MemoryFeed.js +1 -1
  40. package/dist/resources/MemoryGrant.js +1 -1
  41. package/dist/resources/MemoryMaintenance.js +1 -1
  42. package/dist/resources/MemoryReflect.js +2 -2
  43. package/dist/resources/MemoryReindex.js +1 -1
  44. package/dist/resources/MemoryUsage.js +1 -1
  45. package/dist/resources/OAuth.js +1 -1
  46. package/dist/resources/OAuthClient.js +1 -1
  47. package/dist/resources/OrgEvent.js +1 -1
  48. package/dist/resources/OrgEventCatchup.js +1 -1
  49. package/dist/resources/OrgEventMaintenance.js +1 -1
  50. package/dist/resources/PairingToken.js +1 -1
  51. package/dist/resources/Peer.js +1 -1
  52. package/dist/resources/Presence.js +3 -3
  53. package/dist/resources/RecordUsage.js +1 -1
  54. package/dist/resources/Relationship.js +1 -1
  55. package/dist/resources/SemanticSearch.js +18 -46
  56. package/dist/resources/SkillScan.js +1 -1
  57. package/dist/resources/Soul.js +1 -1
  58. package/dist/resources/SoulFeed.js +1 -1
  59. package/dist/resources/WorkspaceLatest.js +1 -1
  60. package/dist/resources/WorkspaceState.js +1 -1
  61. package/dist/resources/XAA.js +1 -1
  62. package/dist/resources/a2a-url.js +1 -1
  63. package/dist/resources/abstention.js +1 -1
  64. package/dist/resources/agent-auth.js +1 -1
  65. package/dist/resources/agentcard-fields.js +1 -1
  66. package/dist/resources/auth-middleware.js +1 -1
  67. package/dist/resources/embeddings-boot.js +14 -16
  68. package/dist/resources/embeddings-provider.js +15 -12
  69. package/dist/resources/federation-cleanup.js +5 -5
  70. package/dist/resources/federation-nonce-store.js +5 -5
  71. package/dist/resources/health.js +53 -25
  72. package/dist/resources/in-process.js +225 -0
  73. package/dist/resources/instance-identity.js +1 -1
  74. package/dist/resources/mcp-client-metadata-fields.js +1 -1
  75. package/dist/resources/mcp-handler.js +1 -1
  76. package/dist/resources/mcp-oauth.js +2 -2
  77. package/dist/resources/mcp-tools.js +23 -17
  78. package/dist/resources/memory-read-scope.js +1 -1
  79. package/dist/resources/memory-reflect-lib.js +3 -3
  80. package/dist/resources/memory-visibility.js +3 -3
  81. package/dist/resources/migration-boot.js +81 -11
  82. package/dist/resources/migrations/data-dir.js +205 -0
  83. package/dist/resources/migrations/embedding-stamp.js +3 -3
  84. package/dist/resources/migrations/graph-heal.js +2 -2
  85. package/dist/resources/migrations/ledger.js +1 -1
  86. package/dist/resources/migrations/progress.js +33 -0
  87. package/dist/resources/migrations/registry.js +14 -5
  88. package/dist/resources/migrations/runner.js +93 -13
  89. package/dist/resources/migrations/state.js +13 -2
  90. package/dist/resources/migrations/synthetic-test-migration.js +1 -1
  91. package/dist/resources/migrations/visibility-backfill.js +232 -0
  92. package/dist/resources/models-dir.js +18 -9
  93. package/dist/resources/record-types.js +28 -0
  94. package/dist/resources/semantic-retrieval-core.js +6 -5
  95. package/dist/resources/usage-recording.js +1 -1
  96. package/dist/src/lib/scheduler-platform.js +128 -0
  97. package/dist/src/lib/xml-escape.js +54 -0
  98. package/dist/src/rem/scheduler.js +397 -0
  99. package/docs/deploying-on-fabric.md +267 -0
  100. package/docs/deployment.md +6 -0
  101. package/docs/embedding-in-a-harper-app.md +299 -0
  102. package/docs/federation.md +61 -4
  103. package/docs/integrations.md +3 -0
  104. package/docs/mcp-clients.md +16 -7
  105. package/docs/quickstart.md +80 -54
  106. package/docs/releasing.md +72 -38
  107. package/docs/supply-chain-policy.md +40 -4
  108. package/docs/troubleshooting.md +24 -0
  109. package/docs/upgrade.md +100 -5
  110. package/package.json +4 -14
  111. package/schemas/memory.graphql +9 -1
  112. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  113. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  114. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  115. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  116. package/dist/resources/rerank-provider.js +0 -569
  117. 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,9 +327,195 @@ function readPortFromConfig() {
261
327
  catch { /* ignore */ }
262
328
  return null;
263
329
  }
264
- // Unified port resolution: --port flag > FLAIR_URL env > config file > default
265
- // Every command that talks to Harper MUST use these helpers.
266
- function resolveHttpPort(opts) {
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
+ }
445
+ /**
446
+ * Read the persisted ops-API bind host from ~/.flair/config.yaml (flair#863).
447
+ * Line-anchored so it can't be satisfied by some other key that merely ends
448
+ * in `opsBind`, and quote-tolerant because a hand-edited config may quote it.
449
+ *
450
+ * `path` is injected the same way launchdPlistPath's `launchAgentsDir` is —
451
+ * so tests exercise the real parse against a temp file instead of the
452
+ * developer's own ~/.flair.
453
+ */
454
+ function readOpsBindFromConfig(path = configPath()) {
455
+ try {
456
+ if (existsSync(path)) {
457
+ const yaml = readFileSync(path, "utf-8");
458
+ const m = yaml.match(/^\s*opsBind:\s*["']?([^"'\s#]+)["']?/m);
459
+ if (m && m[1])
460
+ return m[1];
461
+ }
462
+ }
463
+ catch { /* ignore */ }
464
+ return null;
465
+ }
466
+ /** Read the persisted ops-API port from ~/.flair/config.yaml (flair#863). */
467
+ function readOpsPortFromConfig(path = configPath()) {
468
+ try {
469
+ if (existsSync(path)) {
470
+ const yaml = readFileSync(path, "utf-8");
471
+ const m = yaml.match(/^\s*opsPort:\s*(\d+)/m);
472
+ if (m)
473
+ return Number(m[1]);
474
+ }
475
+ }
476
+ catch { /* ignore */ }
477
+ return null;
478
+ }
479
+ /**
480
+ * Unified port resolution: `--port` flag > `FLAIR_URL` env > Harper's own config
481
+ * in the instance's data directory > (default install only) the per-user config
482
+ * > default.
483
+ *
484
+ * Every command that talks to Harper MUST use this helper.
485
+ *
486
+ * `opts.dataDir` names the instance (flair#914). Commands without a
487
+ * `--data-dir` flag do not carry one and therefore mean the default install,
488
+ * which is what they have always meant.
489
+ *
490
+ * `mode` distinguishes ADDRESSING an instance that is expected to exist from
491
+ * CREATING one:
492
+ *
493
+ * - `"address"` (default) — a non-default data directory Harper has never
494
+ * written a config into is a hard error. That case is precisely the bug
495
+ * this fixes: falling back to the per-user file would answer with some
496
+ * OTHER instance's port, which is a silent wrong answer wearing a
497
+ * migration. The default install keeps today's behaviour exactly.
498
+ * - `"create"` — `flair init`, which is establishing the instance rather
499
+ * than looking one up. An unknown instance takes DEFAULT_PORT, the same as
500
+ * a clean install has always done, instead of the refusal that would make
501
+ * `flair init --data-dir <new>` impossible.
502
+ *
503
+ * Note that `init`'s own `--port` option carries a commander default of
504
+ * DEFAULT_PORT, so in practice `opts.port` is always set there and this
505
+ * function returns on the first rung — including when re-initialising an
506
+ * instance that already serves a different port, which is silently renumbered
507
+ * to DEFAULT_PORT. That is pre-existing (it is equally true of the default
508
+ * install on a custom port, where re-running init is `flair doctor`'s standing
509
+ * remedy) and is tracked separately; the "create" rung is what this function
510
+ * would answer if that default were removed, and is what keeps the mode
511
+ * distinction honest rather than hypothetical.
512
+ *
513
+ * Nothing is copied, migrated or written here. Harper's config already IS the
514
+ * per-instance record, so there is no second file to keep in step — which is
515
+ * the whole reason the port is read from it. The per-user file stays exactly as
516
+ * it is for the default install that has always relied on it.
517
+ */
518
+ function resolveHttpPort(opts, mode = "address") {
267
519
  if (opts.port !== undefined && opts.port !== null) {
268
520
  const n = Number(opts.port);
269
521
  if (!isNaN(n) && n > 0)
@@ -275,7 +527,28 @@ function resolveHttpPort(opts) {
275
527
  if (m)
276
528
  return Number(m[1]);
277
529
  }
278
- return readPortFromConfig() ?? DEFAULT_PORT;
530
+ const dataDir = instanceDataDir(opts);
531
+ // 1. Harper's own config for this instance. Once Harper has written one it is
532
+ // the only answer — it is maintained by the process that binds the socket.
533
+ const harperPort = readPortFromHarperConfig(dataDir);
534
+ if (harperPort !== null)
535
+ return harperPort;
536
+ // 2. Harper has written nothing here. For the DEFAULT install the per-user
537
+ // file is about this instance by definition — a single-instance install is
538
+ // the overwhelmingly common case and its recorded port is correct — so it
539
+ // stays the answer for existing installs. Read, never rewritten.
540
+ if (isDefaultDataDir(dataDir)) {
541
+ return readPortFromConfig() ?? DEFAULT_PORT;
542
+ }
543
+ // 3. A non-default directory Harper has never written to. `init` may go on to
544
+ // create the instance; nothing else may guess.
545
+ if (mode === "create")
546
+ return DEFAULT_PORT;
547
+ throw new Error(`cannot determine which port ${dataDir} serves: Harper has written no config there `
548
+ + `(no ${HARPER_CONFIG_FILENAMES.join(" or ")}), so that directory does not hold an instance yet. `
549
+ + `Falling back to ~/.flair/config.yaml would answer with the port of a DIFFERENT instance — that file records one port `
550
+ + `for the whole user, not one per data directory. `
551
+ + `Pass --port <port> to say which port that instance serves, or run 'flair init --data-dir ${dataDir} --port <port>' to create it.`);
279
552
  }
280
553
  // Unified base URL resolution. Precedence:
281
554
  // --target > --url > FLAIR_TARGET env > FLAIR_URL env > http://127.0.0.1:<resolveHttpPort>
@@ -297,6 +570,15 @@ function resolveAgentIdOrEnv(opts) {
297
570
  return opts.agent || process.env.FLAIR_AGENT_ID || null;
298
571
  }
299
572
  // Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
573
+ //
574
+ // Deliberately NOT routed through Harper's per-instance config the way
575
+ // resolveHttpPort is (flair#914). The last rung couples the ops port to the HTTP
576
+ // port the CALLER named, and that coupling is what makes `flair init --port N`
577
+ // land its ops API at N-1 on a host that already runs an instance. A Harper rung
578
+ // above it would answer that call with the EXISTING instance's ops port instead,
579
+ // which is the bug flair#914 fixed pointing the other way. The ops port becomes
580
+ // per-instance when the lifecycle commands grow `--data-dir` and there is a
581
+ // named instance to attribute it to.
300
582
  function resolveOpsPort(opts) {
301
583
  if (opts.opsPort !== undefined && opts.opsPort !== null) {
302
584
  const n = Number(opts.opsPort);
@@ -320,29 +602,60 @@ function resolveOpsPort(opts) {
320
602
  // Default: httpPort - 1
321
603
  return resolveHttpPort(opts) - 1;
322
604
  }
323
- // Ops API bind-host resolution (flair#670): --ops-bind flag > FLAIR_OPS_BIND
324
- // env > loopback default. This is the escape hatch — deployments that
325
- // genuinely need remote ops access (multi-host / Fabric) pass an explicit
326
- // wider address (e.g. `--ops-bind 0.0.0.0`) to opt back in; everything else
327
- // gets the loopback-only single-host default. FLAIR_OPS_BIND (not just the
328
- // flag) matters for `flair start`'s non-launchd fallback spawn, which has no
329
- // --ops-bind flag of its own — see the comment at its OPERATIONSAPI_NETWORK_PORT
330
- // assignment for why it re-resolves this on every start instead of trusting
331
- // the persisted config alone.
332
- function resolveOpsBindHost(opts) {
333
- if (opts.opsBind !== undefined && opts.opsBind !== null && String(opts.opsBind).trim() !== "") {
334
- return String(opts.opsBind).trim();
335
- }
336
- const envBind = process.env.FLAIR_OPS_BIND;
605
+ // Ops API bind-host resolution (flair#670, #863): --ops-bind flag >
606
+ // FLAIR_OPS_BIND env > persisted `opsBind` in ~/.flair/config.yaml > loopback
607
+ // default. This is the escape hatch deployments that genuinely need remote
608
+ // ops access (multi-host / Fabric) pass an explicit wider address (e.g.
609
+ // `--ops-bind 0.0.0.0`) to opt back in; everything else gets the loopback-only
610
+ // single-host default.
611
+ //
612
+ // The config-file rung (flair#863) is what makes `--ops-bind` durable. Every
613
+ // Harper spawn re-asserts the bind (see opsNetworkPortValue's doc comment for
614
+ // why it MUST), and those spawns happen in `flair start` / `flair restart` /
615
+ // `flair upgrade`, none of which take an `--ops-bind` flag. Without a
616
+ // flair-owned persisted value, a one-off `flair init --ops-bind 0.0.0.0` would
617
+ // be silently reverted to the loopback default by the very next restart — the
618
+ // widening case would be as broken as the narrowing one. Mirrors
619
+ // resolveOpsPort's config rung.
620
+ export function resolveOpsBindHostFrom(flag, envBind, configuredBind) {
621
+ if (flag !== undefined && flag !== null && String(flag).trim() !== "")
622
+ return String(flag).trim();
337
623
  if (envBind && envBind.trim() !== "")
338
624
  return envBind.trim();
625
+ if (configuredBind && configuredBind.trim() !== "")
626
+ return configuredBind.trim();
339
627
  return DEFAULT_OPS_BIND_HOST;
340
628
  }
629
+ function resolveOpsBindHost(opts) {
630
+ return resolveOpsBindHostFrom(opts.opsBind, process.env.FLAIR_OPS_BIND, readOpsBindFromConfig());
631
+ }
632
+ /**
633
+ * The one place that renders Harper's `operationsApi.network.port` value
634
+ * (flair#863). Used by both the HARPER_SET_CONFIG block
635
+ * (buildOperationsApiConfig) and every `OPERATIONSAPI_NETWORK_PORT` env var
636
+ * flair sets, so the two can never disagree about whether the bind host is
637
+ * present.
638
+ *
639
+ * Why every spawn has to carry the host-qualified form, not a bare number:
640
+ * Harper's HARPER_SET_CONFIG handling (harper
641
+ * config/harperConfigEnvVars.ts) records, for each key it force-sets, the
642
+ * value that key had BEFORE the force — `state.originalValues` in
643
+ * `<rootPath>/backup/.harper-config-state.json` — and on the next boot where
644
+ * HARPER_SET_CONFIG is absent, `cleanupRemovedEnvVar` RESTORES those
645
+ * originals into harper-config.yaml. So a bare `OPERATIONSAPI_NETWORK_PORT`
646
+ * anywhere in flair's spawn chain doesn't just lose on that boot: it is
647
+ * latched as the "original" and silently re-widens the bind to all interfaces
648
+ * on the first later boot that omits HARPER_SET_CONFIG. That is exactly what
649
+ * `flair restart` / `flair upgrade` do on the non-launchd path.
650
+ */
651
+ export function opsNetworkPortValue(opsBindHost, opsPort) {
652
+ return `${opsBindHost}:${opsPort}`;
653
+ }
341
654
  /**
342
655
  * Build the `operationsApi` block for Harper's HARPER_SET_CONFIG (flair#670).
343
656
  *
344
657
  * `network.port` uses Harper's "host:port" string form — Harper's server
345
- * bootstrap (@harperfast/harper dist/server/threads/threadServer.js,
658
+ * bootstrap (harper dist/server/threads/threadServer.js,
346
659
  * listenOnPorts/listenOnPortsBun) splits a config port value on its last
347
660
  * `:` into an explicit bind host + port when present, and falls back to
348
661
  * binding all interfaces (0.0.0.0 / ::) when given a bare number. A colon-free
@@ -350,15 +663,48 @@ function resolveOpsBindHost(opts) {
350
663
  * it with a host is the only config-level way to narrow the bind.
351
664
  *
352
665
  * `domainSocket` lives under `network` per Harper's own config schema
353
- * (@harperfast/harper/config-root.schema.json → properties.operationsApi
666
+ * (harper/config-root.schema.json → properties.operationsApi
354
667
  * .properties.network.properties.domainSocket, and
355
668
  * dist/validation/configValidator.js's `operationsApi.network.domainSocket`
356
669
  * Joi path) — nested here, not as a sibling of `network`.
357
670
  */
358
671
  export function buildOperationsApiConfig(opsPort, opsSocket, opsBindHost) {
359
672
  return {
360
- network: { port: `${opsBindHost}:${opsPort}`, cors: true, domainSocket: opsSocket },
673
+ network: { port: opsNetworkPortValue(opsBindHost, opsPort), cors: true, domainSocket: opsSocket },
674
+ };
675
+ }
676
+ /**
677
+ * Build the flair-owned environment overrides for a DIRECT (non-launchd)
678
+ * Harper spawn (flair#863) — shared by `flair start`'s fallback path and
679
+ * startFlairProcess() (which backs `flair restart` and `flair upgrade`).
680
+ * Callers spread this over `process.env`.
681
+ *
682
+ * These two sites used to build the env inline and had drifted: `start` set a
683
+ * host-qualified OPERATIONSAPI_NETWORK_PORT, `startFlairProcess` set none at
684
+ * all. Neither path sets HARPER_SET_CONFIG, and Harper restores the
685
+ * pre-SET_CONFIG original for every key SET_CONFIG had forced whenever that
686
+ * variable is absent — so the site that omitted the var silently re-widened
687
+ * the ops API to all interfaces on every restart/upgrade, and persisted it.
688
+ * One builder means the next spawn site cannot reintroduce that gap.
689
+ *
690
+ * Deliberately omits HDB_ADMIN_PASSWORD when no password is in hand: an empty
691
+ * string would strip Harper's auth on an existing install.
692
+ */
693
+ export function buildDirectSpawnEnv(opts) {
694
+ const env = {
695
+ ROOTPATH: opts.dataDir,
696
+ // The embedding backend self-registers in-process at boot
697
+ // (resources/embeddings-boot.ts); this only tells it where the model lives.
698
+ FLAIR_MODELS_DIR: opts.modelsDir,
699
+ DEFAULTS_MODE: "dev",
700
+ HDB_ADMIN_USERNAME: opts.adminUser,
701
+ HTTP_PORT: String(opts.httpPort),
702
+ OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opts.opsBindHost, opts.opsPort),
703
+ LOCAL_STUDIO: "false",
361
704
  };
705
+ if (opts.adminPass)
706
+ env.HDB_ADMIN_PASSWORD = opts.adminPass;
707
+ return env;
362
708
  }
363
709
  /**
364
710
  * Decide whether a persisted `operationsApi.network.port` value (read back
@@ -693,10 +1039,56 @@ function resolveOpsUrlFromTarget(targetUrl) {
693
1039
  }
694
1040
  return url.toString().replace(/\/$/, "");
695
1041
  }
696
- function writeConfig(port) {
697
- const p = configPath();
698
- mkdirSync(join(homedir(), ".flair"), { recursive: true });
699
- writeFileSync(p, `# Flair configuration\nport: ${port}\n`);
1042
+ /**
1043
+ * Persist the instance coordinates other commands need to find and re-assert
1044
+ * this install (flair#863).
1045
+ *
1046
+ * This rewrites the file wholesale, so `opsPort`/`opsBind` default to whatever
1047
+ * is already persisted rather than being dropped — otherwise an unrelated
1048
+ * caller (e.g. `flair doctor --fix` correcting a drifted HTTP port) would
1049
+ * silently erase the operator's `--ops-bind` choice and the next restart would
1050
+ * revert the bind.
1051
+ */
1052
+ function writeConfig(port, opsPort, opsBind, path = configPath()) {
1053
+ const resolvedOpsPort = opsPort ?? readOpsPortFromConfig(path);
1054
+ const resolvedOpsBind = opsBind ?? readOpsBindFromConfig(path);
1055
+ mkdirSync(dirname(path), { recursive: true });
1056
+ let body = `# Flair configuration\nport: ${port}\n`;
1057
+ if (resolvedOpsPort !== null && resolvedOpsPort !== undefined)
1058
+ body += `opsPort: ${resolvedOpsPort}\n`;
1059
+ if (resolvedOpsBind)
1060
+ body += `opsBind: ${resolvedOpsBind}\n`;
1061
+ writeFileSync(path, body);
1062
+ }
1063
+ /**
1064
+ * Persist the coordinates of the instance served from `dataDir` (flair#914).
1065
+ *
1066
+ * Harper records its OWN coordinates — `rootPath`, `http.port`,
1067
+ * `operationsApi.network.port` — into `<dataDir>/harper-config.yaml` on every
1068
+ * boot, and that is what `resolveHttpPort` reads. So there is nothing
1069
+ * instance-local for flair to write: the instance already describes itself, and
1070
+ * a second copy is a drift source, not a record (flair#937).
1071
+ *
1072
+ * What remains is the per-user file, and the GUARD on it is the whole of the
1073
+ * flair#914 fix. `init` used to write `~/.flair/config.yaml` unconditionally, so
1074
+ * `flair init --data-dir X --port P` overwrote whatever port the DEFAULT install
1075
+ * had recorded, and every later lookup answered with P. Writing it only for the
1076
+ * default install is what makes one instance's `init` unable to renumber
1077
+ * another's.
1078
+ *
1079
+ * The guard lives HERE rather than at the call sites: a caller that forgets it
1080
+ * reintroduces flair#914 silently, and there is no test that would notice at the
1081
+ * call site it was forgotten in.
1082
+ *
1083
+ * It keeps the readers that have no `--data-dir` of their own
1084
+ * (`readPortFromConfig` — `flair uninstall`, the `rem` scheduler, `api()`,
1085
+ * doctor's config line) correct for the one instance they are able to address.
1086
+ * Those read Harper's config directly when the lifecycle commands grow the flag,
1087
+ * and this file goes with them.
1088
+ */
1089
+ function persistDefaultInstallCoordinates(dataDir, port, opsPort, opsBind) {
1090
+ if (isDefaultDataDir(dataDir))
1091
+ writeConfig(port, opsPort, opsBind);
700
1092
  }
701
1093
  function privKeyPath(agentId, keysDir) {
702
1094
  return join(keysDir, `${agentId}.key`);
@@ -708,16 +1100,135 @@ function flairPackageDir() {
708
1100
  // dist/cli.js → package root (one level up from dist/)
709
1101
  return join(import.meta.dirname ?? __dirname, "..");
710
1102
  }
1103
+ /**
1104
+ * Harper npm package names this build knows about, newest-convention first.
1105
+ *
1106
+ * flair#870: flair depends on the BARE `harper` package name. The scoped
1107
+ * `@harperfast/harper` name is kept as a fallback so an in-place upgrade over
1108
+ * a pre-#870 install (whose node_modules still holds the scoped copy, and
1109
+ * whose Harper is the one currently serving the data dir) keeps booting until
1110
+ * the next clean install. Bare name is tried first so a tree that has BOTH
1111
+ * resolves to the one flair actually declares.
1112
+ *
1113
+ * This list is a FALLBACK, not the authority — see declaredHarperPackageNames.
1114
+ */
1115
+ const KNOWN_HARPER_PACKAGE_NAMES = ["harper", "@harperfast/harper"];
1116
+ /**
1117
+ * The Harper package name(s) the install at `packageRoot` actually declares,
1118
+ * read fresh off disk on every call.
1119
+ *
1120
+ * flair#905: `flair upgrade` replaces this package tree while the CLI is
1121
+ * executing out of it, so anything the running code "knows" about the tree
1122
+ * describes the tree that WAS there, not the one that is. That is exactly how
1123
+ * the 0.29.0 → 0.30.0 upgrade broke: 0.30.0 renamed its Harper dependency
1124
+ * `@harperfast/harper` → `harper` (flair#870), 0.29.0's resolver only ever
1125
+ * probed the scoped name, and the post-swap tree no longer had it — so the
1126
+ * restart reported "Harper binary not found" against an install that was
1127
+ * perfectly intact. Deriving the name from the package.json sitting at
1128
+ * `packageRoot` reads the POST-swap truth instead of a compiled-in guess, so a
1129
+ * future rename cannot break the same way.
1130
+ *
1131
+ * Matches `harper` and `@scope/harper` only — never `harper-fabric-embeddings`
1132
+ * or any other `harper`-prefixed dependency.
1133
+ */
1134
+ export function declaredHarperPackageNames(packageRoot) {
1135
+ try {
1136
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8"));
1137
+ return Object.keys(pkg.dependencies ?? {}).filter((n) => /^(@[^/]+\/)?harper$/.test(n));
1138
+ }
1139
+ catch {
1140
+ return [];
1141
+ }
1142
+ }
1143
+ /**
1144
+ * Locate a Harper binary under `roots`, and report every path that was tried.
1145
+ *
1146
+ * The `searched` list is not decoration: "Harper binary not found" with no
1147
+ * paths is unactionable, and the message it replaced named the wrong remedy
1148
+ * (flair#905). Callers surface these paths verbatim.
1149
+ */
1150
+ export function resolveHarperBin(roots) {
1151
+ const searched = [];
1152
+ for (const root of roots) {
1153
+ const names = [...declaredHarperPackageNames(root), ...KNOWN_HARPER_PACKAGE_NAMES];
1154
+ for (const name of names) {
1155
+ const candidate = join(root, "node_modules", ...name.split("/"), "dist", "bin", "harper.js");
1156
+ if (searched.includes(candidate))
1157
+ continue;
1158
+ searched.push(candidate);
1159
+ if (existsSync(candidate))
1160
+ return { path: candidate, searched };
1161
+ }
1162
+ }
1163
+ return { path: null, searched };
1164
+ }
1165
+ /**
1166
+ * Roots under which a Harper install is looked for: this package's own
1167
+ * directory (dist/cli.js → ../node_modules/...) then the caller's cwd.
1168
+ *
1169
+ * Recomputed per call rather than captured at module load — see
1170
+ * declaredHarperPackageNames for why anything cached across a package swap is
1171
+ * a bug waiting to happen.
1172
+ */
1173
+ function harperSearchRoots() {
1174
+ return [flairPackageDir(), process.cwd()];
1175
+ }
1176
+ /**
1177
+ * The operator-facing message for "we looked for Harper and it isn't there".
1178
+ *
1179
+ * flair#905: the text this replaces was `Harper binary not found. Run 'flair
1180
+ * init' first.` — which is wrong twice over on an initialised instance. It
1181
+ * names no path, so there is nothing to check; and `flair init` cannot fix a
1182
+ * missing Harper (init resolves the same binary the same way) while it CAN be
1183
+ * mistaken for "re-provision my instance" by someone reading it at 3am. A
1184
+ * missing binary is an incomplete package tree, so the remedy is a reinstall.
1185
+ */
1186
+ export function harperBinNotFoundMessage(searched) {
1187
+ return [
1188
+ "Harper binary not found — this Flair package tree looks incomplete.",
1189
+ " Searched:",
1190
+ ...searched.map((p) => ` ${p}`),
1191
+ " Reinstall the package: npm install -g @tpsdev-ai/flair@latest",
1192
+ " Your data in ~/.flair is untouched — `flair init` will NOT fix this (it resolves the same binary).",
1193
+ ].join("\n");
1194
+ }
711
1195
  function harperBin() {
712
- // Resolve relative to this file's location (dist/cli.js → ../node_modules/...)
713
- const candidates = [
714
- join(import.meta.dirname ?? __dirname, "..", "node_modules", "@harperfast", "harper", "dist", "bin", "harper.js"),
715
- join(process.cwd(), "node_modules", "@harperfast", "harper", "dist", "bin", "harper.js"),
716
- ];
717
- for (const c of candidates)
718
- if (existsSync(c))
719
- return c;
720
- return null;
1196
+ return resolveHarperBin(harperSearchRoots()).path;
1197
+ }
1198
+ /**
1199
+ * Parse `lsof -ti` output into PIDs safe to signal as "the thing on this port".
1200
+ *
1201
+ * Two filters, both load-bearing (flair#800, extended by flair#905):
1202
+ *
1203
+ * A bare `lsof -ti :<port>` matches CLIENT sockets as well as the listening
1204
+ * server — including the calling process's OWN keep-alive connections, left by
1205
+ * anything that has spoken HTTP to that port in this run (the version-handshake
1206
+ * nudge on every command, a health probe, a caller's `fetch`). `-sTCP:LISTEN`
1207
+ * on the command narrows it to the server; this function is the second half:
1208
+ * never return our own PID, whatever lsof said. flair#800 was that exact
1209
+ * self-SIGTERM in `flair upgrade`'s stop step; the same unfiltered pattern
1210
+ * survived in `flair stop`, `flair uninstall` and `flair doctor`, where it can
1211
+ * kill the caller, kill an unrelated client, or tell an operator to `kill` the
1212
+ * PID of the shell they are typing into.
1213
+ */
1214
+ export function parseListeningPids(lsofOutput, selfPid) {
1215
+ return (lsofOutput ?? "")
1216
+ .trim()
1217
+ .split("\n")
1218
+ .map((s) => Number(s.trim()))
1219
+ .filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== selfPid);
1220
+ }
1221
+ /**
1222
+ * PIDs LISTENING on `port`, never this process. Empty when nothing is there or
1223
+ * lsof is unavailable — callers treat that as "not running".
1224
+ */
1225
+ function listeningPidsOnPort(port, exec) {
1226
+ try {
1227
+ return parseListeningPids(exec(`lsof -ti :${port} -sTCP:LISTEN`), process.pid);
1228
+ }
1229
+ catch {
1230
+ return [];
1231
+ }
721
1232
  }
722
1233
  // ─── Helpers ─────────────────────────────────────────────────────────────────
723
1234
  function b64(bytes) {
@@ -1863,49 +2374,16 @@ async function runSoulWizard(agentId) {
1863
2374
  return entries.filter(([, v]) => v.trim().length > 0);
1864
2375
  }
1865
2376
  // ─── Program ─────────────────────────────────────────────────────────────────
1866
- // Read version from package.json at the package root
1867
- const __pkgDir = join(import.meta.dirname ?? __dirname, "..");
1868
- const __pkgVersion = (() => {
1869
- try {
1870
- return JSON.parse(readFileSync(join(__pkgDir, "package.json"), "utf-8")).version;
1871
- }
1872
- catch {
1873
- return "unknown";
1874
- }
1875
- })();
1876
- /**
1877
- * The `@tpsdev-ai/flair-mcp` spec written into a client's MCP config.
1878
- *
1879
- * PINNED, deliberately. A bare `npx -y @tpsdev-ai/flair-mcp` re-resolves to
1880
- * whatever is currently published on EVERY agent session — so a single bad
1881
- * publish (stolen credentials, a malicious commit that clears review, or a
1882
- * compromised dependency of the MCP package) reaches every wired user
1883
- * silently, with no lockfile and no review step in the path. The postmark-mcp
1884
- * incident was exactly this shape: a legitimate publish by the legitimate
1885
- * owner, propagating for 16 days before anyone noticed. Worse, a yank does
1886
- * not help — unpinned clients keep resolving latest.
1887
- *
1888
- * Our publish side is already hardened (OIDC staged publish, human 2FA at the
1889
- * release gate), but that defends against credential theft, not against a bad
1890
- * version being published legitimately. The consumer side is where that gap
1891
- * closes, and pinning is what closes it: a wired client keeps running the
1892
- * exact version that was current when it was wired, and moving forward
1893
- * becomes a deliberate act.
1894
- *
1895
- * flair and flair-mcp ship in version lockstep from this monorepo, so the
1896
- * running CLI's own version is the correct pin. `flair init` re-run rewires
1897
- * to the then-current version; see the upgrade-rewire follow-up issue for
1898
- * making `flair upgrade` do the same.
1899
- *
1900
- * Falls back to the unpinned spec only when the version can't be read, which
1901
- * is the same condition under which `--version` reports "unknown" — a broken
1902
- * install, where a working MCP wiring matters more than a precise pin.
1903
- */
1904
- export function mcpServerSpec(version = __pkgVersion) {
1905
- return version && version !== "unknown"
1906
- ? `@tpsdev-ai/flair-mcp@${version}`
1907
- : "@tpsdev-ai/flair-mcp";
1908
- }
2377
+ // This CLI's own version. Resolution lives in src/lib/mcp-spec.ts so that the
2378
+ // client-wiring code (src/install/clients.ts) shares ONE definition of both
2379
+ // the version and the MCP spec derived from it — see flair#907 for what
2380
+ // happened when the pin lived next to a single call site.
2381
+ const __pkgVersion = flairCliVersion();
2382
+ // mcpServerSpec now lives in src/lib/mcp-spec.ts alongside the version
2383
+ // resolution it depends on, so every writer shares it. Re-exported here
2384
+ // because it is part of this module's public surface (tests and callers
2385
+ // import it from src/cli.ts).
2386
+ export { mcpServerSpec };
1909
2387
  const program = new Command();
1910
2388
  program.name("flair").version(__pkgVersion, "-v, --version");
1911
2389
  // ─── CLI↔server version handshake (flair#695 §B) ────────────────────────────
@@ -2156,11 +2634,21 @@ program
2156
2634
  return;
2157
2635
  }
2158
2636
  // ── Local init (full one-command setup) ──
2159
- const httpPort = resolveHttpPort(opts);
2160
- const opsPort = resolveOpsPort(opts);
2161
- const opsBindHost = resolveOpsBindHost(opts);
2162
2637
  const keysDir = opts.keysDir ?? defaultKeysDir();
2163
2638
  const dataDir = opts.dataDir ?? defaultDataDir();
2639
+ // "create" mode (flair#914): init ESTABLISHES an instance, so a data
2640
+ // directory with no recorded port is a new instance taking the default,
2641
+ // not the hard error every other caller gets — otherwise `flair init
2642
+ // --data-dir <new>` could never succeed. `dataDir` is resolved first so
2643
+ // this is never asked before the instance is known. (`--port` carries a
2644
+ // commander default, so this usually returns on the flag rung; see
2645
+ // resolveHttpPort's doc comment.)
2646
+ const httpPort = resolveHttpPort(opts, "create");
2647
+ // The already-resolved port is handed to the ops resolver rather than
2648
+ // letting it re-resolve — its last rung is `resolveHttpPort(opts) - 1`,
2649
+ // which would ask the same question again in "address" mode.
2650
+ const opsPort = resolveOpsPort({ ...opts, port: httpPort });
2651
+ const opsBindHost = resolveOpsBindHost(opts);
2164
2652
  // Resolve MCP client selection (union of init's auto-wire + the multi-client
2165
2653
  // detection/wiring that the front-door command provides). `--no-mcp` sets
2166
2654
  // opts.mcp === false (commander negates the flag). Validate an explicit
@@ -2302,15 +2790,23 @@ program
2302
2790
  catch { /* not running */ }
2303
2791
  if (!alreadyRunning) {
2304
2792
  const bin = harperBin();
2305
- if (!bin)
2306
- throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
2793
+ if (!bin) {
2794
+ throw new Error("Harper CLI not found: no dist/bin/harper.js under node_modules/harper " +
2795
+ "(or the legacy node_modules/@harperfast/harper) next to this flair " +
2796
+ `install or in ${process.cwd()}.\n` +
2797
+ "Flair ships Harper as a dependency, so this normally means a partial " +
2798
+ "or interrupted install.\nFix: reinstall flair — npm install -g @tpsdev-ai/flair");
2799
+ }
2307
2800
  mkdirSync(dataDir, { recursive: true });
2308
2801
  // Detect whether Harper has already been installed in this data dir.
2309
- // harper-config.yaml is created during install — its presence means
2802
+ // Harper's config is created during install — its presence means
2310
2803
  // install already ran. Re-running install against an existing data dir
2311
2804
  // crashes in Harper v5 beta.6+ (checkForExistingInstall queries the
2312
- // database before the env is initialized).
2313
- const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
2805
+ // database before the env is initialized). Goes through
2806
+ // harperConfigPath so an install predating Harper's config-file rename
2807
+ // (harperdb-config.yaml) is still recognised as installed rather than
2808
+ // re-installed over.
2809
+ const alreadyInstalled = harperConfigPath(dataDir) !== null;
2314
2810
  const opsSocket = join(dataDir, "operations-server");
2315
2811
  // authorizeLocal: false (flair#654) — a credential-less loopback ops-API
2316
2812
  // request is no longer auto-authorized as super_user. Every ops-API
@@ -2338,7 +2834,12 @@ program
2338
2834
  THREADS_COUNT: "1",
2339
2835
  NODE_HOSTNAME: "localhost",
2340
2836
  HTTP_PORT: String(httpPort),
2341
- OPERATIONSAPI_NETWORK_PORT: String(opsPort),
2837
+ // flair#863: host-qualified, NOT a bare port. A bare value here is
2838
+ // what Harper latches as `originalValues["operationsApi.network.port"]`
2839
+ // when HARPER_SET_CONFIG force-sets the same key — and restores on the
2840
+ // first later boot without HARPER_SET_CONFIG, re-widening the bind to
2841
+ // all interfaces. See opsNetworkPortValue's doc comment.
2842
+ OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opsBindHost, opsPort),
2342
2843
  LOCAL_STUDIO: "false",
2343
2844
  };
2344
2845
  // models (flair#504 Phase 1): the embedding backend registers itself
@@ -2453,47 +2954,38 @@ program
2453
2954
  // backend on every KeepAlive restart in-process. See that file's
2454
2955
  // header (flair#694) for why this replaced the old HARPER_CONFIG
2455
2956
  // plist line.
2456
- const escapeXml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
2457
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
2458
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2459
- <plist version="1.0">
2460
- <dict>
2461
- <key>Label</key><string>${label}</string>
2462
- <key>ProgramArguments</key>
2463
- <array>
2464
- <string>${process.execPath}</string>
2465
- <string>${harperBinPath}</string>
2466
- <string>run</string>
2467
- <string>.</string>
2468
- </array>
2469
- <key>WorkingDirectory</key><string>${flairPackageDir()}</string>
2470
- <key>EnvironmentVariables</key>
2471
- <dict>
2472
- <key>ROOTPATH</key><string>${dataDir}</string>
2473
- <key>FLAIR_MODELS_DIR</key><string>${modelsDir}</string>
2474
- <key>HARPER_SET_CONFIG</key><string>${escapeXml(setConfig)}</string>
2475
- <key>DEFAULTS_MODE</key><string>dev</string>
2476
- <key>HDB_ADMIN_USERNAME</key><string>${adminUser}</string>
2477
- <key>HDB_ADMIN_PASSWORD</key><string>${adminPass}</string>
2478
- <key>THREADS_COUNT</key><string>1</string>
2479
- <key>NODE_HOSTNAME</key><string>localhost</string>
2480
- <key>HTTP_PORT</key><string>${httpPort}</string>
2481
- <key>OPERATIONSAPI_NETWORK_PORT</key><string>${opsPort}</string>
2482
- <key>LOCAL_STUDIO</key><string>false</string>
2483
- </dict>
2484
- <key>RunAtLoad</key><true/>
2485
- <key>KeepAlive</key><true/>
2486
- <key>StandardOutPath</key><string>${join(dataDir, "log", "launchd-stdout.log")}</string>
2487
- <key>StandardErrorPath</key><string>${join(dataDir, "log", "launchd-stderr.log")}</string>
2488
- </dict>
2489
- </plist>`;
2957
+ const plist = buildLaunchdPlist({
2958
+ label,
2959
+ execPath: process.execPath,
2960
+ harperBinPath,
2961
+ workingDirectory: flairPackageDir(),
2962
+ dataDir,
2963
+ modelsDir,
2964
+ setConfig,
2965
+ adminUser,
2966
+ adminPass,
2967
+ httpPort,
2968
+ opsNetworkPort: opsNetworkPortValue(opsBindHost, opsPort),
2969
+ });
2490
2970
  writeFileSync(plistPath, plist);
2491
2971
  console.log("Launchd service registered ✓");
2492
2972
  }
2493
2973
  }
2494
2974
  }
2495
- // Persist port to config so other commands can find this instance
2496
- writeConfig(httpPort);
2975
+ // Persist the instance coordinates so other commands can find AND
2976
+ // re-assert this instance. flair#863: `opsBind` in particular has to be
2977
+ // persisted here, not just handed to this run's spawn — `flair start` /
2978
+ // `flair restart` / `flair upgrade` re-assert the bind on every spawn and
2979
+ // have no `--ops-bind` flag of their own, so this is the only thing that
2980
+ // survives an `--ops-bind` choice past the next restart. It also runs on
2981
+ // the already-running path (where init skips the spawn entirely), which is
2982
+ // what makes doctor's `flair init && flair restart` remedy actually apply.
2983
+ // flair#914: written ONLY when this init is about the default install, so a
2984
+ // second instance can no longer overwrite the first's recorded port. This
2985
+ // instance's own port needs no write here — Harper has just recorded it in
2986
+ // <dataDir>/harper-config.yaml, which is what resolveHttpPort reads (see
2987
+ // persistDefaultInstallCoordinates).
2988
+ persistDefaultInstallCoordinates(dataDir, httpPort, opsPort, opsBindHost);
2497
2989
  if (agentId) {
2498
2990
  // Generate or reuse keypair
2499
2991
  mkdirSync(keysDir, { recursive: true });
@@ -2614,13 +3106,35 @@ program
2614
3106
  // skips wiring entirely; `--client <name>` targets one client; the
2615
3107
  // default (no flag) wires every detected client.
2616
3108
  const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
3109
+ // `wired` is the load-bearing field (flair#906): it separates "a config
3110
+ // file was actually written" from "we printed something and moved on".
3111
+ // Both outcomes used to be pushed here indistinguishably as far as the
3112
+ // user was concerned, so `--client all` reported success for a client it
3113
+ // had not wired.
2617
3114
  const wiringResults = [];
3115
+ // Human-readable labels + the clients `--client all` passed over because
3116
+ // they aren't installed, so the closing summary can account for every
3117
+ // client the user asked for rather than only the ones we tried.
3118
+ const clientLabels = new Map();
3119
+ const skippedUndetected = [];
2618
3120
  if (!noMcp && clientOpt !== "none") {
3121
+ // A spec we cannot pin is a security property quietly downgraded, so
3122
+ // say so BEFORE writing it and again in the summary below (flair#907).
3123
+ // stderr: this must survive `flair init | tee`, and it is a warning,
3124
+ // not part of the command's normal output.
3125
+ const pinWarning = unpinnedSpecWarning();
3126
+ if (pinWarning) {
3127
+ console.error("");
3128
+ for (const line of pinWarning.split("\n"))
3129
+ console.error(` ⚠ ${line}`);
3130
+ }
2619
3131
  // Determine which clients to wire.
2620
3132
  let clients = detectClients();
2621
3133
  if (selectedClients.length > 0) {
2622
3134
  clients = clients.filter(c => selectedClients.includes(c.id));
2623
3135
  }
3136
+ for (const c of clients)
3137
+ clientLabels.set(c.id, c.label);
2624
3138
  const detected = clients.filter(c => c.detected);
2625
3139
  if (!clientOpt) {
2626
3140
  if (detected.length === 0) {
@@ -2635,6 +3149,15 @@ program
2635
3149
  : selectedClients.length > 0
2636
3150
  ? selectedClients
2637
3151
  : clients.filter(c => c.detected).map(c => c.id);
3152
+ // `--client all` is a promise about every client, so the ones it
3153
+ // passed over have to be accounted for too — silently omitting them
3154
+ // is how "all" reports success for work it never did (flair#906).
3155
+ if (clientOpt === "all") {
3156
+ for (const c of clients) {
3157
+ if (!c.detected)
3158
+ skippedUndetected.push(c.label);
3159
+ }
3160
+ }
2638
3161
  for (const clientId of toWire) {
2639
3162
  if (clientId === "claude-code") {
2640
3163
  // Claude Code gets real auto-wiring into ~/.claude.json (zero-install
@@ -2652,32 +3175,45 @@ program
2652
3175
  // provenance.claimed.client = "claude-code".
2653
3176
  env: { ...mcpEnv, FLAIR_CLIENT: "claude-code" },
2654
3177
  };
3178
+ // ~/.claude.json exists once Claude Code has been RUN, not once it
3179
+ // is installed — so gating the write on it skipped every user who
3180
+ // installed Claude Code and Flair in the same sitting (flair#906).
3181
+ // The file is Claude Code's own and creating it with a single
3182
+ // `mcpServers` key is exactly what `claude mcp add` does, so an
3183
+ // absent file is created rather than turned into a printed snippet
3184
+ // the user has to notice and act on.
2655
3185
  try {
2656
- if (existsSync(claudeJsonPath)) {
2657
- const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
2658
- const existing = claudeJson.mcpServers?.flair;
2659
- if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
2660
- console.log(` ✓ Claude Code already wired in ~/.claude.json`);
2661
- wiringResults.push({ client: "claude-code", message: "already wired" });
2662
- }
2663
- else {
2664
- claudeJson.mcpServers = claudeJson.mcpServers || {};
2665
- claudeJson.mcpServers.flair = flairMcpConfig;
2666
- writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
2667
- console.log(` ✓ Claude Code wired in ~/.claude.json (restart Claude Code to pick it up)`);
2668
- wiringResults.push({ client: "claude-code", message: "wired ~/.claude.json" });
2669
- }
3186
+ const claudeJsonExisted = existsSync(claudeJsonPath);
3187
+ const claudeJson = claudeJsonExisted
3188
+ ? JSON.parse(readFileSync(claudeJsonPath, "utf-8"))
3189
+ : {};
3190
+ const existing = claudeJson.mcpServers?.flair;
3191
+ if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
3192
+ console.log(` ✓ Claude Code already wired in ~/.claude.json`);
3193
+ wiringResults.push({ client: "claude-code", message: "already wired", wired: true });
2670
3194
  }
2671
3195
  else {
2672
- console.log(` MCP config (add to ~/.claude.json):`);
2673
- console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
2674
- wiringResults.push({ client: "claude-code", message: "snippet printed (no ~/.claude.json)" });
3196
+ claudeJson.mcpServers = claudeJson.mcpServers || {};
3197
+ claudeJson.mcpServers.flair = flairMcpConfig;
3198
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
3199
+ const how = claudeJsonExisted ? "wired in ~/.claude.json" : "wired in ~/.claude.json (created)";
3200
+ console.log(` ✓ Claude Code ${how} (restart Claude Code to pick it up)`);
3201
+ wiringResults.push({
3202
+ client: "claude-code",
3203
+ message: claudeJsonExisted ? "wired ~/.claude.json" : "created and wired ~/.claude.json",
3204
+ wired: true,
3205
+ });
2675
3206
  }
2676
3207
  }
2677
- catch {
3208
+ catch (err) {
3209
+ // Only a genuine read/parse/write failure lands here now (bad
3210
+ // permissions, malformed existing JSON) — never merely "the file
3211
+ // does not exist yet".
3212
+ const reason = err instanceof Error ? err.message : String(err);
3213
+ console.log(` ${render.icons.warn} Claude Code: could not write ~/.claude.json (${reason})`);
2678
3214
  console.log(` MCP config (add manually to ~/.claude.json):`);
2679
3215
  console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
2680
- wiringResults.push({ client: "claude-code", message: "snippet printed" });
3216
+ wiringResults.push({ client: "claude-code", message: `snippet printed (${reason})`, wired: false });
2681
3217
  }
2682
3218
  // ── CLAUDE.md bootstrap line (flair#597) ──────────────────────────
2683
3219
  // The MCP block alone isn't a working setup — Claude Code also needs
@@ -2721,7 +3257,7 @@ program
2721
3257
  break;
2722
3258
  default: result = { ok: false, message: `Unknown client: ${clientId}` };
2723
3259
  }
2724
- wiringResults.push({ client: clientId, message: result.message });
3260
+ wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
2725
3261
  console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
2726
3262
  }
2727
3263
  }
@@ -2799,6 +3335,34 @@ program
2799
3335
  console.log(" Use --skip-smoke to bypass.");
2800
3336
  }
2801
3337
  }
3338
+ // ── MCP wiring summary (flair#906) ───────────────────────────────────
3339
+ // LAST thing init prints, deliberately. Every fact below was already
3340
+ // available mid-run, but a client that was not wired appeared only as a
3341
+ // snippet in the middle of a wall of output, after a success line — so
3342
+ // the user's next action was to open their client and find nothing
3343
+ // there, with no reason to suspect init. A client the user asked for and
3344
+ // did NOT get must still be on screen when the command finishes.
3345
+ if (!noMcp && clientOpt !== "none") {
3346
+ // The pin warning repeats here for the same reason the summary exists:
3347
+ // a warning only in scrollback is a warning the user acts on never.
3348
+ const summaryLines = renderWiringSummary(wiringResults, {
3349
+ labels: clientLabels,
3350
+ skippedUndetected,
3351
+ rewireHint: `flair init --agent ${agentId} --client all`,
3352
+ unpinned: unpinnedSpecWarning() !== null,
3353
+ });
3354
+ for (const line of summaryLines) {
3355
+ const icon = line.level === "ok" ? render.icons.ok :
3356
+ line.level === "error" ? render.icons.error :
3357
+ line.level === "warn" ? render.icons.warn :
3358
+ line.level === "muted" ? render.icons.bullet :
3359
+ null;
3360
+ if (line.level === "heading")
3361
+ console.log(`\n ${line.text}`);
3362
+ else
3363
+ console.log(` ${icon} ${line.text}`);
3364
+ }
3365
+ }
2802
3366
  }
2803
3367
  else {
2804
3368
  const httpUrl = `http://127.0.0.1:${httpPort}`;
@@ -4779,13 +5343,56 @@ function signRequestBody(body, secretKey) {
4779
5343
  const signBodyFresh = signRequestBody;
4780
5344
  // ─── flair federation ────────────────────────────────────────────────────────
4781
5345
  const federation = program.command("federation").description("Manage federation (hub-and-spoke sync)");
5346
+ /**
5347
+ * The most recent CONTACT with any peer (max of peer.lastSyncAt), or null.
5348
+ *
5349
+ * Contact, not merge: a sync that reaches the peer and legitimately has
5350
+ * nothing to send still proves the driver ran. This is half of what lets
5351
+ * `federation status` tell "nothing is driving sync" apart from "sync is
5352
+ * running, the peer is unreachable" (flair#922) — the other half is whether
5353
+ * the service manager has a driver loaded.
5354
+ *
5355
+ * Returns null on ANY failure. A driver verdict is a diagnostic aid; it must
5356
+ * never be the reason `federation status` fails.
5357
+ */
5358
+ async function latestPeerContact(opts) {
5359
+ try {
5360
+ const target = resolveTarget(opts);
5361
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
5362
+ const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
5363
+ let best = null;
5364
+ for (const p of peers ?? []) {
5365
+ if (!p?.lastSyncAt)
5366
+ continue;
5367
+ const t = Date.parse(p.lastSyncAt);
5368
+ if (Number.isFinite(t) && (best === null || t > best))
5369
+ best = t;
5370
+ }
5371
+ return best === null ? null : new Date(best).toISOString();
5372
+ }
5373
+ catch {
5374
+ return null;
5375
+ }
5376
+ }
5377
+ /**
5378
+ * True when the CLI is pointed at the instance running on THIS machine.
5379
+ *
5380
+ * The scheduler check is inherently local — launchctl/systemctl only know
5381
+ * about jobs on the host the CLI is running on. Reporting "no driver" while
5382
+ * `--target` points at someone else's hub would be a confident claim about a
5383
+ * machine we cannot see, so the driver block is suppressed for remote targets.
5384
+ */
5385
+ function driverCheckAppliesTo(opts) {
5386
+ const target = resolveTarget(opts);
5387
+ return !target || isLocalBase(target.replace(/\/$/, ""));
5388
+ }
4782
5389
  federation
4783
5390
  .command("status")
4784
5391
  .description("Show federation status and peer connections")
4785
5392
  .option("--port <port>", "Harper HTTP port")
4786
5393
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
4787
5394
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
4788
- .option("--json", "Emit JSON {instance, peers} (also: pipe + FLAIR_OUTPUT=json)")
5395
+ .option("--json", "Emit JSON {instance, peers, driver} (also: pipe + FLAIR_OUTPUT=json)")
4789
5396
  .action(async (opts) => {
4790
5397
  const target = resolveTarget(opts);
4791
5398
  const baseUrl = target ? target.replace(/\/$/, "") : undefined;
@@ -4793,8 +5400,41 @@ federation
4793
5400
  try {
4794
5401
  const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
4795
5402
  const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
5403
+ // Driver state (flair#922). Computed from the LOCAL service manager, so
5404
+ // it is only meaningful when the CLI is pointed at the local instance.
5405
+ let driver = null;
5406
+ let assessment = null;
5407
+ if (driverCheckAppliesTo(opts)) {
5408
+ try {
5409
+ const { schedulerStatus, assessDriver } = await import("./federation/scheduler.js");
5410
+ driver = schedulerStatus();
5411
+ let lastSyncAt = null;
5412
+ for (const p of peers ?? []) {
5413
+ if (!p?.lastSyncAt)
5414
+ continue;
5415
+ const t = Date.parse(p.lastSyncAt);
5416
+ if (Number.isFinite(t) && (lastSyncAt === null || t > Date.parse(lastSyncAt))) {
5417
+ lastSyncAt = new Date(t).toISOString();
5418
+ }
5419
+ }
5420
+ assessment = assessDriver({
5421
+ installed: driver.installed,
5422
+ active: driver.active,
5423
+ intervalSeconds: driver.intervalSeconds,
5424
+ lastSyncAt,
5425
+ now: Date.now(),
5426
+ });
5427
+ }
5428
+ catch {
5429
+ // An unsupported platform (neither darwin nor linux) or an
5430
+ // unreadable unit must not take down `federation status` — the peer
5431
+ // table is still the primary output.
5432
+ driver = null;
5433
+ assessment = null;
5434
+ }
5435
+ }
4796
5436
  if (mode === "json") {
4797
- console.log(render.asJSON({ instance, peers }));
5437
+ console.log(render.asJSON({ instance, peers, driver, driverAssessment: assessment }));
4798
5438
  return;
4799
5439
  }
4800
5440
  const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
@@ -4806,6 +5446,26 @@ federation
4806
5446
  console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
4807
5447
  return;
4808
5448
  }
5449
+ // Print the driver line BEFORE the per-peer table: "is anything running
5450
+ // sync at all" is the question that decides how to read everything
5451
+ // below it.
5452
+ if (assessment) {
5453
+ const icon = assessment.verdict === "driving" || assessment.verdict === "external-driver"
5454
+ ? render.icons.ok
5455
+ : assessment.verdict === "unknown"
5456
+ ? render.icons.info
5457
+ : render.icons.warn;
5458
+ const color = assessment.verdict === "driving" || assessment.verdict === "external-driver"
5459
+ ? render.c.green
5460
+ : assessment.verdict === "unknown"
5461
+ ? render.c.dim
5462
+ : render.c.yellow;
5463
+ console.log();
5464
+ console.log(`${icon} ${render.wrap(color, assessment.headline)}`);
5465
+ console.log(` ${render.wrap(render.c.dim, assessment.detail)}`);
5466
+ if (assessment.remedy)
5467
+ console.log(` ${render.wrap(render.c.cyan, assessment.remedy)}`);
5468
+ }
4809
5469
  const now = Date.now();
4810
5470
  const formatPeerAge = (iso, refNow, staleAfterMs) => {
4811
5471
  if (!iso)
@@ -4867,7 +5527,16 @@ federation
4867
5527
  });
4868
5528
  if (haveStale) {
4869
5529
  console.log();
4870
- console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, "Check skippedReasons in SyncLog or run 'flair federation sync'.")}`);
5530
+ // The staleness warning used to fire identically whether sync was
5531
+ // running and the peer was unreachable, or nothing had run sync since
5532
+ // the day the spoke was paired (flair#922). Those need opposite
5533
+ // actions, so the remedy is now chosen by the driver verdict instead
5534
+ // of always pointing at SyncLog.
5535
+ const noDriver = assessment?.verdict === "no-driver" || assessment?.verdict === "driver-inactive";
5536
+ const remedy = noDriver
5537
+ ? "Nothing is driving sync — see the driver line above. Run 'flair federation sync enable'."
5538
+ : "Check skippedReasons in SyncLog or run 'flair federation sync'.";
5539
+ console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, remedy)}`);
4871
5540
  }
4872
5541
  const haveContactButNoMerge = peers.some((p) => {
4873
5542
  if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
@@ -5510,21 +6179,164 @@ export async function runFederationSyncOnce(opts) {
5510
6179
  return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
5511
6180
  }
5512
6181
  }
5513
- federation
6182
+ const federationSync = federation
5514
6183
  .command("sync")
5515
- .description("Push local changes to the hub")
6184
+ .description("Push local changes to the hub (one-shot). Subcommands manage the scheduled driver.")
5516
6185
  .option("--port <port>", "Harper HTTP port")
5517
6186
  .option("--admin-pass <pass>", "Admin password")
6187
+ .option("--admin-pass-file <path>", "Read the admin password from a file (e.g. ~/.flair/admin-pass). Preferred for launchd/cron — keeps the secret out of ps and shell history.")
5518
6188
  .option("--ops-port <port>", "Harper operations API port")
5519
6189
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
5520
6190
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
5521
6191
  .action(async (opts) => {
6192
+ // --admin-pass-file resolves into the same `adminPass` slot the inline
6193
+ // flag uses, so the scheduler never has to embed a secret in a unit file.
6194
+ // readAdminPassFileSecure() refuses a file that is not owner-only.
6195
+ if (!opts.adminPass && opts.adminPassFile) {
6196
+ try {
6197
+ opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
6198
+ }
6199
+ catch (err) {
6200
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
6201
+ process.exit(1);
6202
+ }
6203
+ }
5522
6204
  const r = await runFederationSyncOnce(opts);
5523
6205
  if (r.error) {
5524
6206
  console.error(`Error: ${r.error.message}`);
5525
6207
  process.exit(1);
5526
6208
  }
5527
6209
  });
6210
+ // ─── flair federation sync enable | disable | status ────────────────────────
6211
+ // The supervised driver (flair#922). Federation had no automatic driver at
6212
+ // all: `sync` is one-shot and `watch` is a foreground loop that dies with its
6213
+ // terminal, so every operator paired a spoke, saw one successful sync, and
6214
+ // then silently stopped syncing.
6215
+ //
6216
+ // Shape deliberately mirrors `flair rem nightly enable|disable|status` —
6217
+ // same verbs, same platform coverage (launchd on macOS, systemd --user timer
6218
+ // on Linux), same "never claim success before activation succeeded" rule.
6219
+ // Strategy is a PERIODIC ONE-SHOT rather than a supervised long-lived
6220
+ // watcher; the reasoning is in src/federation/scheduler.ts's header.
6221
+ federationSync
6222
+ .command("enable")
6223
+ .description("Install the sync driver (launchd on macOS, systemd timer on Linux)")
6224
+ .option("--interval <seconds>", `Seconds between syncs (default ${FEDERATION_SYNC_DEFAULT_INTERVAL})`, String(FEDERATION_SYNC_DEFAULT_INTERVAL))
6225
+ .option("--admin-pass-file <path>", "Path to a 0600 file holding the admin password (default ~/.flair/admin-pass when it exists). The PATH is stored in the unit — never the password.")
6226
+ // Deliberately NOT `--no-admin-pass-file`: commander treats a `--no-x` flag
6227
+ // as the negation of `--x`, and declaring both on one command makes the
6228
+ // POSITIVE option silently parse to undefined — `--admin-pass-file /path`
6229
+ // would be accepted and dropped, producing a driver that fails auth every
6230
+ // cycle with no error anywhere. Verified against commander 14.
6231
+ .option("--no-credentials", "Do not wire any credential file into the unit")
6232
+ .option("--target <url>", "Remote Flair URL to sync (default: the local instance)")
6233
+ .action(async (_opts, cmd) => {
6234
+ // optsWithGlobals(), NOT the action's first argument: `--admin-pass-file`
6235
+ // and `--target` are declared on BOTH this subcommand and its parent
6236
+ // (`flair federation sync`), and when a parent declares the same option
6237
+ // name commander binds the value to the PARENT — the subcommand's own
6238
+ // opts come back undefined. Reading only the local opts silently dropped
6239
+ // `--admin-pass-file <path>` here, which would have installed a driver
6240
+ // that failed auth every cycle with no error anywhere. Verified against
6241
+ // commander 14.
6242
+ const opts = cmd.optsWithGlobals();
6243
+ const intervalSeconds = Number(opts.interval);
6244
+ if (!Number.isFinite(intervalSeconds)) {
6245
+ console.error(`Error: --interval must be a number of seconds (got: ${opts.interval})`);
6246
+ process.exit(1);
6247
+ }
6248
+ // Default to the canonical admin-pass file when it is actually there.
6249
+ // Silently wiring a path that does not exist would produce a driver that
6250
+ // runs and fails auth every interval — the failure mode this whole issue
6251
+ // is about, in a new costume.
6252
+ let adminPassFile;
6253
+ if (opts.credentials === false) {
6254
+ adminPassFile = undefined;
6255
+ }
6256
+ else if (typeof opts.adminPassFile === "string" && opts.adminPassFile) {
6257
+ adminPassFile = opts.adminPassFile;
6258
+ }
6259
+ else {
6260
+ const fallback = defaultAdminPassPath();
6261
+ adminPassFile = existsSync(fallback) ? fallback : undefined;
6262
+ }
6263
+ const { enableScheduler, formatEnableReport } = await import("./federation/scheduler.js");
6264
+ try {
6265
+ const r = enableScheduler({ intervalSeconds, adminPassFile, target: opts.target });
6266
+ const { lines, ok } = formatEnableReport(r, { adminPassFile, target: opts.target });
6267
+ for (const line of lines)
6268
+ console.log(line);
6269
+ if (!ok)
6270
+ process.exit(1);
6271
+ }
6272
+ catch (err) {
6273
+ console.error(`Error: ${err.message}`);
6274
+ process.exit(1);
6275
+ }
6276
+ });
6277
+ federationSync
6278
+ .command("disable")
6279
+ .description("Remove the sync driver (peers and sync history are preserved)")
6280
+ .option("--remove-shim", "Also delete the ~/.flair/bin/flair-federation-sync shim")
6281
+ .action(async (opts) => {
6282
+ const { disableScheduler } = await import("./federation/scheduler.js");
6283
+ try {
6284
+ const r = disableScheduler({ removeShim: !!opts.removeShim });
6285
+ if (r.removed.length === 0) {
6286
+ console.log(`(Federation sync driver was not installed on ${r.platform})`);
6287
+ return;
6288
+ }
6289
+ console.log(`✅ Federation sync driver disabled (${r.platform})`);
6290
+ console.log(` Removed:`);
6291
+ for (const p of r.removed)
6292
+ console.log(` ${p}`);
6293
+ if (r.unloadResult && r.unloadResult.code !== 0) {
6294
+ console.log(` Unload: ${r.unloadCommand.join(" ")} → code ${r.unloadResult.code}`);
6295
+ if (r.unloadResult.stderr)
6296
+ console.log(` stderr: ${r.unloadResult.stderr.trim()}`);
6297
+ }
6298
+ console.log(`\nPeers, keys and sync history are untouched. Nothing will sync until you`);
6299
+ console.log(`re-enable the driver or run \`flair federation sync\` by hand.`);
6300
+ }
6301
+ catch (err) {
6302
+ console.error(`Error: ${err.message}`);
6303
+ process.exit(1);
6304
+ }
6305
+ });
6306
+ federationSync
6307
+ .command("status")
6308
+ .description("Show whether a sync driver is installed and genuinely active")
6309
+ .option("--port <port>", "Harper HTTP port")
6310
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
6311
+ .option("--json", "Emit JSON")
6312
+ .action(async (_opts, cmd) => {
6313
+ // See the comment on `enable` above: `--target`/`--port` are declared on
6314
+ // the parent too, so commander binds them there.
6315
+ const opts = cmd.optsWithGlobals();
6316
+ const { schedulerStatus, formatStatusReport, assessDriver } = await import("./federation/scheduler.js");
6317
+ try {
6318
+ const s = schedulerStatus();
6319
+ const lastSyncAt = await latestPeerContact(opts);
6320
+ const a = assessDriver({
6321
+ installed: s.installed,
6322
+ active: s.active,
6323
+ intervalSeconds: s.intervalSeconds,
6324
+ lastSyncAt,
6325
+ now: Date.now(),
6326
+ });
6327
+ if (render.resolveOutputMode(opts) === "json") {
6328
+ console.log(render.asJSON({ driver: s, assessment: a, lastSyncAt }));
6329
+ return;
6330
+ }
6331
+ const { lines } = formatStatusReport(s, a);
6332
+ for (const line of lines)
6333
+ console.log(line);
6334
+ }
6335
+ catch (err) {
6336
+ console.error(`Error: ${err.message}`);
6337
+ process.exit(1);
6338
+ }
6339
+ });
5528
6340
  export async function runFederationWatch(opts) {
5529
6341
  const intervalMs = Math.max(5, parseFloat(opts.interval) || 30) * 1000;
5530
6342
  let stopped = false;
@@ -6423,27 +7235,17 @@ remNightly
6423
7235
  const minute = parseInt(match[2], 10);
6424
7236
  const port = readPortFromConfig() ?? DEFAULT_PORT;
6425
7237
  const flairUrl = opts.flairUrl || process.env.FLAIR_URL || `http://127.0.0.1:${port}`;
6426
- const { enableScheduler } = await import("./rem/scheduler.js");
7238
+ const { enableScheduler, formatEnableReport } = await import("./rem/scheduler.js");
6427
7239
  try {
6428
7240
  const r = enableScheduler({ agentId, flairUrl, hour, minute });
6429
- console.log(`✅ REM nightly scheduler enabled (${r.platform})`);
6430
- console.log(` Schedule: ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} local time`);
6431
- console.log(` Scheduler: ${r.schedulerPath}`);
6432
- console.log(` Shim: ${r.shimPath}`);
6433
- console.log(` Agent: ${agentId}`);
6434
- console.log(` Flair URL: ${flairUrl}`);
6435
- if (r.loadResult) {
6436
- if (r.loadResult.code === 0) {
6437
- console.log(` Load: ${r.loadCommand.join(" ")} → ok`);
6438
- }
6439
- else {
6440
- console.log(` Load: ${r.loadCommand.join(" ")} → code ${r.loadResult.code}`);
6441
- if (r.loadResult.stderr)
6442
- console.log(` stderr: ${r.loadResult.stderr.trim()}`);
6443
- }
6444
- }
6445
- console.log(`\nTip: run \`flair rem nightly run-once --dry-run\` to verify the cycle works`);
6446
- console.log(` before the first scheduled fire. Disable with \`flair rem nightly disable\`.`);
7241
+ // formatEnableReport() owns the success-vs-failure decision (flair#850:
7242
+ // do not print a success headline before activation is known to have
7243
+ // succeeded) — see src/rem/scheduler.ts for the unit-tested logic.
7244
+ const { lines, ok } = formatEnableReport(r, { hour, minute, agentId, flairUrl });
7245
+ for (const line of lines)
7246
+ console.log(line);
7247
+ if (!ok)
7248
+ process.exit(1);
6447
7249
  }
6448
7250
  catch (err) {
6449
7251
  console.error(`Error: ${err.message}`);
@@ -6481,18 +7283,14 @@ remNightly
6481
7283
  });
6482
7284
  remNightly
6483
7285
  .command("status")
6484
- .description("Show whether the nightly scheduler is installed")
7286
+ .description("Show whether the nightly scheduler is installed and genuinely active")
6485
7287
  .action(async () => {
6486
- const { schedulerStatus } = await import("./rem/scheduler.js");
7288
+ const { schedulerStatus, formatStatusReport } = await import("./rem/scheduler.js");
6487
7289
  try {
6488
7290
  const s = schedulerStatus();
6489
- console.log(`REM nightly scheduler (${s.platform}):`);
6490
- console.log(` Installed: ${s.installed ? "yes" : "no"}`);
6491
- console.log(` Scheduler: ${s.schedulerPath}`);
6492
- console.log(` Shim: ${s.shimPath}${s.shimExists ? "" : " (missing)"}`);
6493
- if (!s.installed) {
6494
- console.log(`\nEnable with: flair rem nightly enable --agent <id> [--at HH:MM]`);
6495
- }
7291
+ const { lines } = formatStatusReport(s);
7292
+ for (const line of lines)
7293
+ console.log(line);
6496
7294
  }
6497
7295
  catch (err) {
6498
7296
  console.error(`Error: ${err.message}`);
@@ -7650,11 +8448,12 @@ export function resolveFabricCredentials(opts) {
7650
8448
  // Mirrors `flair deploy`'s credential handling (FABRIC_USER/FABRIC_PASSWORD env
7651
8449
  // fallbacks, password-via-flag warning, --fabric-password-file — see
7652
8450
  // resolveFabricCredentials above) and NEVER prints credentials. The
7653
- // version-resolution + @harperfast/harper pin + reuse of deploy() lives in
8451
+ // version-resolution + harper pin + reuse of deploy() lives in
7654
8452
  // src/fabric-upgrade.ts; this wrapper only does CLI plumbing + the confirm.
7655
8453
  async function runFabricUpgrade(opts) {
7656
8454
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
7657
8455
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
8456
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
7658
8457
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
7659
8458
  let fabricUser;
7660
8459
  let fabricPassword;
@@ -7690,6 +8489,12 @@ async function runFabricUpgrade(opts) {
7690
8489
  check,
7691
8490
  restart: opts.restart !== false,
7692
8491
  replicated: opts.replicated !== false,
8492
+ // flair#878 — previously unreachable from this command; see
8493
+ // FabricUpgradeOptions.
8494
+ deployRetries: Number(opts.deployRetries ?? 0),
8495
+ ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
8496
+ convergenceCheck: opts.convergenceCheck !== false,
8497
+ convergenceTimeoutMs: opts.convergenceTimeout != null ? Number(opts.convergenceTimeout) : undefined,
7693
8498
  };
7694
8499
  console.log(`${green("→")} Upgrading Fabric Flair at ${upgradeOpts.target}`);
7695
8500
  if (check)
@@ -7716,6 +8521,20 @@ async function runFabricUpgrade(opts) {
7716
8521
  console.log(`\n${green("✓")} already up to date.`);
7717
8522
  return;
7718
8523
  }
8524
+ if (result.convergedAfterReplicationError) {
8525
+ // flair#878: this deploy is a SUCCESS that harper's own exit code called
8526
+ // a failure. Say both halves out loud — an operator who saw the
8527
+ // replication error scroll past needs to know it resolved, and an
8528
+ // operator reading only this line needs to know it happened at all.
8529
+ console.log(`\n${yellow("⚠")} harper reported a peer-replication failure during this upgrade, but the component ` +
8530
+ `tree on every named peer node matched the origin when checked afterwards — replication converged ` +
8531
+ `on its own. Harper replicates components asynchronously, so a replication error at deploy time is ` +
8532
+ `a snapshot, not a verdict.`);
8533
+ }
8534
+ if (result.replicationWarning) {
8535
+ console.log(`\n${yellow("⚠")} Deployed to the ORIGIN NODE ONLY — peer replication did not converge and ` +
8536
+ `--ignore-replication-errors was set. The peer will need to catch up via federation sync or a later deploy.`);
8537
+ }
7719
8538
  console.log(`\n${green("✓")} Fabric upgrade complete.`);
7720
8539
  // ── Post-upgrade fleet sweep (flair#636) ────────────────────────────────
7721
8540
  // "deploy complete" from harper's own CLI means "origin took it" — this
@@ -7747,6 +8566,15 @@ async function runFabricUpgrade(opts) {
7747
8566
  if (hint.includes("401") || hint.includes("unauthoriz")) {
7748
8567
  console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
7749
8568
  }
8569
+ // flair#878: harper's own replication error tells the operator to "pass
8570
+ // ignore_replication_errors: true" — until now there was no way to do that
8571
+ // through `flair upgrade`. Name the flag that actually does it, and the
8572
+ // one that turns off the retry that can make things worse.
8573
+ if (hint.includes("peer replication") || hint.includes("ignore_replication_errors")) {
8574
+ console.error(dim(" hint: --ignore-replication-errors accepts an origin-only upgrade (the peer catches up via federation sync or a later deploy)"));
8575
+ console.error(dim(" hint: --convergence-timeout <ms> waits longer for asynchronous replication before giving up (default 180000)"));
8576
+ console.error(dim(" hint: --deploy-retries defaults to 0 — a retry can turn a transient replication warning into a hard install failure (flair#878)"));
8577
+ }
7750
8578
  process.exit(1);
7751
8579
  }
7752
8580
  }
@@ -7758,7 +8586,7 @@ async function runFabricUpgrade(opts) {
7758
8586
  // before the package swap, with a keep-last-3 retention policy.
7759
8587
  //
7760
8588
  // Native-backup alternative considered and rejected: Harper ships a
7761
- // `get_backup` operation (@harperfast/harper's dataLayer/getBackup.ts,
8589
+ // `get_backup` operation (harper's dataLayer/getBackup.ts,
7762
8590
  // wired in server/serverHelpers/serverUtilities.ts, documented in
7763
8591
  // components/mcp/tools/schemas/operationDescriptions.ts) that streams a
7764
8592
  // live backup over the running HTTP operations API. It's available in this
@@ -7938,6 +8766,22 @@ export const UPGRADE_SNAPSHOT_NUDGE_LINES = [
7938
8766
  const snapshotCmd = program
7939
8767
  .command("snapshot")
7940
8768
  .description("Physical ~/.flair/data snapshots (byte-exact tar.gz, local-only — see `flair backup`/`flair restore` for the logical JSON export/import)");
8769
+ /**
8770
+ * `resolveHttpPort` for a command that takes `--data-dir`, reported as a
8771
+ * message rather than a stack trace (flair#914).
8772
+ *
8773
+ * The throw is a refusal to guess which instance a directory is, and a refusal
8774
+ * has to tell the operator what to pass instead — a stack trace does not.
8775
+ */
8776
+ function resolveHttpPortForDataDir(opts) {
8777
+ try {
8778
+ return resolveHttpPort(opts);
8779
+ }
8780
+ catch (err) {
8781
+ console.error(`❌ ${err?.message ?? err}`);
8782
+ process.exit(1);
8783
+ }
8784
+ }
7941
8785
  snapshotCmd
7942
8786
  .command("create")
7943
8787
  .description("Take a physical snapshot of the Flair data directory now (briefly stops Flair for a consistent copy — use `flair backup` for a no-downtime logical export)")
@@ -7945,11 +8789,16 @@ snapshotCmd
7945
8789
  .option("--port <port>", "Harper HTTP port (used to quiesce Flair around the snapshot)")
7946
8790
  .action(async (opts) => {
7947
8791
  const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
7948
- const port = resolveHttpPort(opts);
8792
+ // Existence first, THEN the port. A directory that isn't there has a more
8793
+ // specific diagnosis than "it doesn't say which port it serves", and the
8794
+ // caller should get the one that names the actual problem (flair#914).
7949
8795
  if (!existsSync(dataDir)) {
7950
8796
  console.error(`Error: data directory does not exist: ${dataDir}`);
7951
8797
  process.exit(1);
7952
8798
  }
8799
+ // flair#914: the port of the instance NAMED here, never the per-user
8800
+ // file's — refuses rather than guessing when that directory has no record.
8801
+ const port = resolveHttpPortForDataDir(opts);
7953
8802
  console.log(`Snapshotting ${dataDir}...`);
7954
8803
  console.log("(Flair will be briefly stopped for a point-in-time-consistent copy, then restarted.)");
7955
8804
  // Same consistency requirement as the upgrade path's snapshot step: a
@@ -7960,7 +8809,9 @@ snapshotCmd
7960
8809
  // point-in-time-consistent guarantee, not a weaker one.
7961
8810
  let stoppedForSnapshot = false;
7962
8811
  try {
7963
- await stopFlairProcess(port);
8812
+ // `dataDir`, not the default (flair#902) — quiesce the instance this
8813
+ // command was pointed at, never whichever one owns ~/.flair/data.
8814
+ await stopFlairProcess(port, dataDir);
7964
8815
  stoppedForSnapshot = true;
7965
8816
  const snapshot = await createDataSnapshot(dataDir);
7966
8817
  const removed = pruneOldSnapshots();
@@ -7973,14 +8824,14 @@ snapshotCmd
7973
8824
  console.error(`❌ snapshot failed: ${err.message}`);
7974
8825
  if (stoppedForSnapshot) {
7975
8826
  try {
7976
- await startFlairProcess(port);
8827
+ await startFlairProcess(port, dataDir);
7977
8828
  }
7978
8829
  catch { /* best effort — surface the original snapshot error, not this */ }
7979
8830
  }
7980
8831
  process.exit(1);
7981
8832
  }
7982
8833
  try {
7983
- await startFlairProcess(port);
8834
+ await startFlairProcess(port, dataDir);
7984
8835
  }
7985
8836
  catch (err) {
7986
8837
  console.error(`❌ the snapshot succeeded but Flair failed to restart: ${err.message}`);
@@ -8038,7 +8889,9 @@ snapshotCmd
8038
8889
  process.exit(1);
8039
8890
  }
8040
8891
  const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
8041
- const port = resolveHttpPort(opts);
8892
+ // flair#914: the port of the instance NAMED here, never the per-user
8893
+ // file's — refuses rather than guessing when that directory has no record.
8894
+ const port = resolveHttpPortForDataDir(opts);
8042
8895
  console.log("This will STOP Flair, DELETE the current data directory, and replace it with:");
8043
8896
  console.log(` snapshot: ${snapshotPath}`);
8044
8897
  console.log(` target: ${dataDir}`);
@@ -8056,32 +8909,62 @@ snapshotCmd
8056
8909
  }
8057
8910
  }
8058
8911
  try {
8059
- await stopFlairProcess(port);
8912
+ // `dataDir`, not the default (flair#902) — the whole point of this
8913
+ // command's --data-dir is that it may name a scratch directory, and
8914
+ // stopping the default instance instead is how a cautious inspect-a-
8915
+ // snapshot-somewhere-else took production down.
8916
+ await stopFlairProcess(port, dataDir);
8060
8917
  }
8061
8918
  catch (err) {
8062
8919
  console.error(`❌ failed to stop Flair: ${err.message}`);
8063
8920
  process.exit(1);
8064
8921
  }
8922
+ // Validate the archive BEFORE the destructive rmSync below. Restore
8923
+ // accepts snapshots this CLI did not create — copied off another machine,
8924
+ // downloaded, handed over during a migration — so the archive is untrusted
8925
+ // input, and a hostile one must not cost the operator their data directory
8926
+ // on its way to being refused.
8927
+ try {
8928
+ await validateSnapshotArchive({ file: snapshotPath, targetDir: dataDir });
8929
+ }
8930
+ catch (err) {
8931
+ console.error(`❌ ${err.message}`);
8932
+ console.error(` ${dataDir} was NOT modified.`);
8933
+ process.exit(1);
8934
+ }
8065
8935
  try {
8066
8936
  rmSync(dataDir, { recursive: true, force: true });
8067
8937
  mkdirSync(dataDir, { recursive: true, mode: 0o700 });
8068
- // preservePaths mirrors createDataSnapshot's own preservePaths: true —
8069
- // restores absolute symlink targets verbatim instead of node-tar's
8070
- // default of stripping the leading "/" on extraction. No `follow`
8071
- // option, so symlinks extract as symlinks (never their targets'
8072
- // contents), and file modes extract exactly as stored — the archive
8073
- // itself already only contains what createDataSnapshot's filter chose
8074
- // to include (in-bounds symlinks, regular files/dirs only), so restore
8075
- // needs no re-filtering of its own.
8076
- await tarExtract({ file: snapshotPath, cwd: dataDir, preservePaths: true });
8938
+ // extractSnapshotSafely keeps preservePaths: true — load-bearing for
8939
+ // symlink TARGET fidelity, mirroring createDataSnapshot while doing
8940
+ // the entry-path containment that flag disables. See
8941
+ // src/lib/safe-snapshot-extract.ts for why the flag cannot simply be
8942
+ // dropped. No `follow` option, so symlinks extract as symlinks (never
8943
+ // their targets' contents), and file modes extract exactly as stored.
8944
+ await extractSnapshotSafely({ file: snapshotPath, targetDir: dataDir });
8077
8945
  }
8078
8946
  catch (err) {
8079
8947
  console.error(`❌ restore failed: ${err.message}`);
8080
8948
  console.error(` ${dataDir} may be partially restored or empty — do not start Flair until this is resolved.`);
8081
8949
  process.exit(1);
8082
8950
  }
8951
+ // flair#914: a snapshot is a byte-exact copy of a data directory, so it
8952
+ // carries the SOURCE instance's harper-config.yaml, and the extract just
8953
+ // wrote it over this instance's. Between here and the boot below, that file
8954
+ // names the SOURCE's port — but nothing re-resolves in that window: `port`
8955
+ // was resolved before the extract and is handed to startFlairProcess
8956
+ // explicitly, and Harper rewrites http.port / operationsApi.network.port
8957
+ // from that spawn's environment as it boots. So the directory is
8958
+ // self-describing again the moment it is serving, without flair writing into
8959
+ // Harper's config to make it so.
8960
+ //
8961
+ // The port is a property of the instance, not of the data it serves. That
8962
+ // is also what keeps a snapshot from somewhere else out of the business of
8963
+ // naming ports on this host: restoring one to look at it cannot hand the
8964
+ // restored directory a port it did not have — the boot immediately below is
8965
+ // what settles the question, on this host's terms.
8083
8966
  try {
8084
- await startFlairProcess(port);
8967
+ await startFlairProcess(port, dataDir);
8085
8968
  }
8086
8969
  catch (err) {
8087
8970
  console.error(`❌ restore succeeded but Flair failed to restart: ${err.message}`);
@@ -8104,17 +8987,25 @@ program
8104
8987
  // ── Fabric upgrade (--target) ────────────────────────────────────────────
8105
8988
  // When --target is passed, upgrade the Flair component DEPLOYED to that
8106
8989
  // Harper Fabric URL instead of the local npm install. Reuses `flair deploy`
8107
- // under the hood with the @harperfast/harper pin baked in (flair#513).
8990
+ // under the hood with the harper pin baked in (flair#513).
8108
8991
  .option("--target <url>", "Upgrade the Flair deployed to this Fabric URL (not the local install)")
8109
8992
  .option("--fabric-user <user>", "Fabric admin username — for --target (env: FABRIC_USER preferred; inline leaks to shell history)")
8110
8993
  .option("--fabric-password <pass>", "Fabric admin password — for --target (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
8111
8994
  .option("--fabric-password-file <path>", "Read the Fabric admin password from a file (chmod 600) — for --target")
8112
8995
  .option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
8113
- .option("--harper-version <semver>", "Pin @harperfast/harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
8996
+ .option("--harper-version <semver>", "Pin harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
8114
8997
  .option("--project <name>", "Fabric component name for --target", "flair")
8115
8998
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
8116
8999
  .option("--yes", "Skip the confirmation prompt for --target")
8117
9000
  .option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
9001
+ // ── flair#878 ─────────────────────────────────────────────────────────────
9002
+ // These existed on `flair deploy` but stopped at the upgrade boundary, so
9003
+ // harper's own remedy ("pass ignore_replication_errors: true") was not
9004
+ // actually reachable through `flair upgrade --target`.
9005
+ .option("--deploy-retries <n>", "Retry the full harper deploy this many times for --target, ONLY when peer replication is positively observed not to converge (default: 0 — a retry can escalate a transient replication warning into a hard install failure; see flair#878)", "0")
9006
+ .option("--ignore-replication-errors", "For --target: if peer replication still hasn't converged, accept an origin-only deploy instead of failing (the peer catches up via federation sync or a later deploy)")
9007
+ .option("--no-convergence-check", "For --target: skip the post-replication-error convergence poll and fail on harper's error verbatim (default: poll — Harper replicates asynchronously, so its error is a snapshot, not a verdict; flair#878)")
9008
+ .option("--convergence-timeout <ms>", "For --target: how long to wait for peer replication to converge before reporting a replication failure (default: 180000)")
8118
9009
  .action(async (opts) => {
8119
9010
  // ── Fabric-upgrade branch ───────────────────────────────────────────────
8120
9011
  if (opts.target) {
@@ -8250,6 +9141,13 @@ program
8250
9141
  // `opts` — safe to call this early.
8251
9142
  const { restart: shouldRestart, verify: shouldVerify, deprecatedRestartFlagUsed } = resolveUpgradeRestartVerify(opts);
8252
9143
  const upgradePort = resolveHttpPort({});
9144
+ // The instance this upgrade is about, named once next to its port
9145
+ // (flair#902). `flair upgrade` has no --data-dir, so this IS the default
9146
+ // install — but stop/start/restart now take the directory explicitly, so
9147
+ // the choice is made here in the open rather than assumed inside them.
9148
+ // A default that happens to be right is the same defect waiting for the
9149
+ // next caller.
9150
+ const upgradeDataDir = defaultDataDir();
8253
9151
  // Hoisted so the pre-flight check (below) and the post-restart/rollback
8254
9152
  // verification steps (further down) all target the same URL — upgrade
8255
9153
  // never restarts Flair onto a different port.
@@ -8332,8 +9230,7 @@ program
8332
9230
  // the exact same mechanism as a standalone command for anyone who wants
8333
9231
  // one without wrapping it around an upgrade.
8334
9232
  const flairIsUpgrading = npmUpgrades.some((u) => u.pkg === "@tpsdev-ai/flair");
8335
- const snapshotDataDir = defaultDataDir();
8336
- const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, existsSync(snapshotDataDir));
9233
+ const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, existsSync(upgradeDataDir));
8337
9234
  let snapshotPath = null;
8338
9235
  if (snapshotDecision === "nudge") {
8339
9236
  // Non-blocking nudge only — never prompt/block here, this must stay
@@ -8346,7 +9243,7 @@ program
8346
9243
  console.log(render.wrap(render.c.dim, line));
8347
9244
  }
8348
9245
  else if (snapshotDecision === "no-data") {
8349
- console.log(`\n(no data directory at ${snapshotDataDir} yet — nothing to snapshot)`);
9246
+ console.log(`\n(no data directory at ${upgradeDataDir} yet — nothing to snapshot)`);
8350
9247
  }
8351
9248
  else if (snapshotDecision === "snapshot") {
8352
9249
  console.log("\nSnapshotting data before upgrade...");
@@ -8365,9 +9262,9 @@ program
8365
9262
  // the server being up).
8366
9263
  let stoppedForSnapshot = false;
8367
9264
  try {
8368
- await stopFlairProcess(upgradePort);
9265
+ await stopFlairProcess(upgradePort, upgradeDataDir);
8369
9266
  stoppedForSnapshot = true;
8370
- const snapshot = await createDataSnapshot(snapshotDataDir);
9267
+ const snapshot = await createDataSnapshot(upgradeDataDir);
8371
9268
  snapshotPath = snapshot.path;
8372
9269
  const removed = pruneOldSnapshots();
8373
9270
  console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
@@ -8381,14 +9278,14 @@ program
8381
9278
  console.error(" Aborting upgrade — no packages were changed. Omit --snapshot to proceed without one (not recommended).");
8382
9279
  if (stoppedForSnapshot) {
8383
9280
  try {
8384
- await startFlairProcess(upgradePort);
9281
+ await startFlairProcess(upgradePort, upgradeDataDir);
8385
9282
  }
8386
9283
  catch { /* best effort — surface the original snapshot error, not this */ }
8387
9284
  }
8388
9285
  process.exit(1);
8389
9286
  }
8390
9287
  try {
8391
- await startFlairProcess(upgradePort);
9288
+ await startFlairProcess(upgradePort, upgradeDataDir);
8392
9289
  }
8393
9290
  catch (err) {
8394
9291
  console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
@@ -8464,15 +9361,118 @@ program
8464
9361
  console.log("\nRestarting Flair...");
8465
9362
  const port = upgradePort;
8466
9363
  // baseUrl was hoisted above (pre-flight, fix #1) — same URL, no redeclaration.
9364
+ /**
9365
+ * Roll @tpsdev-ai/flair back to `toVersion`, restart on it, re-verify, and
9366
+ * exit. Shared by the two ways an upgrade can fail after the package swap:
9367
+ * the restart itself (flair#905) and post-restart verification (flair#635).
9368
+ *
9369
+ * flair#905 found the restart leg wired straight to `process.exit(1)` — so
9370
+ * `docs/upgrade.md`'s "install → restart → verify → rollback-on-failure, in
9371
+ * one step" was only ever true for the verify leg. An upgrade that installed
9372
+ * new packages and then failed to start them left the operator on the new
9373
+ * version with nothing running and no rollback, which is the one outcome the
9374
+ * whole transaction exists to prevent.
9375
+ */
9376
+ const rollbackTo = async (toVersion, reason) => {
9377
+ console.log(`\nRolling back @tpsdev-ai/flair to ${toVersion}...`);
9378
+ try {
9379
+ execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
9380
+ }
9381
+ catch (err) {
9382
+ console.error(`❌ rollback install failed: ${err.message}`);
9383
+ console.error(` Flair is currently on the FAILED version (${expectedFlairVersion ?? "unknown"}) and is NOT running.`);
9384
+ console.error(` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
9385
+ process.exit(1);
9386
+ }
9387
+ // Same post-swap rule as the upgrade restart above: the rolled-back
9388
+ // version's own CLI is the thing that knows how to start it.
9389
+ const rolledBackCli = resolveInstalledFlairCli(flairPackageDir(), toVersion);
9390
+ try {
9391
+ await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
9392
+ }
9393
+ catch (err) {
9394
+ console.error(`❌ rollback restart failed: ${err.message}`);
9395
+ console.error(` @tpsdev-ai/flair@${toVersion} is installed but NOT running. Start it with: flair start`);
9396
+ console.error(" Then check: flair status");
9397
+ process.exit(1);
9398
+ }
9399
+ const rollbackVerify = await probeInstance(baseUrl, {
9400
+ expectVersion: toVersion,
9401
+ timeoutMs: STARTUP_TIMEOUT_MS,
9402
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
9403
+ });
9404
+ const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
9405
+ if (rollbackVerdict.kind === "rolled-back") {
9406
+ console.error(`❌ upgrade failed and was rolled back to @tpsdev-ai/flair@${toVersion} (running, verified).`);
9407
+ console.error(` Original failure: ${reason}`);
9408
+ process.exit(1);
9409
+ }
9410
+ console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
9411
+ // flair#741 fix #3: this is the exact incident report — a 403 from a
9412
+ // responding, healthy server (credentials-only failure) was printed as
9413
+ // "state UNKNOWN — do not assume data integrity" for BOTH the upgrade
9414
+ // verify AND the rollback re-verify, because the same missing-auth-
9415
+ // material condition rejects both. Reserve the UNKNOWN/do-not-assume
9416
+ // text for failures where the instance's real state genuinely can't be
9417
+ // determined (connection refused, timeout, 5xx) — a credential-only
9418
+ // failure here means the rollback likely landed fine and the checker
9419
+ // simply can't prove it.
9420
+ if (isCredentialOnlyFailure(rollbackVerify)) {
9421
+ console.error(" The instance is up and responding — the verifier could not authenticate (credentials, not the rollback, are the problem).");
9422
+ console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key, then check: flair doctor");
9423
+ }
9424
+ else {
9425
+ console.error(" Instance state is UNKNOWN — do not assume data integrity.");
9426
+ }
9427
+ // This double-failure isn't auto-recoverable yet (flair#637) — but if a
9428
+ // pre-upgrade snapshot landed, point at the CONCRETE path instead of
9429
+ // just the issue number, so recovery doesn't start with a GitHub search.
9430
+ if (snapshotPath) {
9431
+ console.error(` A pre-upgrade snapshot is available: ${snapshotPath}`);
9432
+ console.error(` Restore: flair snapshot restore "${snapshotPath}" (or see docs/upgrade.md#downgrade).`);
9433
+ }
9434
+ else {
9435
+ console.error(" No pre-upgrade snapshot was taken for this run (snapshot is opt-in — pass --snapshot next time, or ~/.flair/data didn't exist yet).");
9436
+ console.error(" Check `flair snapshot list` for a manual one, or restore from a `flair backup` JSON export. See docs/upgrade.md#downgrade.");
9437
+ }
9438
+ process.exit(1);
9439
+ };
9440
+ // flair#905: hand the restart to the CLI that was just installed, resolved
9441
+ // from disk AFTER the swap. `null` (flair itself wasn't swapped, or the new
9442
+ // tree can't be verified) falls back to an in-process restart, announced.
9443
+ const flairWasSwapped = flairIsUpgrading && !flairInstallFailed;
9444
+ let newCli = null;
9445
+ if (flairWasSwapped) {
9446
+ const resolved = resolveInstalledFlairCli(flairPackageDir(), expectedFlairVersion);
9447
+ if (resolved.ok === false) {
9448
+ console.error(`warning: could not verify the newly installed CLI (${resolved.reason}) — restarting with this process's own code instead.`);
9449
+ }
9450
+ else {
9451
+ newCli = { cliPath: resolved.cliPath, version: resolved.version };
9452
+ }
9453
+ }
9454
+ let restartWasDelegated = false;
8467
9455
  try {
8468
- await restartFlair(port);
9456
+ restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
8469
9457
  }
8470
9458
  catch (err) {
8471
9459
  console.error(`❌ restart failed: ${err.message}`);
8472
- console.error(" Flair may be partially down. Check: flair doctor");
9460
+ console.error(" Flair is NOT running. Your data in ~/.flair was not touched by this upgrade.");
9461
+ if (flairWasSwapped && previousFlairVersion) {
9462
+ await rollbackTo(previousFlairVersion, `restart failed: ${err.message}`);
9463
+ }
9464
+ // Not reached when a rollback ran — rollbackTo always exits. Say WHICH of
9465
+ // the two "no rollback" cases this is; "nothing to roll back" is not the
9466
+ // same statement as "we don't know what to roll back to".
9467
+ console.error(flairWasSwapped
9468
+ ? " Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown."
9469
+ : " Nothing to roll back: @tpsdev-ai/flair itself was not changed by this upgrade.");
9470
+ console.error(" Start it with: flair start — then check: flair status");
8473
9471
  process.exit(1);
8474
9472
  }
8475
- console.log("✅ Flair restarted");
9473
+ // The delegated `flair restart` printed its own success line; don't say it twice.
9474
+ if (!restartWasDelegated)
9475
+ console.log("✅ Flair restarted");
8476
9476
  if (!shouldVerify) {
8477
9477
  console.log(" (--no-verify: skipping post-restart verification)");
8478
9478
  return;
@@ -8515,63 +9515,7 @@ program
8515
9515
  console.error(" Check the instance now: flair doctor");
8516
9516
  process.exit(1);
8517
9517
  }
8518
- console.log(`\nRolling back @tpsdev-ai/flair to ${verdict.toVersion}...`);
8519
- try {
8520
- execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${verdict.toVersion}`], { stdio: "pipe" });
8521
- }
8522
- catch (err) {
8523
- console.error(`❌ rollback install failed: ${err.message}`);
8524
- console.error(` Flair is currently running the FAILED version (${expectedFlairVersion ?? "unknown"}). Manual intervention required.`);
8525
- process.exit(1);
8526
- }
8527
- try {
8528
- await restartFlair(port);
8529
- }
8530
- catch (err) {
8531
- console.error(`❌ rollback restart failed: ${err.message}`);
8532
- console.error(" Instance state is UNKNOWN — it may be down entirely. Check: flair doctor");
8533
- process.exit(1);
8534
- }
8535
- const rollbackVerify = await probeInstance(baseUrl, {
8536
- expectVersion: verdict.toVersion,
8537
- timeoutMs: STARTUP_TIMEOUT_MS,
8538
- authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
8539
- });
8540
- const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
8541
- if (rollbackVerdict.kind === "rolled-back") {
8542
- console.error(`❌ upgrade failed verification and was rolled back to @tpsdev-ai/flair@${verdict.toVersion}.`);
8543
- console.error(` Original failure: ${verdict.reason}`);
8544
- process.exit(1);
8545
- }
8546
- console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
8547
- // flair#741 fix #3: this is the exact incident report — a 403 from a
8548
- // responding, healthy server (credentials-only failure) was printed as
8549
- // "state UNKNOWN — do not assume data integrity" for BOTH the upgrade
8550
- // verify AND the rollback re-verify, because the same missing-auth-
8551
- // material condition rejects both. Reserve the UNKNOWN/do-not-assume
8552
- // text for failures where the instance's real state genuinely can't be
8553
- // determined (connection refused, timeout, 5xx) — a credential-only
8554
- // failure here means the rollback likely landed fine and the checker
8555
- // simply can't prove it.
8556
- if (isCredentialOnlyFailure(rollbackVerify)) {
8557
- console.error(" The instance is up and responding — the verifier could not authenticate (credentials, not the rollback, are the problem).");
8558
- console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key, then check: flair doctor");
8559
- }
8560
- else {
8561
- console.error(" Instance state is UNKNOWN — do not assume data integrity.");
8562
- }
8563
- // This double-failure isn't auto-recoverable yet (flair#637) — but if a
8564
- // pre-upgrade snapshot landed, point at the CONCRETE path instead of
8565
- // just the issue number, so recovery doesn't start with a GitHub search.
8566
- if (snapshotPath) {
8567
- console.error(` A pre-upgrade snapshot is available: ${snapshotPath}`);
8568
- console.error(` Restore: flair snapshot restore "${snapshotPath}" (or see docs/upgrade.md#downgrade).`);
8569
- }
8570
- else {
8571
- 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).");
8572
- console.error(" Check `flair snapshot list` for a manual one, or restore from a `flair backup` JSON export. See docs/upgrade.md#downgrade.");
8573
- }
8574
- process.exit(1);
9518
+ await rollbackTo(verdict.toVersion, verdict.reason);
8575
9519
  });
8576
9520
  // ─── flair stop ───────────────────────────────────────────────────────────────
8577
9521
  program
@@ -8598,14 +9542,19 @@ program
8598
9542
  }
8599
9543
  }
8600
9544
  }
8601
- // Fallback: find process by port
9545
+ // Fallback: find process by port. Listening sockets only, never our own
9546
+ // PID — see parseListeningPids (flair#800/flair#905): this used to SIGTERM
9547
+ // every process holding ANY socket on the port, so `flair stop` could kill
9548
+ // itself (leaving Flair running) or kill an unrelated client of it.
8602
9549
  try {
8603
9550
  const { execSync } = await import("node:child_process");
8604
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
8605
- if (lsof) {
8606
- const pids = lsof.split("\n").map(p => p.trim()).filter(Boolean);
9551
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
9552
+ if (pids.length > 0) {
8607
9553
  for (const pid of pids) {
8608
- process.kill(Number(pid), "SIGTERM");
9554
+ try {
9555
+ process.kill(pid, "SIGTERM");
9556
+ }
9557
+ catch { /* already gone */ }
8609
9558
  }
8610
9559
  console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
8611
9560
  }
@@ -8662,40 +9611,30 @@ program
8662
9611
  }
8663
9612
  }
8664
9613
  // Direct start (Linux, or macOS fallback when no launchd plist)
8665
- const bin = harperBin();
8666
- if (!bin) {
8667
- console.error("❌ Harper binary not found. Run 'flair init' first.");
9614
+ const harper = resolveHarperBin(harperSearchRoots());
9615
+ if (!harper.path) {
9616
+ console.error(`❌ ${harperBinNotFoundMessage(harper.searched)}`);
8668
9617
  process.exit(1);
8669
9618
  }
9619
+ const bin = harper.path;
8670
9620
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
8671
- const opsPort = resolveOpsPort(opts);
8672
- // flair#670: this fallback path (no launchd plist) sets no HARPER_SET_CONFIG,
8673
- // so OPERATIONSAPI_NETWORK_PORT applies unfiltered on top of whatever init
8674
- // persisted to harper-config.yaml. A bare port number here would silently
8675
- // strip a loopback (or escape-hatch) bind host back to all-interfaces on
8676
- // every plain `flair start`. Re-resolving the same host:port form init uses
8677
- // keeps this re-assertion consistent instead of regressing it — the
8678
- // escape hatch on this path is FLAIR_OPS_BIND (no --ops-bind flag on `start`).
8679
- const opsBindHost = resolveOpsBindHost({});
8680
- const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
9621
+ // flair#670/#863: this fallback path (no launchd plist) sets no
9622
+ // HARPER_SET_CONFIG, so the ops bind has to be re-asserted explicitly on
9623
+ // every spawn see buildDirectSpawnEnv. The escape hatch on this path is
9624
+ // FLAIR_OPS_BIND or the `opsBind` that `flair init --ops-bind` persisted to
9625
+ // ~/.flair/config.yaml; there is no --ops-bind flag on `start`.
8681
9626
  const env = {
8682
9627
  ...process.env,
8683
- ROOTPATH: dataDir,
8684
- // See the matching comment at the install-time spawn site above.
8685
- FLAIR_MODELS_DIR: modelsDir,
8686
- DEFAULTS_MODE: "dev",
8687
- HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
8688
- HTTP_PORT: String(port),
8689
- OPERATIONSAPI_NETWORK_PORT: `${opsBindHost}:${opsPort}`,
8690
- LOCAL_STUDIO: "false",
9628
+ ...buildDirectSpawnEnv({
9629
+ dataDir,
9630
+ modelsDir: process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models"),
9631
+ httpPort: port,
9632
+ opsPort: resolveOpsPort(opts),
9633
+ opsBindHost: resolveOpsBindHost({}),
9634
+ adminUser: DEFAULT_ADMIN_USER,
9635
+ adminPass,
9636
+ }),
8691
9637
  };
8692
- // Only set HDB_ADMIN_PASSWORD if we have a real value — empty string
8693
- // would strip Harper's auth on an existing install
8694
- if (adminPass) {
8695
- env.HDB_ADMIN_PASSWORD = adminPass;
8696
- }
8697
- // models (flair#504 Phase 1): no env var needed — resources/embeddings-boot.ts
8698
- // self-registers the backend in-process on every boot (flair#694).
8699
9638
  const proc = spawn(process.execPath, [bin, "run", "."], {
8700
9639
  cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
8701
9640
  });
@@ -8711,6 +9650,81 @@ program
8711
9650
  }
8712
9651
  });
8713
9652
  // ─── flair restart ────────────────────────────────────────────────────────────
9653
+ /**
9654
+ * Refuse to act on a launchd service that belongs to a DIFFERENT data
9655
+ * directory than the one the command is operating on (flair#902).
9656
+ *
9657
+ * `resolveLaunchdLabel`'s instance-scoped label is a hash of the data dir,
9658
+ * so it can never address another instance — but its pre-flair#693 legacy
9659
+ * fallback CAN: `ai.tpsdev.flair` is a single global label, returned for
9660
+ * ANY data dir whenever that plist exists. On a host that still has one,
9661
+ * `flair snapshot restore --data-dir <scratch>` would resolve the legacy
9662
+ * service and stop whatever install it actually belongs to.
9663
+ *
9664
+ * The plist records the instance it was written for (`ROOTPATH`), so the
9665
+ * check is exact. Refuses ONLY on positive contradiction: a plist with no
9666
+ * ROOTPATH (hand-written, or some other writer's) is no evidence and is
9667
+ * left alone rather than blocking a legitimate stop.
9668
+ *
9669
+ * Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD. Only the
9670
+ * extracted ROOTPATH path ever reaches a message.
9671
+ */
9672
+ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9673
+ let declared = null;
9674
+ try {
9675
+ const raw = readFileSync(plistPath, "utf-8");
9676
+ const m = raw.match(/<key>ROOTPATH<\/key>\s*<string>([^<]*)<\/string>/);
9677
+ // The plist stores this XML-escaped (buildLaunchdPlist), so a data dir
9678
+ // containing `&` is on disk as `&amp;`. Decode before comparing, or the
9679
+ // path would never equal itself and this guard would refuse a legitimate
9680
+ // stop/start on any instance whose path contains an escaped character.
9681
+ declared = m ? unescapeXml(m[1]) : null;
9682
+ }
9683
+ catch {
9684
+ return; // unreadable — no evidence, don't block
9685
+ }
9686
+ if (declared === null)
9687
+ return;
9688
+ if (resolve(declared) === resolve(dataDir))
9689
+ return;
9690
+ throw new Error(`refusing to ${action} launchd service ${label}: its plist (${plistPath}) is registered to data directory ` +
9691
+ `${resolve(declared)}, not ${resolve(dataDir)} — that is a different Flair instance. ` +
9692
+ `Re-run with --data-dir ${resolve(declared)} to act on that one, or run ` +
9693
+ `'flair init --data-dir ${resolve(dataDir)}' to register a service for this one.`);
9694
+ }
9695
+ /**
9696
+ * Refuse a port-based SIGTERM that cannot be attributed to `dataDir`
9697
+ * (flair#902).
9698
+ *
9699
+ * The port fallback below identifies its target by port number and nothing
9700
+ * else, so `--data-dir <scratch>` with a port that scratch instance does not
9701
+ * serve signals whichever instance DOES serve it. That is the whole of this
9702
+ * bug on Linux, where there is no launchd path at all.
9703
+ *
9704
+ * Scoped deliberately to a non-default data dir. For the default install the
9705
+ * port genuinely is that instance's port by every convention in this CLI
9706
+ * (`writeConfig`/`readPortFromConfig`), and the only evidence available here
9707
+ * — `<dataDir>/hdb.pid` vs the listening PIDs — is not something we can
9708
+ * require without risking a false refusal on a working install whose PID file
9709
+ * is missing or whose listener is a worker. So the default path keeps today's
9710
+ * behavior exactly, and the residual gap is stated rather than papered over:
9711
+ * a default-dir port stop is still unattributed. The new refusal can only
9712
+ * fire for a caller that explicitly named another data dir — the case that is
9713
+ * wrong today whenever the port does not match.
9714
+ */
9715
+ function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
9716
+ if (resolve(dataDir) === defaultDataDir())
9717
+ return;
9718
+ const expected = readHarperPid(dataDir);
9719
+ if (expected !== null && listeningPids.includes(expected))
9720
+ return;
9721
+ const why = expected === null
9722
+ ? `no hdb.pid under that directory, so it does not look like a running instance`
9723
+ : `its recorded PID ${expected} is not the process listening on ${port}`;
9724
+ throw new Error(`refusing to stop the process listening on port ${port}: it cannot be attributed to ${resolve(dataDir)} (${why}). ` +
9725
+ `Stopping by port alone would signal a different Flair instance. ` +
9726
+ `Pass --port with the port ${resolve(dataDir)} actually serves, or omit --data-dir to operate on the default install.`);
9727
+ }
8714
9728
  /**
8715
9729
  * Stop the local Flair (Harper) process — launchd `stop` on darwin when a
8716
9730
  * plist is present (falling back on failure), otherwise a manual SIGTERM by
@@ -8719,19 +9733,34 @@ program
8719
9733
  * start without duplicating this logic — `restartFlair` is now just
8720
9734
  * `stopFlairProcess` followed by `startFlairProcess`.
8721
9735
  *
9736
+ * `dataDir` is REQUIRED and names the instance to stop — it is not a
9737
+ * convenience parameter. It used to be resolved internally from
9738
+ * `defaultDataDir()`, which meant `flair snapshot create|restore --data-dir
9739
+ * <elsewhere>` stopped the DEFAULT instance and reported success
9740
+ * (flair#902). A port alone does not identify an instance to launchd; the
9741
+ * data dir does, via `resolveLaunchdLabel`.
9742
+ *
8722
9743
  * Idempotent-ish: stopping an already-stopped instance is a harmless no-op
8723
9744
  * on both paths (launchctl stop on an unloaded/idle service, or an empty
8724
9745
  * `lsof` match).
9746
+ *
9747
+ * Throws when the resolved target provably belongs to a different instance
9748
+ * — see assertLaunchdServiceOwnedBy / assertPortInstanceOwnedBy. Callers
9749
+ * already treat a failed stop as fatal, which is the point: refusing beats
9750
+ * quiescing the wrong install.
8725
9751
  */
8726
- async function stopFlairProcess(port) {
9752
+ async function stopFlairProcess(port, dataDir) {
8727
9753
  if (process.platform === "darwin") {
8728
- const dataDir = defaultDataDir();
8729
9754
  // resolveLaunchdLabel (flair#693) finds whichever label this data dir
8730
9755
  // is currently registered under (new instance-scoped, or a
8731
9756
  // pre-flair#693 legacy install) — stop only needs to operate on
8732
9757
  // whichever exists, no migration.
8733
9758
  const { label, plistPath } = resolveLaunchdLabel(dataDir);
8734
9759
  if (existsSync(plistPath)) {
9760
+ // Outside the try below: an ownership refusal must NOT degrade into
9761
+ // the port-based fallback, which would go on to signal by port the
9762
+ // very instance we just refused to touch.
9763
+ assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "stop");
8735
9764
  try {
8736
9765
  const { execSync } = await import("node:child_process");
8737
9766
  // Ensure the service is loaded (init writes the plist but doesn't load it)
@@ -8759,45 +9788,55 @@ async function stopFlairProcess(port) {
8759
9788
  }
8760
9789
  // Port-based stop (Linux, or macOS fallback when no launchd plist)
8761
9790
  console.log("Stopping...");
8762
- try {
8763
- const { execSync } = await import("node:child_process");
8764
- // -sTCP:LISTEN: match the LISTENING server only a bare `lsof -ti :port`
8765
- // also matches CLIENT sockets referencing the port, including THIS CLI's
8766
- // own keep-alive connections left by the credential pre-flight's
8767
- // probeInstance() HTTP calls (flair#741). Without the filter, the upgrade
8768
- // path SIGTERM'd its own process mid-restart "Stopping..." then death
8769
- // (exit 143) before "Starting..." ever ran, leaving the server down
8770
- // (flair#800, deterministic on the Linux/non-launchd default path).
8771
- const lsof = execSync(`lsof -ti :${port} -sTCP:LISTEN`, { encoding: "utf-8" }).trim();
8772
- if (lsof) {
8773
- for (const pid of lsof.split("\n")) {
8774
- const target = Number(pid.trim());
8775
- // Belt-and-suspenders: never SIGTERM ourselves, whatever lsof says.
8776
- if (!Number.isFinite(target) || target === process.pid)
8777
- continue;
8778
- try {
8779
- process.kill(target, "SIGTERM");
8780
- }
8781
- catch { }
8782
- }
8783
- // Wait briefly for shutdown
8784
- await new Promise(r => setTimeout(r, 2000));
9791
+ const { execSync } = await import("node:child_process");
9792
+ // -sTCP:LISTEN plus a self-PID guard, both inside listeningPidsOnPort: a bare
9793
+ // `lsof -ti :port` also matches CLIENT sockets referencing the port, including
9794
+ // THIS CLI's own keep-alive connections left by the credential pre-flight's
9795
+ // probeInstance() HTTP calls (flair#741). Without the filter, the upgrade
9796
+ // path SIGTERM'd its own process mid-restart "Stopping..." then death
9797
+ // (exit 143) before "Starting..." ever ran, leaving the server down
9798
+ // (flair#800, deterministic on the Linux/non-launchd default path).
9799
+ // flair#905 moved both halves into that one helper because the same
9800
+ // unfiltered pattern had survived in `flair stop`, `flair uninstall` and
9801
+ // `flair doctor` — one guarded resolver is what keeps the next site honest.
9802
+ // It returns [] when lsof matches nothing, which is this path's "not running".
9803
+ const targets = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
9804
+ if (targets.length === 0)
9805
+ return;
9806
+ // Deliberately outside any catch: a refusal must reach the caller, not be
9807
+ // swallowed as "not running" and reported as a successful stop.
9808
+ assertPortInstanceOwnedBy(port, dataDir, targets);
9809
+ for (const target of targets) {
9810
+ try {
9811
+ process.kill(target, "SIGTERM");
8785
9812
  }
9813
+ catch { }
8786
9814
  }
8787
- catch { /* not running */ }
9815
+ // Wait briefly for shutdown
9816
+ await new Promise((r) => setTimeout(r, 2000));
8788
9817
  }
8789
9818
  /**
8790
9819
  * Start the local Flair (Harper) process — launchd `start` on darwin when a
8791
9820
  * plist is present (falling back on failure), otherwise a direct spawn.
8792
9821
  * Counterpart to `stopFlairProcess`; see that function's doc comment.
9822
+ *
9823
+ * `dataDir` is REQUIRED for the same reason it is on `stopFlairProcess`
9824
+ * (flair#902): it, not the port, is what identifies the instance. This
9825
+ * function resolved it internally from `defaultDataDir()` too, so the
9826
+ * snapshot commands' restart leg brought the DEFAULT instance back up after
9827
+ * operating on a `--data-dir` elsewhere.
8793
9828
  */
8794
- async function startFlairProcess(port) {
8795
- const dataDir = defaultDataDir();
9829
+ async function startFlairProcess(port, dataDir) {
8796
9830
  if (process.platform === "darwin") {
8797
9831
  // resolveLaunchdLabel (flair#693) finds whichever label this data dir
8798
9832
  // is currently registered under before we attempt anything.
8799
- const { plistPath } = resolveLaunchdLabel(dataDir);
9833
+ const { label, plistPath } = resolveLaunchdLabel(dataDir);
8800
9834
  if (existsSync(plistPath)) {
9835
+ // Same ownership gate as the stop path (flair#902), and outside the
9836
+ // try for the same reason: starting the wrong service would then wait
9837
+ // for health on `port`, see the OTHER instance answer, and report
9838
+ // success.
9839
+ assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "start");
8801
9840
  try {
8802
9841
  const { execSync } = await import("node:child_process");
8803
9842
  ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
@@ -8811,31 +9850,39 @@ async function startFlairProcess(port) {
8811
9850
  }
8812
9851
  }
8813
9852
  console.log("Starting...");
8814
- const bin = harperBin();
8815
- if (!bin) {
8816
- throw new Error("Harper binary not found. Run 'flair init' first.");
9853
+ const harper = resolveHarperBin(harperSearchRoots());
9854
+ if (!harper.path) {
9855
+ throw new Error(harperBinNotFoundMessage(harper.searched));
8817
9856
  }
9857
+ const bin = harper.path;
8818
9858
  // Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
8819
9859
  // Without this, `flair init --admin-pass X` (which only exports HDB_*
8820
9860
  // to the initial Harper spawn) followed by `flair restart` would silently
8821
9861
  // drop admin credentials — any subsequent auth'd call returns 401.
8822
9862
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
8823
- const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
9863
+ // flair#863: the same env `flair start` builds, from the same builder — this
9864
+ // path sets no HARPER_SET_CONFIG, and Harper RESTORES the pre-SET_CONFIG
9865
+ // original for every key SET_CONFIG had forced whenever that variable is
9866
+ // absent (cleanupRemovedEnvVar; see opsNetworkPortValue). This site used to
9867
+ // set no OPERATIONSAPI_NETWORK_PORT at all, so every `flair restart` /
9868
+ // `flair upgrade` on the non-launchd path silently reverted
9869
+ // `operationsApi.network.port` to a bare number in harper-config.yaml and
9870
+ // re-bound the ops API to all interfaces — permanently, since the reverted
9871
+ // value is what the next boot reads. It also made doctor's
9872
+ // `flair init && flair restart` remedy a no-op: the restart undid whatever
9873
+ // init had just written.
8824
9874
  const env = {
8825
9875
  ...process.env,
8826
- ROOTPATH: dataDir,
8827
- // See the matching comment at the install-time spawn site above.
8828
- FLAIR_MODELS_DIR: modelsDir,
8829
- DEFAULTS_MODE: "dev",
8830
- HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
8831
- HTTP_PORT: String(port),
8832
- LOCAL_STUDIO: "false",
9876
+ ...buildDirectSpawnEnv({
9877
+ dataDir,
9878
+ modelsDir: process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models"),
9879
+ httpPort: port,
9880
+ opsPort: resolveOpsPort({ port }),
9881
+ opsBindHost: resolveOpsBindHost({}),
9882
+ adminUser: DEFAULT_ADMIN_USER,
9883
+ adminPass,
9884
+ }),
8833
9885
  };
8834
- if (adminPass) {
8835
- env.HDB_ADMIN_PASSWORD = adminPass;
8836
- }
8837
- // models (flair#504 Phase 1): no env var needed — resources/embeddings-boot.ts
8838
- // self-registers the backend in-process on every boot (flair#694).
8839
9886
  const proc = spawn(process.execPath, [bin, "run", "."], {
8840
9887
  cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
8841
9888
  });
@@ -8854,28 +9901,119 @@ async function startFlairProcess(port) {
8854
9901
  * Throws on failure instead of calling process.exit — callers decide how to
8855
9902
  * react (`flair restart` exits 1; `flair upgrade` treats a failed restart as
8856
9903
  * an upgrade failure and may attempt a rollback).
9904
+ *
9905
+ * Takes `dataDir` explicitly (flair#902) so the instance being bounced is
9906
+ * named at the call site rather than assumed from `defaultDataDir()` two
9907
+ * frames down.
8857
9908
  */
8858
- async function restartFlair(port) {
8859
- await stopFlairProcess(port);
8860
- await startFlairProcess(port);
9909
+ async function restartFlair(port, dataDir) {
9910
+ await stopFlairProcess(port, dataDir);
9911
+ await startFlairProcess(port, dataDir);
8861
9912
  // Bust the version-handshake cache so the next preAction nudge re-fetches
8862
9913
  // the LIVE version instead of the pre-restart cached one (the false
8863
9914
  // "server is running <old>" users hit for up to 60s post-upgrade+restart).
8864
9915
  // Same (rootPath, serverUrl) key the preAction hook computes (~line 2189)
8865
- // — must match exactly, or this busts the wrong cache file.
9916
+ // — must match exactly, or this busts the wrong cache file. Deliberately
9917
+ // NOT `dataDir`: the key is whatever the preAction hook computed for THIS
9918
+ // process, and using the restarted instance's dir instead would bust a
9919
+ // different cache file (or none) and leave the stale entry in place.
8866
9920
  try {
8867
9921
  invalidateHandshakeCache(process.env.ROOTPATH ?? defaultDataDir(), `http://127.0.0.1:${port}`);
8868
9922
  }
8869
9923
  catch { /* best-effort — never fail a restart over cache cleanup */ }
8870
9924
  }
9925
+ export function resolveInstalledFlairCli(packageRoot, expectVersion, deps = {}) {
9926
+ const exists = deps.exists ?? existsSync;
9927
+ const read = deps.read ?? ((p) => readFileSync(p, "utf-8"));
9928
+ const cliPath = join(packageRoot, "dist", "cli.js");
9929
+ if (!exists(cliPath))
9930
+ return { ok: false, reason: `no dist/cli.js at ${cliPath}` };
9931
+ let version;
9932
+ try {
9933
+ version = JSON.parse(read(join(packageRoot, "package.json"))).version ?? "";
9934
+ }
9935
+ catch (err) {
9936
+ return { ok: false, reason: `could not read ${join(packageRoot, "package.json")}: ${err?.message ?? err}` };
9937
+ }
9938
+ if (!version)
9939
+ return { ok: false, reason: `${join(packageRoot, "package.json")} declares no version` };
9940
+ if (expectVersion && version !== expectVersion) {
9941
+ return { ok: false, reason: `${packageRoot} holds ${version}, expected ${expectVersion}` };
9942
+ }
9943
+ return { ok: true, cliPath, version };
9944
+ }
9945
+ /**
9946
+ * Restart Flair after a package swap, through the newly installed CLI when one
9947
+ * could be located and in-process otherwise.
9948
+ *
9949
+ * The in-process fallback is deliberate and is NOT a silent one: it announces
9950
+ * which path it took and why. A missing/unverifiable new CLI means the swap
9951
+ * itself is suspect, and refusing to restart at all would turn a recoverable
9952
+ * upgrade into a guaranteed outage — but a fallback nobody can see in the
9953
+ * output is how "it restarted fine" and "it restarted with the wrong code"
9954
+ * become indistinguishable after the fact.
9955
+ *
9956
+ * Delegation is limited to the DEFAULT data directory, and that limit is not
9957
+ * incidental. The child is `flair restart`, which has no `--data-dir` and
9958
+ * therefore restarts `defaultDataDir()` — handing it a `dataDir` that is not
9959
+ * the default would restart a different instance and then wait for health on
9960
+ * `port` and watch the wrong one answer. That is flair#902 exactly, and the
9961
+ * required `dataDir` parameter here exists so the condition is checkable rather
9962
+ * than assumed. Today the upgrade path only ever operates on the default
9963
+ * install, so this never triggers; if that changes, `flair restart` needs a
9964
+ * `--data-dir` before this may delegate.
9965
+ *
9966
+ * Throws on failure; the caller owns the rollback decision. Returns whether the
9967
+ * restart was delegated, so the caller can leave the success line to whichever
9968
+ * process actually printed one.
9969
+ */
9970
+ async function restartAfterUpgrade(port, dataDir, newCliArg) {
9971
+ let newCli = newCliArg;
9972
+ if (newCli && resolve(dataDir) !== defaultDataDir()) {
9973
+ console.error(`warning: not delegating the restart to ${newCli.cliPath} — it would restart ${defaultDataDir()}, ` +
9974
+ `not ${resolve(dataDir)}. Restarting with this process's own code instead.`);
9975
+ newCli = null;
9976
+ }
9977
+ if (!newCli) {
9978
+ await restartFlair(port, dataDir);
9979
+ return false;
9980
+ }
9981
+ console.log(` (restarting via the newly installed CLI: ${newCli.cliPath} @ ${newCli.version})`);
9982
+ const { spawnSync } = await import("node:child_process");
9983
+ const res = spawnSync(process.execPath, [newCli.cliPath, "restart", "--port", String(port)], {
9984
+ encoding: "utf-8",
9985
+ // The child runs the same stop→start→waitForHealth sequence this process
9986
+ // would have; give it the full startup budget plus slack for the stop leg
9987
+ // rather than killing a restart that is merely slow.
9988
+ timeout: STARTUP_TIMEOUT_MS * 3,
9989
+ env: process.env,
9990
+ });
9991
+ if (res.stdout)
9992
+ process.stdout.write(res.stdout);
9993
+ if (res.stderr)
9994
+ process.stderr.write(res.stderr);
9995
+ if (res.error) {
9996
+ throw new Error(`could not run the newly installed CLI (${newCli.cliPath}): ${res.error.message}`);
9997
+ }
9998
+ if (res.status !== 0) {
9999
+ const detail = (res.stderr ?? "").trim().split("\n").filter(Boolean).pop();
10000
+ throw new Error(`the newly installed CLI (@tpsdev-ai/flair@${newCli.version}) failed to restart Flair` +
10001
+ `${res.signal ? ` (killed by ${res.signal})` : ` (exit ${res.status})`}` +
10002
+ `${detail ? `: ${detail}` : ""}`);
10003
+ }
10004
+ return true;
10005
+ }
8871
10006
  program
8872
10007
  .command("restart")
8873
10008
  .description("Restart the Flair (Harper) instance")
8874
10009
  .option("--port <port>", "Harper HTTP port")
8875
10010
  .action(async (opts) => {
8876
10011
  const port = resolveHttpPort(opts);
10012
+ // Explicit, not defaulted inside restartFlair (flair#902): `flair
10013
+ // restart` has no --data-dir, so the default install IS what it means —
10014
+ // and saying so here is what keeps that true when someone adds one.
8877
10015
  try {
8878
- await restartFlair(port);
10016
+ await restartFlair(port, defaultDataDir());
8879
10017
  console.log("✅ Flair restarted");
8880
10018
  }
8881
10019
  catch (err) {
@@ -8914,14 +10052,16 @@ program
8914
10052
  if (removedAny)
8915
10053
  console.log("✅ Launchd service removed");
8916
10054
  }
8917
- // Kill any process still on the port (covers direct-start, no-service, or failed unload)
10055
+ // Kill any process still on the port (covers direct-start, no-service, or
10056
+ // failed unload). Listening sockets only, never our own PID — see
10057
+ // parseListeningPids (flair#800/flair#905).
8918
10058
  try {
8919
10059
  const { execSync } = await import("node:child_process");
8920
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
8921
- if (lsof) {
8922
- for (const pid of lsof.split("\n")) {
10060
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
10061
+ if (pids.length > 0) {
10062
+ for (const pid of pids) {
8923
10063
  try {
8924
- process.kill(Number(pid.trim()), "SIGTERM");
10064
+ process.kill(pid, "SIGTERM");
8925
10065
  }
8926
10066
  catch { }
8927
10067
  }
@@ -9052,7 +10192,7 @@ program
9052
10192
  // through the HNSW cosine index, which throws "Cosine distance comparison
9053
10193
  // requires an array" against rows whose stored embedding shape is
9054
10194
  // incompatible with the running Harper version (e.g. data written under
9055
- // @harperfast/harper@5.0.1 read under 5.0.9). The ops API bypasses the
10195
+ // harper@5.0.1 read under 5.0.9). The ops API bypasses the
9056
10196
  // vector index — exactly what we need when the goal is to replace every
9057
10197
  // embedding with a freshly-computed one. Without this path, `flair
9058
10198
  // reembed` could not recover from the very condition it exists to fix.
@@ -9336,8 +10476,10 @@ program
9336
10476
  .option("--no-verify", "Skip post-deploy served-API verification (default: verify — on by design, so the CLI can't report success on an empty/broken deploy)")
9337
10477
  .option("--verify-timeout <ms>", "Milliseconds to wait for the served API to settle after harper's post-deploy restart before verifying (default: 300000)")
9338
10478
  .option("--verify-resource <name>", "Resource to verify is serving after deploy (repeatable; default: derived from the deployed package's dist/resources)", (val, prev) => [...prev, val], [])
9339
- .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")
9340
- .option("--ignore-replication-errors", "If peer replication is still failing once retries are exhausted, treat it as a non-fatal warning and succeed with an origin-only deploy (the peer catches up via federation sync or a later deploy)")
10479
+ .option("--deploy-retries <n>", "Retry the full harper deploy this many times, ONLY when peer replication is positively observed not to converge (default: 0 retrying re-runs harper's component install and can escalate a transient replication warning into a hard ENOTEMPTY install failure; see flair#878)", "0")
10480
+ .option("--ignore-replication-errors", "If peer replication still hasn't converged, treat it as a non-fatal warning and succeed with an origin-only deploy (the peer catches up via federation sync or a later deploy)")
10481
+ .option("--no-convergence-check", "Skip the post-replication-error convergence poll and fail on harper's error verbatim (default: poll — Harper replicates asynchronously, so its error is a snapshot, not a verdict; flair#878)")
10482
+ .option("--convergence-timeout <ms>", "How long to wait for peer replication to converge before reporting a replication failure (default: 180000)")
9341
10483
  .option("--no-fleet-verify", "Skip the automatic post-deploy fleet convergence sweep (default: sweep runs — see flair#636)")
9342
10484
  .action(async (opts) => {
9343
10485
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
@@ -9372,8 +10514,10 @@ program
9372
10514
  verify: opts.verify !== false,
9373
10515
  verifyResources: opts.verifyResource?.length ? opts.verifyResource : undefined,
9374
10516
  verifyTimeoutMs: Number(opts.verifyTimeout ?? 300_000),
9375
- deployRetries: Number(opts.deployRetries ?? 2),
10517
+ deployRetries: Number(opts.deployRetries ?? 0),
9376
10518
  ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
10519
+ convergenceCheck: opts.convergenceCheck !== false,
10520
+ convergenceTimeoutMs: opts.convergenceTimeout != null ? Number(opts.convergenceTimeout) : undefined,
9377
10521
  onProgress: (msg) => console.log(dim(` ${msg}`)),
9378
10522
  };
9379
10523
  const errors = validateDeployOptions(deployOpts);
@@ -9398,6 +10542,11 @@ program
9398
10542
  console.log(dim(` package root: ${result.packageRoot}`));
9399
10543
  return;
9400
10544
  }
10545
+ if (result.convergedAfterReplicationError) {
10546
+ // flair#878: harper's exit code said failure; the per-node component
10547
+ // comparison said otherwise. Both halves get said out loud.
10548
+ console.log(`\n${yellow("⚠")} harper reported a peer-replication failure, but every named peer node's component tree matched the origin when checked afterwards — replication converged on its own.`);
10549
+ }
9401
10550
  if (result.replicationWarning) {
9402
10551
  console.log(`\n${yellow("⚠")} Flair ${result.version} deployed to the ORIGIN NODE ONLY — peer replication did not complete (see warning above). The peer will catch up via federation sync or a later deploy.`);
9403
10552
  }
@@ -9452,8 +10601,9 @@ program
9452
10601
  if (hint?.includes("did not settle")) {
9453
10602
  console.error(dim(" hint: Harper may still be restarting — check Fabric Studio, or retry with a longer --verify-timeout"));
9454
10603
  }
9455
- if (hint?.includes("peer replication failed after")) {
10604
+ if (hint?.includes("peer replication")) {
9456
10605
  console.error(dim(" hint: pass --ignore-replication-errors to accept an origin-only deploy, or re-run once the peer link recovers"));
10606
+ console.error(dim(" hint: --convergence-timeout <ms> waits longer for asynchronous replication before giving up (default 180000)"));
9457
10607
  }
9458
10608
  process.exit(1);
9459
10609
  }
@@ -9487,7 +10637,7 @@ Exit codes:
9487
10637
 
9488
10638
  "peer" here means a Flair federation peer (GET /FederationPeers on the
9489
10639
  origin) — NOT Harper's own cluster-replication nodes, which the OSS
9490
- @harperfast/harper build this CLI ships does not expose (cluster_status is
10640
+ harper build this CLI ships does not expose (cluster_status is
9491
10641
  a harper-pro-only operation). A Fabric replica that was never
9492
10642
  federation-paired (\`flair federation pair\`) is invisible to this sweep —
9493
10643
  see src/fleet-verify.ts's file header for the full caveat.`)
@@ -9662,7 +10812,10 @@ program
9662
10812
  console.log(` ${render.wrap(render.c.dim, "Would update config to port")} ${discoveredPort}`);
9663
10813
  }
9664
10814
  else {
9665
- writeConfig(discoveredPort);
10815
+ // dataDir0 is defaultDataDir() — `flair doctor` has no
10816
+ // --data-dir, so the default install is what it means, and
10817
+ // saying so keeps that true when it grows one (flair#914).
10818
+ persistDefaultInstallCoordinates(dataDir0, discoveredPort);
9666
10819
  console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
9667
10820
  fixed++;
9668
10821
  }
@@ -9686,8 +10839,13 @@ program
9686
10839
  // Check if something else grabbed the port
9687
10840
  try {
9688
10841
  const { execSync } = await import("node:child_process");
9689
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
9690
- if (lsof) {
10842
+ // Listening sockets only, never our own PID doctor has already
10843
+ // probed this port over HTTP, so a bare lsof reports doctor's own
10844
+ // process as the squatter and tells the operator to kill it
10845
+ // (flair#905; see parseListeningPids).
10846
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
10847
+ if (pids.length > 0) {
10848
+ const lsof = pids.join(" ");
9691
10849
  console.log(` ${render.icons.error} Nothing responding on port ${port} ${render.wrap(render.c.dim, `(port occupied by PID ${lsof})`)}`);
9692
10850
  console.log(` ${render.wrap(render.c.dim, "Fix:")} kill ${lsof} && flair restart`);
9693
10851
  }
@@ -9809,9 +10967,8 @@ program
9809
10967
  // (see its doc comment) now reuses the existing password instead, so this
9810
10968
  // remedy is safe to follow on a working install.
9811
10969
  try {
9812
- const harperConfigPath = join(defaultDataDir(), "harper-config.yaml");
9813
- if (existsSync(harperConfigPath)) {
9814
- const harperConfig = parseYaml(readFileSync(harperConfigPath, "utf-8")) || {};
10970
+ const harperConfig = readHarperConfig(defaultDataDir());
10971
+ if (harperConfig) {
9815
10972
  const opsPortValue = harperConfig?.operationsApi?.network?.port;
9816
10973
  const bind = detectOpsApiAllInterfacesBind(opsPortValue);
9817
10974
  if (bind.allInterfaces) {
@@ -9947,6 +11104,16 @@ program
9947
11104
  else {
9948
11105
  let claudeCodeAgentId;
9949
11106
  let anyKnownAgentId;
11107
+ // `doctor --fix` writes client configs through the same wire functions
11108
+ // init does, so it owes the user the same warning when the spec it would
11109
+ // write cannot be pinned (flair#907).
11110
+ if (autoFix) {
11111
+ const pinWarning = unpinnedSpecWarning();
11112
+ if (pinWarning) {
11113
+ for (const line of pinWarning.split("\n"))
11114
+ console.log(` ${render.icons.warn} ${line}`);
11115
+ }
11116
+ }
9950
11117
  for (const client of detectedClients) {
9951
11118
  const block = readClientMcpBlock(client.id, homedir());
9952
11119
  if (client.id === "claude-code" && block.agentId)
@@ -10265,9 +11432,30 @@ program
10265
11432
  if (migBlock.cyclePhase === "pre-hash") {
10266
11433
  console.log(`${indent}${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
10267
11434
  }
11435
+ // flair#812: the boot trigger sets `scheduled` synchronously at
11436
+ // module load, so `idle` means resources/migration-boot.js never
11437
+ // loaded in the serving process — NO migration will ever run on
11438
+ // this instance, which is precisely the failure that went unnoticed
11439
+ // because a skipped cycle looked identical to a clean one.
11440
+ if (migBlock.cyclePhase === "idle") {
11441
+ console.log(`${indent}${render.icons.error} Migration boot cycle never fired on this instance — no migration will run until this is resolved. Check the instance log for [flair-migrations] and confirm the running build ships dist/resources/migration-boot.js.`);
11442
+ issues++;
11443
+ }
11444
+ // A cycle that reached a terminal phase carrying an error explains
11445
+ // itself here rather than only in the process log — the reason
11446
+ // string names the paths tried and the remedy.
11447
+ if (migBlock.lastCycleError) {
11448
+ console.log(`${indent}${render.icons.error} Last migration cycle did not complete: ${migBlock.lastCycleError}`);
11449
+ issues++;
11450
+ }
10268
11451
  for (const m of migBlock.migrations) {
10269
11452
  if (m.state === "completed") {
10270
- console.log(`${indent}${render.icons.ok} ${m.id}: completed`);
11453
+ // flair#812: a `reason` on a COMPLETED migration means the
11454
+ // runner short-circuited it from the (hand-editable) state file
11455
+ // rather than verifying the corpus this boot. Print it, so an
11456
+ // unverified claim is never rendered as a verified one.
11457
+ const note = m.reason ? ` ${render.wrap(render.c.dim, `(${m.reason})`)}` : "";
11458
+ console.log(`${indent}${render.icons.ok} ${m.id}: completed${note}`);
10271
11459
  }
10272
11460
  else if (m.state === "halted" || m.state === "failed") {
10273
11461
  console.log(`${indent}${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
@@ -11346,6 +12534,17 @@ sessionSnapshot
11346
12534
  process.exit(1);
11347
12535
  }
11348
12536
  mkdirSync(targetDir, { recursive: true, mode: 0o700 });
12537
+ // Deliberately NOT preservePaths, and deliberately NOT routed through
12538
+ // extractSnapshotSafely. --snapshot is an operator-supplied path, so
12539
+ // provenance here is no more controlled than the data-dir restore's — the
12540
+ // difference is the flag, not the trust. node-tar's defaults keep their
12541
+ // own containment: leading "/" stripped from entry paths, ".." entries
12542
+ // dropped, and no writing through a symlink (including one created
12543
+ // earlier in the same archive). Verified against the pinned tar (7.5.20)
12544
+ // on all four cases, each contained, with a benign control entry landing
12545
+ // to prove the archives parsed. Add `preservePaths` here and that
12546
+ // containment disappears — this call would then need extractSnapshotSafely,
12547
+ // exactly as the data-dir restore does. See src/lib/safe-snapshot-extract.ts.
11349
12548
  await tarExtract({ file: snapshotPath, cwd: targetDir });
11350
12549
  console.log(targetDir);
11351
12550
  console.error(` extracted to: ${targetDir}`);
@@ -13862,6 +15061,8 @@ if (import.meta.main) {
13862
15061
  await runCli();
13863
15062
  }
13864
15063
  // ─── Exported for testing ─────────────────────────────────────────────────────
13865
- export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
15064
+ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost,
15065
+ // Harper's own config — the per-instance port record (flair#914)
15066
+ harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
13866
15067
  // launchd label (flair#693)
13867
15068
  LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };