From 2e91c74bf53cd8bffa2a2f8308119955cae0227e Mon Sep 17 00:00:00 2001 From: Raphael Date: Sat, 24 Oct 2026 22:33:18 +0200 Subject: [PATCH] feat: added npm and cargo release --- .github/workflows/release.yml | 42 ++++++++++++ README.md | 6 ++ npm/.gitignore | 3 + npm/LICENSE | 21 ++++++ npm/README.md | 18 +++++ npm/bin/codezero.js | 16 +++++ npm/lib/extract.js | 76 +++++++++++++++++++++ npm/lib/install.js | 122 ++++++++++++++++++++++++++++++++++ npm/package.json | 28 ++++++++ 9 files changed, 332 insertions(+) create mode 100644 npm/.gitignore create mode 100644 npm/LICENSE create mode 100644 npm/README.md create mode 100755 npm/bin/codezero.js create mode 100644 npm/lib/extract.js create mode 100644 npm/lib/install.js create mode 100644 npm/package.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ab9445..94385ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,10 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Set crate version from tag + shell: bash + run: sed -i.bak "s/^version = \".*\"/version = \"${GITHUB_REF_NAME#v}\"/" Cargo.toml && rm Cargo.toml.bak + - name: Install Rust uses: dtolnay/rust-toolchain@stable with: @@ -79,3 +83,41 @@ jobs: with: files: dist/* generate_release_notes: true + + cargo-publish: + needs: github-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set crate version from tag + run: sed -i "s/^version = \".*\"/version = \"${GITHUB_REF_NAME#v}\"/" Cargo.toml + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Publish + run: cargo publish --token "${{ secrets.CARGO_REGISTRY_TOKEN }}" + + npm-publish: + needs: github-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Set package version from tag + working-directory: npm + run: npm version --no-git-tag-version "${GITHUB_REF_NAME#v}" + + - name: Publish + working-directory: npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 5c37004..c7d5d53 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ brew install code0-tech/tap/codezero cargo install codezero ``` +**npm:** + +```sh +npm install -g codezero +``` + **Prebuilt binary:** grab the archive for your OS/arch from the [latest release](https://github.com/code0-tech/hydra/releases/latest) and put the extracted `codezero` on your `PATH`. diff --git a/npm/.gitignore b/npm/.gitignore new file mode 100644 index 0000000..00379ad --- /dev/null +++ b/npm/.gitignore @@ -0,0 +1,3 @@ +bin/codezero +bin/codezero.exe +bin/*.download diff --git a/npm/LICENSE b/npm/LICENSE new file mode 100644 index 0000000..8ad1deb --- /dev/null +++ b/npm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 code0-tech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..7e94049 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,18 @@ +# codezero + +`codezero` — a CLI setup wizard for running [CodeZero](https://codezero.build) locally. + +```sh +npm install -g codezero +codezero +``` + +This package is a thin Node.js wrapper: on install it downloads the prebuilt `codezero` binary +for your OS/arch from the matching [GitHub release](https://github.com/code0-tech/hydra/releases). +If the postinstall download is skipped (e.g. `pnpm` without `trustedDependencies`, Deno, or +`--ignore-scripts`), the wrapper downloads the binary lazily on first run instead. + +Supported platforms: Linux x64, macOS x64/arm64, Windows x64. + +Source, issues, and other install methods (Homebrew, Cargo, prebuilt archives): see the +[hydra repository](https://github.com/code0-tech/hydra). diff --git a/npm/bin/codezero.js b/npm/bin/codezero.js new file mode 100755 index 0000000..962ecb8 --- /dev/null +++ b/npm/bin/codezero.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('child_process'); +const { ensureBinary } = require('../lib/install.js'); + +ensureBinary() + .then((binPath) => { + const result = spawnSync(binPath, process.argv.slice(2), { stdio: 'inherit' }); + if (result.error) throw result.error; + process.exit(result.status === null ? 1 : result.status); + }) + .catch((err) => { + console.error(`codezero: ${err.message}`); + process.exit(1); + }); diff --git a/npm/lib/extract.js b/npm/lib/extract.js new file mode 100644 index 0000000..f744275 --- /dev/null +++ b/npm/lib/extract.js @@ -0,0 +1,76 @@ +'use strict'; + +const zlib = require('zlib'); + +// Minimal USTAR reader: enough to pull a single named file out of the +// gzip-compressed tarballs the release workflow produces. Avoids a +// dependency on the `tar` package for a one-entry archive. +function extractTarGz(buffer, fileName) { + const tar = zlib.gunzipSync(buffer); + let offset = 0; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every((b) => b === 0)) break; // end-of-archive marker + + const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, ''); + const sizeOctal = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim(); + const size = parseInt(sizeOctal, 8) || 0; + const typeflag = String.fromCharCode(header[156]); + + const dataStart = offset + 512; + if (name === fileName && (typeflag === '0' || typeflag === '\0')) { + return tar.subarray(dataStart, dataStart + size); + } + + offset = dataStart + Math.ceil(size / 512) * 512; + } + throw new Error(`"${fileName}" not found in tar archive`); +} + +// Minimal ZIP reader for a single stored/deflated entry, using the +// central directory to find it. Avoids a dependency on a zip library. +function extractZip(buffer, fileName) { + const EOCD_SIG = 0x06054b50; + let eocdOffset = -1; + for (let i = buffer.length - 22; i >= 0; i--) { + if (buffer.readUInt32LE(i) === EOCD_SIG) { + eocdOffset = i; + break; + } + } + if (eocdOffset === -1) throw new Error('not a valid zip file (no end-of-central-directory)'); + + const entryCount = buffer.readUInt16LE(eocdOffset + 10); + let cdOffset = buffer.readUInt32LE(eocdOffset + 16); + + for (let i = 0; i < entryCount; i++) { + const sig = buffer.readUInt32LE(cdOffset); + if (sig !== 0x02014b50) throw new Error('malformed zip central directory'); + + const compressionMethod = buffer.readUInt16LE(cdOffset + 10); + const compressedSize = buffer.readUInt32LE(cdOffset + 20); + const nameLen = buffer.readUInt16LE(cdOffset + 28); + const extraLen = buffer.readUInt16LE(cdOffset + 30); + const commentLen = buffer.readUInt16LE(cdOffset + 32); + const localHeaderOffset = buffer.readUInt32LE(cdOffset + 42); + const name = buffer + .subarray(cdOffset + 46, cdOffset + 46 + nameLen) + .toString('utf8'); + + if (name === fileName) { + const lfNameLen = buffer.readUInt16LE(localHeaderOffset + 26); + const lfExtraLen = buffer.readUInt16LE(localHeaderOffset + 28); + const dataStart = localHeaderOffset + 30 + lfNameLen + lfExtraLen; + const data = buffer.subarray(dataStart, dataStart + compressedSize); + + if (compressionMethod === 0) return data; + if (compressionMethod === 8) return zlib.inflateRawSync(data); + throw new Error(`unsupported zip compression method ${compressionMethod}`); + } + + cdOffset += 46 + nameLen + extraLen + commentLen; + } + throw new Error(`"${fileName}" not found in zip archive`); +} + +module.exports = { extractTarGz, extractZip }; diff --git a/npm/lib/install.js b/npm/lib/install.js new file mode 100644 index 0000000..e392fc5 --- /dev/null +++ b/npm/lib/install.js @@ -0,0 +1,122 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const https = require('https'); +const crypto = require('crypto'); + +const { extractTarGz, extractZip } = require('./extract.js'); + +const pkg = require('../package.json'); + +const REPO = 'code0-tech/hydra'; + +// Mirrors the `asset` names produced by .github/workflows/release.yml. +function releaseAsset() { + const platform = os.platform(); + const arch = os.arch(); + + if (platform === 'linux' && arch === 'x64') return { name: 'codezero-linux-x86_64', ext: 'tar.gz' }; + if (platform === 'darwin' && arch === 'x64') return { name: 'codezero-macos-x86_64', ext: 'tar.gz' }; + if (platform === 'darwin' && arch === 'arm64') return { name: 'codezero-macos-arm64', ext: 'tar.gz' }; + if (platform === 'win32' && arch === 'x64') return { name: 'codezero-windows-x86_64', ext: 'zip' }; + + throw new Error( + `codezero: no prebuilt binary for platform "${platform}" (${arch}). ` + + 'Install from source instead: https://github.com/code0-tech/hydra#install' + ); +} + +function binaryName() { + return os.platform() === 'win32' ? 'codezero.exe' : 'codezero'; +} + +function binaryDir() { + return path.join(__dirname, '..', 'bin'); +} + +function binaryPath() { + return path.join(binaryDir(), binaryName()); +} + +function get(url, redirects = 5) { + return new Promise((resolve, reject) => { + https + .get(url, { headers: { 'User-Agent': 'codezero-npm-installer' } }, (res) => { + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location && + redirects > 0 + ) { + res.resume(); + resolve(get(res.headers.location, redirects - 1)); + return; + } + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`request to ${url} failed with status ${res.statusCode}`)); + return; + } + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks))); + res.on('error', reject); + }) + .on('error', reject); + }); +} + +async function verifyChecksum(tag, assetName, data) { + try { + const checksums = ( + await get(`https://github.com/${REPO}/releases/download/${tag}/checksums.txt`) + ).toString('utf8'); + const line = checksums.split('\n').find((l) => l.trim().endsWith(assetName)); + if (!line) return; + const expected = line.trim().split(/\s+/)[0]; + const actual = crypto.createHash('sha256').update(data).digest('hex'); + if (expected !== actual) { + throw new Error(`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`); + } + } catch (err) { + if (err.message.startsWith('checksum mismatch')) throw err; + console.warn(`codezero: could not verify checksum (${err.message}), continuing anyway`); + } +} + +async function ensureBinary() { + const dest = binaryPath(); + if (fs.existsSync(dest)) return dest; + + const { name, ext } = releaseAsset(); + const tag = `v${pkg.version}`; + const assetName = `${name}.${ext}`; + const url = `https://github.com/${REPO}/releases/download/${tag}/${assetName}`; + + console.log(`codezero: downloading ${assetName} from release ${tag}...`); + const archive = await get(url); + await verifyChecksum(tag, assetName, archive); + + const data = ext === 'zip' ? extractZip(archive, binaryName()) : extractTarGz(archive, binaryName()); + + fs.mkdirSync(binaryDir(), { recursive: true }); + const tmp = `${dest}.download`; + fs.writeFileSync(tmp, data); + if (os.platform() !== 'win32') fs.chmodSync(tmp, 0o755); + fs.renameSync(tmp, dest); + + return dest; +} + +module.exports = { ensureBinary, binaryPath }; + +if (require.main === module) { + ensureBinary() + .then(() => console.log('codezero: binary ready')) + .catch((err) => { + console.warn(`codezero: postinstall download failed (${err.message}).`); + console.warn('codezero: it will be downloaded on first run instead.'); + }); +} diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..e008124 --- /dev/null +++ b/npm/package.json @@ -0,0 +1,28 @@ +{ + "name": "codezero", + "version": "0.1.0", + "description": "A CLI setup wizard for running CodeZero locally", + "license": "MIT", + "homepage": "https://codezero.build", + "repository": { + "type": "git", + "url": "git+https://github.com/code0-tech/hydra.git" + }, + "keywords": ["codezero", "cli", "setup"], + "bin": { + "codezero": "bin/codezero.js" + }, + "files": [ + "bin/codezero.js", + "lib/install.js", + "lib/extract.js" + ], + "scripts": { + "postinstall": "node lib/install.js || exit 0" + }, + "os": ["darwin", "linux", "win32"], + "cpu": ["x64", "arm64"], + "engines": { + "node": ">=18" + } +}