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