@cotal-ai/cli 0.57.0 → 0.58.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.
@@ -1 +1 @@
1
- {"version":3,"file":"up.d.ts","sourceRoot":"","sources":["../../src/commands/up.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"up.d.ts","sourceRoot":"","sources":["../../src/commands/up.ts"],"names":[],"mappings":"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"}
@@ -2,11 +2,11 @@ import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { createConnection, createServer } from "node:net";
4
4
  import { hostname } from "node:os";
5
- import { mkdirSync, writeFileSync, readFileSync, existsSync, openSync, statSync, readSync, closeSync, lstatSync, rmSync, realpathSync, } from "node:fs";
5
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, openSync, statSync, readSync, closeSync, lstatSync, rmSync, realpathSync, readdirSync, } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
- import { isReachable, DEFAULT_SERVER, createSpaceAuth, serverConfig, openServerConfig, validateTlsMaterial, mintCreds, mintLifecycleUid, DEV_OWNER, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, setupSpaceStreams, reconcileSpaceTtls, standaloneConnectOpts, seedChannelRegistry, ensureDefaultDeliveryClass, mkSecretDir, writeSecretFile, } from "@cotal-ai/core";
7
+ import { isReachable, DEFAULT_SERVER, createSpaceAuth, serverConfig, openServerConfig, validateTlsMaterial, mintCreds, mintLifecycleUid, DEV_OWNER, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, setupSpaceStreams, reconcileSpaceTtls, standaloneConnectOpts, requireBrokerFloor, parseServerVersion, seedChannelRegistry, ensureDefaultDeliveryClass, mkSecretDir, writeSecretFile, } from "@cotal-ai/core";
8
8
  import { connect } from "@nats-io/transport-node";
9
- import { assertSingleSpaceBroker, assertUserAuthInfo, authDir, getSoleSpaceAuth, getSpaceAuth, hasUserAuthState, listSpaceAccounts, preloadSpaceAccounts, putSpaceAuth, clearCurrent, findMesh, getCurrent, loadMeshes, connectionEvictorCredsKey, membershipConfigPath, membershipObserverCredsKey, membershipRwCredsKey, MEMBERSHIP_CONFIG_KIND, MEMBERSHIP_RW_CREDS_KIND, parsePositiveIntegerFlag, recordMesh, meshesForRoot, removeMesh, rotateSystemCreds, setCurrent, staleSystemCreds, SYSTEM_CREDS_FILES, userAuthStateDir, userAuthSpacesOnDisk, workspaceSecretStore, acquireMaintenanceLock, assertStoreIdentity, assessRestoreClaim, beginOrdinaryResume, bindOrdinaryResumeListener, consumeRetiredMaintenance, markOrdinaryResumeActive, markOrdinaryResumeDegraded, replaceDeadOrdinaryResumeListener, localProcessOwnerStatus, readMaintenanceJournal, readMaintenanceResumeDocument, seatCheckpointDir, readStoreIdentity, recordOrdinaryResumeManagerCommit, releaseMaintenanceLock, retireOrdinaryResume, sameStoreIdentity, readBrokerPolicy, writeBrokerPolicy, removeIdentityPin, writeIdentityPin, localProcessPath, MANAGER_PIDFILE, assertManagerCanSpare, verifyIdentityPin, isLoopbackHost, } from "@cotal-ai/workspace";
9
+ import { assertSingleSpaceBroker, assertUserAuthInfo, authDir, getSoleSpaceAuth, getSpaceAuth, hasUserAuthState, listSpaceAccounts, preloadSpaceAccounts, putSpaceAuth, clearCurrent, findMesh, getCurrent, loadMeshes, connectionEvictorCredsKey, membershipConfigPath, membershipObserverCredsKey, membershipRwCredsKey, MEMBERSHIP_CONFIG_KIND, MEMBERSHIP_RW_CREDS_KIND, parsePositiveIntegerFlag, recordMesh, meshesForRoot, removeMesh, rotateSystemCreds, setCurrent, staleSystemCreds, SYSTEM_CREDS_FILES, userAuthStateDir, userAuthSpacesOnDisk, workspaceSecretStore, acquireMaintenanceLock, assertStoreIdentity, assessRestoreClaim, beginOrdinaryResume, bindOrdinaryResumeListener, consumeRetiredMaintenance, markOrdinaryResumeActive, markOrdinaryResumeDegraded, replaceDeadOrdinaryResumeListener, localProcessOwnerStatus, readMaintenanceJournal, readMaintenanceResumeDocument, seatCheckpointDir, readStoreIdentity, recordOrdinaryResumeManagerCommit, releaseMaintenanceLock, retireOrdinaryResume, sameStoreIdentity, readBrokerPolicy, writeBrokerPolicy, removeIdentityPin, writePidPair, localProcessPath, MANAGER_PIDFILE, assertManagerCanSpare, verifyIdentityPin, isLoopbackHost, } from "@cotal-ai/workspace";
10
10
  import { ensureAuthService, resolveAuthProvider, stopAuthService } from "../lib/auth-proc.js";
11
11
  import { resolveSpace } from "../lib/status.js";
12
12
  import { c } from "../ui.js";
@@ -584,8 +584,6 @@ async function runUp(args, inheritedLock, onAdopt) {
584
584
  // Gated on the ATTEMPT, not on holding the lock. The lock is now always held, and this refuses
585
585
  // whenever a journal exists — which is precisely the state a resume re-entry is in, so gating
586
586
  // it on the lock would refuse the very resume the lock is here to protect.
587
- if (!resumeAttempt)
588
- assertOrdinaryUpAllowed(cotalRoot(), values["store-dir"] ? resolve(values["store-dir"]) : cotalPath("nats"));
589
587
  let server = values.server ?? DEFAULT_SERVER;
590
588
  const host = values.host ?? "127.0.0.1";
591
589
  // `--host` is the BIND address; `server` is the URL the readiness probe, the mesh registry, and
@@ -596,6 +594,46 @@ async function runUp(args, inheritedLock, onAdopt) {
596
594
  // pair rather than starting a broker nothing can reach.
597
595
  if (values.host)
598
596
  server = reconcileHostAndServer(values.host, values.server);
597
+ // A same-root repair reopens the store the mesh record names, ONCE, before the ordinary-up
598
+ // assertion below reads --store-dir (#2218): an ordinary `up` on a space whose broker died must
599
+ // not silently fall back to the root's default store while the record still names a custom one.
600
+ // Never runs during a resume attempt, which keeps its own store checks. The reachability probe
601
+ // here is reused as `listenerReachable` further down instead of probing twice.
602
+ let repairProbe;
603
+ if (!resumeAttempt) {
604
+ const repairSpace = values.space ?? resolveSpace(process.cwd());
605
+ const repairRoot = cotalRoot();
606
+ const heldForRepair = loadMeshes().find((m) => m.origin !== "manual" && m.origin !== "catalog" && m.server === server && m.root === repairRoot && m.space === repairSpace);
607
+ if (heldForRepair) {
608
+ repairProbe = await isReachable(server);
609
+ if (!repairProbe) {
610
+ if (values["store-dir"] !== undefined) {
611
+ const requestedStoreDir = resolve(values["store-dir"]);
612
+ if (heldForRepair.storeDir !== undefined && requestedStoreDir !== heldForRepair.storeDir) {
613
+ console.error(c.red(`✗ mesh "${repairSpace}" is recorded at ${server} with store ${heldForRepair.storeDir} - a stopped broker can't change --store-dir (it is fixed at start); repair it with \`cotal up --store-dir ${heldForRepair.storeDir}\`, or \`cotal down\` it first`));
614
+ process.exit(1);
615
+ }
616
+ }
617
+ else if (heldForRepair.storeDir !== undefined) {
618
+ values["store-dir"] = heldForRepair.storeDir;
619
+ console.log(c.dim(`reopening the recorded store ${heldForRepair.storeDir}`));
620
+ }
621
+ else {
622
+ const defaultStore = cotalPath("nats");
623
+ let hasEntries = false;
624
+ try {
625
+ hasEntries = readdirSync(join(defaultStore, "jetstream")).length > 0;
626
+ }
627
+ catch { /* absent: no jetstream dir yet */ }
628
+ if (!hasEntries) {
629
+ console.error(c.yellow(`! mesh "${repairSpace}" was recorded before the store directory was kept, so this repair opens ${defaultStore}; if the previous broker used --store-dir, stop this one with \`cotal down\` and repair with that flag (its Store Directory: line is in ${repairRoot}/.cotal/nats.log)`));
630
+ }
631
+ }
632
+ }
633
+ }
634
+ }
635
+ if (!resumeAttempt)
636
+ assertOrdinaryUpAllowed(cotalRoot(), values["store-dir"] ? resolve(values["store-dir"]) : cotalPath("nats"));
599
637
  const restoredAttempt = resumeAttempt ? pendingRestores.get(resumeAttempt) : undefined;
600
638
  if (restoredAttempt?.reentry) {
601
639
  if (!restoredAttempt.listenerProof)
@@ -622,7 +660,7 @@ async function runUp(args, inheritedLock, onAdopt) {
622
660
  }
623
661
  if (ordinaryAttempt?.journalState === "resume-committed")
624
662
  throw new Error(`resume attempt ${resumeAttempt} is manager-committed but has no adoptable bound listener; preserving the commit token and retained suppression`);
625
- const listenerReachable = await isReachable(server);
663
+ const listenerReachable = repairProbe !== undefined ? repairProbe : await isReachable(server);
626
664
  if (resumeAttempt && listenerReachable)
627
665
  throw new Error(`resume attempt ${resumeAttempt} refuses the unproven occupied listener at ${server}`);
628
666
  if (listenerReachable) {
@@ -678,6 +716,13 @@ async function runUp(args, inheritedLock, onAdopt) {
678
716
  console.error(c.red(`✗ mesh "${held.space}" is already running at ${server} - a running broker can't change --max-file-store (it is fixed at start); \`cotal down\` it first, then \`cotal up --max-file-store ${maxFileStore}\``));
679
717
  process.exit(1);
680
718
  }
719
+ // The store directory is fixed the same way: an explicit --store-dir on a live refresh can
720
+ // only be refused when it disagrees with what the running broker actually opened. Absent
721
+ // `held.storeDir` (a pre-field record) has nothing to compare against, so it is not checked.
722
+ if (values["store-dir"] !== undefined && held.storeDir !== undefined && resolve(values["store-dir"]) !== held.storeDir) {
723
+ console.error(c.red(`✗ mesh "${held.space}" is already running at ${server} - a running broker can't change --store-dir (it is fixed at start); \`cotal down\` it first, then \`cotal up --store-dir ${resolve(values["store-dir"])}\``));
724
+ process.exit(1);
725
+ }
681
726
  // `--max-sessions` is fixed when the manager starts. A refresh reuses a live manager as-is
682
727
  // (`ensureManager` returns immediately), then used to persist the requested ceiling anyway.
683
728
  // That printed `✓ already running` over an unchanged plane and left MeshEntry lying about it.
@@ -746,6 +791,9 @@ async function runUp(args, inheritedLock, onAdopt) {
746
791
  // a same-root caller holds the space's signing material either way.
747
792
  reconcileCreds = await mintCreds(spaceAuth, newIdentity(), "provisioner");
748
793
  }
794
+ const v = await brokerVersion(server, reconcileCreds);
795
+ if (belowPresenceSafeBroker(v))
796
+ console.error(c.yellow(`! nats-server ${v} is below 2.14.5: its file-backed presence bucket can latch after a stall and close the mesh to new joins (#1356); put a nats-server 2.14.5 or newer on PATH (it wins over the bundled one)`));
749
797
  for (const r of await reconcileSpaceTtls({ servers: server, space: held.space, creds: reconcileCreds }))
750
798
  console.log(c.dim(` reconciled ${r.stream} TTL ${r.fromMs === 0 ? "none" : `${r.fromMs}ms`} -> ${r.toMs}ms`));
751
799
  }
@@ -826,7 +874,7 @@ async function runUp(args, inheritedLock, onAdopt) {
826
874
  // no transport decision to record — and `recordOurMesh` writes the entry whole, so omitting
827
875
  // the field here would erase the requirement on every bare refresh, exactly the way dropping
828
876
  // `attachHost` would silently demote the mesh to loopback.
829
- recordOurMesh({ space: held.space, server, root, mode: held.mode, ...(held.tlsRequired !== undefined ? { tlsRequired: held.tlsRequired } : {}), ...(userAuth ? { userAuth } : {}), ...(heldAttachHost ? { attachHost: heldAttachHost } : {}), ...(heldMaxSessions !== undefined ? { maxSessions: heldMaxSessions } : {}), ...(held.maxFileStore !== undefined ? { maxFileStore: held.maxFileStore } : {}), ts: new Date().toISOString() }, "refresh");
877
+ recordOurMesh({ space: held.space, server, root, mode: held.mode, ...(held.tlsRequired !== undefined ? { tlsRequired: held.tlsRequired } : {}), ...(userAuth ? { userAuth } : {}), ...(heldAttachHost ? { attachHost: heldAttachHost } : {}), ...(heldMaxSessions !== undefined ? { maxSessions: heldMaxSessions } : {}), ...(held.maxFileStore !== undefined ? { maxFileStore: held.maxFileStore } : {}), ...(held.storeDir !== undefined ? { storeDir: held.storeDir } : {}), ts: new Date().toISOString() }, "refresh");
830
878
  return;
831
879
  }
832
880
  const who = held ? `mesh "${held.space}" (${held.root})` : "a broker not started here";
@@ -947,9 +995,9 @@ async function runUp(args, inheritedLock, onAdopt) {
947
995
  const listenerStartedAt = new Date().toISOString();
948
996
  const child = spawn(bin, natsArgs, { stdio: "inherit" });
949
997
  let activationFinished = !resumeAttempt;
950
- if (child.pid)
951
- writeFileSync(cotalPath("nats.pid"), String(child.pid));
952
- writeIdentityPin(cotalPath("nats.pid"), child.pid ?? 0); // #969: pin pid to process start
998
+ if (!child.pid)
999
+ throw new Error("nats-server spawned with no pid");
1000
+ writePidPair(cotalPath("nats.pid"), child.pid); // #969/#1238: publish the pair by rename, never torn
953
1001
  if (restored && process.env.COTAL_SMOKE_EXIT_AFTER_RESTORE_LISTENER_SPAWN === "1")
954
1002
  process.exit(87);
955
1003
  if (restored)
@@ -1041,28 +1089,47 @@ async function runUp(args, inheritedLock, onAdopt) {
1041
1089
  process.on("SIGTERM", stop);
1042
1090
  // The broker is gone — drop it from the registry (and the `current` pointer if it was the default)
1043
1091
  // so a later `cotal spawn` doesn't try to join a dead mesh.
1044
- child.on("exit", async (code) => {
1092
+ child.on("exit", async (code, signal) => {
1045
1093
  removeIdentityPin(cotalPath("nats.pid"));
1046
1094
  rmSync(cotalPath("nats.pid"), { force: true });
1047
1095
  // Logged, never silently swallowed; the daemon kill runs in stopDelivery's finally regardless.
1048
1096
  await stopDelivery(undefined, undefined, space).catch((e) => console.error(`! delivery teardown: ${e.message}`));
1049
1097
  await stopManager(undefined, undefined, undefined, space).catch((e) => console.error(`! manager teardown: ${e.message}`));
1050
1098
  await stopAuthService(space).catch((e) => console.error(`! auth teardown: ${e.message}`));
1051
- // Only unrecord if the registry still points at THIS broker. A newer broker for the same space
1052
- // (a concurrent `up`, or a different-port re-up that recorded after us) may have replaced our
1053
- // record — removing by name would clobber the live winner and hide it from the registry.
1054
- // …and only if it is still OUR kind of record. A concurrent `cotal meshes add --force` can
1055
- // replace it with a hand-registered one carrying the same server + root; that record outlives
1056
- // this broker by design (it is the operator's, and only they remove it), so unrecording it on
1057
- // our exit would delete a registration this process never owned.
1058
- const mine = findMesh(space);
1059
- if (mine && mine.origin !== "manual" && mine.server === server && mine.root === cotalRoot()) {
1060
- removeMesh(space);
1061
- if (getCurrent() === space)
1062
- clearCurrent();
1099
+ // Unrecording (and the exit code below) both depend on WHY the broker is gone. `stopping` is
1100
+ // true only for the intended shutdown `stop()` drives (Ctrl-C, SIGTERM) — everything else here
1101
+ // (a crash, an OOM kill, a signal from outside `up`) leaves the record and its store in place.
1102
+ if (stopping) {
1103
+ // Only unrecord if the registry still points at THIS broker. A newer broker for the same space
1104
+ // (a concurrent `up`, or a different-port re-up that recorded after us) may have replaced our
1105
+ // record — removing by name would clobber the live winner and hide it from the registry.
1106
+ // …and only if it is still OUR kind of record. A concurrent `cotal meshes add --force` can
1107
+ // replace it with a hand-registered one carrying the same server + root; that record outlives
1108
+ // this broker by design (it is the operator's, and only they remove it), so unrecording it on
1109
+ // our exit would delete a registration this process never owned.
1110
+ const mine = findMesh(space);
1111
+ if (mine && mine.origin !== "manual" && mine.server === server && mine.root === cotalRoot()) {
1112
+ removeMesh(space);
1113
+ if (getCurrent() === space)
1114
+ clearCurrent();
1115
+ }
1116
+ if (activationFinished)
1117
+ process.exit(code ?? 0);
1118
+ }
1119
+ else {
1120
+ // The record is kept ON PURPOSE: it still names the store this broker opened, so a repair
1121
+ // `up` reopens that SAME store instead of falling back to the root's default, and a
1122
+ // supervising unit's restart takes the refresh path against the recorded store rather than
1123
+ // racing a second broker into existence. Foreground stdio is inherited (its own log lines
1124
+ // are already on this terminal, above), so there is no `.cotal/nats.log` to name here.
1125
+ const detail = signal ? `signal ${signal}` : `code ${code}`;
1126
+ console.error(c.red(`✗ nats-server exited unexpectedly (${detail}); the mesh "${space}" stays recorded (\`cotal meshes\` lists it offline); log: see the terminal output above; repair: \`cotal up --server ${server} --space ${space}\``));
1127
+ // A foreground broker under `Restart=on-failure` must exit non-zero on a crash so the unit
1128
+ // restarts it; exiting 0 here (as an unconditional `code ?? 0` used to) looked like a clean
1129
+ // stop and left the unit sitting dead.
1130
+ if (activationFinished)
1131
+ process.exit(code ?? 1);
1063
1132
  }
1064
- if (activationFinished)
1065
- process.exit(code ?? 0);
1066
1133
  });
1067
1134
  const ready = await waitReady(server, setup?.creds);
1068
1135
  if (!ready) {
@@ -1073,6 +1140,9 @@ async function runUp(args, inheritedLock, onAdopt) {
1073
1140
  markPendingResumeDegraded(resumeAttempt ?? "", reason, startupLock);
1074
1141
  throw new Error(reason);
1075
1142
  }
1143
+ const v = await brokerVersion(server, setup?.creds);
1144
+ if (belowPresenceSafeBroker(v))
1145
+ console.error(c.yellow(`! nats-server ${v} is below 2.14.5: its file-backed presence bucket can latch after a stall and close the mesh to new joins (#1356); put a nats-server 2.14.5 or newer on PATH (it wins over the bundled one)`));
1076
1146
  if (restored)
1077
1147
  await provePreparedRestoreListener(restored);
1078
1148
  // Listener ready — commit the transport decision (S5: not before start).
@@ -1110,6 +1180,7 @@ async function runUp(args, inheritedLock, onAdopt) {
1110
1180
  ...(effectiveAttachHost ? { attachHost: effectiveAttachHost } : {}),
1111
1181
  ...(effectiveMaxSessions !== undefined ? { maxSessions: effectiveMaxSessions } : {}),
1112
1182
  ...(maxFileStore !== undefined ? { maxFileStore } : {}),
1183
+ storeDir,
1113
1184
  ts: new Date().toISOString(),
1114
1185
  }, "started");
1115
1186
  // Bring up the delivery daemon WITH the server (auth mode only — it self-gates on `.cotal/auth`).
@@ -1476,6 +1547,7 @@ async function resumeProvenOrdinaryListener(pending, heldLock) {
1476
1547
  ...(adoptAttachHost ? { attachHost: adoptAttachHost } : {}),
1477
1548
  ...(adoptMaxSessions !== undefined ? { maxSessions: adoptMaxSessions } : {}),
1478
1549
  ...(pending.maxFileStore !== undefined ? { maxFileStore: pending.maxFileStore } : {}),
1550
+ storeDir: resolve(pending.storeDir),
1479
1551
  ts: new Date().toISOString(),
1480
1552
  }, "started");
1481
1553
  const controlPlane = await startDeliveryWithBroker(pending.space, pending.server, adoptedTlsRequired(pending.root), {
@@ -1521,6 +1593,7 @@ async function resumeProvenRestoreListener(prepared, heldLock) {
1521
1593
  ...(svc.userAuth ? { userAuth: svc.userAuth } : {}),
1522
1594
  ...(restoreAttachHost ? { attachHost: restoreAttachHost } : {}),
1523
1595
  ...(restoreMaxSessions !== undefined ? { maxSessions: restoreMaxSessions } : {}),
1596
+ storeDir: resolve(prepared.targetPath),
1524
1597
  ts: new Date().toISOString(),
1525
1598
  }, "started");
1526
1599
  const controlPlane = await startDeliveryWithBroker(prepared.space, prepared.server, adoptedTlsRequired(prepared.root), {
@@ -2041,8 +2114,9 @@ opts) {
2041
2114
  const child = spawn(bin, args, { detached: true, stdio: ["ignore", fd, fd] });
2042
2115
  closeSync(fd);
2043
2116
  if (opts.boundListener) {
2044
- writeFileSync(cotalPath("nats.pid"), String(child.pid));
2045
- writeIdentityPin(cotalPath("nats.pid"), child.pid ?? 0); // #969: pin pid to process start
2117
+ if (!child.pid)
2118
+ throw new Error("nats-server spawned with no pid");
2119
+ writePidPair(cotalPath("nats.pid"), child.pid); // #969/#1238: publish the pair by rename, never torn
2046
2120
  if (process.env.COTAL_SMOKE_EXIT_AFTER_RESTORE_LISTENER_SPAWN === "1")
2047
2121
  process.exit(87);
2048
2122
  try {
@@ -2067,9 +2141,24 @@ opts) {
2067
2141
  rmSync(cotalPath("nats.pid"), { force: true });
2068
2142
  throw new Error(`nats-server did not become reachable at ${server} - see ${logPath}`);
2069
2143
  }
2070
- if (!opts.boundListener)
2071
- writeFileSync(cotalPath("nats.pid"), String(child.pid));
2072
- writeIdentityPin(cotalPath("nats.pid"), child.pid ?? 0); // #969: pin pid to process start
2144
+ let brokerVer;
2145
+ try {
2146
+ brokerVer = await brokerVersion(server, setup?.creds);
2147
+ }
2148
+ catch (e) {
2149
+ child.kill("SIGTERM");
2150
+ if (opts.boundListener)
2151
+ removeIdentityPin(cotalPath("nats.pid"));
2152
+ rmSync(cotalPath("nats.pid"), { force: true });
2153
+ throw e;
2154
+ }
2155
+ if (belowPresenceSafeBroker(brokerVer))
2156
+ console.error(c.yellow(`! nats-server ${brokerVer} is below 2.14.5: its file-backed presence bucket can latch after a stall and close the mesh to new joins (#1356); put a nats-server 2.14.5 or newer on PATH (it wins over the bundled one)`));
2157
+ if (!opts.boundListener) {
2158
+ if (!child.pid)
2159
+ throw new Error("nats-server spawned with no pid");
2160
+ writePidPair(cotalPath("nats.pid"), child.pid); // #969/#1238: publish the pair by rename, never torn
2161
+ }
2073
2162
  if (opts.boundListener)
2074
2163
  await opts.boundListener.verify();
2075
2164
  // POST-START MUST NOT LEAVE AN ORPHAN LISTENER.
@@ -2125,6 +2214,7 @@ opts) {
2125
2214
  ...(effectiveAttachHost ? { attachHost: effectiveAttachHost } : {}),
2126
2215
  ...(effectiveMaxSessions !== undefined ? { maxSessions: effectiveMaxSessions } : {}),
2127
2216
  ...(opts.maxFileStore !== undefined ? { maxFileStore: opts.maxFileStore } : {}),
2217
+ storeDir,
2128
2218
  ts: new Date().toISOString(),
2129
2219
  }, "started");
2130
2220
  // Commit policy BEFORE delivery launch (S9). Listener is proved; refuse paths never reach here.
@@ -2356,6 +2446,31 @@ async function waitReady(server, creds) {
2356
2446
  }
2357
2447
  return false;
2358
2448
  }
2449
+ // SPEC §13.12: reads the connected broker's version, and refuses loud below the 2.12 floor
2450
+ // before any pidfile or provisioning touches it (the same gate `requireBrokerFloor` enforces
2451
+ // on every other control-surface connection).
2452
+ async function brokerVersion(server, creds) {
2453
+ const nc = await connect({ servers: server, ...standaloneConnectOpts({ creds, tls: false }) });
2454
+ try {
2455
+ requireBrokerFloor(nc);
2456
+ return nc.info?.version ?? "";
2457
+ }
2458
+ finally {
2459
+ await nc.drain();
2460
+ }
2461
+ }
2462
+ // #1356: the presence bucket's file-backed latch is fixed in nats-server 2.14.5. Below that,
2463
+ // `cotal up` warns (never refuses: 2.14.0 meets the SPEC §13.12 floor and is the bundled broker).
2464
+ function belowPresenceSafeBroker(version) {
2465
+ const p = parseServerVersion(version);
2466
+ if (!p)
2467
+ return false;
2468
+ if (p.major !== 2)
2469
+ return p.major < 2;
2470
+ if (p.minor !== 14)
2471
+ return p.minor < 14;
2472
+ return p.patch < 5;
2473
+ }
2359
2474
  /** Wildcard binds mean "every interface", so they are NOT an address a client can be told to dial:
2360
2475
  * the probe/registry URL stays whatever it already is (loopback by default), which the wildcard
2361
2476
  * bind necessarily covers. */