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