diff --git a/.github/workflows/release-cli-npm.yml b/.github/workflows/release-cli-npm.yml new file mode 100644 index 00000000..7b99f4ae --- /dev/null +++ b/.github/workflows/release-cli-npm.yml @@ -0,0 +1,148 @@ +name: Release CLI to NPM + +on: + push: + tags: [v*] + workflow_dispatch: + +jobs: + build-binaries: + name: Build ${{ matrix.pkg }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - pkg: cli-darwin-arm64 + runner: macos-latest + target: aarch64-apple-darwin + binary: yaakcli + - pkg: cli-darwin-x64 + runner: macos-latest + target: x86_64-apple-darwin + binary: yaakcli + - pkg: cli-linux-arm64 + runner: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + binary: yaakcli + - pkg: cli-linux-x64 + runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + binary: yaakcli + - pkg: cli-win32-arm64 + runner: windows-latest + target: aarch64-pc-windows-msvc + binary: yaakcli.exe + - pkg: cli-win32-x64 + runner: windows-latest + target: x86_64-pc-windows-msvc + binary: yaakcli.exe + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: release-cli-npm + cache-on-failure: true + + - name: Build yaakcli + run: cargo build --locked --release -p yaak-cli --bin yaakcli --target ${{ matrix.target }} + + - name: Stage binary artifact + shell: bash + run: | + set -euo pipefail + mkdir -p "npm/dist/${{ matrix.pkg }}" + cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}" + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.pkg }} + path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }} + if-no-files-found: error + + publish-npm: + name: Publish @yaakapp/cli packages + needs: build-binaries + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Download binary artifacts + uses: actions/download-artifact@v4 + with: + pattern: cli-* + path: npm/dist + merge-multiple: false + + - name: Prepare npm packages + env: + YAAK_CLI_VERSION: ${{ github.ref_name }} + run: node npm/prepare-publish.js + + - name: Ensure NPM token exists + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "NPM_TOKEN is not configured" + exit 1 + fi + + - name: Publish npm packages + working-directory: npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + node <<'JS' + const { execSync } = require('node:child_process'); + const { readFileSync } = require('node:fs'); + + const order = [ + 'cli-darwin-arm64', + 'cli-darwin-x64', + 'cli-linux-arm64', + 'cli-linux-x64', + 'cli-win32-arm64', + 'cli-win32-x64', + 'cli' + ]; + + function pkg(dir) { + return JSON.parse(readFileSync(`./${dir}/package.json`, 'utf-8')); + } + + for (const dir of order) { + const p = pkg(dir); + const spec = `${p.name}@${p.version}`; + + try { + execSync(`npm view ${spec} version`, { stdio: 'pipe' }); + console.log(`Skipping ${spec} (already published)`); + continue; + } catch (_) { + console.log(`Publishing ${spec}`); + execSync(`npm publish ./${dir} --access public`, { stdio: 'inherit' }); + } + } + JS diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 00000000..4629a58c --- /dev/null +++ b/npm/README.md @@ -0,0 +1,6 @@ +# Yaak CLI NPM Packages + +The Rust `yaakcli` binary is published to NPM with a meta package (`@yaakapp/cli`) and +platform-specific optional dependency packages. + +This follows the same strategy previously used in the standalone `yaak-cli` repo. diff --git a/npm/cli-darwin-arm64/bin/.gitkeep b/npm/cli-darwin-arm64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/npm/cli-darwin-arm64/package.json b/npm/cli-darwin-arm64/package.json new file mode 100644 index 00000000..e8ca680b --- /dev/null +++ b/npm/cli-darwin-arm64/package.json @@ -0,0 +1,10 @@ +{ + "name": "@yaakapp/cli-darwin-arm64", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "git+https://github.com/mountain-loop/yaak.git" + }, + "os": ["darwin"], + "cpu": ["arm64"] +} diff --git a/npm/cli-darwin-x64/bin/.gitkeep b/npm/cli-darwin-x64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/npm/cli-darwin-x64/package.json b/npm/cli-darwin-x64/package.json new file mode 100644 index 00000000..a7e34777 --- /dev/null +++ b/npm/cli-darwin-x64/package.json @@ -0,0 +1,10 @@ +{ + "name": "@yaakapp/cli-darwin-x64", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "git+https://github.com/mountain-loop/yaak.git" + }, + "os": ["darwin"], + "cpu": ["x64"] +} diff --git a/npm/cli-linux-arm64/bin/.gitkeep b/npm/cli-linux-arm64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/npm/cli-linux-arm64/package.json b/npm/cli-linux-arm64/package.json new file mode 100644 index 00000000..21ed08a8 --- /dev/null +++ b/npm/cli-linux-arm64/package.json @@ -0,0 +1,10 @@ +{ + "name": "@yaakapp/cli-linux-arm64", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "git+https://github.com/mountain-loop/yaak.git" + }, + "os": ["linux"], + "cpu": ["arm64"] +} diff --git a/npm/cli-linux-x64/bin/.gitkeep b/npm/cli-linux-x64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/npm/cli-linux-x64/package.json b/npm/cli-linux-x64/package.json new file mode 100644 index 00000000..72b7df70 --- /dev/null +++ b/npm/cli-linux-x64/package.json @@ -0,0 +1,10 @@ +{ + "name": "@yaakapp/cli-linux-x64", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "git+https://github.com/mountain-loop/yaak.git" + }, + "os": ["linux"], + "cpu": ["x64"] +} diff --git a/npm/cli-win32-arm64/bin/.gitkeep b/npm/cli-win32-arm64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/npm/cli-win32-arm64/package.json b/npm/cli-win32-arm64/package.json new file mode 100644 index 00000000..fdfe1451 --- /dev/null +++ b/npm/cli-win32-arm64/package.json @@ -0,0 +1,10 @@ +{ + "name": "@yaakapp/cli-win32-arm64", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "git+https://github.com/mountain-loop/yaak.git" + }, + "os": ["win32"], + "cpu": ["arm64"] +} diff --git a/npm/cli-win32-x64/bin/.gitkeep b/npm/cli-win32-x64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/npm/cli-win32-x64/package.json b/npm/cli-win32-x64/package.json new file mode 100644 index 00000000..5f615e82 --- /dev/null +++ b/npm/cli-win32-x64/package.json @@ -0,0 +1,10 @@ +{ + "name": "@yaakapp/cli-win32-x64", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "git+https://github.com/mountain-loop/yaak.git" + }, + "os": ["win32"], + "cpu": ["x64"] +} diff --git a/npm/cli/.gitignore b/npm/cli/.gitignore new file mode 100644 index 00000000..fc29e867 --- /dev/null +++ b/npm/cli/.gitignore @@ -0,0 +1,2 @@ +yaakcli +yaakcli.exe diff --git a/npm/cli/bin/cli.js b/npm/cli/bin/cli.js new file mode 100755 index 00000000..1a83aac8 --- /dev/null +++ b/npm/cli/bin/cli.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +const path = require("path"); +const childProcess = require("child_process"); +const { BINARY_NAME, PLATFORM_SPECIFIC_PACKAGE_NAME } = require("../common"); + +function getBinaryPath() { + try { + if (!PLATFORM_SPECIFIC_PACKAGE_NAME) { + throw new Error("unsupported platform"); + } + return require.resolve(`${PLATFORM_SPECIFIC_PACKAGE_NAME}/bin/${BINARY_NAME}`); + } catch (_) { + return path.join(__dirname, "..", BINARY_NAME); + } +} + +childProcess.execFileSync(getBinaryPath(), process.argv.slice(2), { + stdio: "inherit" +}); diff --git a/npm/cli/common.js b/npm/cli/common.js new file mode 100644 index 00000000..07caeaa6 --- /dev/null +++ b/npm/cli/common.js @@ -0,0 +1,20 @@ +const BINARY_DISTRIBUTION_PACKAGES = { + darwin_arm64: "@yaakapp/cli-darwin-arm64", + darwin_x64: "@yaakapp/cli-darwin-x64", + linux_arm64: "@yaakapp/cli-linux-arm64", + linux_x64: "@yaakapp/cli-linux-x64", + win32_x64: "@yaakapp/cli-win32-x64", + win32_arm64: "@yaakapp/cli-win32-arm64" +}; + +const BINARY_DISTRIBUTION_VERSION = require("./package.json").version; +const BINARY_NAME = process.platform === "win32" ? "yaakcli.exe" : "yaakcli"; +const PLATFORM_SPECIFIC_PACKAGE_NAME = + BINARY_DISTRIBUTION_PACKAGES[`${process.platform}_${process.arch}`]; + +module.exports = { + BINARY_DISTRIBUTION_PACKAGES, + BINARY_DISTRIBUTION_VERSION, + BINARY_NAME, + PLATFORM_SPECIFIC_PACKAGE_NAME +}; diff --git a/npm/cli/index.js b/npm/cli/index.js new file mode 100644 index 00000000..888c76ca --- /dev/null +++ b/npm/cli/index.js @@ -0,0 +1,20 @@ +const path = require("path"); +const childProcess = require("child_process"); +const { PLATFORM_SPECIFIC_PACKAGE_NAME, BINARY_NAME } = require("./common"); + +function getBinaryPath() { + try { + if (!PLATFORM_SPECIFIC_PACKAGE_NAME) { + throw new Error("unsupported platform"); + } + return require.resolve(`${PLATFORM_SPECIFIC_PACKAGE_NAME}/bin/${BINARY_NAME}`); + } catch (_) { + return path.join(__dirname, BINARY_NAME); + } +} + +module.exports.runBinary = function runBinary(...args) { + childProcess.execFileSync(getBinaryPath(), args, { + stdio: "inherit" + }); +}; diff --git a/npm/cli/install.js b/npm/cli/install.js new file mode 100644 index 00000000..ef8f1b59 --- /dev/null +++ b/npm/cli/install.js @@ -0,0 +1,97 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const zlib = require("node:zlib"); +const https = require("node:https"); +const { + BINARY_DISTRIBUTION_VERSION, + BINARY_NAME, + PLATFORM_SPECIFIC_PACKAGE_NAME +} = require("./common"); + +const fallbackBinaryPath = path.join(__dirname, BINARY_NAME); + +function makeRequest(url) { + return new Promise((resolve, reject) => { + https + .get(url, (response) => { + if (response.statusCode >= 200 && response.statusCode < 300) { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolve(Buffer.concat(chunks))); + } else if ( + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + makeRequest(response.headers.location).then(resolve, reject); + } else { + reject( + new Error( + `npm responded with status code ${response.statusCode} when downloading package ${url}` + ) + ); + } + }) + .on("error", (error) => reject(error)); + }); +} + +function extractFileFromTarball(tarballBuffer, filepath) { + let offset = 0; + while (offset < tarballBuffer.length) { + const header = tarballBuffer.subarray(offset, offset + 512); + offset += 512; + + const fileName = header.toString("utf-8", 0, 100).replace(/\0.*/g, ""); + const fileSize = parseInt(header.toString("utf-8", 124, 136).replace(/\0.*/g, ""), 8); + + if (fileName === filepath) { + return tarballBuffer.subarray(offset, offset + fileSize); + } + + offset = (offset + fileSize + 511) & ~511; + } + + return null; +} + +async function downloadBinaryFromNpm() { + if (!PLATFORM_SPECIFIC_PACKAGE_NAME) { + throw new Error(`Unsupported platform: ${process.platform}/${process.arch}`); + } + + const packageNameWithoutScope = PLATFORM_SPECIFIC_PACKAGE_NAME.split("/")[1]; + const tarballUrl = `https://registry.npmjs.org/${PLATFORM_SPECIFIC_PACKAGE_NAME}/-/${packageNameWithoutScope}-${BINARY_DISTRIBUTION_VERSION}.tgz`; + const tarballDownloadBuffer = await makeRequest(tarballUrl); + const tarballBuffer = zlib.unzipSync(tarballDownloadBuffer); + + const binary = extractFileFromTarball(tarballBuffer, `package/bin/${BINARY_NAME}`); + if (!binary) { + throw new Error(`Could not find package/bin/${BINARY_NAME} in tarball`); + } + + fs.writeFileSync(fallbackBinaryPath, binary); + fs.chmodSync(fallbackBinaryPath, "755"); +} + +function isPlatformSpecificPackageInstalled() { + try { + if (!PLATFORM_SPECIFIC_PACKAGE_NAME) { + return false; + } + require.resolve(`${PLATFORM_SPECIFIC_PACKAGE_NAME}/bin/${BINARY_NAME}`); + return true; + } catch (_) { + return false; + } +} + +if (!isPlatformSpecificPackageInstalled()) { + console.log("Platform package missing. Downloading Yaak CLI binary from npm..."); + downloadBinaryFromNpm().catch((err) => { + console.error("Failed to install Yaak CLI binary:", err); + process.exitCode = 1; + }); +} else { + console.log("Platform package present. Using bundled Yaak CLI binary."); +} diff --git a/npm/cli/package.json b/npm/cli/package.json new file mode 100644 index 00000000..db8e7e90 --- /dev/null +++ b/npm/cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "@yaakapp/cli", + "version": "0.0.1", + "main": "./index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/mountain-loop/yaak.git" + }, + "scripts": { + "postinstall": "node ./install.js", + "prepublishOnly": "node ./prepublish.js" + }, + "bin": { + "yaakcli": "bin/cli.js" + }, + "optionalDependencies": { + "@yaakapp/cli-darwin-x64": "0.0.1", + "@yaakapp/cli-darwin-arm64": "0.0.1", + "@yaakapp/cli-linux-arm64": "0.0.1", + "@yaakapp/cli-linux-x64": "0.0.1", + "@yaakapp/cli-win32-x64": "0.0.1", + "@yaakapp/cli-win32-arm64": "0.0.1" + } +} diff --git a/npm/cli/prepublish.js b/npm/cli/prepublish.js new file mode 100644 index 00000000..cddc3052 --- /dev/null +++ b/npm/cli/prepublish.js @@ -0,0 +1,5 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const readme = path.join(__dirname, "..", "..", "README.md"); +fs.copyFileSync(readme, path.join(__dirname, "README.md")); diff --git a/npm/prepare-publish.js b/npm/prepare-publish.js new file mode 100644 index 00000000..32b9949d --- /dev/null +++ b/npm/prepare-publish.js @@ -0,0 +1,74 @@ +const { copyFileSync, existsSync, readFileSync, writeFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const version = process.env.YAAK_CLI_VERSION?.replace(/^v/, ""); +if (!version) { + console.error("YAAK_CLI_VERSION is not set"); + process.exit(1); +} + +const packages = [ + "cli", + "cli-darwin-arm64", + "cli-darwin-x64", + "cli-linux-arm64", + "cli-linux-x64", + "cli-win32-arm64", + "cli-win32-x64" +]; + +const binaries = [ + { + src: join(__dirname, "dist", "cli-darwin-arm64", "yaakcli"), + dest: join(__dirname, "cli-darwin-arm64", "bin", "yaakcli") + }, + { + src: join(__dirname, "dist", "cli-darwin-x64", "yaakcli"), + dest: join(__dirname, "cli-darwin-x64", "bin", "yaakcli") + }, + { + src: join(__dirname, "dist", "cli-linux-arm64", "yaakcli"), + dest: join(__dirname, "cli-linux-arm64", "bin", "yaakcli") + }, + { + src: join(__dirname, "dist", "cli-linux-x64", "yaakcli"), + dest: join(__dirname, "cli-linux-x64", "bin", "yaakcli") + }, + { + src: join(__dirname, "dist", "cli-win32-arm64", "yaakcli.exe"), + dest: join(__dirname, "cli-win32-arm64", "bin", "yaakcli.exe") + }, + { + src: join(__dirname, "dist", "cli-win32-x64", "yaakcli.exe"), + dest: join(__dirname, "cli-win32-x64", "bin", "yaakcli.exe") + } +]; + +for (const { src, dest } of binaries) { + if (!existsSync(src)) { + console.error(`Missing binary artifact: ${src}`); + process.exit(1); + } + copyFileSync(src, dest); +} + +for (const pkg of packages) { + const filepath = join(__dirname, pkg, "package.json"); + const json = JSON.parse(readFileSync(filepath, "utf-8")); + json.version = version; + + if (json.name === "@yaakapp/cli") { + json.optionalDependencies = { + "@yaakapp/cli-darwin-x64": version, + "@yaakapp/cli-darwin-arm64": version, + "@yaakapp/cli-linux-arm64": version, + "@yaakapp/cli-linux-x64": version, + "@yaakapp/cli-win32-x64": version, + "@yaakapp/cli-win32-arm64": version + }; + } + + writeFileSync(filepath, `${JSON.stringify(json, null, 2)}\n`); +} + +console.log(`Prepared @yaakapp/cli npm packages for ${version}`);