diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6185dca --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 877d6f4..d7292fe 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 new file mode 100644 index 0000000..dafcd12 --- /dev/null +++ b/docs/NPM_DISTRIBUTION.md @@ -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`. 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..ceff396 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/idx.js b/scripts/idx.js new file mode 100755 index 0000000..42cbc8d --- /dev/null +++ b/scripts/idx.js @@ -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); +}); diff --git a/scripts/install.js b/scripts/install.js new file mode 100755 index 0000000..ececbaf --- /dev/null +++ b/scripts/install.js @@ -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); +}); diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh new file mode 100755 index 0000000..e6b58ac --- /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")" +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)"