@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.
- package/README.md +18 -1
- package/dist/index.mjs +367 -111
- 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/
|
|
159
|
-
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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,
|
|
252
|
-
const
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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(`${
|
|
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
|
-
|
|
265
|
-
|
|
425
|
+
const { manifest, envelope } = trusted;
|
|
426
|
+
writeLastCheck(enginesDir, now());
|
|
266
427
|
const stableVersion = manifest.version;
|
|
267
|
-
|
|
268
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
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.
|
|
38
|
+
"@jsorm/core": "1.2.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/node": "^26.1.1",
|