From b7709d2722e0d5077b3f9031ce35fd419c5bfa3a Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Tue, 29 Sep 2026 15:40:18 -0400 Subject: [PATCH 1/3] Ship lean and full CLI editions with Node binary selection Retain compile-time language features and Rust parser configuration. Build both editions per release target and expose a pinned, versioned Node cache for choosing executables at runtime. AI-assisted implementation and validation with Codex. --- .github/workflows/release.yml | 44 +++++++++++- .github/workflows/test.yml | 1 + .gitignore | 3 + Cargo.toml | 20 ++++-- README.md | 18 +++++ diffr-ts/README.md | 44 ++++++++++-- diffr-ts/bin/binary.d.mts | 11 +++ diffr-ts/bin/binary.mjs | 90 ++++++++++++++++++++++++ diffr-ts/bin/fetch.mjs | 85 +++------------------- diffr-ts/package.json | 7 +- diffr-ts/scripts/pin.mjs | 25 ++++--- diffr-ts/src/fetch.test.ts | 58 ++++++++++++++- diffr-ts/src/pin.test.ts | 38 ++++++++++ diffr-ts/src/version.test.ts | 11 +-- sample_files/ocaml_interface_1.mli | 3 + sample_files/ocaml_interface_2.mli | 3 + scripts/editions.Dockerfile | 26 +++++++ scripts/editions.Dockerfile.dockerignore | 4 ++ scripts/macos-entitlements.plist | 5 ++ scripts/release.py | 11 +-- scripts/smoke_editions.mjs | 59 ++++++++++++++++ src/parse/guess_language.rs | 27 +++++++ src/parse/tree_sitter_parser.rs | 8 +++ tests/cli.rs | 12 ++++ 24 files changed, 501 insertions(+), 112 deletions(-) create mode 100644 diffr-ts/bin/binary.d.mts create mode 100644 diffr-ts/bin/binary.mjs create mode 100644 diffr-ts/src/pin.test.ts create mode 100644 sample_files/ocaml_interface_1.mli create mode 100644 sample_files/ocaml_interface_2.mli create mode 100644 scripts/editions.Dockerfile create mode 100644 scripts/editions.Dockerfile.dockerignore create mode 100644 scripts/macos-entitlements.plist create mode 100644 scripts/smoke_editions.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71da543a6..302bbf819 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: with: bun-version: 1.3.10 - uses: dtolnay/rust-toolchain@stable - - name: Build and package + - name: Build both editions env: TARGET: ${{ matrix.target }} RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} @@ -47,7 +47,37 @@ jobs: exit 1 fi cargo xtask install --root "$RUNNER_TEMP/install" + cargo install --path . --locked --features all-languages --root "$RUNNER_TEMP/full" + - name: Import release signing identity + if: runner.os == 'macOS' && github.ref_type == 'tag' + env: + CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + test -n "$CERTIFICATE" && test -n "$CERTIFICATE_PASSWORD" + python3 -c 'import os,base64; open(os.environ["RUNNER_TEMP"]+"/certificate.p12","wb").write(base64.b64decode(os.environ["CERTIFICATE"]))' + password=$(openssl rand -hex 24) + security create-keychain -p "$password" "$RUNNER_TEMP/diffr.keychain-db" + security set-keychain-settings -lut 21600 "$RUNNER_TEMP/diffr.keychain-db" + security unlock-keychain -p "$password" "$RUNNER_TEMP/diffr.keychain-db" + security import "$RUNNER_TEMP/certificate.p12" -k "$RUNNER_TEMP/diffr.keychain-db" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -k "$password" "$RUNNER_TEMP/diffr.keychain-db" + security list-keychains -d user -s "$RUNNER_TEMP/diffr.keychain-db" + echo 'DIFFR_SIGN_IDENTITY=Developer ID Application: Workable Solutions Inc. (PWXY59YDAY)' >> "$GITHUB_ENV" + - name: Sign macOS executables before packaging + if: runner.os == 'macOS' + run: | + for binary in "$RUNNER_TEMP/install/bin/diffr" "$RUNNER_TEMP/full/bin/diffr"; do + codesign --force --sign "${DIFFR_SIGN_IDENTITY:--}" --options runtime --entitlements scripts/macos-entitlements.plist "$binary" + codesign --verify --strict "$binary" + done + - name: Package both editions + env: + TARGET: ${{ matrix.target }} + run: | + version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) python3 scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/install" --output dist + python3 scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/full" --edition full --output dist - name: Verify extracted archives without Bun or checkout assets env: TARGET: ${{ matrix.target }} @@ -57,6 +87,18 @@ jobs: tar -xzf "dist/diffr-$version-$TARGET.tar.gz" -C "$RUNNER_TEMP/extracted/bin" python3 xtask/tests/smoke_install.py "$RUNNER_TEMP/extracted" python3 scripts/smoke_cli_archive.py "dist/diffr-cli-$version-$TARGET.tar.gz" "$version" + python3 scripts/smoke_cli_archive.py "dist/diffr-cli-full-$version-$TARGET.tar.gz" "$version" + node scripts/smoke_editions.mjs dist sample_files + - name: Test both editions in a minimal Linux runtime + if: runner.os == 'Linux' + run: | + docker build -f scripts/editions.Dockerfile --target runtime -t diffr-editions . + docker run --rm --network none --read-only --user 65534:65534 --tmpfs /tmp:rw,exec,nosuid,size=512m diffr-editions + - name: Clean signing material + if: always() && runner.os == 'macOS' && github.ref_type == 'tag' + run: | + security delete-keychain "$RUNNER_TEMP/diffr.keychain-db" || true + rm -f "$RUNNER_TEMP/certificate.p12" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ matrix.target }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5937e739..6b3d3b94a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -131,6 +131,7 @@ jobs: bun-version: 1.3.10 - uses: dtolnay/rust-toolchain@1.85.0 - run: cargo build --locked + - run: cargo test --locked --features all-languages --bin diffr --test cli - working-directory: diffr-ts run: | bun install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 0b191a4af..fb3e07fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ plugin.wasm # The bundled WASM summarizer ships ready to embed without a WASM toolchain. !/plugins/summarize/plugin.wasm /crates/*/target + +# Staged release archives. +/dist/ diff --git a/Cargo.toml b/Cargo.toml index a8f37b0b0..b39a52880 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,24 +94,24 @@ tree-sitter-fortran = { version = "0.6.0", optional = true } tree-sitter-fsharp = { version = "0.3.0", optional = true } tree-sitter-gleam = "1.0.0" tree-sitter-go = "0.25.0" -tree-sitter-haskell = "0.23.1" +tree-sitter-haskell = { version = "0.23.1", optional = true } tree-sitter-hcl = "1.1.0" tree-sitter-html = "0.23.2" tree-sitter-java-orchard = "0.5.9" tree-sitter-javascript = "0.25.0" tree-sitter-json = "0.24.8" -tree-sitter-julia = "0.23.1" +tree-sitter-julia = { version = "0.23.1", optional = true } tree-sitter-lua = "0.5" tree-sitter-make = "1.1.1" tree-sitter-newick = "1.1.0" tree-sitter-nix = "0.3.0" tree-sitter-objc = "3.0.2" -tree-sitter-ocaml = "0.25.0" +tree-sitter-ocaml = { version = "0.25.0", optional = true } tree-sitter-pascal = "0.10.0" tree-sitter-php = "0.24.0" tree-sitter-proto = "0.4.0" tree-sitter-python = "0.25.0" -tree-sitter-qmljs = "0.3.0" +tree-sitter-qmljs = { version = "0.3.0", optional = true } tree-sitter-r = "1.2.0" tree-sitter-racket = "0.24.7" tree-sitter-ruby = "0.23.1" @@ -119,13 +119,13 @@ tree-sitter-rust-orchard = "0.16.6" tree-sitter-scala = "0.26.2" tree-sitter-scheme = "0.24.7" tree-sitter-sequel = "0.3.11" -tree-sitter-sfapex = "2.4.0" +tree-sitter-sfapex = { version = "2.4.0", optional = true } tree-sitter-solidity = "1.2.13" tree-sitter-swift = "0.7.1" tree-sitter-toml-ng = "0.7.0" tree-sitter-typescript = "0.23.2" tree-sitter-verilog = { version = "1.0.3", optional = true } -tree-sitter-vhdl = "1.4.0" +tree-sitter-vhdl = { version = "1.4.0", optional = true } tree-sitter-xml = "0.7.0" tree-sitter-yaml = "0.7.0" tree-sitter-zig = "1.1.2" @@ -235,8 +235,14 @@ name = "wasm" required-features = ["wasm-plugin-tests"] [features] -all-languages = ["lang-fortran", "lang-fsharp", "lang-verilog"] +all-languages = ["lang-fortran", "lang-fsharp", "lang-verilog", "lang-ocaml", "lang-julia", "lang-haskell", "lang-vhdl", "lang-apex", "lang-qml"] lang-fortran = ["dep:tree-sitter-fortran"] lang-fsharp = ["dep:tree-sitter-fsharp"] lang-verilog = ["dep:tree-sitter-verilog"] +lang-ocaml = ["dep:tree-sitter-ocaml"] +lang-julia = ["dep:tree-sitter-julia"] +lang-haskell = ["dep:tree-sitter-haskell"] +lang-vhdl = ["dep:tree-sitter-vhdl"] +lang-apex = ["dep:tree-sitter-sfapex"] +lang-qml = ["dep:tree-sitter-qmljs"] wasm-plugin-tests = [] diff --git a/README.md b/README.md index 0c73fb4b7..da84f085a 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,21 @@ We hope in the future that we can find some way to upstream some / all of these MIT. See `LICENSE` for the terms and `NOTICE` for the third-party work diffr builds on, starting with the lovely [difftastic](https://github.com/Wilfred/difftastic). + +## Language editions + +Default builds are lean. Build all supported parsers with +`cargo build --release --locked --features all-languages`, or select individual +`lang-*` Cargo features. Lean builds omit Apex, Fortran, F#, Haskell, Julia, +OCaml, QML, Verilog, and VHDL and use text diffs for those files. LaTeX and SQL +remain included. + +Releases provide `diffr-cli--.tar.gz` (lean) and +`diffr-cli-full--.tar.gz` (full). The existing `diffr` archive +contains the lean CLI and TUI. Both editions use the same executable name and wire +protocol. Node applications can [download and select either edition](diffr-ts/README.md#choosing-languages-at-runtime). + +To make another bundled parser optional, mark its Cargo dependency optional, add +its `lang-*` feature to `all-languages`, and gate its recognition/configuration +with that feature. Keep parser rules in Rust; no separate language package or +registry is required. Test both default and `all-languages` builds. diff --git a/diffr-ts/README.md b/diffr-ts/README.md index 7256802e6..5045c88c5 100644 --- a/diffr-ts/README.md +++ b/diffr-ts/README.md @@ -9,13 +9,48 @@ const event = decodeStructuralDiffEvent(line); ``` ```sh -npx --package @dev.fast/diffr@0.1.6 diffr-fetch --into ./bin --required +npx --package @dev.fast/diffr@0.1.7 diffr-fetch --into ./bin --required ``` -Supports macOS arm64, macOS x64, Linux arm64, and Linux x64. `--check` verifies an existing install without +Supports macOS arm64, macOS x64, Linux arm64/glibc, and Linux x64/glibc. `--check` verifies an existing install without network access. Downloads warn on network failure unless `--required`; invalid hashes or archives always fail. +## Choosing languages at runtime + +The default `lean` edition excludes Apex, Fortran, F#, Haskell, Julia, OCaml +(including interfaces), QML, Verilog, and VHDL. It uses text diffs for those files. +The `full` edition compiles all supported languages. Both use the same wire format. + +```ts +import { ensureBinary } from "@dev.fast/diffr/binary"; +import { spawn } from "node:child_process"; + +// Call when the user selects an edition; retain the returned path for comparisons. +const executable = await ensureBinary({ + directory: appDataDirectory, + edition: "full", // or "lean" +}); +spawn(executable, ["--repo", repository, "--format", "ndjson", "HEAD"]); +``` + +The API downloads the package's exact pinned release and process architecture, +checks the archive hash, and stores editions separately under +`////diffr`. Installed binaries are checked +for corruption and reused offline. `check: true` prohibits downloads. Select a new +path only after the promise succeeds; existing comparisons finish on their old +executable. Clear application diff caches when switching. Do not replace files +inside a signed application bundle. + +The CLI also accepts `--edition full`; `--into` still writes `diffr` directly into +the given directory for existing build scripts. Use different directories to keep +both editions. Neither API needs Rust, npm, or a compiler at runtime; extraction +uses the operating system's `tar`. + +Full downloads require a release with full-edition pins. The existing 0.1.7 pins +remain unchanged until matching artifacts are published; requesting an unpinned +edition fails rather than downloading a different version. + ## Wire changes Keep these files in sync: @@ -30,8 +65,9 @@ Keep these files in sync: ## Release After tagging the matching Rust release and building with `cargo build --locked`, -run from `diffr-ts`. New pins select the CLI-only release archives; older pins -continue to use the full archives: +run from `diffr-ts`. Pinning downloads all eight CLI archives (two editions on +four targets) and writes the catalog only after every download succeeds. Legacy +single-edition pins remain supported: ```sh bun install --frozen-lockfile diff --git a/diffr-ts/bin/binary.d.mts b/diffr-ts/bin/binary.d.mts new file mode 100644 index 000000000..b1cd872b6 --- /dev/null +++ b/diffr-ts/bin/binary.d.mts @@ -0,0 +1,11 @@ +export type Edition = "lean" | "full"; + +/** Downloads only when requested. Returns an absolute executable path. */ +export function ensureBinary(options: { + directory: string; + edition?: Edition; + /** Verify a cached installation without downloading. */ + check?: boolean; + /** Override the package's pinned release catalog. */ + pins?: string; +}): Promise; diff --git a/diffr-ts/bin/binary.mjs b/diffr-ts/bin/binary.mjs new file mode 100644 index 000000000..5255f4cea --- /dev/null +++ b/diffr-ts/bin/binary.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu" }; + +/** Download a pinned edition into a versioned cache and return its executable. */ +export async function ensureBinary({ directory, edition = "lean", check = false, pins: pinsPath = join(root, "pins.json") }) { + if (typeof directory !== "string" || !directory) throw new Error("directory is required"); + if (!["lean", "full"].includes(edition)) throw new Error(`unknown diffr edition: ${edition}`); + const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const target = targets[`${process.platform}-${process.arch}`]; + if (!target) throw new Error(`no diffr release for ${process.platform}-${process.arch}`); + return installBinary({ into: join(directory, version, target, edition), edition, check, pinsPath, required: true }); +} + +export async function installBinary({ into, edition = "lean", check = false, required = false, pinsPath = join(root, "pins.json") }) { + into = resolve(into); + const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const pins = JSON.parse(readFileSync(pinsPath, "utf8")); + if (pins.version !== version) throw new Error(`pins.json is for ${pins.version}, package is ${version}; run \`npm run pin\``); + const target = targets[`${process.platform}-${process.arch}`]; + if (!target) throw new Error(`no diffr release for ${process.platform}-${process.arch}`); + if (!["lean", "full"].includes(edition)) throw new Error(`unknown diffr edition: ${edition}`); + const pin = pins.editions?.[edition] ?? (edition === "lean" ? pins : undefined); + if (!pin) throw new Error(`pins.json has no ${edition} edition for ${version}`); + const expected = pin.sha256?.[target]; + if (typeof expected !== "string" || !/^[a-f0-9]{64}$/.test(expected)) { + throw new Error(`pins.json has no valid sha256 for ${target}`); + } + + const artifact = pin.artifact ?? "diffr"; + const allowed = edition === "full" ? ["diffr-cli-full"] : ["diffr", "diffr-cli"]; + if (!allowed.includes(artifact)) throw new Error(`unknown pinned artifact: ${artifact}`); + const binary = join(into, "diffr"); + const stampPath = join(into, "diffr.stamp.json"); + let installed = false; + try { + const stamp = JSON.parse(readFileSync(stampPath, "utf8")); + const stat = lstatSync(binary); + installed = stat.isFile() && (stat.mode & 0o111) !== 0 && stamp.version === version && stamp.target === target + && stamp.edition === edition && stamp.archiveSha256 === expected + && stamp.binarySha256 === createHash("sha256").update(readFileSync(binary)).digest("hex"); + } catch { /* Missing or damaged installations are fetched again. */ } + if (installed) { + console.error(`diffr ${version} (${target}) already at ${binary}`); + return binary; + } + if (check) throw new Error(`${binary} is missing or not diffr ${version} (${target})`); + + const asset = `${artifact}-${version}-${target}.tar.gz`; + const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${asset}`; + let bytes; + try { + const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`); + bytes = Buffer.from(await response.arrayBuffer()); + } catch (error) { + if (required) throw new Error(`could not download ${url}: ${error.message}`); + console.warn(`diffr-fetch: could not download ${url}: ${error.message}; continuing without a bundled diffr`); + return; + } + const actual = createHash("sha256").update(bytes).digest("hex"); + if (actual !== expected) throw new Error(`${asset} sha256 ${actual} does not match pinned ${expected}`); + + mkdirSync(into, { recursive: true }); + const staging = mkdtempSync(join(into, ".diffr-")); + try { + const archive = join(staging, "archive.tar.gz"); + writeFileSync(archive, bytes); + const tar = spawnSync("tar", ["-xzf", archive, "-C", staging, "diffr"], { encoding: "utf8" }); + const extracted = join(staging, "diffr"); + if (tar.status !== 0 || !lstatSync(extracted).isFile()) { + throw new Error(`${asset} does not contain a regular diffr file at its root`); + } + chmodSync(extracted, 0o755); + const stamp = join(staging, "stamp.json"); + writeFileSync(stamp, `${JSON.stringify({ version, target, edition, archiveSha256: expected, binarySha256: createHash("sha256").update(readFileSync(extracted)).digest("hex") }, null, 2)}\n`); + renameSync(extracted, binary); + renameSync(stamp, stampPath); + } finally { + rmSync(staging, { recursive: true, force: true }); + } + console.error(`diffr ${version} (${target}) installed at ${binary}`); + return binary; +} diff --git a/diffr-ts/bin/fetch.mjs b/diffr-ts/bin/fetch.mjs index 6ce74518f..ec56fa6f5 100755 --- a/diffr-ts/bin/fetch.mjs +++ b/diffr-ts/bin/fetch.mjs @@ -1,88 +1,19 @@ #!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu" }; +import { installBinary } from "./binary.mjs"; try { - await main(); -} catch (error) { - console.error(`diffr-fetch: ${error.message}`); - process.exitCode = 1; -} - -async function main() { const { values } = parseArgs({ options: { into: { type: "string" }, + edition: { type: "string", default: "lean" }, check: { type: "boolean" }, required: { type: "boolean" }, - pins: { type: "string", default: join(root, "pins.json") }, + pins: { type: "string" }, } }); if (!values.into) throw new Error("--into is required"); - const into = resolve(values.into); - const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); - const pins = JSON.parse(readFileSync(values.pins, "utf8")); - if (pins.version !== version) throw new Error(`pins.json is for ${pins.version}, package is ${version}; run \`npm run pin\``); - const target = targets[`${process.platform}-${process.arch}`]; - if (!target) throw new Error(`no diffr release for ${process.platform}-${process.arch}`); - const expected = pins.sha256?.[target]; - if (typeof expected !== "string" || !/^[a-f0-9]{64}$/.test(expected)) { - throw new Error(`pins.json has no valid sha256 for ${target}`); - } - - const binary = join(into, "diffr"); - const stampPath = join(into, "diffr.stamp.json"); - let installed = false; - try { - const stamp = JSON.parse(readFileSync(stampPath, "utf8")); - const stat = lstatSync(binary); - installed = stat.isFile() && (stat.mode & 0o111) !== 0 && stamp.version === version && stamp.target === target; - } catch { /* Missing or damaged installations are fetched again. */ } - if (installed) { - console.log(`diffr ${version} (${target}) already at ${binary}`); - return; - } - if (values.check) throw new Error(`${binary} is missing or not diffr ${version} (${target})`); - - const artifact = pins.artifact ?? "diffr"; - if (!["diffr", "diffr-cli"].includes(artifact)) throw new Error(`unknown pinned artifact: ${artifact}`); - const asset = `${artifact}-${version}-${target}.tar.gz`; - const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${asset}`; - let bytes; - try { - const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`); - bytes = Buffer.from(await response.arrayBuffer()); - } catch (error) { - if (values.required) throw new Error(`could not download ${url}: ${error.message}`); - console.warn(`diffr-fetch: could not download ${url}: ${error.message}; continuing without a bundled diffr`); - return; - } - const actual = createHash("sha256").update(bytes).digest("hex"); - if (actual !== expected) throw new Error(`${asset} sha256 ${actual} does not match pinned ${expected}`); - - mkdirSync(into, { recursive: true }); - const staging = mkdtempSync(join(into, ".diffr-")); - try { - const archive = join(staging, "archive.tar.gz"); - writeFileSync(archive, bytes); - const tar = spawnSync("tar", ["-xzf", archive, "-C", staging, "diffr"], { encoding: "utf8" }); - const extracted = join(staging, "diffr"); - if (tar.status !== 0 || !lstatSync(extracted).isFile()) { - throw new Error(`${asset} does not contain a regular diffr file at its root`); - } - chmodSync(extracted, 0o755); - const stamp = join(staging, "stamp.json"); - writeFileSync(stamp, `${JSON.stringify({ version, target }, null, 2)}\n`); - renameSync(extracted, binary); - renameSync(stamp, stampPath); - } finally { - rmSync(staging, { recursive: true, force: true }); - } - console.log(`diffr ${version} (${target}) installed at ${binary}`); + await installBinary({ into: values.into, edition: values.edition, check: values.check, + required: values.required, pinsPath: values.pins }); +} catch (error) { + console.error(`diffr-fetch: ${error.message}`); + process.exitCode = 1; } diff --git a/diffr-ts/package.json b/diffr-ts/package.json index 2f5fdcdc5..aeee3a456 100644 --- a/diffr-ts/package.json +++ b/diffr-ts/package.json @@ -12,7 +12,12 @@ "default": "./dist/index.js" }, "./package.json": "./package.json", - "./src/*": "./src/*" + "./src/*": "./src/*", + "./binary": { + "types": "./bin/binary.d.mts", + "import": "./bin/binary.mjs", + "default": "./bin/binary.mjs" + } }, "bin": { "diffr-fetch": "bin/fetch.mjs" diff --git a/diffr-ts/scripts/pin.mjs b/diffr-ts/scripts/pin.mjs index b858ec44c..e8ef830ff 100755 --- a/diffr-ts/scripts/pin.mjs +++ b/diffr-ts/scripts/pin.mjs @@ -8,17 +8,20 @@ const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, ".."); const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; const targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]; -const artifact = "diffr-cli"; -const sha256 = {}; -for (const target of targets) { - const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${artifact}-${version}-${target}.tar.gz`; - const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); - if (!response.ok) { - console.error(`pin: GET ${url} failed with ${response.status} ${response.statusText}`); - process.exit(1); +const editions = {}; +for (const [edition, artifact] of [["lean", "diffr-cli"], ["full", "diffr-cli-full"]]) { + const sha256 = {}; + for (const target of targets) { + const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${artifact}-${version}-${target}.tar.gz`; + const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); + if (!response.ok) { + console.error(`pin: GET ${url} failed with ${response.status} ${response.statusText}`); + process.exit(1); + } + sha256[target] = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex"); + console.log(`${target} ${sha256[target]}`); } - sha256[target] = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex"); - console.log(`${target} ${sha256[target]}`); + editions[edition] = { artifact, sha256 }; } -writeFileSync(join(root, "pins.json"), `${JSON.stringify({ version, artifact, sha256 }, null, 2)}\n`); +writeFileSync(join(root, "pins.json"), `${JSON.stringify({ version, editions }, null, 2)}\n`); console.log(`wrote pins.json for ${version}`); diff --git a/diffr-ts/src/fetch.test.ts b/diffr-ts/src/fetch.test.ts index 282902a9c..cd089781d 100644 --- a/diffr-ts/src/fetch.test.ts +++ b/diffr-ts/src/fetch.test.ts @@ -56,7 +56,7 @@ test("downloads, verifies, extracts, stamps, and reuses the pinned binary", () = ); expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content); expect(statSync(join(f.into, "diffr")).mode & 0o777).toBe(0o755); - expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toEqual({ version, target }); + expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toMatchObject({ version, target, edition: "lean" }); expect(readdirSync(f.into).sort()).toEqual(["diffr", "diffr.stamp.json"]); rmSync(join(f.dir, "requested")); expect(f.run(["--check"], true).code).toBe(0); @@ -131,8 +131,8 @@ test("Linux ARM64 downloads and stamps the aarch64 release", () => { `https://github.com/devdotfast/diffr/releases/download/${version}/diffr-cli-${version}-aarch64-unknown-linux-gnu.tar.gz`, ); expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content); - expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toEqual({ - version, target: "aarch64-unknown-linux-gnu", + expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toMatchObject({ + version, target: "aarch64-unknown-linux-gnu", edition: "lean", }); expect(f.run(["--check"], true).code).toBe(0); }); @@ -152,3 +152,55 @@ test("unknown pinned artifacts fail before downloading", () => { expect(f.run().err).toContain("unknown pinned artifact"); expect(existsSync(join(f.dir, "requested"))).toBe(false); }); + + +test("editions select distinct pinned archives and reject unpublished full builds", () => { + const f = fixture("diffr", "diffr-cli"); + expect(f.run(["--edition", "full"]).err).toContain("no full edition"); + expect(existsSync(join(f.dir, "requested"))).toBe(false); + const lean = JSON.parse(readFileSync(f.pins, "utf8")); + writeFileSync(f.pins, JSON.stringify({ version, editions: { + lean, full: { ...lean, artifact: "diffr-cli-full" }, + } })); + expect(f.run(["--edition", "full"]).code).toBe(0); + expect(readFileSync(join(f.dir, "requested"), "utf8")).toContain(`/diffr-cli-full-${version}-${target}.tar.gz`); + expect(f.run(["--check"]).code).toBe(1); + expect(f.run(["--edition", "full", "--check"], true).code).toBe(0); + expect(f.run(["--edition", "invalid"]).err).toContain("unknown diffr edition"); +}); + +test("a corrupt cached executable is rejected and repaired", () => { + const f = fixture(); + expect(f.run().code).toBe(0); + writeFileSync(join(f.into, "diffr"), "corrupt"); + expect(f.run(["--check"], true).code).toBe(1); + expect(f.run().code).toBe(0); + expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content); +}); + +test("runtime API keeps editions separate, supports concurrent installs and offline switching", () => { + const f = fixture("diffr", "diffr-cli"); + const lean = JSON.parse(readFileSync(f.pins, "utf8")); + writeFileSync(f.pins, JSON.stringify({ version, editions: { lean, full: { ...lean, artifact: "diffr-cli-full" } } })); + const script = join(f.dir, "switch.mjs"); + writeFileSync(script, ` + import assert from "node:assert/strict"; + import { ensureBinary } from ${JSON.stringify(new URL("../bin/binary.mjs", import.meta.url).href)}; + const options = ${JSON.stringify({ directory: f.into, pins: f.pins })}; + const lean = await ensureBinary(options); + const full = await Promise.all([ensureBinary({ ...options, edition: "full" }), ensureBinary({ ...options, edition: "full" })]); + assert.equal(full[0], full[1]); + assert.notEqual(lean, full[0]); + globalThis.fetch = () => { throw new Error("offline"); }; + assert.equal(await ensureBinary({ ...options, check: true }), lean); + assert.equal(await ensureBinary({ ...options, edition: "full", check: true }), full[0]); + console.log(JSON.stringify([lean, full[0]])); + `); + const result = Bun.spawnSync(["node", "--import", join(f.dir, "mock.mjs"), script]); + expect(result.exitCode, result.stderr.toString()).toBe(0); + const paths = JSON.parse(result.stdout.toString()); + for (const [i, edition] of ["lean", "full"].entries()) { + expect(paths[i]).toBe(join(f.into, version, target!, edition, "diffr")); + expect(readFileSync(paths[i], "utf8")).toBe(f.content); + } +}); diff --git a/diffr-ts/src/pin.test.ts b/diffr-ts/src/pin.test.ts new file mode 100644 index 000000000..6e55c0def --- /dev/null +++ b/diffr-ts/src/pin.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +for (const failLast of [false, true]) { + test(`pinning ${failLast ? "preserves the previous catalog on partial publication" : "requires all eight edition archives"}`, () => { + const root = mkdtempSync(join(tmpdir(), "diffr-pin-")); + try { + mkdirSync(join(root, "scripts")); + cpSync(join(import.meta.dir, "../scripts/pin.mjs"), join(root, "scripts/pin.mjs")); + writeFileSync(join(root, "package.json"), JSON.stringify({ version: "1.2.3" })); + writeFileSync(join(root, "pins.json"), "previous pins"); + writeFileSync(join(root, "mock.mjs"), ` + import assert from "node:assert/strict"; + let count = 0; + globalThis.fetch = async (url) => { + count++; + assert.ok(url.includes(count <= 4 ? "/diffr-cli-1.2.3-" : "/diffr-cli-full-1.2.3-")); + return new Response("archive", { status: ${failLast} && count === 8 ? 404 : 200 }); + }; + process.on("exit", () => assert.equal(count, 8)); + `); + const result = Bun.spawnSync(["node", "--import", join(root, "mock.mjs"), join(root, "scripts/pin.mjs")]); + expect(result.exitCode, result.stderr.toString()).toBe(failLast ? 1 : 0); + const contents = readFileSync(join(root, "pins.json"), "utf8"); + if (failLast) expect(contents).toBe("previous pins"); + else { + const pins = JSON.parse(contents); + expect(Object.keys(pins.editions).sort()).toEqual(["full", "lean"]); + expect(pins.editions.full.artifact).toBe("diffr-cli-full"); + for (const edition of ["lean", "full"]) expect(Object.keys(pins.editions[edition].sha256)).toHaveLength(4); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} diff --git a/diffr-ts/src/version.test.ts b/diffr-ts/src/version.test.ts index e87e32561..7befeef6c 100644 --- a/diffr-ts/src/version.test.ts +++ b/diffr-ts/src/version.test.ts @@ -17,8 +17,11 @@ test("release pins match the package version and cover all supported targets", ( const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const pins = JSON.parse(readFileSync(join(root, "pins.json"), "utf8")); expect(pins.version).toBe(pkg.version); - expect(Object.keys(pins.sha256).sort()).toEqual([ - "aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", - ]); - for (const hash of Object.values(pins.sha256)) expect(hash).toMatch(/^[a-f0-9]{64}$/); + if (pins.editions) expect(Object.keys(pins.editions).sort()).toEqual(["full", "lean"]); + for (const pin of Object.values(pins.editions ?? { lean: pins }) as { sha256: Record }[]) { + expect(Object.keys(pin.sha256).sort()).toEqual([ + "aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", + ]); + for (const hash of Object.values(pin.sha256)) expect(hash).toMatch(/^[a-f0-9]{64}$/); + } }); diff --git a/sample_files/ocaml_interface_1.mli b/sample_files/ocaml_interface_1.mli new file mode 100644 index 000000000..3e647cea8 --- /dev/null +++ b/sample_files/ocaml_interface_1.mli @@ -0,0 +1,3 @@ +(** Public interface *) +type result = { value : int } +val compute : int -> result diff --git a/sample_files/ocaml_interface_2.mli b/sample_files/ocaml_interface_2.mli new file mode 100644 index 000000000..b0da7420d --- /dev/null +++ b/sample_files/ocaml_interface_2.mli @@ -0,0 +1,3 @@ +(** Public interface *) +type result = { value : int; label : string } +val compute : int -> string -> result diff --git a/scripts/editions.Dockerfile b/scripts/editions.Dockerfile new file mode 100644 index 000000000..8eb20d455 --- /dev/null +++ b/scripts/editions.Dockerfile @@ -0,0 +1,26 @@ +FROM rust:1.85-bookworm AS build +RUN apt-get update && apt-get install -y --no-install-recommends python3 cmake clang pkg-config && rm -rf /var/lib/apt/lists/* +ENV RUSTUP_TOOLCHAIN=1.85.0 CARGO_BUILD_JOBS=4 +WORKDIR /work +COPY . . +RUN --mount=type=cache,target=/work/target,sharing=locked \ + --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ + target=$(rustc -vV | sed -n 's/^host: //p') && \ + version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) && \ + cargo build --release --locked && \ + mkdir -p /lean/bin /full/bin /artifacts && cp target/release/diffr /lean/bin/ && \ + cargo build --release --locked --features all-languages && cp target/release/diffr /full/bin/ && \ + python3 scripts/release.py pack --version "$version" --target "$target" --install /lean --cli-only --output /artifacts && \ + python3 scripts/release.py pack --version "$version" --target "$target" --install /full --edition full --output /artifacts + +FROM scratch AS artifacts +COPY --from=build /artifacts/ / + +FROM node:22-bookworm-slim AS runtime +WORKDIR /opt/diffr +COPY dist/ dist/ +COPY diffr-ts/bin/ diffr-ts/bin/ +COPY diffr-ts/package.json diffr-ts/package.json +COPY scripts/smoke_editions.mjs scripts/smoke_editions.mjs +COPY sample_files/ sample_files/ +CMD ["node", "scripts/smoke_editions.mjs", "dist", "sample_files"] diff --git a/scripts/editions.Dockerfile.dockerignore b/scripts/editions.Dockerfile.dockerignore new file mode 100644 index 000000000..5fcf89465 --- /dev/null +++ b/scripts/editions.Dockerfile.dockerignore @@ -0,0 +1,4 @@ +.git +target +**/node_modules +**/__pycache__ diff --git a/scripts/macos-entitlements.plist b/scripts/macos-entitlements.plist new file mode 100644 index 000000000..d7757761a --- /dev/null +++ b/scripts/macos-entitlements.plist @@ -0,0 +1,5 @@ + + + +com.apple.security.cs.allow-unsigned-executable-memory + diff --git a/scripts/release.py b/scripts/release.py index ecc44e4c8..4ce68ef2e 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -14,11 +14,12 @@ def archive_name(version, target, artifact="diffr"): return f"{artifact}-{version}-{target}.tar.gz" -def pack(version, target, install, output): +def pack(version, target, install, output, edition="lean", cli_only=False): actual = subprocess.check_output([install / "bin/diffr", "--version"], text=True).strip() if actual != f"diffr {version}": raise ValueError(f"Release version mismatch: {actual}") - for artifact in ("diffr", "diffr-cli"): + artifacts = ("diffr-cli-full",) if edition == "full" else (("diffr-cli",) if cli_only else ("diffr", "diffr-cli")) + for artifact in artifacts: with tarfile.open(output / archive_name(version, target, artifact), "w:gz") as archive: binaries = ("diffr", "diffr-tui") if artifact == "diffr" else ("diffr",) for name in binaries: @@ -30,7 +31,7 @@ def pack(version, target, install, output): def formula(version, output): checksums = {} for target in TARGETS: - for artifact in ("diffr", "diffr-cli"): + for artifact in ("diffr", "diffr-cli", "diffr-cli-full"): name = archive_name(version, target, artifact) checksums[name] = hashlib.sha256((output / name).read_bytes()).hexdigest() (output / "SHA256SUMS").write_text("".join( @@ -89,6 +90,8 @@ def install parser.add_argument("--output", type=Path, required=True) parser.add_argument("--target", choices=TARGETS) parser.add_argument("--install", type=Path) + parser.add_argument("--cli-only", action="store_true") + parser.add_argument("--edition", choices=("lean", "full"), default="lean") args = parser.parse_args() if not re.fullmatch(r"\d+\.\d+\.\d+", args.version): parser.error("version must be a stable X.Y.Z release") @@ -96,6 +99,6 @@ def install if args.command == "pack": if not args.target or not args.install: parser.error("pack requires --target and --install") - pack(args.version, args.target, args.install.resolve(), args.output) + pack(args.version, args.target, args.install.resolve(), args.output, args.edition, args.cli_only) else: formula(args.version, args.output) diff --git a/scripts/smoke_editions.mjs b/scripts/smoke_editions.mjs new file mode 100644 index 000000000..869c8ecb8 --- /dev/null +++ b/scripts/smoke_editions.mjs @@ -0,0 +1,59 @@ +// Exercise the public downloader with exact staged release bytes, then switch executables. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { ensureBinary } from "../diffr-ts/bin/binary.mjs"; + +const dist = resolve(process.argv[2]); +const fixtures = resolve(process.argv[3]); +const { version } = JSON.parse(readFileSync(new URL("../diffr-ts/package.json", import.meta.url))); +const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-arm64": "aarch64-unknown-linux-gnu", "linux-x64": "x86_64-unknown-linux-gnu" }; +const target = targets[`${process.platform}-${process.arch}`]; +assert.ok(target); +const directory = mkdtempSync(join(tmpdir(), "diffr-editions-")); +try { + const editions = {}; + for (const [edition, artifact] of [["lean", "diffr-cli"], ["full", "diffr-cli-full"]]) { + const bytes = readFileSync(join(dist, `${artifact}-${version}-${target}.tar.gz`)); + editions[edition] = { artifact, sha256: { [target]: createHash("sha256").update(bytes).digest("hex") } }; + } + const pins = join(directory, "pins.json"); + writeFileSync(pins, JSON.stringify({ version, editions })); + const options = { directory, pins }; + globalThis.fetch = async (url) => { + assert.ok(url.startsWith(`https://github.com/devdotfast/diffr/releases/download/${version}/`)); + return new Response(readFileSync(join(dist, basename(url)))); + }; + const lean = await ensureBinary(options); + const full = await ensureBinary({ ...options, edition: "full" }); + assert.notEqual(lean, full); + globalThis.fetch = () => { throw new Error("network disabled"); }; + assert.equal(await ensureBinary({ ...options, check: true }), lean); + assert.equal(await ensureBinary({ ...options, edition: "full", check: true }), full); + + const env = { PATH: "", HOME: directory, XDG_CONFIG_HOME: directory }; + const run = (binary, args) => { + const result = spawnSync(binary, args, { env, cwd: directory, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + assert.equal(result.status, 0, result.stderr); + return result.stdout; + }; + for (const [prefix, extension] of [["apex", "trigger"], ["fortran", "f90"], ["f_sharp", "fs"], ["haskell", "hs"], ["julia", "jl"], ["ocaml", "ml"], ["ocaml_interface", "mli"], ["qml", "qml"], ["verilog", "sv"], ["vhdl", "vhd"]]) { + const args = ["--no-index", "--format", "ndjson", "--syntax", "--", join(fixtures, `${prefix}_1.${extension}`), join(fixtures, `${prefix}_2.${extension}`)]; + // Switch full -> lean -> full in one Node process with no application restart. + for (const binary of [full, lean, full]) { + const events = run(binary, args).trim().split("\n").map(JSON.parse); + assert.equal(events.at(-1).type, "complete"); + assert.equal(events.at(-1).failed, 0); + const file = events.find(event => event.type === "file"); + assert.equal(file.diff.stats.fallback?.code, binary === lean ? "unsupported_language" : undefined, prefix); + } + } + const common = ["--no-index", "--format", "ndjson", "--syntax", "--", join(fixtures, "comments_1.rs"), join(fixtures, "comments_2.rs")]; + assert.equal(run(lean, common), run(full, common), "bundled Rust output must match across editions"); + console.log(`PASS: ${target} lean/full installs, offline reuse and ten-language runtime switching`); +} finally { + rmSync(directory, { recursive: true, force: true }); +} diff --git a/src/parse/guess_language.rs b/src/parse/guess_language.rs index c36ca5fc6..f68550d6e 100644 --- a/src/parse/guess_language.rs +++ b/src/parse/guess_language.rs @@ -20,6 +20,7 @@ use strum::{EnumIter, IntoEnumIterator}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)] pub(crate) enum Language { Ada, + #[cfg(feature = "lang-apex")] Apex, Asm, Bash, @@ -44,6 +45,7 @@ pub(crate) enum Language { Fortran, Gleam, Go, + #[cfg(feature = "lang-haskell")] Haskell, Hcl, Html, @@ -52,6 +54,7 @@ pub(crate) enum Language { JavaScript, JavascriptJsx, Json, + #[cfg(feature = "lang-julia")] Julia, Kotlin, LaTeX, @@ -60,13 +63,16 @@ pub(crate) enum Language { Newick, Nix, ObjC, + #[cfg(feature = "lang-ocaml")] OCaml, + #[cfg(feature = "lang-ocaml")] OCamlInterface, Pascal, Perl, Php, Proto, Python, + #[cfg(feature = "lang-qml")] Qml, R, Racket, @@ -83,6 +89,7 @@ pub(crate) enum Language { TypeScriptTsx, #[cfg(feature = "lang-verilog")] Verilog, + #[cfg(feature = "lang-vhdl")] Vhdl, Xml, Yaml, @@ -124,6 +131,7 @@ pub(crate) fn language_override_from_name(name: &str) -> Option &'static str { match language { Ada => "Ada", + #[cfg(feature = "lang-apex")] Apex => "Apex", Asm => "Assembly", Bash => "Bash", @@ -148,6 +156,7 @@ pub(crate) fn language_name(language: Language) -> &'static str { Fortran => "Fortran", Gleam => "Gleam", Go => "Go", + #[cfg(feature = "lang-haskell")] Haskell => "Haskell", Hcl => "HCL", Html => "HTML", @@ -156,6 +165,7 @@ pub(crate) fn language_name(language: Language) -> &'static str { JavaScript => "JavaScript", JavascriptJsx => "JavaScript JSX", Json => "JSON", + #[cfg(feature = "lang-julia")] Julia => "Julia", Kotlin => "Kotlin", LaTeX => "LaTeX", @@ -164,13 +174,16 @@ pub(crate) fn language_name(language: Language) -> &'static str { Newick => "Newick", Nix => "Nix", ObjC => "Objective-C", + #[cfg(feature = "lang-ocaml")] OCaml => "OCaml", + #[cfg(feature = "lang-ocaml")] OCamlInterface => "OCaml Interface", Pascal => "Pascal", Perl => "Perl", Php => "PHP", Proto => "Proto", Python => "Python", + #[cfg(feature = "lang-qml")] Qml => "QML", R => "R", Racket => "Racket", @@ -187,6 +200,7 @@ pub(crate) fn language_name(language: Language) -> &'static str { TypeScriptTsx => "TypeScript TSX", #[cfg(feature = "lang-verilog")] Verilog => "Verilog", + #[cfg(feature = "lang-vhdl")] Vhdl => "VHDL", Xml => "XML", Yaml => "YAML", @@ -251,6 +265,7 @@ pub(crate) fn language_globs(language: Language) -> Vec { "zshenv", "zshrc", ], + #[cfg(feature = "lang-apex")] Apex => &["*.apexc", "*.trigger"], C => &["*.c"], Clojure => &[ @@ -301,6 +316,7 @@ pub(crate) fn language_globs(language: Language) -> Vec { Fortran => &["*.f", "*.for", "*.f90", "*.F", "*.FOR", "*.F90"], Gleam => &["*.gleam"], Go => &["*.go"], + #[cfg(feature = "lang-haskell")] Haskell => &["*.hs"], Hcl => &["*.hcl", "*.nomad", "*.tf", "*.tfvars", "*.workflow"], Html => &["*.html", "*.htm", "*.xhtml"], @@ -338,6 +354,7 @@ pub(crate) fn language_globs(language: Language) -> Vec { "flake.lock", ], JavascriptJsx => &["*.jsx"], + #[cfg(feature = "lang-julia")] Julia => &["*.jl"], Kotlin => &["*.kt", "*.ktm", "*.kts"], LaTeX => &["*.aux", "*.cls", "*.sty", "*.tex"], @@ -366,7 +383,9 @@ pub(crate) fn language_globs(language: Language) -> Vec { Newick => &["*.nhx", "*.nwk", "*.nh"], Nix => &["*.nix"], ObjC => &["*.m"], + #[cfg(feature = "lang-ocaml")] OCaml => &["*.ml"], + #[cfg(feature = "lang-ocaml")] OCamlInterface => &["*.mli"], Pascal => &["*.pas", "*.dfm", "*.dpr", "*.lpr", "*.pascal"], Perl => &["*.pm", "*.pl"], @@ -375,6 +394,7 @@ pub(crate) fn language_globs(language: Language) -> Vec { ], Proto => &["*.proto"], Python => &["*.py", "*.py3", "*.pyi", "*.bzl", "TARGETS", "BUCK", "DEPS"], + #[cfg(feature = "lang-qml")] Qml => &["*.qml"], R => &["*.R", "*.r", "*.rd", "*.rsx", ".Rprofile", "expr-dist"], Racket => &["*.rkt"], @@ -406,6 +426,7 @@ pub(crate) fn language_globs(language: Language) -> Vec { TypeScriptTsx => &["*.tsx"], #[cfg(feature = "lang-verilog")] Verilog => &["*.v", "*.sv", "*.vh"], + #[cfg(feature = "lang-vhdl")] Vhdl => &["*.vhdl", "*.vhd"], Xml => &[ "*.ant", @@ -560,6 +581,7 @@ fn from_emacs_mode_header(src: &str) -> Option { "fortran" => Fortran, "gleam" => Gleam, "go" => Go, + #[cfg(feature = "lang-haskell")] "haskell" => Haskell, "hcl" => Hcl, "html" => Html, @@ -581,10 +603,12 @@ fn from_emacs_mode_header(src: &str) -> Option { "sql" => Sql, "swift" => Swift, "toml" => Toml, + #[cfg(feature = "lang-ocaml")] "tuareg" => OCaml, "typescript" => TypeScript, #[cfg(feature = "lang-verilog")] "verilog" => Verilog, + #[cfg(feature = "lang-vhdl")] "vhdl" => Vhdl, "yaml" => Yaml, "zig" => Zig, @@ -613,9 +637,11 @@ fn from_shebang(src: &str) -> Option { "elixir" => return Some(Elixir), "escript" => return Some(Erlang), "fish" => return Some(Fish), + #[cfg(feature = "lang-haskell")] "runghc" | "runhaskell" | "runhugs" => return Some(Haskell), "chakra" | "d8" | "gjs" | "js" | "node" | "nodejs" | "qjs" | "rhino" | "v8" | "v8-shell" => return Some(JavaScript), + #[cfg(feature = "lang-ocaml")] "ocaml" | "ocamlrun" | "ocamlscript" => return Some(OCaml), "perl" => return Some(Perl), "python" | "python2" | "python3" => return Some(Python), @@ -725,6 +751,7 @@ mod tests { } #[test] + #[cfg(feature = "lang-ocaml")] fn test_guess_by_emacs_mode_shorthand() { let path = Path::new("foo"); assert_eq!(guess(path, "(* -*- tuareg -*- *)", &[]), Some(OCaml)); diff --git a/src/parse/tree_sitter_parser.rs b/src/parse/tree_sitter_parser.rs index 1aef0c877..6f4cdef9e 100644 --- a/src/parse/tree_sitter_parser.rs +++ b/src/parse/tree_sitter_parser.rs @@ -92,6 +92,7 @@ extern "C" { } // TODO: begin/end and object/end. +#[cfg(feature = "lang-ocaml")] const OCAML_ATOM_NODES: [&str; 6] = [ "character", "string", @@ -138,6 +139,7 @@ fn build_config(language: guess::Language) -> TreeSitterConfig { sub_languages: vec![], } } + #[cfg(feature = "lang-apex")] Apex => { let language_fn = tree_sitter_sfapex::apex::LANGUAGE; let language = tree_sitter::Language::new(language_fn); @@ -517,6 +519,7 @@ fn build_config(language: guess::Language) -> TreeSitterConfig { sub_languages: vec![], } } + #[cfg(feature = "lang-haskell")] Haskell => { let language_fn = tree_sitter_haskell::LANGUAGE; let language = tree_sitter::Language::new(language_fn); @@ -689,6 +692,7 @@ fn build_config(language: guess::Language) -> TreeSitterConfig { sub_languages: vec![], } } + #[cfg(feature = "lang-julia")] Julia => { let language_fn = tree_sitter_julia::LANGUAGE; let language = tree_sitter::Language::new(language_fn); @@ -843,6 +847,7 @@ fn build_config(language: guess::Language) -> TreeSitterConfig { sub_languages: vec![], } } + #[cfg(feature = "lang-ocaml")] OCaml => { let language_fn = tree_sitter_ocaml::LANGUAGE_OCAML; let language = tree_sitter::Language::new(language_fn); @@ -856,6 +861,7 @@ fn build_config(language: guess::Language) -> TreeSitterConfig { sub_languages: vec![], } } + #[cfg(feature = "lang-ocaml")] OCamlInterface => { let language_fn = tree_sitter_ocaml::LANGUAGE_OCAML_INTERFACE; let language = tree_sitter::Language::new(language_fn); @@ -961,6 +967,7 @@ fn build_config(language: guess::Language) -> TreeSitterConfig { sub_languages: vec![], } } + #[cfg(feature = "lang-qml")] Qml => { let language_fn = tree_sitter_qmljs::LANGUAGE; let language = tree_sitter::Language::new(language_fn); @@ -1265,6 +1272,7 @@ fn build_config(language: guess::Language) -> TreeSitterConfig { sub_languages: vec![], } } + #[cfg(feature = "lang-vhdl")] Vhdl => { let language_fn = tree_sitter_vhdl::LANGUAGE; let language = tree_sitter::Language::new(language_fn); diff --git a/tests/cli.rs b/tests/cli.rs index 7eff6f7ac..b1670166a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -40,6 +40,18 @@ fn optional_languages_follow_build_features() { std::fs::write(&config, "[plugins]\norder = []\n").unwrap(); for (name, fixture, extension, enabled) in [ + ("Apex", "apex", "trigger", cfg!(feature = "lang-apex")), + ("Haskell", "haskell", "hs", cfg!(feature = "lang-haskell")), + ("Julia", "julia", "jl", cfg!(feature = "lang-julia")), + ("OCaml", "ocaml", "ml", cfg!(feature = "lang-ocaml")), + ( + "OCaml Interface", + "ocaml_interface", + "mli", + cfg!(feature = "lang-ocaml"), + ), + ("QML", "qml", "qml", cfg!(feature = "lang-qml")), + ("VHDL", "vhdl", "vhd", cfg!(feature = "lang-vhdl")), ("Fortran", "fortran", "f90", cfg!(feature = "lang-fortran")), ("Verilog", "verilog", "sv", cfg!(feature = "lang-verilog")), ("F#", "f_sharp", "fs", cfg!(feature = "lang-fsharp")), From 796eb11a2c2ef77daa62b5247bba90b383dc22ca Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Tue, 29 Sep 2026 15:57:46 -0400 Subject: [PATCH 2/3] Simplify lean/full editions to a --full fetch flag Drop the Node runtime binary API, macOS signing, the Docker edition smoke test, and the pin script test. Release builds still pack a full CLI archive per target, and diffr-fetch --full selects it from optional full pins. --- .github/workflows/release.yml | 41 +---------- .gitignore | 3 - README.md | 20 ++---- diffr-ts/README.md | 46 ++---------- diffr-ts/bin/binary.d.mts | 11 --- diffr-ts/bin/binary.mjs | 90 ------------------------ diffr-ts/bin/fetch.mjs | 89 ++++++++++++++++++++--- diffr-ts/package.json | 7 +- diffr-ts/scripts/pin.mjs | 10 +-- diffr-ts/src/fetch.test.ts | 62 ++++------------ diffr-ts/src/pin.test.ts | 38 ---------- diffr-ts/src/version.test.ts | 7 +- scripts/editions.Dockerfile | 26 ------- scripts/editions.Dockerfile.dockerignore | 4 -- scripts/macos-entitlements.plist | 5 -- scripts/release.py | 8 +-- scripts/smoke_editions.mjs | 59 ---------------- 17 files changed, 118 insertions(+), 408 deletions(-) delete mode 100644 diffr-ts/bin/binary.d.mts delete mode 100644 diffr-ts/bin/binary.mjs delete mode 100644 diffr-ts/src/pin.test.ts delete mode 100644 scripts/editions.Dockerfile delete mode 100644 scripts/editions.Dockerfile.dockerignore delete mode 100644 scripts/macos-entitlements.plist delete mode 100644 scripts/smoke_editions.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 302bbf819..1a7c329ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: with: bun-version: 1.3.10 - uses: dtolnay/rust-toolchain@stable - - name: Build both editions + - name: Build and package env: TARGET: ${{ matrix.target }} RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} @@ -48,34 +48,6 @@ jobs: fi cargo xtask install --root "$RUNNER_TEMP/install" cargo install --path . --locked --features all-languages --root "$RUNNER_TEMP/full" - - name: Import release signing identity - if: runner.os == 'macOS' && github.ref_type == 'tag' - env: - CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} - CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - test -n "$CERTIFICATE" && test -n "$CERTIFICATE_PASSWORD" - python3 -c 'import os,base64; open(os.environ["RUNNER_TEMP"]+"/certificate.p12","wb").write(base64.b64decode(os.environ["CERTIFICATE"]))' - password=$(openssl rand -hex 24) - security create-keychain -p "$password" "$RUNNER_TEMP/diffr.keychain-db" - security set-keychain-settings -lut 21600 "$RUNNER_TEMP/diffr.keychain-db" - security unlock-keychain -p "$password" "$RUNNER_TEMP/diffr.keychain-db" - security import "$RUNNER_TEMP/certificate.p12" -k "$RUNNER_TEMP/diffr.keychain-db" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -k "$password" "$RUNNER_TEMP/diffr.keychain-db" - security list-keychains -d user -s "$RUNNER_TEMP/diffr.keychain-db" - echo 'DIFFR_SIGN_IDENTITY=Developer ID Application: Workable Solutions Inc. (PWXY59YDAY)' >> "$GITHUB_ENV" - - name: Sign macOS executables before packaging - if: runner.os == 'macOS' - run: | - for binary in "$RUNNER_TEMP/install/bin/diffr" "$RUNNER_TEMP/full/bin/diffr"; do - codesign --force --sign "${DIFFR_SIGN_IDENTITY:--}" --options runtime --entitlements scripts/macos-entitlements.plist "$binary" - codesign --verify --strict "$binary" - done - - name: Package both editions - env: - TARGET: ${{ matrix.target }} - run: | - version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) python3 scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/install" --output dist python3 scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/full" --edition full --output dist - name: Verify extracted archives without Bun or checkout assets @@ -88,17 +60,6 @@ jobs: python3 xtask/tests/smoke_install.py "$RUNNER_TEMP/extracted" python3 scripts/smoke_cli_archive.py "dist/diffr-cli-$version-$TARGET.tar.gz" "$version" python3 scripts/smoke_cli_archive.py "dist/diffr-cli-full-$version-$TARGET.tar.gz" "$version" - node scripts/smoke_editions.mjs dist sample_files - - name: Test both editions in a minimal Linux runtime - if: runner.os == 'Linux' - run: | - docker build -f scripts/editions.Dockerfile --target runtime -t diffr-editions . - docker run --rm --network none --read-only --user 65534:65534 --tmpfs /tmp:rw,exec,nosuid,size=512m diffr-editions - - name: Clean signing material - if: always() && runner.os == 'macOS' && github.ref_type == 'tag' - run: | - security delete-keychain "$RUNNER_TEMP/diffr.keychain-db" || true - rm -f "$RUNNER_TEMP/certificate.p12" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ matrix.target }} diff --git a/.gitignore b/.gitignore index fb3e07fa6..0b191a4af 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,3 @@ plugin.wasm # The bundled WASM summarizer ships ready to embed without a WASM toolchain. !/plugins/summarize/plugin.wasm /crates/*/target - -# Staged release archives. -/dist/ diff --git a/README.md b/README.md index da84f085a..8ba046368 100644 --- a/README.md +++ b/README.md @@ -81,18 +81,8 @@ diffr builds on, starting with the lovely ## Language editions -Default builds are lean. Build all supported parsers with -`cargo build --release --locked --features all-languages`, or select individual -`lang-*` Cargo features. Lean builds omit Apex, Fortran, F#, Haskell, Julia, -OCaml, QML, Verilog, and VHDL and use text diffs for those files. LaTeX and SQL -remain included. - -Releases provide `diffr-cli--.tar.gz` (lean) and -`diffr-cli-full--.tar.gz` (full). The existing `diffr` archive -contains the lean CLI and TUI. Both editions use the same executable name and wire -protocol. Node applications can [download and select either edition](diffr-ts/README.md#choosing-languages-at-runtime). - -To make another bundled parser optional, mark its Cargo dependency optional, add -its `lang-*` feature to `all-languages`, and gate its recognition/configuration -with that feature. Keep parser rules in Rust; no separate language package or -registry is required. Test both default and `all-languages` builds. +Default builds omit Apex, Fortran, F#, Haskell, Julia, OCaml, QML, Verilog, and +VHDL, and fall back to text diffs for them. Build with `--features all-languages` +(or individual `lang-*` features) to include them. Releases ship both +`diffr-cli--.tar.gz` (lean) and +`diffr-cli-full--.tar.gz`. diff --git a/diffr-ts/README.md b/diffr-ts/README.md index 5045c88c5..33a05da7d 100644 --- a/diffr-ts/README.md +++ b/diffr-ts/README.md @@ -9,47 +9,16 @@ const event = decodeStructuralDiffEvent(line); ``` ```sh -npx --package @dev.fast/diffr@0.1.7 diffr-fetch --into ./bin --required +npx --package @dev.fast/diffr@0.1.6 diffr-fetch --into ./bin --required ``` -Supports macOS arm64, macOS x64, Linux arm64/glibc, and Linux x64/glibc. `--check` verifies an existing install without +Supports macOS arm64, macOS x64, Linux arm64, and Linux x64. `--check` verifies an existing install without network access. Downloads warn on network failure unless `--required`; invalid hashes or archives always fail. -## Choosing languages at runtime - -The default `lean` edition excludes Apex, Fortran, F#, Haskell, Julia, OCaml -(including interfaces), QML, Verilog, and VHDL. It uses text diffs for those files. -The `full` edition compiles all supported languages. Both use the same wire format. - -```ts -import { ensureBinary } from "@dev.fast/diffr/binary"; -import { spawn } from "node:child_process"; - -// Call when the user selects an edition; retain the returned path for comparisons. -const executable = await ensureBinary({ - directory: appDataDirectory, - edition: "full", // or "lean" -}); -spawn(executable, ["--repo", repository, "--format", "ndjson", "HEAD"]); -``` - -The API downloads the package's exact pinned release and process architecture, -checks the archive hash, and stores editions separately under -`////diffr`. Installed binaries are checked -for corruption and reused offline. `check: true` prohibits downloads. Select a new -path only after the promise succeeds; existing comparisons finish on their old -executable. Clear application diff caches when switching. Do not replace files -inside a signed application bundle. - -The CLI also accepts `--edition full`; `--into` still writes `diffr` directly into -the given directory for existing build scripts. Use different directories to keep -both editions. Neither API needs Rust, npm, or a compiler at runtime; extraction -uses the operating system's `tar`. - -Full downloads require a release with full-edition pins. The existing 0.1.7 pins -remain unchanged until matching artifacts are published; requesting an unpinned -edition fails rather than downloading a different version. +`--full` fetches the full edition, which adds the parsers lean builds omit (Apex, +Fortran, F#, Haskell, Julia, OCaml, QML, Verilog, VHDL). It requires full-edition +pins, which start with the next release. ## Wire changes @@ -65,9 +34,8 @@ Keep these files in sync: ## Release After tagging the matching Rust release and building with `cargo build --locked`, -run from `diffr-ts`. Pinning downloads all eight CLI archives (two editions on -four targets) and writes the catalog only after every download succeeds. Legacy -single-edition pins remain supported: +run from `diffr-ts`. New pins select the lean and full CLI-only release archives; +older pins continue to use the combined archives: ```sh bun install --frozen-lockfile diff --git a/diffr-ts/bin/binary.d.mts b/diffr-ts/bin/binary.d.mts deleted file mode 100644 index b1cd872b6..000000000 --- a/diffr-ts/bin/binary.d.mts +++ /dev/null @@ -1,11 +0,0 @@ -export type Edition = "lean" | "full"; - -/** Downloads only when requested. Returns an absolute executable path. */ -export function ensureBinary(options: { - directory: string; - edition?: Edition; - /** Verify a cached installation without downloading. */ - check?: boolean; - /** Override the package's pinned release catalog. */ - pins?: string; -}): Promise; diff --git a/diffr-ts/bin/binary.mjs b/diffr-ts/bin/binary.mjs deleted file mode 100644 index 5255f4cea..000000000 --- a/diffr-ts/bin/binary.mjs +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu" }; - -/** Download a pinned edition into a versioned cache and return its executable. */ -export async function ensureBinary({ directory, edition = "lean", check = false, pins: pinsPath = join(root, "pins.json") }) { - if (typeof directory !== "string" || !directory) throw new Error("directory is required"); - if (!["lean", "full"].includes(edition)) throw new Error(`unknown diffr edition: ${edition}`); - const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); - const target = targets[`${process.platform}-${process.arch}`]; - if (!target) throw new Error(`no diffr release for ${process.platform}-${process.arch}`); - return installBinary({ into: join(directory, version, target, edition), edition, check, pinsPath, required: true }); -} - -export async function installBinary({ into, edition = "lean", check = false, required = false, pinsPath = join(root, "pins.json") }) { - into = resolve(into); - const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); - const pins = JSON.parse(readFileSync(pinsPath, "utf8")); - if (pins.version !== version) throw new Error(`pins.json is for ${pins.version}, package is ${version}; run \`npm run pin\``); - const target = targets[`${process.platform}-${process.arch}`]; - if (!target) throw new Error(`no diffr release for ${process.platform}-${process.arch}`); - if (!["lean", "full"].includes(edition)) throw new Error(`unknown diffr edition: ${edition}`); - const pin = pins.editions?.[edition] ?? (edition === "lean" ? pins : undefined); - if (!pin) throw new Error(`pins.json has no ${edition} edition for ${version}`); - const expected = pin.sha256?.[target]; - if (typeof expected !== "string" || !/^[a-f0-9]{64}$/.test(expected)) { - throw new Error(`pins.json has no valid sha256 for ${target}`); - } - - const artifact = pin.artifact ?? "diffr"; - const allowed = edition === "full" ? ["diffr-cli-full"] : ["diffr", "diffr-cli"]; - if (!allowed.includes(artifact)) throw new Error(`unknown pinned artifact: ${artifact}`); - const binary = join(into, "diffr"); - const stampPath = join(into, "diffr.stamp.json"); - let installed = false; - try { - const stamp = JSON.parse(readFileSync(stampPath, "utf8")); - const stat = lstatSync(binary); - installed = stat.isFile() && (stat.mode & 0o111) !== 0 && stamp.version === version && stamp.target === target - && stamp.edition === edition && stamp.archiveSha256 === expected - && stamp.binarySha256 === createHash("sha256").update(readFileSync(binary)).digest("hex"); - } catch { /* Missing or damaged installations are fetched again. */ } - if (installed) { - console.error(`diffr ${version} (${target}) already at ${binary}`); - return binary; - } - if (check) throw new Error(`${binary} is missing or not diffr ${version} (${target})`); - - const asset = `${artifact}-${version}-${target}.tar.gz`; - const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${asset}`; - let bytes; - try { - const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`); - bytes = Buffer.from(await response.arrayBuffer()); - } catch (error) { - if (required) throw new Error(`could not download ${url}: ${error.message}`); - console.warn(`diffr-fetch: could not download ${url}: ${error.message}; continuing without a bundled diffr`); - return; - } - const actual = createHash("sha256").update(bytes).digest("hex"); - if (actual !== expected) throw new Error(`${asset} sha256 ${actual} does not match pinned ${expected}`); - - mkdirSync(into, { recursive: true }); - const staging = mkdtempSync(join(into, ".diffr-")); - try { - const archive = join(staging, "archive.tar.gz"); - writeFileSync(archive, bytes); - const tar = spawnSync("tar", ["-xzf", archive, "-C", staging, "diffr"], { encoding: "utf8" }); - const extracted = join(staging, "diffr"); - if (tar.status !== 0 || !lstatSync(extracted).isFile()) { - throw new Error(`${asset} does not contain a regular diffr file at its root`); - } - chmodSync(extracted, 0o755); - const stamp = join(staging, "stamp.json"); - writeFileSync(stamp, `${JSON.stringify({ version, target, edition, archiveSha256: expected, binarySha256: createHash("sha256").update(readFileSync(extracted)).digest("hex") }, null, 2)}\n`); - renameSync(extracted, binary); - renameSync(stamp, stampPath); - } finally { - rmSync(staging, { recursive: true, force: true }); - } - console.error(`diffr ${version} (${target}) installed at ${binary}`); - return binary; -} diff --git a/diffr-ts/bin/fetch.mjs b/diffr-ts/bin/fetch.mjs index ec56fa6f5..ac9391326 100755 --- a/diffr-ts/bin/fetch.mjs +++ b/diffr-ts/bin/fetch.mjs @@ -1,19 +1,92 @@ #!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; -import { installBinary } from "./binary.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu" }; try { + await main(); +} catch (error) { + console.error(`diffr-fetch: ${error.message}`); + process.exitCode = 1; +} + +async function main() { const { values } = parseArgs({ options: { into: { type: "string" }, - edition: { type: "string", default: "lean" }, + full: { type: "boolean", default: false }, check: { type: "boolean" }, required: { type: "boolean" }, - pins: { type: "string" }, + pins: { type: "string", default: join(root, "pins.json") }, } }); if (!values.into) throw new Error("--into is required"); - await installBinary({ into: values.into, edition: values.edition, check: values.check, - required: values.required, pinsPath: values.pins }); -} catch (error) { - console.error(`diffr-fetch: ${error.message}`); - process.exitCode = 1; + const into = resolve(values.into); + const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const pins = JSON.parse(readFileSync(values.pins, "utf8")); + if (pins.version !== version) throw new Error(`pins.json is for ${pins.version}, package is ${version}; run \`npm run pin\``); + const target = targets[`${process.platform}-${process.arch}`]; + if (!target) throw new Error(`no diffr release for ${process.platform}-${process.arch}`); + const pin = values.full ? pins.full : pins; + if (!pin) throw new Error(`pins.json has no full edition for ${version}`); + const expected = pin.sha256?.[target]; + if (typeof expected !== "string" || !/^[a-f0-9]{64}$/.test(expected)) { + throw new Error(`pins.json has no valid sha256 for ${target}`); + } + + const binary = join(into, "diffr"); + const stampPath = join(into, "diffr.stamp.json"); + let installed = false; + try { + const stamp = JSON.parse(readFileSync(stampPath, "utf8")); + const stat = lstatSync(binary); + installed = stat.isFile() && (stat.mode & 0o111) !== 0 && stamp.version === version && stamp.target === target + && Boolean(stamp.full) === values.full; + } catch { /* Missing or damaged installations are fetched again. */ } + if (installed) { + console.log(`diffr ${version} (${target}) already at ${binary}`); + return; + } + if (values.check) throw new Error(`${binary} is missing or not diffr ${version} (${target})`); + + const artifact = values.full ? "diffr-cli-full" : pins.artifact ?? "diffr"; + if (!["diffr", "diffr-cli", "diffr-cli-full"].includes(artifact)) throw new Error(`unknown pinned artifact: ${artifact}`); + const asset = `${artifact}-${version}-${target}.tar.gz`; + const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${asset}`; + let bytes; + try { + const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`); + bytes = Buffer.from(await response.arrayBuffer()); + } catch (error) { + if (values.required) throw new Error(`could not download ${url}: ${error.message}`); + console.warn(`diffr-fetch: could not download ${url}: ${error.message}; continuing without a bundled diffr`); + return; + } + const actual = createHash("sha256").update(bytes).digest("hex"); + if (actual !== expected) throw new Error(`${asset} sha256 ${actual} does not match pinned ${expected}`); + + mkdirSync(into, { recursive: true }); + const staging = mkdtempSync(join(into, ".diffr-")); + try { + const archive = join(staging, "archive.tar.gz"); + writeFileSync(archive, bytes); + const tar = spawnSync("tar", ["-xzf", archive, "-C", staging, "diffr"], { encoding: "utf8" }); + const extracted = join(staging, "diffr"); + if (tar.status !== 0 || !lstatSync(extracted).isFile()) { + throw new Error(`${asset} does not contain a regular diffr file at its root`); + } + chmodSync(extracted, 0o755); + const stamp = join(staging, "stamp.json"); + writeFileSync(stamp, `${JSON.stringify({ version, target, ...(values.full && { full: true }) }, null, 2)}\n`); + renameSync(extracted, binary); + renameSync(stamp, stampPath); + } finally { + rmSync(staging, { recursive: true, force: true }); + } + console.log(`diffr ${version} (${target}) installed at ${binary}`); } diff --git a/diffr-ts/package.json b/diffr-ts/package.json index aeee3a456..2f5fdcdc5 100644 --- a/diffr-ts/package.json +++ b/diffr-ts/package.json @@ -12,12 +12,7 @@ "default": "./dist/index.js" }, "./package.json": "./package.json", - "./src/*": "./src/*", - "./binary": { - "types": "./bin/binary.d.mts", - "import": "./bin/binary.mjs", - "default": "./bin/binary.mjs" - } + "./src/*": "./src/*" }, "bin": { "diffr-fetch": "bin/fetch.mjs" diff --git a/diffr-ts/scripts/pin.mjs b/diffr-ts/scripts/pin.mjs index e8ef830ff..5e240b3cd 100755 --- a/diffr-ts/scripts/pin.mjs +++ b/diffr-ts/scripts/pin.mjs @@ -8,8 +8,7 @@ const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, ".."); const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; const targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]; -const editions = {}; -for (const [edition, artifact] of [["lean", "diffr-cli"], ["full", "diffr-cli-full"]]) { +async function hashes(artifact) { const sha256 = {}; for (const target of targets) { const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${artifact}-${version}-${target}.tar.gz`; @@ -19,9 +18,10 @@ for (const [edition, artifact] of [["lean", "diffr-cli"], ["full", "diffr-cli-fu process.exit(1); } sha256[target] = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex"); - console.log(`${target} ${sha256[target]}`); + console.log(`${artifact} ${target} ${sha256[target]}`); } - editions[edition] = { artifact, sha256 }; + return sha256; } -writeFileSync(join(root, "pins.json"), `${JSON.stringify({ version, editions }, null, 2)}\n`); +const pins = { version, artifact: "diffr-cli", sha256: await hashes("diffr-cli"), full: { sha256: await hashes("diffr-cli-full") } }; +writeFileSync(join(root, "pins.json"), `${JSON.stringify(pins, null, 2)}\n`); console.log(`wrote pins.json for ${version}`); diff --git a/diffr-ts/src/fetch.test.ts b/diffr-ts/src/fetch.test.ts index cd089781d..1cf4f9ab6 100644 --- a/diffr-ts/src/fetch.test.ts +++ b/diffr-ts/src/fetch.test.ts @@ -56,7 +56,7 @@ test("downloads, verifies, extracts, stamps, and reuses the pinned binary", () = ); expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content); expect(statSync(join(f.into, "diffr")).mode & 0o777).toBe(0o755); - expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toMatchObject({ version, target, edition: "lean" }); + expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toEqual({ version, target }); expect(readdirSync(f.into).sort()).toEqual(["diffr", "diffr.stamp.json"]); rmSync(join(f.dir, "requested")); expect(f.run(["--check"], true).code).toBe(0); @@ -131,8 +131,8 @@ test("Linux ARM64 downloads and stamps the aarch64 release", () => { `https://github.com/devdotfast/diffr/releases/download/${version}/diffr-cli-${version}-aarch64-unknown-linux-gnu.tar.gz`, ); expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content); - expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toMatchObject({ - version, target: "aarch64-unknown-linux-gnu", edition: "lean", + expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toEqual({ + version, target: "aarch64-unknown-linux-gnu", }); expect(f.run(["--check"], true).code).toBe(0); }); @@ -153,54 +153,16 @@ test("unknown pinned artifacts fail before downloading", () => { expect(existsSync(join(f.dir, "requested"))).toBe(false); }); - -test("editions select distinct pinned archives and reject unpublished full builds", () => { +test("--full downloads the full archive, fails without full pins, and refetches when switching", () => { const f = fixture("diffr", "diffr-cli"); - expect(f.run(["--edition", "full"]).err).toContain("no full edition"); + expect(f.run(["--full"]).err).toContain("no full edition"); expect(existsSync(join(f.dir, "requested"))).toBe(false); - const lean = JSON.parse(readFileSync(f.pins, "utf8")); - writeFileSync(f.pins, JSON.stringify({ version, editions: { - lean, full: { ...lean, artifact: "diffr-cli-full" }, - } })); - expect(f.run(["--edition", "full"]).code).toBe(0); - expect(readFileSync(join(f.dir, "requested"), "utf8")).toContain(`/diffr-cli-full-${version}-${target}.tar.gz`); + const pins = JSON.parse(readFileSync(f.pins, "utf8")); + writeFileSync(f.pins, JSON.stringify({ ...pins, full: { sha256: pins.sha256 } })); + expect(f.run(["--full"]).code).toBe(0); + expect(readFileSync(join(f.dir, "requested"), "utf8")).toBe( + `https://github.com/devdotfast/diffr/releases/download/${version}/diffr-cli-full-${version}-${target}.tar.gz`, + ); + expect(f.run(["--full", "--check"]).code).toBe(0); expect(f.run(["--check"]).code).toBe(1); - expect(f.run(["--edition", "full", "--check"], true).code).toBe(0); - expect(f.run(["--edition", "invalid"]).err).toContain("unknown diffr edition"); -}); - -test("a corrupt cached executable is rejected and repaired", () => { - const f = fixture(); - expect(f.run().code).toBe(0); - writeFileSync(join(f.into, "diffr"), "corrupt"); - expect(f.run(["--check"], true).code).toBe(1); - expect(f.run().code).toBe(0); - expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content); -}); - -test("runtime API keeps editions separate, supports concurrent installs and offline switching", () => { - const f = fixture("diffr", "diffr-cli"); - const lean = JSON.parse(readFileSync(f.pins, "utf8")); - writeFileSync(f.pins, JSON.stringify({ version, editions: { lean, full: { ...lean, artifact: "diffr-cli-full" } } })); - const script = join(f.dir, "switch.mjs"); - writeFileSync(script, ` - import assert from "node:assert/strict"; - import { ensureBinary } from ${JSON.stringify(new URL("../bin/binary.mjs", import.meta.url).href)}; - const options = ${JSON.stringify({ directory: f.into, pins: f.pins })}; - const lean = await ensureBinary(options); - const full = await Promise.all([ensureBinary({ ...options, edition: "full" }), ensureBinary({ ...options, edition: "full" })]); - assert.equal(full[0], full[1]); - assert.notEqual(lean, full[0]); - globalThis.fetch = () => { throw new Error("offline"); }; - assert.equal(await ensureBinary({ ...options, check: true }), lean); - assert.equal(await ensureBinary({ ...options, edition: "full", check: true }), full[0]); - console.log(JSON.stringify([lean, full[0]])); - `); - const result = Bun.spawnSync(["node", "--import", join(f.dir, "mock.mjs"), script]); - expect(result.exitCode, result.stderr.toString()).toBe(0); - const paths = JSON.parse(result.stdout.toString()); - for (const [i, edition] of ["lean", "full"].entries()) { - expect(paths[i]).toBe(join(f.into, version, target!, edition, "diffr")); - expect(readFileSync(paths[i], "utf8")).toBe(f.content); - } }); diff --git a/diffr-ts/src/pin.test.ts b/diffr-ts/src/pin.test.ts deleted file mode 100644 index 6e55c0def..000000000 --- a/diffr-ts/src/pin.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, test } from "bun:test"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -for (const failLast of [false, true]) { - test(`pinning ${failLast ? "preserves the previous catalog on partial publication" : "requires all eight edition archives"}`, () => { - const root = mkdtempSync(join(tmpdir(), "diffr-pin-")); - try { - mkdirSync(join(root, "scripts")); - cpSync(join(import.meta.dir, "../scripts/pin.mjs"), join(root, "scripts/pin.mjs")); - writeFileSync(join(root, "package.json"), JSON.stringify({ version: "1.2.3" })); - writeFileSync(join(root, "pins.json"), "previous pins"); - writeFileSync(join(root, "mock.mjs"), ` - import assert from "node:assert/strict"; - let count = 0; - globalThis.fetch = async (url) => { - count++; - assert.ok(url.includes(count <= 4 ? "/diffr-cli-1.2.3-" : "/diffr-cli-full-1.2.3-")); - return new Response("archive", { status: ${failLast} && count === 8 ? 404 : 200 }); - }; - process.on("exit", () => assert.equal(count, 8)); - `); - const result = Bun.spawnSync(["node", "--import", join(root, "mock.mjs"), join(root, "scripts/pin.mjs")]); - expect(result.exitCode, result.stderr.toString()).toBe(failLast ? 1 : 0); - const contents = readFileSync(join(root, "pins.json"), "utf8"); - if (failLast) expect(contents).toBe("previous pins"); - else { - const pins = JSON.parse(contents); - expect(Object.keys(pins.editions).sort()).toEqual(["full", "lean"]); - expect(pins.editions.full.artifact).toBe("diffr-cli-full"); - for (const edition of ["lean", "full"]) expect(Object.keys(pins.editions[edition].sha256)).toHaveLength(4); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -} diff --git a/diffr-ts/src/version.test.ts b/diffr-ts/src/version.test.ts index 7befeef6c..da34cdd1f 100644 --- a/diffr-ts/src/version.test.ts +++ b/diffr-ts/src/version.test.ts @@ -17,11 +17,10 @@ test("release pins match the package version and cover all supported targets", ( const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const pins = JSON.parse(readFileSync(join(root, "pins.json"), "utf8")); expect(pins.version).toBe(pkg.version); - if (pins.editions) expect(Object.keys(pins.editions).sort()).toEqual(["full", "lean"]); - for (const pin of Object.values(pins.editions ?? { lean: pins }) as { sha256: Record }[]) { - expect(Object.keys(pin.sha256).sort()).toEqual([ + for (const { sha256 } of pins.full ? [pins, pins.full] : [pins]) { + expect(Object.keys(sha256).sort()).toEqual([ "aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", ]); - for (const hash of Object.values(pin.sha256)) expect(hash).toMatch(/^[a-f0-9]{64}$/); + for (const hash of Object.values(sha256)) expect(hash).toMatch(/^[a-f0-9]{64}$/); } }); diff --git a/scripts/editions.Dockerfile b/scripts/editions.Dockerfile deleted file mode 100644 index 8eb20d455..000000000 --- a/scripts/editions.Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM rust:1.85-bookworm AS build -RUN apt-get update && apt-get install -y --no-install-recommends python3 cmake clang pkg-config && rm -rf /var/lib/apt/lists/* -ENV RUSTUP_TOOLCHAIN=1.85.0 CARGO_BUILD_JOBS=4 -WORKDIR /work -COPY . . -RUN --mount=type=cache,target=/work/target,sharing=locked \ - --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ - target=$(rustc -vV | sed -n 's/^host: //p') && \ - version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) && \ - cargo build --release --locked && \ - mkdir -p /lean/bin /full/bin /artifacts && cp target/release/diffr /lean/bin/ && \ - cargo build --release --locked --features all-languages && cp target/release/diffr /full/bin/ && \ - python3 scripts/release.py pack --version "$version" --target "$target" --install /lean --cli-only --output /artifacts && \ - python3 scripts/release.py pack --version "$version" --target "$target" --install /full --edition full --output /artifacts - -FROM scratch AS artifacts -COPY --from=build /artifacts/ / - -FROM node:22-bookworm-slim AS runtime -WORKDIR /opt/diffr -COPY dist/ dist/ -COPY diffr-ts/bin/ diffr-ts/bin/ -COPY diffr-ts/package.json diffr-ts/package.json -COPY scripts/smoke_editions.mjs scripts/smoke_editions.mjs -COPY sample_files/ sample_files/ -CMD ["node", "scripts/smoke_editions.mjs", "dist", "sample_files"] diff --git a/scripts/editions.Dockerfile.dockerignore b/scripts/editions.Dockerfile.dockerignore deleted file mode 100644 index 5fcf89465..000000000 --- a/scripts/editions.Dockerfile.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -.git -target -**/node_modules -**/__pycache__ diff --git a/scripts/macos-entitlements.plist b/scripts/macos-entitlements.plist deleted file mode 100644 index d7757761a..000000000 --- a/scripts/macos-entitlements.plist +++ /dev/null @@ -1,5 +0,0 @@ - - - -com.apple.security.cs.allow-unsigned-executable-memory - diff --git a/scripts/release.py b/scripts/release.py index 4ce68ef2e..27f6751a8 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -14,12 +14,11 @@ def archive_name(version, target, artifact="diffr"): return f"{artifact}-{version}-{target}.tar.gz" -def pack(version, target, install, output, edition="lean", cli_only=False): +def pack(version, target, install, output, edition="lean"): actual = subprocess.check_output([install / "bin/diffr", "--version"], text=True).strip() if actual != f"diffr {version}": raise ValueError(f"Release version mismatch: {actual}") - artifacts = ("diffr-cli-full",) if edition == "full" else (("diffr-cli",) if cli_only else ("diffr", "diffr-cli")) - for artifact in artifacts: + for artifact in ("diffr-cli-full",) if edition == "full" else ("diffr", "diffr-cli"): with tarfile.open(output / archive_name(version, target, artifact), "w:gz") as archive: binaries = ("diffr", "diffr-tui") if artifact == "diffr" else ("diffr",) for name in binaries: @@ -90,7 +89,6 @@ def install parser.add_argument("--output", type=Path, required=True) parser.add_argument("--target", choices=TARGETS) parser.add_argument("--install", type=Path) - parser.add_argument("--cli-only", action="store_true") parser.add_argument("--edition", choices=("lean", "full"), default="lean") args = parser.parse_args() if not re.fullmatch(r"\d+\.\d+\.\d+", args.version): @@ -99,6 +97,6 @@ def install if args.command == "pack": if not args.target or not args.install: parser.error("pack requires --target and --install") - pack(args.version, args.target, args.install.resolve(), args.output, args.edition, args.cli_only) + pack(args.version, args.target, args.install.resolve(), args.output, args.edition) else: formula(args.version, args.output) diff --git a/scripts/smoke_editions.mjs b/scripts/smoke_editions.mjs deleted file mode 100644 index 869c8ecb8..000000000 --- a/scripts/smoke_editions.mjs +++ /dev/null @@ -1,59 +0,0 @@ -// Exercise the public downloader with exact staged release bytes, then switch executables. -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, join, resolve } from "node:path"; -import { ensureBinary } from "../diffr-ts/bin/binary.mjs"; - -const dist = resolve(process.argv[2]); -const fixtures = resolve(process.argv[3]); -const { version } = JSON.parse(readFileSync(new URL("../diffr-ts/package.json", import.meta.url))); -const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-arm64": "aarch64-unknown-linux-gnu", "linux-x64": "x86_64-unknown-linux-gnu" }; -const target = targets[`${process.platform}-${process.arch}`]; -assert.ok(target); -const directory = mkdtempSync(join(tmpdir(), "diffr-editions-")); -try { - const editions = {}; - for (const [edition, artifact] of [["lean", "diffr-cli"], ["full", "diffr-cli-full"]]) { - const bytes = readFileSync(join(dist, `${artifact}-${version}-${target}.tar.gz`)); - editions[edition] = { artifact, sha256: { [target]: createHash("sha256").update(bytes).digest("hex") } }; - } - const pins = join(directory, "pins.json"); - writeFileSync(pins, JSON.stringify({ version, editions })); - const options = { directory, pins }; - globalThis.fetch = async (url) => { - assert.ok(url.startsWith(`https://github.com/devdotfast/diffr/releases/download/${version}/`)); - return new Response(readFileSync(join(dist, basename(url)))); - }; - const lean = await ensureBinary(options); - const full = await ensureBinary({ ...options, edition: "full" }); - assert.notEqual(lean, full); - globalThis.fetch = () => { throw new Error("network disabled"); }; - assert.equal(await ensureBinary({ ...options, check: true }), lean); - assert.equal(await ensureBinary({ ...options, edition: "full", check: true }), full); - - const env = { PATH: "", HOME: directory, XDG_CONFIG_HOME: directory }; - const run = (binary, args) => { - const result = spawnSync(binary, args, { env, cwd: directory, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); - assert.equal(result.status, 0, result.stderr); - return result.stdout; - }; - for (const [prefix, extension] of [["apex", "trigger"], ["fortran", "f90"], ["f_sharp", "fs"], ["haskell", "hs"], ["julia", "jl"], ["ocaml", "ml"], ["ocaml_interface", "mli"], ["qml", "qml"], ["verilog", "sv"], ["vhdl", "vhd"]]) { - const args = ["--no-index", "--format", "ndjson", "--syntax", "--", join(fixtures, `${prefix}_1.${extension}`), join(fixtures, `${prefix}_2.${extension}`)]; - // Switch full -> lean -> full in one Node process with no application restart. - for (const binary of [full, lean, full]) { - const events = run(binary, args).trim().split("\n").map(JSON.parse); - assert.equal(events.at(-1).type, "complete"); - assert.equal(events.at(-1).failed, 0); - const file = events.find(event => event.type === "file"); - assert.equal(file.diff.stats.fallback?.code, binary === lean ? "unsupported_language" : undefined, prefix); - } - } - const common = ["--no-index", "--format", "ndjson", "--syntax", "--", join(fixtures, "comments_1.rs"), join(fixtures, "comments_2.rs")]; - assert.equal(run(lean, common), run(full, common), "bundled Rust output must match across editions"); - console.log(`PASS: ${target} lean/full installs, offline reuse and ten-language runtime switching`); -} finally { - rmSync(directory, { recursive: true, force: true }); -} From e331d994405033ebc2a46d849e72c34b314f94e4 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Tue, 29 Sep 2026 16:00:54 -0400 Subject: [PATCH 3/3] Drop root README editions section --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index 8ba046368..0c73fb4b7 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,3 @@ We hope in the future that we can find some way to upstream some / all of these MIT. See `LICENSE` for the terms and `NOTICE` for the third-party work diffr builds on, starting with the lovely [difftastic](https://github.com/Wilfred/difftastic). - -## Language editions - -Default builds omit Apex, Fortran, F#, Haskell, Julia, OCaml, QML, Verilog, and -VHDL, and fall back to text diffs for them. Build with `--features all-languages` -(or individual `lang-*` features) to include them. Releases ship both -`diffr-cli--.tar.gz` (lean) and -`diffr-cli-full--.tar.gz`.