@jsorm/cli 0.2.0 → 0.2.2

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 +202 -87
  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
@@ -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,76 +94,6 @@ function detectFramework() {
85
94
  return "none";
86
95
  }
87
96
  //#endregion
88
- //#region src/manifest.ts
89
- const EMBEDDED_PUBLIC_KEY_B64 = "6AUQMvF7jBV+S8yv9K8hnnoYzIznQw4nf5x5LAuEsfM=";
90
- function getSpkiPublicKey(rawKeyB64 = EMBEDDED_PUBLIC_KEY_B64) {
91
- const rawKey = Buffer.from(rawKeyB64, "base64");
92
- const spkiDer = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), rawKey]);
93
- return createPublicKey({
94
- key: spkiDer,
95
- format: "der",
96
- type: "spki"
97
- });
98
- }
99
- function invalid(reason) {
100
- return {
101
- status: "invalid",
102
- reason
103
- };
104
- }
105
- /**
106
- * Verifies the envelope `{ success, signature, signed_payload }` and returns
107
- * the manifest decoded from `signed_payload`, the exact bytes that were signed.
108
- * The unsigned `envelope.manifest` copy is never trusted.
109
- */
110
- function parseSignedEnvelope(envelope, publicKey) {
111
- if (!envelope || typeof envelope !== "object") return invalid("Invalid manifest response");
112
- const { success, signature, signed_payload } = envelope;
113
- if (success !== true) return invalid("Invalid manifest response");
114
- if (typeof signature !== "string" || typeof signed_payload !== "string") return invalid("Manifest signature or signed payload missing. Refusing to continue without signature verification.");
115
- const sigBytes = Buffer.from(signature, "base64");
116
- if (sigBytes.length !== 64) return invalid(`Invalid signature length: ${sigBytes.length} (expected 64)`);
117
- const signedBytes = Buffer.from(signed_payload, "base64");
118
- let valid = false;
119
- try {
120
- valid = verify(null, signedBytes, publicKey, sigBytes);
121
- } catch {}
122
- if (!valid) return invalid("Manifest signature verification failed. The manifest may have been tampered with.");
123
- let manifest;
124
- try {
125
- manifest = JSON.parse(signedBytes.toString("utf-8"));
126
- } catch {
127
- return invalid("Signed manifest is not valid JSON");
128
- }
129
- if (typeof manifest?.version !== "string" || !manifest.targets || typeof manifest.targets !== "object") return invalid("Invalid manifest format");
130
- return {
131
- status: "ok",
132
- manifest
133
- };
134
- }
135
- async function fetchManifest(url, publicKey = getSpkiPublicKey()) {
136
- let res;
137
- try {
138
- res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
139
- } catch (err) {
140
- return {
141
- status: "unreachable",
142
- reason: `Manifest fetch error: ${err instanceof Error ? err.message : err}`
143
- };
144
- }
145
- if (!res.ok) return {
146
- status: "unreachable",
147
- reason: `Manifest fetch failed: ${res.status} ${res.statusText}`
148
- };
149
- let envelope;
150
- try {
151
- envelope = await res.json();
152
- } catch {
153
- return invalid("Invalid manifest response");
154
- }
155
- return parseSignedEnvelope(envelope, publicKey);
156
- }
157
- //#endregion
158
97
  //#region src/version.ts
159
98
  function compareVersions(a, b) {
160
99
  const pa = a.split(".").map(Number);
@@ -181,6 +120,22 @@ function isCompatibleEngine(cliVersion, engineVersion) {
181
120
  function getVersionDir(enginesDir, version) {
182
121
  return join(enginesDir, `v${version}`);
183
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
+ }
184
139
  /** Installed versions that hold `binaryName`, newest first, e.g. ["0.2.1", "0.2.0"]. */
185
140
  function listInstalledVersions(enginesDir, binaryName) {
186
141
  if (!existsSync(enginesDir)) return [];
@@ -229,8 +184,8 @@ function extractTarGz(tarPath, destDir) {
229
184
  * Returns the installed binary path.
230
185
  */
231
186
  async function installEngine(options) {
232
- const { url, enginesDir, version, binaryName, expectedSha256, log = () => {} } = options;
233
- mkdirSync(enginesDir, { recursive: true });
187
+ const { url, enginesDir, version, binaryName, expectedSha256, expectedBinarySha256, envelope, log = () => {} } = options;
188
+ ensureOwnerOnlyDir(enginesDir);
234
189
  const tempDir = mkdtempSync(join(enginesDir, ".tmp-"));
235
190
  try {
236
191
  const tarPath = join(tempDir, "engine.tar.gz");
@@ -247,10 +202,15 @@ async function installEngine(options) {
247
202
  extractTarGz(tarPath, extractDir);
248
203
  const extractedPath = join(extractDir, "jsorm");
249
204
  if (!existsSync(extractedPath)) throw new Error(`Archive does not contain the "jsorm" binary`);
250
- if (process.platform !== "win32") chmodSync(extractedPath, 493);
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 });
251
210
  const destDir = getVersionDir(enginesDir, version);
252
211
  const destPath = join(destDir, binaryName);
253
- mkdirSync(destDir, { recursive: true });
212
+ ensureOwnerOnlyDir(destDir);
213
+ renameSync(envelopeTemp, getEnvelopePath(enginesDir, version, binaryName));
254
214
  renameSync(extractedPath, destPath);
255
215
  log(` ✓ Engine installed: ${destPath}`);
256
216
  return destPath;
@@ -262,6 +222,124 @@ async function installEngine(options) {
262
222
  }
263
223
  }
264
224
  //#endregion
225
+ //#region src/manifest.ts
226
+ const EMBEDDED_PUBLIC_KEY_B64 = "6AUQMvF7jBV+S8yv9K8hnnoYzIznQw4nf5x5LAuEsfM=";
227
+ function getSpkiPublicKey(rawKeyB64 = EMBEDDED_PUBLIC_KEY_B64) {
228
+ const rawKey = Buffer.from(rawKeyB64, "base64");
229
+ const spkiDer = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), rawKey]);
230
+ return createPublicKey({
231
+ key: spkiDer,
232
+ format: "der",
233
+ type: "spki"
234
+ });
235
+ }
236
+ function invalid(reason) {
237
+ return {
238
+ status: "invalid",
239
+ reason
240
+ };
241
+ }
242
+ /**
243
+ * Verifies the envelope `{ success, signature, signed_payload }` and returns
244
+ * the manifest decoded from `signed_payload`, the exact bytes that were signed.
245
+ * The unsigned `envelope.manifest` copy is never trusted.
246
+ */
247
+ function parseSignedEnvelope(envelope, publicKey) {
248
+ if (!envelope || typeof envelope !== "object") return invalid("Invalid manifest response");
249
+ const { success, signature, signed_payload } = envelope;
250
+ if (success !== true) return invalid("Invalid manifest response");
251
+ if (typeof signature !== "string" || typeof signed_payload !== "string") return invalid("Manifest signature or signed payload missing. Refusing to continue without signature verification.");
252
+ const sigBytes = Buffer.from(signature, "base64");
253
+ if (sigBytes.length !== 64) return invalid(`Invalid signature length: ${sigBytes.length} (expected 64)`);
254
+ const signedBytes = Buffer.from(signed_payload, "base64");
255
+ let valid = false;
256
+ try {
257
+ valid = verify(null, signedBytes, publicKey, sigBytes);
258
+ } catch {}
259
+ if (!valid) return invalid("Manifest signature verification failed. The manifest may have been tampered with.");
260
+ let manifest;
261
+ try {
262
+ manifest = JSON.parse(signedBytes.toString("utf-8"));
263
+ } catch {
264
+ return invalid("Signed manifest is not valid JSON");
265
+ }
266
+ if (typeof manifest?.version !== "string" || !manifest.targets || typeof manifest.targets !== "object") return invalid("Invalid manifest format");
267
+ return {
268
+ status: "ok",
269
+ manifest,
270
+ envelope: {
271
+ success: true,
272
+ signature,
273
+ signed_payload
274
+ }
275
+ };
276
+ }
277
+ async function fetchManifest(url, publicKey = getSpkiPublicKey()) {
278
+ let res;
279
+ try {
280
+ res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
281
+ } catch (err) {
282
+ return {
283
+ status: "unreachable",
284
+ reason: `Manifest fetch error: ${err instanceof Error ? err.message : err}`
285
+ };
286
+ }
287
+ if (!res.ok) return {
288
+ status: "unreachable",
289
+ reason: `Manifest fetch failed: ${res.status} ${res.statusText}`
290
+ };
291
+ let envelope;
292
+ try {
293
+ envelope = await res.json();
294
+ } catch {
295
+ return invalid("Invalid manifest response");
296
+ }
297
+ return parseSignedEnvelope(envelope, publicKey);
298
+ }
299
+ //#endregion
300
+ //#region src/verify.ts
301
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
302
+ /**
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.
306
+ */
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;
314
+ try {
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
+ };
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 };
341
+ }
342
+ //#endregion
265
343
  //#region src/resolve.ts
266
344
  const DAY_MS = 864e5;
267
345
  const LAST_CHECK_FILE = ".last-check";
@@ -290,19 +368,40 @@ async function fetchTrustedManifest(options) {
290
368
  if (result.status === "invalid") throw new Error(result.reason);
291
369
  log(` ✓ Manifest signature verified`);
292
370
  log(` ✓ Manifest v${result.manifest.version} (${Object.keys(result.manifest.targets).length} targets)`);
293
- return result.manifest;
371
+ return {
372
+ manifest: result.manifest,
373
+ envelope: result.envelope
374
+ };
294
375
  }
295
376
  /**
296
377
  * Returns the path of an engine binary that is safe to run: the installed one
297
378
  * 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.
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.
301
383
  */
302
384
  async function resolveEngine(options) {
303
- const { enginesDir, binaryName, downloadUrl, cliVersion, log = () => {}, checkIntervalMs = DAY_MS, skipUpdateCheck = false, force = false, requireLiveCheck = false, now = Date.now } = options;
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;
304
386
  const compatible = (version) => isCompatibleEngine(cliVersion, version);
305
- const installedVer = detectInstalledVersion(enginesDir, binaryName, compatible);
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
+ }
306
405
  const installedPath = installedVer ? join(getVersionDir(enginesDir, installedVer), binaryName) : null;
307
406
  if (installedPath && !force && !requireLiveCheck) {
308
407
  if (skipUpdateCheck) return installedPath;
@@ -310,15 +409,20 @@ async function resolveEngine(options) {
310
409
  const elapsed = lastCheck === null ? Infinity : now() - lastCheck;
311
410
  if (checkIntervalMs > 0 && elapsed >= 0 && elapsed < checkIntervalMs) return installedPath;
312
411
  }
313
- const manifest = await fetchTrustedManifest(options);
314
- if ("unreachable" in manifest) {
412
+ const trusted = await fetchTrustedManifest({
413
+ ...options,
414
+ publicKey
415
+ });
416
+ if ("unreachable" in trusted) {
315
417
  if (installedPath && !requireLiveCheck) {
316
- log(` ⚠ ${manifest.unreachable}`);
418
+ log(` ⚠ ${trusted.unreachable}`);
317
419
  log(` ⚠ Using installed engine v${installedVer} without checking for updates`);
318
420
  return installedPath;
319
421
  }
320
- throw new Error(`${manifest.unreachable}\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.`);
321
424
  }
425
+ const { manifest, envelope } = trusted;
322
426
  writeLastCheck(enginesDir, now());
323
427
  const stableVersion = manifest.version;
324
428
  if (!compatible(stableVersion)) {
@@ -331,11 +435,13 @@ async function resolveEngine(options) {
331
435
  }
332
436
  const target = manifest.targets[binaryName];
333
437
  if (!target) throw new Error(`No binary available for ${binaryName}\n Available: ${Object.keys(manifest.targets).join(", ")}`);
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.`);
334
439
  if (installedPath && installedVer && !force && compareVersions(stableVersion, installedVer) <= 0) {
335
440
  log(` ✓ Engine v${installedVer} is up to date (stable: v${stableVersion})`);
336
441
  return installedPath;
337
442
  }
338
- if (!installedVer) log(` 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}...`);
339
445
  else if (compareVersions(stableVersion, installedVer) > 0) log(` Update available: v${installedVer} → v${stableVersion}`);
340
446
  else log(` Reinstalling engine v${stableVersion}...`);
341
447
  log(` Requesting download URL...`);
@@ -350,6 +456,8 @@ async function resolveEngine(options) {
350
456
  version: stableVersion,
351
457
  binaryName,
352
458
  expectedSha256: target.sha256,
459
+ expectedBinarySha256: target.binary_sha256,
460
+ envelope,
353
461
  log
354
462
  });
355
463
  }
@@ -358,8 +466,9 @@ async function checkForUpdate(options) {
358
466
  const { enginesDir, binaryName, cliVersion, now = Date.now } = options;
359
467
  const compatible = (version) => isCompatibleEngine(cliVersion, version);
360
468
  const installed = detectInstalledVersion(enginesDir, binaryName, compatible);
361
- const manifest = await fetchTrustedManifest(options);
362
- if ("unreachable" in manifest) throw new Error(manifest.unreachable);
469
+ const trusted = await fetchTrustedManifest(options);
470
+ if ("unreachable" in trusted) throw new Error(trusted.unreachable);
471
+ const { manifest } = trusted;
363
472
  writeLastCheck(enginesDir, now());
364
473
  const latestCompatible = compatible(manifest.version);
365
474
  return {
@@ -488,6 +597,11 @@ async function main() {
488
597
  console.log(" JSORM_NO_UPDATE_CHECK=1 Skip engine update checks while an engine is installed");
489
598
  return;
490
599
  }
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}`);
604
+ }
491
605
  const resolveOptions = {
492
606
  enginesDir: ENGINES_DIR,
493
607
  binaryName: getEngineBinaryName(),
@@ -495,6 +609,7 @@ async function main() {
495
609
  downloadUrl: STABLE_DOWNLOAD_URL,
496
610
  cliVersion: readVersion(),
497
611
  skipUpdateCheck: process.env.JSORM_NO_UPDATE_CHECK === "1",
612
+ updateCommand: `${cliRunner(detectPackageManager())} update`,
498
613
  log: console.log
499
614
  };
500
615
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jsorm/cli",
3
3
  "type": "module",
4
- "version": "0.2.0",
4
+ "version": "0.2.2",
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.2.0"
38
+ "@jsorm/core": "1.2.2"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^26.1.1",