Merge pull request #22 from 0xrsydn/feat/npm-distribution

feat: add npm distribution wrapper for idx-cli
This commit is contained in:
brutaljokerz 2026-09-25 02:18:04 +07:00 • committed by GitHub
commit 52cf73beb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 613 additions and 0 deletions

109
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,109 @@
name: Release
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 }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
asset: linux-x64
- 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 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: |
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 "$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:
name: idx-${{ matrix.asset }}
path: idx-${{ matrix.asset }}
if-no-files-found: error
publish:
name: Attach binaries to release
needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Download target artifacts
uses: actions/download-artifact@v4
with:
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: ${{ env.RELEASE_TAG }}
files: |
idx-linux-x64
idx-linux-arm64
idx-darwin-arm64
idx-darwin-x64
SHA256SUMS

2
.gitignore vendored
View file

@ -1,4 +1,6 @@
node_modules/
/bin/
*.tgz
target/
.direnv/
.claude/

94
docs/NPM_DISTRIBUTION.md Normal file
View file

@ -0,0 +1,94 @@
# 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-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.
## 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.
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,
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 `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 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`.

View file

@ -61,6 +61,7 @@
inputsFrom = [ idxPackage ];
packages = with pkgs; [
rustToolchain
nodejs_22
cargo-watch
cargo-nextest
prek

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"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",
"smoke": "bash scripts/npm-smoke.sh"
}
}

39
scripts/idx.js Executable file
View file

@ -0,0 +1,39 @@
#!/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" });
// 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);
});
child.once("exit", (code, signal) => {
if (signal) {
process.removeAllListeners(signal);
process.kill(process.pid, signal);
return;
}
process.exit(code === null ? 1 : code);
});

262
scripts/install.js Executable file
View file

@ -0,0 +1,262 @@
#!/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, "..");
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 requestTimeoutMs = 60_000;
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",
},
"darwin/x64": {
label: "darwin-x64",
triple: "x86_64-apple-darwin",
asset: "idx-darwin-x64",
},
};
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(assetName) {
return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`;
}
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 request(url, onResponse, 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 download 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 req = 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();
request(redirectedUrl, onResponse, redirects + 1).then(resolve, reject);
return;
}
if (status !== 200) {
response.resume();
reject(new Error(`download failed with HTTP ${status} for ${url}`));
return;
}
onResponse(response).then(resolve, reject);
},
);
req.setTimeout(requestTimeoutMs, () => {
req.destroy(new Error(`timed out after ${requestTimeoutMs / 1000}s downloading ${url}`));
});
req.once("error", reject);
});
}
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 {
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 override = process.env.IDX_BINARY_URL;
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;
}
console.log(`idx-cli: downloading binary from IDX_BINARY_URL (${override})`);
} else {
console.log(`idx-cli: downloading ${target.asset} for ${target.label} from ${source}`);
}
const expectedSha256 = override
? overrideSha256
: await expectedReleaseChecksum(target.asset);
await replaceWithDownloadedBinary(source, expectedSha256);
console.log(`idx-cli: installed binary at ${binaryPath}`);
}
main().catch((error) => {
console.error(`idx-cli: ${error.message}`);
process.exit(1);
});

74
scripts/npm-smoke.sh Executable file
View file

@ -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")"
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"
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
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 ! grep -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)"