diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ab9445..323003b 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,44 @@ 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 + permissions: + id-token: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v4 + with: + node-version: 26 + registry-url: https://registry.npmjs.org + + - name: Update npm + run: npm install -g npm@latest + + - 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 diff --git a/README.md b/README.md index 5c37004..935df87 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 @code0-tech/cli +``` + **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..3ec22c2 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,19 @@ +# codezero + +`codezero` — a CLI setup wizard for running [CodeZero](https://codezero.build) locally. + +```sh +npm install -g @code0-tech/cli +codezero +``` + +This package is a 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) +and extracts it with `tar-stream`/`yauzl`. 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..d5b69fd --- /dev/null +++ b/npm/lib/extract.js @@ -0,0 +1,67 @@ +'use strict'; + +const zlib = require('zlib'); +const { Readable } = require('stream'); +const tar = require('tar-stream'); +const yauzl = require('yauzl'); + +// Pulls a single named file out of the gzip-compressed tarballs the release +// workflow produces. +function extractTarGz(buffer, fileName) { + return new Promise((resolve, reject) => { + const extract = tar.extract(); + let found = null; + + extract.on('entry', (header, stream, next) => { + if (header.name !== fileName) { + stream.resume(); + return next(); + } + const chunks = []; + stream.on('data', (c) => chunks.push(c)); + stream.on('end', () => { + found = Buffer.concat(chunks); + next(); + }); + }); + + extract.on('finish', () => { + if (!found) reject(new Error(`"${fileName}" not found in tar archive`)); + else resolve(found); + }); + extract.on('error', reject); + + Readable.from(zlib.gunzipSync(buffer)).pipe(extract); + }); +} + +// Pulls a single named file out of the zip archives the release workflow +// produces. +function extractZip(buffer, fileName) { + return new Promise((resolve, reject) => { + yauzl.fromBuffer(buffer, { lazyEntries: true }, (err, zipfile) => { + if (err) return reject(err); + + zipfile.on('error', reject); + zipfile.on('end', () => reject(new Error(`"${fileName}" not found in zip archive`))); + + zipfile.on('entry', (entry) => { + if (entry.fileName !== fileName) { + zipfile.readEntry(); + return; + } + zipfile.openReadStream(entry, (err, stream) => { + if (err) return reject(err); + const chunks = []; + stream.on('data', (c) => chunks.push(c)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); + }); + + zipfile.readEntry(); + }); + }); +} + +module.exports = { extractTarGz, extractZip }; diff --git a/npm/lib/install.js b/npm/lib/install.js new file mode 100644 index 0000000..4fc669e --- /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' ? await extractZip(archive, binaryName()) : await 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-lock.json b/npm/package-lock.json new file mode 100644 index 0000000..18bf365 --- /dev/null +++ b/npm/package-lock.json @@ -0,0 +1,201 @@ +{ + "name": "@code0-tech/cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@code0-tech/cli", + "version": "0.1.0", + "cpu": [ + "x64", + "arm64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "tar-stream": "^3.2.2", + "yauzl": "^3.4.0" + }, + "bin": { + "codezero": "bin/codezero.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.5.tgz", + "integrity": "sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + } + } +} diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..bc1c148 --- /dev/null +++ b/npm/package.json @@ -0,0 +1,32 @@ +{ + "name": "@code0-tech/cli", + "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" + }, + "dependencies": { + "tar-stream": "^3.2.2", + "yauzl": "^3.4.0" + } +}