@tpsdev-ai/flair 0.34.0 → 0.36.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/config.yaml CHANGED
@@ -81,3 +81,20 @@ authentication:
81
81
  # own risk.
82
82
  authorizeLocal: false
83
83
  enableSessions: true
84
+
85
+ # @harperfast/oauth — authorization server component declaration.
86
+ # Commented out by default: the plugin is only needed when FLAIR_MCP_OAUTH=on.
87
+ # When you enable the flag without uncommenting this block, the boot guard
88
+ # (resources/mcp-oauth.ts) logs an error with the exact YAML to add — uncomment
89
+ # the block below and add mcp.* config. The issuer is derived at runtime from
90
+ # FLAIR_MCP_ISSUER / FLAIR_PUBLIC_URL; do not hardcode one here.
91
+ #
92
+ # "@harperfast/oauth":
93
+ # providers:
94
+ # default:
95
+ # authorizationEndpoint: "/OAuthAuthorize"
96
+ # tokenEndpoint: "/OAuthToken"
97
+ # revocationEndpoint: "/OAuthRevoke"
98
+ # registrationEndpoint: "/OAuthRegister"
99
+ # jwksUri: "/.well-known/jwks.json"
100
+ # discoveryEndpoint: "/.well-known/oauth-authorization-server"
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { deploy as deployToFabric, validateOptions as validateDeployOptions, bui
14
14
  import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, assertNoSecretKeysAdded, describePublicUrlFinding, planComponentEnv, readEnvValue, } from "./component-env.js";
15
15
  import { fabricUpgrade } from "./fabric-upgrade.js";
16
16
  import { checkVersion, formatVersionNudge, primeVersionCheckCache, FLAIR_PKG_NAME } from "./version-check.js";
17
+ import { readInstalledHarperVersion, fetchDeclaredHarperVersion, writeEngineVersionStamp, checkEngineVersionBackwards, UPGRADE_SNAPSHOT_ROOT, } from "./engine-version.js";
17
18
  import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache } from "./version-handshake.js";
18
19
  import { probeInstance } from "./probe.js";
19
20
  import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
@@ -8823,7 +8824,8 @@ async function runFabricUpgrade(opts) {
8823
8824
  // standalone filesystem utility). Rejected in favor of the file-level
8824
8825
  // snapshot below. See docs/upgrade.md for the restore procedure this
8825
8826
  // produces.
8826
- const UPGRADE_SNAPSHOT_ROOT = resolve(homedir(), ".flair", "upgrade-snapshots");
8827
+ // UPGRADE_SNAPSHOT_ROOT is defined in engine-version.ts (the module that owns the path)
8828
+ // and imported from there for all callers.
8827
8829
  const UPGRADE_SNAPSHOT_RETAIN = 3;
8828
8830
  function upgradeSnapshotFileName() {
8829
8831
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
@@ -8947,9 +8949,12 @@ export function pruneOldSnapshots(retain = UPGRADE_SNAPSHOT_RETAIN, snapshotRoot
8947
8949
  }
8948
8950
  return removed;
8949
8951
  }
8950
- export function decideUpgradeSnapshotAction(flairIsUpgrading, snapshotRequested, hasDataDir) {
8952
+ export function decideUpgradeSnapshotAction(flairIsUpgrading, snapshotRequested, hasDataDir, engineVersionChanging, engineSnapshotOptOut) {
8951
8953
  if (!flairIsUpgrading)
8952
8954
  return "not-upgrading";
8955
+ // Engine version change forces a snapshot unless explicitly opted out.
8956
+ if (engineVersionChanging && hasDataDir && !engineSnapshotOptOut)
8957
+ return "engine-version-change";
8953
8958
  if (!snapshotRequested)
8954
8959
  return hasDataDir ? "nudge" : "not-upgrading";
8955
8960
  return hasDataDir ? "snapshot" : "no-data";
@@ -8967,6 +8972,75 @@ export const UPGRADE_SNAPSHOT_NUDGE_LINES = [
8967
8972
  "No pre-upgrade snapshot will be taken.",
8968
8973
  "To capture one first: `flair snapshot create` (physical) or `flair backup` (logical export), or re-run with --snapshot.",
8969
8974
  ];
8975
+ /**
8976
+ * Run the stop → snapshot → prune → restart dance for a pre-upgrade snapshot.
8977
+ * Extracted from the upgrade action so the --snapshot and engine-version-change
8978
+ * branches share the same mechanism (flair#1047).
8979
+ *
8980
+ * On snapshot failure: aborts the upgrade (process.exit(1)), restarting Flair
8981
+ * first if it was stopped. On restart-after-snapshot failure: also exits.
8982
+ */
8983
+ async function runUpgradeSnapshot(port, dataDir) {
8984
+ // Consistency: a running Harper's data dir can be mid-write, and a
8985
+ // plain file copy of a live database directory isn't guaranteed
8986
+ // point-in-time consistent (Harper 5.x = RocksDB: WAL/SST/MANIFEST
8987
+ // can tear under a live copy). Stopping first — then immediately
8988
+ // restarting the OLD version, before any package changes — gives a
8989
+ // quiesced, safe-to-copy directory with only a brief blip, even for
8990
+ // --no-restart (the snapshot's correctness doesn't depend on
8991
+ // whether the caller wants a restart AFTER the upgrade — those are
8992
+ // orthogonal). See docs/upgrade.md for the native-backup alternative
8993
+ // considered and rejected (Harper's `get_backup` op backs up one
8994
+ // table/schema at a time over the running HTTP API — not the whole
8995
+ // data dir — and rejecting it here means this path never depends on
8996
+ // the server being up).
8997
+ let stoppedForSnapshot = false;
8998
+ let snapshotPath = null;
8999
+ try {
9000
+ await stopFlairProcess(port, dataDir);
9001
+ stoppedForSnapshot = true;
9002
+ const snapshot = await createDataSnapshot(dataDir);
9003
+ snapshotPath = snapshot.path;
9004
+ const removed = pruneOldSnapshots();
9005
+ console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
9006
+ console.log(` Restore: flair snapshot restore "${snapshotPath}"`);
9007
+ if (removed.length > 0) {
9008
+ console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
9009
+ }
9010
+ }
9011
+ catch (err) {
9012
+ console.error(`❌ snapshot failed: ${err.message}`);
9013
+ console.error(" Aborting upgrade — no packages were changed.");
9014
+ if (stoppedForSnapshot) {
9015
+ try {
9016
+ await startFlairProcess(port, dataDir);
9017
+ }
9018
+ catch { /* best effort — surface the original snapshot error, not this */ }
9019
+ }
9020
+ process.exit(1);
9021
+ }
9022
+ try {
9023
+ await startFlairProcess(port, dataDir);
9024
+ }
9025
+ catch (err) {
9026
+ console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
9027
+ console.error(` The snapshot itself succeeded (${snapshotPath}) — no packages were changed. Check: flair doctor`);
9028
+ process.exit(1);
9029
+ }
9030
+ }
9031
+ /**
9032
+ * Stamp the data directory with the currently-installed Harper engine version
9033
+ * (flair#1047). Called after every successful boot — start, restart, upgrade.
9034
+ * Best-effort: a failure to stamp is not a boot failure.
9035
+ */
9036
+ function stampEngineVersionIfRunning(dataDir) {
9037
+ try {
9038
+ const version = readInstalledHarperVersion(flairPackageDir());
9039
+ if (version)
9040
+ writeEngineVersionStamp(dataDir, version);
9041
+ }
9042
+ catch { /* best-effort — stamp failure must not prevent boot */ }
9043
+ }
8970
9044
  // ─── flair snapshot ─────────────────────────────────────────────────────────
8971
9045
  // Explicit, first-class surface for the physical data-dir snapshot mechanism
8972
9046
  // above (createDataSnapshot / pruneOldSnapshots / UPGRADE_SNAPSHOT_ROOT).
@@ -9204,6 +9278,7 @@ program
9204
9278
  .option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
9205
9279
  .option("--no-verify", "Skip post-restart health/version/auth verification (default: verify — so a broken upgrade can't report success; see flair#635)")
9206
9280
  .option("--snapshot", "Take a pre-upgrade ~/.flair/data snapshot before the package swap, keep-last-3 retention (default: off — see `flair snapshot create` to take one by hand, or `flair backup` for a logical export; flair#637)")
9281
+ .option("--no-engine-snapshot", "Skip the pre-upgrade snapshot even when the Harper engine version is changing (flair#1047). The snapshot is automatic on engine-version changes because the tested-downgrade guarantee does not hold across engine boundaries. Opting out prints what is being given up.")
9207
9282
  .option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
9208
9283
  // ── Fabric upgrade (--target) ────────────────────────────────────────────
9209
9284
  // When --target is passed, upgrade the Flair component DEPLOYED to that
@@ -9457,8 +9532,40 @@ program
9457
9532
  // moved from opt-out to opt-in. `flair snapshot create` (below) exposes
9458
9533
  // the exact same mechanism as a standalone command for anyone who wants
9459
9534
  // one without wrapping it around an upgrade.
9535
+ //
9536
+ // flair#1047: the tested-downgrade guarantee does not hold across engine
9537
+ // version boundaries — a Harper bump is the only realistic source of a
9538
+ // cross-version boot break. When the engine version is changing, the
9539
+ // snapshot is unconditional. Opting out requires --no-engine-snapshot
9540
+ // and prints what is being given up.
9460
9541
  const flairIsUpgrading = npmUpgrades.some((u) => u.pkg === "@tpsdev-ai/flair");
9461
- const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, existsSync(upgradeDataDir));
9542
+ const hasDataDir = existsSync(upgradeDataDir);
9543
+ const flairFinding = findings.find((f) => f.name === "@tpsdev-ai/flair");
9544
+ // Determine whether the engine (Harper) version is changing.
9545
+ let engineVersionChanging = false;
9546
+ let currentEngineVersion = null;
9547
+ let targetEngineVersion = null;
9548
+ if (flairIsUpgrading && hasDataDir) {
9549
+ currentEngineVersion = readInstalledHarperVersion(flairPackageDir());
9550
+ const targetFlairVersion = flairFinding?.latest;
9551
+ if (targetFlairVersion && currentEngineVersion) {
9552
+ targetEngineVersion = await fetchDeclaredHarperVersion(targetFlairVersion);
9553
+ if (targetEngineVersion === null) {
9554
+ // Registry lookup failed — cannot determine the target Harper
9555
+ // version. Assume it might change (safe default) and print why.
9556
+ engineVersionChanging = true;
9557
+ console.log(render.wrap(render.c.dim, `Could not determine the target Harper version from the npm registry — forcing a pre-upgrade snapshot as a precaution.`));
9558
+ }
9559
+ else {
9560
+ engineVersionChanging = targetEngineVersion !== currentEngineVersion;
9561
+ }
9562
+ }
9563
+ else {
9564
+ // Cannot determine — assume it might change (safe default).
9565
+ engineVersionChanging = true;
9566
+ }
9567
+ }
9568
+ const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, hasDataDir, engineVersionChanging, !!opts.noEngineSnapshot);
9462
9569
  let snapshotPath = null;
9463
9570
  if (snapshotDecision === "nudge") {
9464
9571
  // Non-blocking nudge only — never prompt/block here, this must stay
@@ -9473,53 +9580,20 @@ program
9473
9580
  else if (snapshotDecision === "no-data") {
9474
9581
  console.log(`\n(no data directory at ${upgradeDataDir} yet — nothing to snapshot)`);
9475
9582
  }
9583
+ else if (snapshotDecision === "engine-version-change") {
9584
+ // Engine version is changing — snapshot is unconditional (flair#1047).
9585
+ // The operator can opt out with --no-engine-snapshot, which prints what
9586
+ // is being given up (handled in the nudge branch above).
9587
+ const fromLabel = currentEngineVersion ?? "unknown";
9588
+ const toLabel = targetEngineVersion ?? "unknown";
9589
+ console.log(`\nHarper engine version changing (${fromLabel} → ${toLabel}) — snapshotting data before upgrade...`);
9590
+ console.log(render.wrap(render.c.dim, "The tested-downgrade guarantee does not hold across engine version boundaries."));
9591
+ console.log(render.wrap(render.c.dim, "Pass --no-engine-snapshot to skip this (not recommended)."));
9592
+ await runUpgradeSnapshot(upgradePort, upgradeDataDir);
9593
+ }
9476
9594
  else if (snapshotDecision === "snapshot") {
9477
9595
  console.log("\nSnapshotting data before upgrade...");
9478
- // Consistency: a running Harper's data dir can be mid-write, and a
9479
- // plain file copy of a live database directory isn't guaranteed
9480
- // point-in-time consistent (Harper 5.x = RocksDB: WAL/SST/MANIFEST
9481
- // can tear under a live copy). Stopping first — then immediately
9482
- // restarting the OLD version, before any package changes — gives a
9483
- // quiesced, safe-to-copy directory with only a brief blip, even for
9484
- // --no-restart (the snapshot's correctness doesn't depend on
9485
- // whether the caller wants a restart AFTER the upgrade — those are
9486
- // orthogonal). See docs/upgrade.md for the native-backup alternative
9487
- // considered and rejected (Harper's `get_backup` op backs up one
9488
- // table/schema at a time over the running HTTP API — not the whole
9489
- // data dir — and rejecting it here means this path never depends on
9490
- // the server being up).
9491
- let stoppedForSnapshot = false;
9492
- try {
9493
- await stopFlairProcess(upgradePort, upgradeDataDir);
9494
- stoppedForSnapshot = true;
9495
- const snapshot = await createDataSnapshot(upgradeDataDir);
9496
- snapshotPath = snapshot.path;
9497
- const removed = pruneOldSnapshots();
9498
- console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
9499
- console.log(` Restore: flair snapshot restore "${snapshotPath}"`);
9500
- if (removed.length > 0) {
9501
- console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
9502
- }
9503
- }
9504
- catch (err) {
9505
- console.error(`❌ snapshot failed: ${err.message}`);
9506
- console.error(" Aborting upgrade — no packages were changed. Omit --snapshot to proceed without one (not recommended).");
9507
- if (stoppedForSnapshot) {
9508
- try {
9509
- await startFlairProcess(upgradePort, upgradeDataDir);
9510
- }
9511
- catch { /* best effort — surface the original snapshot error, not this */ }
9512
- }
9513
- process.exit(1);
9514
- }
9515
- try {
9516
- await startFlairProcess(upgradePort, upgradeDataDir);
9517
- }
9518
- catch (err) {
9519
- console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
9520
- console.error(` The snapshot itself succeeded (${snapshotPath}) — no packages were changed. Check: flair doctor`);
9521
- process.exit(1);
9522
- }
9596
+ await runUpgradeSnapshot(upgradePort, upgradeDataDir);
9523
9597
  }
9524
9598
  // Perform upgrade. `latest` comes from the npm registry's HTTP
9525
9599
  // response, so CodeQL (correctly) treats it as untrusted input.
@@ -9571,7 +9645,6 @@ program
9571
9645
  // --restart is kept as a deprecated no-op for old muscle memory.
9572
9646
  // Upgrade = install → restart → verify → (rollback on failure), one
9573
9647
  // transaction — never report success on a broken restart.
9574
- const flairFinding = findings.find((f) => f.name === "@tpsdev-ai/flair");
9575
9648
  const previousFlairVersion = flairFinding?.installed ?? null;
9576
9649
  const expectedFlairVersion = flairFinding?.status === "outdated" && !flairInstallFailed
9577
9650
  ? flairFinding.latest
@@ -9880,6 +9953,16 @@ program
9880
9953
  console.error("❌ No Flair data directory found. Run 'flair init' first.");
9881
9954
  process.exit(1);
9882
9955
  }
9956
+ // flair#1047: refuse to boot if the store was written by a newer engine.
9957
+ const runningHarperVersion = readInstalledHarperVersion(flairPackageDir());
9958
+ if (runningHarperVersion) {
9959
+ const backwardsError = checkEngineVersionBackwards(dataDir, runningHarperVersion);
9960
+ if (backwardsError) {
9961
+ console.error(`❌ Cannot start Flair — the data directory was written by a newer Harper engine.\n`);
9962
+ console.error(backwardsError);
9963
+ process.exit(1);
9964
+ }
9965
+ }
9883
9966
  const platform = process.platform;
9884
9967
  if (platform === "darwin") {
9885
9968
  // resolveLaunchdLabel (flair#693) finds whichever label this data
@@ -9902,6 +9985,7 @@ program
9902
9985
  console.log(`Migrated launchd service off the legacy label (${LEGACY_LAUNCHD_LABEL}) → ${label} ✓`);
9903
9986
  await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
9904
9987
  readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
9988
+ stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
9905
9989
  console.log("✅ Flair started (launchd)");
9906
9990
  return;
9907
9991
  }
@@ -9942,6 +10026,7 @@ program
9942
10026
  try {
9943
10027
  await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
9944
10028
  readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
10029
+ stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
9945
10030
  console.log(`✅ Flair started on port ${port}`);
9946
10031
  }
9947
10032
  catch {
@@ -10275,6 +10360,7 @@ async function startFlairProcess(port, dataDir) {
10275
10360
  ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
10276
10361
  await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
10277
10362
  readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
10363
+ stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
10278
10364
  return;
10279
10365
  }
10280
10366
  catch (err) {
@@ -10322,6 +10408,7 @@ async function startFlairProcess(port, dataDir) {
10322
10408
  proc.unref();
10323
10409
  await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
10324
10410
  readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
10411
+ stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
10325
10412
  }
10326
10413
  /**
10327
10414
  * The ONE restart mechanism for a local Flair install. Shared by `flair
@@ -867,7 +867,19 @@ export function resolveCollisionSafeName(existingNames, filename) {
867
867
  */
868
868
  export function classifyKeyFile(agentId, seedValid, registration, baseUrl) {
869
869
  if (!seedValid) {
870
- return { class: "invalid", reason: "not a parseable Ed25519 private key seed" };
870
+ // NOT "invalid", and therefore NOT prunable. "I could not parse this" and
871
+ // "this is a stale agent key" are different findings, and only the second
872
+ // is safe to act on. `~/.flair/keys/<id>.key` is a namespace shared by two
873
+ // writers: plaintext Ed25519 seeds, and AES-256-GCM keystore blobs written
874
+ // by FileKeyStore (flair#1026). A keystore blob is unparseable AS A SEED
875
+ // while being a LIVE federation key — classifying it "invalid" moved a key
876
+ // that was in use. An unidentified file is reported for a human and left
877
+ // exactly where it is.
878
+ return {
879
+ class: "unidentified",
880
+ reason: "not a parseable Ed25519 private key seed — may be a keystore blob or another format; " +
881
+ "left in place, inspect it before removing anything (flair#1026)",
882
+ };
871
883
  }
872
884
  if (registration?.state === "registered") {
873
885
  return { class: "keep", reason: `agent '${agentId}' is registered on ${baseUrl} — never pruned` };
@@ -0,0 +1,239 @@
1
+ // engine-version.ts — Harper engine version tracking (flair#1047)
2
+ //
3
+ // Two concerns, one module:
4
+ //
5
+ // 1. STORE STAMP — flair records the engine version that last wrote the data
6
+ // directory. At boot, if the store was written by a NEWER engine than the
7
+ // one running, flair refuses to start and says so: which version wrote the
8
+ // store, which is running now, and what to do about it.
9
+ //
10
+ // 2. VERSION READ — read the Harper version installed alongside this flair
11
+ // package, and (when a target flair version is known) the Harper version
12
+ // that target declares. Used by the upgrade path to decide whether the
13
+ // engine version is changing and a pre-upgrade snapshot is therefore
14
+ // mandatory.
15
+ //
16
+ // The stamp is a single-line file in the data directory. It must survive the
17
+ // data directory being moved and must not require a Harper query to read — if
18
+ // the engine cannot boot, we still need to read it.
19
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { join, resolve } from "node:path";
21
+ import { homedir } from "node:os";
22
+ /** Filename of the engine-version stamp inside the data directory. */
23
+ export const ENGINE_VERSION_STAMP = "engine-version.txt";
24
+ /** Root directory for pre-upgrade snapshots (~/.flair/upgrade-snapshots). */
25
+ export const UPGRADE_SNAPSHOT_ROOT = resolve(homedir(), ".flair", "upgrade-snapshots");
26
+ /** Read the Harper version installed alongside this flair package. */
27
+ export function readInstalledHarperVersion(packageRoot) {
28
+ for (const name of ["harper", "@harperfast/harper"]) {
29
+ const pkgPath = join(packageRoot, "node_modules", ...name.split("/"), "package.json");
30
+ if (!existsSync(pkgPath))
31
+ continue;
32
+ try {
33
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
34
+ if (pkg.version)
35
+ return pkg.version;
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * Fetch the Harper version that a given @tpsdev-ai/flair version declares as
45
+ * a dependency. Returns null when the lookup fails (network, unparseable, etc.)
46
+ * — callers treat null as "cannot determine, assume it might change."
47
+ */
48
+ export async function fetchDeclaredHarperVersion(flairVersion) {
49
+ try {
50
+ const res = await fetch(`https://registry.npmjs.org/@tpsdev-ai/flair/${flairVersion}`, { signal: AbortSignal.timeout(5000) });
51
+ if (!res.ok)
52
+ return null;
53
+ const data = await res.json();
54
+ return data.dependencies?.harper ?? data.dependencies?.["@harperfast/harper"] ?? null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ // ─── Store stamp ─────────────────────────────────────────────────────────────
61
+ /** Write the engine version stamp into the data directory. */
62
+ export function writeEngineVersionStamp(dataDir, version) {
63
+ writeFileSync(join(dataDir, ENGINE_VERSION_STAMP), `${version}\n`, "utf-8");
64
+ }
65
+ /** Read the engine version stamp from the data directory, or null if absent. */
66
+ export function readEngineVersionStamp(dataDir) {
67
+ const stampPath = join(dataDir, ENGINE_VERSION_STAMP);
68
+ if (!existsSync(stampPath))
69
+ return null;
70
+ try {
71
+ return readFileSync(stampPath, "utf-8").trim() || null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ /**
78
+ * Check whether the running engine is OLDER than the engine that last wrote
79
+ * the store. Returns null when the check passes (no stamp, or stamp ≤ running),
80
+ * or an error message when the store is newer.
81
+ *
82
+ * The error must be actionable: actor, state, remedy.
83
+ */
84
+ export function checkEngineVersionBackwards(dataDir, runningVersion) {
85
+ const stamp = readEngineVersionStamp(dataDir);
86
+ if (!stamp)
87
+ return null; // no stamp — nothing to compare (pre-stamp install)
88
+ const parsed = compareVersions(stamp, runningVersion);
89
+ if (parsed === null) {
90
+ // Genuinely unparseable — cannot determine ordering. Refuse with a
91
+ // message that does NOT claim one is newer than the other.
92
+ return [
93
+ `This Flair install is running Harper ${runningVersion}, but the data directory at`,
94
+ ` ${dataDir}`,
95
+ `was last written by Harper ${stamp}.`,
96
+ ``,
97
+ `The engine version stamp could not be compared to the running version.`,
98
+ `An older Harper cannot safely read a store written by a newer one.`,
99
+ ``,
100
+ ...buildRecoveryLines(),
101
+ ].join("\n");
102
+ }
103
+ if (parsed > 0) {
104
+ // stamp > running — backwards boot, refuse.
105
+ return [
106
+ `This Flair install is running Harper ${runningVersion}, but the data directory at`,
107
+ ` ${dataDir}`,
108
+ `was last written by Harper ${stamp} — a newer engine version.`,
109
+ ``,
110
+ `An older Harper cannot safely read a store written by a newer one.`,
111
+ `The data may appear intact but can be silently unreadable.`,
112
+ ``,
113
+ ...buildRecoveryLines(),
114
+ ].join("\n");
115
+ }
116
+ return null; // running >= stamp — allowed
117
+ }
118
+ // ─── Version comparison (flair#1047) ─────────────────────────────────────────
119
+ /**
120
+ * Compare two semver-like version strings.
121
+ * Returns negative when a < b, positive when a > b, zero when equal,
122
+ * or null when either version is genuinely unparseable (not N.N.N at all).
123
+ *
124
+ * Pre-release ordering follows semver: a version WITH a pre-release tag is
125
+ * LOWER than the same core without one (5.2.0-rc1 < 5.2.0). When both have
126
+ * pre-releases, identifiers are compared dot by dot — numeric parts
127
+ * numerically, the rest as strings.
128
+ */
129
+ function compareVersions(a, b) {
130
+ const pa = parseVersion(a);
131
+ const pb = parseVersion(b);
132
+ if (!pa || !pb)
133
+ return null;
134
+ // Compare major.minor.patch (and any additional numeric components) numerically.
135
+ const coreLen = Math.max(pa.core.length, pb.core.length);
136
+ for (let i = 0; i < coreLen; i++) {
137
+ const ac = pa.core[i] ?? 0;
138
+ const bc = pb.core[i] ?? 0;
139
+ if (ac !== bc)
140
+ return ac - bc;
141
+ }
142
+ // Cores are equal — compare pre-release tags.
143
+ if (pa.pre === null && pb.pre === null)
144
+ return 0;
145
+ if (pa.pre === null)
146
+ return 1; // a has no pre-release → a > b
147
+ if (pb.pre === null)
148
+ return -1; // b has no pre-release → a < b
149
+ // Both have pre-releases — compare identifiers dot by dot.
150
+ const len = Math.max(pa.pre.length, pb.pre.length);
151
+ for (let i = 0; i < len; i++) {
152
+ const ai = pa.pre[i];
153
+ const bi = pb.pre[i];
154
+ if (ai === undefined)
155
+ return -1; // fewer identifiers → lower
156
+ if (bi === undefined)
157
+ return 1;
158
+ const an = Number(ai);
159
+ const bn = Number(bi);
160
+ const aIsNum = !isNaN(an);
161
+ const bIsNum = !isNaN(bn);
162
+ if (aIsNum && bIsNum) {
163
+ if (an !== bn)
164
+ return an - bn;
165
+ }
166
+ else if (aIsNum) {
167
+ return -1; // numeric < string
168
+ }
169
+ else if (bIsNum) {
170
+ return 1;
171
+ }
172
+ else {
173
+ if (ai !== bi)
174
+ return ai < bi ? -1 : 1;
175
+ }
176
+ }
177
+ return 0;
178
+ }
179
+ function parseVersion(v) {
180
+ // Split off pre-release: everything after the first hyphen.
181
+ const hyphenIdx = v.indexOf("-");
182
+ const coreStr = hyphenIdx === -1 ? v : v.slice(0, hyphenIdx);
183
+ const preStr = hyphenIdx === -1 ? null : v.slice(hyphenIdx + 1);
184
+ const coreParts = coreStr.split(".");
185
+ if (coreParts.length < 3)
186
+ return null; // not at least N.N.N
187
+ const core = coreParts.map(Number);
188
+ if (core.some(isNaN))
189
+ return null; // non-numeric core component
190
+ const pre = preStr ? preStr.split(".") : null;
191
+ return { core, pre };
192
+ }
193
+ /**
194
+ * Build the recovery lines for a backwards-boot refusal message.
195
+ * Inspects the snapshot directory so the operator gets a runnable command
196
+ * (or a clear "nothing to restore" message) instead of a literal placeholder.
197
+ */
198
+ export function buildRecoveryLines(snapshotDir) {
199
+ const effectiveSnapshotDir = snapshotDir ?? UPGRADE_SNAPSHOT_ROOT;
200
+ const snapshots = readSnapshotFiles(effectiveSnapshotDir);
201
+ if (snapshots.length === 0) {
202
+ return [
203
+ `To recover:`,
204
+ ` No pre-upgrade snapshot was found.`,
205
+ ` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`,
206
+ ` 2. Or restore from a flair backup export (if you have one).`,
207
+ ``,
208
+ `This check only helps from the release that ships it onward — it cannot`,
209
+ `rescue a downgrade to a build that predates the stamp.`,
210
+ ];
211
+ }
212
+ const newest = snapshots[0];
213
+ const lines = [`To recover:`];
214
+ if (snapshots.length > 1) {
215
+ lines.push(` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`, ` 2. Or restore from the newest pre-upgrade snapshot:`, ` flair snapshot restore ${newest.path}`, ``, ` (To see all snapshots: flair snapshot list)`);
216
+ }
217
+ else {
218
+ lines.push(` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`, ` 2. Or restore from the pre-upgrade snapshot:`, ` flair snapshot restore ${newest.path}`);
219
+ }
220
+ lines.push(``, `This check only helps from the release that ships it onward — it cannot`, `rescue a downgrade to a build that predates the stamp.`);
221
+ return lines;
222
+ }
223
+ /** Read snapshot files (.tar.gz), newest first. Lexical sort = chronological, because the
224
+ filename carries an ISO 8601 timestamp (see upgradeSnapshotFileName). If that format ever
225
+ changes, this sort must be revisited. */
226
+ function readSnapshotFiles(dir) {
227
+ if (!existsSync(dir))
228
+ return [];
229
+ try {
230
+ return readdirSync(dir)
231
+ .filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
232
+ .sort() // alphabetical = chronological (flair-data-<timestamp>.tar.gz)
233
+ .reverse() // newest first
234
+ .map((f) => ({ name: f, path: join(dir, f) }));
235
+ }
236
+ catch {
237
+ return [];
238
+ }
239
+ }
@@ -113,7 +113,7 @@ export class AdminInstance extends Resource {
113
113
  // actionable and still teaches them the surface exists.
114
114
  const mcp = mcpRouteState();
115
115
  const mcpCell = mcp.mounted
116
- ? `<code>${publicUrl}/mcp</code>`
116
+ ? `<code>${esc(publicUrl)}/mcp</code>`
117
117
  : `<span class="badge badge-gray">${esc(mcp.status)}</span>` +
118
118
  `<div style="margin-top:4px;color:#666;font-size:0.9em">${esc(mcp.reason)}</div>`;
119
119
  // Try to read instance public key
@@ -141,7 +141,7 @@ export class AdminInstance extends Resource {
141
141
  </div>
142
142
  <div class="card">
143
143
  <h3>Public URL</h3>
144
- <div style="font-family:monospace;font-size:0.9em;word-break:break-all">${publicUrl}</div>
144
+ <div style="font-family:monospace;font-size:0.9em;word-break:break-all">${esc(publicUrl)}</div>
145
145
  </div>
146
146
  </div>
147
147
 
@@ -156,12 +156,12 @@ export class AdminInstance extends Resource {
156
156
  <div class="card">
157
157
  <h3>Endpoints</h3>
158
158
  <table style="box-shadow:none">
159
- <tr><td>API</td><td><code>${publicUrl}/</code></td></tr>
159
+ <tr><td>API</td><td><code>${esc(publicUrl)}/</code></td></tr>
160
160
  <tr><td>MCP</td><td>${mcpCell}</td></tr>
161
- <tr><td>OAuth Discovery</td><td><code>${publicUrl}/OAuthMetadata</code></td></tr>
162
- <tr><td>OAuth Authorize</td><td><code>${publicUrl}/OAuthAuthorize</code></td></tr>
163
- <tr><td>OAuth Token</td><td><code>${publicUrl}/OAuthToken</code></td></tr>
164
- <tr><td>Admin</td><td><code>${publicUrl}/AdminDashboard</code></td></tr>
161
+ <tr><td>OAuth Discovery</td><td><code>${esc(publicUrl)}/OAuthMetadata</code></td></tr>
162
+ <tr><td>OAuth Authorize</td><td><code>${esc(publicUrl)}/OAuthAuthorize</code></td></tr>
163
+ <tr><td>OAuth Token</td><td><code>${esc(publicUrl)}/OAuthToken</code></td></tr>
164
+ <tr><td>Admin</td><td><code>${esc(publicUrl)}/AdminDashboard</code></td></tr>
165
165
  </table>
166
166
  </div>
167
167
  `;