diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 8af8db0..28aa0fb 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -375,3 +375,79 @@ jobs: chmod +x "$TMP_DIR/volcano-linux-amd64" "$TMP_DIR/volcano-linux-amd64" --help "$TMP_DIR/volcano-linux-amd64" --version + + publish-npm: + name: Publish npm package + runs-on: ubuntu-latest # Trusted publishing requires GitHub-hosted runners + needs: [resolve-release, publish-github-release] + # Publish to npm only for stable releases (skip main/nightly prereleases). + # This job runs npm publish directly in the release-triggered run, so npm's + # OIDC trusted publisher must be configured with this workflow's filename + # (publish-cli.yml). Dispatched/reusable workflows are not supported. + if: needs.resolve-release.outputs.prerelease == 'false' + permissions: + contents: read + id-token: write # REQUIRED for npm trusted publishing (OIDC) + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + # Trusted publishing requires npm >= 11.5.1; pin to the npm 11 line for + # reproducibility rather than tracking npm@latest across major versions. + - name: Upgrade npm + run: npm install -g npm@11 + + - name: Resolve npm version + id: npm_version + env: + CLI_VERSION: ${{ needs.resolve-release.outputs.cli_version }} + run: | + set -euo pipefail + VERSION="${CLI_VERSION#v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Refusing to publish non-stable version to npm: ${CLI_VERSION}" + exit 1 + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Set package version + run: | + npm version "${{ steps.npm_version.outputs.version }}" \ + --no-git-tag-version --allow-same-version + + - name: Verify release assets exist + env: + VERSION: ${{ steps.npm_version.outputs.version }} + run: | + set -euo pipefail + base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" + # Ranged GET (first byte) confirms each asset is actually downloadable, + # which is what the package's postinstall does. + for target in linux-amd64 linux-arm64 macos-amd64 macos-arm64 windows-amd64; do + asset="volcano-${target}" + if [ "$target" = "windows-amd64" ]; then + asset="${asset}.exe" + fi + echo "Checking ${base}/${asset}" + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/${asset}" + done + echo "Checking ${base}/SHA256SUMS" + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/SHA256SUMS" + + # OIDC trusted publishing: npm detects the id-token and authenticates + # automatically; provenance is generated for public repos. No NPM_TOKEN. + # VOLCANO_SKIP_DOWNLOAD stops the postinstall fetching a binary in CI. + # + # BOOTSTRAP: until an npm admin does the first manual publish and sets the + # OIDC trusted publisher (workflow file: publish-cli.yml), this step fails + # with ENEEDAUTH. continue-on-error keeps the release green meanwhile -- + # REMOVE it once trusted publishing is live so real failures surface. + - name: Publish to npm + continue-on-error: true + env: + VOLCANO_SKIP_DOWNLOAD: "1" + run: npm publish diff --git a/.gitignore b/.gitignore index 6be6ad5..7f8dfb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /volcano /dist/ +# Downloaded platform binaries fetched by the npm postinstall (keep bin/volcano.js) +/bin/volcano-* +node_modules/ /.env /.env.local /.env.* diff --git a/README.md b/README.md index 128b57f..a251c82 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,18 @@ Install the latest published release: curl -fsSL https://github.com/Kong/volcano-cli/releases/latest/download/install.sh | bash ``` +Or install from npm: + +```bash +npm install -g @kong/volcano-cli +volcano --help +``` + +The npm package is a thin wrapper: its `postinstall` step downloads the +platform-specific binary from the matching GitHub Release and verifies it +against that release's `SHA256SUMS`. Set `VOLCANO_SKIP_DOWNLOAD=1` to skip the +download (it is fetched on first run instead). + Build from source: ```bash diff --git a/bin/volcano.js b/bin/volcano.js new file mode 100644 index 0000000..f46db53 --- /dev/null +++ b/bin/volcano.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +'use strict'; + +// Launcher shim: exec the platform-specific Volcano CLI binary, forwarding all +// arguments, stdio, and the exit code. If the binary is missing (e.g. install +// ran with --ignore-scripts, or the postinstall download failed) it is fetched +// and verified on demand. + +const fs = require('fs'); +const { spawn } = require('child_process'); +const { binaryPath, ensureBinary } = require('../scripts/npm/download.js'); + +async function resolveBinary() { + try { + const bin = binaryPath(); + if (fs.existsSync(bin)) { + return bin; + } + return await ensureBinary(); + } catch (err) { + // Covers both download failures and unsupported platforms: binaryPath() -> + // resolveTarget() throws for e.g. Windows arm64, which passes the + // package.json os/cpu gate but has no published binary. + console.error(`volcano: could not obtain the CLI binary: ${err.message}`); + console.error( + 'Install it manually from https://github.com/Kong/volcano-cli/releases ' + + 'or re-install the package.' + ); + process.exit(1); + } +} + +async function main() { + const bin = await resolveBinary(); + const child = spawn(bin, process.argv.slice(2), { stdio: 'inherit' }); + + child.on('error', (err) => { + console.error(`volcano: failed to launch binary: ${err.message}`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + // Re-raise the signal so the parent reflects the child's termination. + process.kill(process.pid, signal); + return; + } + process.exit(code == null ? 1 : code); + }); +} + +main().catch((err) => { + // Last-resort guard so failures never surface as an unhandled rejection. + console.error(`volcano: ${err.message}`); + process.exit(1); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..94fd100 --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "@kong/volcano-cli", + "version": "0.0.0", + "description": "Command-line client for Volcano, Kong's hosting platform.", + "keywords": [ + "volcano", + "kong", + "cli" + ], + "homepage": "https://github.com/Kong/volcano-cli#readme", + "bugs": { + "url": "https://github.com/Kong/volcano-cli/issues" + }, + "license": "Apache-2.0", + "type": "commonjs", + "repository": { + "type": "git", + "url": "git+https://github.com/Kong/volcano-cli.git" + }, + "bin": { + "volcano": "bin/volcano.js" + }, + "files": [ + "bin/volcano.js", + "scripts/npm/" + ], + "scripts": { + "postinstall": "node scripts/npm/install.js", + "prepublishOnly": "node -e \"if (require('./package.json').version === '0.0.0') { console.error('Refusing to publish version 0.0.0; set the real version first (npm version ).'); process.exit(1); }\"" + }, + "engines": { + "node": ">=18" + }, + "os": [ + "linux", + "darwin", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/scripts/npm/download.js b/scripts/npm/download.js new file mode 100644 index 0000000..8161960 --- /dev/null +++ b/scripts/npm/download.js @@ -0,0 +1,198 @@ +'use strict'; + +// Shared helpers for downloading and verifying the platform-specific Volcano +// CLI binary from GitHub Releases. Used by the postinstall script +// (scripts/npm/install.js) and, as a self-healing fallback, by the launcher +// shim (bin/volcano.js). + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const https = require('https'); +const crypto = require('crypto'); +const { pipeline } = require('stream'); +const { promisify } = require('util'); + +const streamPipeline = promisify(pipeline); + +const pkg = require('../../package.json'); + +// Allow pointing at a mirror (kept consistent with scripts/install-volcano.sh). +const RELEASES_BASE = ( + process.env.VOLCANO_GITHUB_RELEASES_URL || + 'https://github.com/Kong/volcano-cli/releases' +).replace(/\/+$/, ''); + +// Map Node's platform-arch to the release asset target id. +const TARGETS = { + 'linux-x64': 'linux-amd64', + 'linux-arm64': 'linux-arm64', + 'darwin-x64': 'macos-amd64', + 'darwin-arm64': 'macos-arm64', + 'win32-x64': 'windows-amd64', +}; + +function resolveTarget() { + const key = `${process.platform}-${process.arch}`; + const target = TARGETS[key]; + if (!target) { + throw new Error( + `Unsupported platform "${key}". Volcano CLI ships binaries for: ` + + `${Object.keys(TARGETS).join(', ')}.` + ); + } + return target; +} + +function binaryExt() { + return process.platform === 'win32' ? '.exe' : ''; +} + +function assetName() { + return `volcano-${resolveTarget()}${binaryExt()}`; +} + +// Absolute path where the downloaded binary lives (next to the launcher shim). +function binaryPath() { + return path.join(__dirname, '..', '..', 'bin', assetName()); +} + +function releaseTag() { + // The npm package version maps 1:1 to the GitHub release tag `v`. + return `v${pkg.version}`; +} + +function get(url, redirects = 0) { + return new Promise((resolve, reject) => { + if (redirects > 10) { + reject(new Error(`Too many redirects while fetching ${url}`)); + return; + } + let protocol; + try { + protocol = new URL(url).protocol; + } catch (err) { + reject(new Error(`Invalid download URL "${url}": ${err.message}`)); + return; + } + // Pick the client by scheme so an http:// mirror + // (VOLCANO_GITHUB_RELEASES_URL) works too. + const client = protocol === 'https:' ? https : protocol === 'http:' ? http : null; + if (!client) { + reject(new Error(`Unsupported URL scheme "${protocol}" for ${url}; use http or https.`)); + return; + } + const req = client.get( + url, + { headers: { 'User-Agent': `volcano-cli-npm/${pkg.version}` } }, + (res) => { + const { statusCode, headers } = res; + if (statusCode >= 300 && statusCode < 400 && headers.location) { + res.resume(); + const next = new URL(headers.location, url).toString(); + resolve(get(next, redirects + 1)); + return; + } + if (statusCode !== 200) { + res.resume(); + reject(new Error(`Request to ${url} failed with status ${statusCode}`)); + return; + } + resolve(res); + } + ); + req.on('error', reject); + req.setTimeout(60000, () => { + req.destroy(new Error(`Request to ${url} timed out`)); + }); + }); +} + +async function getText(url) { + const res = await get(url); + const chunks = []; + for await (const chunk of res) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +// Parse a `shasum -a 256` style manifest and return the hex digest for `name`. +function parseChecksum(manifest, name) { + const lines = manifest.split('\n'); + for (const line of lines) { + const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (match && match[2].trim() === name) { + return match[1].toLowerCase(); + } + } + return null; +} + +async function downloadToFile(url, dest) { + const tmp = `${dest}.download-${process.pid}`; + try { + const res = await get(url); + await streamPipeline(res, fs.createWriteStream(tmp)); + // Hash the fully-written file so we verify exactly what landed on disk + // (also avoids the flowing-mode subtleties of hashing mid-stream). + const hash = crypto.createHash('sha256'); + await streamPipeline(fs.createReadStream(tmp), hash); + const digest = hash.digest('hex'); + // rename does not overwrite an existing destination on Windows, so clear it + // first (also covers a check-then-write race and force re-downloads). + fs.rmSync(dest, { force: true }); + fs.renameSync(tmp, dest); + return digest; + } catch (err) { + // Never leave a partial download behind. + fs.rmSync(tmp, { force: true }); + throw err; + } +} + +// Download the correct binary for the current platform and verify its checksum +// against the release's SHA256SUMS manifest. Idempotent: no-op if present. +async function ensureBinary({ force = false } = {}) { + const dest = binaryPath(); + if (!force && fs.existsSync(dest)) { + return dest; + } + + if (pkg.version === '0.0.0') { + throw new Error( + 'No published binary for development version 0.0.0; build from source ' + + 'with `make build` or install a released version.' + ); + } + + const name = assetName(); + const tag = releaseTag(); + const base = `${RELEASES_BASE}/download/${tag}`; + + const manifest = await getText(`${base}/SHA256SUMS`); + const expected = parseChecksum(manifest, name); + if (!expected) { + throw new Error(`No SHA256 checksum for ${name} in ${tag}/SHA256SUMS`); + } + + fs.mkdirSync(path.dirname(dest), { recursive: true }); + const actual = (await downloadToFile(`${base}/${name}`, dest)).toLowerCase(); + if (actual !== expected) { + fs.rmSync(dest, { force: true }); + throw new Error( + `Checksum mismatch for ${name}: expected ${expected}, got ${actual}` + ); + } + + if (process.platform !== 'win32') { + fs.chmodSync(dest, 0o755); + } + return dest; +} + +module.exports = { + resolveTarget, + assetName, + binaryPath, + releaseTag, + ensureBinary, +}; diff --git a/scripts/npm/install.js b/scripts/npm/install.js new file mode 100644 index 0000000..4bc4ee4 --- /dev/null +++ b/scripts/npm/install.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +'use strict'; + +// npm postinstall hook: download the platform-specific Volcano CLI binary. +// +// Failures here are non-fatal so that `npm install` still succeeds in +// restricted/offline environments (or with `--ignore-scripts`). In that case +// the launcher shim (bin/volcano.js) downloads the binary on first use. + +const { ensureBinary, resolveTarget } = require('./download.js'); +const pkg = require('../../package.json'); + +async function main() { + if (process.env.VOLCANO_SKIP_DOWNLOAD === '1') { + console.log('Skipping Volcano CLI download (VOLCANO_SKIP_DOWNLOAD=1).'); + return; + } + + // No release exists for the placeholder version, so skip quietly for anyone + // running `npm install` in a source checkout (the publish workflow stamps a + // real version before publishing). + if (pkg.version === '0.0.0') { + console.log('Skipping Volcano CLI download for development version 0.0.0.'); + return; + } + + const target = resolveTarget(); + const binary = await ensureBinary(); + console.log(`Installed Volcano CLI (${target}) -> ${binary}`); +} + +main().catch((err) => { + console.warn(`Warning: could not download the Volcano CLI binary: ${err.message}`); + console.warn('It will be downloaded automatically the first time you run "volcano".'); + // Exit 0 so installation does not hard-fail; the shim self-heals on first run. + process.exit(0); +});