/** * CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json. * * Usage: * pnpm release-version * * can be: * - A full semver version (e.g., 1.2.3, v1.2.3, 1.2.3-beta, v1.2.3+build) * - A tag: "alpha", "beta", "rc", "autobuild", "autobuild-latest", or "deploytest" * - "alpha", "beta", "rc": Appends the tag to the current base version (e.g., 1.2.3-beta) * - "autobuild": Appends a timestamped autobuild tag (e.g., 1.2.3+autobuild.2406101530) * - "autobuild-latest": Appends an autobuild tag with latest Tauri commit (e.g., 1.2.3+autobuild.0614.a1b2c3d) * - "deploytest": Appends a timestamped deploytest tag (e.g., 1.2.3+deploytest.2406101530) * * Examples: * pnpm release-version 1.2.3 * pnpm release-version v1.2.3-beta * pnpm release-version beta * pnpm release-version autobuild * pnpm release-version autobuild-latest * pnpm release-version deploytest * */ import { execSync } from 'child_process' import fs from 'fs/promises' import path from 'path' import { program } from 'commander' function getGitShortCommit() { try { return execSync('git rev-parse --short HEAD').toString().trim() } catch { console.warn("[WARN]: Failed to get git short commit, fallback to 'nogit'") return 'nogit' } } function getLatestTauriCommit() { try { const fullHash = execSync( 'bash ./scripts-workflow/get_latest_tauri_commit.bash', ) .toString() .trim() const shortHash = execSync(`git rev-parse --short ${fullHash}`) .toString() .trim() console.log(`[INFO]: Latest Tauri-related commit: ${shortHash}`) return shortHash } catch (error) { console.warn( '[WARN]: Failed to get latest Tauri commit, fallback to current git short commit', ) console.warn(`[WARN]: Error details: ${error.message}`) return getGitShortCommit() } } /** Generates `MMDD`, optionally followed by a commit, in the Asia/Shanghai timezone. */ function generateShortTimestamp(withCommit = false, useTauriCommit = false) { const now = new Date() const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', }) const parts = formatter.formatToParts(now) const month = parts.find((part) => part.type === 'month').value const day = parts.find((part) => part.type === 'day').value if (withCommit) { const gitShort = useTauriCommit ? getLatestTauriCommit() : getGitShortCommit() return `${month}${day}.${gitShort}` } return `${month}${day}` } function isValidVersion(version) { return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test( version, ) } function normalizeVersion(version) { return version.startsWith('v') ? version : `v${version}` } function getBaseVersion(version) { let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, '') base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, '') return base } async function updatePackageVersion(newVersion) { const _dirname = process.cwd() const packageJsonPath = path.join(_dirname, 'package.json') try { const data = await fs.readFile(packageJsonPath, 'utf8') const packageJson = JSON.parse(data) console.log( '[INFO]: Current package.json version is: ', packageJson.version, ) const versionWithoutV = newVersion.startsWith('v') ? newVersion.slice(1) : newVersion const updatedData = data.replace( /^(\s*"version"\s*:\s*)"[^"]+"/m, `$1"${versionWithoutV}"`, ) if (updatedData === data) { throw new Error('version field was not found in package.json') } await fs.writeFile(packageJsonPath, updatedData, 'utf8') console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`) } catch (error) { console.error('Error updating package.json version:', error) throw error } } async function updateCargoVersion(newVersion) { const _dirname = process.cwd() const cargoTomlPath = path.join(_dirname, 'src-tauri', 'Cargo.toml') try { const data = await fs.readFile(cargoTomlPath, 'utf8') const lines = data.split('\n') const versionWithoutV = newVersion.startsWith('v') ? newVersion.slice(1) : newVersion const updatedLines = lines.map((line) => { if (line.trim().startsWith('version =')) { return line.replace( /version\s*=\s*"[^"]+"/, `version = "${versionWithoutV}"`, ) } return line }) await fs.writeFile(cargoTomlPath, updatedLines.join('\n'), 'utf8') console.log(`[INFO]: Cargo.toml version updated to: ${versionWithoutV}`) } catch (error) { console.error('Error updating Cargo.toml version:', error) throw error } } async function updateCargoLockVersion(newVersion) { const _dirname = process.cwd() const cargoLockPath = path.join(_dirname, 'Cargo.lock') const versionWithoutV = newVersion.startsWith('v') ? newVersion.slice(1) : newVersion const packageVersionPattern = /(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = )"[^"]+"/ try { const data = await fs.readFile(cargoLockPath, 'utf8') const updatedData = data.replace( packageVersionPattern, `$1"${versionWithoutV}"`, ) if (updatedData === data) { throw new Error('clash-verge package entry was not found in Cargo.lock') } await fs.writeFile(cargoLockPath, updatedData, 'utf8') console.log(`[INFO]: Cargo.lock version updated to: ${versionWithoutV}`) } catch (error) { console.error('Error updating Cargo.lock version:', error) throw error } } async function updateTauriConfigVersion(newVersion) { const _dirname = process.cwd() const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json') try { const data = await fs.readFile(tauriConfigPath, 'utf8') const tauriConfig = JSON.parse(data) const versionWithoutV = newVersion.startsWith('v') ? newVersion.slice(1) : newVersion console.log( '[INFO]: Current tauri.conf.json version is: ', tauriConfig.version, ) const updatedData = data.replace( /^(\s*"version"\s*:\s*)"[^"]+"/m, `$1"${versionWithoutV}"`, ) if (updatedData === data) { throw new Error('version field was not found in tauri.conf.json') } await fs.writeFile(tauriConfigPath, updatedData, 'utf8') console.log( `[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`, ) } catch (error) { console.error('Error updating tauri.conf.json version:', error) throw error } } async function getCurrentVersion() { const _dirname = process.cwd() const packageJsonPath = path.join(_dirname, 'package.json') try { const data = await fs.readFile(packageJsonPath, 'utf8') const packageJson = JSON.parse(data) return packageJson.version } catch (error) { console.error('Error getting current version:', error) throw error } } async function main(versionArg) { if (!versionArg) { console.error('Error: Version argument is required') process.exit(1) } try { let newVersion const validTags = [ 'alpha', 'beta', 'rc', 'autobuild', 'autobuild-latest', 'deploytest', ] if (validTags.includes(versionArg.toLowerCase())) { const currentVersion = await getCurrentVersion() const baseVersion = getBaseVersion(currentVersion) if (versionArg.toLowerCase() === 'autobuild') { newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}` } else if (versionArg.toLowerCase() === 'autobuild-latest') { const latestTauriCommit = getLatestTauriCommit() newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}` } else if (versionArg.toLowerCase() === 'deploytest') { newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}` } else { newVersion = `${baseVersion}-${versionArg.toLowerCase()}` } } else { if (!isValidVersion(versionArg)) { console.error('Error: Invalid version format') process.exit(1) } newVersion = normalizeVersion(versionArg) } console.log(`[INFO]: Updating versions to: ${newVersion}`) await updatePackageVersion(newVersion) await updateCargoVersion(newVersion) await updateCargoLockVersion(newVersion) await updateTauriConfigVersion(newVersion) console.log('[SUCCESS]: All version updates completed successfully!') } catch (error) { console.error('[ERROR]: Failed to update versions:', error) process.exit(1) } } program .name('pnpm release-version') .description('Update project version numbers') .argument('', 'version tag or full version') .action(main) .parse(process.argv)