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