@ornncompute/cli 0.2.11 → 0.2.13

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,32 @@ 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.
373
+
374
+ Updates publish an initialized `update-install-*.lock` directory in the Ornn
375
+ config directory. Its unique owner file prevents competing processes from
376
+ removing a replacement lock during recovery or release. Directory locks from
377
+ processes that exit before installation are reclaimed on the next update
378
+ attempt; live or inaccessible PIDs and malformed metadata remain locked. Legacy regular-file locks require
379
+ manual cleanup after confirming that no updater is running: deleting them
380
+ automatically could remove a live lock created by an older CLI. PID reuse can
381
+ also require manual cleanup. An installation marker keeps the lock held if the
382
+ CLI is killed during installation, because its package installer may still be
383
+ running. Confirm that installer has stopped before removing the lock. An
384
+ interrupted package installation may still require reinstalling the CLI if the package
385
+ manager has already removed the previous version.
361
386
 
362
387
  ## Deployment
363
388
 
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.13",
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,20 @@
1
+ const FORWARDABLE_SIGNALS =
2
+ process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
3
+
4
+ export function forwardSignals(signalSource, child, onSignal) {
5
+ const listeners = FORWARDABLE_SIGNALS.map((signal) => {
6
+ const listener = () => {
7
+ onSignal(signal);
8
+ try {
9
+ child?.kill?.(signal);
10
+ } catch {}
11
+ };
12
+ signalSource.once(signal, listener);
13
+ return [signal, listener];
14
+ });
15
+ return () => {
16
+ for (const [signal, listener] of listeners.splice(0)) {
17
+ signalSource.off?.(signal, listener) ?? signalSource.removeListener?.(signal, listener);
18
+ }
19
+ };
20
+ }
@@ -0,0 +1,291 @@
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 no updater or package installer 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 lock;
202
+ try {
203
+ try {
204
+ lock = await acquireLock({ env, binPath: context.binPath ?? binPath });
205
+ } catch {}
206
+ if (interruptedSignal) return exitForSignal(interruptedSignal);
207
+ if (!lock) 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 lock.runInstall(() => {
236
+ if (interruptedSignal) return { signal: interruptedSignal };
237
+ return install({ context, version: target, ...subprocessOptions });
238
+ });
239
+ } catch {}
240
+ if (interruptedSignal || installResult?.signal) {
241
+ return exitForSignal(interruptedSignal ?? installResult.signal);
242
+ }
243
+
244
+ let replacementBin;
245
+ try {
246
+ replacementBin = await resolveBin({ context, ...subprocessOptions });
247
+ } catch {}
248
+ if (interruptedSignal) return exitForSignal(interruptedSignal);
249
+ if (!replacementBin) {
250
+ return failedProbe(stderr);
251
+ }
252
+
253
+ let result;
254
+ try {
255
+ result = await probe(replacementBin, subprocessOptions);
256
+ } catch {}
257
+ if (interruptedSignal) return exitForSignal(interruptedSignal);
258
+ if (!result?.ok) {
259
+ return failedProbe(stderr);
260
+ }
261
+ if (installResult?.exitCode !== 0) {
262
+ warning(stderr, "The automatic update failed; restarting the verified installed CLI.");
263
+ return { kind: "restart", binPath: replacementBin };
264
+ }
265
+ if (result.version !== target) {
266
+ return unexpectedVersion(stderr, result.version, options.currentVersion, target);
267
+ }
268
+ return { kind: "restart", binPath: replacementBin };
269
+ } finally {
270
+ removeSignals();
271
+ if (lock) await Promise.resolve(lock.release()).catch(() => {});
272
+ }
273
+ }
274
+
275
+ export async function bootstrap(argv = process.argv.slice(2), io = {}) {
276
+ const binPath = io.binPath ?? process.argv[1];
277
+ const env = io.env ?? process.env;
278
+ const result = await prepareStartup(argv, { ...io, binPath, currentVersion: VERSION, env });
279
+ if (result.kind === "exit") {
280
+ return result.code;
281
+ }
282
+ if (result.kind === "restart") {
283
+ return await restartCli(result.binPath, argv, {
284
+ env,
285
+ signalSource: io.signalSource ?? process,
286
+ spawnProcess: io.spawnProcess ?? spawn,
287
+ });
288
+ }
289
+ const entry = await import("./command-entry.mjs");
290
+ return await entry.execute(argv, { ...io, binPath, env });
291
+ }
@@ -0,0 +1,100 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ mkdir,
4
+ mkdtemp,
5
+ readFile,
6
+ readdir,
7
+ rename,
8
+ rm,
9
+ rmdir,
10
+ unlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+
15
+ async function isStale(path) {
16
+ let pid;
17
+ try {
18
+ ({ pid } = JSON.parse(await readFile(path, "utf8")));
19
+ } catch {
20
+ return false;
21
+ }
22
+ if (!Number.isInteger(pid) || pid <= 0) return false;
23
+ try {
24
+ process.kill(pid, 0);
25
+ return false;
26
+ } catch (error) {
27
+ return error.code === "ESRCH";
28
+ }
29
+ }
30
+
31
+ async function removeOwner(lockPath, owner) {
32
+ await unlink(join(lockPath, owner)).catch(() => {});
33
+ // A replacement has a different owner file, so rmdir cannot remove it.
34
+ await rmdir(lockPath).catch(() => {});
35
+ }
36
+
37
+ async function reclaim(lockPath) {
38
+ let owners;
39
+ try {
40
+ owners = await readdir(lockPath);
41
+ } catch {
42
+ return;
43
+ }
44
+ if (owners.length === 0) {
45
+ await rmdir(lockPath).catch(() => {});
46
+ } else if (owners.length === 1 && (await isStale(join(lockPath, owners[0])))) {
47
+ await removeOwner(lockPath, owners[0]);
48
+ }
49
+ }
50
+
51
+ export async function acquireFileLock(lockPath) {
52
+ let pending;
53
+ const owner = randomUUID();
54
+ try {
55
+ await mkdir(dirname(lockPath), { mode: 0o700, recursive: true });
56
+ pending = await mkdtemp(`${lockPath}-`);
57
+ await writeFile(
58
+ join(pending, owner),
59
+ `${JSON.stringify({ pid: process.pid })}\n`,
60
+ {
61
+ mode: 0o600,
62
+ }
63
+ );
64
+ for (let attempt = 0; attempt < 2; attempt += 1) {
65
+ try {
66
+ // Publish metadata atomically; an occupied nonempty directory cannot be replaced.
67
+ await rename(pending, lockPath);
68
+ pending = null;
69
+ let released = false;
70
+ return {
71
+ async release() {
72
+ if (released) return;
73
+ released = true;
74
+ await removeOwner(lockPath, owner);
75
+ },
76
+ async runInstall(install) {
77
+ if (released) throw new Error("The update lock has already been released.");
78
+ const marker = join(lockPath, `${randomUUID()}.install`);
79
+ // A killed coordinator cannot prove its installer exited. Keep that lock held.
80
+ try {
81
+ await writeFile(marker, "", { mode: 0o600, flag: "wx" });
82
+ return await install();
83
+ } finally {
84
+ await unlink(marker).catch(() => {});
85
+ }
86
+ },
87
+ };
88
+ } catch (error) {
89
+ if (!["EEXIST", "ENOTEMPTY", "ENOTDIR", "EISDIR", "EPERM"].includes(error.code))
90
+ return null;
91
+ if (attempt === 0) await reclaim(lockPath);
92
+ }
93
+ }
94
+ } catch {
95
+ return null;
96
+ } finally {
97
+ if (pending) await rm(pending, { recursive: true, force: true }).catch(() => {});
98
+ }
99
+ return null;
100
+ }
package/src/update.mjs CHANGED
@@ -1,13 +1,15 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, realpathSync } from "node:fs";
2
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { readFile } 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";
9
+ import { acquireFileLock } from "./update-lock.mjs";
7
10
 
8
- export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
9
11
  export const UPDATE_CHECK_TIMEOUT_MS = 3000;
10
- const UPDATE_CACHE_FILE = "update-check.json";
12
+ const UPDATE_LOCK_PREFIX = "update-install";
11
13
  const PACKAGE_NAME = "@ornncompute/cli";
12
14
  const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
13
15
  const INSTALLED_KINDS = new Set(["bun", "npm", "pnpm", "yarn"]);
@@ -19,6 +21,13 @@ const INSTALL_COMMANDS = {
19
21
  yarn: (spec) => ["yarn", ["global", "add", spec]],
20
22
  };
21
23
 
24
+ const GLOBAL_ROOT_COMMANDS = {
25
+ bun: ["pm", "ls", "-g"],
26
+ npm: ["root", "-g"],
27
+ pnpm: ["root", "-g"],
28
+ yarn: ["global", "dir"],
29
+ };
30
+
22
31
  function parseVersion(value) {
23
32
  if (typeof value !== "string") {
24
33
  return null;
@@ -77,6 +86,10 @@ export function compareVersions(a, b) {
77
86
  return 0;
78
87
  }
79
88
 
89
+ export function isValidReleaseVersion(version) {
90
+ return typeof version === "string" && VERSION_PATTERN.test(version);
91
+ }
92
+
80
93
  export function detectInstallContext(binPath, env = process.env) {
81
94
  if (!binPath) {
82
95
  return { kind: "unknown", binPath: null };
@@ -100,18 +113,30 @@ export function detectInstallContext(binPath, env = process.env) {
100
113
  if (!has("node_modules")) {
101
114
  return { kind: "dev", binPath: resolved };
102
115
  }
103
- if (has(".bun") || isUnderDir(lower, env.BUN_INSTALL)) {
116
+ if (isBunGlobalInstall(lower, env)) {
104
117
  return { kind: "bun", binPath: resolved };
105
118
  }
106
- if (has(".pnpm") || has("pnpm") || isUnderDir(lower, env.PNPM_HOME)) {
119
+ if (isPnpmGlobalInstall(lower, env)) {
107
120
  return { kind: "pnpm", binPath: resolved };
108
121
  }
109
- if (has("yarn") || has(".yarn")) {
122
+ if (isYarnGlobalInstall(lower)) {
110
123
  return { kind: "yarn", binPath: resolved };
111
124
  }
125
+ if (isNpmGlobalInstall(lower, env)) {
126
+ return { kind: "npm", binPath: resolved };
127
+ }
112
128
  if (isProjectInstall(resolved, lower)) {
113
129
  return { kind: "project", binPath: resolved };
114
130
  }
131
+ if (has(".bun") || isUnderDir(lower, env.BUN_INSTALL)) {
132
+ return { kind: "bun", binPath: resolved };
133
+ }
134
+ if (has(".pnpm") || has("pnpm") || isUnderDir(lower, env.PNPM_HOME)) {
135
+ return { kind: "pnpm", binPath: resolved };
136
+ }
137
+ if (has("yarn") || has(".yarn")) {
138
+ return { kind: "yarn", binPath: resolved };
139
+ }
115
140
  return { kind: "npm", binPath: resolved };
116
141
  }
117
142
 
@@ -136,114 +161,240 @@ function isUnderDir(lowerPath, dir) {
136
161
  return lowerPath.startsWith(`${trimmed.replaceAll("\\", "/").toLowerCase().replace(/\/+$/, "")}/`);
137
162
  }
138
163
 
139
- export function resolveUpdateRegistry(env = process.env) {
140
- const registry = env.npm_config_registry?.trim() || "https://registry.npmjs.org";
141
- return registry.replace(/\/+$/, "");
164
+ function isBunGlobalInstall(lowerPath, env) {
165
+ return (
166
+ lowerPath.includes("/.bun/install/global/node_modules/") ||
167
+ (isUnderDir(lowerPath, env.BUN_INSTALL) && lowerPath.includes("/install/global/node_modules/"))
168
+ );
142
169
  }
143
170
 
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;
171
+ function isPnpmGlobalInstall(lowerPath, env) {
172
+ return /\/pnpm\/global\/\d+\/node_modules\//.test(lowerPath) || isUnderDir(lowerPath, env.PNPM_HOME);
173
+ }
174
+
175
+ function isYarnGlobalInstall(lowerPath) {
176
+ return lowerPath.includes("/yarn/global/node_modules/") || lowerPath.includes("/yarn/data/global/node_modules/");
177
+ }
178
+
179
+ function isNpmGlobalInstall(lowerPath, env) {
180
+ const configuredPrefix = env.npm_config_prefix?.trim();
181
+ if (configuredPrefix) {
182
+ let resolvedPrefix = configuredPrefix;
183
+ try {
184
+ resolvedPrefix = realpathSync(configuredPrefix);
185
+ } catch {}
186
+ const prefix = resolvedPrefix.replaceAll("\\", "/").toLowerCase().replace(/\/+$/, "");
187
+ if (lowerPath.startsWith(`${prefix}/node_modules/`) || lowerPath.startsWith(`${prefix}/lib/node_modules/`)) {
188
+ return true;
156
189
  }
157
- const payload = await response.json();
158
- const version = payload?.version;
159
- return typeof version === "string" && VERSION_PATTERN.test(version) ? version : null;
160
- } catch {
190
+ }
191
+
192
+ if (process.platform !== "win32") {
193
+ const executable = realpathSync(process.execPath).replaceAll("\\", "/").toLowerCase();
194
+ const suffix = "/bin/node";
195
+ if (executable.endsWith(suffix)) {
196
+ const prefix = executable.slice(0, -suffix.length);
197
+ return lowerPath.startsWith(`${prefix}/lib/node_modules/`);
198
+ }
199
+ }
200
+ return false;
201
+ }
202
+
203
+ function pathApi(platform) {
204
+ return platform === "win32" ? win32 : posix;
205
+ }
206
+
207
+ function stableInstallRoot(binPath) {
208
+ if (!binPath) {
161
209
  return null;
162
210
  }
211
+ let resolved = binPath;
212
+ try {
213
+ resolved = realpathSync(binPath);
214
+ } catch {}
215
+ const normalized = resolved.replaceAll("\\", "/");
216
+ const markerIndex = normalized.toLowerCase().indexOf("/node_modules/");
217
+ return markerIndex > 0 ? normalized.slice(0, markerIndex) : null;
163
218
  }
164
219
 
165
- function updateCachePath(env) {
166
- return join(getAuthConfigDir(env), UPDATE_CACHE_FILE);
220
+ function pathIdentity(path) {
221
+ const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "");
222
+ return /^[A-Za-z]:\//.test(normalized) ? normalized.toLowerCase() : normalized;
167
223
  }
168
224
 
169
- async function readUpdateCache(path) {
225
+ function canonicalPathIdentity(path) {
170
226
  try {
171
- const parsed = JSON.parse(await readFile(path, "utf8"));
172
- return parsed && typeof parsed === "object" ? parsed : null;
227
+ return pathIdentity(realpathSync(path));
173
228
  } catch {
229
+ return pathIdentity(path);
230
+ }
231
+ }
232
+
233
+ function updateLockPath(env, binPath) {
234
+ const configDir = getAuthConfigDir(env);
235
+ const installRoot = stableInstallRoot(binPath);
236
+ if (!installRoot || !isAbsolute(configDir)) {
174
237
  return null;
175
238
  }
239
+ const key = createHash("sha256").update(pathIdentity(installRoot)).digest("hex").slice(0, 24);
240
+ return join(configDir, `${UPDATE_LOCK_PREFIX}-${key}.lock`);
176
241
  }
177
242
 
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`;
181
- 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;
186
- } catch {
187
- await rm(tmpPath, { force: true }).catch(() => {});
188
- return false;
243
+ export async function acquireUpdateLock({ env = process.env, binPath }) {
244
+ const lockPath = updateLockPath(env, binPath);
245
+ return lockPath ? acquireFileLock(lockPath) : null;
246
+ }
247
+
248
+ function installInvocation(context, version) {
249
+ if (version !== "latest" && !isValidReleaseVersion(version)) {
250
+ throw new CliApiError("The update target must be a valid release version.");
251
+ }
252
+ const commandFactory = INSTALL_COMMANDS[context?.kind];
253
+ if (!commandFactory) {
254
+ throw new CliApiError("Could not determine which package manager should install the update.");
189
255
  }
256
+ return commandFactory(`${PACKAGE_NAME}@${version}`);
190
257
  }
191
258
 
192
- export async function maybeNotifyUpdate({
193
- binPath,
194
- currentVersion,
259
+ export async function installVersion({
260
+ context,
261
+ version,
262
+ spawnProcess,
263
+ platform = process.platform,
195
264
  env = process.env,
196
- fetchImpl = fetch,
197
- now = Date.now(),
198
- stderr,
265
+ signalSource = process,
199
266
  }) {
200
- 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)) {
212
- return;
213
- }
214
-
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,
267
+ const [command, args] = installInvocation(context, version);
268
+ return await new Promise((resolve, reject) => {
269
+ let parentSignal = null;
270
+ let settled = false;
271
+ const child = spawnProcess(command, args, {
272
+ env,
273
+ shell: platform === "win32",
274
+ stdio: "inherit",
275
+ });
276
+ const cleanup = forwardSignals(signalSource, child, (signal) => {
277
+ parentSignal ??= signal;
278
+ });
279
+ child.on("error", (error) => {
280
+ if (settled) return;
281
+ settled = true;
282
+ cleanup();
283
+ reject(
284
+ new CliApiError(
285
+ error?.code === "ENOENT"
286
+ ? `${command} was not found on PATH. Install it or run: npm install -g ${PACKAGE_NAME}@latest`
287
+ : `Failed to run ${command}: ${error?.message ?? error}`,
288
+ ),
289
+ );
290
+ });
291
+ child.on("exit", (code, signal) => {
292
+ if (settled) return;
293
+ settled = true;
294
+ cleanup();
295
+ resolve({
296
+ exitCode: parentSignal || signal ? 1 : (code ?? 0),
297
+ signal: parentSignal ?? signal ?? null,
228
298
  });
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
- }
299
+ });
300
+ });
301
+ }
302
+
303
+ async function resolveGlobalRoot({ kind, spawnProcess, env, platform, signalSource }) {
304
+ return await new Promise((resolve) => {
305
+ let output = "";
306
+ let interrupted = false;
307
+ const child = spawnProcess(kind, GLOBAL_ROOT_COMMANDS[kind], {
308
+ env,
309
+ shell: platform === "win32",
310
+ stdio: ["ignore", "pipe", "ignore"],
311
+ });
312
+ const cleanup = forwardSignals(signalSource, child, () => {
313
+ interrupted = true;
314
+ });
315
+ child.stdout?.on("data", (chunk) => {
316
+ output += String(chunk);
317
+ });
318
+ child.on("error", () => {
319
+ cleanup();
320
+ resolve(null);
321
+ });
322
+ child.on("exit", (code, signal) => {
323
+ cleanup();
324
+ resolve(code === 0 && !signal && !interrupted ? output.trim() || null : null);
325
+ });
326
+ });
327
+ }
328
+
329
+ export async function resolveInstalledBin({
330
+ context,
331
+ spawnProcess,
332
+ env = process.env,
333
+ platform = process.platform,
334
+ signalSource = process,
335
+ }) {
336
+ if (!INSTALLED_KINDS.has(context?.kind)) {
337
+ return null;
338
+ }
339
+ const installRoot = stableInstallRoot(context.binPath);
340
+ if (!installRoot) {
341
+ return null;
342
+ }
241
343
 
242
- if (latestKnown && compareVersions(latestKnown, currentVersion) > 0) {
243
- stderr.write(`Update available: ornn CLI ${currentVersion} -> ${latestKnown}. Run \`ornn update\`.\n`);
344
+ const paths = pathApi(platform);
345
+ const packageRoot = paths.join(installRoot, "node_modules");
346
+ const output = await resolveGlobalRoot({
347
+ kind: context.kind,
348
+ spawnProcess,
349
+ env,
350
+ platform,
351
+ signalSource,
352
+ });
353
+ let configuredRoot = output;
354
+ if (context.kind === "bun") {
355
+ // Bun lists the effective global directory before its dependency tree.
356
+ configuredRoot = /^(.+) node_modules \(\d+(?: installed)?\)$/.exec(output?.split(/\r?\n/)[0] ?? "")?.[1];
357
+ }
358
+ if (!configuredRoot || !paths.isAbsolute(configuredRoot)) {
359
+ return null;
360
+ }
361
+ if (context.kind === "bun" || context.kind === "yarn") {
362
+ configuredRoot = paths.join(configuredRoot, "node_modules");
363
+ }
364
+ if (canonicalPathIdentity(configuredRoot) !== canonicalPathIdentity(packageRoot)) {
365
+ return null;
366
+ }
367
+
368
+ const candidate = paths.join(packageRoot, "@ornncompute", "cli", "bin", "ornn.js");
369
+ if (!paths.isAbsolute(candidate) || !existsSync(candidate)) {
370
+ return null;
371
+ }
372
+ return candidate;
373
+ }
374
+
375
+ export function resolveUpdateRegistry(env = process.env) {
376
+ const registry = env.npm_config_registry?.trim() || "https://registry.npmjs.org";
377
+ return registry.replace(/\/+$/, "");
378
+ }
379
+
380
+ export async function fetchLatestVersion({
381
+ env = process.env,
382
+ fetchImpl = fetch,
383
+ timeoutMs = UPDATE_CHECK_TIMEOUT_MS,
384
+ } = {}) {
385
+ try {
386
+ const response = await fetchImpl(`${resolveUpdateRegistry(env)}/${PACKAGE_NAME}/latest`, {
387
+ headers: { Accept: "application/json" },
388
+ signal: AbortSignal.timeout(timeoutMs),
389
+ });
390
+ if (!response.ok) {
391
+ return null;
244
392
  }
393
+ const payload = await response.json();
394
+ const version = payload?.version;
395
+ return typeof version === "string" && VERSION_PATTERN.test(version) ? version : null;
245
396
  } catch {
246
- // Never break or slow the command the notifier rides on.
397
+ return null;
247
398
  }
248
399
  }
249
400
 
@@ -265,95 +416,91 @@ export async function updateCommand({
265
416
  throw new CliApiError("ornn is running from a development checkout; update it with git pull.");
266
417
  }
267
418
  if (context.kind === "project") {
268
- throw new CliApiError("ornn is installed as a project dependency; update it there with: npm update @ornncompute/cli");
419
+ throw new CliApiError(
420
+ "ornn is installed as a project dependency; update it there with: npm update @ornncompute/cli",
421
+ );
269
422
  }
270
423
  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");
424
+ throw new CliApiError(
425
+ "Could not determine how ornn was installed. Reinstall with: npm install -g @ornncompute/cli@latest",
426
+ );
285
427
  }
286
428
 
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) {
429
+ const lock = await acquireUpdateLock({ env, binPath: context.binPath });
430
+ if (!lock) {
431
+ const lockPath = updateLockPath(env, context.binPath);
307
432
  throw new CliApiError(
308
- updateFailureMessage({ binPath: context.binPath, command, commandArgs, exitCode, kind: context.kind, platform }),
433
+ `Could not acquire the update lock. If another ornn update is running, wait for it to finish and retry.${
434
+ lockPath
435
+ ? ` If no updater or package installer is running, remove the stale lock at ${lockPath} and retry.`
436
+ : " Check the config directory permissions and retry."
437
+ }`,
309
438
  );
310
439
  }
311
440
 
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
- }
441
+ try {
442
+ const installedBin = await resolveInstalledBin({ context, spawnProcess, env, platform });
443
+ if (!installedBin) {
444
+ throw new CliApiError(
445
+ `Could not verify the ${context.kind} global installation root. No files were changed; reinstall with: npm install -g ${PACKAGE_NAME}@latest`,
446
+ );
447
+ }
315
448
 
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");
449
+ const latest = await fetchLatestVersion({ env, fetchImpl });
450
+ if (latest && compareVersions(latest, currentVersion) <= 0) {
451
+ stdout.write(`ornn CLI is already up to date (${currentVersion}).\n`);
452
+ return 0;
453
+ }
454
+ if (!latest) {
455
+ stderr.write("Could not check the npm registry for the latest version; attempting the update anyway.\n");
322
456
  }
323
- await writeUpdateCache(updateCachePath(env), {
324
- lastCheckedAt: new Date().toISOString(),
325
- latestVersion: latest ?? null,
326
- });
327
- return 0;
328
- }
329
457
 
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`);
458
+ const version = latest ?? "latest";
459
+ const [command, commandArgs] = installInvocation(context, version);
460
+ stderr.write(`Running: ${command} ${commandArgs.join(" ")}\n`);
461
+ const result = await lock.runInstall(() => installVersion({ context, version, spawnProcess, platform, env }));
462
+ if (result.exitCode !== 0) {
463
+ throw new CliApiError(
464
+ updateFailureMessage({
465
+ binPath: context.binPath,
466
+ command,
467
+ commandArgs,
468
+ exitCode: result.exitCode,
469
+ kind: context.kind,
470
+ platform,
471
+ }),
472
+ );
350
473
  }
474
+
475
+ if (platform === "win32") {
476
+ stderr.write("If this terminal reports an error after updating, it is safe to ignore; open a new terminal.\n");
477
+ }
478
+
479
+ const installedVersion = await readInstalledVersion(installedBin);
480
+ if (!installedVersion) {
481
+ stdout.write("Update finished. Run `ornn --version` to confirm the installed version.\n");
482
+ } else if (installedVersion === currentVersion && latest) {
483
+ stderr.write(
484
+ `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`,
485
+ );
486
+ return 1;
487
+ } else if (installedVersion === currentVersion) {
488
+ stdout.write(`ornn CLI is still ${currentVersion}; the registry may not have a newer version.\n`);
489
+ } else if (compareVersions(installedVersion, currentVersion) < 0) {
490
+ stderr.write(
491
+ `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`,
492
+ );
493
+ return 1;
494
+ } else {
495
+ stdout.write(`Updated ornn CLI ${currentVersion} -> ${installedVersion}.\n`);
496
+ if (latest && compareVersions(installedVersion, latest) < 0) {
497
+ stderr.write(`Note: ${latest} is the latest on the registry; your mirror may be behind.\n`);
498
+ }
499
+ }
500
+ return 0;
501
+ } finally {
502
+ await lock.release();
351
503
  }
352
- await writeUpdateCache(updateCachePath(env), {
353
- lastCheckedAt: new Date().toISOString(),
354
- latestVersion: latest ?? installedVersion ?? null,
355
- });
356
- return 0;
357
504
  }
358
505
 
359
506
  function updateFailureMessage({ binPath, command, commandArgs, exitCode, kind, platform }) {