diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06e10bb..da3b5b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,3 +21,33 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm: + needs: binaries + runs-on: ubuntu-latest + permissions: + contents: read + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - name: Download release binaries + run: gh release download "$RELEASE_TAG" --pattern 'gorse-cli_*' --dir dist/npm-binaries + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Stage npm packages + run: node npm/scripts/build-packages.js "$RELEASE_TAG" dist/npm-binaries dist/npm + - name: Publish platform packages before the launcher + run: | + set -e + tag=latest + if [[ "$RELEASE_TAG" == *-* ]]; then tag=next; fi + for package in dist/npm/cli-*; do + npm publish "./$package" --access public --tag "$tag" + done + npm publish ./dist/npm/cli --access public --tag "$tag" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index a5bd233..9e8b5c5 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,12 @@ profile.cov go.work go.work.sum +# npm and release artifacts +node_modules/ +dist/ +*.tgz +/gorse-cli + # env file .env diff --git a/README.md b/README.md index 00977e6..19f8477 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,24 @@ Command-line tools for managing Gorse clusters. Migrated from ## Install +With npm (Node.js 18 or later): + +```sh +npm install -g @gorse/cli +gorse-cli --help +``` + +Or run without a global installation: + +```sh +npx @gorse/cli --help +``` + +The npm package selects a prebuilt native binary through platform-specific optional +dependencies; no Go compiler or install scripts are required. Keep optional +dependencies enabled. Supported platforms match the standalone releases: Linux +(x64, arm64, loong64, riscv64), Windows (x64, arm64), and macOS (arm64). + Build from source: ```sh @@ -47,6 +65,23 @@ go vet ./... go build -o gorse-cli . ``` +### Publishing npm packages + +The release workflow packages the same binaries uploaded by GoReleaser and publishes +platform packages first, followed by `@gorse/cli` with exact-version optional +dependencies. Stable releases use npm's `latest` tag; prereleases use `next`. +Configure the repository's `NPM_TOKEN` secret with permission to publish all +`@gorse/cli*` packages in the npm organization before publishing a release. +The GitHub release tag must be a canonical npm SemVer version, optionally prefixed +with `v` (for example, `v1.2.3` or `v1.2.3-rc.1`). + +To inspect packages locally after downloading all release binaries: + +```sh +node npm/scripts/build-packages.js v1.2.3 dist/npm-binaries dist/npm +npm pack ./dist/npm/cli +``` + Tests start a real local Gorse Master with temporary SQLite stores. The Gorse module is pinned in `go.mod`; its SQLite replacement must also be declared here because Go does not inherit dependency replacement directives. diff --git a/npm/bin/gorse-cli.js b/npm/bin/gorse-cli.js new file mode 100644 index 0000000..b4e1e67 --- /dev/null +++ b/npm/bin/gorse-cli.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const packageName = `@gorse/cli-${process.platform}-${process.arch}`; +let binary; +try { + binary = path.join(path.dirname(require.resolve(`${packageName}/package.json`)), "bin", process.platform === "win32" ? "gorse-cli.exe" : "gorse-cli"); +} catch { + console.error(`Missing optional dependency ${packageName}. Check that your platform is supported and reinstall with optional dependencies enabled: npm install -g @gorse/cli`); + process.exit(1); +} + +const child = spawn(binary, process.argv.slice(2), { stdio: "inherit" }); +const handlers = new Map(); +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + const handler = () => child.kill(signal); + handlers.set(signal, handler); + process.on(signal, handler); +} +child.on("error", (error) => { + console.error(`Unable to start gorse-cli: ${error.message}`); + process.exit(1); +}); +child.on("exit", (code, signal) => { + for (const [name, handler] of handlers) process.removeListener(name, handler); + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); +}); diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..f51715f --- /dev/null +++ b/npm/package.json @@ -0,0 +1,16 @@ +{ + "name": "@gorse/cli", + "version": "0.0.0-dev", + "description": "Command-line tools for managing Gorse clusters", + "license": "Apache-2.0", + "type": "module", + "bin": { "gorse-cli": "bin/gorse-cli.js" }, + "engines": { "node": ">=18" }, + "files": ["bin/gorse-cli.js"], + "repository": { + "type": "git", + "url": "git+https://github.com/gorse-io/cli.git", + "directory": "npm" + }, + "publishConfig": { "access": "public" } +} diff --git a/npm/platforms.js b/npm/platforms.js new file mode 100644 index 0000000..10db8e8 --- /dev/null +++ b/npm/platforms.js @@ -0,0 +1,10 @@ +// Keep aligned with the targets in .goreleaser.yaml. +export const platforms = [ + { os: "linux", cpu: "x64", goos: "linux", goarch: "amd64" }, + { os: "linux", cpu: "arm64", goos: "linux", goarch: "arm64" }, + { os: "linux", cpu: "loong64", goos: "linux", goarch: "loong64" }, + { os: "linux", cpu: "riscv64", goos: "linux", goarch: "riscv64" }, + { os: "win32", cpu: "x64", goos: "windows", goarch: "amd64" }, + { os: "win32", cpu: "arm64", goos: "windows", goarch: "arm64" }, + { os: "darwin", cpu: "arm64", goos: "darwin", goarch: "arm64" }, +]; diff --git a/npm/scripts/build-packages.js b/npm/scripts/build-packages.js new file mode 100644 index 0000000..89cf8a1 --- /dev/null +++ b/npm/scripts/build-packages.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { platforms } from "../platforms.js"; + +const [tag, binaries, output] = process.argv.slice(2); +const version = tag?.replace(/^v/, ""); +// npm versions must be canonical SemVer; reject build metadata (npm strips it). +const number = "(?:0|[1-9][0-9]*)"; +const identifier = `(?:${number}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)`; +if (!version || !new RegExp(`^${number}\\.${number}\\.${number}(?:-${identifier}(?:\\.${identifier})*)?$`).test(version) || !binaries || !output) { + throw new Error("Usage: node npm/scripts/build-packages.js (version must be canonical SemVer)"); +} +const npmRoot = fileURLToPath(new URL("../", import.meta.url)); +const repoRoot = path.resolve(npmRoot, ".."); +const main = JSON.parse(readFileSync(path.join(npmRoot, "package.json"), "utf8")); +main.version = version; +main.optionalDependencies = {}; + +function stage(directory, manifest) { + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, "package.json"), JSON.stringify(manifest, null, 2) + "\n"); + for (const file of ["README.md", "LICENSE"]) copyFileSync(path.join(repoRoot, file), path.join(directory, file)); +} + +for (const target of platforms) { + const name = `cli-${target.os}-${target.cpu}`; + const packageName = `@gorse/${name}`; + const directory = path.join(output, name); + const executable = target.os === "win32" ? "gorse-cli.exe" : "gorse-cli"; + mkdirSync(path.join(directory, "bin"), { recursive: true }); + copyFileSync(path.join(binaries, `gorse-cli_${target.goos}_${target.goarch}${target.os === "win32" ? ".exe" : ""}`), path.join(directory, "bin", executable)); + chmodSync(path.join(directory, "bin", executable), 0o755); + stage(directory, { + name: packageName, version, description: main.description, license: main.license, + repository: main.repository, publishConfig: main.publishConfig, + os: [target.os], cpu: [target.cpu], files: ["bin"], + }); + main.optionalDependencies[packageName] = version; +} +const directory = path.join(output, "cli"); +mkdirSync(path.join(directory, "bin"), { recursive: true }); +copyFileSync(path.join(npmRoot, "bin/gorse-cli.js"), path.join(directory, "bin/gorse-cli.js")); +chmodSync(path.join(directory, "bin/gorse-cli.js"), 0o755); +stage(directory, main);