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
This commit is contained in:
hermes 2026-08-18 11:14:55 +00:00
commit 221dc01397
7 changed files with 463 additions and 0 deletions

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

@ -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

55
docs/NPM_DISTRIBUTION.md Normal file
View file

@ -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`.

View file

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

33
package.json Normal file
View file

@ -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"
}
}

31
scripts/idx.js Executable file
View file

@ -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);
});

192
scripts/install.js Executable file
View file

@ -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);
});

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")"
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)"