From 221dc0139732070f69704b13beb5b465a60c7eb0 Mon Sep 17 00:00:00 2001 From: hermes Date: Tue, 18 Aug 2026 11:14:55 +0000 Subject: [PATCH 1/2] feat: add npm distribution wrapper for idx-cli Add an npm wrapper (package.json + postinstall downloader) so the Rust binary can be installed via npm/npx. Include a local smoke test, a GitHub Release cross-compile workflow, and nodejs in the dev shell. - package.json: idx-cli v0.2.3, bin idx, engines node>=16 - scripts/install.js: dependency-free postinstall that downloads the platform-specific GitHub Release asset (or IDX_BINARY_URL override) - scripts/idx.js: binary launcher preserving exit codes/signals - scripts/npm-smoke.sh: npm pack -> install tarball -> run idx --help and idx config --help, locale, no publish required - .github/workflows/release.yml: cross-compile linux-x64/arm64 and darwin-arm64 releases and attach binaries to the version tag - flake.nix: add nodejs_22 to the dev shell - docs/NPM_DISTRIBUTION.md: usage, local test, and release flow --- .github/workflows/release.yml | 77 ++++++++++++++ docs/NPM_DISTRIBUTION.md | 55 ++++++++++ flake.nix | 1 + package.json | 33 ++++++ scripts/idx.js | 31 ++++++ scripts/install.js | 192 ++++++++++++++++++++++++++++++++++ scripts/npm-smoke.sh | 74 +++++++++++++ 7 files changed, 463 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 docs/NPM_DISTRIBUTION.md create mode 100644 package.json create mode 100755 scripts/idx.js create mode 100755 scripts/install.js create mode 100755 scripts/npm-smoke.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..abc9f43 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.asset }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + asset: linux-x64 + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + asset: linux-arm64 + - os: macos-14 + target: aarch64-apple-darwin + asset: darwin-arm64 + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install Rust toolchain and target + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install Linux ARM64 linker + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: sudo apt-get update && sudo apt-get install --yes gcc-aarch64-linux-gnu + + - name: Build release binary + shell: bash + run: | + if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc + fi + cargo build --release --locked --target "${{ matrix.target }}" + cp "target/${{ matrix.target }}/release/idx" "idx-${{ matrix.asset }}" + chmod 0755 "idx-${{ matrix.asset }}" + + - name: Upload target artifact + uses: actions/upload-artifact@v4 + with: + name: idx-${{ matrix.asset }} + path: idx-${{ matrix.asset }} + if-no-files-found: error + + publish: + name: Attach binaries to release + needs: build + runs-on: ubuntu-latest + steps: + - name: Download target artifacts + uses: actions/download-artifact@v4 + with: + pattern: idx-* + merge-multiple: true + + - name: Attach binaries to the version tag + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: | + idx-linux-x64 + idx-linux-arm64 + idx-darwin-arm64 diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md new file mode 100644 index 0000000..38c00d5 --- /dev/null +++ b/docs/NPM_DISTRIBUTION.md @@ -0,0 +1,55 @@ +# npm distribution + +The npm package `idx-cli` is a small wrapper around the native `idx` binary. +The package keeps the JavaScript launcher and postinstall downloader. The +downloader selects a prebuilt GitHub Release asset for the current platform and +places it at the path used by the `idx` bin entry. + +Supported release assets: + +| npm platform | Rust target | Release asset | +| --- | --- | --- | +| Linux x64 | `x86_64-unknown-linux-gnu` | `idx-linux-x64` | +| Linux arm64 | `aarch64-unknown-linux-gnu` | `idx-linux-arm64` | +| macOS arm64 | `aarch64-apple-darwin` | `idx-darwin-arm64` | + +Windows and other platform combinations are out of scope. The postinstall +script reports the supported targets when it rejects a platform. + +## Use the package + +```bash +npm install --global idx-cli +idx --help + +# Or run the package without a global install. +npx idx-cli --help +``` + +The package version and the release tag must match. For example, package +version `0.2.3` downloads assets from the `v0.2.3` GitHub Release. + +## Local smoke test + +The smoke script builds or reuses `target/release/idx`, packs the npm wrapper, +installs the tarball into a new temporary directory, and runs the installed +`idx` command. It uses `IDX_BINARY_URL` with a local `file:` URL, so the test +does not need a registry publish or a network download. + +```bash +nix develop -c bash -c 'scripts/npm-smoke.sh' +nix develop -c bash -c 'npm pack --dry-run' +``` + +The `prepare` npm script builds the Rust release binary when npm packs the +wrapper from this checkout. The `postinstall` script performs the release +download for normal package installation. `IDX_BINARY_URL` accepts an HTTP(S) +URL, a `file:` URL, or an existing local path for local testing. + +## GitHub Release workflow + +Pushing a tag that matches `v*` starts +`.github/workflows/release.yml`. The workflow builds all three supported Rust +targets, names the binaries with the asset names above, and attaches them to +the matching GitHub Release. The asset names and URLs must stay aligned with +`scripts/install.js`. diff --git a/flake.nix b/flake.nix index b5f318f..ee09631 100644 --- a/flake.nix +++ b/flake.nix @@ -61,6 +61,7 @@ inputsFrom = [ idxPackage ]; packages = with pkgs; [ rustToolchain + nodejs_22 cargo-watch cargo-nextest prek diff --git a/package.json b/package.json new file mode 100644 index 0000000..1ee9b6f --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "idx-cli", + "version": "0.2.3", + "description": "CLI tool for Indonesian stock market (IDX) analysis", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/0xrsydn/idx-cli.git" + }, + "homepage": "https://github.com/0xrsydn/idx-cli", + "keywords": [ + "idx", + "stocks", + "indonesia", + "finance", + "cli" + ], + "engines": { + "node": ">=16" + }, + "bin": { + "idx": "./scripts/idx.js" + }, + "files": [ + "scripts/idx.js", + "scripts/install.js" + ], + "scripts": { + "postinstall": "node scripts/install.js", + "prepare": "cargo build --release --locked", + "smoke": "bash scripts/npm-smoke.sh" + } +} diff --git a/scripts/idx.js b/scripts/idx.js new file mode 100755 index 0000000..a28db2d --- /dev/null +++ b/scripts/idx.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +const binaryName = process.platform === "win32" ? "idx.exe" : "idx"; +const binaryPath = path.join(__dirname, "..", "bin", binaryName); + +if (!fs.existsSync(binaryPath)) { + console.error(`idx-cli: installed binary not found at ${binaryPath}`); + console.error("idx-cli: rerun npm install or set IDX_BINARY_URL for a local binary"); + process.exit(1); +} + +const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" }); + +child.once("error", (error) => { + console.error(`idx-cli: failed to start ${binaryPath}: ${error.message}`); + process.exit(1); +}); + +child.once("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code === null ? 1 : code); +}); diff --git a/scripts/install.js b/scripts/install.js new file mode 100755 index 0000000..b0adf03 --- /dev/null +++ b/scripts/install.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const http = require("node:http"); +const https = require("node:https"); +const path = require("node:path"); +const { URL, fileURLToPath } = require("node:url"); + +const packageRoot = path.resolve(__dirname, ".."); +const packageManifest = JSON.parse( + fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"), +); +const binaryName = process.platform === "win32" ? "idx.exe" : "idx"; +const binaryDirectory = path.join(packageRoot, "bin"); +const binaryPath = path.join(binaryDirectory, binaryName); +const githubRepository = "0xrsydn/idx-cli"; +const maxRedirects = 5; + +const targets = { + "linux/x64": { + label: "linux-x64", + triple: "x86_64-unknown-linux-gnu", + asset: "idx-linux-x64", + }, + "linux/arm64": { + label: "linux-arm64", + triple: "aarch64-unknown-linux-gnu", + asset: "idx-linux-arm64", + }, + "darwin/arm64": { + label: "darwin-arm64", + triple: "aarch64-apple-darwin", + asset: "idx-darwin-arm64", + }, +}; + +function currentTarget() { + const key = `${process.platform}/${process.arch}`; + const target = targets[key]; + if (!target) { + const supported = Object.values(targets) + .map(({ label, triple }) => `${label} (${triple})`) + .join(", "); + throw new Error( + `unsupported platform ${key}; supported targets are ${supported}`, + ); + } + return target; +} + +function releaseAssetUrl(target) { + return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${target.asset}`; +} + +function ensureBinaryDirectory() { + fs.mkdirSync(binaryDirectory, { recursive: true }); +} + +function temporaryBinaryPath() { + return `${binaryPath}.${process.pid}.tmp`; +} + +function replaceWithLocalBinary(sourcePath) { + const resolvedSource = path.resolve(sourcePath); + const sourceStat = fs.statSync(resolvedSource, { throwIfNoEntry: false }); + if (!sourceStat || !sourceStat.isFile()) { + throw new Error(`local binary does not exist or is not a file: ${resolvedSource}`); + } + + const temporaryPath = temporaryBinaryPath(); + try { + fs.copyFileSync(resolvedSource, temporaryPath); + fs.chmodSync(temporaryPath, 0o755); + fs.renameSync(temporaryPath, binaryPath); + } finally { + if (fs.existsSync(temporaryPath)) { + fs.unlinkSync(temporaryPath); + } + } +} + +function download(url, destination, redirects = 0) { + if (redirects > maxRedirects) { + return Promise.reject(new Error(`too many redirects while downloading ${url}`)); + } + + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch (error) { + return Promise.reject(new Error(`invalid IDX_BINARY_URL: ${error.message}`)); + } + + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return Promise.reject(new Error(`unsupported download protocol: ${parsedUrl.protocol}`)); + } + + const client = parsedUrl.protocol === "https:" ? https : http; + return new Promise((resolve, reject) => { + const request = client.get( + parsedUrl, + { + headers: { + Accept: "application/octet-stream", + "User-Agent": `idx-cli-npm/${packageManifest.version}`, + }, + }, + (response) => { + const status = response.statusCode || 0; + if (status >= 300 && status < 400 && response.headers.location) { + const redirectedUrl = new URL(response.headers.location, parsedUrl).toString(); + response.resume(); + download(redirectedUrl, destination, redirects + 1).then(resolve, reject); + return; + } + + if (status !== 200) { + response.resume(); + reject(new Error(`download failed with HTTP ${status} for ${url}`)); + return; + } + + const output = fs.createWriteStream(destination, { mode: 0o755 }); + output.once("finish", resolve); + output.once("error", reject); + response.once("error", reject); + response.pipe(output); + }, + ); + request.once("error", reject); + }); +} + +async function replaceWithDownloadedBinary(url) { + const temporaryPath = temporaryBinaryPath(); + try { + await download(url, temporaryPath); + fs.chmodSync(temporaryPath, 0o755); + fs.renameSync(temporaryPath, binaryPath); + } finally { + if (fs.existsSync(temporaryPath)) { + fs.unlinkSync(temporaryPath); + } + } +} + +function localPathFromOverride(value) { + if (value.startsWith("file:")) { + const parsedUrl = new URL(value); + if (parsedUrl.protocol !== "file:") { + throw new Error(`unsupported local URL protocol: ${parsedUrl.protocol}`); + } + return fileURLToPath(parsedUrl); + } + + if (!/^[a-z][a-z\d+.-]*:\/\//i.test(value)) { + const candidate = path.resolve(value); + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +async function main() { + const target = currentTarget(); + const override = process.env.IDX_BINARY_URL; + const source = override || releaseAssetUrl(target); + + ensureBinaryDirectory(); + if (override) { + const localPath = localPathFromOverride(override); + if (localPath) { + replaceWithLocalBinary(localPath); + console.log(`idx-cli: installed local binary from ${path.resolve(localPath)}`); + return; + } + console.log(`idx-cli: downloading binary from IDX_BINARY_URL (${override})`); + } else { + console.log(`idx-cli: downloading ${target.asset} for ${target.label} from ${source}`); + } + + await replaceWithDownloadedBinary(source); + console.log(`idx-cli: installed binary at ${binaryPath}`); +} + +main().catch((error) => { + console.error(`idx-cli: ${error.message}`); + process.exit(1); +}); diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh new file mode 100755 index 0000000..32f1d0c --- /dev/null +++ b/scripts/npm-smoke.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/idx-cli-npm-smoke.XXXXXX")" +tarball="${smoke_dir}/idx-cli-0.2.3.tgz" +install_dir="${smoke_dir}/install" +npm_cache="${smoke_dir}/npm-cache" + +cleanup() { + rm -rf "$smoke_dir" +} + +on_error() { + local status=$? + echo "npm smoke: FAILED on line ${BASH_LINENO[0]} (status ${status})" >&2 + exit "$status" +} + +trap cleanup EXIT +trap on_error ERR + +cd "$repo_root" + +release_binary="${repo_root}/target/release/idx" +if [[ -x "$release_binary" ]]; then + echo "npm smoke: reusing ${release_binary}" +else + echo "npm smoke: building ${release_binary}" + cargo build --release --locked +fi + +if [[ ! -x "$release_binary" ]]; then + echo "npm smoke: release binary was not produced at ${release_binary}" >&2 + exit 1 +fi + +echo "npm smoke: packing wrapper" +mkdir -p "$npm_cache" +npm_config_cache="$npm_cache" npm pack --silent --pack-destination "$smoke_dir" >/dev/null +if [[ ! -f "$tarball" ]]; then + echo "npm smoke: expected tarball was not produced at ${tarball}" >&2 + exit 1 +fi + +mkdir -p "$install_dir" +binary_url="$(node -e 'const { pathToFileURL } = require("node:url"); process.stdout.write(pathToFileURL(process.argv[1]).href)' "$release_binary")" +echo "npm smoke: installing tarball into ${install_dir}" +npm_config_cache="$npm_cache" IDX_BINARY_URL="$binary_url" npm install \ + --prefix "$install_dir" \ + "$tarball" \ + --no-save \ + --no-package-lock \ + --no-audit \ + --no-fund \ + --foreground-scripts + +resolved_bin="${install_dir}/node_modules/.bin/idx" +if [[ ! -x "$resolved_bin" ]]; then + echo "npm smoke: npm did not create an executable bin link at ${resolved_bin}" >&2 + exit 1 +fi + +help_output="${smoke_dir}/help.txt" +echo "npm smoke: running installed idx --help" +"$resolved_bin" --help | tee "$help_output" +if ! rg -q "Usage: idx" "$help_output"; then + echo "npm smoke: --help output did not contain the expected usage line" >&2 + exit 1 +fi + +echo "npm smoke: running installed idx config --help" +"$resolved_bin" config --help +echo "npm smoke: PASS (pack, install, --help, and config --help)" From 4f8c90a108512b1f8c555cb6cc7d8199a5a6b1e8 Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 24 Sep 2026 18:57:53 +0000 Subject: [PATCH 2/2] fix: harden npm distribution release and installer - release.yml: build static musl Linux binaries (no host glibc requirement), add darwin-x64, publish SHA256SUMS, allow workflow_dispatch for an existing tag (v0.2.3 has no binaries yet), and run build-only on PRs that touch the workflow or installer - install.js: verify downloads against the release SHA256SUMS (IDX_BINARY_SHA256 for overrides), add a 60s request timeout, use stream.pipeline so truncated downloads fail, and skip the download inside the source checkout - idx.js: forward SIGTERM/SIGHUP to the binary and keep the signal exit status - package.json: drop the prepare script (no Rust toolchain needed to pack/publish) - npm-smoke.sh: read the tarball name from npm pack, use grep over rg - .gitignore: ignore /bin/ and *.tgz - docs: musl targets, checksums, pnpm/bun opt-in, manual release flow Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017a5Cv5Z1NzdYKgKnTiAF4E --- .github/workflows/release.yml | 52 +++++++++++++---- .gitignore | 2 + docs/NPM_DISTRIBUTION.md | 59 ++++++++++++++++---- package.json | 1 - scripts/idx.js | 8 +++ scripts/install.js | 102 ++++++++++++++++++++++++++++------ scripts/npm-smoke.sh | 8 +-- 7 files changed, 191 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abc9f43..6185dca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,25 @@ on: push: tags: - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to build and attach binaries to (for example v0.2.3)" + required: true + pull_request: + paths: + - ".github/workflows/release.yml" + - "Cargo.toml" + - "Cargo.lock" + - "package.json" + - "scripts/install.js" permissions: contents: write +env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + jobs: build: name: Build ${{ matrix.asset }} @@ -17,38 +32,49 @@ jobs: matrix: include: - os: ubuntu-latest - target: x86_64-unknown-linux-gnu + target: x86_64-unknown-linux-musl asset: linux-x64 - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl asset: linux-arm64 - os: macos-14 target: aarch64-apple-darwin asset: darwin-arm64 + - os: macos-14 + target: x86_64-apple-darwin + asset: darwin-x64 steps: - name: Check out source uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} - name: Install Rust toolchain and target uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - - name: Install Linux ARM64 linker - if: matrix.target == 'aarch64-unknown-linux-gnu' - run: sudo apt-get update && sudo apt-get install --yes gcc-aarch64-linux-gnu + - name: Install musl toolchain + if: endsWith(matrix.target, '-linux-musl') + run: sudo apt-get update && sudo apt-get install --yes musl-tools - name: Build release binary shell: bash run: | - if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then - export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc + target="${{ matrix.target }}" + if [[ "$target" == *-linux-musl ]]; then + # Bundled SQLite and ring compile C code; build it against musl too. + export "CC_${target//-/_}=musl-gcc" fi - cargo build --release --locked --target "${{ matrix.target }}" + cargo build --release --locked --target "$target" cp "target/${{ matrix.target }}/release/idx" "idx-${{ matrix.asset }}" chmod 0755 "idx-${{ matrix.asset }}" + - name: Check binary runs + if: matrix.target != 'x86_64-apple-darwin' + run: ./idx-${{ matrix.asset }} --help >/dev/null + - name: Upload target artifact uses: actions/upload-artifact@v4 with: @@ -59,6 +85,7 @@ jobs: publish: name: Attach binaries to release needs: build + if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - name: Download target artifacts @@ -67,11 +94,16 @@ jobs: pattern: idx-* merge-multiple: true + - name: Write checksums + run: sha256sum idx-linux-x64 idx-linux-arm64 idx-darwin-arm64 idx-darwin-x64 > SHA256SUMS + - name: Attach binaries to the version tag uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} + tag_name: ${{ env.RELEASE_TAG }} files: | idx-linux-x64 idx-linux-arm64 idx-darwin-arm64 + idx-darwin-x64 + SHA256SUMS diff --git a/.gitignore b/.gitignore index e5145bc..716cccb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +/bin/ +*.tgz target/ .direnv/ .claude/ diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md index 38c00d5..dafcd12 100644 --- a/docs/NPM_DISTRIBUTION.md +++ b/docs/NPM_DISTRIBUTION.md @@ -9,9 +9,13 @@ Supported release assets: | npm platform | Rust target | Release asset | | --- | --- | --- | -| Linux x64 | `x86_64-unknown-linux-gnu` | `idx-linux-x64` | -| Linux arm64 | `aarch64-unknown-linux-gnu` | `idx-linux-arm64` | +| Linux x64 | `x86_64-unknown-linux-musl` | `idx-linux-x64` | +| Linux arm64 | `aarch64-unknown-linux-musl` | `idx-linux-arm64` | | macOS arm64 | `aarch64-apple-darwin` | `idx-darwin-arm64` | +| macOS x64 | `x86_64-apple-darwin` | `idx-darwin-x64` | + +The Linux binaries are statically linked against musl, so they do not depend +on the host glibc version. Windows and other platform combinations are out of scope. The postinstall script reports the supported targets when it rejects a platform. @@ -29,6 +33,25 @@ npx idx-cli --help The package version and the release tag must match. For example, package version `0.2.3` downloads assets from the `v0.2.3` GitHub Release. +The postinstall script downloads `SHA256SUMS` from the same release and +refuses to install a binary whose checksum does not match. + +### pnpm and bun + +pnpm 10+ and bun do not run dependency lifecycle scripts by default, so the +binary is never downloaded and `idx` reports that it is missing. Allow the +postinstall script explicitly: + +```bash +# pnpm +pnpm add --global --allow-build=idx-cli idx-cli + +# bun +bun add --global --trust idx-cli +``` + +Plain `npm install` and `npx` run the postinstall script normally. + ## Local smoke test The smoke script builds or reuses `target/release/idx`, packs the npm wrapper, @@ -41,15 +64,31 @@ nix develop -c bash -c 'scripts/npm-smoke.sh' nix develop -c bash -c 'npm pack --dry-run' ``` -The `prepare` npm script builds the Rust release binary when npm packs the -wrapper from this checkout. The `postinstall` script performs the release -download for normal package installation. `IDX_BINARY_URL` accepts an HTTP(S) -URL, a `file:` URL, or an existing local path for local testing. +The `postinstall` script performs the release download for normal package +installation. It skips the download when it runs inside this source checkout +(where `Cargo.toml` exists) unless `IDX_BINARY_URL` is set. `IDX_BINARY_URL` +accepts an HTTP(S) URL, a `file:` URL, or an existing local path for local +testing. Set `IDX_BINARY_SHA256` to verify an override binary as well. ## GitHub Release workflow Pushing a tag that matches `v*` starts -`.github/workflows/release.yml`. The workflow builds all three supported Rust -targets, names the binaries with the asset names above, and attaches them to -the matching GitHub Release. The asset names and URLs must stay aligned with -`scripts/install.js`. +`.github/workflows/release.yml`. The workflow builds all supported Rust +targets, names the binaries with the asset names above, writes `SHA256SUMS`, +and attaches everything to the matching GitHub Release. The asset names and +URLs must stay aligned with `scripts/install.js`. + +To attach binaries to a tag that already exists, run the workflow manually: + +```bash +gh workflow run release.yml -f tag=v0.2.3 +``` + +Pull requests that touch the workflow or the installer run the build jobs +without publishing, so target breakage shows up before a release. + +Release order: + +1. Push the tag, or run the workflow for an existing tag. +2. Confirm the release has all binaries plus `SHA256SUMS`. +3. Run `npm publish`. diff --git a/package.json b/package.json index 1ee9b6f..ceff396 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ ], "scripts": { "postinstall": "node scripts/install.js", - "prepare": "cargo build --release --locked", "smoke": "bash scripts/npm-smoke.sh" } } diff --git a/scripts/idx.js b/scripts/idx.js index a28db2d..42cbc8d 100755 --- a/scripts/idx.js +++ b/scripts/idx.js @@ -16,6 +16,13 @@ if (!fs.existsSync(binaryPath)) { const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" }); +// Pass termination signals from a supervisor (kill, docker stop) to the binary. +for (const signal of ["SIGTERM", "SIGHUP"]) { + process.on(signal, () => child.kill(signal)); +} +// Ctrl+C already reaches the binary through the terminal's process group. +process.on("SIGINT", () => {}); + child.once("error", (error) => { console.error(`idx-cli: failed to start ${binaryPath}: ${error.message}`); process.exit(1); @@ -23,6 +30,7 @@ child.once("error", (error) => { child.once("exit", (code, signal) => { if (signal) { + process.removeAllListeners(signal); process.kill(process.pid, signal); return; } diff --git a/scripts/install.js b/scripts/install.js index b0adf03..ececbaf 100755 --- a/scripts/install.js +++ b/scripts/install.js @@ -1,10 +1,12 @@ #!/usr/bin/env node "use strict"; +const crypto = require("node:crypto"); const fs = require("node:fs"); const http = require("node:http"); const https = require("node:https"); const path = require("node:path"); +const { pipeline } = require("node:stream"); const { URL, fileURLToPath } = require("node:url"); const packageRoot = path.resolve(__dirname, ".."); @@ -16,6 +18,7 @@ const binaryDirectory = path.join(packageRoot, "bin"); const binaryPath = path.join(binaryDirectory, binaryName); const githubRepository = "0xrsydn/idx-cli"; const maxRedirects = 5; +const requestTimeoutMs = 60_000; const targets = { "linux/x64": { @@ -33,6 +36,11 @@ const targets = { triple: "aarch64-apple-darwin", asset: "idx-darwin-arm64", }, + "darwin/x64": { + label: "darwin-x64", + triple: "x86_64-apple-darwin", + asset: "idx-darwin-x64", + }, }; function currentTarget() { @@ -49,8 +57,8 @@ function currentTarget() { return target; } -function releaseAssetUrl(target) { - return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${target.asset}`; +function releaseAssetUrl(assetName) { + return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`; } function ensureBinaryDirectory() { @@ -80,7 +88,7 @@ function replaceWithLocalBinary(sourcePath) { } } -function download(url, destination, redirects = 0) { +function request(url, onResponse, redirects = 0) { if (redirects > maxRedirects) { return Promise.reject(new Error(`too many redirects while downloading ${url}`)); } @@ -89,7 +97,7 @@ function download(url, destination, redirects = 0) { try { parsedUrl = new URL(url); } catch (error) { - return Promise.reject(new Error(`invalid IDX_BINARY_URL: ${error.message}`)); + return Promise.reject(new Error(`invalid download URL: ${error.message}`)); } if (!["http:", "https:"].includes(parsedUrl.protocol)) { @@ -98,7 +106,7 @@ function download(url, destination, redirects = 0) { const client = parsedUrl.protocol === "https:" ? https : http; return new Promise((resolve, reject) => { - const request = client.get( + const req = client.get( parsedUrl, { headers: { @@ -111,7 +119,7 @@ function download(url, destination, redirects = 0) { if (status >= 300 && status < 400 && response.headers.location) { const redirectedUrl = new URL(response.headers.location, parsedUrl).toString(); response.resume(); - download(redirectedUrl, destination, redirects + 1).then(resolve, reject); + request(redirectedUrl, onResponse, redirects + 1).then(resolve, reject); return; } @@ -121,21 +129,69 @@ function download(url, destination, redirects = 0) { return; } - const output = fs.createWriteStream(destination, { mode: 0o755 }); - output.once("finish", resolve); - output.once("error", reject); - response.once("error", reject); - response.pipe(output); + onResponse(response).then(resolve, reject); }, ); - request.once("error", reject); + req.setTimeout(requestTimeoutMs, () => { + req.destroy(new Error(`timed out after ${requestTimeoutMs / 1000}s downloading ${url}`)); + }); + req.once("error", reject); }); } -async function replaceWithDownloadedBinary(url) { +function download(url, destination) { + return request( + url, + (response) => + new Promise((resolve, reject) => { + const output = fs.createWriteStream(destination, { mode: 0o755 }); + pipeline(response, output, (error) => (error ? reject(error) : resolve())); + }), + ); +} + +function downloadText(url) { + return request( + url, + (response) => + new Promise((resolve, reject) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.once("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + response.once("error", reject); + }), + ); +} + +function sha256File(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +async function expectedReleaseChecksum(assetName) { + const sums = await downloadText(releaseAssetUrl("SHA256SUMS")); + for (const line of sums.split(/\r?\n/)) { + const [hash, name] = line.trim().split(/\s+\*?/); + if (name === assetName && /^[0-9a-f]{64}$/i.test(hash)) { + return hash.toLowerCase(); + } + } + throw new Error(`SHA256SUMS for v${packageManifest.version} has no entry for ${assetName}`); +} + +function verifyChecksum(filePath, expected, label) { + const actual = sha256File(filePath); + if (actual !== expected.toLowerCase()) { + throw new Error(`checksum mismatch for ${label}: expected ${expected}, got ${actual}`); + } +} + +async function replaceWithDownloadedBinary(url, expectedSha256) { const temporaryPath = temporaryBinaryPath(); try { await download(url, temporaryPath); + if (expectedSha256) { + verifyChecksum(temporaryPath, expectedSha256, url); + } fs.chmodSync(temporaryPath, 0o755); fs.renameSync(temporaryPath, binaryPath); } finally { @@ -165,14 +221,25 @@ function localPathFromOverride(value) { } async function main() { - const target = currentTarget(); const override = process.env.IDX_BINARY_URL; - const source = override || releaseAssetUrl(target); + const overrideSha256 = process.env.IDX_BINARY_SHA256; + + // `npm install` inside the Rust checkout should not fetch a release binary. + if (!override && fs.existsSync(path.join(packageRoot, "Cargo.toml"))) { + console.log("idx-cli: source checkout detected; skipping binary download (set IDX_BINARY_URL to override)"); + return; + } + + const target = currentTarget(); + const source = override || releaseAssetUrl(target.asset); ensureBinaryDirectory(); if (override) { const localPath = localPathFromOverride(override); if (localPath) { + if (overrideSha256) { + verifyChecksum(localPath, overrideSha256, localPath); + } replaceWithLocalBinary(localPath); console.log(`idx-cli: installed local binary from ${path.resolve(localPath)}`); return; @@ -182,7 +249,10 @@ async function main() { console.log(`idx-cli: downloading ${target.asset} for ${target.label} from ${source}`); } - await replaceWithDownloadedBinary(source); + const expectedSha256 = override + ? overrideSha256 + : await expectedReleaseChecksum(target.asset); + await replaceWithDownloadedBinary(source, expectedSha256); console.log(`idx-cli: installed binary at ${binaryPath}`); } diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index 32f1d0c..e6b58ac 100755 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -3,7 +3,6 @@ set -Eeuo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/idx-cli-npm-smoke.XXXXXX")" -tarball="${smoke_dir}/idx-cli-0.2.3.tgz" install_dir="${smoke_dir}/install" npm_cache="${smoke_dir}/npm-cache" @@ -37,8 +36,9 @@ fi echo "npm smoke: packing wrapper" mkdir -p "$npm_cache" -npm_config_cache="$npm_cache" npm pack --silent --pack-destination "$smoke_dir" >/dev/null -if [[ ! -f "$tarball" ]]; then +tarball_name="$(npm_config_cache="$npm_cache" npm pack --silent --pack-destination "$smoke_dir" | tail -n 1)" +tarball="${smoke_dir}/${tarball_name}" +if [[ -z "$tarball_name" || ! -f "$tarball" ]]; then echo "npm smoke: expected tarball was not produced at ${tarball}" >&2 exit 1 fi @@ -64,7 +64,7 @@ fi help_output="${smoke_dir}/help.txt" echo "npm smoke: running installed idx --help" "$resolved_bin" --help | tee "$help_output" -if ! rg -q "Usage: idx" "$help_output"; then +if ! grep -q "Usage: idx" "$help_output"; then echo "npm smoke: --help output did not contain the expected usage line" >&2 exit 1 fi