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