2026-04-30 13:52:41 +02:00
|
|
|
import { execSync } from "node:child_process"
|
|
|
|
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
|
|
|
import { dirname, join, resolve } from "node:path"
|
|
|
|
|
import process from "node:process"
|
|
|
|
|
import { fileURLToPath } from "node:url"
|
|
|
|
|
|
|
|
|
|
interface RootPackageJson {
|
|
|
|
|
workspaces?: string[]
|
|
|
|
|
}
|
2025-08-19 00:09:09 +02:00
|
|
|
|
|
|
|
|
interface PackageJson {
|
2026-04-30 13:52:41 +02:00
|
|
|
name?: string
|
|
|
|
|
version?: string
|
2025-08-19 00:09:09 +02:00
|
|
|
optionalDependencies?: Record<string, string>
|
2026-04-30 13:52:41 +02:00
|
|
|
[key: string]: unknown
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface WorkspacePackage {
|
|
|
|
|
name: string
|
|
|
|
|
packageJsonPath: string
|
|
|
|
|
packageJson: PackageJson
|
2025-08-19 00:09:09 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
type ReleaseType = "patch" | "minor" | "major"
|
|
|
|
|
|
|
|
|
|
const CORE_PACKAGE_NAME = "@opentui/core"
|
|
|
|
|
const LOCKSTEP_PACKAGE_PREFIX = "@opentui/"
|
|
|
|
|
const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/
|
|
|
|
|
|
2025-08-19 00:09:09 +02:00
|
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
|
|
|
const __dirname = dirname(__filename)
|
|
|
|
|
const rootDir = resolve(__dirname, "..")
|
|
|
|
|
|
|
|
|
|
const args = process.argv.slice(2)
|
2026-04-30 13:52:41 +02:00
|
|
|
const dryRun = args.includes("--dry-run")
|
|
|
|
|
const noInstall = args.includes("--no-install")
|
|
|
|
|
const explicitVersion = args.find((arg) => !arg.startsWith("--"))
|
|
|
|
|
const releaseType = getRequestedReleaseType(args, explicitVersion)
|
|
|
|
|
|
|
|
|
|
const lockstepPackages = getLockstepPackages()
|
|
|
|
|
const corePackage = lockstepPackages.find((pkg) => pkg.name === CORE_PACKAGE_NAME)
|
2025-08-19 00:09:09 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (!corePackage) {
|
|
|
|
|
console.error(`Error: ${CORE_PACKAGE_NAME} was not found in the workspace packages`)
|
2025-08-19 00:09:09 +02:00
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (!corePackage.packageJson.version) {
|
|
|
|
|
console.error(`Error: ${CORE_PACKAGE_NAME} does not have a version field`)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
2025-09-07 22:51:09 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
const currentVersion = corePackage.packageJson.version
|
|
|
|
|
const version = resolveTargetVersion(explicitVersion, releaseType, currentVersion)
|
2025-09-07 22:51:09 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (!VERSION_PATTERN.test(version)) {
|
|
|
|
|
console.error(`Error: Invalid version format: ${version}`)
|
|
|
|
|
console.error("Version should follow semver format (e.g., 1.0.0, 1.0.0-beta.1)")
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
warnOnVersionDrift(lockstepPackages, currentVersion)
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`\nPreparing release ${version}${dryRun ? " (dry run)" : ""} for ${lockstepPackages.length} lock-step packages...\n`,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for (const workspacePackage of lockstepPackages) {
|
|
|
|
|
updateWorkspacePackage(workspacePackage, version, dryRun)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dryRun) {
|
|
|
|
|
console.log("\nDry run: skipped writing package.json files and bun install")
|
|
|
|
|
} else if (noInstall) {
|
|
|
|
|
console.log("\nSkipping bun install (--no-install)")
|
|
|
|
|
} else {
|
|
|
|
|
console.log("\nUpdating bun.lock...")
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
execSync("bun install", { cwd: rootDir, stdio: "inherit" })
|
|
|
|
|
console.log(" bun.lock updated successfully")
|
2025-09-07 22:51:09 +02:00
|
|
|
} catch (error) {
|
2026-04-30 13:52:41 +02:00
|
|
|
console.error(` Failed to update bun.lock: ${error}`)
|
2025-09-07 22:51:09 +02:00
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (dryRun) {
|
|
|
|
|
console.log(`\nDry run complete for release ${version}.`)
|
|
|
|
|
process.exit(0)
|
2025-08-19 00:09:09 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
console.log(`
|
|
|
|
|
Successfully prepared release ${version} for ${lockstepPackages.length} lock-step packages!
|
2025-08-19 00:09:09 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
Packages:
|
|
|
|
|
${lockstepPackages.map((pkg) => `- ${pkg.name}`).join("\n")}
|
2025-08-19 00:09:09 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
Next steps:
|
|
|
|
|
1. Review the changes: git diff
|
|
|
|
|
2. Build the packages: bun run build
|
|
|
|
|
3. Commit the changes: git add -A && git commit -m "Release v${version}"
|
|
|
|
|
4. Push the commit: git push
|
2026-04-30 13:56:29 +02:00
|
|
|
5. Tag the release after the commit: git tag v${version} -m "Release v${version}"
|
2026-04-30 13:52:41 +02:00
|
|
|
6. Push the tag to trigger the release workflow: git push origin v${version}
|
|
|
|
|
`)
|
2025-08-19 19:45:36 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function getRequestedReleaseType(args: string[], explicitVersion?: string): ReleaseType | null {
|
|
|
|
|
const requestedReleaseTypes = ["--patch", "--minor", "--major"].filter((arg) => args.includes(arg))
|
2025-08-19 19:45:36 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (requestedReleaseTypes.length > 1) {
|
|
|
|
|
console.error("Error: Please specify only one of --patch, --minor, or --major")
|
|
|
|
|
process.exit(1)
|
2025-08-19 00:09:09 +02:00
|
|
|
}
|
2025-08-19 19:45:36 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (explicitVersion && requestedReleaseTypes.length > 0 && explicitVersion !== "*") {
|
|
|
|
|
console.error("Error: Provide either an explicit version or a release type flag, not both")
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
2025-08-19 00:09:09 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (explicitVersion === "*" || requestedReleaseTypes[0] === "--patch") {
|
|
|
|
|
return "patch"
|
|
|
|
|
}
|
2026-04-30 11:01:13 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (requestedReleaseTypes[0] === "--minor") {
|
|
|
|
|
return "minor"
|
|
|
|
|
}
|
2026-04-30 11:01:13 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (requestedReleaseTypes[0] === "--major") {
|
|
|
|
|
return "major"
|
|
|
|
|
}
|
2026-04-30 11:01:13 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
return null
|
|
|
|
|
}
|
2026-04-30 11:01:13 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function resolveTargetVersion(
|
|
|
|
|
explicitVersion: string | undefined,
|
|
|
|
|
releaseType: ReleaseType | null,
|
|
|
|
|
currentVersion: string,
|
|
|
|
|
): string {
|
|
|
|
|
if (explicitVersion && explicitVersion !== "*") {
|
|
|
|
|
return explicitVersion
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const effectiveReleaseType = releaseType ?? "patch"
|
|
|
|
|
const nextVersion = incrementVersion(currentVersion, effectiveReleaseType)
|
|
|
|
|
console.log(`Auto-incrementing ${effectiveReleaseType} version from ${currentVersion} to ${nextVersion}`)
|
|
|
|
|
return nextVersion
|
2026-04-30 11:01:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function incrementVersion(version: string, releaseType: ReleaseType): string {
|
|
|
|
|
if (version.includes("-")) {
|
|
|
|
|
console.error(`Error: Auto-increment is only supported for stable versions. Current version: ${version}`)
|
|
|
|
|
console.error("Please provide the target prerelease version explicitly")
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/)
|
|
|
|
|
if (!match) {
|
|
|
|
|
console.error(`Error: Invalid current version format: ${version}`)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
2026-04-30 12:51:39 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
const major = Number.parseInt(match[1], 10)
|
|
|
|
|
const minor = Number.parseInt(match[2], 10)
|
|
|
|
|
const patch = Number.parseInt(match[3], 10)
|
2026-04-30 12:51:39 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (releaseType === "major") {
|
|
|
|
|
return `${major + 1}.0.0`
|
|
|
|
|
}
|
2026-04-30 12:51:39 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (releaseType === "minor") {
|
|
|
|
|
return `${major}.${minor + 1}.0`
|
|
|
|
|
}
|
2026-04-30 12:51:39 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
return `${major}.${minor}.${patch + 1}`
|
|
|
|
|
}
|
2025-08-19 00:57:18 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function getLockstepPackages(): WorkspacePackage[] {
|
|
|
|
|
const rootPackageJson = readJson<RootPackageJson>(join(rootDir, "package.json"))
|
|
|
|
|
const workspacePatterns = rootPackageJson.workspaces ?? []
|
|
|
|
|
|
|
|
|
|
const workspaceDirs = [...new Set(workspacePatterns.flatMap(expandWorkspacePattern))]
|
|
|
|
|
const packages = workspaceDirs
|
|
|
|
|
.map((workspaceDir) => join(workspaceDir, "package.json"))
|
|
|
|
|
.filter((packageJsonPath) => existsSync(packageJsonPath))
|
|
|
|
|
.map((packageJsonPath) => ({
|
|
|
|
|
packageJsonPath,
|
|
|
|
|
packageJson: readJson<PackageJson>(packageJsonPath),
|
|
|
|
|
}))
|
|
|
|
|
.filter(
|
|
|
|
|
(entry): entry is { packageJsonPath: string; packageJson: PackageJson & { name: string; version: string } } =>
|
|
|
|
|
typeof entry.packageJson.name === "string" &&
|
|
|
|
|
entry.packageJson.name.startsWith(LOCKSTEP_PACKAGE_PREFIX) &&
|
|
|
|
|
typeof entry.packageJson.version === "string",
|
|
|
|
|
)
|
|
|
|
|
.map((entry) => ({
|
|
|
|
|
name: entry.packageJson.name,
|
|
|
|
|
packageJsonPath: entry.packageJsonPath,
|
|
|
|
|
packageJson: entry.packageJson,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
return packages.sort((left, right) => {
|
|
|
|
|
if (left.name === CORE_PACKAGE_NAME) {
|
|
|
|
|
return -1
|
|
|
|
|
}
|
2025-08-19 19:45:36 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (right.name === CORE_PACKAGE_NAME) {
|
|
|
|
|
return 1
|
|
|
|
|
}
|
2025-08-19 19:45:36 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
return left.name.localeCompare(right.name)
|
|
|
|
|
})
|
2025-08-19 00:57:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function expandWorkspacePattern(pattern: string): string[] {
|
|
|
|
|
if (!pattern.includes("*")) {
|
|
|
|
|
return [join(rootDir, pattern)]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pattern.endsWith("/*") && pattern.indexOf("*") === pattern.length - 1) {
|
|
|
|
|
const baseDir = join(rootDir, pattern.slice(0, -2))
|
2025-08-19 00:09:09 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (!existsSync(baseDir)) {
|
|
|
|
|
return []
|
|
|
|
|
}
|
2025-08-19 19:45:36 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
return readdirSync(baseDir, { withFileTypes: true })
|
|
|
|
|
.filter((entry) => entry.isDirectory())
|
|
|
|
|
.map((entry) => join(baseDir, entry.name))
|
|
|
|
|
}
|
2025-08-19 19:45:36 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
console.error(`Error: Unsupported workspace pattern: ${pattern}`)
|
2025-08-19 00:09:09 +02:00
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function readJson<T>(filePath: string): T {
|
|
|
|
|
return JSON.parse(readFileSync(filePath, "utf8")) as T
|
|
|
|
|
}
|
2026-04-28 02:10:37 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function writeJson(filePath: string, value: unknown): void {
|
|
|
|
|
writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n")
|
|
|
|
|
}
|
2026-04-28 02:10:37 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function warnOnVersionDrift(lockstepPackages: WorkspacePackage[], sourceVersion: string): void {
|
|
|
|
|
const driftedPackages = lockstepPackages.filter((pkg) => pkg.packageJson.version !== sourceVersion)
|
2026-04-28 02:10:37 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
if (driftedPackages.length === 0) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.warn("Detected version drift across lock-step packages. Using @opentui/core as the source of truth:")
|
|
|
|
|
console.warn(` ${CORE_PACKAGE_NAME}: ${sourceVersion}`)
|
|
|
|
|
|
|
|
|
|
for (const workspacePackage of driftedPackages) {
|
|
|
|
|
console.warn(` ${workspacePackage.name}: ${workspacePackage.packageJson.version}`)
|
|
|
|
|
}
|
2026-04-28 02:10:37 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function updateWorkspacePackage(workspacePackage: WorkspacePackage, version: string, dryRun: boolean): void {
|
|
|
|
|
const previousVersion = workspacePackage.packageJson.version
|
|
|
|
|
|
|
|
|
|
if (previousVersion === version) {
|
|
|
|
|
console.log(`No change for ${workspacePackage.name} (already ${version})`)
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`Updating ${workspacePackage.name} from ${previousVersion} to ${version}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
workspacePackage.packageJson.version = version
|
|
|
|
|
|
|
|
|
|
if (workspacePackage.name === CORE_PACKAGE_NAME) {
|
|
|
|
|
const updatedOptionalDependencies = updateCoreOptionalDependencies(workspacePackage.packageJson, version)
|
|
|
|
|
|
|
|
|
|
for (const dependencyName of updatedOptionalDependencies) {
|
|
|
|
|
console.log(` Updated ${dependencyName} to ${version}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!dryRun) {
|
|
|
|
|
writeJson(workspacePackage.packageJsonPath, workspacePackage.packageJson)
|
|
|
|
|
}
|
2025-08-19 16:30:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
function updateCoreOptionalDependencies(packageJson: PackageJson, version: string): string[] {
|
|
|
|
|
if (!packageJson.optionalDependencies) {
|
|
|
|
|
return []
|
|
|
|
|
}
|
2025-08-19 00:09:09 +02:00
|
|
|
|
2026-04-30 13:52:41 +02:00
|
|
|
const updatedDependencyNames: string[] = []
|
|
|
|
|
|
|
|
|
|
for (const dependencyName of Object.keys(packageJson.optionalDependencies)) {
|
|
|
|
|
if (!dependencyName.startsWith("@opentui/core-")) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packageJson.optionalDependencies[dependencyName] = version
|
|
|
|
|
updatedDependencyNames.push(dependencyName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return updatedDependencyNames
|
|
|
|
|
}
|