@jsorm/cli 0.1.3 → 0.2.0

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 (2) hide show
  1. package/dist/index.mjs +180 -39
  2. package/package.json +2 -2
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";
@@ -155,7 +155,7 @@ async function fetchManifest(url, publicKey = getSpkiPublicKey()) {
155
155
  return parseSignedEnvelope(envelope, publicKey);
156
156
  }
157
157
  //#endregion
158
- //#region src/install.ts
158
+ //#region src/version.ts
159
159
  function compareVersions(a, b) {
160
160
  const pa = a.split(".").map(Number);
161
161
  const pb = b.split(".").map(Number);
@@ -167,15 +167,37 @@ function compareVersions(a, b) {
167
167
  }
168
168
  return 0;
169
169
  }
170
+ /** Release line of a version: "0.2" while the major is 0, "1" from 1.0 on. */
171
+ function releaseLine(version) {
172
+ const [major = "0", minor = "0"] = version.split(".");
173
+ return major === "0" ? `0.${minor}` : major;
174
+ }
175
+ /** The CLI only runs engines of its own release line. */
176
+ function isCompatibleEngine(cliVersion, engineVersion) {
177
+ return releaseLine(cliVersion) === releaseLine(engineVersion);
178
+ }
179
+ //#endregion
180
+ //#region src/install.ts
170
181
  function getVersionDir(enginesDir, version) {
171
182
  return join(enginesDir, `v${version}`);
172
183
  }
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;
184
+ /** Installed versions that hold `binaryName`, newest first, e.g. ["0.2.1", "0.2.0"]. */
185
+ function listInstalledVersions(enginesDir, binaryName) {
186
+ if (!existsSync(enginesDir)) return [];
187
+ return readdirSync(enginesDir).filter((d) => d.startsWith("v") && existsSync(join(enginesDir, d, binaryName))).map((d) => d.slice(1)).sort((a, b) => compareVersions(b, a));
188
+ }
189
+ /** Newest installed version accepted by `accept`, e.g. "0.2.1". */
190
+ function detectInstalledVersion(enginesDir, binaryName, accept = () => true) {
191
+ return listInstalledVersions(enginesDir, binaryName).find(accept) ?? null;
192
+ }
193
+ /** Removes every installed version directory except `keepVersion`. Returns the removed versions. */
194
+ function pruneInstalledVersions(enginesDir, binaryName, keepVersion) {
195
+ const removed = listInstalledVersions(enginesDir, binaryName).filter((v) => v !== keepVersion);
196
+ for (const version of removed) rmSync(getVersionDir(enginesDir, version), {
197
+ recursive: true,
198
+ force: true
199
+ });
200
+ return removed;
179
201
  }
180
202
  async function sha256File(filePath) {
181
203
  const hash = createHash("sha256");
@@ -241,38 +263,81 @@ async function installEngine(options) {
241
263
  }
242
264
  //#endregion
243
265
  //#region src/resolve.ts
266
+ const DAY_MS = 864e5;
267
+ const LAST_CHECK_FILE = ".last-check";
268
+ function readLastCheck(enginesDir) {
269
+ try {
270
+ const value = Number(readFileSync(join(enginesDir, LAST_CHECK_FILE), "utf-8").trim());
271
+ return Number.isFinite(value) ? value : null;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+ function writeLastCheck(enginesDir, time) {
277
+ try {
278
+ mkdirSync(enginesDir, { recursive: true });
279
+ writeFileSync(join(enginesDir, LAST_CHECK_FILE), String(time));
280
+ } catch {}
281
+ }
282
+ function incompatibleMessage(cliVersion, engineVersion) {
283
+ 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`;
284
+ }
285
+ async function fetchTrustedManifest(options) {
286
+ const { manifestUrl, publicKey = getSpkiPublicKey(), log = () => {} } = options;
287
+ log(` Fetching manifest from ${manifestUrl}...`);
288
+ const result = await fetchManifest(manifestUrl, publicKey);
289
+ if (result.status === "unreachable") return { unreachable: result.reason };
290
+ if (result.status === "invalid") throw new Error(result.reason);
291
+ log(` ✓ Manifest signature verified`);
292
+ log(` ✓ Manifest v${result.manifest.version} (${Object.keys(result.manifest.targets).length} targets)`);
293
+ return result.manifest;
294
+ }
244
295
  /**
245
296
  * 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.
297
+ * when it is current, or a freshly installed stable release. Only engines of
298
+ * the CLI's release line are used. When the server cannot be reached, an
299
+ * installed engine is used as is. Throws when no engine can be provided or
300
+ * when the server's answer cannot be trusted.
249
301
  */
250
302
  async function resolveEngine(options) {
251
- const { enginesDir, binaryName, manifestUrl, downloadUrl, publicKey = getSpkiPublicKey(), log = () => {} } = options;
252
- const installedVer = detectInstalledVersion(enginesDir, binaryName);
303
+ const { enginesDir, binaryName, downloadUrl, cliVersion, log = () => {}, checkIntervalMs = DAY_MS, skipUpdateCheck = false, force = false, requireLiveCheck = false, now = Date.now } = options;
304
+ const compatible = (version) => isCompatibleEngine(cliVersion, version);
305
+ const installedVer = detectInstalledVersion(enginesDir, binaryName, compatible);
253
306
  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}`);
307
+ if (installedPath && !force && !requireLiveCheck) {
308
+ if (skipUpdateCheck) return installedPath;
309
+ const lastCheck = readLastCheck(enginesDir);
310
+ const elapsed = lastCheck === null ? Infinity : now() - lastCheck;
311
+ if (checkIntervalMs > 0 && elapsed >= 0 && elapsed < checkIntervalMs) return installedPath;
312
+ }
313
+ const manifest = await fetchTrustedManifest(options);
314
+ if ("unreachable" in manifest) {
315
+ if (installedPath && !requireLiveCheck) {
316
+ log(` ⚠ ${manifest.unreachable}`);
259
317
  log(` ⚠ Using installed engine v${installedVer} without checking for updates`);
260
318
  return installedPath;
261
319
  }
262
- throw new Error(`${result.reason}\n ✖ Failed to fetch manifest.`);
320
+ throw new Error(`${manifest.unreachable}\n ✖ Failed to fetch manifest.`);
263
321
  }
264
- if (result.status === "invalid") throw new Error(result.reason);
265
- const { manifest } = result;
322
+ writeLastCheck(enginesDir, now());
266
323
  const stableVersion = manifest.version;
267
- log(` ✓ Manifest signature verified`);
268
- log(` ✓ Manifest v${stableVersion} (${Object.keys(manifest.targets).length} targets)`);
324
+ if (!compatible(stableVersion)) {
325
+ if (installedPath && !requireLiveCheck) {
326
+ log(` ⚠ ${incompatibleMessage(cliVersion, stableVersion)}`);
327
+ log(` ⚠ Using installed engine v${installedVer}`);
328
+ return installedPath;
329
+ }
330
+ throw new Error(incompatibleMessage(cliVersion, stableVersion));
331
+ }
269
332
  const target = manifest.targets[binaryName];
270
333
  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) {
334
+ if (installedPath && installedVer && !force && compareVersions(stableVersion, installedVer) <= 0) {
272
335
  log(` ✓ Engine v${installedVer} is up to date (stable: v${stableVersion})`);
273
336
  return installedPath;
274
337
  }
275
- log(installedVer ? ` Update available: v${installedVer} → v${stableVersion}` : ` Engine not found. Downloading v${stableVersion}...`);
338
+ if (!installedVer) log(` Engine not found. Downloading v${stableVersion}...`);
339
+ else if (compareVersions(stableVersion, installedVer) > 0) log(` Update available: v${installedVer} → v${stableVersion}`);
340
+ else log(` Reinstalling engine v${stableVersion}...`);
276
341
  log(` Requesting download URL...`);
277
342
  const dlRes = await fetch(`${downloadUrl}/${binaryName}`, { signal: AbortSignal.timeout(1e4) });
278
343
  if (!dlRes.ok) throw new Error(`Download request failed: ${dlRes.status} ${dlRes.statusText}`);
@@ -288,6 +353,66 @@ async function resolveEngine(options) {
288
353
  log
289
354
  });
290
355
  }
356
+ /** Reads the live manifest and compares it with the installed engine, without installing. */
357
+ async function checkForUpdate(options) {
358
+ const { enginesDir, binaryName, cliVersion, now = Date.now } = options;
359
+ const compatible = (version) => isCompatibleEngine(cliVersion, version);
360
+ const installed = detectInstalledVersion(enginesDir, binaryName, compatible);
361
+ const manifest = await fetchTrustedManifest(options);
362
+ if ("unreachable" in manifest) throw new Error(manifest.unreachable);
363
+ writeLastCheck(enginesDir, now());
364
+ const latestCompatible = compatible(manifest.version);
365
+ return {
366
+ installed,
367
+ latest: manifest.version,
368
+ compatible: latestCompatible,
369
+ updateAvailable: latestCompatible && (installed === null || compareVersions(manifest.version, installed) > 0)
370
+ };
371
+ }
372
+ //#endregion
373
+ //#region src/update.ts
374
+ /**
375
+ * `jsorm update`: checks the live manifest now, installs the newest
376
+ * compatible engine (again when `force`), and removes every other installed
377
+ * version. Fails, removing nothing, when the live manifest cannot be reached
378
+ * or its engine belongs to another release line.
379
+ */
380
+ async function updateEngine(options) {
381
+ const { enginesDir, binaryName, cliVersion, log = () => {} } = options;
382
+ await resolveEngine({
383
+ ...options,
384
+ checkIntervalMs: 0,
385
+ skipUpdateCheck: false,
386
+ requireLiveCheck: true
387
+ });
388
+ const version = detectInstalledVersion(enginesDir, binaryName, (v) => isCompatibleEngine(cliVersion, v));
389
+ if (!version) throw new Error("No compatible engine is installed after the update");
390
+ const removed = pruneInstalledVersions(enginesDir, binaryName, version);
391
+ for (const old of removed) log(` ✓ Removed engine v${old}`);
392
+ log(` ✓ Engine v${version} is installed`);
393
+ return {
394
+ version,
395
+ removed
396
+ };
397
+ }
398
+ const UPDATE_FLAGS = ["--check", "--force"];
399
+ /** Validates `jsorm update` flags; returns an error message, or null when valid. */
400
+ function validateUpdateArgs(args) {
401
+ const unknown = args.filter((arg) => !UPDATE_FLAGS.includes(arg));
402
+ if (unknown.length > 0) return `Unknown option for jsorm update: ${unknown.join(" ")} (use --check or --force)`;
403
+ if (args.includes("--check") && args.includes("--force")) return "jsorm update: --check and --force cannot be combined";
404
+ return null;
405
+ }
406
+ /** `jsorm update --check`: reports the installed and latest engine without installing. */
407
+ async function reportUpdate(options) {
408
+ const { log = () => {} } = options;
409
+ const status = await checkForUpdate(options);
410
+ log(` Installed engine: ${status.installed ? `v${status.installed}` : "none"}`);
411
+ log(` Latest engine: v${status.latest}`);
412
+ if (!status.compatible) log(` ⚠ v${status.latest} needs a newer @jsorm/cli: npm install -D @jsorm/cli@latest`);
413
+ else if (status.updateAvailable) log(` Update available. Run: jsorm update`);
414
+ else log(` ✓ Up to date`);
415
+ }
291
416
  //#endregion
292
417
  //#region src/index.ts
293
418
  const __filename = fileURLToPath(import.meta.url);
@@ -351,32 +476,48 @@ async function main() {
351
476
  console.log(" configure Interactive project setup wizard");
352
477
  console.log(" gen Generate types, model types, and runtime files");
353
478
  console.log(" watch Watch model files and regenerate on changes");
479
+ console.log(" update Install the latest compatible engine and remove old ones");
480
+ console.log(" (--check: only report, --force: reinstall)");
354
481
  console.log();
355
482
  console.log("Options:");
356
483
  console.log(" --help, -h Print help");
357
484
  console.log(" --version, -v, -V Print version");
358
485
  console.log(" --dry-run Show what would be generated without writing files");
486
+ console.log();
487
+ console.log("Environment:");
488
+ console.log(" JSORM_NO_UPDATE_CHECK=1 Skip engine update checks while an engine is installed");
359
489
  return;
360
490
  }
361
- const localEnginePath = getLocalDevEnginePath();
362
- if (localEnginePath) {
363
- executeEngine(localEnginePath, args);
364
- return;
365
- }
366
- let enginePath;
491
+ const resolveOptions = {
492
+ enginesDir: ENGINES_DIR,
493
+ binaryName: getEngineBinaryName(),
494
+ manifestUrl: STABLE_MANIFEST_URL,
495
+ downloadUrl: STABLE_DOWNLOAD_URL,
496
+ cliVersion: readVersion(),
497
+ skipUpdateCheck: process.env.JSORM_NO_UPDATE_CHECK === "1",
498
+ log: console.log
499
+ };
367
500
  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
- });
501
+ if (args[0] === "update") {
502
+ const invalid = validateUpdateArgs(args.slice(1));
503
+ if (invalid) throw new Error(invalid);
504
+ if (args.includes("--check")) await reportUpdate(resolveOptions);
505
+ else await updateEngine({
506
+ ...resolveOptions,
507
+ force: args.includes("--force")
508
+ });
509
+ return;
510
+ }
511
+ const localEnginePath = getLocalDevEnginePath();
512
+ if (localEnginePath) {
513
+ executeEngine(localEnginePath, args);
514
+ return;
515
+ }
516
+ executeEngine(await resolveEngine(resolveOptions), args);
375
517
  } catch (err) {
376
518
  console.error(` ✖ ${err instanceof Error ? err.message : err}`);
377
519
  process.exit(1);
378
520
  }
379
- executeEngine(enginePath, args);
380
521
  }
381
522
  main();
382
523
  //#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.0",
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.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^26.1.1",