Add npm CLI package publishing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Executable
+200
@@ -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}`);
|
||||
Executable
+105
@@ -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.`);
|
||||
}
|
||||
Executable
+26
@@ -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.');
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user