@ouro.bot/cli 0.1.0-alpha.904 → 0.1.0-alpha.905

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/changelog.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.905",
6
+ "changes": [
7
+ "Ouro keeps itself current without a human. New `ouro self-update` runs one unattended pass. It honors an `ouro rollback` pin for seven days and then rewrites the intent to latest (pins now record pinnedAt, and a pin without one gets it written into version-intent.json when first seen). It asks registry.npmjs.org, the npm registry configured on the machine and GitHub releases for the newest @ouro.bot/cli and installs from whichever serves it, so a network that blocks npmjs or a lagging npm mirror no longer strands a machine; a GitHub release counts only when github-actions[bot] created the release and its asset under this repository's v<version> download URL. It validates and runs the new version's --version before activating it, swaps CurrentVersion atomically, restarts the daemon only if it was already running (a stopped daemon stays stopped, and a dev-mode daemon is left alone), and when the restarted daemon does not answer with the new version it reactivates the previous one, pins it with reason auto-rollback and never retries that release; a newer release replaces the auto-rollback pin. When nothing is newer but a running daemon still reports an older version than CurrentVersion, it restarts the daemon with the same health check and rollback, and the current status names any release source that did not answer. Each pass writes ~/.ouro-cli/update-state.json, and `ouro status` ends with an Updates line that starts with \"needs attention\" when the last pass failed, the updater has not run for three hours, or the launchd agent is missing. On macOS, `ouro up` and `ouro self-update` install bot.ouro.updater, a launchd agent that runs the pass at login and hourly, independent of the daemon, preferring a Homebrew node of the same major version so nvm switches do not rewrite it; `ouro up` does not reload the agent while a live pass holds the update lock, the lock is reclaimed only when its owner process is gone, and the `ouro up` it runs to restart the daemon has stdin closed and OURO_UNATTENDED=1 so its prompts take their default answer. After publishing to npm, CI downloads the exact tarball npm serves, attests its build provenance and attaches it to a GitHub release. Rolling back below this version stops unattended updates until `ouro up --latest`. A package end-to-end test serves the packed build as a GitHub release and runs `ouro self-update` in a clean HOME. See docs/known-issues-and-recovery.md. The Unraid template and the Sanctuary bundle runtime move to this version."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.904",
6
12
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.904",
2
+ "runtimeVersion": "0.1.0-alpha.905",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.904</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.905</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -68,6 +68,8 @@ const agent_discovery_1 = require("./agent-discovery");
68
68
  const bundle_manifest_1 = require("../../mind/bundle-manifest");
69
69
  const ouro_bot_global_installer_1 = require("../versioning/ouro-bot-global-installer");
70
70
  const version_intent_1 = require("../versioning/version-intent");
71
+ const self_update_defaults_1 = require("../versioning/self-update-defaults");
72
+ const update_state_1 = require("../versioning/update-state");
71
73
  const logs_prune_1 = require("./logs-prune");
72
74
  const daemon_health_1 = require("./daemon-health");
73
75
  const log_tailer_1 = require("./log-tailer");
@@ -445,6 +447,11 @@ function defaultPrepareDaemonRuntimeReplacement() {
445
447
  (0, child_process_1.execSync)("/bin/sleep 1", { stdio: "ignore" });
446
448
  }
447
449
  async function defaultPromptInput(question) {
450
+ // The unattended updater runs `ouro up` with no one to answer; take the
451
+ // default answer instead of waiting on a stdin that never ends. Piped
452
+ // stdin (scripts, tests) still answers prompts.
453
+ if (process.env.OURO_UNATTENDED === "1")
454
+ return "";
448
455
  const readline = await Promise.resolve().then(() => __importStar(require("readline/promises")));
449
456
  const rl = readline.createInterface({
450
457
  input: process.stdin,
@@ -813,6 +820,16 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
813
820
  getCurrentCliVersion: () => (0, ouro_version_manager_1.getCurrentVersion)({}),
814
821
  getPreviousCliVersion: () => (0, ouro_version_manager_1.getPreviousVersion)({}),
815
822
  listCliVersions: () => (0, ouro_version_manager_1.listInstalledVersions)({}),
823
+ runSelfUpdate: async () => {
824
+ const { runSelfUpdate } = await Promise.resolve().then(() => __importStar(require("../versioning/self-update")));
825
+ const { createDefaultSelfUpdateDeps } = await Promise.resolve().then(() => __importStar(require("../versioning/self-update-defaults")));
826
+ return runSelfUpdate(createDefaultSelfUpdateDeps({ socketPath }));
827
+ },
828
+ ensureUpdaterAgent: () => (0, self_update_defaults_1.ensureDefaultUpdaterAgent)(),
829
+ readUpdateStatusLine: () => (0, update_state_1.formatUpdateStatusLine)((0, update_state_1.readUpdateState)(), {
830
+ now: Date.now(),
831
+ updaterInstalled: (0, self_update_defaults_1.isDefaultUpdaterAgentInstalled)(),
832
+ }),
816
833
  reExecFromNewVersion: (reArgs) => {
817
834
  const entry = path.join((0, ouro_version_manager_1.getOuroCliHome)(), "CurrentVersion", "node_modules", "@ouro.bot", "cli", "dist", "heart", "daemon", "ouro-entry.js");
818
835
  require("child_process").execFileSync("node", [entry, ...reArgs], { stdio: "inherit" });
@@ -5956,6 +5956,46 @@ function writeVersionIntentWithRecoveryLauncher(deps, intent) {
5956
5956
  }
5957
5957
  deps.writeVersionIntent(intent);
5958
5958
  }
5959
+ const SELF_UPDATE_FAILURE_OUTCOMES = new Set(["unreachable", "install-failed", "rejected", "rolled-back"]);
5960
+ async function executeSelfUpdate(command, deps) {
5961
+ if (!deps.runSelfUpdate) {
5962
+ return returnCliFailure(deps, "self-update is not available in this runtime");
5963
+ }
5964
+ const result = await deps.runSelfUpdate();
5965
+ const lines = [`self-update: ${result.outcome}: ${result.summary}`];
5966
+ // Install or refresh the hourly updater after the pass so its first run
5967
+ // (RunAtLoad) does not race this one for the update lock.
5968
+ if (deps.ensureUpdaterAgent) {
5969
+ try {
5970
+ const agent = deps.ensureUpdaterAgent();
5971
+ if (agent === "installed")
5972
+ lines.push("unattended updater: installed (runs hourly and at login)");
5973
+ else if (agent === "written-reload-pending")
5974
+ lines.push("unattended updater: refreshed; reloads on the next interactive run");
5975
+ else if (agent === null && !command.unattended)
5976
+ lines.push("unattended updater: not available on this platform");
5977
+ }
5978
+ catch (error) {
5979
+ lines.push(`unattended updater: could not install: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error)}`);
5980
+ }
5981
+ }
5982
+ const message = lines.join("\n");
5983
+ if (SELF_UPDATE_FAILURE_OUTCOMES.has(result.outcome)) {
5984
+ return returnCliFailure(deps, message);
5985
+ }
5986
+ deps.writeStdout(message);
5987
+ return message;
5988
+ }
5989
+ function withUpdateStatusLine(message, deps) {
5990
+ let line = null;
5991
+ try {
5992
+ line = deps.readUpdateStatusLine?.() ?? null;
5993
+ }
5994
+ catch {
5995
+ line = null;
5996
+ }
5997
+ return line ? `${message}\n\n${line}` : message;
5998
+ }
5959
5999
  async function performSystemSetup(deps) {
5960
6000
  // Install ouro command to PATH (non-blocking)
5961
6001
  if (deps.installOuroCommand) {
@@ -6031,6 +6071,21 @@ async function performSystemSetup(deps) {
6031
6071
  }
6032
6072
  /* v8 ignore stop */
6033
6073
  }
6074
+ // Keep the unattended updater installed so updates do not depend on the daemon.
6075
+ if (deps.ensureUpdaterAgent) {
6076
+ try {
6077
+ deps.ensureUpdaterAgent();
6078
+ }
6079
+ catch (error) {
6080
+ (0, runtime_1.emitNervesEvent)({
6081
+ level: "warn",
6082
+ component: "daemon",
6083
+ event: "daemon.system_setup_updater_agent_error",
6084
+ message: "failed to install the unattended updater launch agent",
6085
+ meta: { error: error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error) },
6086
+ });
6087
+ }
6088
+ }
6034
6089
  // Register .ouro bundle type (UTI on macOS)
6035
6090
  await registerOuroBundleTypeNonBlocking(deps);
6036
6091
  }
@@ -7274,7 +7329,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
7274
7329
  }
7275
7330
  targetVersion = previousVersion;
7276
7331
  }
7277
- writeVersionIntentWithRecoveryLauncher(deps, { schemaVersion: 1, mode: "pinned", targetVersion });
7332
+ writeVersionIntentWithRecoveryLauncher(deps, {
7333
+ schemaVersion: 1,
7334
+ mode: "pinned",
7335
+ targetVersion,
7336
+ pinnedAt: new Date((deps.now ?? Date.now)()).toISOString(),
7337
+ reason: "rollback",
7338
+ });
7278
7339
  deps.activateCliVersion(targetVersion);
7279
7340
  // Stop daemon (non-fatal if not running)
7280
7341
  try {
@@ -7291,6 +7352,10 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
7291
7352
  if (command.kind === "a2a.identity" || command.kind === "a2a.message" || command.kind === "a2a.outbox") {
7292
7353
  return executeA2AClientCommand(command, deps);
7293
7354
  }
7355
+ // ── self-update (unattended-safe update pass; never starts a stopped daemon) ──
7356
+ if (command.kind === "self-update") {
7357
+ return executeSelfUpdate(command, deps);
7358
+ }
7294
7359
  // ── versions command (local install list + published update truth, no daemon socket needed) ──
7295
7360
  if (command.kind === "versions") {
7296
7361
  const versions = deps.listCliVersions?.() ?? [];
@@ -8979,7 +9044,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
8979
9044
  if (command.kind === "daemon.status" && (0, cli_render_1.isDaemonUnavailableError)(error)) {
8980
9045
  const message = command.json
8981
9046
  ? daemonUnavailableStatusJsonOutput(deps.socketPath, deps.healthFilePath)
8982
- : (0, cli_render_1.daemonUnavailableStatusOutput)(deps.socketPath, deps.healthFilePath);
9047
+ : withUpdateStatusLine((0, cli_render_1.daemonUnavailableStatusOutput)(deps.socketPath, deps.healthFilePath), deps);
8983
9048
  deps.writeStdout(message);
8984
9049
  return message;
8985
9050
  }
@@ -8994,7 +9059,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
8994
9059
  const message = command.kind === "daemon.status"
8995
9060
  ? command.json
8996
9061
  ? formatDaemonStatusJsonOutput(response)
8997
- : (0, cli_render_1.formatDaemonStatusOutput)(response, fallbackMessage)
9062
+ : withUpdateStatusLine((0, cli_render_1.formatDaemonStatusOutput)(response, fallbackMessage), deps)
8998
9063
  : command.kind === "habit.probe" && command.json
8999
9064
  ? habitProbeJsonResponse(response)
9000
9065
  : fallbackMessage;
@@ -62,6 +62,12 @@ exports.COMMAND_REGISTRY = {
62
62
  usage: "ouro rollback [<version>]",
63
63
  example: "ouro rollback 0.1.0-alpha.250",
64
64
  },
65
+ "self-update": {
66
+ category: "Lifecycle",
67
+ description: "Update the installed CLI to the newest release from npm or GitHub, restart a running daemon on it, roll back if it fails its checks, and install the hourly launchd updater on macOS",
68
+ usage: "ouro self-update [--unattended]",
69
+ example: "ouro self-update",
70
+ },
65
71
  versions: {
66
72
  category: "Lifecycle",
67
73
  description: "List installed CLI versions and show the active version intent mode and target",
@@ -2988,6 +2988,13 @@ function parseOuroCommand(args) {
2988
2988
  throw new Error("Usage: ouro move <agent> here");
2989
2989
  return { kind: "agent.move", agent: second };
2990
2990
  }
2991
+ if (head === "self-update") {
2992
+ const rest = args.slice(1);
2993
+ const unknown = rest.filter((token) => token !== "--unattended");
2994
+ if (unknown.length > 0)
2995
+ throw new Error("Usage: ouro self-update [--unattended]");
2996
+ return { kind: "self-update", ...(rest.includes("--unattended") ? { unattended: true } : {}) };
2997
+ }
2991
2998
  if (head === "stop" || head === "down")
2992
2999
  return { kind: "daemon.stop" };
2993
3000
  if (head === "status") {
@@ -240,6 +240,7 @@ function activateVersion(version, deps) {
240
240
  const readlinkSync = deps.readlinkSync ?? fs.readlinkSync;
241
241
  const unlinkSync = deps.unlinkSync ?? fs.unlinkSync;
242
242
  const symlinkSync = deps.symlinkSync ?? fs.symlinkSync;
243
+ const renameSync = deps.renameSync ?? fs.renameSync;
243
244
  const existsSync = deps.existsSync ?? fs.existsSync;
244
245
  /* v8 ignore stop */
245
246
  const currentVersionPath = path.join(cliHome, "CurrentVersion");
@@ -269,11 +270,33 @@ function activateVersion(version, deps) {
269
270
  }
270
271
  symlinkSync(oldTarget, previousPath);
271
272
  }
272
- // Update CurrentVersion symlink
273
- if (existsSync(currentVersionPath)) {
274
- unlinkSync(currentVersionPath);
273
+ // Swap CurrentVersion atomically: build the new link beside it and rename
274
+ // it over the old one, so a process killed mid-swap never leaves the
275
+ // machine without a CurrentVersion.
276
+ const tempPath = `${currentVersionPath}.next-${process.pid}`;
277
+ try {
278
+ unlinkSync(tempPath);
279
+ }
280
+ catch {
281
+ // No leftover from an earlier interrupted swap.
282
+ }
283
+ symlinkSync(newTarget, tempPath);
284
+ try {
285
+ renameSync(tempPath, currentVersionPath);
286
+ }
287
+ catch {
288
+ // rename cannot replace a real directory; fall back to unlink + link.
289
+ try {
290
+ unlinkSync(tempPath);
291
+ }
292
+ catch {
293
+ // Already gone.
294
+ }
295
+ if (existsSync(currentVersionPath)) {
296
+ unlinkSync(currentVersionPath);
297
+ }
298
+ symlinkSync(newTarget, currentVersionPath);
275
299
  }
276
- symlinkSync(newTarget, currentVersionPath);
277
300
  }
278
301
  /**
279
302
  * Compare two version strings of the form `0.1.0-alpha.{n}`. Returns
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GITHUB_RELEASE_PUBLISHER = exports.GITHUB_DOWNLOAD_BASE = exports.GITHUB_RELEASES_API = exports.NPMJS_REGISTRY = exports.CLI_PACKAGE = void 0;
37
+ exports.selfUpdateE2eOverrides = selfUpdateE2eOverrides;
38
+ exports.githubTarballName = githubTarballName;
39
+ exports.lookupLatestRelease = lookupLatestRelease;
40
+ exports.installRelease = installRelease;
41
+ const path = __importStar(require("path"));
42
+ const runtime_1 = require("../../nerves/runtime");
43
+ const ouro_version_manager_1 = require("./ouro-version-manager");
44
+ exports.CLI_PACKAGE = "@ouro.bot/cli";
45
+ exports.NPMJS_REGISTRY = "https://registry.npmjs.org/";
46
+ exports.GITHUB_RELEASES_API = "https://api.github.com/repos/ourostack/ouroboros/releases?per_page=20";
47
+ exports.GITHUB_DOWNLOAD_BASE = "https://github.com/ourostack/ouroboros/releases/download/";
48
+ /** Only releases and assets that our release workflow created are trusted. */
49
+ exports.GITHUB_RELEASE_PUBLISHER = "github-actions[bot]";
50
+ const LOOKUP_TIMEOUT_MS = 15_000;
51
+ const INSTALL_TIMEOUT_MS = 10 * 60_000;
52
+ /**
53
+ * The package end-to-end test serves a packed build as a GitHub release from
54
+ * a loopback HTTP server. Honored only with OURO_SELF_UPDATE_E2E=1 and a
55
+ * loopback base URL, so it cannot point a real machine at another host.
56
+ */
57
+ function selfUpdateE2eOverrides(env) {
58
+ const base = env.OURO_SELF_UPDATE_E2E_GITHUB_BASE?.replace(/\/+$/, "");
59
+ if (env.OURO_SELF_UPDATE_E2E !== "1" || !base)
60
+ return null;
61
+ if (!/^http:\/\/(127\.0\.0\.1|localhost):\d+$/.test(base))
62
+ return null;
63
+ return { github: { apiUrl: `${base}/releases`, downloadBase: `${base}/releases/download/` }, githubOnly: true };
64
+ }
65
+ function githubTarballName(version) {
66
+ return `ouro.bot-cli-${version}.tgz`;
67
+ }
68
+ function message(error) {
69
+ return error instanceof Error ? error.message : String(error);
70
+ }
71
+ async function askNpmjs(deps) {
72
+ const body = await deps.fetchJson(`${exports.NPMJS_REGISTRY}${exports.CLI_PACKAGE.replace("/", "%2f")}`, LOOKUP_TIMEOUT_MS);
73
+ const version = body?.["dist-tags"]?.latest;
74
+ if (typeof version !== "string")
75
+ throw new Error("registry response has no latest dist-tag");
76
+ return { source: "npmjs", version };
77
+ }
78
+ async function askConfiguredRegistry(deps) {
79
+ const stdout = await deps.execFile("npm", ["view", exports.CLI_PACKAGE, "dist-tags.latest", "--json"], { timeoutMs: LOOKUP_TIMEOUT_MS });
80
+ const version = JSON.parse(stdout.trim());
81
+ if (typeof version !== "string")
82
+ throw new Error("npm view returned no latest dist-tag");
83
+ return { source: "npm-registry", version };
84
+ }
85
+ async function askGithub(deps) {
86
+ const github = deps.github ?? { apiUrl: exports.GITHUB_RELEASES_API, downloadBase: exports.GITHUB_DOWNLOAD_BASE };
87
+ const releases = await deps.fetchJson(github.apiUrl, LOOKUP_TIMEOUT_MS);
88
+ if (!Array.isArray(releases))
89
+ throw new Error("GitHub releases response is not a list");
90
+ let best = null;
91
+ for (const release of releases) {
92
+ if (release.draft === true || typeof release.tag_name !== "string")
93
+ continue;
94
+ const tag = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)$/.exec(release.tag_name);
95
+ if (!tag || release.author?.login !== exports.GITHUB_RELEASE_PUBLISHER)
96
+ continue;
97
+ const version = tag[1];
98
+ const name = githubTarballName(version);
99
+ const expectedUrl = `${github.downloadBase}v${version}/${name}`;
100
+ const asset = (release.assets ?? []).find((entry) => entry.name === name &&
101
+ entry.browser_download_url === expectedUrl &&
102
+ entry.uploader?.login === exports.GITHUB_RELEASE_PUBLISHER);
103
+ if (!asset)
104
+ continue;
105
+ if (!best || (0, ouro_version_manager_1.compareCliVersions)(version, best.version) > 0) {
106
+ best = { source: "github", version, tarballUrl: expectedUrl };
107
+ }
108
+ }
109
+ if (!best)
110
+ throw new Error(`no GitHub release published by ${exports.GITHUB_RELEASE_PUBLISHER} carries an npm tarball`);
111
+ return best;
112
+ }
113
+ async function lookupLatestRelease(deps) {
114
+ const asks = [
115
+ ["npmjs", () => askNpmjs(deps)],
116
+ ["npm-registry", () => askConfiguredRegistry(deps)],
117
+ ["github", () => askGithub(deps)],
118
+ ];
119
+ const answers = await Promise.all(asks.filter(([source]) => !deps.githubOnly || source === "github").map(async ([source, ask]) => {
120
+ try {
121
+ return await ask();
122
+ }
123
+ catch (error) {
124
+ return { source, error: message(error) };
125
+ }
126
+ }));
127
+ let latestVersion = null;
128
+ for (const answer of answers) {
129
+ if (answer.version && (!latestVersion || (0, ouro_version_manager_1.compareCliVersions)(answer.version, latestVersion) > 0)) {
130
+ latestVersion = answer.version;
131
+ }
132
+ }
133
+ (0, runtime_1.emitNervesEvent)({
134
+ component: "daemon",
135
+ event: "daemon.self_update_lookup",
136
+ message: "looked up the latest published CLI release",
137
+ meta: {
138
+ latestVersion,
139
+ answers: answers.map((answer) => `${answer.source}=${answer.version ?? `error: ${answer.error}`}`).join("; "),
140
+ },
141
+ });
142
+ return { latestVersion, answers };
143
+ }
144
+ /**
145
+ * Install `version` into `~/.ouro-cli/versions/<version>`, trying each source
146
+ * that can serve it. Returns the source that worked.
147
+ */
148
+ async function installRelease(version, lookup, cliHome, deps) {
149
+ const prefix = path.join(cliHome, "versions", version);
150
+ const github = lookup.answers.find((answer) => answer.source === "github" && answer.version === version && answer.tarballUrl);
151
+ const attempts = [
152
+ ["npm-registry", ["install", "--prefix", prefix, `${exports.CLI_PACKAGE}@${version}`]],
153
+ ...(github ? [["github", ["install", "--prefix", prefix, github.tarballUrl]]] : []),
154
+ ["npmjs", ["install", "--prefix", prefix, `${exports.CLI_PACKAGE}@${version}`, "--registry", exports.NPMJS_REGISTRY]],
155
+ ];
156
+ const failures = [];
157
+ for (const [source, args] of attempts.filter(([source]) => !deps.githubOnly || source === "github")) {
158
+ try {
159
+ await deps.execFile("npm", args, { timeoutMs: INSTALL_TIMEOUT_MS });
160
+ (0, runtime_1.emitNervesEvent)({
161
+ component: "daemon",
162
+ event: "daemon.self_update_installed",
163
+ message: "installed CLI release for self-update",
164
+ meta: { version, source },
165
+ });
166
+ return source;
167
+ }
168
+ catch (error) {
169
+ failures.push(`${source}: ${message(error).split("\n")[0]}`);
170
+ }
171
+ }
172
+ throw new Error(`could not install ${version} from any source (${failures.join("; ")})`);
173
+ }
@@ -0,0 +1,294 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.releaseSourceDeps = void 0;
37
+ exports.isSelfUpdateLockHeld = isSelfUpdateLockHeld;
38
+ exports.createDefaultSelfUpdateDeps = createDefaultSelfUpdateDeps;
39
+ exports.ensureDefaultUpdaterAgent = ensureDefaultUpdaterAgent;
40
+ exports.isDefaultUpdaterAgentInstalled = isDefaultUpdaterAgentInstalled;
41
+ /* v8 ignore start -- production wiring for self-update: real network, npm, launchd and daemon socket; the logic it feeds is unit-tested through injected deps @preserve */
42
+ const child_process_1 = require("child_process");
43
+ const fs = __importStar(require("fs"));
44
+ const os = __importStar(require("os"));
45
+ const path = __importStar(require("path"));
46
+ const runtime_1 = require("../../nerves/runtime");
47
+ const cli_render_1 = require("../daemon/cli-render");
48
+ const socket_client_1 = require("../daemon/socket-client");
49
+ const ouro_version_manager_1 = require("./ouro-version-manager");
50
+ const release_sources_1 = require("./release-sources");
51
+ const update_state_1 = require("./update-state");
52
+ const updater_launchd_1 = require("./updater-launchd");
53
+ const version_intent_1 = require("./version-intent");
54
+ const DAEMON_UP_TIMEOUT_MS = 10 * 60_000;
55
+ const DAEMON_HEALTH_WAIT_MS = 90_000;
56
+ function execFileText(command, args, options) {
57
+ return new Promise((resolve, reject) => {
58
+ (0, child_process_1.execFile)(command, args, { timeout: options.timeoutMs, maxBuffer: 16 * 1024 * 1024, env: process.env }, (error, stdout, stderr) => {
59
+ if (error) {
60
+ const detail = String(stderr || "").trim().split("\n").slice(-3).join(" ");
61
+ reject(new Error(`${command} ${args[0] ?? ""} failed: ${detail || error.message}`));
62
+ return;
63
+ }
64
+ resolve(String(stdout));
65
+ });
66
+ });
67
+ }
68
+ async function fetchJson(url, timeoutMs) {
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
71
+ try {
72
+ const response = await fetch(url, {
73
+ signal: controller.signal,
74
+ headers: { accept: "application/json", "user-agent": "ouro-self-update" },
75
+ });
76
+ if (!response.ok)
77
+ throw new Error(`HTTP ${response.status} from ${new URL(url).host}`);
78
+ return await response.json();
79
+ }
80
+ catch (error) {
81
+ if (error instanceof Error && error.name === "AbortError") {
82
+ throw new Error(`timed out after ${Math.round(timeoutMs / 1000)}s reaching ${new URL(url).host}`);
83
+ }
84
+ throw error;
85
+ }
86
+ finally {
87
+ clearTimeout(timer);
88
+ }
89
+ }
90
+ /**
91
+ * Run a command with stdin closed and OURO_UNATTENDED=1. `ouro up` can prompt
92
+ * (for example about manually cloned bundles); unattended, every prompt takes
93
+ * its default answer at once instead of waiting out the restart timeout.
94
+ */
95
+ function runDetachedFromStdin(command, args, timeoutMs) {
96
+ return new Promise((resolve, reject) => {
97
+ const child = (0, child_process_1.spawn)(command, args, { stdio: ["ignore", "ignore", "pipe"], env: { ...process.env, OURO_UNATTENDED: "1" } });
98
+ let stderr = "";
99
+ child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); });
100
+ const timer = setTimeout(() => {
101
+ child.kill("SIGTERM");
102
+ reject(new Error(`${command} ${args.join(" ")} timed out after ${Math.round(timeoutMs / 1000)}s`));
103
+ }, timeoutMs);
104
+ child.on("error", (error) => {
105
+ clearTimeout(timer);
106
+ reject(error);
107
+ });
108
+ child.on("close", (code) => {
109
+ clearTimeout(timer);
110
+ if (code === 0)
111
+ resolve();
112
+ else
113
+ reject(new Error(`${args.join(" ")} exited ${code}: ${stderr.trim().split("\n").slice(-3).join(" ")}`));
114
+ });
115
+ });
116
+ }
117
+ exports.releaseSourceDeps = { fetchJson, execFile: execFileText, ...(0, release_sources_1.selfUpdateE2eOverrides)(process.env) };
118
+ function isPidAlive(pid) {
119
+ try {
120
+ process.kill(pid, 0);
121
+ return true;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ function lockPath(cliHome) {
128
+ return path.join(cliHome, "self-update.lock");
129
+ }
130
+ /** The pid holding the update lock when that process is still alive. */
131
+ function liveLockOwner(cliHome) {
132
+ try {
133
+ const pid = Number.parseInt(fs.readFileSync(lockPath(cliHome), "utf8"), 10);
134
+ return Number.isFinite(pid) && pid > 0 && isPidAlive(pid) ? pid : null;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ /** True when another live process is in the middle of an update pass. */
141
+ function isSelfUpdateLockHeld(cliHome) {
142
+ const owner = liveLockOwner(cliHome);
143
+ return owner !== null && owner !== process.pid;
144
+ }
145
+ function acquireLock(cliHome) {
146
+ const file = lockPath(cliHome);
147
+ fs.mkdirSync(cliHome, { recursive: true });
148
+ for (let attempt = 0; attempt < 2; attempt++) {
149
+ try {
150
+ const fd = fs.openSync(file, "wx", 0o600);
151
+ fs.writeSync(fd, `${process.pid}\n`);
152
+ fs.closeSync(fd);
153
+ return () => {
154
+ try {
155
+ fs.unlinkSync(file);
156
+ }
157
+ catch { /* already released */ }
158
+ };
159
+ }
160
+ catch (error) {
161
+ if (error.code !== "EEXIST")
162
+ throw error;
163
+ // A lock is stale only when its owner is gone; a long install by a live
164
+ // owner keeps it, however long it takes.
165
+ if (liveLockOwner(cliHome) !== null)
166
+ return null;
167
+ try {
168
+ fs.unlinkSync(file);
169
+ }
170
+ catch { /* raced with the owner releasing it */ }
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+ function versionEntry(cliHome, version) {
176
+ return path.join(cliHome, "versions", version, "node_modules", "@ouro.bot", "cli", "dist", "heart", "daemon", "ouro-entry.js");
177
+ }
178
+ async function runningDaemonVersion(socketPath) {
179
+ try {
180
+ const response = await (0, socket_client_1.sendDaemonCommand)(socketPath, { kind: "daemon.status" });
181
+ return (0, cli_render_1.parseStatusPayload)(response.data)?.overview.version ?? null;
182
+ }
183
+ catch {
184
+ return null;
185
+ }
186
+ }
187
+ function createDefaultSelfUpdateDeps(options = {}) {
188
+ const homeDir = options.homeDir ?? os.homedir();
189
+ const cliHome = (0, ouro_version_manager_1.getOuroCliHome)(homeDir);
190
+ // The package end-to-end test must never see a daemon on the host machine.
191
+ const socketPath = process.env.OURO_SELF_UPDATE_E2E === "1"
192
+ ? path.join(cliHome, "e2e-no-daemon.sock")
193
+ : options.socketPath ?? socket_client_1.DEFAULT_DAEMON_SOCKET_PATH;
194
+ const launcher = path.join(cliHome, "bin", "ouro-launcher.js");
195
+ return {
196
+ now: () => Date.now(),
197
+ getCurrentVersion: () => (0, ouro_version_manager_1.getCurrentVersion)({ homeDir }),
198
+ readIntent: () => (0, version_intent_1.readVersionIntent)({ homeDir }),
199
+ writeIntent: (intent) => (0, version_intent_1.writeVersionIntent)(intent, { homeDir }),
200
+ readState: () => (0, update_state_1.readUpdateState)({ homeDir }),
201
+ writeState: (state) => (0, update_state_1.writeUpdateState)(state, { homeDir }),
202
+ lookupLatest: () => (0, release_sources_1.lookupLatestRelease)(exports.releaseSourceDeps),
203
+ install: (version, lookup) => (0, release_sources_1.installRelease)(version, lookup, cliHome, exports.releaseSourceDeps),
204
+ validate: (version) => (0, ouro_version_manager_1.validateInstalledVersionForActivation)(version, { homeDir }),
205
+ smokeTest: async (version) => {
206
+ try {
207
+ const output = (await execFileText(process.execPath, [versionEntry(cliHome, version), "--version"], { timeoutMs: 60_000 })).trim();
208
+ return output.includes(version) ? { ok: true } : { ok: false, detail: `--version printed "${output.slice(0, 120)}"` };
209
+ }
210
+ catch (error) {
211
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
212
+ }
213
+ },
214
+ activate: (version) => {
215
+ (0, ouro_version_manager_1.activateVersion)(version, { homeDir });
216
+ (0, ouro_version_manager_1.pruneOldVersions)(undefined, { homeDir });
217
+ },
218
+ isDaemonRunning: () => (0, socket_client_1.checkDaemonSocketAlive)(socketPath),
219
+ runningDaemonVersion: async () => (await (0, socket_client_1.checkDaemonSocketAlive)(socketPath)) ? runningDaemonVersion(socketPath) : null,
220
+ restartDaemon: async (expectedVersion) => {
221
+ try {
222
+ await runDetachedFromStdin(process.execPath, [launcher, "up"], DAEMON_UP_TIMEOUT_MS);
223
+ }
224
+ catch (error) {
225
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
226
+ }
227
+ const deadline = Date.now() + DAEMON_HEALTH_WAIT_MS;
228
+ let seen = null;
229
+ while (Date.now() < deadline) {
230
+ seen = await runningDaemonVersion(socketPath);
231
+ if (seen === expectedVersion)
232
+ return { ok: true };
233
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
234
+ }
235
+ return { ok: false, detail: seen ? `daemon answered with ${seen}, expected ${expectedVersion}` : "daemon did not answer after ouro up" };
236
+ },
237
+ isDevMode: () => fs.existsSync(path.join(cliHome, "dev-config.json")),
238
+ acquireLock: () => acquireLock(cliHome),
239
+ };
240
+ }
241
+ function nodeMajorOf(nodePath) {
242
+ try {
243
+ fs.accessSync(nodePath, fs.constants.X_OK);
244
+ const version = (0, child_process_1.execFileSync)(nodePath, ["--version"], { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
245
+ const major = Number.parseInt(version.replace(/^v/, ""), 10);
246
+ return Number.isFinite(major) ? major : null;
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ }
252
+ function ensureDefaultUpdaterAgent(options = {}) {
253
+ // The package end-to-end test runs in a throwaway HOME; it must never load
254
+ // a real launch agent on the host.
255
+ if (process.platform !== "darwin" || process.env.OURO_SELF_UPDATE_E2E === "1")
256
+ return null;
257
+ const homeDir = options.homeDir ?? os.homedir();
258
+ const result = (0, updater_launchd_1.ensureUpdaterAgent)({
259
+ homeDir,
260
+ userUid: os.userInfo().uid,
261
+ nodePath: (0, updater_launchd_1.chooseUpdaterNodePath)({
262
+ execPath: process.execPath,
263
+ runningMajor: Number.parseInt(process.versions.node, 10),
264
+ majorOf: nodeMajorOf,
265
+ }),
266
+ readFile: (filePath) => {
267
+ try {
268
+ return fs.readFileSync(filePath, "utf8");
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ },
274
+ writeFile: (filePath, content) => fs.writeFileSync(filePath, content, "utf8"),
275
+ mkdirp: (dir) => fs.mkdirSync(dir, { recursive: true }),
276
+ exec: (command) => { require("child_process").execSync(command, { stdio: "ignore" }); },
277
+ runningUnderUpdaterAgent: process.env.XPC_SERVICE_NAME === updater_launchd_1.UPDATER_PLIST_LABEL,
278
+ updateInProgress: isSelfUpdateLockHeld((0, ouro_version_manager_1.getOuroCliHome)(homeDir)),
279
+ });
280
+ (0, runtime_1.emitNervesEvent)({
281
+ component: "daemon",
282
+ event: "daemon.self_update_agent_checked",
283
+ message: "checked the unattended updater launch agent",
284
+ meta: { result },
285
+ });
286
+ return result;
287
+ }
288
+ function isDefaultUpdaterAgentInstalled(options = {}) {
289
+ if (process.platform !== "darwin")
290
+ return null;
291
+ const homeDir = options.homeDir ?? os.homedir();
292
+ return fs.existsSync(path.join(homeDir, "Library", "LaunchAgents", `${updater_launchd_1.UPDATER_PLIST_LABEL}.plist`));
293
+ }
294
+ /* v8 ignore stop */
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PIN_MAX_AGE_MS = void 0;
4
+ exports.runSelfUpdate = runSelfUpdate;
5
+ const runtime_1 = require("../../nerves/runtime");
6
+ const ouro_version_manager_1 = require("./ouro-version-manager");
7
+ /**
8
+ * One unattended update pass. It never starts a stopped daemon, never leaves
9
+ * the machine on a version that failed its checks, and always records what
10
+ * happened in update-state.json so `ouro status` can report it.
11
+ *
12
+ * Steps:
13
+ * 1. Honor a human pin (`ouro rollback`) for PIN_MAX_AGE_MS, then expire it
14
+ * by rewriting the intent to "latest". A pin without a timestamp gets
15
+ * one the first time a pass sees it.
16
+ * 2. Ask every release source for the newest version. When nothing is newer
17
+ * but a running daemon still reports an older version (a pass was killed
18
+ * between activation and restart), restart it on CurrentVersion.
19
+ * 3. Install it, validate the payload and run its `--version` smoke test.
20
+ * 4. Activate it. If the daemon was running, restart it on the new version
21
+ * and check that it answers; otherwise leave the daemon stopped.
22
+ * 5. On a failed check, reactivate the previous version, pin it with reason
23
+ * "auto-rollback", restart the daemon on it, and never retry the failed
24
+ * version.
25
+ */
26
+ exports.PIN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
27
+ function errorText(error) {
28
+ return error instanceof Error ? error.message : String(error);
29
+ }
30
+ const FAILURE_OUTCOMES = ["unreachable", "install-failed", "rejected", "rolled-back"];
31
+ async function runSelfUpdate(deps) {
32
+ const release = deps.acquireLock();
33
+ if (!release) {
34
+ return { outcome: "busy", summary: "another ouro self-update is already running" };
35
+ }
36
+ try {
37
+ return await runLocked(deps);
38
+ }
39
+ finally {
40
+ release();
41
+ }
42
+ }
43
+ async function runLocked(deps) {
44
+ const now = deps.now();
45
+ const previous = deps.readState();
46
+ const failedVersions = [...(previous?.failedVersions ?? [])];
47
+ const currentVersion = deps.getCurrentVersion();
48
+ const finish = (outcome, fields) => {
49
+ const { summary, ...rest } = fields;
50
+ const state = {
51
+ schemaVersion: 1,
52
+ lastRunAt: new Date(now).toISOString(),
53
+ outcome,
54
+ currentVersion: deps.getCurrentVersion(),
55
+ failedVersions,
56
+ consecutiveFailures: FAILURE_OUTCOMES.includes(outcome) ? (previous?.consecutiveFailures ?? 0) + 1 : 0,
57
+ ...rest,
58
+ detail: rest.detail ?? summary,
59
+ };
60
+ deps.writeState(state);
61
+ (0, runtime_1.emitNervesEvent)({
62
+ level: FAILURE_OUTCOMES.includes(outcome) ? "warn" : "info",
63
+ component: "daemon",
64
+ event: "daemon.self_update_result",
65
+ message: summary,
66
+ meta: { outcome, currentVersion: state.currentVersion, latestVersion: state.latestVersion ?? null },
67
+ });
68
+ return { outcome, summary, state };
69
+ };
70
+ /**
71
+ * Restart the daemon on `target`. On a failed health check, never retry
72
+ * `target`, pin and reactivate `fallback`, and restart on it. Returns the
73
+ * rolled-back result, or null when `target` came up healthy.
74
+ */
75
+ const restartOrRollBack = async (target, fallback, source) => {
76
+ const health = await deps.restartDaemon(target);
77
+ if (health.ok)
78
+ return null;
79
+ failedVersions.push(target);
80
+ if (!fallback) {
81
+ return finish("rolled-back", {
82
+ summary: `${target} did not come up healthy (${health.detail ?? "no detail"}) and there is no previous version to return to`,
83
+ latestVersion: target,
84
+ source,
85
+ });
86
+ }
87
+ deps.writeIntent({
88
+ schemaVersion: 1,
89
+ mode: "pinned",
90
+ targetVersion: fallback,
91
+ pinnedAt: new Date(now).toISOString(),
92
+ reason: "auto-rollback",
93
+ });
94
+ deps.activate(fallback);
95
+ const restored = await deps.restartDaemon(fallback);
96
+ return finish("rolled-back", {
97
+ summary: `${target} did not come up healthy (${health.detail ?? "no detail"}); rolled back to ${fallback}${restored.ok ? "" : ` but the daemon did not answer on it either (${restored.detail ?? "no detail"})`}`,
98
+ latestVersion: target,
99
+ source,
100
+ });
101
+ };
102
+ // 1. Pins.
103
+ const intent = deps.readIntent();
104
+ if (intent?.mode === "pinned" && intent.reason !== "auto-rollback") {
105
+ let pinnedAt = intent.pinnedAt;
106
+ if (!pinnedAt || Number.isNaN(Date.parse(pinnedAt))) {
107
+ // A pin written before pins carried a timestamp: start its clock now and
108
+ // record it in the intent itself, so the pin cannot outlive its expiry.
109
+ pinnedAt = new Date(now).toISOString();
110
+ deps.writeIntent({ ...intent, pinnedAt });
111
+ }
112
+ const expiresAtMs = Date.parse(pinnedAt) + exports.PIN_MAX_AGE_MS;
113
+ if (now < expiresAtMs) {
114
+ const until = new Date(expiresAtMs).toISOString();
115
+ return finish("pinned", {
116
+ summary: `pinned to ${intent.targetVersion} by ouro rollback; unattended updates resume after ${until} (run \`ouro up --latest\` to resume now)`,
117
+ detail: `pin honored until ${until}`,
118
+ });
119
+ }
120
+ (0, runtime_1.emitNervesEvent)({
121
+ level: "warn",
122
+ component: "daemon",
123
+ event: "daemon.self_update_pin_expired",
124
+ message: "a rollback pin older than the maximum age no longer blocks updates",
125
+ meta: { targetVersion: intent.targetVersion, pinnedAt },
126
+ });
127
+ deps.writeIntent({ schemaVersion: 1, mode: "latest", targetVersion: intent.targetVersion });
128
+ }
129
+ // 2. Lookup.
130
+ const lookup = await deps.lookupLatest();
131
+ const sourceSummary = lookup.answers.map((answer) => `${answer.source}: ${answer.version ?? answer.error}`).join("; ");
132
+ const latest = lookup.latestVersion;
133
+ if (!latest) {
134
+ return finish("unreachable", { summary: `no release source answered (${sourceSummary})` });
135
+ }
136
+ const lookupSource = lookup.answers.find((answer) => answer.version === latest)?.source;
137
+ if (currentVersion && (0, ouro_version_manager_1.compareCliVersions)(latest, currentVersion) <= 0) {
138
+ // A pass killed between activation and restart leaves the old daemon
139
+ // running; finish the job instead of reporting "current" forever.
140
+ const daemonVersion = deps.isDevMode() ? null : await deps.runningDaemonVersion();
141
+ if (daemonVersion && daemonVersion !== currentVersion) {
142
+ const restart = await restartOrRollBack(currentVersion, daemonVersion);
143
+ if (restart)
144
+ return restart;
145
+ return finish("updated", {
146
+ summary: `daemon was still running ${daemonVersion}; restarted it on ${currentVersion} and it is healthy`,
147
+ latestVersion: latest,
148
+ source: lookupSource,
149
+ });
150
+ }
151
+ const failedSources = lookup.answers.filter((answer) => !answer.version).map((answer) => `${answer.source}: ${answer.error}`);
152
+ return finish("current", {
153
+ summary: `current at ${currentVersion}${failedSources.length ? ` (unreachable: ${failedSources.join("; ")})` : ""}`,
154
+ latestVersion: latest,
155
+ source: lookupSource,
156
+ });
157
+ }
158
+ if (failedVersions.includes(latest)) {
159
+ return finish("rejected", {
160
+ summary: `latest ${latest} failed its checks on this machine earlier; staying on ${currentVersion} until a newer release ships`,
161
+ latestVersion: latest,
162
+ });
163
+ }
164
+ // 3. Install, validate, smoke test.
165
+ let installSource;
166
+ try {
167
+ installSource = await deps.install(latest, lookup);
168
+ }
169
+ catch (error) {
170
+ return finish("install-failed", { summary: errorText(error), latestVersion: latest });
171
+ }
172
+ const validation = deps.validate(latest);
173
+ const smoke = validation.ok ? await deps.smokeTest(latest) : { ok: false, detail: validation.message };
174
+ if (!smoke.ok) {
175
+ failedVersions.push(latest);
176
+ return finish("rejected", {
177
+ summary: `${latest} failed its pre-activation check: ${smoke.detail ?? "no detail"}; staying on ${currentVersion}`,
178
+ latestVersion: latest,
179
+ source: installSource,
180
+ });
181
+ }
182
+ // 4. Activate, then restart a running daemon on the new version.
183
+ const daemonWasRunning = !deps.isDevMode() && await deps.isDaemonRunning();
184
+ deps.writeIntent({ schemaVersion: 1, mode: "latest", targetVersion: latest });
185
+ deps.activate(latest);
186
+ if (daemonWasRunning) {
187
+ const restart = await restartOrRollBack(latest, currentVersion, installSource);
188
+ if (restart)
189
+ return restart;
190
+ }
191
+ return finish("updated", {
192
+ summary: `updated ${currentVersion ?? "unknown"} -> ${latest} via ${installSource}${daemonWasRunning ? "; daemon restarted and healthy" : "; daemon was not running and was left stopped"}`,
193
+ latestVersion: latest,
194
+ source: installSource,
195
+ });
196
+ }
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.UPDATER_INTERVAL_SECONDS = void 0;
37
+ exports.updateStatePath = updateStatePath;
38
+ exports.readUpdateState = readUpdateState;
39
+ exports.writeUpdateState = writeUpdateState;
40
+ exports.formatUpdateStatusLine = formatUpdateStatusLine;
41
+ const fs = __importStar(require("fs"));
42
+ const os = __importStar(require("os"));
43
+ const path = __importStar(require("path"));
44
+ const runtime_1 = require("../../nerves/runtime");
45
+ exports.UPDATER_INTERVAL_SECONDS = 60 * 60;
46
+ function updateStatePath(homeDir = os.homedir()) {
47
+ return path.join(homeDir, ".ouro-cli", "update-state.json");
48
+ }
49
+ function readUpdateState(deps = {}) {
50
+ const readFileSync = deps.readFileSync ?? fs.readFileSync;
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(updateStatePath(deps.homeDir), "utf8"));
53
+ if (parsed?.schemaVersion !== 1 || typeof parsed.lastRunAt !== "string" || typeof parsed.outcome !== "string")
54
+ return null;
55
+ return {
56
+ ...parsed,
57
+ failedVersions: Array.isArray(parsed.failedVersions) ? parsed.failedVersions.filter((v) => typeof v === "string") : [],
58
+ consecutiveFailures: typeof parsed.consecutiveFailures === "number" ? parsed.consecutiveFailures : 0,
59
+ };
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ function writeUpdateState(state, deps = {}) {
66
+ const writeFileSync = deps.writeFileSync ?? fs.writeFileSync;
67
+ const renameSync = deps.renameSync ?? fs.renameSync;
68
+ const mkdirSync = deps.mkdirSync ?? fs.mkdirSync;
69
+ const destination = updateStatePath(deps.homeDir);
70
+ mkdirSync(path.dirname(destination), { recursive: true });
71
+ const temporary = `${destination}.${process.pid}.tmp`;
72
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
73
+ renameSync(temporary, destination);
74
+ (0, runtime_1.emitNervesEvent)({
75
+ level: ["updated", "current", "pinned"].includes(state.outcome) ? "info" : "warn",
76
+ component: "daemon",
77
+ event: "daemon.self_update_state_written",
78
+ message: "recorded the unattended updater outcome",
79
+ meta: { outcome: state.outcome, currentVersion: state.currentVersion, latestVersion: state.latestVersion ?? null, detail: state.detail ?? null },
80
+ });
81
+ }
82
+ function formatAge(ms) {
83
+ const minutes = Math.max(0, Math.round(ms / 60_000));
84
+ if (minutes < 60)
85
+ return `${minutes}m ago`;
86
+ const hours = Math.round(minutes / 60);
87
+ if (hours < 48)
88
+ return `${hours}h ago`;
89
+ return `${Math.round(hours / 24)}d ago`;
90
+ }
91
+ /**
92
+ * One line for `ouro status`. Problems lead with "needs attention" so a
93
+ * stuck machine is visible at a glance.
94
+ */
95
+ function formatUpdateStatusLine(state, options) {
96
+ if (!state) {
97
+ return options.updaterInstalled === false
98
+ ? "Updates: needs attention: the unattended updater is not installed; run `ouro self-update` once"
99
+ : "Updates: no update check recorded yet; run `ouro self-update`";
100
+ }
101
+ const ageMs = options.now - Date.parse(state.lastRunAt);
102
+ const age = formatAge(ageMs);
103
+ const stale = ageMs > exports.UPDATER_INTERVAL_SECONDS * 1000 * 3;
104
+ const version = state.currentVersion ?? "unknown version";
105
+ let line;
106
+ switch (state.outcome) {
107
+ case "updated":
108
+ line = `Updates: updated to ${version} ${age}${state.source ? ` via ${state.source}` : ""}`;
109
+ break;
110
+ case "current":
111
+ line = `Updates: current at ${version} (checked ${age}${state.source ? ` via ${state.source}` : ""})`;
112
+ break;
113
+ case "pinned":
114
+ line = `Updates: pinned to ${version} (checked ${age}; ${state.detail ?? "pin honored"})`;
115
+ break;
116
+ default:
117
+ line = `Updates: needs attention: ${state.outcome} ${age} on ${version}${state.latestVersion ? ` (latest ${state.latestVersion})` : ""}: ${state.detail ?? "no detail"}`;
118
+ }
119
+ if (stale) {
120
+ line += `; needs attention: the updater has not run since ${state.lastRunAt}`;
121
+ }
122
+ if (options.updaterInstalled === false) {
123
+ line += "; needs attention: the unattended updater is not installed; run `ouro self-update` once";
124
+ }
125
+ return line;
126
+ }
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.STABLE_NODE_PATHS = exports.UPDATER_PLIST_LABEL = void 0;
37
+ exports.chooseUpdaterNodePath = chooseUpdaterNodePath;
38
+ exports.updaterPlistPath = updaterPlistPath;
39
+ exports.generateUpdaterPlist = generateUpdaterPlist;
40
+ exports.ensureUpdaterAgent = ensureUpdaterAgent;
41
+ const path = __importStar(require("path"));
42
+ const runtime_1 = require("../../nerves/runtime");
43
+ const update_state_1 = require("./update-state");
44
+ /**
45
+ * A launchd agent that runs `ouro self-update --unattended` every hour and at
46
+ * login, independent of the Ouro daemon. Updates keep flowing when the
47
+ * daemon is stopped, crashed, or was never started on this machine.
48
+ */
49
+ exports.UPDATER_PLIST_LABEL = "bot.ouro.updater";
50
+ /** Node binaries that survive nvm switches and Node upgrades, in preference order. */
51
+ exports.STABLE_NODE_PATHS = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
52
+ /**
53
+ * Pick the node binary the updater agent runs. A stable path (Homebrew) is
54
+ * preferred when it exists and runs the same major Node version as this
55
+ * process, so the plist does not churn every time another nvm node runs
56
+ * `ouro up`; otherwise the running node is used.
57
+ */
58
+ function chooseUpdaterNodePath(options) {
59
+ for (const candidate of options.candidates ?? exports.STABLE_NODE_PATHS) {
60
+ if (options.majorOf(candidate) === options.runningMajor)
61
+ return candidate;
62
+ }
63
+ return options.execPath;
64
+ }
65
+ function updaterPlistPath(homeDir) {
66
+ return path.join(homeDir, "Library", "LaunchAgents", `${exports.UPDATER_PLIST_LABEL}.plist`);
67
+ }
68
+ function xmlEscape(value) {
69
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
70
+ }
71
+ function generateUpdaterPlist(options) {
72
+ const launcher = path.join(options.homeDir, ".ouro-cli", "bin", "ouro-launcher.js");
73
+ const envPath = [path.dirname(options.nodePath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":");
74
+ return [
75
+ `<?xml version="1.0" encoding="UTF-8"?>`,
76
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
77
+ `<plist version="1.0">`,
78
+ `<dict>`,
79
+ ` <key>Label</key>`,
80
+ ` <string>${exports.UPDATER_PLIST_LABEL}</string>`,
81
+ ` <key>ProgramArguments</key>`,
82
+ ` <array>`,
83
+ ` <string>${xmlEscape(options.nodePath)}</string>`,
84
+ ` <string>${xmlEscape(launcher)}</string>`,
85
+ ` <string>self-update</string>`,
86
+ ` <string>--unattended</string>`,
87
+ ` </array>`,
88
+ ` <key>RunAtLoad</key>`,
89
+ ` <true/>`,
90
+ ` <key>StartInterval</key>`,
91
+ ` <integer>${update_state_1.UPDATER_INTERVAL_SECONDS}</integer>`,
92
+ ` <key>ProcessType</key>`,
93
+ ` <string>Background</string>`,
94
+ ` <key>EnvironmentVariables</key>`,
95
+ ` <dict>`,
96
+ ` <key>PATH</key>`,
97
+ ` <string>${xmlEscape(envPath)}</string>`,
98
+ ` </dict>`,
99
+ `</dict>`,
100
+ `</plist>`,
101
+ ``,
102
+ ].join("\n");
103
+ }
104
+ /**
105
+ * Write the updater plist when it is missing or stale (for example after the
106
+ * node binary moved) and load it. When the updater agent itself is running,
107
+ * or another process holds the update lock, reloading would kill the run in
108
+ * progress, so it only rewrites the file and the next interactive
109
+ * `ouro self-update` or `ouro up` reloads it.
110
+ */
111
+ function ensureUpdaterAgent(deps) {
112
+ const plistPath = updaterPlistPath(deps.homeDir);
113
+ const desired = generateUpdaterPlist({ homeDir: deps.homeDir, nodePath: deps.nodePath });
114
+ const existing = deps.readFile(plistPath);
115
+ const domain = `gui/${deps.userUid}`;
116
+ let loaded = true;
117
+ try {
118
+ deps.exec(`launchctl print ${domain}/${exports.UPDATER_PLIST_LABEL}`);
119
+ }
120
+ catch {
121
+ loaded = false;
122
+ }
123
+ if (existing === desired && loaded)
124
+ return "unchanged";
125
+ deps.mkdirp(path.dirname(plistPath));
126
+ deps.writeFile(plistPath, desired);
127
+ if (deps.runningUnderUpdaterAgent || deps.updateInProgress) {
128
+ (0, runtime_1.emitNervesEvent)({
129
+ component: "daemon",
130
+ event: "daemon.self_update_agent_written",
131
+ message: "rewrote the updater launch agent; reload waits for the next interactive run",
132
+ meta: { plistPath, updateInProgress: deps.updateInProgress },
133
+ });
134
+ return "written-reload-pending";
135
+ }
136
+ if (loaded) {
137
+ try {
138
+ deps.exec(`launchctl bootout ${domain}/${exports.UPDATER_PLIST_LABEL}`);
139
+ }
140
+ catch { /* already gone */ }
141
+ }
142
+ deps.exec(`launchctl bootstrap ${domain} "${plistPath}"`);
143
+ (0, runtime_1.emitNervesEvent)({
144
+ component: "daemon",
145
+ event: "daemon.self_update_agent_installed",
146
+ message: "installed the unattended updater launch agent",
147
+ meta: { plistPath, intervalSeconds: update_state_1.UPDATER_INTERVAL_SECONDS },
148
+ });
149
+ return "installed";
150
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.904",
3
+ "version": "0.1.0-alpha.905",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.904",
9
+ "version": "0.1.0-alpha.905",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.904",
3
+ "version": "0.1.0-alpha.905",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -36,6 +36,7 @@
36
36
  "test": "vitest run",
37
37
  "test:integration": "npm run build && vitest run --config vitest.integration.config.ts",
38
38
  "test:e2e:package": "npm run build && node scripts/package-e2e.cjs",
39
+ "test:e2e:self-update": "node scripts/self-update-package-e2e.cjs",
39
40
  "test:e2e:ouro-bot-package": "node scripts/ouro-bot-package-e2e.cjs",
40
41
  "test:e2e:real-smoke": "npm run build && node scripts/nightly-real-smoke.cjs",
41
42
  "typecheck:mailbox-ui": "tsc --noEmit -p packages/mailbox-ui/tsconfig.json",