@dynomate/cli 0.0.10 → 0.1.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.
- package/README.md +27 -23
- package/keys/dynomate-cli-2026-01.pem +11 -0
- package/lib/archive.js +11 -2
- package/lib/compatibility.js +54 -0
- package/lib/install.js +20 -34
- package/lib/launcher.js +1 -1
- package/lib/manifest.js +91 -0
- package/lib/network.js +37 -14
- package/lib/release.js +10 -25
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# @dynomate/cli
|
|
2
2
|
|
|
3
|
-
This package installs the native Dynomate command-line client.
|
|
4
|
-
|
|
3
|
+
This package installs the native Dynomate command-line client. For current
|
|
4
|
+
installation and download instructions, see
|
|
5
|
+
[Dynomate CLI installation](https://dynomate.io/docs/cli/installation/).
|
|
6
|
+
Test releases use the npm `next` tag:
|
|
5
7
|
|
|
6
8
|
```bash
|
|
7
|
-
npm install --global @dynomate/cli@next
|
|
9
|
+
npm install --global --ignore-scripts=false --allow-scripts=@dynomate/cli @dynomate/cli@next
|
|
8
10
|
dynomate-cli --version
|
|
9
11
|
```
|
|
10
12
|
|
|
@@ -18,26 +20,28 @@ Run the same install command again to upgrade. The package exposes only
|
|
|
18
20
|
- 64-bit GNU/Linux (`x64`) with glibc 2.35 or newer (Ubuntu 22.04 baseline)
|
|
19
21
|
- 64-bit Windows (`x64`)
|
|
20
22
|
|
|
21
|
-
Installation downloads the archive for the current platform from
|
|
22
|
-
release
|
|
23
|
-
`
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
23
|
+
Installation downloads the archive for the current platform from an immutable
|
|
24
|
+
Dynomate release. Before trusting an archive hash, the installer verifies that
|
|
25
|
+
release's detached `manifest.sig`
|
|
26
|
+
over the exact `manifest.json` bytes with the public key packaged in this npm
|
|
27
|
+
package. It validates the authenticated target, filename, size, and SHA-256,
|
|
28
|
+
then atomically installs only the `dynomate-cli` executable.
|
|
29
|
+
|
|
30
|
+
Install scripts are required. npm 12 blocks dependency lifecycle scripts unless
|
|
31
|
+
they are explicitly approved. A saved `ignore-scripts=true` setting also skips
|
|
32
|
+
them unless it is overridden for this install. If the native executable is
|
|
33
|
+
missing, the launcher exits with status 1, performs no network request, and
|
|
34
|
+
points to the installation guide. The installer needs HTTPS access to the
|
|
35
|
+
Dynomate release service during installation.
|
|
36
|
+
|
|
37
|
+
For other installation methods, use the
|
|
38
|
+
[CLI installation guide](https://dynomate.io/docs/cli/installation/). The release
|
|
39
|
+
host is used by the installer to fetch authenticated native archives.
|
|
40
|
+
|
|
41
|
+
Test releases stay on the npm `next` dist-tag. A release operator promotes a
|
|
42
|
+
validated version to `latest` only after the native release and npm package
|
|
43
|
+
have passed the release runbook; users should not assume `latest` points at a
|
|
44
|
+
test release.
|
|
41
45
|
|
|
42
46
|
## License
|
|
43
47
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-----BEGIN PUBLIC KEY-----
|
|
2
|
+
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAjUtWOzCuBpSntIpltpQs
|
|
3
|
+
0cXxDyWXL8Y4jAlTYy8iiRvy07e9046rOqGoff90EEslCgQqLjGhHtQRWjQrnnlC
|
|
4
|
+
GBHf+DPR0V0Q9QI0hZcnOFdWAxg79eVFwNc/RexhU6Zw8KqsvrDkREmSkPSK0MTl
|
|
5
|
+
2nrw/JyKSH0QmTdAonIuZ3rp0oSVq66VQALAJ8/q0qpO2mok+JH4DnEAvORfvu6n
|
|
6
|
+
oQxaL0191I3uVuAOiOcAgpWGhTpfPTo0RmKdtEyzBYRFgetz+On8OaOky+M4GtME
|
|
7
|
+
8c65KbEDXq+KReMUmv69JT3mHawH8ZLG3KHI4Kg52ZbQL9B+beWvitWjMaWXuwAv
|
|
8
|
+
IL8A5nNzhziKjyFdnK4sW8u8LSKnPy4rh57drzFDxWmGjk4IsS1332zx3yy3JuO9
|
|
9
|
+
ZZL9CGpWRyKLeGGMZhLVRtsTyMhHhgIeamQITxTpU33Ir7oQV8zHsc7K/IA8j/RG
|
|
10
|
+
z0dXsEvS43Fu68A6kXPrdo/AGwuYv41Y7i9TnLGWrkdFAgMBAAE=
|
|
11
|
+
-----END PUBLIC KEY-----
|
package/lib/archive.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { gunzipSync, inflateRawSync } from "node:zlib";
|
|
3
3
|
|
|
4
|
+
const MAX_EXECUTABLE_BYTES = 512 * 1024 * 1024;
|
|
5
|
+
const MAX_TAR_OUTPUT_BYTES = MAX_EXECUTABLE_BYTES + 1024 * 1024;
|
|
6
|
+
|
|
4
7
|
function basename(path) {
|
|
5
8
|
return path.replaceAll("\\", "/").split("/").filter(Boolean).at(-1);
|
|
6
9
|
}
|
|
@@ -17,7 +20,7 @@ function oneExecutable(matches, executableName) {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
function extractTarGz(buffer, executableName) {
|
|
20
|
-
const tar = gunzipSync(buffer);
|
|
23
|
+
const tar = gunzipSync(buffer, { maxOutputLength: MAX_TAR_OUTPUT_BYTES });
|
|
21
24
|
const matches = [];
|
|
22
25
|
for (let offset = 0; offset + 512 <= tar.length;) {
|
|
23
26
|
const header = tar.subarray(offset, offset + 512);
|
|
@@ -41,6 +44,7 @@ function extractTarGz(buffer, executableName) {
|
|
|
41
44
|
if (!Number.isSafeInteger(size) || dataEnd > tar.length) throw new Error("Archive contains a truncated tar entry");
|
|
42
45
|
if (basename(name) === executableName) {
|
|
43
46
|
if (type !== 0 && type !== 48) throw new Error(`Archive entry ${name} is not a regular file`);
|
|
47
|
+
if (size > MAX_EXECUTABLE_BYTES) throw new Error(`Archive entry ${name} exceeds the extraction limit`);
|
|
44
48
|
matches.push(Buffer.from(tar.subarray(dataStart, dataEnd)));
|
|
45
49
|
}
|
|
46
50
|
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
@@ -83,6 +87,7 @@ function extractZip(buffer, executableName) {
|
|
|
83
87
|
if (basename(name) === executableName) {
|
|
84
88
|
if (flags & 1) throw new Error("Encrypted ZIP entries are not supported");
|
|
85
89
|
if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset === 0xffffffff) throw new Error("ZIP64 archives are not supported");
|
|
90
|
+
if (uncompressedSize === 0 || uncompressedSize > MAX_EXECUTABLE_BYTES) throw new Error(`ZIP entry ${name} exceeds the extraction limit`);
|
|
86
91
|
if (localOffset + 30 > buffer.length || buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error("ZIP local header is invalid");
|
|
87
92
|
const localNameLength = buffer.readUInt16LE(localOffset + 26);
|
|
88
93
|
const localExtraLength = buffer.readUInt16LE(localOffset + 28);
|
|
@@ -90,7 +95,11 @@ function extractZip(buffer, executableName) {
|
|
|
90
95
|
const end = start + compressedSize;
|
|
91
96
|
if (end > buffer.length) throw new Error("Archive contains a truncated ZIP entry");
|
|
92
97
|
const compressed = buffer.subarray(start, end);
|
|
93
|
-
const data = method === 0
|
|
98
|
+
const data = method === 0
|
|
99
|
+
? Buffer.from(compressed)
|
|
100
|
+
: method === 8
|
|
101
|
+
? inflateRawSync(compressed, { maxOutputLength: MAX_EXECUTABLE_BYTES })
|
|
102
|
+
: null;
|
|
94
103
|
if (!data) throw new Error(`Unsupported ZIP compression method: ${method}`);
|
|
95
104
|
if (data.length !== uncompressedSize || crc32(data) !== expectedCrc) throw new Error(`ZIP entry ${name} failed integrity validation`);
|
|
96
105
|
matches.push(data);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { release as osRelease } from "node:os";
|
|
3
|
+
|
|
4
|
+
const MINIMUM_GLIBC = [2, 35];
|
|
5
|
+
|
|
6
|
+
function parseVersion(value) {
|
|
7
|
+
const match = /(?:^|\s)(\d+)\.(\d+)(?:\D|$)/.exec(value || "");
|
|
8
|
+
return match ? [Number(match[1]), Number(match[2])] : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function atLeast(actual, minimum) {
|
|
12
|
+
return actual[0] > minimum[0] || (actual[0] === minimum[0] && actual[1] >= minimum[1]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function detectLinuxLibc({ report, run = execFileSync } = {}) {
|
|
16
|
+
const runtimeReport = report === undefined ? process.report?.getReport?.() : report;
|
|
17
|
+
const runtimeVersion = runtimeReport?.header?.glibcVersionRuntime;
|
|
18
|
+
const sharedObjects = runtimeReport?.sharedObjects || [];
|
|
19
|
+
if (sharedObjects.some((entry) => /(?:^|\/)ld-musl[^/]*\.so/i.test(entry))) return { family: "musl" };
|
|
20
|
+
if (runtimeVersion) return { family: "glibc", version: runtimeVersion };
|
|
21
|
+
|
|
22
|
+
for (const [command, args] of [["getconf", ["GNU_LIBC_VERSION"]], ["ldd", ["--version"]]]) {
|
|
23
|
+
try {
|
|
24
|
+
const output = String(run(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }));
|
|
25
|
+
if (/musl/i.test(output)) return { family: "musl" };
|
|
26
|
+
const version = parseVersion(output);
|
|
27
|
+
if (version && /glibc|gnu libc|gnu c library|^glibc/i.test(output.trim())) {
|
|
28
|
+
return { family: "glibc", version: version.join(".") };
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Try the next conservative probe.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { family: "unknown" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function assertPlatformCompatibility({ platform = process.platform, arch = process.arch, report, run, kernelRelease = osRelease() } = {}) {
|
|
38
|
+
if (platform === "darwin") {
|
|
39
|
+
if (!["arm64", "x64"].includes(arch)) throw new Error(`Unsupported platform: ${platform}/${arch}`);
|
|
40
|
+
const darwinMajor = Number.parseInt(kernelRelease.split(".")[0], 10);
|
|
41
|
+
if (!Number.isInteger(darwinMajor) || darwinMajor < 20) throw new Error("Dynomate CLI requires macOS 11 or newer.");
|
|
42
|
+
} else if (platform === "linux") {
|
|
43
|
+
if (arch !== "x64") throw new Error(`Unsupported platform: ${platform}/${arch}`);
|
|
44
|
+
const libc = detectLinuxLibc({ report, run });
|
|
45
|
+
if (libc.family === "musl") throw new Error("Dynomate CLI does not support musl Linux (including Alpine); glibc 2.35 or newer is required.");
|
|
46
|
+
const version = libc.family === "glibc" ? parseVersion(libc.version) : null;
|
|
47
|
+
if (!version) throw new Error("Could not identify the system C library; Dynomate CLI requires glibc 2.35 or newer.");
|
|
48
|
+
if (!atLeast(version, MINIMUM_GLIBC)) throw new Error(`Dynomate CLI requires glibc 2.35 or newer; detected ${libc.version}.`);
|
|
49
|
+
} else if (platform === "win32") {
|
|
50
|
+
if (arch !== "x64") throw new Error(`Unsupported platform: ${platform}/${arch}`);
|
|
51
|
+
} else {
|
|
52
|
+
throw new Error(`Unsupported platform: ${platform}/${arch}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/lib/install.js
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
import { chmod, mkdir, mkdtemp, rename, rm
|
|
1
|
+
import { chmod, mkdir, mkdtemp, rename, rm } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { extractExecutable } from "./archive.js";
|
|
4
|
-
import {
|
|
4
|
+
import { assertPlatformCompatibility } from "./compatibility.js";
|
|
5
|
+
import { MAX_MANIFEST_BYTES, MAX_SIGNATURE_BYTES, selectManifestArtifact, validateManifest, verifyManifestBytes } from "./manifest.js";
|
|
6
|
+
import { downloadBuffer, downloadFile } from "./network.js";
|
|
5
7
|
import { nativeBinaryPath } from "./paths.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
async function exists(path) {
|
|
9
|
-
try {
|
|
10
|
-
await stat(path);
|
|
11
|
-
return true;
|
|
12
|
-
} catch (error) {
|
|
13
|
-
if (error.code === "ENOENT") return false;
|
|
14
|
-
throw error;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
8
|
+
import { releaseDescriptor } from "./release.js";
|
|
17
9
|
|
|
18
10
|
export async function installNative(options = {}) {
|
|
11
|
+
assertPlatformCompatibility(options);
|
|
19
12
|
const descriptor = releaseDescriptor(options);
|
|
20
13
|
const destination = options.destination || nativeBinaryPath(descriptor);
|
|
21
14
|
const parent = dirname(destination);
|
|
@@ -23,31 +16,24 @@ export async function installNative(options = {}) {
|
|
|
23
16
|
const temporaryDirectory = await mkdtemp(join(parent, ".install-"));
|
|
24
17
|
const archivePath = join(temporaryDirectory, descriptor.archiveName);
|
|
25
18
|
const stagedBinary = join(temporaryDirectory, descriptor.executable);
|
|
26
|
-
const backup = `${destination}.previous-${process.pid}`;
|
|
27
|
-
let movedExisting = false;
|
|
28
19
|
try {
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
const networkOptions = { allowInsecure: options.allowInsecure === true };
|
|
21
|
+
const manifestBytes = await downloadBuffer(descriptor.manifestUrl, { ...networkOptions, maxBytes: MAX_MANIFEST_BYTES, label: "Release manifest" });
|
|
22
|
+
const signatureBytes = await downloadBuffer(descriptor.signatureUrl, { ...networkOptions, maxBytes: MAX_SIGNATURE_BYTES, label: "Manifest signature" });
|
|
23
|
+
const verifyReleaseManifest = options.verifyManifest || verifyManifestBytes;
|
|
24
|
+
const authenticated = await verifyReleaseManifest(manifestBytes, signatureBytes, { keyRing: options.keyRing });
|
|
25
|
+
const manifest = validateManifest(authenticated, descriptor);
|
|
26
|
+
const artifact = selectManifestArtifact(manifest, descriptor.triple);
|
|
27
|
+
const { sha256: actualHash } = await downloadFile(descriptor.archiveUrl, archivePath, { ...networkOptions, expectedBytes: artifact.size });
|
|
28
|
+
if (actualHash !== artifact.sha256) {
|
|
29
|
+
throw new Error(`Checksum mismatch for ${descriptor.archiveName}: expected ${artifact.sha256}, received ${actualHash}`);
|
|
34
30
|
}
|
|
35
31
|
await extractExecutable(archivePath, descriptor.format, descriptor.executable, stagedBinary);
|
|
36
32
|
if (descriptor.platform !== "win32") await chmod(stagedBinary, 0o755);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
try {
|
|
43
|
-
await rename(stagedBinary, destination);
|
|
44
|
-
} catch (error) {
|
|
45
|
-
if (movedExisting) await rename(backup, destination);
|
|
46
|
-
movedExisting = false;
|
|
47
|
-
throw error;
|
|
48
|
-
}
|
|
49
|
-
if (movedExisting) await rm(backup, { force: true });
|
|
50
|
-
return { destination, descriptor, sha256: actualHash };
|
|
33
|
+
// The staging directory lives beside the destination, so this is one
|
|
34
|
+
// same-filesystem atomic replacement. Never vacate the live path first.
|
|
35
|
+
await rename(stagedBinary, destination);
|
|
36
|
+
return { destination, descriptor, sha256: actualHash, manifest };
|
|
51
37
|
} finally {
|
|
52
38
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
53
39
|
}
|
package/lib/launcher.js
CHANGED
|
@@ -8,7 +8,7 @@ export async function ensureNativeBinary(binary = nativeBinaryPath()) {
|
|
|
8
8
|
await access(binary, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
9
9
|
} catch {
|
|
10
10
|
throw new Error(
|
|
11
|
-
"The native Dynomate CLI is
|
|
11
|
+
"The native Dynomate CLI installation is incomplete. npm may not have run this package's install script. See https://dynomate.io/docs/cli/installation/ for installation and repair instructions.",
|
|
12
12
|
);
|
|
13
13
|
}
|
|
14
14
|
return binary;
|
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { constants, createPublicKey, verify } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { MANIFEST_KEY_ID, PACKAGE_VERSION, RELEASE_TAG, TARGETS } from "./release.js";
|
|
4
|
+
|
|
5
|
+
export const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
6
|
+
export const MAX_SIGNATURE_BYTES = 16 * 1024;
|
|
7
|
+
export const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024;
|
|
8
|
+
export const DEFAULT_KEY_RING = new Map([[MANIFEST_KEY_ID, new URL("../keys/dynomate-cli-2026-01.pem", import.meta.url)]]);
|
|
9
|
+
|
|
10
|
+
function fail(message) { throw new Error(`Invalid signed release manifest: ${message}`); }
|
|
11
|
+
|
|
12
|
+
function requireExactKeys(value, expected, label) {
|
|
13
|
+
const actual = Object.keys(value).sort();
|
|
14
|
+
const wanted = [...expected].sort();
|
|
15
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) fail(`${label} contains unsupported or missing fields`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function decodeSignature(bytes) {
|
|
19
|
+
const text = Buffer.from(bytes).toString("ascii");
|
|
20
|
+
if (!/^[A-Za-z0-9+/]+={0,2}\n$/.test(text)) throw new Error("Invalid manifest signature encoding");
|
|
21
|
+
const signature = Buffer.from(text.slice(0, -1), "base64");
|
|
22
|
+
if (`${signature.toString("base64")}\n` !== text) throw new Error("Invalid manifest signature encoding");
|
|
23
|
+
return signature;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function loadKeyRing(keyRing = DEFAULT_KEY_RING) {
|
|
27
|
+
try {
|
|
28
|
+
return new Map(await Promise.all([...keyRing].map(async ([keyId, value]) => [keyId, value instanceof URL ? await readFile(value, "utf8") : value])));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code === "ENOENT") throw new Error("The trusted Dynomate manifest public key is not packaged; refusing an unauthenticated installation.", { cause: error });
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function verifyManifestBytes(manifestBytes, signatureBytes, { keyRing = DEFAULT_KEY_RING } = {}) {
|
|
36
|
+
const signature = decodeSignature(signatureBytes);
|
|
37
|
+
const keys = await loadKeyRing(keyRing);
|
|
38
|
+
if (keys.size === 0) throw new Error("Release manifest signature verification failed: trusted key ring is empty");
|
|
39
|
+
const authenticatedBy = [];
|
|
40
|
+
for (const [keyId, publicKey] of keys) {
|
|
41
|
+
const key = createPublicKey(publicKey);
|
|
42
|
+
if (key.asymmetricKeyType !== "rsa" || key.asymmetricKeyDetails?.modulusLength !== 3072) {
|
|
43
|
+
throw new Error(`Trusted manifest key ${keyId} must be RSA-3072`);
|
|
44
|
+
}
|
|
45
|
+
if (verify("RSA-SHA256", manifestBytes, { key, padding: constants.RSA_PKCS1_PADDING }, signature)) authenticatedBy.push(keyId);
|
|
46
|
+
}
|
|
47
|
+
if (authenticatedBy.length !== 1) throw new Error("Release manifest signature verification failed");
|
|
48
|
+
let manifest;
|
|
49
|
+
try { manifest = JSON.parse(Buffer.from(manifestBytes).toString("utf8")); }
|
|
50
|
+
catch { throw new Error("Authenticated release manifest is not valid JSON"); }
|
|
51
|
+
if (manifest.keyId !== authenticatedBy[0]) fail("keyId does not match the signing key");
|
|
52
|
+
return manifest;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateManifest(manifest, { version = PACKAGE_VERSION, tag = RELEASE_TAG } = {}) {
|
|
56
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) fail("root must be an object");
|
|
57
|
+
requireExactKeys(manifest, ["schemaVersion", "keyId", "version", "tag", "artifacts"], "root");
|
|
58
|
+
if (manifest.schemaVersion !== 1) fail("unsupported schemaVersion");
|
|
59
|
+
if (manifest.keyId !== MANIFEST_KEY_ID) fail(`unsupported keyId ${String(manifest.keyId)}`);
|
|
60
|
+
if (manifest.version !== version) fail(`version must be ${version}`);
|
|
61
|
+
if (manifest.tag !== tag) fail(`tag must be ${tag}`);
|
|
62
|
+
if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length !== TARGETS.size) fail(`artifacts must contain exactly ${TARGETS.size} entries`);
|
|
63
|
+
const expectedByTriple = new Map([...TARGETS.values()].map((target) => [target.triple, target]));
|
|
64
|
+
const targets = new Set();
|
|
65
|
+
const filenames = new Set();
|
|
66
|
+
for (const artifact of manifest.artifacts) {
|
|
67
|
+
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) fail("artifact must be an object");
|
|
68
|
+
const expected = expectedByTriple.get(artifact.target);
|
|
69
|
+
if (!expected) fail(`unexpected target ${String(artifact.target)}`);
|
|
70
|
+
if (targets.has(artifact.target)) fail(`duplicate target ${artifact.target}`);
|
|
71
|
+
targets.add(artifact.target);
|
|
72
|
+
const artifactKeys = ["target", "platform", "architecture", "format", "filename", "size", "sha256"];
|
|
73
|
+
if (expected.libc) artifactKeys.push("libc", "minimumLibcVersion");
|
|
74
|
+
requireExactKeys(artifact, artifactKeys, `artifact ${artifact.target}`);
|
|
75
|
+
const filename = `${tag}-${expected.triple}.${expected.format}`;
|
|
76
|
+
if (artifact.platform !== expected.manifestPlatform || artifact.architecture !== expected.architecture || artifact.format !== expected.format || artifact.filename !== filename) fail(`metadata does not match target ${artifact.target}`);
|
|
77
|
+
if ((artifact.libc ?? undefined) !== expected.libc || (artifact.minimumLibcVersion ?? undefined) !== expected.minimumLibcVersion) fail(`libc metadata does not match target ${artifact.target}`);
|
|
78
|
+
if (filenames.has(artifact.filename)) fail(`duplicate filename ${artifact.filename}`);
|
|
79
|
+
filenames.add(artifact.filename);
|
|
80
|
+
if (!/^[a-f0-9]{64}$/.test(artifact.sha256)) fail(`invalid sha256 for ${artifact.target}`);
|
|
81
|
+
if (!Number.isSafeInteger(artifact.size) || artifact.size <= 0 || artifact.size > MAX_ARTIFACT_BYTES) fail(`invalid size for ${artifact.target}`);
|
|
82
|
+
}
|
|
83
|
+
for (const triple of expectedByTriple.keys()) if (!targets.has(triple)) fail(`missing target ${triple}`);
|
|
84
|
+
return manifest;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function selectManifestArtifact(manifest, triple) {
|
|
88
|
+
const matches = manifest.artifacts.filter((artifact) => artifact.target === triple);
|
|
89
|
+
if (matches.length !== 1) fail(`expected exactly one artifact for ${triple}`);
|
|
90
|
+
return matches[0];
|
|
91
|
+
}
|
package/lib/network.js
CHANGED
|
@@ -2,16 +2,16 @@ import { createWriteStream } from "node:fs";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { get as httpGet } from "node:http";
|
|
4
4
|
import { get as httpsGet } from "node:https";
|
|
5
|
+
import { Transform } from "node:stream";
|
|
5
6
|
import { pipeline } from "node:stream/promises";
|
|
6
7
|
|
|
7
8
|
const MAX_REDIRECTS = 5;
|
|
8
|
-
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
9
9
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
10
10
|
|
|
11
|
-
function open(url, redirects = 0) {
|
|
11
|
+
function open(url, { redirects = 0, allowInsecure = false } = {}) {
|
|
12
12
|
return new Promise((resolve, reject) => {
|
|
13
13
|
const parsed = new URL(url);
|
|
14
|
-
const get = parsed.protocol === "https:" ? httpsGet : parsed.protocol === "http:" ? httpGet : null;
|
|
14
|
+
const get = parsed.protocol === "https:" ? httpsGet : allowInsecure && parsed.protocol === "http:" ? httpGet : null;
|
|
15
15
|
if (!get) return reject(new Error(`Unsupported download protocol: ${parsed.protocol}`));
|
|
16
16
|
const request = get(parsed, { headers: { "user-agent": "@dynomate/cli installer" } }, (response) => {
|
|
17
17
|
const status = response.statusCode ?? 0;
|
|
@@ -22,7 +22,7 @@ function open(url, redirects = 0) {
|
|
|
22
22
|
if (parsed.protocol === "https:" && redirected.protocol !== "https:") {
|
|
23
23
|
return reject(new Error(`Refusing insecure redirect while downloading ${url}`));
|
|
24
24
|
}
|
|
25
|
-
return resolve(open(redirected.href, redirects + 1));
|
|
25
|
+
return resolve(open(redirected.href, { redirects: redirects + 1, allowInsecure }));
|
|
26
26
|
}
|
|
27
27
|
if (status !== 200) {
|
|
28
28
|
response.resume();
|
|
@@ -37,25 +37,48 @@ function open(url, redirects = 0) {
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export async function
|
|
41
|
-
|
|
40
|
+
export async function downloadBuffer(url, { maxBytes, allowInsecure = false, label = "response" } = {}) {
|
|
41
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("A positive download limit is required");
|
|
42
|
+
const response = await open(url, { allowInsecure });
|
|
43
|
+
const declaredLength = Number(response.headers["content-length"]);
|
|
44
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
45
|
+
response.destroy();
|
|
46
|
+
throw new Error(`${label} is larger than the ${maxBytes}-byte limit`);
|
|
47
|
+
}
|
|
42
48
|
const chunks = [];
|
|
43
49
|
let length = 0;
|
|
44
50
|
for await (const chunk of response) {
|
|
45
51
|
length += chunk.length;
|
|
46
|
-
if (length >
|
|
52
|
+
if (length > maxBytes) {
|
|
47
53
|
response.destroy();
|
|
48
|
-
throw new Error(
|
|
54
|
+
throw new Error(`${label} is larger than the ${maxBytes}-byte limit`);
|
|
49
55
|
}
|
|
50
56
|
chunks.push(chunk);
|
|
51
57
|
}
|
|
52
|
-
return Buffer.concat(chunks)
|
|
58
|
+
return Buffer.concat(chunks);
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
export async function downloadFile(url, destination) {
|
|
56
|
-
|
|
61
|
+
export async function downloadFile(url, destination, { expectedBytes, allowanceBytes = 64 * 1024, allowInsecure = false } = {}) {
|
|
62
|
+
if (!Number.isSafeInteger(expectedBytes) || expectedBytes <= 0) throw new Error("A positive signed artifact size is required");
|
|
63
|
+
const response = await open(url, { allowInsecure });
|
|
64
|
+
const maximumBytes = expectedBytes + allowanceBytes;
|
|
65
|
+
const declaredLength = Number(response.headers["content-length"]);
|
|
66
|
+
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
|
|
67
|
+
response.destroy();
|
|
68
|
+
throw new Error(`Artifact exceeds its signed size allowance (${maximumBytes} bytes)`);
|
|
69
|
+
}
|
|
57
70
|
const hash = createHash("sha256");
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
const output = createWriteStream(destination, { flags: "wx", mode: 0o600 });
|
|
72
|
+
let receivedBytes = 0;
|
|
73
|
+
const limiter = new Transform({
|
|
74
|
+
transform(chunk, _encoding, callback) {
|
|
75
|
+
receivedBytes += chunk.length;
|
|
76
|
+
if (receivedBytes > maximumBytes) return callback(new Error(`Artifact exceeds its signed size allowance (${maximumBytes} bytes)`));
|
|
77
|
+
hash.update(chunk);
|
|
78
|
+
callback(null, chunk);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
await pipeline(response, limiter, output);
|
|
82
|
+
if (receivedBytes !== expectedBytes) throw new Error(`Artifact size mismatch: expected ${expectedBytes} bytes, received ${receivedBytes}`);
|
|
83
|
+
return { sha256: hash.digest("hex"), receivedBytes };
|
|
61
84
|
}
|
package/lib/release.js
CHANGED
|
@@ -5,12 +5,14 @@ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.me
|
|
|
5
5
|
export const PACKAGE_VERSION = packageJson.version;
|
|
6
6
|
export const RELEASE_TAG = `dynomate-cli-v${PACKAGE_VERSION}`;
|
|
7
7
|
export const DEFAULT_RELEASE_BASE_URL = "https://releases.dynomate.io/cli";
|
|
8
|
+
export const MANIFEST_KEY_ID = "dynomate-cli-2026-01";
|
|
9
|
+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?$/;
|
|
8
10
|
|
|
9
|
-
const TARGETS = new Map([
|
|
10
|
-
["darwin-arm64", { triple: "aarch64-apple-darwin", format: "tar.gz", executable: "dynomate-cli" }],
|
|
11
|
-
["darwin-x64", { triple: "x86_64-apple-darwin", format: "tar.gz", executable: "dynomate-cli" }],
|
|
12
|
-
["linux-x64", { triple: "x86_64-unknown-linux-gnu", format: "tar.gz", executable: "dynomate-cli" }],
|
|
13
|
-
["win32-x64", { triple: "x86_64-pc-windows-msvc", format: "zip", executable: "dynomate-cli.exe" }],
|
|
11
|
+
export const TARGETS = new Map([
|
|
12
|
+
["darwin-arm64", { triple: "aarch64-apple-darwin", manifestPlatform: "macos", architecture: "arm64", format: "tar.gz", executable: "dynomate-cli" }],
|
|
13
|
+
["darwin-x64", { triple: "x86_64-apple-darwin", manifestPlatform: "macos", architecture: "x64", format: "tar.gz", executable: "dynomate-cli" }],
|
|
14
|
+
["linux-x64", { triple: "x86_64-unknown-linux-gnu", manifestPlatform: "linux", architecture: "x64", libc: "glibc", minimumLibcVersion: "2.35", format: "tar.gz", executable: "dynomate-cli" }],
|
|
15
|
+
["win32-x64", { triple: "x86_64-pc-windows-msvc", manifestPlatform: "windows", architecture: "x64", format: "zip", executable: "dynomate-cli.exe" }],
|
|
14
16
|
]);
|
|
15
17
|
|
|
16
18
|
export function selectTarget(platform = process.platform, arch = process.arch) {
|
|
@@ -29,7 +31,7 @@ export function releaseDescriptor({
|
|
|
29
31
|
baseUrl = process.env.DYNOMATE_CLI_RELEASE_BASE_URL || DEFAULT_RELEASE_BASE_URL,
|
|
30
32
|
version = PACKAGE_VERSION,
|
|
31
33
|
} = {}) {
|
|
32
|
-
if (
|
|
34
|
+
if (!SEMVER_PATTERN.test(version)) {
|
|
33
35
|
throw new Error(`Invalid package version: ${version}`);
|
|
34
36
|
}
|
|
35
37
|
const target = selectTarget(platform, arch);
|
|
@@ -42,24 +44,7 @@ export function releaseDescriptor({
|
|
|
42
44
|
tag,
|
|
43
45
|
archiveName,
|
|
44
46
|
archiveUrl: `${releaseUrl}/${archiveName}`,
|
|
45
|
-
|
|
47
|
+
manifestUrl: `${releaseUrl}/manifest.json`,
|
|
48
|
+
signatureUrl: `${releaseUrl}/manifest.sig`,
|
|
46
49
|
};
|
|
47
50
|
}
|
|
48
|
-
|
|
49
|
-
export function parseChecksumManifest(text, archiveName) {
|
|
50
|
-
const matches = [];
|
|
51
|
-
for (const line of text.split(/\r?\n/)) {
|
|
52
|
-
if (!line.trim()) continue;
|
|
53
|
-
const match = /^([a-fA-F0-9]{64})\s+\*?(.+?)\s*$/.exec(line);
|
|
54
|
-
if (!match) throw new Error("Invalid SHA256SUMS manifest");
|
|
55
|
-
if (match[2] === archiveName) matches.push(match[1].toLowerCase());
|
|
56
|
-
}
|
|
57
|
-
if (matches.length !== 1) {
|
|
58
|
-
throw new Error(
|
|
59
|
-
matches.length === 0
|
|
60
|
-
? `SHA256SUMS does not contain ${archiveName}`
|
|
61
|
-
: `SHA256SUMS contains duplicate entries for ${archiveName}`,
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
return matches[0];
|
|
65
|
-
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dynomate/cli",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Installer and launcher for the native Dynomate CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/
|
|
10
|
-
"directory": "packages/cli"
|
|
9
|
+
"url": "https://github.com/dynomate/dynomate-cli"
|
|
11
10
|
},
|
|
12
|
-
"homepage": "https://dynomate.io",
|
|
11
|
+
"homepage": "https://dynomate.io/docs/cli/installation/",
|
|
13
12
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/
|
|
13
|
+
"url": "https://github.com/dynomate/dynomate-cli/issues"
|
|
15
14
|
},
|
|
16
15
|
"publishConfig": {
|
|
17
16
|
"access": "public",
|
|
@@ -26,6 +25,7 @@
|
|
|
26
25
|
"files": [
|
|
27
26
|
"bin/",
|
|
28
27
|
"lib/",
|
|
28
|
+
"keys/",
|
|
29
29
|
"scripts/",
|
|
30
30
|
"LICENSE",
|
|
31
31
|
"README.md"
|