|
1 | 1 | #!/usr/bin/env tsx |
2 | 2 | /** |
3 | 3 | * Version Sync Script |
4 | | - * Auto-sync all sub-package versions before commit |
| 4 | + * Auto-sync all publishable package versions before commit. |
5 | 5 | */ |
6 | | -import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'node:fs' |
7 | | -import { execSync } from 'node:child_process' |
8 | | -import { resolve, join } from 'node:path' |
| 6 | +import {readdirSync, readFileSync, writeFileSync} from 'node:fs' |
| 7 | +import {join, relative, resolve} from 'node:path' |
9 | 8 | import process from 'node:process' |
10 | 9 |
|
11 | | -interface PackageEntry { |
12 | | - readonly path: string |
13 | | - readonly name: string |
| 10 | +interface VersionedJson { |
| 11 | + version?: string |
| 12 | + [key: string]: unknown |
14 | 13 | } |
15 | 14 |
|
16 | | -interface PackageJson { |
17 | | - version?: string |
| 15 | +const ROOT_DIR = resolve('.') |
| 16 | +const ROOT_PACKAGE_PATH = resolve(ROOT_DIR, 'package.json') |
| 17 | +const ROOT_CARGO_PATH = resolve(ROOT_DIR, 'Cargo.toml') |
| 18 | +const IGNORED_DIRECTORIES = new Set([ |
| 19 | + '.git', |
| 20 | + '.next', |
| 21 | + '.turbo', |
| 22 | + 'coverage', |
| 23 | + 'dist', |
| 24 | + 'node_modules', |
| 25 | + 'target', |
| 26 | +]) |
| 27 | + |
| 28 | +function readJsonFile(filePath: string): VersionedJson { |
| 29 | + return JSON.parse(readFileSync(filePath, 'utf-8').replace(/^\uFEFF/, '')) as VersionedJson |
18 | 30 | } |
19 | 31 |
|
20 | | -function getCatalogVersion(pkgName: string): string | null { |
21 | | - try { |
22 | | - const yamlContent = readFileSync(resolve('pnpm-workspace.yaml'), 'utf-8') |
23 | | - const match = yamlContent.match(new RegExp(`${pkgName.replace('@', '\\@')}:\\s*\\^?([^\\s]+)`)) |
24 | | - return match ? match[1] : null |
25 | | - } catch { |
26 | | - return null |
27 | | - } |
| 32 | +function writeJsonFile(filePath: string, value: VersionedJson): void { |
| 33 | + writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf-8') |
28 | 34 | } |
29 | 35 |
|
30 | | -const eslintConfigVersion = getCatalogVersion('@truenine/eslint10-config') |
31 | | -const rootPackagePath = resolve('package.json') |
32 | | -const requestedVersion = process.argv[2]?.trim() |
33 | | -const rootPkg: PackageJson = JSON.parse(readFileSync(rootPackagePath, 'utf-8')) |
| 36 | +function discoverFilesByName(baseDir: string, fileName: string): string[] { |
| 37 | + const found: string[] = [] |
| 38 | + const entries = readdirSync(baseDir, {withFileTypes: true}) |
34 | 39 |
|
35 | | -if (requestedVersion && rootPkg.version !== requestedVersion) { |
36 | | - rootPkg.version = requestedVersion |
37 | | - writeFileSync(rootPackagePath, JSON.stringify(rootPkg, null, 2) + '\n', 'utf-8') |
38 | | -} |
| 40 | + for (const entry of entries) { |
| 41 | + const entryPath = join(baseDir, entry.name) |
39 | 42 |
|
40 | | -const rootVersion = rootPkg.version |
| 43 | + if (entry.isDirectory()) { |
| 44 | + if (entry.name.startsWith('.')) { |
| 45 | + continue |
| 46 | + } |
41 | 47 |
|
42 | | -if (!rootVersion) { |
43 | | - console.error('❌ Root package.json missing version field') |
44 | | - process.exit(1) |
45 | | -} |
| 48 | + if (IGNORED_DIRECTORIES.has(entry.name)) { |
| 49 | + continue |
| 50 | + } |
46 | 51 |
|
47 | | -console.log(`🔄 Syncing version: ${rootVersion}`) |
48 | | -if (eslintConfigVersion) { |
49 | | - console.log(`🔄 Catalog @truenine/eslint10-config: ^${eslintConfigVersion}`) |
50 | | -} |
| 52 | + found.push(...discoverFilesByName(entryPath, fileName)) |
| 53 | + continue |
| 54 | + } |
51 | 55 |
|
52 | | -const topLevelWorkspacePackages: readonly PackageEntry[] = [ |
53 | | - { path: 'cli/package.json', name: 'cli' }, |
54 | | - { path: 'mcp/package.json', name: 'mcp' }, |
55 | | - { path: 'gui/package.json', name: 'gui' }, |
56 | | - { path: 'doc/package.json', name: 'doc' }, |
57 | | -] |
58 | | - |
59 | | -// Discover all libraries and their npm sub-packages |
60 | | -function discoverLibraryPackages(): PackageEntry[] { |
61 | | - const entries: PackageEntry[] = [] |
62 | | - const librariesDir = resolve('libraries') |
63 | | - if (!existsSync(librariesDir)) return entries |
64 | | - for (const lib of readdirSync(librariesDir)) { |
65 | | - const libDir = join(librariesDir, lib) |
66 | | - if (!statSync(libDir).isDirectory()) continue |
67 | | - const libPkg = join(libDir, 'package.json') |
68 | | - if (existsSync(libPkg)) { |
69 | | - entries.push({ path: `libraries/${lib}/package.json`, name: `lib:${lib}` }) |
| 56 | + if (entry.isFile() && entry.name === fileName) { |
| 57 | + found.push(entryPath) |
70 | 58 | } |
71 | 59 | } |
72 | | - return entries |
| 60 | + |
| 61 | + return found |
73 | 62 | } |
74 | 63 |
|
75 | | -// Discover npm platform sub-packages under a given directory (e.g. cli/npm/) |
76 | | -function discoverNpmSubPackages(baseDir: string, prefix: string): PackageEntry[] { |
77 | | - const entries: PackageEntry[] = [] |
78 | | - const npmDir = resolve(baseDir, 'npm') |
79 | | - if (!existsSync(npmDir) || !statSync(npmDir).isDirectory()) return entries |
80 | | - for (const platform of readdirSync(npmDir)) { |
81 | | - const platformDir = join(npmDir, platform) |
82 | | - if (!statSync(platformDir).isDirectory()) continue |
83 | | - const platformPkg = join(platformDir, 'package.json') |
84 | | - if (existsSync(platformPkg)) { |
85 | | - entries.push({ path: `${baseDir}/npm/${platform}/package.json`, name: `${prefix}/${platform}` }) |
| 64 | +function updateVersionLineInSection( |
| 65 | + content: string, |
| 66 | + sectionName: string, |
| 67 | + targetVersion: string, |
| 68 | +): string { |
| 69 | + const lines = content.split(/\r?\n/) |
| 70 | + let inTargetSection = false |
| 71 | + |
| 72 | + for (let index = 0; index < lines.length; index += 1) { |
| 73 | + const line = lines[index] |
| 74 | + const trimmed = line.trim() |
| 75 | + |
| 76 | + if (/^\[.*\]$/.test(trimmed)) { |
| 77 | + inTargetSection = trimmed === `[${sectionName}]` |
| 78 | + continue |
| 79 | + } |
| 80 | + |
| 81 | + if (!inTargetSection) { |
| 82 | + continue |
| 83 | + } |
| 84 | + |
| 85 | + if (/^version\.workspace\s*=/.test(trimmed)) { |
| 86 | + return content |
86 | 87 | } |
87 | | - } |
88 | | - return entries |
89 | | -} |
90 | 88 |
|
91 | | -// Discover all packages under packages/ |
92 | | -function discoverPackagesDir(): PackageEntry[] { |
93 | | - const entries: PackageEntry[] = [] |
94 | | - const packagesDir = resolve('packages') |
95 | | - if (!existsSync(packagesDir)) return entries |
96 | | - for (const pkg of readdirSync(packagesDir)) { |
97 | | - const pkgDir = join(packagesDir, pkg) |
98 | | - if (!statSync(pkgDir).isDirectory()) continue |
99 | | - const pkgFile = join(pkgDir, 'package.json') |
100 | | - if (existsSync(pkgFile)) { |
101 | | - entries.push({ path: `packages/${pkg}/package.json`, name: `pkg:${pkg}` }) |
| 89 | + const match = line.match(/^(\s*version\s*=\s*")([^"]+)(".*)$/) |
| 90 | + if (match == null) { |
| 91 | + continue |
102 | 92 | } |
| 93 | + |
| 94 | + if (match[2] === targetVersion) { |
| 95 | + return content |
| 96 | + } |
| 97 | + |
| 98 | + lines[index] = `${match[1]}${targetVersion}${match[3]}` |
| 99 | + return lines.join('\n') |
103 | 100 | } |
104 | | - return entries |
| 101 | + |
| 102 | + return content |
105 | 103 | } |
106 | 104 |
|
107 | | -const libraryPackages = discoverLibraryPackages() |
108 | | -const packagesPackages = discoverPackagesDir() |
109 | | -const cliNpmPackages = discoverNpmSubPackages('cli', 'cli-napi') |
| 105 | +function syncJsonVersion( |
| 106 | + filePath: string, |
| 107 | + rootVersion: string, |
| 108 | + changedPaths: Set<string>, |
| 109 | +): void { |
| 110 | + try { |
| 111 | + const json = readJsonFile(filePath) |
| 112 | + if (json.version === rootVersion) { |
| 113 | + return |
| 114 | + } |
110 | 115 |
|
111 | | -let changed = false |
| 116 | + console.log(` ✓ ${relative(ROOT_DIR, filePath)}: version ${String(json.version ?? '(none)')} -> ${rootVersion}`) |
| 117 | + json.version = rootVersion |
| 118 | + writeJsonFile(filePath, json) |
| 119 | + changedPaths.add(filePath) |
| 120 | + } catch { |
| 121 | + console.log(`⚠️ ${relative(ROOT_DIR, filePath)} not found or invalid, skipping`) |
| 122 | + } |
| 123 | +} |
112 | 124 |
|
113 | | -for (const pkg of [...topLevelWorkspacePackages, ...libraryPackages, ...packagesPackages, ...cliNpmPackages]) { |
114 | | - const fullPath = resolve(pkg.path) |
| 125 | +function syncCargoVersion( |
| 126 | + filePath: string, |
| 127 | + sectionName: string, |
| 128 | + rootVersion: string, |
| 129 | + changedPaths: Set<string>, |
| 130 | +): void { |
115 | 131 | try { |
116 | | - const content = readFileSync(fullPath, 'utf-8').replace(/^\uFEFF/, '') |
117 | | - const pkgJson: PackageJson = JSON.parse(content) |
118 | | - |
119 | | - if (pkgJson.version !== rootVersion) { |
120 | | - console.log(` ✓ ${pkg.name}: version ${pkgJson.version} → ${rootVersion}`) |
121 | | - pkgJson.version = rootVersion |
122 | | - writeFileSync(fullPath, JSON.stringify(pkgJson, null, 2) + '\n', 'utf-8') |
123 | | - changed = true |
| 132 | + const originalContent = readFileSync(filePath, 'utf-8') |
| 133 | + const updatedContent = updateVersionLineInSection(originalContent, sectionName, rootVersion) |
| 134 | + |
| 135 | + if (updatedContent === originalContent) { |
| 136 | + return |
124 | 137 | } |
| 138 | + |
| 139 | + writeFileSync(filePath, updatedContent, 'utf-8') |
| 140 | + console.log(` ✓ ${relative(ROOT_DIR, filePath)}: version -> ${rootVersion}`) |
| 141 | + changedPaths.add(filePath) |
125 | 142 | } catch { |
126 | | - console.log(`⚠️ ${pkg.path} not found or invalid, skipping`) |
| 143 | + console.log(`⚠️ ${relative(ROOT_DIR, filePath)} not found or invalid, skipping`) |
127 | 144 | } |
128 | 145 | } |
129 | 146 |
|
130 | | -// Sync root workspace Cargo.toml version |
131 | | -const workspaceCargoTomlPath = resolve('Cargo.toml') |
132 | | -try { |
133 | | - const cargoContent = readFileSync(workspaceCargoTomlPath, 'utf-8') |
134 | | - const cargoUpdated = cargoContent.replace( |
135 | | - /(\[workspace\.package\][\s\S]*?^version = ")([^"]+)(")/m, |
136 | | - `$1${rootVersion}$3`, |
137 | | - ) |
138 | | - if (cargoContent !== cargoUpdated) { |
139 | | - writeFileSync(workspaceCargoTomlPath, cargoUpdated, 'utf-8') |
140 | | - console.log(` ✓ workspace Cargo.toml: version → ${rootVersion}`) |
141 | | - changed = true |
142 | | - } |
143 | | -} catch { |
144 | | - console.log('⚠️ Cargo.toml not found, skipping') |
| 147 | +const requestedVersion = process.argv[2]?.trim() |
| 148 | +const rootPkg = readJsonFile(ROOT_PACKAGE_PATH) |
| 149 | +const changedPaths = new Set<string>() |
| 150 | + |
| 151 | +if (requestedVersion && rootPkg.version !== requestedVersion) { |
| 152 | + rootPkg.version = requestedVersion |
| 153 | + writeJsonFile(ROOT_PACKAGE_PATH, rootPkg) |
| 154 | + changedPaths.add(ROOT_PACKAGE_PATH) |
145 | 155 | } |
146 | 156 |
|
147 | | -// Sync GUI Cargo.toml version |
148 | | -const cargoTomlPath = resolve('gui/src-tauri/Cargo.toml') |
149 | | -try { |
150 | | - const cargoContent = readFileSync(cargoTomlPath, 'utf-8') |
151 | | - const cargoUpdated = cargoContent.replace(/^version = ".*"/m, `version = "${rootVersion}"`) |
152 | | - if (cargoContent !== cargoUpdated) { |
153 | | - writeFileSync(cargoTomlPath, cargoUpdated, 'utf-8') |
154 | | - console.log(` ✓ Cargo.toml: version → ${rootVersion}`) |
155 | | - changed = true |
156 | | - } |
157 | | -} catch { |
158 | | - console.log('⚠️ gui/src-tauri/Cargo.toml not found, skipping') |
| 157 | +const rootVersion = rootPkg.version |
| 158 | + |
| 159 | +if (rootVersion == null || rootVersion === '') { |
| 160 | + console.error('Root package.json missing version field') |
| 161 | + process.exit(1) |
159 | 162 | } |
160 | 163 |
|
161 | | -// Sync tauri.conf.json version |
162 | | -const tauriConfPath = resolve('gui/src-tauri/tauri.conf.json') |
163 | | -try { |
164 | | - const tauriConfContent = readFileSync(tauriConfPath, 'utf-8') |
165 | | - const tauriConf = JSON.parse(tauriConfContent) |
166 | | - if (tauriConf.version !== rootVersion) { |
167 | | - console.log(` ✓ tauri.conf.json: version ${tauriConf.version ?? '(none)'} → ${rootVersion}`) |
168 | | - tauriConf.version = rootVersion |
169 | | - writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n', 'utf-8') |
170 | | - changed = true |
171 | | - } |
172 | | -} catch { |
173 | | - console.log('⚠️ gui/src-tauri/tauri.conf.json not found, skipping') |
| 164 | +console.log(`🔄 Syncing version: ${rootVersion}`) |
| 165 | + |
| 166 | +const packageJsonPaths = discoverFilesByName(ROOT_DIR, 'package.json') |
| 167 | + .filter(filePath => resolve(filePath) !== ROOT_PACKAGE_PATH) |
| 168 | + .sort() |
| 169 | + |
| 170 | +for (const filePath of packageJsonPaths) { |
| 171 | + syncJsonVersion(filePath, rootVersion, changedPaths) |
174 | 172 | } |
175 | 173 |
|
176 | | -// Sync version field in tnmsc.example.json files |
177 | | -const exampleConfigPaths = [ |
178 | | - 'libraries/init-bundle/public/public/tnmsc.example.json', |
179 | | -] |
| 174 | +syncCargoVersion(ROOT_CARGO_PATH, 'workspace.package', rootVersion, changedPaths) |
180 | 175 |
|
181 | | -for (const examplePath of exampleConfigPaths) { |
182 | | - const fullPath = resolve(examplePath) |
183 | | - try { |
184 | | - const content = readFileSync(fullPath, 'utf-8') |
185 | | - const exampleJson = JSON.parse(content) as Record<string, unknown> |
186 | | - if (exampleJson['version'] !== rootVersion) { |
187 | | - console.log(` ✓ ${examplePath}: version ${String(exampleJson['version'] ?? '(none)')} → ${rootVersion}`) |
188 | | - exampleJson['version'] = rootVersion |
189 | | - writeFileSync(fullPath, JSON.stringify(exampleJson, null, 2) + '\n', 'utf-8') |
190 | | - changed = true |
191 | | - } |
192 | | - } catch { |
193 | | - console.log(`⚠️ ${examplePath} not found or invalid, skipping`) |
194 | | - } |
| 176 | +const cargoTomlPaths = discoverFilesByName(ROOT_DIR, 'Cargo.toml') |
| 177 | + .filter(filePath => resolve(filePath) !== ROOT_CARGO_PATH) |
| 178 | + .sort() |
| 179 | + |
| 180 | +for (const filePath of cargoTomlPaths) { |
| 181 | + syncCargoVersion(filePath, 'package', rootVersion, changedPaths) |
195 | 182 | } |
196 | 183 |
|
197 | | -if (changed) { |
198 | | - console.log('\n📦 Versions synced, auto-staging changes...') |
199 | | - try { |
200 | | - const filesToStage = [ |
201 | | - 'package.json', |
202 | | - 'Cargo.toml', |
203 | | - 'gui/src-tauri/Cargo.toml', |
204 | | - 'gui/src-tauri/tauri.conf.json', |
205 | | - 'libraries/init-bundle/public/public/tnmsc.example.json', |
206 | | - ...topLevelWorkspacePackages.map(p => p.path), |
207 | | - ...libraryPackages.map(p => p.path), |
208 | | - ...packagesPackages.map(p => p.path), |
209 | | - ...cliNpmPackages.map(p => p.path), |
210 | | - ].filter(path => existsSync(resolve(path))) |
211 | | - execSync( |
212 | | - `git add ${filesToStage.join(' ')}`, |
213 | | - { stdio: 'inherit' } |
214 | | - ) |
215 | | - console.log('✅ Staged modified files') |
216 | | - } catch { |
217 | | - console.log('⚠️ git add failed, please execute manually') |
218 | | - } |
219 | | -} else { |
| 184 | +for (const filePath of discoverFilesByName(ROOT_DIR, 'tauri.conf.json').sort()) { |
| 185 | + syncJsonVersion(filePath, rootVersion, changedPaths) |
| 186 | +} |
| 187 | + |
| 188 | +if (changedPaths.size === 0) { |
220 | 189 | console.log('\n✅ All versions consistent, no update needed') |
| 190 | + process.exit(0) |
| 191 | +} |
| 192 | + |
| 193 | +const changedRelativePaths = [...changedPaths] |
| 194 | + .map(filePath => relative(ROOT_DIR, filePath)) |
| 195 | + .sort() |
| 196 | + |
| 197 | +console.error('\n❌ Versions were out of sync. Updated files:') |
| 198 | +for (const relativePath of changedRelativePaths) { |
| 199 | + console.error(` - ${relativePath}`) |
221 | 200 | } |
| 201 | +console.error('\nReview these changes and rerun the commit.') |
| 202 | +process.exit(1) |
0 commit comments