From e564777877890e6c04870804b8bd68f994d721ef Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Fri, 26 Jun 2026 13:59:54 -0500 Subject: [PATCH] Add npm CLI package publishing --- AGENTS.md | 57 ++++++++ README.md | 14 +- npm/scripts/lib/release-config.mjs | 126 ++++++++++++++++++ npm/scripts/prepare-release.mjs | 200 +++++++++++++++++++++++++++++ npm/scripts/publish-release.mjs | 105 +++++++++++++++ npm/scripts/verify-release.mjs | 26 ++++ package.json | 8 ++ 7 files changed, 534 insertions(+), 2 deletions(-) create mode 100644 npm/scripts/lib/release-config.mjs create mode 100755 npm/scripts/prepare-release.mjs create mode 100755 npm/scripts/publish-release.mjs create mode 100755 npm/scripts/verify-release.mjs create mode 100644 package.json diff --git a/AGENTS.md b/AGENTS.md index 8686351..89f4a00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,6 +199,63 @@ gh release view "$TAG" --repo owenqwenstarsky/cassady --json tagName,name,isDraf Do **not** publish the draft or mark it as the final/latest release unless the user explicitly asks. +## NPM package process: prepare and publish the CLI packages + +Use this process when preparing Cassady for npm. The npm distribution is a tiny wrapper package plus platform-specific binary packages. Do not publish to npm unless the user explicitly asks; when they ask for a publish-ready setup, make sure the packages can be published with one command. + +### Package layout and names + +The committed npm tooling generates packages under `dist/npm/` from the current Rust version in `Cargo.toml`: + +- Wrapper package: `cassady` + - Exposes both npm binaries: `cass` and `cassady`. + - Depends on the platform packages through `optionalDependencies` at the exact same version. +- Platform packages: + - `@cassady/cli-darwin-arm64` for `aarch64-apple-darwin` + - `@cassady/cli-linux-x64` for `x86_64-unknown-linux-gnu` + - `@cassady/cli-linux-arm64` for `aarch64-unknown-linux-gnu` + - `@cassady/cli-win32-x64` for `x86_64-pc-windows-gnu` + +Before the first publish, make sure the npm account owns or has publish access to the `cassady` package name and the `@cassady` scope. If the npm package name or scope changes, update `npm/scripts/lib/release-config.mjs` and regenerate the packages. Scoped packages are published with `--access public`. + +### 1. Build the release binaries first + +The npm packages copy binaries from `target//release/`, so run the same verification and target builds used for the GitHub release first: + +```sh +cargo +stable test --locked --all-targets +cargo +stable build --release --locked --target aarch64-apple-darwin +cargo +stable zigbuild --release --locked --target x86_64-unknown-linux-gnu +cargo +stable zigbuild --release --locked --target aarch64-unknown-linux-gnu +cargo +stable zigbuild --release --locked --target x86_64-pc-windows-gnu +``` + +### 2. Generate and verify the npm package directories + +```sh +npm run npm:prepare +npm run npm:verify +``` + +`npm:prepare` rebuilds `dist/npm/` for the current `Cargo.toml` version. `npm:verify` runs `npm publish --dry-run` for each generated package and does not publish anything. + +### 3. Publish to npm when explicitly requested + +The publish command prepares packages, checks npm auth, runs `npm login` if needed, checks whether each package version already exists, publishes platform packages first, publishes the wrapper last, and verifies that the published versions can be packed from npm. The existence/verification checks intentionally use `npm pack --dry-run` instead of `npm view` because npm registry metadata for newly-created scoped packages can briefly return 404 even after publish succeeds. + +```sh +npm run npm:publish +``` + +Optional checks and controls: + +```sh +npm run npm:publish -- --dry-run # full publish flow without publishing +NPM_TAG=next npm run npm:publish # publish under a non-latest dist-tag +``` + +If a version already exists, npm cannot overwrite it. Stop and ask before changing versions, deleting packages, or moving dist-tags. + ## Roadmap process: write a new release entry in `ROADMAP.md` Use this process when planning a future Cassady release. Follow the existing newest-first format in `ROADMAP.md` so new entries look like prior releases. diff --git a/README.md b/README.md index 482add9..6104dd1 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,12 @@ The project installs two equivalent commands, `cass` and `cassady`; examples use - Windows binaries are built for releases, but deeper Windows terminal, path, shell, and filesystem polish is planned for a later release. - `cass update` can update release-archive installs from official GitHub releases; external package managers should still be updated through their own tools. -## Install from source +## Install with npm + +The recommended installation method is npm: ```sh -cargo install --path . +npm install -g cassady ``` This installs both commands: @@ -26,6 +28,14 @@ cass --version cassady --version ``` +The npm package installs a small launcher plus the matching Rust-built binary package for your platform. Supported npm platforms are macOS Apple Silicon, Linux x86_64, Linux ARM64, and Windows x86_64. + +## Install from source + +```sh +cargo install --path . +``` + ## First use Start Cassady in a project directory: diff --git a/npm/scripts/lib/release-config.mjs b/npm/scripts/lib/release-config.mjs new file mode 100644 index 0000000..51cb693 --- /dev/null +++ b/npm/scripts/lib/release-config.mjs @@ -0,0 +1,126 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +export const scriptsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const repoRoot = path.resolve(scriptsDir, '..', '..'); +export const generatedRoot = path.join(repoRoot, 'dist', 'npm'); + +export const wrapperPackage = { + name: 'cassady', + dirName: 'cassady', +}; + +export const platforms = [ + { + name: '@cassady/cli-darwin-arm64', + dirName: 'cassady-cli-darwin-arm64', + description: 'Cassady CLI binaries for macOS Apple Silicon.', + triple: 'aarch64-apple-darwin', + os: 'darwin', + cpu: 'arm64', + exeSuffix: '', + }, + { + name: '@cassady/cli-linux-x64', + dirName: 'cassady-cli-linux-x64', + description: 'Cassady CLI binaries for Linux x86_64.', + triple: 'x86_64-unknown-linux-gnu', + os: 'linux', + cpu: 'x64', + exeSuffix: '', + }, + { + name: '@cassady/cli-linux-arm64', + dirName: 'cassady-cli-linux-arm64', + description: 'Cassady CLI binaries for Linux ARM64.', + triple: 'aarch64-unknown-linux-gnu', + os: 'linux', + cpu: 'arm64', + exeSuffix: '', + }, + { + name: '@cassady/cli-win32-x64', + dirName: 'cassady-cli-win32-x64', + description: 'Cassady CLI binaries for Windows x86_64.', + triple: 'x86_64-pc-windows-gnu', + os: 'win32', + cpu: 'x64', + exeSuffix: '.exe', + }, +]; + +export function readCargoVersion() { + const cargoToml = fs.readFileSync(path.join(repoRoot, 'Cargo.toml'), 'utf8'); + const match = cargoToml.match(/^version\s*=\s*"([^"]+)"/m); + if (!match) { + throw new Error('Could not read package.version from Cargo.toml'); + } + return match[1]; +} + +export function packageDir(packageInfo) { + return path.join(generatedRoot, packageInfo.dirName); +} + +export function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repoRoot, + stdio: options.stdio ?? 'inherit', + env: options.env ?? process.env, + shell: false, + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const rendered = [command, ...args].join(' '); + throw new Error(`${rendered} exited with status ${result.status}`); + } + + return result; +} + +export function npmArgsForPublish(packageInfo, { dryRun = false, tag = 'latest' } = {}) { + const args = ['publish', '--tag', tag]; + if (packageInfo.name.startsWith('@')) { + args.push('--access', 'public'); + } + if (dryRun) { + args.push('--dry-run'); + } + return args; +} + +export function npmPackVersion(packageName, version) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cassady-npm-pack-')); + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cassady-npm-cache-')); + try { + return spawnSync( + 'npm', + [ + 'pack', + `${packageName}@${version}`, + '--dry-run', + '--json', + '--registry=https://registry.npmjs.org/', + '--prefer-online', + '--cache', + cacheDir, + ], + { + cwd: tempDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + } + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(cacheDir, { recursive: true, force: true }); + } +} diff --git a/npm/scripts/prepare-release.mjs b/npm/scripts/prepare-release.mjs new file mode 100755 index 0000000..764581e --- /dev/null +++ b/npm/scripts/prepare-release.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { + generatedRoot, + packageDir, + platforms, + readCargoVersion, + repoRoot, + wrapperPackage, +} from './lib/release-config.mjs'; + +const version = readCargoVersion(); + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function ensureFile(filePath, help) { + if (!fs.existsSync(filePath)) { + throw new Error(`Missing ${filePath}\n${help}`); + } +} + +function copyExecutable(source, destination) { + fs.copyFileSync(source, destination); + fs.chmodSync(destination, 0o755); +} + +function repositoryMetadata() { + return { + type: 'git', + url: 'git+https://github.com/owenqwenstarsky/cassady.git', + }; +} + +function packageHomepage() { + return 'https://github.com/owenqwenstarsky/cassady#readme'; +} + +function writePlatformPackage(platform) { + const dir = packageDir(platform); + const binDir = path.join(dir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + for (const command of ['cass', 'cassady']) { + const fileName = `${command}${platform.exeSuffix}`; + const source = path.join(repoRoot, 'target', platform.triple, 'release', fileName); + const destination = path.join(binDir, fileName); + ensureFile( + source, + `Build release binaries first. For ${platform.triple}, run the release build command from AGENTS.md.` + ); + copyExecutable(source, destination); + } + + writeJson(path.join(dir, 'package.json'), { + name: platform.name, + version, + description: platform.description, + license: 'MIT', + homepage: packageHomepage(), + repository: repositoryMetadata(), + os: [platform.os], + cpu: [platform.cpu], + files: ['bin'], + }); + + fs.writeFileSync( + path.join(dir, 'README.md'), + `# ${platform.name}\n\n${platform.description}\n\nThis package is installed automatically by the \`cassady\` npm package on supported platforms. It contains the Rust-built \`cass\` and \`cassady\` executables for \`${platform.triple}\`.\n` + ); +} + +function writeWrapperPackage() { + const dir = packageDir(wrapperPackage); + fs.mkdirSync(path.join(dir, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'lib'), { recursive: true }); + + const optionalDependencies = Object.fromEntries( + platforms.map((platform) => [platform.name, version]) + ); + + writeJson(path.join(dir, 'package.json'), { + name: wrapperPackage.name, + version, + description: 'Cassady/Cass minimal terminal coding agent', + license: 'MIT', + homepage: packageHomepage(), + repository: repositoryMetadata(), + bin: { + cass: 'bin/cass.js', + cassady: 'bin/cassady.js', + }, + optionalDependencies, + engines: { + node: '>=16', + }, + files: ['bin', 'lib'], + }); + + fs.writeFileSync( + path.join(dir, 'README.md'), + `# Cassady / Cass\n\nCassady (\`cass\`) is a terminal coding agent written in Rust. This npm package is a tiny launcher that installs the matching platform-specific binary package and exposes both commands:\n\n\`\`\`sh\nnpm install -g cassady\ncass --version\ncassady --version\n\`\`\`\n\nThe platform-specific packages contain the Rust-built executables. Supported npm platforms are macOS Apple Silicon, Linux x86_64, Linux ARM64, and Windows x86_64.\n` + ); + + const launcher = `'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const packagesByPlatform = { + 'darwin-arm64': '@cassady/cli-darwin-arm64', + 'linux-x64': '@cassady/cli-linux-x64', + 'linux-arm64': '@cassady/cli-linux-arm64', + 'win32-x64': '@cassady/cli-win32-x64', +}; + +function packageNameForCurrentPlatform() { + const key = process.platform + '-' + process.arch; + const packageName = packagesByPlatform[key]; + if (!packageName) { + throw new Error( + 'Unsupported platform for Cassady: ' + process.platform + ' ' + process.arch + + '. Supported platforms: ' + Object.keys(packagesByPlatform).join(', ') + ); + } + return packageName; +} + +function binaryPath(command) { + const packageName = packageNameForCurrentPlatform(); + let packageJsonPath; + try { + packageJsonPath = require.resolve(packageName + '/package.json'); + } catch (error) { + throw new Error( + 'Cassady binary package was not installed: ' + packageName + '.\\n' + + 'Try reinstalling with npm install -g cassady and make sure optional dependencies are enabled.\\n' + + 'Original error: ' + error.message + ); + } + + const executable = process.platform === 'win32' ? command + '.exe' : command; + const resolved = path.join(path.dirname(packageJsonPath), 'bin', executable); + if (!fs.existsSync(resolved)) { + throw new Error('Cassady executable is missing from ' + packageName + ': ' + resolved); + } + return resolved; +} + +function run(command) { + let executable; + try { + executable = binaryPath(command); + } catch (error) { + console.error(error.message); + process.exit(1); + } + + const result = spawnSync(executable, process.argv.slice(2), { stdio: 'inherit' }); + if (result.error) { + console.error(result.error.message); + process.exit(1); + } + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + process.exit(result.status == null ? 1 : result.status); +} + +module.exports = { run }; +`; + + fs.writeFileSync(path.join(dir, 'lib', 'run.js'), launcher); + + for (const command of ['cass', 'cassady']) { + const scriptPath = path.join(dir, 'bin', `${command}.js`); + fs.writeFileSync(scriptPath, `#!/usr/bin/env node\nrequire('../lib/run').run('${command}');\n`); + fs.chmodSync(scriptPath, 0o755); + } +} + +fs.rmSync(generatedRoot, { recursive: true, force: true }); +fs.mkdirSync(generatedRoot, { recursive: true }); + +for (const platform of platforms) { + writePlatformPackage(platform); +} +writeWrapperPackage(); + +console.log(`Prepared npm packages in ${generatedRoot}`); +console.log(`Version: ${version}`); +console.log('Packages:'); +for (const platform of platforms) { + console.log(`- ${platform.name}`); +} +console.log(`- ${wrapperPackage.name}`); diff --git a/npm/scripts/publish-release.mjs b/npm/scripts/publish-release.mjs new file mode 100755 index 0000000..8712490 --- /dev/null +++ b/npm/scripts/publish-release.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + npmArgsForPublish, + npmPackVersion, + packageDir, + platforms, + readCargoVersion, + run, + scriptsDir, + wrapperPackage, +} from './lib/release-config.mjs'; + +const args = new Set(process.argv.slice(2)); +const dryRun = args.has('--dry-run'); +const skipPrepare = args.has('--skip-prepare'); +const tag = process.env.NPM_TAG || 'latest'; +const version = readCargoVersion(); +const packages = [...platforms, wrapperPackage]; + +function npmWhoami() { + return spawnSync('npm', ['whoami'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); +} + +function ensureNpmAuth() { + const whoami = npmWhoami(); + if (whoami.status === 0) { + console.log(`Authenticated to npm as ${whoami.stdout.trim()}`); + return; + } + + console.log('Not authenticated to npm. Starting `npm login`...'); + run('npm', ['login']); + + const afterLogin = npmWhoami(); + if (afterLogin.status !== 0) { + throw new Error('npm login completed, but `npm whoami` still failed.'); + } + console.log(`Authenticated to npm as ${afterLogin.stdout.trim()}`); +} + +function isNotFound(result) { + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + return result.status !== 0 && /E404|404 Not Found|not found/i.test(output); +} + +function publishedVersionExists(packageName) { + const result = npmPackVersion(packageName, version); + if (result.status === 0) { + return true; + } + if (isNotFound(result)) { + return false; + } + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); + throw new Error(`Could not query npm for ${packageName}@${version}:\n${output}`); +} + +if (!skipPrepare) { + run(process.execPath, [path.join(scriptsDir, 'prepare-release.mjs')]); +} + +ensureNpmAuth(); + +console.log(`\nChecking npm registry for ${version}...`); +for (const packageInfo of packages) { + if (publishedVersionExists(packageInfo.name)) { + console.log(`- ${packageInfo.name}@${version} already exists; it will be skipped.`); + } else { + console.log(`- ${packageInfo.name}@${version} is available.`); + } +} + +console.log(`\n${dryRun ? 'Dry-run publishing' : 'Publishing'} npm packages with tag "${tag}"...`); +for (const packageInfo of packages) { + if (!dryRun && publishedVersionExists(packageInfo.name)) { + console.log(`\n==> Skipping ${packageInfo.name}@${version}; already published.`); + continue; + } + + console.log(`\n==> ${packageInfo.name}`); + run('npm', npmArgsForPublish(packageInfo, { dryRun, tag }), { + cwd: packageDir(packageInfo), + }); +} + +if (dryRun) { + console.log('\nDry run completed. No packages were published.'); +} else { + console.log('\nVerifying published npm packages...'); + for (const packageInfo of packages) { + const result = npmPackVersion(packageInfo.name, version); + if (result.status !== 0) { + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); + throw new Error(`Published package not visible on npm yet: ${packageInfo.name}@${version}\n${output}`); + } + console.log(`- ${packageInfo.name}@${version}`); + } + console.log(`\nPublished Cassady ${version} to npm.`); +} diff --git a/npm/scripts/verify-release.mjs b/npm/scripts/verify-release.mjs new file mode 100755 index 0000000..1e802ba --- /dev/null +++ b/npm/scripts/verify-release.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { + npmArgsForPublish, + packageDir, + platforms, + readCargoVersion, + run, + scriptsDir, + wrapperPackage, +} from './lib/release-config.mjs'; + +const version = readCargoVersion(); +const packages = [...platforms, wrapperPackage]; + +run(process.execPath, [path.join(scriptsDir, 'prepare-release.mjs')]); + +console.log(`\nVerifying npm packages for ${version} with npm publish --dry-run...`); +for (const packageInfo of packages) { + console.log(`\n==> ${packageInfo.name}`); + run('npm', npmArgsForPublish(packageInfo, { dryRun: true }), { + cwd: packageDir(packageInfo), + }); +} + +console.log('\nNPM package verification completed. No packages were published.'); diff --git a/package.json b/package.json new file mode 100644 index 0000000..a36cc40 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "scripts": { + "npm:prepare": "node npm/scripts/prepare-release.mjs", + "npm:verify": "node npm/scripts/verify-release.mjs", + "npm:publish": "node npm/scripts/publish-release.mjs" + } +}