@jsorm/cli 0.1.3 → 0.2.1

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.
Files changed (3) hide show
  1. package/README.md +18 -1
  2. package/dist/index.mjs +367 -111
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -60,8 +60,25 @@ jsorm watch
60
60
 
61
61
  ---
62
62
 
63
+ ## jsorm update
64
+
65
+ Installs the latest engine compatible with this CLI and removes older installed versions. It checks the download server now and fails, removing nothing, when the server cannot be reached or the latest engine belongs to a newer release line.
66
+
67
+ ```bash
68
+ jsorm update # install the latest compatible engine
69
+ jsorm update --check # report installed and latest, install nothing
70
+ jsorm update --force # reinstall the latest compatible engine
71
+ ```
72
+
73
+ The engine lives in `~/.jsorm/engines/v<version>/` (owner-only). Before every run the CLI checks the binary against the SHA-256 signed in the release manifest; a modified engine is reinstalled, never run. Other commands check for updates at most once every 24 hours; `JSORM_NO_UPDATE_CHECK=1` skips the check while an engine is installed.
74
+
75
+ ---
76
+
63
77
  ## Requirements
64
78
 
65
79
  - Node.js 22+
80
+ - macOS or Linux, x64 or arm64 (Windows is not supported yet)
66
81
  - `jsorm.config.ts` at project root
67
- - Models in configured path
82
+ - Models in configured path
83
+
84
+ Run commands through your package manager: `pnpm jsorm`, `npx jsorm`, `yarn jsorm` or `bunx jsorm`. A local install does not put `jsorm` on `PATH`.
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync, spawn } from "node:child_process";
3
- import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
3
+ import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
@@ -43,6 +43,15 @@ function detectPackageManager() {
43
43
  if (process.env.npm_config_user_agent?.includes("yarn")) return "yarn";
44
44
  return "npm";
45
45
  }
46
+ /** Command that runs a locally installed `jsorm` with this package manager. */
47
+ function cliRunner(pm) {
48
+ switch (pm) {
49
+ case "npm": return "npx jsorm";
50
+ case "yarn": return "yarn jsorm";
51
+ case "bun": return "bunx jsorm";
52
+ default: return "pnpm jsorm";
53
+ }
54
+ }
46
55
  function detectRuntime() {
47
56
  if (typeof process !== "undefined" && process.versions?.bun) return "bun";
48
57
  if (typeof process !== "undefined" && process.versions?.deno) return "deno";
@@ -85,6 +94,134 @@ function detectFramework() {
85
94
  return "none";
86
95
  }
87
96
  //#endregion
97
+ //#region src/version.ts
98
+ function compareVersions(a, b) {
99
+ const pa = a.split(".").map(Number);
100
+ const pb = b.split(".").map(Number);
101
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
102
+ const na = pa[i] ?? 0;
103
+ const nb = pb[i] ?? 0;
104
+ if (na > nb) return 1;
105
+ if (na < nb) return -1;
106
+ }
107
+ return 0;
108
+ }
109
+ /** Release line of a version: "0.2" while the major is 0, "1" from 1.0 on. */
110
+ function releaseLine(version) {
111
+ const [major = "0", minor = "0"] = version.split(".");
112
+ return major === "0" ? `0.${minor}` : major;
113
+ }
114
+ /** The CLI only runs engines of its own release line. */
115
+ function isCompatibleEngine(cliVersion, engineVersion) {
116
+ return releaseLine(cliVersion) === releaseLine(engineVersion);
117
+ }
118
+ //#endregion
119
+ //#region src/install.ts
120
+ function getVersionDir(enginesDir, version) {
121
+ return join(enginesDir, `v${version}`);
122
+ }
123
+ /** Signed envelope stored next to an installed binary. */
124
+ function getEnvelopePath(enginesDir, version, binaryName) {
125
+ return join(getVersionDir(enginesDir, version), `${binaryName}.envelope.json`);
126
+ }
127
+ /**
128
+ * Creates `dir` (and missing parents) readable and writable by its owner
129
+ * only, and tightens it when it already exists: nobody else may replace an
130
+ * engine the CLI is going to run.
131
+ */
132
+ function ensureOwnerOnlyDir(dir) {
133
+ mkdirSync(dir, {
134
+ recursive: true,
135
+ mode: 448
136
+ });
137
+ if (process.platform !== "win32") chmodSync(dir, 448);
138
+ }
139
+ /** Installed versions that hold `binaryName`, newest first, e.g. ["0.2.1", "0.2.0"]. */
140
+ function listInstalledVersions(enginesDir, binaryName) {
141
+ if (!existsSync(enginesDir)) return [];
142
+ return readdirSync(enginesDir).filter((d) => d.startsWith("v") && existsSync(join(enginesDir, d, binaryName))).map((d) => d.slice(1)).sort((a, b) => compareVersions(b, a));
143
+ }
144
+ /** Newest installed version accepted by `accept`, e.g. "0.2.1". */
145
+ function detectInstalledVersion(enginesDir, binaryName, accept = () => true) {
146
+ return listInstalledVersions(enginesDir, binaryName).find(accept) ?? null;
147
+ }
148
+ /** Removes every installed version directory except `keepVersion`. Returns the removed versions. */
149
+ function pruneInstalledVersions(enginesDir, binaryName, keepVersion) {
150
+ const removed = listInstalledVersions(enginesDir, binaryName).filter((v) => v !== keepVersion);
151
+ for (const version of removed) rmSync(getVersionDir(enginesDir, version), {
152
+ recursive: true,
153
+ force: true
154
+ });
155
+ return removed;
156
+ }
157
+ async function sha256File(filePath) {
158
+ const hash = createHash("sha256");
159
+ await pipeline(createReadStream(filePath), hash);
160
+ return hash.digest("hex");
161
+ }
162
+ async function downloadToFile(url, destPath) {
163
+ const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
164
+ if (!res.ok || !res.body) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
165
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
166
+ }
167
+ function extractTarGz(tarPath, destDir) {
168
+ try {
169
+ execFileSync("tar", [
170
+ "-xzf",
171
+ tarPath,
172
+ "-C",
173
+ destDir
174
+ ], { stdio: "pipe" });
175
+ } catch (err) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ throw new Error(`tar extraction failed: ${msg}`);
178
+ }
179
+ }
180
+ /**
181
+ * Downloads, verifies and extracts the engine inside a private temp directory,
182
+ * and only then moves the binary into `<enginesDir>/v<version>/<binaryName>`.
183
+ * A failed or interrupted install never leaves a binary at the final path.
184
+ * Returns the installed binary path.
185
+ */
186
+ async function installEngine(options) {
187
+ const { url, enginesDir, version, binaryName, expectedSha256, expectedBinarySha256, envelope, log = () => {} } = options;
188
+ ensureOwnerOnlyDir(enginesDir);
189
+ const tempDir = mkdtempSync(join(enginesDir, ".tmp-"));
190
+ try {
191
+ const tarPath = join(tempDir, "engine.tar.gz");
192
+ log(` Downloading from ${url}...`);
193
+ await downloadToFile(url, tarPath);
194
+ log(` ✓ Downloaded ${(statSync(tarPath).size / 1024).toFixed(1)} KB`);
195
+ log(` Verifying checksum...`);
196
+ const hash = await sha256File(tarPath);
197
+ if (hash !== expectedSha256) throw new Error(`Checksum mismatch!\n Expected: ${expectedSha256}\n Got: ${hash}`);
198
+ log(` ✓ Checksum verified`);
199
+ log(` Extracting...`);
200
+ const extractDir = join(tempDir, "out");
201
+ mkdirSync(extractDir);
202
+ extractTarGz(tarPath, extractDir);
203
+ const extractedPath = join(extractDir, "jsorm");
204
+ if (!existsSync(extractedPath)) throw new Error(`Archive does not contain the "jsorm" binary`);
205
+ const binaryHash = await sha256File(extractedPath);
206
+ if (binaryHash !== expectedBinarySha256) throw new Error(`Binary checksum mismatch!\n Expected: ${expectedBinarySha256}\n Got: ${binaryHash}`);
207
+ if (process.platform !== "win32") chmodSync(extractedPath, 448);
208
+ const envelopeTemp = join(tempDir, "envelope.json");
209
+ writeFileSync(envelopeTemp, JSON.stringify(envelope), { mode: 384 });
210
+ const destDir = getVersionDir(enginesDir, version);
211
+ const destPath = join(destDir, binaryName);
212
+ ensureOwnerOnlyDir(destDir);
213
+ renameSync(envelopeTemp, getEnvelopePath(enginesDir, version, binaryName));
214
+ renameSync(extractedPath, destPath);
215
+ log(` ✓ Engine installed: ${destPath}`);
216
+ return destPath;
217
+ } finally {
218
+ rmSync(tempDir, {
219
+ recursive: true,
220
+ force: true
221
+ });
222
+ }
223
+ }
224
+ //#endregion
88
225
  //#region src/manifest.ts
89
226
  const EMBEDDED_PUBLIC_KEY_B64 = "6AUQMvF7jBV+S8yv9K8hnnoYzIznQw4nf5x5LAuEsfM=";
90
227
  function getSpkiPublicKey(rawKeyB64 = EMBEDDED_PUBLIC_KEY_B64) {
@@ -129,7 +266,12 @@ function parseSignedEnvelope(envelope, publicKey) {
129
266
  if (typeof manifest?.version !== "string" || !manifest.targets || typeof manifest.targets !== "object") return invalid("Invalid manifest format");
130
267
  return {
131
268
  status: "ok",
132
- manifest
269
+ manifest,
270
+ envelope: {
271
+ success: true,
272
+ signature,
273
+ signed_payload
274
+ }
133
275
  };
134
276
  }
135
277
  async function fetchManifest(url, publicKey = getSpkiPublicKey()) {
@@ -155,124 +297,153 @@ async function fetchManifest(url, publicKey = getSpkiPublicKey()) {
155
297
  return parseSignedEnvelope(envelope, publicKey);
156
298
  }
157
299
  //#endregion
158
- //#region src/install.ts
159
- function compareVersions(a, b) {
160
- const pa = a.split(".").map(Number);
161
- const pb = b.split(".").map(Number);
162
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
163
- const na = pa[i] ?? 0;
164
- const nb = pb[i] ?? 0;
165
- if (na > nb) return 1;
166
- if (na < nb) return -1;
167
- }
168
- return 0;
169
- }
170
- function getVersionDir(enginesDir, version) {
171
- return join(enginesDir, `v${version}`);
172
- }
173
- /** Newest installed version that holds `binaryName`, e.g. "0.1.3". */
174
- function detectInstalledVersion(enginesDir, binaryName) {
175
- if (!existsSync(enginesDir)) return null;
176
- const dirs = readdirSync(enginesDir).filter((d) => d.startsWith("v")).sort((a, b) => compareVersions(b.slice(1), a.slice(1)));
177
- for (const dir of dirs) if (existsSync(join(enginesDir, dir, binaryName))) return dir.slice(1);
178
- return null;
179
- }
180
- async function sha256File(filePath) {
181
- const hash = createHash("sha256");
182
- await pipeline(createReadStream(filePath), hash);
183
- return hash.digest("hex");
184
- }
185
- async function downloadToFile(url, destPath) {
186
- const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
187
- if (!res.ok || !res.body) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
188
- await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
189
- }
190
- function extractTarGz(tarPath, destDir) {
191
- try {
192
- execFileSync("tar", [
193
- "-xzf",
194
- tarPath,
195
- "-C",
196
- destDir
197
- ], { stdio: "pipe" });
198
- } catch (err) {
199
- const msg = err instanceof Error ? err.message : String(err);
200
- throw new Error(`tar extraction failed: ${msg}`);
201
- }
202
- }
300
+ //#region src/verify.ts
301
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
203
302
  /**
204
- * Downloads, verifies and extracts the engine inside a private temp directory,
205
- * and only then moves the binary into `<enginesDir>/v<version>/<binaryName>`.
206
- * A failed or interrupted install never leaves a binary at the final path.
207
- * Returns the installed binary path.
303
+ * Checks an installed engine before it runs: its stored envelope must be
304
+ * signed with `publicKey`, declare this version and target, and carry the
305
+ * SHA-256 of the binary on disk.
208
306
  */
209
- async function installEngine(options) {
210
- const { url, enginesDir, version, binaryName, expectedSha256, log = () => {} } = options;
211
- mkdirSync(enginesDir, { recursive: true });
212
- const tempDir = mkdtempSync(join(enginesDir, ".tmp-"));
307
+ async function verifyInstalledEngine(enginesDir, version, binaryName, publicKey) {
308
+ const envelopePath = getEnvelopePath(enginesDir, version, binaryName);
309
+ if (!existsSync(envelopePath)) return {
310
+ ok: false,
311
+ reason: "no signed envelope next to the binary"
312
+ };
313
+ let envelope;
213
314
  try {
214
- const tarPath = join(tempDir, "engine.tar.gz");
215
- log(` Downloading from ${url}...`);
216
- await downloadToFile(url, tarPath);
217
- log(` ✓ Downloaded ${(statSync(tarPath).size / 1024).toFixed(1)} KB`);
218
- log(` Verifying checksum...`);
219
- const hash = await sha256File(tarPath);
220
- if (hash !== expectedSha256) throw new Error(`Checksum mismatch!\n Expected: ${expectedSha256}\n Got: ${hash}`);
221
- log(` ✓ Checksum verified`);
222
- log(` Extracting...`);
223
- const extractDir = join(tempDir, "out");
224
- mkdirSync(extractDir);
225
- extractTarGz(tarPath, extractDir);
226
- const extractedPath = join(extractDir, "jsorm");
227
- if (!existsSync(extractedPath)) throw new Error(`Archive does not contain the "jsorm" binary`);
228
- if (process.platform !== "win32") chmodSync(extractedPath, 493);
229
- const destDir = getVersionDir(enginesDir, version);
230
- const destPath = join(destDir, binaryName);
231
- mkdirSync(destDir, { recursive: true });
232
- renameSync(extractedPath, destPath);
233
- log(` ✓ Engine installed: ${destPath}`);
234
- return destPath;
235
- } finally {
236
- rmSync(tempDir, {
237
- recursive: true,
238
- force: true
239
- });
315
+ envelope = JSON.parse(readFileSync(envelopePath, "utf-8"));
316
+ } catch {
317
+ return {
318
+ ok: false,
319
+ reason: "the stored envelope is not valid JSON"
320
+ };
240
321
  }
322
+ const result = parseSignedEnvelope(envelope, publicKey);
323
+ if (result.status !== "ok") return {
324
+ ok: false,
325
+ reason: result.reason
326
+ };
327
+ if (result.manifest.version !== version) return {
328
+ ok: false,
329
+ reason: `the stored envelope is for v${result.manifest.version}`
330
+ };
331
+ const expected = result.manifest.targets[binaryName]?.binary_sha256;
332
+ if (typeof expected !== "string" || !SHA256_HEX.test(expected)) return {
333
+ ok: false,
334
+ reason: `the stored envelope has no binary_sha256 for ${binaryName}`
335
+ };
336
+ if (await sha256File(join(getVersionDir(enginesDir, version), binaryName)) !== expected) return {
337
+ ok: false,
338
+ reason: "the binary does not match its signed SHA-256"
339
+ };
340
+ return { ok: true };
241
341
  }
242
342
  //#endregion
243
343
  //#region src/resolve.ts
344
+ const DAY_MS = 864e5;
345
+ const LAST_CHECK_FILE = ".last-check";
346
+ function readLastCheck(enginesDir) {
347
+ try {
348
+ const value = Number(readFileSync(join(enginesDir, LAST_CHECK_FILE), "utf-8").trim());
349
+ return Number.isFinite(value) ? value : null;
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+ function writeLastCheck(enginesDir, time) {
355
+ try {
356
+ mkdirSync(enginesDir, { recursive: true });
357
+ writeFileSync(join(enginesDir, LAST_CHECK_FILE), String(time));
358
+ } catch {}
359
+ }
360
+ function incompatibleMessage(cliVersion, engineVersion) {
361
+ return `The latest engine v${engineVersion} needs a newer @jsorm/cli (this is v${cliVersion}, release line ${releaseLine(cliVersion)}).\n Upgrade it: npm install -D @jsorm/cli@latest`;
362
+ }
363
+ async function fetchTrustedManifest(options) {
364
+ const { manifestUrl, publicKey = getSpkiPublicKey(), log = () => {} } = options;
365
+ log(` Fetching manifest from ${manifestUrl}...`);
366
+ const result = await fetchManifest(manifestUrl, publicKey);
367
+ if (result.status === "unreachable") return { unreachable: result.reason };
368
+ if (result.status === "invalid") throw new Error(result.reason);
369
+ log(` ✓ Manifest signature verified`);
370
+ log(` ✓ Manifest v${result.manifest.version} (${Object.keys(result.manifest.targets).length} targets)`);
371
+ return {
372
+ manifest: result.manifest,
373
+ envelope: result.envelope
374
+ };
375
+ }
244
376
  /**
245
377
  * Returns the path of an engine binary that is safe to run: the installed one
246
- * when it is current, or a freshly installed stable release. When the server
247
- * cannot be reached, an installed engine is used as is. Throws when no engine
248
- * can be provided or when the server's answer cannot be trusted.
378
+ * when it is current, or a freshly installed stable release. Only engines of
379
+ * the CLI's release line are used, and an installed engine only runs after
380
+ * its binary matches the signed SHA-256 stored next to it. When the server
381
+ * cannot be reached, a verified installed engine is used as is. Throws when
382
+ * no engine can be provided or when the server's answer cannot be trusted.
249
383
  */
250
384
  async function resolveEngine(options) {
251
- const { enginesDir, binaryName, manifestUrl, downloadUrl, publicKey = getSpkiPublicKey(), log = () => {} } = options;
252
- const installedVer = detectInstalledVersion(enginesDir, binaryName);
385
+ const { enginesDir, binaryName, downloadUrl, cliVersion, publicKey = getSpkiPublicKey(), log = () => {}, updateCommand = "jsorm update", checkIntervalMs = DAY_MS, skipUpdateCheck = false, force = false, requireLiveCheck = false, now = Date.now } = options;
386
+ const compatible = (version) => isCompatibleEngine(cliVersion, version);
387
+ let installedVer = detectInstalledVersion(enginesDir, binaryName, compatible);
388
+ let rejectedVer = null;
389
+ if (installedVer) {
390
+ const check = await verifyInstalledEngine(enginesDir, installedVer, binaryName, publicKey);
391
+ if (!check.ok) {
392
+ log(` ⚠ Installed engine v${installedVer} failed verification: ${check.reason}`);
393
+ try {
394
+ rmSync(getVersionDir(enginesDir, installedVer), {
395
+ recursive: true,
396
+ force: true
397
+ });
398
+ } catch (err) {
399
+ log(` ⚠ Could not remove engine v${installedVer}: ${err instanceof Error ? err.message : err}`);
400
+ }
401
+ rejectedVer = installedVer;
402
+ installedVer = null;
403
+ }
404
+ }
253
405
  const installedPath = installedVer ? join(getVersionDir(enginesDir, installedVer), binaryName) : null;
254
- log(` Fetching manifest from ${manifestUrl}...`);
255
- const result = await fetchManifest(manifestUrl, publicKey);
256
- if (result.status === "unreachable") {
257
- if (installedPath) {
258
- log(` ⚠ ${result.reason}`);
406
+ if (installedPath && !force && !requireLiveCheck) {
407
+ if (skipUpdateCheck) return installedPath;
408
+ const lastCheck = readLastCheck(enginesDir);
409
+ const elapsed = lastCheck === null ? Infinity : now() - lastCheck;
410
+ if (checkIntervalMs > 0 && elapsed >= 0 && elapsed < checkIntervalMs) return installedPath;
411
+ }
412
+ const trusted = await fetchTrustedManifest({
413
+ ...options,
414
+ publicKey
415
+ });
416
+ if ("unreachable" in trusted) {
417
+ if (installedPath && !requireLiveCheck) {
418
+ log(` ⚠ ${trusted.unreachable}`);
259
419
  log(` ⚠ Using installed engine v${installedVer} without checking for updates`);
260
420
  return installedPath;
261
421
  }
262
- throw new Error(`${result.reason}\n ✖ Failed to fetch manifest.`);
422
+ if (rejectedVer) throw new Error(`${trusted.unreachable}\n ✖ Installed engine v${rejectedVer} failed verification and cannot be reinstalled offline.\n Run "${updateCommand}" once the download server is reachable.`);
423
+ throw new Error(`${trusted.unreachable}\n ✖ Failed to fetch manifest.`);
263
424
  }
264
- if (result.status === "invalid") throw new Error(result.reason);
265
- const { manifest } = result;
425
+ const { manifest, envelope } = trusted;
426
+ writeLastCheck(enginesDir, now());
266
427
  const stableVersion = manifest.version;
267
- log(` ✓ Manifest signature verified`);
268
- log(` ✓ Manifest v${stableVersion} (${Object.keys(manifest.targets).length} targets)`);
428
+ if (!compatible(stableVersion)) {
429
+ if (installedPath && !requireLiveCheck) {
430
+ log(` ⚠ ${incompatibleMessage(cliVersion, stableVersion)}`);
431
+ log(` ⚠ Using installed engine v${installedVer}`);
432
+ return installedPath;
433
+ }
434
+ throw new Error(incompatibleMessage(cliVersion, stableVersion));
435
+ }
269
436
  const target = manifest.targets[binaryName];
270
437
  if (!target) throw new Error(`No binary available for ${binaryName}\n Available: ${Object.keys(manifest.targets).join(", ")}`);
271
- if (installedPath && installedVer && compareVersions(stableVersion, installedVer) <= 0) {
438
+ if (!/^[0-9a-f]{64}$/.test(target.binary_sha256 ?? "")) throw new Error(`Engine v${stableVersion} has no signed binary hash for ${binaryName}; it cannot be verified after install.`);
439
+ if (installedPath && installedVer && !force && compareVersions(stableVersion, installedVer) <= 0) {
272
440
  log(` ✓ Engine v${installedVer} is up to date (stable: v${stableVersion})`);
273
441
  return installedPath;
274
442
  }
275
- log(installedVer ? ` Update available: v${installedVer} → v${stableVersion}` : ` Engine not found. Downloading v${stableVersion}...`);
443
+ if (rejectedVer) log(` Reinstalling engine v${stableVersion}...`);
444
+ else if (!installedVer) log(` Engine not found. Downloading v${stableVersion}...`);
445
+ else if (compareVersions(stableVersion, installedVer) > 0) log(` Update available: v${installedVer} → v${stableVersion}`);
446
+ else log(` Reinstalling engine v${stableVersion}...`);
276
447
  log(` Requesting download URL...`);
277
448
  const dlRes = await fetch(`${downloadUrl}/${binaryName}`, { signal: AbortSignal.timeout(1e4) });
278
449
  if (!dlRes.ok) throw new Error(`Download request failed: ${dlRes.status} ${dlRes.statusText}`);
@@ -285,9 +456,72 @@ async function resolveEngine(options) {
285
456
  version: stableVersion,
286
457
  binaryName,
287
458
  expectedSha256: target.sha256,
459
+ expectedBinarySha256: target.binary_sha256,
460
+ envelope,
288
461
  log
289
462
  });
290
463
  }
464
+ /** Reads the live manifest and compares it with the installed engine, without installing. */
465
+ async function checkForUpdate(options) {
466
+ const { enginesDir, binaryName, cliVersion, now = Date.now } = options;
467
+ const compatible = (version) => isCompatibleEngine(cliVersion, version);
468
+ const installed = detectInstalledVersion(enginesDir, binaryName, compatible);
469
+ const trusted = await fetchTrustedManifest(options);
470
+ if ("unreachable" in trusted) throw new Error(trusted.unreachable);
471
+ const { manifest } = trusted;
472
+ writeLastCheck(enginesDir, now());
473
+ const latestCompatible = compatible(manifest.version);
474
+ return {
475
+ installed,
476
+ latest: manifest.version,
477
+ compatible: latestCompatible,
478
+ updateAvailable: latestCompatible && (installed === null || compareVersions(manifest.version, installed) > 0)
479
+ };
480
+ }
481
+ //#endregion
482
+ //#region src/update.ts
483
+ /**
484
+ * `jsorm update`: checks the live manifest now, installs the newest
485
+ * compatible engine (again when `force`), and removes every other installed
486
+ * version. Fails, removing nothing, when the live manifest cannot be reached
487
+ * or its engine belongs to another release line.
488
+ */
489
+ async function updateEngine(options) {
490
+ const { enginesDir, binaryName, cliVersion, log = () => {} } = options;
491
+ await resolveEngine({
492
+ ...options,
493
+ checkIntervalMs: 0,
494
+ skipUpdateCheck: false,
495
+ requireLiveCheck: true
496
+ });
497
+ const version = detectInstalledVersion(enginesDir, binaryName, (v) => isCompatibleEngine(cliVersion, v));
498
+ if (!version) throw new Error("No compatible engine is installed after the update");
499
+ const removed = pruneInstalledVersions(enginesDir, binaryName, version);
500
+ for (const old of removed) log(` ✓ Removed engine v${old}`);
501
+ log(` ✓ Engine v${version} is installed`);
502
+ return {
503
+ version,
504
+ removed
505
+ };
506
+ }
507
+ const UPDATE_FLAGS = ["--check", "--force"];
508
+ /** Validates `jsorm update` flags; returns an error message, or null when valid. */
509
+ function validateUpdateArgs(args) {
510
+ const unknown = args.filter((arg) => !UPDATE_FLAGS.includes(arg));
511
+ if (unknown.length > 0) return `Unknown option for jsorm update: ${unknown.join(" ")} (use --check or --force)`;
512
+ if (args.includes("--check") && args.includes("--force")) return "jsorm update: --check and --force cannot be combined";
513
+ return null;
514
+ }
515
+ /** `jsorm update --check`: reports the installed and latest engine without installing. */
516
+ async function reportUpdate(options) {
517
+ const { log = () => {} } = options;
518
+ const status = await checkForUpdate(options);
519
+ log(` Installed engine: ${status.installed ? `v${status.installed}` : "none"}`);
520
+ log(` Latest engine: v${status.latest}`);
521
+ if (!status.compatible) log(` ⚠ v${status.latest} needs a newer @jsorm/cli: npm install -D @jsorm/cli@latest`);
522
+ else if (status.updateAvailable) log(` Update available. Run: jsorm update`);
523
+ else log(` ✓ Up to date`);
524
+ }
291
525
  //#endregion
292
526
  //#region src/index.ts
293
527
  const __filename = fileURLToPath(import.meta.url);
@@ -351,32 +585,54 @@ async function main() {
351
585
  console.log(" configure Interactive project setup wizard");
352
586
  console.log(" gen Generate types, model types, and runtime files");
353
587
  console.log(" watch Watch model files and regenerate on changes");
588
+ console.log(" update Install the latest compatible engine and remove old ones");
589
+ console.log(" (--check: only report, --force: reinstall)");
354
590
  console.log();
355
591
  console.log("Options:");
356
592
  console.log(" --help, -h Print help");
357
593
  console.log(" --version, -v, -V Print version");
358
594
  console.log(" --dry-run Show what would be generated without writing files");
595
+ console.log();
596
+ console.log("Environment:");
597
+ console.log(" JSORM_NO_UPDATE_CHECK=1 Skip engine update checks while an engine is installed");
359
598
  return;
360
599
  }
361
- const localEnginePath = getLocalDevEnginePath();
362
- if (localEnginePath) {
363
- executeEngine(localEnginePath, args);
364
- return;
600
+ try {
601
+ ensureOwnerOnlyDir(dirname(ENGINES_DIR));
602
+ } catch (err) {
603
+ console.error(` ⚠ Could not restrict ${dirname(ENGINES_DIR)}: ${err instanceof Error ? err.message : err}`);
365
604
  }
366
- let enginePath;
605
+ const resolveOptions = {
606
+ enginesDir: ENGINES_DIR,
607
+ binaryName: getEngineBinaryName(),
608
+ manifestUrl: STABLE_MANIFEST_URL,
609
+ downloadUrl: STABLE_DOWNLOAD_URL,
610
+ cliVersion: readVersion(),
611
+ skipUpdateCheck: process.env.JSORM_NO_UPDATE_CHECK === "1",
612
+ updateCommand: `${cliRunner(detectPackageManager())} update`,
613
+ log: console.log
614
+ };
367
615
  try {
368
- enginePath = await resolveEngine({
369
- enginesDir: ENGINES_DIR,
370
- binaryName: getEngineBinaryName(),
371
- manifestUrl: STABLE_MANIFEST_URL,
372
- downloadUrl: STABLE_DOWNLOAD_URL,
373
- log: console.log
374
- });
616
+ if (args[0] === "update") {
617
+ const invalid = validateUpdateArgs(args.slice(1));
618
+ if (invalid) throw new Error(invalid);
619
+ if (args.includes("--check")) await reportUpdate(resolveOptions);
620
+ else await updateEngine({
621
+ ...resolveOptions,
622
+ force: args.includes("--force")
623
+ });
624
+ return;
625
+ }
626
+ const localEnginePath = getLocalDevEnginePath();
627
+ if (localEnginePath) {
628
+ executeEngine(localEnginePath, args);
629
+ return;
630
+ }
631
+ executeEngine(await resolveEngine(resolveOptions), args);
375
632
  } catch (err) {
376
633
  console.error(` ✖ ${err instanceof Error ? err.message : err}`);
377
634
  process.exit(1);
378
635
  }
379
- executeEngine(enginePath, args);
380
636
  }
381
637
  main();
382
638
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jsorm/cli",
3
3
  "type": "module",
4
- "version": "0.1.3",
4
+ "version": "0.2.1",
5
5
  "description": "CLI de JSORM",
6
6
  "license": "MPL-2.0",
7
7
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@jsorm/core": "1.0.3"
38
+ "@jsorm/core": "1.2.1"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^26.1.1",