#!/usr/bin/env bun // Publishes cline and all platform-specific binary packages to npm. // // Usage: // bun script/publish-npm.ts # publish with "latest" tag // bun script/publish-npm.ts --tag next # publish with "next" tag // bun script/publish-npm.ts --dry-run # preview without publishing // // Prerequisites: // - Run script/build.ts first to generate dist/ packages // - GitHub trusted publishing or `npm login` for authentication import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { parseArgs } from "node:util"; import { $ } from "bun"; const cliDir = join(import.meta.dir, ".."); process.chdir(cliDir); const { values } = parseArgs({ args: Bun.argv.slice(2), options: { "dry-run": { type: "boolean", default: false }, tag: { type: "string", default: "latest" }, }, strict: true, }); const dryRun = values["dry-run"] ?? false; const npmTag = values.tag ?? "latest"; const wrapperPackageName = "cline"; const expectedPlatformPackages = [ "@cline/cli-darwin-arm64", "@cline/cli-darwin-x64", "@cline/cli-linux-arm64", "@cline/cli-linux-x64", "@cline/cli-windows-arm64", "@cline/cli-windows-x64", ] as const; const hostSdkPackages = [ { name: "@cline/sdk", directory: "sdk" }, { name: "@cline/core", directory: "core" }, { name: "@cline/agents", directory: "agents" }, { name: "@cline/llms", directory: "llms" }, { name: "@cline/shared", directory: "shared" }, ] as const; interface PlatformPackageManifest { name: string; version: string; os: string[]; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function isStringArray(value: unknown): value is string[] { return ( Array.isArray(value) && value.every((item) => typeof item === "string") ); } function isPlatformPackageManifest( value: unknown, ): value is PlatformPackageManifest { return ( isRecord(value) && typeof value.name === "string" && typeof value.version === "string" && isStringArray(value.os) ); } function readPackageVersion(name: string, packageJsonPath: string): string { const pkg: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8")); if (!isRecord(pkg) || pkg.name !== name || typeof pkg.version !== "string") { console.error(`Invalid package manifest for ${name}: ${packageJsonPath}`); process.exit(1); } return pkg.version; } function buildHostSdkDependencies(): Record { const dependencies: Record = {}; for (const pkg of hostSdkPackages) { dependencies[pkg.name] = readPackageVersion( pkg.name, join(cliDir, "../../sdk/packages", pkg.directory, "package.json"), ); } return dependencies; } function removePackedTarballs(dir: string): void { for (const entry of readdirSync(dir)) { if (entry.endsWith(".tgz")) { rmSync(join(dir, entry), { force: true }); } } } async function npmPackageVersionExists( name: string, version: string, ): Promise { const result = Bun.spawnSync( ["npm", "view", `${name}@${version}`, "version"], { cwd: cliDir, stdout: "ignore", stderr: "ignore", }, ); return result.exitCode === 0; } async function verifyPublishedDependencies( dependencies: Record, ): Promise { const missingDependencies: string[] = []; for (const [name, version] of Object.entries(dependencies).sort()) { if (!(await npmPackageVersionExists(name, version))) { missingDependencies.push(`${name}@${version}`); } } if (missingDependencies.length === 0) { return; } console.error("Wrapper package dependencies are not published:"); for (const dependency of missingDependencies) { console.error(` ${dependency}`); } console.error("Publish the SDK packages before publishing the CLI wrapper."); process.exit(1); } async function publishPackage(input: { name: string; version: string; dir: string; tag: string; dryRun: boolean; }): Promise { if (process.platform !== "win32") { await $`chmod -R 755 .`.cwd(input.dir); } if (input.dryRun) { console.log(` [dry-run] Would publish ${input.name}@${input.version}`); return; } if (await npmPackageVersionExists(input.name, input.version)) { console.log(` ${input.name}@${input.version} already exists, skipping`); return; } console.log(` Publishing ${input.name}@${input.version}...`); removePackedTarballs(input.dir); await $`bun pm pack`.cwd(input.dir); await $`npm publish *.tgz --access public --tag ${input.tag}`.cwd(input.dir); console.log(` Published ${input.name}@${input.version}`); } // Discover built platform packages from dist/ const binaries: Record = {}; for await (const filepath of new Bun.Glob("*/package.json").scan({ cwd: join(cliDir, "dist"), })) { const pkg: unknown = JSON.parse( readFileSync(join(cliDir, "dist", filepath), "utf-8"), ); if (isPlatformPackageManifest(pkg)) { binaries[pkg.name] = pkg.version; } } if (Object.keys(binaries).length === 0) { console.error("No platform packages found in dist/."); console.error("Run `bun script/build.ts` first."); process.exit(1); } const missingPackages = expectedPlatformPackages.filter( (name) => !(name in binaries), ); if (missingPackages.length > 0) { console.error("Missing platform packages in dist/:"); for (const name of missingPackages) { console.error(` ${name}`); } process.exit(1); } const versions = new Set(Object.values(binaries)); if (versions.size !== 1) { console.error("Platform package versions do not match:"); for (const [name, packageVersion] of Object.entries(binaries).sort()) { console.error(` ${name}@${packageVersion}`); } process.exit(1); } const version = Object.values(binaries)[0]; const sourcePkg: unknown = JSON.parse( readFileSync(join(cliDir, "package.json"), "utf-8"), ); const sourcePkgRecord = isRecord(sourcePkg) ? sourcePkg : {}; const sourceVersion = "version" in sourcePkgRecord && typeof sourcePkgRecord.version === "string" ? sourcePkgRecord.version : undefined; if (sourceVersion !== version) { console.error( `Built package version ${version} does not match apps/cli/package.json version ${sourceVersion ?? "(missing)"}.`, ); process.exit(1); } const sourceRepository = "repository" in sourcePkgRecord ? sourcePkgRecord.repository : undefined; const hostSdkDependencies = buildHostSdkDependencies(); console.log(`Publishing ${wrapperPackageName} v${version}`); console.log(` Tag: ${npmTag}`); console.log(` Dry run: ${dryRun}`); console.log(` Platform packages: ${Object.keys(binaries).length}`); for (const name of Object.keys(binaries)) { console.log(` ${name}`); } if (!dryRun) { await verifyPublishedDependencies(hostSdkDependencies); } // Step 1: Publish platform-specific packages (in parallel) console.log("\nPublishing platform packages..."); const platformTasks = Object.keys(binaries) .sort() .map(async (name) => { const dirName = name.replace("@cline/", ""); const pkgDir = join(cliDir, "dist", dirName); await publishPackage({ name, version, dir: pkgDir, tag: npmTag, dryRun, }); }); await Promise.all(platformTasks); // Step 2: Generate and publish the main wrapper package console.log("\nPreparing main package..."); const mainPkgDir = join(cliDir, "dist", "cli"); await $`rm -rf ${mainPkgDir}`; await $`mkdir -p ${mainPkgDir}`; await $`cp -r ${join(cliDir, "bin")} ${join(mainPkgDir, "bin")}`; await $`cp ${join(cliDir, "script/postinstall.mjs")} ${join(mainPkgDir, "postinstall.mjs")}`; // Copy LICENSE from repo root if it exists const licenseFrom = join(cliDir, "../../LICENSE"); if (existsSync(licenseFrom)) { await $`cp ${licenseFrom} ${join(mainPkgDir, "LICENSE")}`; } // Copy README.md so the npm registry listing has the same landing page // as the repo. The published wrapper package is generated fresh in // dist/cli/ each release, so the source README is not picked up // automatically. const readmeFrom = join(cliDir, "README.md"); if (existsSync(readmeFrom)) { await $`cp ${readmeFrom} ${join(mainPkgDir, "README.md")}`; } else { console.error( `Missing ${readmeFrom}. The CLI README must exist before publishing.`, ); process.exit(1); } const mainPkg: unknown = JSON.parse( readFileSync(join(cliDir, "package.json"), "utf-8"), ); const mainPkgRecord = isRecord(mainPkg) ? mainPkg : {}; const description = "description" in mainPkgRecord && typeof mainPkgRecord.description === "string" ? mainPkgRecord.description : undefined; const license = "license" in mainPkgRecord && typeof mainPkgRecord.license === "string" ? mainPkgRecord.license : undefined; const keywords = "keywords" in mainPkgRecord && isStringArray(mainPkgRecord.keywords) ? mainPkgRecord.keywords : undefined; const author = "author" in mainPkgRecord ? mainPkgRecord.author : undefined; const homepage = "homepage" in mainPkgRecord && typeof mainPkgRecord.homepage === "string" ? mainPkgRecord.homepage : undefined; const bugs = "bugs" in mainPkgRecord ? mainPkgRecord.bugs : undefined; const wrapperPackageJson = { name: wrapperPackageName, version, description: description || "Cline CLI", license: license || "Apache-2.0", ...(keywords ? { keywords } : {}), ...(author ? { author } : {}), ...(homepage ? { homepage } : {}), ...(bugs ? { bugs } : {}), ...(sourceRepository ? { repository: sourceRepository } : {}), bin: { cline: "./bin/cline", }, scripts: { postinstall: "node ./postinstall.mjs || true", }, dependencies: hostSdkDependencies, optionalDependencies: binaries, }; await Bun.write( join(mainPkgDir, "package.json"), `${JSON.stringify(wrapperPackageJson, null, 2)}\n`, ); if (dryRun) { console.log( ` [dry-run] Would publish ${wrapperPackageName}@${version} with tag ${npmTag}`, ); console.log("\nDry run complete. No packages were published."); } else { await publishPackage({ name: wrapperPackageName, version, dir: mainPkgDir, tag: npmTag, dryRun: false, }); console.log( `\nPublished ${wrapperPackageName}@${version} with tag ${npmTag}`, ); console.log("\nInstall with:"); console.log(` npm install -g ${wrapperPackageName}`); }