357 lines
10 KiB
TypeScript
357 lines
10 KiB
TypeScript
#!/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<string, unknown> {
|
|
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<string, string> {
|
|
const dependencies: Record<string, string> = {};
|
|
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<boolean> {
|
|
const result = Bun.spawnSync(
|
|
["npm", "view", `${name}@${version}`, "version"],
|
|
{
|
|
cwd: cliDir,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
},
|
|
);
|
|
return result.exitCode === 0;
|
|
}
|
|
|
|
async function verifyPublishedDependencies(
|
|
dependencies: Record<string, string>,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<string, string> = {};
|
|
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}`);
|
|
}
|