From 805a3d29c57e60455a509e8e7c95ecc4c256b3cd Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 17 Sep 2026 13:40:47 +0000 Subject: [PATCH] feat: distribute native CLI through npm --- .github/workflows/ci.yml | 5 ++ .github/workflows/release.yml | 51 +++++++++++++++ .gitignore | 1 + README.md | 11 ++++ npm/draftcat.js | 26 ++++++++ npm/install.js | 118 ++++++++++++++++++++++++++++++++++ npm/install.test.js | 24 +++++++ package-lock.json | 29 +++++++++ package.json | 50 ++++++++++++++ 9 files changed, 315 insertions(+) create mode 100755 npm/draftcat.js create mode 100755 npm/install.js create mode 100644 npm/install.test.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a0377a..b2e6b7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,13 @@ jobs: with: { fetch-depth: 0 } # needed for only-new-issues / --new-from-rev - uses: actions/setup-go@v5 with: { go-version: "1.25" } + - uses: actions/setup-node@v4 + with: { node-version: "22" } - run: go build ./... - run: go test -count=1 -short -timeout 60s ./... + - run: npm ci --ignore-scripts + - run: npm test + - run: npm pack --dry-run # Config validation gates on ERRORS (exit 1). It is intentionally NOT # --strict: many warnings are environment-dependent (empty API-key env # vars at lint time) and advisory, so they must not fail CI. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 129f230..1586679 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,57 @@ permissions: packages: write jobs: + binaries: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + strategy: + matrix: + include: + - { goos: linux, goarch: amd64, platform: linux, arch: x64, extension: "" } + - { goos: linux, goarch: arm64, platform: linux, arch: arm64, extension: "" } + - { goos: darwin, goarch: amd64, platform: darwin, arch: x64, extension: "" } + - { goos: darwin, goarch: arm64, platform: darwin, arch: arm64, extension: "" } + - { goos: windows, goarch: amd64, platform: win32, arch: x64, extension: ".exe" } + - { goos: windows, goarch: arm64, platform: win32, arch: arm64, extension: ".exe" } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: "1.25" } + - name: Build native binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + asset="draftcat-${GITHUB_REF_NAME}-${{ matrix.platform }}-${{ matrix.arch }}${{ matrix.extension }}" + go build -trimpath -buildvcs=false -ldflags="-s -w" -o "$asset" . + gzip -n "$asset" + - uses: actions/upload-artifact@v4 + with: + name: draftcat-${{ matrix.platform }}-${{ matrix.arch }} + path: draftcat-*.gz + if-no-files-found: error + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: binaries + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: draftcat-* + path: dist + merge-multiple: true + - name: Publish GitHub release + working-directory: dist + env: + GH_TOKEN: ${{ github.token }} + run: | + sha256sum draftcat-*.gz > SHA256SUMS + gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --generate-notes --title "$GITHUB_REF_NAME" draftcat-*.gz SHA256SUMS + image: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 0efa3c5..53a8735 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ state.db-shm state.db-wal aiops-architecture-diagram.py draftyard +/npm/bin/ diff --git a/README.md b/README.md index d438149..76bf1f7 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,17 @@ However your agent runs, draftcat sits between it and your customer systems as a ## Quickstart +Install the native binary through npm (Node.js 18 or newer): + +```bash +npm install -g draftcat +draftcat --help +``` + +The installer downloads the matching Linux, macOS, or Windows binary and verifies it against the checksums attached to the GitHub release. No Go toolchain is required. + +Or build from source: + ```bash git clone https://github.com/renezander030/draftcat.git && cd draftcat cp secrets.yaml.example secrets.yaml # operator IDs + API keys diff --git a/npm/draftcat.js b/npm/draftcat.js new file mode 100755 index 0000000..e6841e3 --- /dev/null +++ b/npm/draftcat.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +"use strict"; + +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const executable = process.platform === "win32" ? "draftcat.exe" : "draftcat"; +const binary = path.join(__dirname, "bin", executable); + +if (!fs.existsSync(binary)) { + console.error("Draftcat is not installed. Reinstall the package to download its binary."); + process.exit(1); +} + +const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" }); +if (result.error) { + console.error(`Unable to start Draftcat: ${result.error.message}`); + process.exit(1); +} +if (result.signal) { + process.kill(process.pid, result.signal); +} else { + process.exit(result.status === null ? 1 : result.status); +} diff --git a/npm/install.js b/npm/install.js new file mode 100755 index 0000000..e506761 --- /dev/null +++ b/npm/install.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const https = require("node:https"); +const path = require("node:path"); +const zlib = require("node:zlib"); +const { version } = require("../package.json"); + +const repository = "renezander030/draftcat"; +const maximumDownloadBytes = 128 * 1024 * 1024; +const supported = new Set([ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64", +]); + +function artifactFor(platform, arch, releaseVersion = version) { + const target = `${platform}-${arch}`; + if (!supported.has(target)) { + throw new Error(`Unsupported platform: ${target}`); + } + const extension = platform === "win32" ? ".exe" : ""; + return `draftcat-v${releaseVersion}-${target}${extension}.gz`; +} + +function parseChecksums(contents) { + const checksums = new Map(); + for (const line of contents.trim().split(/\r?\n/)) { + const match = /^([a-f0-9]{64})\s+\*?(.+)$/.exec(line.trim()); + if (match) { + checksums.set(match[2], match[1]); + } + } + return checksums; +} + +function download(url, redirects = 0) { + if (redirects > 5) { + return Promise.reject(new Error("Too many redirects while downloading Draftcat")); + } + return new Promise((resolve, reject) => { + const request = https.get(url, { + headers: { "User-Agent": `draftcat-npm/${version}` }, + }, (response) => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume(); + const next = new URL(response.headers.location, url); + if (next.protocol !== "https:") { + reject(new Error("Refusing a non-HTTPS release redirect")); + return; + } + download(next, redirects + 1).then(resolve, reject); + return; + } + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`Download failed with HTTP ${response.statusCode}`)); + return; + } + const chunks = []; + let size = 0; + response.on("data", (chunk) => { + size += chunk.length; + if (size > maximumDownloadBytes) { + request.destroy(new Error("Draftcat release asset is unexpectedly large")); + return; + } + chunks.push(chunk); + }); + response.on("end", () => resolve(Buffer.concat(chunks))); + }); + request.on("error", reject); + request.setTimeout(30_000, () => request.destroy(new Error("Draftcat download timed out"))); + }); +} + +async function install() { + const artifact = artifactFor(process.platform, process.arch); + const releaseBase = `https://github.com/${repository}/releases/download/v${version}`; + const [archive, checksumFile] = await Promise.all([ + download(`${releaseBase}/${artifact}`), + download(`${releaseBase}/SHA256SUMS`), + ]); + + const expected = parseChecksums(checksumFile.toString("utf8")).get(artifact); + if (!expected) { + throw new Error(`No checksum was published for ${artifact}`); + } + const actual = crypto.createHash("sha256").update(archive).digest("hex"); + if (!crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected))) { + throw new Error(`Checksum verification failed for ${artifact}`); + } + + const executable = process.platform === "win32" ? "draftcat.exe" : "draftcat"; + const binaryDirectory = path.join(__dirname, "bin"); + const destination = path.join(binaryDirectory, executable); + fs.mkdirSync(binaryDirectory, { recursive: true }); + fs.writeFileSync(destination, zlib.gunzipSync(archive), { mode: 0o755 }); + if (process.platform !== "win32") { + fs.chmodSync(destination, 0o755); + } + console.log(`Installed Draftcat v${version} for ${process.platform}-${process.arch}`); +} + +if (require.main === module) { + install().catch((error) => { + console.error(`Unable to install Draftcat: ${error.message}`); + process.exit(1); + }); +} + +module.exports = { artifactFor, parseChecksums }; diff --git a/npm/install.test.js b/npm/install.test.js new file mode 100644 index 0000000..39b794d --- /dev/null +++ b/npm/install.test.js @@ -0,0 +1,24 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { artifactFor, parseChecksums } = require("./install"); + +test("maps Node targets to release assets", () => { + assert.equal(artifactFor("linux", "x64", "0.7.0"), "draftcat-v0.7.0-linux-x64.gz"); + assert.equal(artifactFor("darwin", "arm64", "0.7.0"), "draftcat-v0.7.0-darwin-arm64.gz"); + assert.equal(artifactFor("win32", "x64", "0.7.0"), "draftcat-v0.7.0-win32-x64.exe.gz"); +}); + +test("rejects unsupported targets", () => { + assert.throws(() => artifactFor("freebsd", "x64"), /Unsupported platform/); + assert.throws(() => artifactFor("linux", "ia32"), /Unsupported platform/); +}); + +test("reads GNU and binary-style checksum lines", () => { + const a = "a".repeat(64); + const b = "b".repeat(64); + const checksums = parseChecksums(`${a} draftcat-a.gz\n${b} *draftcat-b.gz\n`); + assert.equal(checksums.get("draftcat-a.gz"), a); + assert.equal(checksums.get("draftcat-b.gz"), b); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3308c29 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "draftcat", + "version": "0.7.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "draftcat", + "version": "0.7.0", + "cpu": [ + "x64", + "arm64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "draftcat": "npm/draftcat.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ae1261 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "draftcat", + "version": "0.7.0", + "description": "Governed AI pipelines with human approval gates", + "license": "MIT", + "author": "Rene Zander", + "homepage": "https://github.com/renezander030/draftcat#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/renezander030/draftcat.git" + }, + "bugs": { + "url": "https://github.com/renezander030/draftcat/issues" + }, + "keywords": [ + "ai", + "approval", + "governance", + "human-in-the-loop", + "llm" + ], + "bin": { + "draftcat": "npm/draftcat.js" + }, + "files": [ + "npm/draftcat.js", + "npm/install.js", + "README.md", + "LICENSE" + ], + "scripts": { + "postinstall": "node npm/install.js", + "test": "node --test npm/*.test.js" + }, + "engines": { + "node": ">=18" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ], + "publishConfig": { + "access": "public" + } +}