@ornncompute/cli 0.1.2 → 0.1.4
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 +110 -18
- package/package.json +2 -2
- package/src/api-client.mjs +80 -1
- package/src/cli.mjs +4078 -706
- package/src/update.mjs +388 -0
package/src/update.mjs
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { CliApiError } from "./api-client.mjs";
|
|
6
|
+
import { getAuthConfigDir } from "./auth-store.mjs";
|
|
7
|
+
|
|
8
|
+
export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
export const UPDATE_CHECK_TIMEOUT_MS = 3000;
|
|
10
|
+
const UPDATE_CACHE_FILE = "update-check.json";
|
|
11
|
+
const PACKAGE_NAME = "@ornncompute/cli";
|
|
12
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
13
|
+
const INSTALLED_KINDS = new Set(["bun", "npm", "pnpm", "yarn"]);
|
|
14
|
+
|
|
15
|
+
const INSTALL_COMMANDS = {
|
|
16
|
+
bun: (spec) => ["bun", ["add", "-g", spec]],
|
|
17
|
+
npm: (spec) => ["npm", ["install", "-g", spec]],
|
|
18
|
+
pnpm: (spec) => ["pnpm", ["add", "-g", spec]],
|
|
19
|
+
yarn: (spec) => ["yarn", ["global", "add", spec]],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function parseVersion(value) {
|
|
23
|
+
if (typeof value !== "string") {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(value.trim().split("+")[0]);
|
|
27
|
+
if (!match) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
nums: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
32
|
+
pre: match[4] ? match[4].split(".") : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function compareVersions(a, b) {
|
|
37
|
+
const left = parseVersion(a);
|
|
38
|
+
const right = parseVersion(b);
|
|
39
|
+
if (!left || !right) {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
for (let index = 0; index < 3; index += 1) {
|
|
43
|
+
if (left.nums[index] !== right.nums[index]) {
|
|
44
|
+
return left.nums[index] < right.nums[index] ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!left.pre && !right.pre) {
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
if (!left.pre) {
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
if (!right.pre) {
|
|
54
|
+
return -1;
|
|
55
|
+
}
|
|
56
|
+
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
|
|
57
|
+
const x = left.pre[index];
|
|
58
|
+
const y = right.pre[index];
|
|
59
|
+
if (x === undefined) {
|
|
60
|
+
return -1;
|
|
61
|
+
}
|
|
62
|
+
if (y === undefined) {
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
const xNumeric = /^\d+$/.test(x);
|
|
66
|
+
const yNumeric = /^\d+$/.test(y);
|
|
67
|
+
if (xNumeric && yNumeric) {
|
|
68
|
+
if (Number(x) !== Number(y)) {
|
|
69
|
+
return Number(x) < Number(y) ? -1 : 1;
|
|
70
|
+
}
|
|
71
|
+
} else if (xNumeric !== yNumeric) {
|
|
72
|
+
return xNumeric ? -1 : 1;
|
|
73
|
+
} else if (x !== y) {
|
|
74
|
+
return x < y ? -1 : 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function detectInstallContext(binPath, env = process.env) {
|
|
81
|
+
if (!binPath) {
|
|
82
|
+
return { kind: "unknown", binPath: null };
|
|
83
|
+
}
|
|
84
|
+
let resolved = binPath;
|
|
85
|
+
try {
|
|
86
|
+
resolved = realpathSync(binPath);
|
|
87
|
+
} catch {}
|
|
88
|
+
const lower = resolved.replaceAll("\\", "/").toLowerCase();
|
|
89
|
+
const segments = lower.split("/").filter(Boolean);
|
|
90
|
+
const has = (segment) => segments.includes(segment);
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
has("_npx") ||
|
|
94
|
+
segments.some((segment) => segment === "dlx" || segment.startsWith("dlx-") || segment.startsWith("xfs-")) ||
|
|
95
|
+
lower.includes("/.bun/install/cache/") ||
|
|
96
|
+
lower.includes("/.yarn/berry/cache/")
|
|
97
|
+
) {
|
|
98
|
+
return { kind: "ephemeral", binPath: resolved };
|
|
99
|
+
}
|
|
100
|
+
if (!has("node_modules")) {
|
|
101
|
+
return { kind: "dev", binPath: resolved };
|
|
102
|
+
}
|
|
103
|
+
if (has(".bun") || isUnderDir(lower, env.BUN_INSTALL)) {
|
|
104
|
+
return { kind: "bun", binPath: resolved };
|
|
105
|
+
}
|
|
106
|
+
if (has(".pnpm") || has("pnpm") || isUnderDir(lower, env.PNPM_HOME)) {
|
|
107
|
+
return { kind: "pnpm", binPath: resolved };
|
|
108
|
+
}
|
|
109
|
+
if (has("yarn") || has(".yarn")) {
|
|
110
|
+
return { kind: "yarn", binPath: resolved };
|
|
111
|
+
}
|
|
112
|
+
if (isProjectInstall(resolved, lower)) {
|
|
113
|
+
return { kind: "project", binPath: resolved };
|
|
114
|
+
}
|
|
115
|
+
return { kind: "npm", binPath: resolved };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isProjectInstall(resolvedPath, lower) {
|
|
119
|
+
// Global npm layouts: <prefix>/lib/node_modules (POSIX) or <prefix>/npm/node_modules (Windows default).
|
|
120
|
+
if (lower.includes("/lib/node_modules/") || lower.includes("/npm/node_modules/")) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
// Other global layouts (nvm-windows, Scoop) have no package.json above node_modules; projects do.
|
|
124
|
+
const index = lower.indexOf("/node_modules/");
|
|
125
|
+
if (index < 0) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return existsSync(join(resolvedPath.slice(0, index), "package.json"));
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isUnderDir(lowerPath, dir) {
|
|
136
|
+
const trimmed = dir?.trim();
|
|
137
|
+
if (!trimmed) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return lowerPath.startsWith(`${trimmed.replaceAll("\\", "/").toLowerCase().replace(/\/+$/, "")}/`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resolveUpdateRegistry(env = process.env) {
|
|
144
|
+
const registry = env.npm_config_registry?.trim() || "https://registry.npmjs.org";
|
|
145
|
+
return registry.replace(/\/+$/, "");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function fetchLatestVersion({
|
|
149
|
+
env = process.env,
|
|
150
|
+
fetchImpl = fetch,
|
|
151
|
+
timeoutMs = UPDATE_CHECK_TIMEOUT_MS,
|
|
152
|
+
} = {}) {
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetchImpl(`${resolveUpdateRegistry(env)}/${PACKAGE_NAME}/latest`, {
|
|
155
|
+
headers: { Accept: "application/json" },
|
|
156
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const payload = await response.json();
|
|
162
|
+
const version = payload?.version;
|
|
163
|
+
return typeof version === "string" && VERSION_PATTERN.test(version) ? version : null;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function updateCachePath(env) {
|
|
170
|
+
return join(getAuthConfigDir(env), UPDATE_CACHE_FILE);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readUpdateCache(path) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
176
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function writeUpdateCache(path, payload) {
|
|
183
|
+
// Atomic tmp+rename: survives concurrent runs and replaces a root-owned file left by a past sudo run.
|
|
184
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
185
|
+
try {
|
|
186
|
+
await mkdir(dirname(path), { mode: 0o700, recursive: true });
|
|
187
|
+
await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
188
|
+
await rename(tmpPath, path);
|
|
189
|
+
return true;
|
|
190
|
+
} catch {
|
|
191
|
+
await rm(tmpPath, { force: true }).catch(() => {});
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function maybeNotifyUpdate({
|
|
197
|
+
binPath,
|
|
198
|
+
currentVersion,
|
|
199
|
+
env = process.env,
|
|
200
|
+
fetchImpl = fetch,
|
|
201
|
+
now = Date.now(),
|
|
202
|
+
stderr,
|
|
203
|
+
}) {
|
|
204
|
+
try {
|
|
205
|
+
if (env.CI && env.CI !== "false") {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!stderr?.isTTY) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (!INSTALLED_KINDS.has(detectInstallContext(binPath, env).kind)) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const cachePath = updateCachePath(env);
|
|
215
|
+
if (!isAbsolute(cachePath)) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const cache = await readUpdateCache(cachePath);
|
|
220
|
+
let latestKnown =
|
|
221
|
+
typeof cache?.latestVersion === "string" && VERSION_PATTERN.test(cache.latestVersion)
|
|
222
|
+
? cache.latestVersion
|
|
223
|
+
: null;
|
|
224
|
+
const checkedAt = Date.parse(cache?.lastCheckedAt ?? "");
|
|
225
|
+
const fresh = Number.isFinite(checkedAt) && now - checkedAt >= 0 && now - checkedAt < UPDATE_CHECK_TTL_MS;
|
|
226
|
+
|
|
227
|
+
if (!fresh) {
|
|
228
|
+
// Reserve the TTL before fetching so an unwritable cache disables the check instead of taxing every command.
|
|
229
|
+
const reserved = await writeUpdateCache(cachePath, {
|
|
230
|
+
lastCheckedAt: new Date(now).toISOString(),
|
|
231
|
+
latestVersion: latestKnown,
|
|
232
|
+
});
|
|
233
|
+
if (!reserved) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const fetched = await fetchLatestVersion({ env, fetchImpl });
|
|
237
|
+
if (fetched) {
|
|
238
|
+
latestKnown = fetched;
|
|
239
|
+
await writeUpdateCache(cachePath, {
|
|
240
|
+
lastCheckedAt: new Date(now).toISOString(),
|
|
241
|
+
latestVersion: fetched,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (latestKnown && compareVersions(latestKnown, currentVersion) > 0) {
|
|
247
|
+
stderr.write(`Update available: ornn CLI ${currentVersion} -> ${latestKnown}. Run \`ornn update\`.\n`);
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// Never break or slow the command the notifier rides on.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function updateCommand({
|
|
255
|
+
binPath,
|
|
256
|
+
currentVersion,
|
|
257
|
+
env = process.env,
|
|
258
|
+
fetchImpl = fetch,
|
|
259
|
+
platform = process.platform,
|
|
260
|
+
spawnProcess,
|
|
261
|
+
stderr,
|
|
262
|
+
stdout,
|
|
263
|
+
}) {
|
|
264
|
+
const context = detectInstallContext(binPath, env);
|
|
265
|
+
if (context.kind === "ephemeral") {
|
|
266
|
+
throw new CliApiError("ornn is running via a package runner (npx/dlx); there is no installed copy to update.");
|
|
267
|
+
}
|
|
268
|
+
if (context.kind === "dev") {
|
|
269
|
+
throw new CliApiError("ornn is running from a development checkout; update it with git pull.");
|
|
270
|
+
}
|
|
271
|
+
if (context.kind === "project") {
|
|
272
|
+
throw new CliApiError("ornn is installed as a project dependency; update it there with: npm update @ornncompute/cli");
|
|
273
|
+
}
|
|
274
|
+
if (context.kind === "unknown") {
|
|
275
|
+
throw new CliApiError("Could not determine how ornn was installed. Reinstall with: npm install -g @ornncompute/cli@latest");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const latest = await fetchLatestVersion({ env, fetchImpl });
|
|
279
|
+
if (latest && compareVersions(latest, currentVersion) <= 0) {
|
|
280
|
+
await writeUpdateCache(updateCachePath(env), {
|
|
281
|
+
lastCheckedAt: new Date().toISOString(),
|
|
282
|
+
latestVersion: latest,
|
|
283
|
+
});
|
|
284
|
+
stdout.write(`ornn CLI is already up to date (${currentVersion}).\n`);
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
if (!latest) {
|
|
288
|
+
stderr.write("Could not check the npm registry for the latest version; attempting the update anyway.\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const spec = `${PACKAGE_NAME}@${latest ?? "latest"}`;
|
|
292
|
+
const [command, commandArgs] = INSTALL_COMMANDS[context.kind](spec);
|
|
293
|
+
stderr.write(`Running: ${command} ${commandArgs.join(" ")}\n`);
|
|
294
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
295
|
+
const child = spawnProcess(command, commandArgs, {
|
|
296
|
+
shell: platform === "win32",
|
|
297
|
+
stdio: "inherit",
|
|
298
|
+
});
|
|
299
|
+
child.on("error", (error) =>
|
|
300
|
+
reject(
|
|
301
|
+
new CliApiError(
|
|
302
|
+
error?.code === "ENOENT"
|
|
303
|
+
? `${command} was not found on PATH. Install it or run: npm install -g ${PACKAGE_NAME}@latest`
|
|
304
|
+
: `Failed to run ${command}: ${error?.message ?? error}`,
|
|
305
|
+
),
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
child.on("exit", (code, signal) => resolve(signal ? 1 : code ?? 0));
|
|
309
|
+
});
|
|
310
|
+
if (exitCode !== 0) {
|
|
311
|
+
throw new CliApiError(
|
|
312
|
+
updateFailureMessage({ binPath: context.binPath, command, commandArgs, exitCode, kind: context.kind, platform }),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (platform === "win32") {
|
|
317
|
+
stderr.write("If this terminal reports an error after updating, it is safe to ignore; open a new terminal.\n");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (context.kind === "pnpm") {
|
|
321
|
+
// pnpm shims pin a versioned store path, so re-reads never see the new version; trust pnpm's exit status.
|
|
322
|
+
if (latest) {
|
|
323
|
+
stdout.write(`Updated ornn CLI ${currentVersion} -> ${latest}. Run \`ornn --version\` in a new shell to confirm.\n`);
|
|
324
|
+
} else {
|
|
325
|
+
stdout.write("Update finished. Run `ornn --version` in a new shell to confirm the installed version.\n");
|
|
326
|
+
}
|
|
327
|
+
await writeUpdateCache(updateCachePath(env), {
|
|
328
|
+
lastCheckedAt: new Date().toISOString(),
|
|
329
|
+
latestVersion: latest ?? null,
|
|
330
|
+
});
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Re-resolve the original bin path so the read sees the freshly installed package.
|
|
335
|
+
const installedVersion = await readInstalledVersion(binPath ?? context.binPath);
|
|
336
|
+
if (!installedVersion) {
|
|
337
|
+
stdout.write("Update finished. Run `ornn --version` to confirm the installed version.\n");
|
|
338
|
+
} else if (installedVersion === currentVersion && latest) {
|
|
339
|
+
stderr.write(
|
|
340
|
+
`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`,
|
|
341
|
+
);
|
|
342
|
+
return 1;
|
|
343
|
+
} else if (installedVersion === currentVersion) {
|
|
344
|
+
stdout.write(`ornn CLI is still ${currentVersion}; the registry may not have a newer version.\n`);
|
|
345
|
+
} else if (compareVersions(installedVersion, currentVersion) < 0) {
|
|
346
|
+
stderr.write(
|
|
347
|
+
`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`,
|
|
348
|
+
);
|
|
349
|
+
return 1;
|
|
350
|
+
} else {
|
|
351
|
+
stdout.write(`Updated ornn CLI ${currentVersion} -> ${installedVersion}.\n`);
|
|
352
|
+
if (latest && compareVersions(installedVersion, latest) < 0) {
|
|
353
|
+
stderr.write(`Note: ${latest} is the latest on the registry; your mirror may be behind.\n`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
await writeUpdateCache(updateCachePath(env), {
|
|
357
|
+
lastCheckedAt: new Date().toISOString(),
|
|
358
|
+
latestVersion: latest ?? installedVersion ?? null,
|
|
359
|
+
});
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function updateFailureMessage({ binPath, command, commandArgs, exitCode, kind, platform }) {
|
|
364
|
+
const manualCommand = `${command} ${commandArgs.join(" ")}`;
|
|
365
|
+
const lines = [`Update failed (exit ${exitCode}). Retry manually: ${manualCommand}`];
|
|
366
|
+
if (platform === "win32") {
|
|
367
|
+
lines.push("If the output above shows a permissions error, retry from an elevated terminal.");
|
|
368
|
+
} else if (binPath && !binPath.startsWith(homedir())) {
|
|
369
|
+
lines.push(`If the output above shows a permissions error, try: sudo ${manualCommand}`);
|
|
370
|
+
}
|
|
371
|
+
if (kind === "yarn") {
|
|
372
|
+
lines.push("If yarn global is unavailable (yarn 2+), use: npm install -g @ornncompute/cli@latest");
|
|
373
|
+
}
|
|
374
|
+
return lines.join("\n");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function readInstalledVersion(binPath) {
|
|
378
|
+
try {
|
|
379
|
+
let resolved = binPath;
|
|
380
|
+
try {
|
|
381
|
+
resolved = realpathSync(binPath);
|
|
382
|
+
} catch {}
|
|
383
|
+
const parsed = JSON.parse(await readFile(join(dirname(resolved), "..", "package.json"), "utf8"));
|
|
384
|
+
return parsed?.name === PACKAGE_NAME && typeof parsed.version === "string" ? parsed.version : null;
|
|
385
|
+
} catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|