@rackbops/ac-agent 2.0.0-alpha.46 → 2.0.0-alpha.48

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 CHANGED
@@ -106,10 +106,58 @@ against the new bundle, `install` stops the old process and starts it again thro
106
106
  `stop`/`start` machinery the `ac-agent stop`/`ac-agent start` commands use, then confirms the new
107
107
  process is actually answering (a health check, retried for a few seconds — the OS command that
108
108
  launches a process can report success before that process has actually started listening) before
109
- printing `restarted: now serving <version>`. If the restart fails outright, or the health check
110
- never comes back healthy, the install is still reported as a success — the printed line is
111
- `restart failed: <reason>; run ac-agent stop && ac-agent start`, and that manual sequence is the
112
- fallback. A fresh install, with nothing running yet, prints neither line.
109
+ printing `restarted: now serving <version>`. Only a health answer reporting the **new** version
110
+ counts (#659), so the old process still answering on the port is not mistaken for the new one.
111
+
112
+ Before copying the new bundle in over a running agent, `install` keeps the one it replaces as
113
+ `bin/ac-agent.prev.mjs` in the state dir (#659; one backup, overwritten by the next install). If the
114
+ new bundle never answers health at its own version, `install` rolls back:
115
+ 1. It stops the task/unit again. On Windows this ends `launch.js`'s retry loop, so it can't rerun the
116
+ broken bundle in the middle of the restore.
117
+ 2. It restores `ac-agent.prev.mjs` and starts it.
118
+ 3. It confirms the previous version answers, then prints
119
+ `rolled back: now serving <previous version> (<reason>)`. The previous version is read from the
120
+ running agent's own health answer just before it is stopped. If that read got no version (no
121
+ `cert.pem` to read it with, no answer, an error answer, or one without a version), any healthy
122
+ answer is accepted, and the line says
123
+ `now serving the previous bundle`.
124
+
125
+ Whenever a rollback is possible, the restart confirmation waits up to a minute instead of a few
126
+ seconds, because a false failure would undo a good release (the Windows cold start has never been
127
+ timed). This applies to a plain install too, such as Tooling's `Update-AcAgent.ps1`. A new bundle that
128
+ never comes up therefore takes about two minutes to report (a minute waiting for it, up to another
129
+ confirming the rollback; a hung connection adds to each attempt). If the install fails before the
130
+ restart (registering the task/unit, the certificate) and a backup was made, the running agent's bundle
131
+ is copied back onto the disk; the error is reported as before.
132
+
133
+ The install then **fails** (exit 1), because it did not take. If the rollback fails too, it prints
134
+ `rollback failed: ...`, leaves both bundles in place, and also exits 1; `ac-agent start` is the
135
+ fallback.
136
+
137
+ When there was no bundle to go back to, a restart that fails outright still reports the install as a
138
+ success, with `restart failed: <reason>; run ac-agent stop && ac-agent start`, and that manual
139
+ sequence is the fallback. A fresh install, with nothing running yet, prints neither line.
140
+
141
+ `install --update` is what the agent's own update route launches; it isn't meant to be run by hand.
142
+ It differs from a plain install in two ways:
143
+ - Its restart confirmation (when an agent is running to restart) is always the minute-long one,
144
+ even with no backup to roll back to.
145
+ - It records each step in `update.json` in the state dir:
146
+ `{status: "installing" | "ok" | "rolled-back" | "failed", from, target, at, message?}`, where
147
+ `from` is null when the previous version could not be read.
148
+ - Once `installing` is written, the outcome is recorded as `ok`, `rolled-back` or `failed`. That
149
+ covers a failure before the restart, and no agent running afterwards.
150
+ - A failure before `installing` is written (reading the config, the backup copy) leaves the
151
+ previous record as it was.
152
+ - It writes a temp file and renames it over, so a reader never sees a partial record.
153
+ - A write that fails (for example a locked file) prints a `warning: could not record update
154
+ status ...` line and never changes the install's outcome. The record then still shows the last
155
+ write that landed, which may be `installing`, so a reader should treat an old `installing` as
156
+ unknown.
157
+
158
+ A plain install never writes `update.json`. `ac-agent version --json` prints
159
+ `{"version", "apiVersion"}`, which is how a staged bundle's agent API version is read without starting
160
+ it.
113
161
 
114
162
  ### The firewall step
115
163
 
@@ -223,7 +271,8 @@ when this session's own AppData writes to the resolved config/state dirs are vir
223
271
  packaged app (see **Run install from a normal shell** above), which also prints a `warning: this
224
272
  session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
225
273
  README, Install` line to stderr. `serve` logs the same warning line once at startup, in the same
226
- circumstance, without refusing to start.
274
+ circumstance, without refusing to start. `update` (#659) is the last `install --update`'s record from
275
+ `update.json`, or `null` when there is none or it is unreadable.
227
276
 
228
277
  ## Pair
229
278
 
@@ -275,7 +324,7 @@ or `systemctl --user stop/start`) without touching the registration itself.
275
324
  | | Windows | Linux |
276
325
  |---|---|---|
277
326
  | Config (`agent.toml`) | `%APPDATA%\ac-agent\` | `~/.config/ac-agent/` |
278
- | State (cert, key, token hash, grants, log, pid, the installed bundle copy) | `%LOCALAPPDATA%\ac-agent\` | `~/.local/state/ac-agent/` |
327
+ | State (cert, key, token hash, grants, log, pid, the installed bundle copy and its one backup, `update.json`) | `%LOCALAPPDATA%\ac-agent\` | `~/.local/state/ac-agent/` |
279
328
 
280
329
  Both are overridable with `AC_AGENT_CONFIG_DIR` / `AC_AGENT_STATE_DIR` — **absolute paths only**; a
281
330
  relative override throws, naming the value. `install` resolves these once, in its own process, and
package/dist/ac-agent.mjs CHANGED
@@ -47,7 +47,7 @@ function _mergeNamespaces(n, m) {
47
47
  }
48
48
 
49
49
  var name = "@rackbops/ac-agent";
50
- var version = "2.0.0-alpha.46";
50
+ var version = "2.0.0-alpha.48";
51
51
  var type = "module";
52
52
  var description = "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.";
53
53
  var bin = {
@@ -71,7 +71,7 @@ var scripts = {
71
71
  };
72
72
  var dependencies = {
73
73
  "@hono/node-server": "^2.1.3",
74
- hono: "^4.13.11",
74
+ hono: "^4.13.12",
75
75
  selfsigned: "^5.0.0",
76
76
  "smol-toml": "^1.9.0",
77
77
  yaml: "^2.9.1"
@@ -81,7 +81,7 @@ var devDependencies = {
81
81
  "@rollup/plugin-json": "^6.0.0",
82
82
  "@rollup/plugin-node-resolve": "^16.0.0",
83
83
  "@types/node": "catalog:",
84
- rollup: "^4.63.5",
84
+ rollup: "^4.64.0",
85
85
  typescript: "catalog:",
86
86
  undici: "^8.11.2",
87
87
  vitest: "catalog:"
@@ -21945,36 +21945,67 @@ function detectVirtualization(deps) {
21945
21945
  function bundleTargetPath(stateDir) {
21946
21946
  return path.join(stateDir, "bin", "ac-agent.mjs");
21947
21947
  }
21948
- // #277 review round 1 (correctness lens): `deps.platform.start()`/`.stop()` are backed by
21949
- // `runPowerShell`/`runCommand` (cli.ts), which resolve unconditionally on the child process's
21950
- // `close` event and never inspect its exit code -- a `Start-ScheduledTask`/`systemctl start` that
21951
- // runs but fails (bad permissions, a corrupted registration, ...) would otherwise be
21952
- // indistinguishable here from one that actually worked, so `installService` could print `restarted:
21953
- // now serving <version>` while nothing is actually listening. `confirmHealthy` below closes that gap
21954
- // by requiring a real, successful `/v1/health` response -- not just "the launch command exited" --
21955
- // before the restart is reported as a success. The new process is several process-creation hops away
21956
- // on Windows (Start-ScheduledTask -> wscript.exe -> JScript -> node), so this retries briefly rather
21957
- // than judging on a single immediate check.
21958
- //
21959
- // #277 review round 2: this budget (worst case: 7 delays x 400ms = 2.8s, plus the fetch attempts
21960
- // themselves) is a deliberately generous, but UNVERIFIED-ON-WINDOWS, guess -- live verification for
21961
- // this fix only covered Linux (nitro, over ssh); no live Windows box was installed against (Melody's
21962
- // install refuses under MSIX AppData virtualization regardless, #263; MILE/SHIORI are roshne's own
21963
- // boxes, out of scope here). A real Start-ScheduledTask -> wscript.exe -> JScript -> node cold start
21964
- // that took longer than this window would report a false "restart failed" over an agent that's
21965
- // actually fine a moment later -- a safe failure direction (never a false "restarted"), but still
21966
- // worth a live Windows timing check before trusting this number long-term.
21967
- const HEALTH_CHECK_ATTEMPTS = 8;
21968
- const HEALTH_CHECK_DELAY_MS = 400;
21969
- async function confirmHealthy(deps, port, certPem, expectedFingerprint) {
21970
- for (let attempt = 0; attempt < HEALTH_CHECK_ATTEMPTS; attempt++) {
21948
+ /** The one previous bundle `install` keeps while it restarts onto a new one (#659): restored when
21949
+ * the new bundle never answers health at its own version. Overwritten by the next install. */
21950
+ function bundleBackupPath(stateDir) {
21951
+ return path.join(stateDir, "bin", "ac-agent.prev.mjs");
21952
+ }
21953
+ function updateRecordPath(stateDir) {
21954
+ return path.join(stateDir, "update.json");
21955
+ }
21956
+ /** Best-effort: the record reports an install's outcome, it never decides it. A write that fails
21957
+ * (on Windows, `renameSync` over a file a reader holds open can throw EPERM/EBUSY; a full disk) is
21958
+ * printed and swallowed -- left to throw inside the restart sequence, it would roll back a release
21959
+ * that came up healthy (#659 review round 1). */
21960
+ function writeUpdateRecord(deps, record) {
21961
+ const target = updateRecordPath(deps.stateDir);
21962
+ const tmp = `${target}.tmp`;
21963
+ try {
21964
+ deps.writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`);
21965
+ deps.renameFile(tmp, target);
21966
+ }
21967
+ catch (err) {
21968
+ deps.print(`warning: could not record update status ${record.status} in ${target}: ${err instanceof Error ? err.message : String(err)}`);
21969
+ }
21970
+ }
21971
+ /** `update.json` parsed, or null when absent or unreadable -- `status` reports it as-is. */
21972
+ function readUpdateRecord(deps) {
21973
+ const text = deps.readFile(updateRecordPath(deps.stateDir));
21974
+ if (text === null)
21975
+ return null;
21976
+ try {
21977
+ const value = JSON.parse(text);
21978
+ return typeof value === "object" && value !== null && typeof value.status === "string"
21979
+ ? value
21980
+ : null;
21981
+ }
21982
+ catch {
21983
+ return null;
21984
+ }
21985
+ }
21986
+ const HEALTH_BUDGET = { attempts: 8, delayMs: 400 };
21987
+ /** `install --update` (#659): a false failure here rolls back a good release, so the update path
21988
+ * waits up to a minute -- the Windows cold start (Start-ScheduledTask -> wscript -> node) has never
21989
+ * been timed live, see above. */
21990
+ const UPDATE_HEALTH_BUDGET = { attempts: 60, delayMs: 1000 };
21991
+ /** Waits for a health answer that is `ok` and, when `expectedVersion` is given, reports exactly that
21992
+ * version (#659): the old process still answering on the port is not the new bundle coming up. */
21993
+ async function confirmHealthy(deps, port, certPem, expectedFingerprint, expectedVersion, budget) {
21994
+ let lastVersion;
21995
+ for (let attempt = 0; attempt < budget.attempts; attempt++) {
21971
21996
  const health = await deps.fetchHealth(port, certPem, expectedFingerprint);
21972
- if (health?.ok)
21973
- return;
21974
- if (attempt < HEALTH_CHECK_ATTEMPTS - 1) {
21975
- await new Promise((resolve) => setTimeout(resolve, HEALTH_CHECK_DELAY_MS));
21997
+ if (health?.ok) {
21998
+ if (expectedVersion === null || health.version === expectedVersion)
21999
+ return;
22000
+ lastVersion = health.version;
22001
+ }
22002
+ if (attempt < budget.attempts - 1) {
22003
+ await new Promise((resolve) => setTimeout(resolve, budget.delayMs));
21976
22004
  }
21977
22005
  }
22006
+ if (lastVersion !== undefined) {
22007
+ throw new Error(`port ${port} still answers version ${lastVersion}, not ${expectedVersion}`);
22008
+ }
21978
22009
  throw new Error(`new process did not answer a health check on port ${port}`);
21979
22010
  }
21980
22011
  async function installService(deps) {
@@ -22028,28 +22059,72 @@ async function installService(deps) {
22028
22059
  deps.print("warning: AC_AGENT_REPOS_ROOT overrides repos_root; the saved value will take effect when it is unset");
22029
22060
  }
22030
22061
  const bundlePath = bundleTargetPath(deps.stateDir);
22031
- deps.copyFile(deps.bundleSourcePath, bundlePath);
22032
- const { certPem } = await ensureCert({
22033
- stateDir: deps.stateDir,
22034
- hostname: deps.hostname(),
22035
- ips: deps.ips,
22036
- readFile: deps.readFile,
22037
- writeFile: (p, content, mode) => deps.writeFile(p, content, mode),
22038
- });
22039
- const fp = fingerprint(certPem);
22040
- // #263: refuse before registering a task/unit nothing can ever see, rather than leave a broken
22041
- // registration behind for `status`/`start` to fail against later. Must run after the state-dir
22042
- // writes above (so a genuinely virtualized session's own writes are what the probe finds) and
22043
- // before `deps.platform.install` (which is what actually registers the task/unit on Windows).
22044
- const virtualization = detectVirtualization(deps);
22045
- if (virtualization.virtualized) {
22046
- throw new Error(installRefusalMessage(virtualization.packageFamily, virtualization.vfsPath));
22062
+ const backupPath = bundleBackupPath(deps.stateDir);
22063
+ // #659: an install over a running agent keeps the bundle it is replacing, and the version that
22064
+ // bundle is serving, so a new bundle that never comes up can be undone. Read before the copy: once
22065
+ // the copy lands, the file on disk is no longer what the running process loaded.
22066
+ const wasRunning = isAgentRunning(deps);
22067
+ let backedUp = false;
22068
+ let previousVersion = null;
22069
+ if (wasRunning && deps.exists(bundlePath)) {
22070
+ previousVersion = await runningVersion(deps, port);
22071
+ deps.copyFile(bundlePath, backupPath);
22072
+ backedUp = true;
22073
+ }
22074
+ const record = (status, message) => {
22075
+ if (!deps.updateMode)
22076
+ return;
22077
+ writeUpdateRecord(deps, {
22078
+ status,
22079
+ from: previousVersion,
22080
+ target: deps.version,
22081
+ at: nowIso(deps),
22082
+ ...(message !== undefined ? { message } : {}),
22083
+ });
22084
+ };
22085
+ record("installing");
22086
+ let certPem;
22087
+ let fp;
22088
+ let result;
22089
+ try {
22090
+ deps.copyFile(deps.bundleSourcePath, bundlePath);
22091
+ certPem = (await ensureCert({
22092
+ stateDir: deps.stateDir,
22093
+ hostname: deps.hostname(),
22094
+ ips: deps.ips,
22095
+ readFile: deps.readFile,
22096
+ writeFile: (p, content, mode) => deps.writeFile(p, content, mode),
22097
+ })).certPem;
22098
+ fp = fingerprint(certPem);
22099
+ // #263: refuse before registering a task/unit nothing can ever see, rather than leave a broken
22100
+ // registration behind for `status`/`start` to fail against later. Must run after the state-dir
22101
+ // writes above (so a genuinely virtualized session's own writes are what the probe finds) and
22102
+ // before `deps.platform.install` (which is what actually registers the task/unit on Windows).
22103
+ const virtualization = detectVirtualization(deps);
22104
+ if (virtualization.virtualized) {
22105
+ throw new Error(installRefusalMessage(virtualization.packageFamily, virtualization.vfsPath));
22106
+ }
22107
+ result = await deps.platform.install({
22108
+ nodePath: deps.nodePath,
22109
+ bundlePath,
22110
+ user: deps.user,
22111
+ });
22112
+ }
22113
+ catch (err) {
22114
+ // #659 review round 1: a failure before the restart leaves the old process running, so put its
22115
+ // bundle back on disk (the next crash restart must load what was serving, not the half-installed
22116
+ // new one) and record the outcome -- `installing` must never be the last word.
22117
+ if (backedUp) {
22118
+ try {
22119
+ deps.copyFile(backupPath, bundlePath);
22120
+ }
22121
+ catch {
22122
+ // the backup is still in place for a manual restore; the original error is what matters
22123
+ }
22124
+ }
22125
+ record("failed", err instanceof Error ? err.message : String(err));
22126
+ throw err;
22047
22127
  }
22048
- const result = await deps.platform.install({
22049
- nodePath: deps.nodePath,
22050
- bundlePath,
22051
- user: deps.user,
22052
- });
22053
22128
  deps.print(`config: ${path.join(deps.configDir, "agent.toml")} (${loaded.source})`);
22054
22129
  deps.print(`state: ${deps.stateDir}`);
22055
22130
  deps.print(`fingerprint: ${fp}`);
@@ -22063,19 +22138,101 @@ async function installService(deps) {
22063
22138
  // path -- so `stopService`'s #238 ordering (task/unit stop before the orphaned-pid kill) is
22064
22139
  // exercised here unchanged, just followed by the same `platform.start()` the `start` verb calls.
22065
22140
  if (isAgentRunning(deps)) {
22141
+ // The long budget whenever a rollback is possible, not only under --update: a false failure there
22142
+ // stops a healthy new process and restores the old bundle. Tooling's Update-AcAgent.ps1 runs a
22143
+ // plain `install`, and the Windows cold start has never been timed (#659 review round 1). With
22144
+ // nothing to roll back to, a failure only prints, so the short budget stays.
22145
+ const budget = deps.updateMode || backedUp
22146
+ ? (deps.rollbackHealthBudget ?? UPDATE_HEALTH_BUDGET)
22147
+ : HEALTH_BUDGET;
22148
+ let restartError = null;
22066
22149
  try {
22067
22150
  await stopService(deps);
22068
22151
  await deps.platform.start();
22069
- await confirmHealthy(deps, port, certPem, fp);
22070
- deps.print(`restarted: now serving ${deps.version}`);
22152
+ await confirmHealthy(deps, port, certPem, fp, deps.version, budget);
22071
22153
  }
22072
22154
  catch (err) {
22073
- // The install itself already succeeded (the task/unit is registered against the new bundle);
22074
- // a restart failure must not be reported as an install failure -- the operator's fallback is
22075
- // the same manual stop/start #238 already documents.
22076
- deps.print(`restart failed: ${err instanceof Error ? err.message : String(err)}; run ac-agent stop && ac-agent start`);
22155
+ restartError = err;
22077
22156
  }
22157
+ if (restartError === null) {
22158
+ // Recorded only after the restart sequence: a failed record write must never look like a
22159
+ // failed restart (#659 review round 1).
22160
+ deps.print(`restarted: now serving ${deps.version}`);
22161
+ record("ok");
22162
+ return;
22163
+ }
22164
+ const reason = restartError instanceof Error ? restartError.message : String(restartError);
22165
+ if (!backedUp) {
22166
+ // Nothing to go back to (no bundle was in place): the install itself already succeeded (the
22167
+ // task/unit is registered against the new bundle), so a restart failure must not be reported
22168
+ // as an install failure -- the operator's fallback is the manual stop/start #238 documents.
22169
+ deps.print(`restart failed: ${reason}; run ac-agent stop && ac-agent start`);
22170
+ record("failed", reason);
22171
+ return;
22172
+ }
22173
+ await rollBack(deps, {
22174
+ port,
22175
+ certPem,
22176
+ fp,
22177
+ bundlePath,
22178
+ backupPath,
22179
+ previousVersion,
22180
+ budget,
22181
+ reason,
22182
+ });
22183
+ return;
22078
22184
  }
22185
+ // Nothing was running to restart (the agent died between the pre-check and now, or a fresh install).
22186
+ // Under --update the agent that launched this install was running, so this is a failure the record
22187
+ // must say, not leave at `installing` (#659 review round 2).
22188
+ record("failed", "no agent was running after the install; run ac-agent start");
22189
+ }
22190
+ function nowIso(deps) {
22191
+ return (deps.now ?? (() => new Date()))().toISOString();
22192
+ }
22193
+ /** The version the running agent reports before it is stopped (#659), read with the cert already in
22194
+ * the state dir -- null when there is no cert yet or the agent does not answer. */
22195
+ async function runningVersion(deps, port) {
22196
+ const certPem = deps.readFile(path.join(deps.stateDir, "cert.pem"));
22197
+ if (certPem === null)
22198
+ return null;
22199
+ const health = await deps.fetchHealth(port, certPem, fingerprint(certPem));
22200
+ return health?.ok && typeof health.version === "string" ? health.version : null;
22201
+ }
22202
+ /** #659: the new bundle never answered health at its own version, so restore the one it replaced.
22203
+ * Stops first, in #238's order (the task/unit, then the pid) -- on Windows that ends `launch.js`'s
22204
+ * retry loop, which would otherwise rerun the broken bundle a minute later, mid-restore. Throws
22205
+ * either way, so `install` exits 1: the install did not take. */
22206
+ async function rollBack(deps, ctx) {
22207
+ const record = (status, message) => {
22208
+ if (!deps.updateMode)
22209
+ return;
22210
+ writeUpdateRecord(deps, {
22211
+ status,
22212
+ from: ctx.previousVersion,
22213
+ target: deps.version,
22214
+ at: nowIso(deps),
22215
+ message,
22216
+ });
22217
+ };
22218
+ try {
22219
+ await stopService(deps);
22220
+ deps.copyFile(ctx.backupPath, ctx.bundlePath);
22221
+ await deps.platform.start();
22222
+ await confirmHealthy(deps, ctx.port, ctx.certPem, ctx.fp, ctx.previousVersion, ctx.budget);
22223
+ }
22224
+ catch (err) {
22225
+ const why = err instanceof Error ? err.message : String(err);
22226
+ deps.print(`rollback failed: ${why}; the new bundle failed first (${ctx.reason}). ` +
22227
+ `${ctx.backupPath} holds the previous bundle: copy it over ${ctx.bundlePath} if it is not ` +
22228
+ "already there, then run ac-agent start");
22229
+ record("failed", `${ctx.reason}; rollback failed: ${why}`);
22230
+ throw new Error(`install did not take and the rollback failed: ${why}`);
22231
+ }
22232
+ const serving = ctx.previousVersion ?? "the previous bundle";
22233
+ deps.print(`rolled back: now serving ${serving} (${ctx.reason})`);
22234
+ record("rolled-back", ctx.reason);
22235
+ throw new Error(`install did not take: ${deps.version} never came up; rolled back to ${serving}`);
22079
22236
  }
22080
22237
  async function status(deps, now = () => new Date()) {
22081
22238
  const loaded = loadConfig(deps.configDir, {
@@ -22113,6 +22270,7 @@ async function status(deps, now = () => new Date()) {
22113
22270
  grants,
22114
22271
  virtualized: virtualization.virtualized,
22115
22272
  lanAddress: deps.ips[0] ?? null,
22273
+ update: readUpdateRecord(deps),
22116
22274
  };
22117
22275
  }
22118
22276
  /** Generates a one-time pairing code (#48): refuses when already paired unless `force` (which is how
@@ -22609,6 +22767,8 @@ async function dispatch(argv, io) {
22609
22767
  port: { type: "string" },
22610
22768
  name: { type: "string" },
22611
22769
  "repos-root": { type: "string" },
22770
+ // #659: set only by the agent's own update route -- see ServiceDeps.updateMode.
22771
+ update: { type: "boolean", default: false },
22612
22772
  },
22613
22773
  allowPositionals: false,
22614
22774
  });
@@ -22619,6 +22779,7 @@ async function dispatch(argv, io) {
22619
22779
  port: values.port !== undefined ? Number.parseInt(values.port, 10) : undefined,
22620
22780
  name: values.name,
22621
22781
  reposRoot,
22782
+ update: values.update ?? false,
22622
22783
  });
22623
22784
  return 0;
22624
22785
  }
@@ -22661,9 +22822,19 @@ async function dispatch(argv, io) {
22661
22822
  case "publish-adopt":
22662
22823
  await io.publishAdopt();
22663
22824
  return 0;
22664
- case "version":
22665
- io.print(packageJson.version);
22825
+ case "version": {
22826
+ const { values } = parseArgs({
22827
+ args: rest,
22828
+ options: { json: { type: "boolean", default: false } },
22829
+ allowPositionals: false,
22830
+ });
22831
+ // #659: `--json` is how the update route reads a staged bundle's API version without
22832
+ // starting it.
22833
+ io.print(values.json
22834
+ ? JSON.stringify({ version: packageJson.version, apiVersion: AGENT_API_VERSION })
22835
+ : packageJson.version);
22666
22836
  return 0;
22837
+ }
22667
22838
  default:
22668
22839
  io.printError(USAGE);
22669
22840
  return 1;
@@ -22854,6 +23025,13 @@ async function buildPlatformOps(port, unitPath, launchScriptPath, configDir, sta
22854
23025
  function systemdUnitPath(homedir) {
22855
23026
  return path.posix.join(homedir, ".config", "systemd", "user", "ac-agent.service");
22856
23027
  }
23028
+ /** A `/v1/health` answer as `install`/`status` read it: the HTTP status, plus the reported version
23029
+ * when the body carries one (#659) -- that version is what tells a restarted new bundle from the old
23030
+ * process still answering on the port. */
23031
+ function healthResultFrom(ok, body) {
23032
+ const version = typeof body === "object" && body !== null ? body.version : undefined;
23033
+ return typeof version === "string" ? { ok, version } : { ok };
23034
+ }
22857
23035
  async function buildServiceDeps(overrides = {}) {
22858
23036
  const os = await import('node:os');
22859
23037
  const fs = await import('node:fs');
@@ -22886,6 +23064,7 @@ async function buildServiceDeps(overrides = {}) {
22886
23064
  },
22887
23065
  readFile,
22888
23066
  writeFile: (p, content, mode) => fs.writeFileSync(p, content, mode !== undefined ? { mode } : undefined),
23067
+ renameFile: (from, to) => fs.renameSync(from, to),
22889
23068
  removeFile: (p) => {
22890
23069
  try {
22891
23070
  fs.unlinkSync(p);
@@ -22912,6 +23091,7 @@ async function buildServiceDeps(overrides = {}) {
22912
23091
  machineNameOverride: overrides.name,
22913
23092
  portOverride: overrides.port,
22914
23093
  reposRootOverride: overrides.reposRoot,
23094
+ updateMode: overrides.update ?? false,
22915
23095
  env: process.env,
22916
23096
  osPlatform: process.platform,
22917
23097
  readdir: (p) => fs.readdirSync(p),
@@ -22939,7 +23119,14 @@ async function buildServiceDeps(overrides = {}) {
22939
23119
  const res = await undiciFetch(`https://127.0.0.1:${healthPort}/v1/health`, {
22940
23120
  dispatcher: agent,
22941
23121
  });
22942
- return { ok: res.ok };
23122
+ let body;
23123
+ try {
23124
+ body = await res.json();
23125
+ }
23126
+ catch {
23127
+ // an unparseable body still counts by its status alone, as before
23128
+ }
23129
+ return healthResultFrom(res.ok, body);
22943
23130
  }
22944
23131
  catch {
22945
23132
  return null;
@@ -74959,4 +75146,4 @@ var tunnel = /*#__PURE__*/Object.freeze({
74959
75146
  registerTunnelVerb: registerTunnelVerb
74960
75147
  });
74961
75148
 
74962
- export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, isEntryPoint, main, realExists };
75149
+ export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, healthResultFrom, isEntryPoint, main, realExists };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/ac-agent",
3
- "version": "2.0.0-alpha.46",
3
+ "version": "2.0.0-alpha.48",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@hono/node-server": "^2.1.3",
23
- "hono": "^4.13.11",
23
+ "hono": "^4.13.12",
24
24
  "selfsigned": "^5.0.0",
25
25
  "smol-toml": "^1.9.0",
26
26
  "yaml": "^2.9.1"
@@ -29,11 +29,11 @@
29
29
  "@rollup/plugin-commonjs": "^29.0.0",
30
30
  "@rollup/plugin-json": "^6.0.0",
31
31
  "@rollup/plugin-node-resolve": "^16.0.0",
32
- "@types/node": "^26.6.3",
33
- "rollup": "^4.63.5",
32
+ "@types/node": "^26.6.4",
33
+ "rollup": "^4.64.0",
34
34
  "typescript": "^7.0.0",
35
35
  "undici": "^8.11.2",
36
- "vitest": "^5.0.2"
36
+ "vitest": "^5.0.3"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsc -p tsconfig.build.json && rollup -c",