Add npm CLI package publishing

This commit is contained in:
2026-06-26 13:59:54 -05:00
parent b4ff4f9f14
commit e564777877
7 changed files with 534 additions and 2 deletions
+57
View File
@@ -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/<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.
+12 -2
View File
@@ -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:
+126
View File
@@ -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 });
}
}
+200
View File
@@ -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}`);
+105
View File
@@ -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.`);
}
+26
View File
@@ -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.');
+8
View File
@@ -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"
}
}