@dynomate/cli 0.0.10 → 0.0.21

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 CHANGED
@@ -4,7 +4,7 @@ This package installs the native Dynomate command-line client. Test releases use
4
4
  the npm `next` tag:
5
5
 
6
6
  ```bash
7
- npm install --global @dynomate/cli@next
7
+ npm install --global --allow-scripts=@dynomate/cli @dynomate/cli@next
8
8
  dynomate-cli --version
9
9
  ```
10
10
 
@@ -20,24 +20,47 @@ Run the same install command again to upgrade. The package exposes only
20
20
 
21
21
  Installation downloads the archive for the current platform from the immutable
22
22
  release at
23
- `https://releases.dynomate.io/cli/dynomate-cli-v<version>/`. The installer reads
24
- that release's `SHA256SUMS`, verifies the archive before extracting it, and then
25
- atomically installs only the `dynomate-cli` executable.
26
-
27
- Install scripts are required. If npm is run with `--ignore-scripts`, the native
28
- executable is not downloaded and the launcher exits with instructions to
29
- reinstall normally. The installer also needs HTTPS access to
30
- `releases.dynomate.io`.
31
-
32
- If install scripts cannot run, download the archive for your platform and the
33
- [SHA256SUMS](https://releases.dynomate.io/cli/dynomate-cli-v0.0.10/SHA256SUMS)
34
- manifest directly, verify it, and invoke `dynomate-cli` (`dynomate-cli.exe` on
35
- Windows) yourself:
36
-
37
- - [macOS Apple Silicon](https://releases.dynomate.io/cli/dynomate-cli-v0.0.10/dynomate-cli-v0.0.10-aarch64-apple-darwin.tar.gz)
38
- - [macOS Intel](https://releases.dynomate.io/cli/dynomate-cli-v0.0.10/dynomate-cli-v0.0.10-x86_64-apple-darwin.tar.gz)
39
- - [GNU/Linux x64](https://releases.dynomate.io/cli/dynomate-cli-v0.0.10/dynomate-cli-v0.0.10-x86_64-unknown-linux-gnu.tar.gz)
40
- - [Windows x64](https://releases.dynomate.io/cli/dynomate-cli-v0.0.10/dynomate-cli-v0.0.10-x86_64-pc-windows-msvc.zip)
23
+ `https://releases.dynomate.io/cli/dynomate-cli-v<version>/`. Before trusting an
24
+ archive hash, the installer verifies that release's detached `manifest.sig`
25
+ over the exact `manifest.json` bytes with the public key packaged in this npm
26
+ package. It validates the authenticated target, filename, size, and SHA-256,
27
+ then atomically installs only the `dynomate-cli` executable.
28
+
29
+ Install scripts are required. npm 12 blocks dependency lifecycle scripts unless
30
+ they are explicitly approved, and other npm configurations can also skip them.
31
+ If the native executable is missing, the launcher exits with status 1, performs
32
+ no network request, and prints the exact repair command shown above. The
33
+ installer needs HTTPS access to `releases.dynomate.io` during installation.
34
+
35
+ If npm install scripts cannot run, use a versioned direct installer. On macOS
36
+ or GNU/Linux:
37
+
38
+ ```bash
39
+ curl --fail --silent --show-error --location \
40
+ https://releases.dynomate.io/cli/install.sh \
41
+ | sh -s -- --version 0.0.21
42
+ ```
43
+
44
+ On Windows PowerShell:
45
+
46
+ ```powershell
47
+ & ([scriptblock]::Create(
48
+ (Invoke-RestMethod https://releases.dynomate.io/cli/install.ps1)
49
+ )) -Version 0.0.21
50
+ ```
51
+
52
+ Both direct installers authenticate the signed manifest before selecting an
53
+ archive. They do not route through npm. The immutable release objects are:
54
+
55
+ - [macOS Apple Silicon](https://releases.dynomate.io/cli/dynomate-cli-v0.0.21/dynomate-cli-v0.0.21-aarch64-apple-darwin.tar.gz)
56
+ - [macOS Intel](https://releases.dynomate.io/cli/dynomate-cli-v0.0.21/dynomate-cli-v0.0.21-x86_64-apple-darwin.tar.gz)
57
+ - [GNU/Linux x64](https://releases.dynomate.io/cli/dynomate-cli-v0.0.21/dynomate-cli-v0.0.21-x86_64-unknown-linux-gnu.tar.gz)
58
+ - [Windows x64](https://releases.dynomate.io/cli/dynomate-cli-v0.0.21/dynomate-cli-v0.0.21-x86_64-pc-windows-msvc.zip)
59
+
60
+ Test releases stay on the npm `next` dist-tag. A release operator promotes a
61
+ validated version to `latest` only after the native release and npm package
62
+ have passed the release runbook; users should not assume `latest` points at a
63
+ test release.
41
64
 
42
65
  ## License
43
66
 
@@ -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 ? Buffer.from(compressed) : method === 8 ? inflateRawSync(compressed) : null;
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, stat } from "node:fs/promises";
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 { downloadFile, downloadText } from "./network.js";
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 { parseChecksumManifest, releaseDescriptor } from "./release.js";
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 manifest = await downloadText(descriptor.checksumUrl);
30
- const expectedHash = parseChecksumManifest(manifest, descriptor.archiveName);
31
- const actualHash = await downloadFile(descriptor.archiveUrl, archivePath);
32
- if (actualHash !== expectedHash) {
33
- throw new Error(`Checksum mismatch for ${descriptor.archiveName}: expected ${expectedHash}, received ${actualHash}`);
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
- if (await exists(destination)) {
38
- await rm(backup, { force: true });
39
- await rename(destination, backup);
40
- movedExisting = true;
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 not installed. Reinstall @dynomate/cli without --ignore-scripts, or use a direct archive from https://releases.dynomate.io/cli/.",
11
+ "The native Dynomate CLI installation is incomplete. npm may not have run this package's install script. Repair it with: npm install --global --allow-scripts=@dynomate/cli @dynomate/cli@next",
12
12
  );
13
13
  }
14
14
  return binary;
@@ -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 downloadText(url) {
41
- const response = await open(url);
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 > MAX_MANIFEST_BYTES) {
52
+ if (length > maxBytes) {
47
53
  response.destroy();
48
- throw new Error(`Checksum manifest is larger than ${MAX_MANIFEST_BYTES} bytes`);
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).toString("utf8");
58
+ return Buffer.concat(chunks);
53
59
  }
54
60
 
55
- export async function downloadFile(url, destination) {
56
- const response = await open(url);
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
- response.on("data", (chunk) => hash.update(chunk));
59
- await pipeline(response, createWriteStream(destination, { flags: "wx", mode: 0o600 }));
60
- return hash.digest("hex");
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 (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
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
- checksumUrl: `${releaseUrl}/SHA256SUMS`,
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.10",
3
+ "version": "0.0.21",
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/Bagio-labs/hammer.git",
10
- "directory": "packages/cli"
9
+ "url": "https://github.com/dynomate/dynomate-cli"
11
10
  },
12
11
  "homepage": "https://dynomate.io",
13
12
  "bugs": {
14
- "url": "https://github.com/Bagio-labs/hammer/issues"
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"