@ornncompute/cli 0.2.11 → 0.2.12

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
@@ -357,7 +357,19 @@ between Public and Private VPN preserves the peer.
357
357
 
358
358
  ## Updating
359
359
 
360
- The CLI checks the npm registry for a newer version at most once per day and prints a one-line notice on stderr when one exists. Run `ornn update` to install it (the command detects npm, pnpm, yarn, or bun installs). The check is skipped in CI and non-interactive shells.
360
+ Interactive global installations check for new releases in the background,
361
+ using a daily interval. When a newer release has been discovered, Ornn
362
+ installs it before your next eligible command and then runs that command
363
+ with the updated CLI. The first check has a one-day grace period.
364
+
365
+ Automatic updates are skipped in CI, with redirected input or output,
366
+ for machine-readable output, and for help or version commands. Project
367
+ dependencies, development checkouts, and package-runner invocations do
368
+ not update automatically. Run `ornn update` to check and update immediately.
369
+
370
+ If an installation fails, Ornn continues only when it can verify that
371
+ the remaining CLI loads successfully. Otherwise it prints reinstall
372
+ instructions. If another update is running, retry after it finishes.
361
373
 
362
374
  ## Deployment
363
375
 
package/bin/ornn.js CHANGED
@@ -1,11 +1,3 @@
1
1
  #!/usr/bin/env node
2
-
3
- import { run } from "../src/cli.mjs";
4
-
5
- const exitCode = await run(process.argv.slice(2), {
6
- env: process.env,
7
- stderr: process.stderr,
8
- stdout: process.stdout,
9
- });
10
-
11
- process.exitCode = exitCode;
2
+ import { bootstrap } from "../src/startup.mjs";
3
+ process.exitCode = await bootstrap();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,9 @@
33
33
  "devDependencies": {
34
34
  "@ornn/capabilities": "0.1.0"
35
35
  },
36
+ "dependencies": {
37
+ "update-notifier": "7.3.1"
38
+ },
36
39
  "engines": {
37
40
  "node": ">=20"
38
41
  }
package/src/cli.mjs CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  tokensListCommand,
52
52
  usersQueryCommand,
53
53
  } from "./catalog-dispatch.mjs";
54
- import { maybeNotifyUpdate, updateCommand } from "./update.mjs";
54
+ import { updateCommand } from "./update.mjs";
55
55
 
56
56
  const { version: VERSION } = createRequire(import.meta.url)("../package.json");
57
57
  const DEFAULT_BUY_NOW_USD_PER_GPU_HOUR = 10;
@@ -358,17 +358,7 @@ const GPU_COMMANDS = new Set(["gpu", "gpus", "reservations"]);
358
358
  const RESERVATION_OP_SUBCOMMANDS = new Set(["transfer", "deploy", "commerce"]);
359
359
 
360
360
  export async function run(argv = [], io = {}) {
361
- const exitCode = await dispatch(argv, io);
362
- if (argv[0] !== "update") {
363
- await maybeNotifyUpdate({
364
- binPath: io.binPath ?? process.argv[1],
365
- currentVersion: VERSION,
366
- env: io.env ?? process.env,
367
- fetchImpl: io.fetch ?? fetch,
368
- stderr: io.stderr ?? process.stderr,
369
- });
370
- }
371
- return exitCode;
361
+ return await dispatch(argv, io);
372
362
  }
373
363
 
374
364
  async function dispatch(argv = [], io = {}) {
@@ -3874,7 +3864,7 @@ async function storage(rawArgs, context) {
3874
3864
  status: response.status,
3875
3865
  });
3876
3866
  }
3877
- // Atomic tmp+rename (same pattern as writeUpdateCache): a mid-stream
3867
+ // Atomic tmp+rename: a mid-stream
3878
3868
  // failure must never truncate or replace an existing file at outputPath.
3879
3869
  const partialPath = `${outputPath}.${process.pid}.partial`;
3880
3870
  try {
@@ -0,0 +1,13 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ const { version } = createRequire(import.meta.url)("../package.json");
4
+
5
+ export async function execute(argv, io) {
6
+ const { run } = await import("./cli.mjs");
7
+ return await run(argv, io);
8
+ }
9
+
10
+ export async function probe() {
11
+ await import("./cli.mjs");
12
+ return version;
13
+ }
@@ -0,0 +1,17 @@
1
+ export function forwardSignals(signalSource, child, onSignal) {
2
+ const listeners = ["SIGINT", "SIGTERM"].map((signal) => {
3
+ const listener = () => {
4
+ onSignal(signal);
5
+ try {
6
+ child?.kill?.(signal);
7
+ } catch {}
8
+ };
9
+ signalSource.once(signal, listener);
10
+ return [signal, listener];
11
+ });
12
+ return () => {
13
+ for (const [signal, listener] of listeners.splice(0)) {
14
+ signalSource.off?.(signal, listener) ?? signalSource.removeListener?.(signal, listener);
15
+ }
16
+ };
17
+ }
@@ -0,0 +1,288 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { constants } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { forwardSignals } from "./signals.mjs";
7
+ import {
8
+ acquireUpdateLock,
9
+ compareVersions,
10
+ detectInstallContext,
11
+ installVersion,
12
+ isValidReleaseVersion,
13
+ resolveInstalledBin,
14
+ } from "./update.mjs";
15
+
16
+ const PACKAGE_NAME = "@ornncompute/cli";
17
+ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
18
+ const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000;
19
+ const PROBE_TIMEOUT_MS = 5000;
20
+ const PROBE_KILL_GRACE_MS = 250;
21
+ const GLOBAL_INSTALL_KINDS = new Set(["bun", "npm", "pnpm", "yarn"]);
22
+ const REINSTALL_GUIDANCE = `Reinstall with: npm install -g ${PACKAGE_NAME}@latest`;
23
+ const PROBE_PROGRAM = `const entry = await import(process.argv[1]);
24
+ process.stdout.write(await entry.probe());`;
25
+ const RESTART_PROGRAM = `const entry = await import(process.argv[1]);
26
+ process.exitCode = await entry.execute(process.argv.slice(3), {
27
+ binPath: process.argv[2], env: process.env,
28
+ stdin: process.stdin, stdout: process.stdout, stderr: process.stderr,
29
+ });`;
30
+
31
+ function hasHumanOnlyArgument(argv) {
32
+ return argv.some(
33
+ (arg) =>
34
+ arg === "--json" ||
35
+ arg.startsWith("--json=") ||
36
+ arg === "--raw" ||
37
+ arg === "--help" ||
38
+ arg === "-h" ||
39
+ arg === "help" ||
40
+ arg === "--version" ||
41
+ arg === "-v" ||
42
+ arg === "version",
43
+ );
44
+ }
45
+
46
+ export function shouldAutoUpdate(argv, { env = process.env, stdin, stdout, stderr, context } = {}) {
47
+ if (!GLOBAL_INSTALL_KINDS.has(context?.kind)) {
48
+ return false;
49
+ }
50
+ if ((env.CI && env.CI !== "false") || !stdin?.isTTY || !stdout?.isTTY || !stderr?.isTTY) {
51
+ return false;
52
+ }
53
+ if (argv.length === 0 || argv[0] === "update" || hasHumanOnlyArgument(argv)) {
54
+ return false;
55
+ }
56
+ return true;
57
+ }
58
+
59
+ async function defaultNotifierFactory(options) {
60
+ const { default: updateNotifier } = await import("update-notifier");
61
+ return updateNotifier(options);
62
+ }
63
+
64
+ function warning(stderr, message) {
65
+ stderr?.write(`${message}\n`);
66
+ }
67
+
68
+ function failedProbe(stderr) {
69
+ warning(stderr, `The installed ornn CLI could not be verified. ${REINSTALL_GUIDANCE}`);
70
+ return { kind: "exit", code: 1 };
71
+ }
72
+
73
+ function unavailableLock(stderr) {
74
+ warning(
75
+ stderr,
76
+ "Automatic update unavailable; retry after any other ornn update finishes. If none is running, remove the stale update-install lock from your Ornn config directory or check that directory's permissions.",
77
+ );
78
+ return { kind: "exit", code: 1 };
79
+ }
80
+
81
+ function exitForSignal(signal) {
82
+ return { kind: "exit", code: 128 + (constants.signals[signal] ?? 1) };
83
+ }
84
+
85
+ function entryUrl(binPath) {
86
+ return pathToFileURL(join(dirname(binPath), "..", "src", "command-entry.mjs")).href;
87
+ }
88
+
89
+ export async function probeInstalledCli(
90
+ binPath,
91
+ { spawnProcess = spawn, env = process.env, signalSource = process, timeoutMs = PROBE_TIMEOUT_MS } = {},
92
+ ) {
93
+ return await new Promise((resolve) => {
94
+ let output = "";
95
+ let settled = false;
96
+ let timedOut = false;
97
+ let forceKillTimer;
98
+ let timeoutTimer;
99
+ const child = spawnProcess(process.execPath, ["--input-type=module", "--eval", PROBE_PROGRAM, entryUrl(binPath)], {
100
+ env,
101
+ shell: false,
102
+ stdio: ["ignore", "pipe", "ignore"],
103
+ });
104
+ const scheduleForceKill = () => {
105
+ forceKillTimer ??= setTimeout(() => child.kill?.("SIGKILL"), PROBE_KILL_GRACE_MS);
106
+ };
107
+ const removeSignals = forwardSignals(signalSource, child, scheduleForceKill);
108
+ const finish = (result) => {
109
+ if (settled) return;
110
+ settled = true;
111
+ clearTimeout(timeoutTimer);
112
+ clearTimeout(forceKillTimer);
113
+ removeSignals();
114
+ resolve(result);
115
+ };
116
+ timeoutTimer = setTimeout(() => {
117
+ timedOut = true;
118
+ child.kill?.("SIGTERM");
119
+ scheduleForceKill();
120
+ }, timeoutMs);
121
+ child.stdout?.on("data", (chunk) => {
122
+ output += String(chunk);
123
+ });
124
+ child.on("error", () => finish({ ok: false, version: null }));
125
+ child.on("close", (code, signal) => {
126
+ const version = output.trim();
127
+ const ok = !timedOut && code === 0 && !signal && isValidReleaseVersion(version);
128
+ finish({
129
+ ok,
130
+ version: ok ? version : null,
131
+ });
132
+ });
133
+ });
134
+ }
135
+
136
+ export async function restartCli(
137
+ binPath,
138
+ argv,
139
+ { spawnProcess = spawn, env = process.env, signalSource = process } = {},
140
+ ) {
141
+ return await new Promise((resolve, reject) => {
142
+ let parentSignal = null;
143
+ const child = spawnProcess(
144
+ process.execPath,
145
+ ["--input-type=module", "--eval", RESTART_PROGRAM, entryUrl(binPath), binPath, ...argv],
146
+ { env, shell: false, stdio: "inherit" },
147
+ );
148
+ const removeSignals = forwardSignals(signalSource, child, (signal) => {
149
+ parentSignal ??= signal;
150
+ });
151
+ child.on("error", (error) => {
152
+ removeSignals();
153
+ reject(error);
154
+ });
155
+ child.on("exit", (code, signal) => {
156
+ removeSignals();
157
+ const interruption = parentSignal ?? signal;
158
+ resolve(interruption ? 128 + (constants.signals[interruption] ?? 1) : (code ?? 1));
159
+ });
160
+ });
161
+ }
162
+
163
+ function unexpectedVersion(stderr, version, currentVersion, target) {
164
+ const detail = isValidReleaseVersion(version)
165
+ ? `The installer left ornn CLI at ${version}, expected ${target}.`
166
+ : "The installer left an invalid ornn CLI version.";
167
+ const downgrade = isValidReleaseVersion(version) && compareVersions(version, currentVersion) < 0;
168
+ warning(stderr, `${detail}${downgrade ? ` It is older than ${currentVersion}.` : ""} ${REINSTALL_GUIDANCE}`);
169
+ return { kind: "exit", code: 1 };
170
+ }
171
+
172
+ export async function prepareStartup(argv, options = {}) {
173
+ const env = options.env ?? process.env;
174
+ const stdin = options.stdin ?? process.stdin;
175
+ const stdout = options.stdout ?? process.stdout;
176
+ const stderr = options.stderr ?? process.stderr;
177
+ const binPath = options.binPath ?? process.argv[1];
178
+ const context = options.context ?? detectInstallContext(binPath, env);
179
+
180
+ if (!shouldAutoUpdate(argv, { env, stdin, stdout, stderr, context })) {
181
+ return { kind: "continue" };
182
+ }
183
+
184
+ let interruptedSignal = null;
185
+ const signalSource = options.signalSource ?? process;
186
+ const removeSignals = forwardSignals(signalSource, null, (signal) => {
187
+ interruptedSignal ??= signal;
188
+ });
189
+ const acquireLock = options.acquireLock ?? acquireUpdateLock;
190
+ const notifierFactory = options.notifierFactory ?? defaultNotifierFactory;
191
+ const install = options.install ?? installVersion;
192
+ const resolveBin = options.resolveBin ?? resolveInstalledBin;
193
+ const probe = options.probe ?? probeInstalledCli;
194
+ const subprocessOptions = {
195
+ env,
196
+ platform: options.platform ?? process.platform,
197
+ signalSource,
198
+ spawnProcess: options.spawnProcess ?? spawn,
199
+ };
200
+
201
+ let release;
202
+ try {
203
+ try {
204
+ release = await acquireLock({ env, binPath: context.binPath ?? binPath });
205
+ } catch {}
206
+ if (interruptedSignal) return exitForSignal(interruptedSignal);
207
+ if (typeof release !== "function") return unavailableLock(stderr);
208
+
209
+ let target;
210
+ try {
211
+ const notifier = await notifierFactory({
212
+ pkg: { name: PACKAGE_NAME, version: options.currentVersion },
213
+ updateCheckInterval: UPDATE_CHECK_INTERVAL,
214
+ distTag: "latest",
215
+ });
216
+ target = notifier?.update?.latest;
217
+ } catch {}
218
+ if (interruptedSignal) return exitForSignal(interruptedSignal);
219
+ if (!isValidReleaseVersion(target) || compareVersions(target, options.currentVersion) <= 0) {
220
+ return { kind: "continue" };
221
+ }
222
+
223
+ warning(stderr, `Updating ornn CLI ${options.currentVersion} -> ${target}...`);
224
+ let stableBin;
225
+ try {
226
+ stableBin = await resolveBin({ context, ...subprocessOptions });
227
+ } catch {}
228
+ if (interruptedSignal) return exitForSignal(interruptedSignal);
229
+ if (!stableBin) {
230
+ return failedProbe(stderr);
231
+ }
232
+
233
+ let installResult;
234
+ try {
235
+ installResult = await install({ context, version: target, ...subprocessOptions });
236
+ } catch {}
237
+ if (interruptedSignal || installResult?.signal) {
238
+ return exitForSignal(interruptedSignal ?? installResult.signal);
239
+ }
240
+
241
+ let replacementBin;
242
+ try {
243
+ replacementBin = await resolveBin({ context, ...subprocessOptions });
244
+ } catch {}
245
+ if (interruptedSignal) return exitForSignal(interruptedSignal);
246
+ if (!replacementBin) {
247
+ return failedProbe(stderr);
248
+ }
249
+
250
+ let result;
251
+ try {
252
+ result = await probe(replacementBin, subprocessOptions);
253
+ } catch {}
254
+ if (interruptedSignal) return exitForSignal(interruptedSignal);
255
+ if (!result?.ok) {
256
+ return failedProbe(stderr);
257
+ }
258
+ if (installResult?.exitCode !== 0) {
259
+ warning(stderr, "The automatic update failed; restarting the verified installed CLI.");
260
+ return { kind: "restart", binPath: replacementBin };
261
+ }
262
+ if (result.version !== target) {
263
+ return unexpectedVersion(stderr, result.version, options.currentVersion, target);
264
+ }
265
+ return { kind: "restart", binPath: replacementBin };
266
+ } finally {
267
+ removeSignals();
268
+ if (typeof release === "function") await Promise.resolve(release()).catch(() => {});
269
+ }
270
+ }
271
+
272
+ export async function bootstrap(argv = process.argv.slice(2), io = {}) {
273
+ const binPath = io.binPath ?? process.argv[1];
274
+ const env = io.env ?? process.env;
275
+ const result = await prepareStartup(argv, { ...io, binPath, currentVersion: VERSION, env });
276
+ if (result.kind === "exit") {
277
+ return result.code;
278
+ }
279
+ if (result.kind === "restart") {
280
+ return await restartCli(result.binPath, argv, {
281
+ env,
282
+ signalSource: io.signalSource ?? process,
283
+ spawnProcess: io.spawnProcess ?? spawn,
284
+ });
285
+ }
286
+ const entry = await import("./command-entry.mjs");
287
+ return await entry.execute(argv, { ...io, binPath, env });
288
+ }
package/src/update.mjs CHANGED
@@ -1,13 +1,14 @@
1
+ import { createHash, randomUUID } from "node:crypto";
1
2
  import { existsSync, realpathSync } from "node:fs";
2
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, open, readFile, rm } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
- import { dirname, isAbsolute, join } from "node:path";
5
+ import { dirname, isAbsolute, join, posix, win32 } from "node:path";
5
6
  import { CliApiError } from "./api-client.mjs";
6
7
  import { getAuthConfigDir } from "./auth-store.mjs";
8
+ import { forwardSignals } from "./signals.mjs";
7
9
 
8
- export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
9
10
  export const UPDATE_CHECK_TIMEOUT_MS = 3000;
10
- const UPDATE_CACHE_FILE = "update-check.json";
11
+ const UPDATE_LOCK_PREFIX = "update-install";
11
12
  const PACKAGE_NAME = "@ornncompute/cli";
12
13
  const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
13
14
  const INSTALLED_KINDS = new Set(["bun", "npm", "pnpm", "yarn"]);
@@ -19,6 +20,13 @@ const INSTALL_COMMANDS = {
19
20
  yarn: (spec) => ["yarn", ["global", "add", spec]],
20
21
  };
21
22
 
23
+ const GLOBAL_ROOT_COMMANDS = {
24
+ bun: ["pm", "ls", "-g"],
25
+ npm: ["root", "-g"],
26
+ pnpm: ["root", "-g"],
27
+ yarn: ["global", "dir"],
28
+ };
29
+
22
30
  function parseVersion(value) {
23
31
  if (typeof value !== "string") {
24
32
  return null;
@@ -77,6 +85,10 @@ export function compareVersions(a, b) {
77
85
  return 0;
78
86
  }
79
87
 
88
+ export function isValidReleaseVersion(version) {
89
+ return typeof version === "string" && VERSION_PATTERN.test(version);
90
+ }
91
+
80
92
  export function detectInstallContext(binPath, env = process.env) {
81
93
  if (!binPath) {
82
94
  return { kind: "unknown", binPath: null };
@@ -100,18 +112,30 @@ export function detectInstallContext(binPath, env = process.env) {
100
112
  if (!has("node_modules")) {
101
113
  return { kind: "dev", binPath: resolved };
102
114
  }
103
- if (has(".bun") || isUnderDir(lower, env.BUN_INSTALL)) {
115
+ if (isBunGlobalInstall(lower, env)) {
104
116
  return { kind: "bun", binPath: resolved };
105
117
  }
106
- if (has(".pnpm") || has("pnpm") || isUnderDir(lower, env.PNPM_HOME)) {
118
+ if (isPnpmGlobalInstall(lower, env)) {
107
119
  return { kind: "pnpm", binPath: resolved };
108
120
  }
109
- if (has("yarn") || has(".yarn")) {
121
+ if (isYarnGlobalInstall(lower)) {
110
122
  return { kind: "yarn", binPath: resolved };
111
123
  }
124
+ if (isNpmGlobalInstall(lower, env)) {
125
+ return { kind: "npm", binPath: resolved };
126
+ }
112
127
  if (isProjectInstall(resolved, lower)) {
113
128
  return { kind: "project", binPath: resolved };
114
129
  }
130
+ if (has(".bun") || isUnderDir(lower, env.BUN_INSTALL)) {
131
+ return { kind: "bun", binPath: resolved };
132
+ }
133
+ if (has(".pnpm") || has("pnpm") || isUnderDir(lower, env.PNPM_HOME)) {
134
+ return { kind: "pnpm", binPath: resolved };
135
+ }
136
+ if (has("yarn") || has(".yarn")) {
137
+ return { kind: "yarn", binPath: resolved };
138
+ }
115
139
  return { kind: "npm", binPath: resolved };
116
140
  }
117
141
 
@@ -136,114 +160,274 @@ function isUnderDir(lowerPath, dir) {
136
160
  return lowerPath.startsWith(`${trimmed.replaceAll("\\", "/").toLowerCase().replace(/\/+$/, "")}/`);
137
161
  }
138
162
 
139
- export function resolveUpdateRegistry(env = process.env) {
140
- const registry = env.npm_config_registry?.trim() || "https://registry.npmjs.org";
141
- return registry.replace(/\/+$/, "");
163
+ function isBunGlobalInstall(lowerPath, env) {
164
+ return (
165
+ lowerPath.includes("/.bun/install/global/node_modules/") ||
166
+ (isUnderDir(lowerPath, env.BUN_INSTALL) && lowerPath.includes("/install/global/node_modules/"))
167
+ );
142
168
  }
143
169
 
144
- export async function fetchLatestVersion({
145
- env = process.env,
146
- fetchImpl = fetch,
147
- timeoutMs = UPDATE_CHECK_TIMEOUT_MS,
148
- } = {}) {
149
- try {
150
- const response = await fetchImpl(`${resolveUpdateRegistry(env)}/${PACKAGE_NAME}/latest`, {
151
- headers: { Accept: "application/json" },
152
- signal: AbortSignal.timeout(timeoutMs),
153
- });
154
- if (!response.ok) {
155
- return null;
170
+ function isPnpmGlobalInstall(lowerPath, env) {
171
+ return /\/pnpm\/global\/\d+\/node_modules\//.test(lowerPath) || isUnderDir(lowerPath, env.PNPM_HOME);
172
+ }
173
+
174
+ function isYarnGlobalInstall(lowerPath) {
175
+ return lowerPath.includes("/yarn/global/node_modules/") || lowerPath.includes("/yarn/data/global/node_modules/");
176
+ }
177
+
178
+ function isNpmGlobalInstall(lowerPath, env) {
179
+ const configuredPrefix = env.npm_config_prefix?.trim();
180
+ if (configuredPrefix) {
181
+ let resolvedPrefix = configuredPrefix;
182
+ try {
183
+ resolvedPrefix = realpathSync(configuredPrefix);
184
+ } catch {}
185
+ const prefix = resolvedPrefix.replaceAll("\\", "/").toLowerCase().replace(/\/+$/, "");
186
+ if (lowerPath.startsWith(`${prefix}/node_modules/`) || lowerPath.startsWith(`${prefix}/lib/node_modules/`)) {
187
+ return true;
156
188
  }
157
- const payload = await response.json();
158
- const version = payload?.version;
159
- return typeof version === "string" && VERSION_PATTERN.test(version) ? version : null;
160
- } catch {
189
+ }
190
+
191
+ if (process.platform !== "win32") {
192
+ const executable = realpathSync(process.execPath).replaceAll("\\", "/").toLowerCase();
193
+ const suffix = "/bin/node";
194
+ if (executable.endsWith(suffix)) {
195
+ const prefix = executable.slice(0, -suffix.length);
196
+ return lowerPath.startsWith(`${prefix}/lib/node_modules/`);
197
+ }
198
+ }
199
+ return false;
200
+ }
201
+
202
+ function pathApi(platform) {
203
+ return platform === "win32" ? win32 : posix;
204
+ }
205
+
206
+ function stableInstallRoot(binPath) {
207
+ if (!binPath) {
161
208
  return null;
162
209
  }
210
+ let resolved = binPath;
211
+ try {
212
+ resolved = realpathSync(binPath);
213
+ } catch {}
214
+ const normalized = resolved.replaceAll("\\", "/");
215
+ const markerIndex = normalized.toLowerCase().indexOf("/node_modules/");
216
+ return markerIndex > 0 ? normalized.slice(0, markerIndex) : null;
163
217
  }
164
218
 
165
- function updateCachePath(env) {
166
- return join(getAuthConfigDir(env), UPDATE_CACHE_FILE);
219
+ function pathIdentity(path) {
220
+ const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "");
221
+ return /^[A-Za-z]:\//.test(normalized) ? normalized.toLowerCase() : normalized;
167
222
  }
168
223
 
169
- async function readUpdateCache(path) {
224
+ function canonicalPathIdentity(path) {
170
225
  try {
171
- const parsed = JSON.parse(await readFile(path, "utf8"));
172
- return parsed && typeof parsed === "object" ? parsed : null;
226
+ return pathIdentity(realpathSync(path));
173
227
  } catch {
228
+ return pathIdentity(path);
229
+ }
230
+ }
231
+
232
+ function updateLockPath(env, binPath) {
233
+ const configDir = getAuthConfigDir(env);
234
+ const installRoot = stableInstallRoot(binPath);
235
+ if (!installRoot || !isAbsolute(configDir)) {
174
236
  return null;
175
237
  }
238
+ const key = createHash("sha256").update(pathIdentity(installRoot)).digest("hex").slice(0, 24);
239
+ return join(configDir, `${UPDATE_LOCK_PREFIX}-${key}.lock`);
176
240
  }
177
241
 
178
- async function writeUpdateCache(path, payload) {
179
- // Atomic tmp+rename: survives concurrent runs and replaces a root-owned file left by a past sudo run.
180
- const tmpPath = `${path}.${process.pid}.tmp`;
242
+ export async function acquireUpdateLock({ env = process.env, binPath }) {
243
+ const lockPath = updateLockPath(env, binPath);
244
+ if (!lockPath) {
245
+ return null;
246
+ }
247
+
248
+ let handle;
181
249
  try {
182
- await mkdir(dirname(path), { mode: 0o700, recursive: true });
183
- await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, { encoding: "utf8", mode: 0o600 });
184
- await rename(tmpPath, path);
185
- return true;
250
+ await mkdir(dirname(lockPath), { mode: 0o700, recursive: true });
251
+ handle = await open(lockPath, "wx", 0o600);
186
252
  } catch {
187
- await rm(tmpPath, { force: true }).catch(() => {});
188
- return false;
253
+ return null;
189
254
  }
190
- }
191
255
 
192
- export async function maybeNotifyUpdate({
193
- binPath,
194
- currentVersion,
195
- env = process.env,
196
- fetchImpl = fetch,
197
- now = Date.now(),
198
- stderr,
199
- }) {
256
+ const token = randomUUID();
200
257
  try {
201
- if (env.CI && env.CI !== "false") {
202
- return;
203
- }
204
- if (!stderr?.isTTY) {
205
- return;
206
- }
207
- if (!INSTALLED_KINDS.has(detectInstallContext(binPath, env).kind)) {
208
- return;
209
- }
210
- const cachePath = updateCachePath(env);
211
- if (!isAbsolute(cachePath)) {
258
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, token })}\n`, "utf8");
259
+ await handle.close();
260
+ } catch {
261
+ await handle.close().catch(() => {});
262
+ await rm(lockPath, { force: true }).catch(() => {});
263
+ return null;
264
+ }
265
+
266
+ let released = false;
267
+ return async () => {
268
+ if (released) {
212
269
  return;
213
270
  }
271
+ released = true;
272
+ try {
273
+ const current = JSON.parse(await readFile(lockPath, "utf8"));
274
+ if (current?.token === token) {
275
+ await rm(lockPath);
276
+ }
277
+ } catch {}
278
+ };
279
+ }
214
280
 
215
- const cache = await readUpdateCache(cachePath);
216
- let latestKnown =
217
- typeof cache?.latestVersion === "string" && VERSION_PATTERN.test(cache.latestVersion)
218
- ? cache.latestVersion
219
- : null;
220
- const checkedAt = Date.parse(cache?.lastCheckedAt ?? "");
221
- const fresh = Number.isFinite(checkedAt) && now - checkedAt >= 0 && now - checkedAt < UPDATE_CHECK_TTL_MS;
222
-
223
- if (!fresh) {
224
- // Reserve the TTL before fetching so an unwritable cache disables the check instead of taxing every command.
225
- const reserved = await writeUpdateCache(cachePath, {
226
- lastCheckedAt: new Date(now).toISOString(),
227
- latestVersion: latestKnown,
281
+ function installInvocation(context, version) {
282
+ if (version !== "latest" && !isValidReleaseVersion(version)) {
283
+ throw new CliApiError("The update target must be a valid release version.");
284
+ }
285
+ const commandFactory = INSTALL_COMMANDS[context?.kind];
286
+ if (!commandFactory) {
287
+ throw new CliApiError("Could not determine which package manager should install the update.");
288
+ }
289
+ return commandFactory(`${PACKAGE_NAME}@${version}`);
290
+ }
291
+
292
+ export async function installVersion({
293
+ context,
294
+ version,
295
+ spawnProcess,
296
+ platform = process.platform,
297
+ env = process.env,
298
+ signalSource = process,
299
+ }) {
300
+ const [command, args] = installInvocation(context, version);
301
+ return await new Promise((resolve, reject) => {
302
+ let parentSignal = null;
303
+ let settled = false;
304
+ const child = spawnProcess(command, args, {
305
+ env,
306
+ shell: platform === "win32",
307
+ stdio: "inherit",
308
+ });
309
+ const cleanup = forwardSignals(signalSource, child, (signal) => {
310
+ parentSignal ??= signal;
311
+ });
312
+ child.on("error", (error) => {
313
+ if (settled) return;
314
+ settled = true;
315
+ cleanup();
316
+ reject(
317
+ new CliApiError(
318
+ error?.code === "ENOENT"
319
+ ? `${command} was not found on PATH. Install it or run: npm install -g ${PACKAGE_NAME}@latest`
320
+ : `Failed to run ${command}: ${error?.message ?? error}`,
321
+ ),
322
+ );
323
+ });
324
+ child.on("exit", (code, signal) => {
325
+ if (settled) return;
326
+ settled = true;
327
+ cleanup();
328
+ resolve({
329
+ exitCode: parentSignal || signal ? 1 : (code ?? 0),
330
+ signal: parentSignal ?? signal ?? null,
228
331
  });
229
- if (!reserved) {
230
- return;
231
- }
232
- const fetched = await fetchLatestVersion({ env, fetchImpl });
233
- if (fetched) {
234
- latestKnown = fetched;
235
- await writeUpdateCache(cachePath, {
236
- lastCheckedAt: new Date(now).toISOString(),
237
- latestVersion: fetched,
238
- });
239
- }
240
- }
332
+ });
333
+ });
334
+ }
335
+
336
+ async function resolveGlobalRoot({ kind, spawnProcess, env, platform, signalSource }) {
337
+ return await new Promise((resolve) => {
338
+ let output = "";
339
+ let interrupted = false;
340
+ const child = spawnProcess(kind, GLOBAL_ROOT_COMMANDS[kind], {
341
+ env,
342
+ shell: platform === "win32",
343
+ stdio: ["ignore", "pipe", "ignore"],
344
+ });
345
+ const cleanup = forwardSignals(signalSource, child, () => {
346
+ interrupted = true;
347
+ });
348
+ child.stdout?.on("data", (chunk) => {
349
+ output += String(chunk);
350
+ });
351
+ child.on("error", () => {
352
+ cleanup();
353
+ resolve(null);
354
+ });
355
+ child.on("exit", (code, signal) => {
356
+ cleanup();
357
+ resolve(code === 0 && !signal && !interrupted ? output.trim() || null : null);
358
+ });
359
+ });
360
+ }
241
361
 
242
- if (latestKnown && compareVersions(latestKnown, currentVersion) > 0) {
243
- stderr.write(`Update available: ornn CLI ${currentVersion} -> ${latestKnown}. Run \`ornn update\`.\n`);
362
+ export async function resolveInstalledBin({
363
+ context,
364
+ spawnProcess,
365
+ env = process.env,
366
+ platform = process.platform,
367
+ signalSource = process,
368
+ }) {
369
+ if (!INSTALLED_KINDS.has(context?.kind)) {
370
+ return null;
371
+ }
372
+ const installRoot = stableInstallRoot(context.binPath);
373
+ if (!installRoot) {
374
+ return null;
375
+ }
376
+
377
+ const paths = pathApi(platform);
378
+ const packageRoot = paths.join(installRoot, "node_modules");
379
+ const output = await resolveGlobalRoot({
380
+ kind: context.kind,
381
+ spawnProcess,
382
+ env,
383
+ platform,
384
+ signalSource,
385
+ });
386
+ let configuredRoot = output;
387
+ if (context.kind === "bun") {
388
+ // Bun lists the effective global directory before its dependency tree.
389
+ configuredRoot = /^(.+) node_modules \(\d+(?: installed)?\)$/.exec(output?.split(/\r?\n/)[0] ?? "")?.[1];
390
+ }
391
+ if (!configuredRoot || !paths.isAbsolute(configuredRoot)) {
392
+ return null;
393
+ }
394
+ if (context.kind === "bun" || context.kind === "yarn") {
395
+ configuredRoot = paths.join(configuredRoot, "node_modules");
396
+ }
397
+ if (canonicalPathIdentity(configuredRoot) !== canonicalPathIdentity(packageRoot)) {
398
+ return null;
399
+ }
400
+
401
+ const candidate = paths.join(packageRoot, "@ornncompute", "cli", "bin", "ornn.js");
402
+ if (!paths.isAbsolute(candidate) || !existsSync(candidate)) {
403
+ return null;
404
+ }
405
+ return candidate;
406
+ }
407
+
408
+ export function resolveUpdateRegistry(env = process.env) {
409
+ const registry = env.npm_config_registry?.trim() || "https://registry.npmjs.org";
410
+ return registry.replace(/\/+$/, "");
411
+ }
412
+
413
+ export async function fetchLatestVersion({
414
+ env = process.env,
415
+ fetchImpl = fetch,
416
+ timeoutMs = UPDATE_CHECK_TIMEOUT_MS,
417
+ } = {}) {
418
+ try {
419
+ const response = await fetchImpl(`${resolveUpdateRegistry(env)}/${PACKAGE_NAME}/latest`, {
420
+ headers: { Accept: "application/json" },
421
+ signal: AbortSignal.timeout(timeoutMs),
422
+ });
423
+ if (!response.ok) {
424
+ return null;
244
425
  }
426
+ const payload = await response.json();
427
+ const version = payload?.version;
428
+ return typeof version === "string" && VERSION_PATTERN.test(version) ? version : null;
245
429
  } catch {
246
- // Never break or slow the command the notifier rides on.
430
+ return null;
247
431
  }
248
432
  }
249
433
 
@@ -265,95 +449,91 @@ export async function updateCommand({
265
449
  throw new CliApiError("ornn is running from a development checkout; update it with git pull.");
266
450
  }
267
451
  if (context.kind === "project") {
268
- throw new CliApiError("ornn is installed as a project dependency; update it there with: npm update @ornncompute/cli");
452
+ throw new CliApiError(
453
+ "ornn is installed as a project dependency; update it there with: npm update @ornncompute/cli",
454
+ );
269
455
  }
270
456
  if (context.kind === "unknown") {
271
- throw new CliApiError("Could not determine how ornn was installed. Reinstall with: npm install -g @ornncompute/cli@latest");
272
- }
273
-
274
- const latest = await fetchLatestVersion({ env, fetchImpl });
275
- if (latest && compareVersions(latest, currentVersion) <= 0) {
276
- await writeUpdateCache(updateCachePath(env), {
277
- lastCheckedAt: new Date().toISOString(),
278
- latestVersion: latest,
279
- });
280
- stdout.write(`ornn CLI is already up to date (${currentVersion}).\n`);
281
- return 0;
282
- }
283
- if (!latest) {
284
- stderr.write("Could not check the npm registry for the latest version; attempting the update anyway.\n");
457
+ throw new CliApiError(
458
+ "Could not determine how ornn was installed. Reinstall with: npm install -g @ornncompute/cli@latest",
459
+ );
285
460
  }
286
461
 
287
- const spec = `${PACKAGE_NAME}@${latest ?? "latest"}`;
288
- const [command, commandArgs] = INSTALL_COMMANDS[context.kind](spec);
289
- stderr.write(`Running: ${command} ${commandArgs.join(" ")}\n`);
290
- const exitCode = await new Promise((resolve, reject) => {
291
- const child = spawnProcess(command, commandArgs, {
292
- shell: platform === "win32",
293
- stdio: "inherit",
294
- });
295
- child.on("error", (error) =>
296
- reject(
297
- new CliApiError(
298
- error?.code === "ENOENT"
299
- ? `${command} was not found on PATH. Install it or run: npm install -g ${PACKAGE_NAME}@latest`
300
- : `Failed to run ${command}: ${error?.message ?? error}`,
301
- ),
302
- ),
303
- );
304
- child.on("exit", (code, signal) => resolve(signal ? 1 : code ?? 0));
305
- });
306
- if (exitCode !== 0) {
462
+ const releaseLock = await acquireUpdateLock({ env, binPath: context.binPath });
463
+ if (!releaseLock) {
464
+ const lockPath = updateLockPath(env, context.binPath);
307
465
  throw new CliApiError(
308
- updateFailureMessage({ binPath: context.binPath, command, commandArgs, exitCode, kind: context.kind, platform }),
466
+ `Could not acquire the update lock. If another ornn update is running, wait for it to finish and retry.${
467
+ lockPath
468
+ ? ` Otherwise remove the stale lock at ${lockPath} and retry.`
469
+ : " Check the config directory permissions and retry."
470
+ }`,
309
471
  );
310
472
  }
311
473
 
312
- if (platform === "win32") {
313
- stderr.write("If this terminal reports an error after updating, it is safe to ignore; open a new terminal.\n");
314
- }
474
+ try {
475
+ const installedBin = await resolveInstalledBin({ context, spawnProcess, env, platform });
476
+ if (!installedBin) {
477
+ throw new CliApiError(
478
+ `Could not verify the ${context.kind} global installation root. No files were changed; reinstall with: npm install -g ${PACKAGE_NAME}@latest`,
479
+ );
480
+ }
315
481
 
316
- if (context.kind === "pnpm") {
317
- // pnpm shims pin a versioned store path, so re-reads never see the new version; trust pnpm's exit status.
318
- if (latest) {
319
- stdout.write(`Updated ornn CLI ${currentVersion} -> ${latest}. Run \`ornn --version\` in a new shell to confirm.\n`);
320
- } else {
321
- stdout.write("Update finished. Run `ornn --version` in a new shell to confirm the installed version.\n");
482
+ const latest = await fetchLatestVersion({ env, fetchImpl });
483
+ if (latest && compareVersions(latest, currentVersion) <= 0) {
484
+ stdout.write(`ornn CLI is already up to date (${currentVersion}).\n`);
485
+ return 0;
486
+ }
487
+ if (!latest) {
488
+ stderr.write("Could not check the npm registry for the latest version; attempting the update anyway.\n");
322
489
  }
323
- await writeUpdateCache(updateCachePath(env), {
324
- lastCheckedAt: new Date().toISOString(),
325
- latestVersion: latest ?? null,
326
- });
327
- return 0;
328
- }
329
490
 
330
- // Re-resolve the original bin path so the read sees the freshly installed package.
331
- const installedVersion = await readInstalledVersion(binPath ?? context.binPath);
332
- if (!installedVersion) {
333
- stdout.write("Update finished. Run `ornn --version` to confirm the installed version.\n");
334
- } else if (installedVersion === currentVersion && latest) {
335
- stderr.write(
336
- `The installer exited successfully, but this ornn still runs ${currentVersion} from ${context.binPath}. Your registry mirror may be behind, or another install shadows this one; check your PATH.\n`,
337
- );
338
- return 1;
339
- } else if (installedVersion === currentVersion) {
340
- stdout.write(`ornn CLI is still ${currentVersion}; the registry may not have a newer version.\n`);
341
- } else if (compareVersions(installedVersion, currentVersion) < 0) {
342
- stderr.write(
343
- `The installer replaced ornn CLI ${currentVersion} with the older ${installedVersion}; your registry mirror may be behind. Reinstall with: npm install -g ${PACKAGE_NAME}@${currentVersion}\n`,
344
- );
345
- return 1;
346
- } else {
347
- stdout.write(`Updated ornn CLI ${currentVersion} -> ${installedVersion}.\n`);
348
- if (latest && compareVersions(installedVersion, latest) < 0) {
349
- stderr.write(`Note: ${latest} is the latest on the registry; your mirror may be behind.\n`);
491
+ const version = latest ?? "latest";
492
+ const [command, commandArgs] = installInvocation(context, version);
493
+ stderr.write(`Running: ${command} ${commandArgs.join(" ")}\n`);
494
+ const result = await installVersion({ context, version, spawnProcess, platform, env });
495
+ if (result.exitCode !== 0) {
496
+ throw new CliApiError(
497
+ updateFailureMessage({
498
+ binPath: context.binPath,
499
+ command,
500
+ commandArgs,
501
+ exitCode: result.exitCode,
502
+ kind: context.kind,
503
+ platform,
504
+ }),
505
+ );
506
+ }
507
+
508
+ if (platform === "win32") {
509
+ stderr.write("If this terminal reports an error after updating, it is safe to ignore; open a new terminal.\n");
510
+ }
511
+
512
+ const installedVersion = await readInstalledVersion(installedBin);
513
+ if (!installedVersion) {
514
+ stdout.write("Update finished. Run `ornn --version` to confirm the installed version.\n");
515
+ } else if (installedVersion === currentVersion && latest) {
516
+ stderr.write(
517
+ `The installer exited successfully, but this ornn still runs ${currentVersion} from ${context.binPath}. Your registry mirror may be behind, or another install shadows this one; check your PATH.\n`,
518
+ );
519
+ return 1;
520
+ } else if (installedVersion === currentVersion) {
521
+ stdout.write(`ornn CLI is still ${currentVersion}; the registry may not have a newer version.\n`);
522
+ } else if (compareVersions(installedVersion, currentVersion) < 0) {
523
+ stderr.write(
524
+ `The installer replaced ornn CLI ${currentVersion} with the older ${installedVersion}; your registry mirror may be behind. Reinstall with: npm install -g ${PACKAGE_NAME}@${currentVersion}\n`,
525
+ );
526
+ return 1;
527
+ } else {
528
+ stdout.write(`Updated ornn CLI ${currentVersion} -> ${installedVersion}.\n`);
529
+ if (latest && compareVersions(installedVersion, latest) < 0) {
530
+ stderr.write(`Note: ${latest} is the latest on the registry; your mirror may be behind.\n`);
531
+ }
350
532
  }
533
+ return 0;
534
+ } finally {
535
+ await releaseLock();
351
536
  }
352
- await writeUpdateCache(updateCachePath(env), {
353
- lastCheckedAt: new Date().toISOString(),
354
- latestVersion: latest ?? installedVersion ?? null,
355
- });
356
- return 0;
357
537
  }
358
538
 
359
539
  function updateFailureMessage({ binPath, command, commandArgs, exitCode, kind, platform }) {