| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { arch } from 'node:os' |
| 4 | import { Command } from 'commander' |
| 5 | import { $, chalk, fs, path } from 'zx' |
| 6 | |
| 7 | function getDefaultPlatform() { |
| 8 | const a = arch() |
| 9 | const dockerArch = a === 'x64' ? 'amd64' : a |
| 10 | return [`linux/${dockerArch}`] |
| 11 | } |
| 12 | |
| 13 | async function cleanOutputDir(contextDir, verbose = false) { |
| 14 | if (await fs.pathExists(contextDir)) { |
| 15 | if (verbose) |
| 16 | console.info(chalk.yellow(`清理输出目录: ${contextDir}`)) |
| 17 | await fs.remove(contextDir) |
| 18 | if (verbose) |
| 19 | console.info(chalk.green('输出目录清理完成')) |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | async function prepareContext(projectName, options = {}) { |
| 24 | const { output = 'tmp/docker-context', verbose = false, contextOnly = false } = options |
| 25 | const contextDir = path.resolve(output) |
| 26 | |
| 27 | console.info(chalk.blue(`准备 Docker 构建上下文: ${projectName}`)) |
| 28 | if (verbose) { |
| 29 | console.info(chalk.gray(`输出目录: ${contextDir}`)) |
| 30 | console.info(chalk.gray(`构建 Docker: ${contextOnly ? '否' : '是'}`)) |
| 31 | } |
| 32 | |
| 33 | // 清理输出目录 |
| 34 | await cleanOutputDir(contextDir, verbose) |
| 35 | |
| 36 | const { dependencies: projects, graph } = await getDependencies(projectName, verbose) |
| 37 | await fs.ensureDir(contextDir) |
| 38 | if (verbose) |
| 39 | console.info(chalk.gray(`创建输出目录: ${contextDir}`)) |
| 40 | |
| 41 | // 先创建依赖专用的 workspace |
| 42 | const depsDir = await createDepsWorkspace(projects, graph, contextDir, verbose) |
| 43 | |
| 44 | await copyArtifacts(projects, graph, contextDir, projectName, verbose) |
| 45 | await copyDockerfile(projectName, contextDir, verbose) |
| 46 | await resetDependencies(projects, contextDir, verbose) |
| 47 | await generateConfig(projects, graph, contextDir, verbose) |
| 48 | await copyAssets(contextDir, verbose) |
| 49 | await copyConfig(contextDir, projectName, verbose) |
| 50 | |
| 51 | return { |
| 52 | projectName, |
| 53 | outputDir: contextDir, |
| 54 | depsDir, |
| 55 | projects, |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | async function getDependencies(appName, verbose = false) { |
| 60 | if (verbose) |
| 61 | console.info(chalk.yellow(`分析 ${appName} 的依赖关系...`)) |
| 62 | |
| 63 | await $`npx nx graph --file=temp-graph.json` |
| 64 | const graphData = await fs.readJson('temp-graph.json') |
| 65 | const graph = graphData.graph |
| 66 | |
| 67 | const dependencies = new Set([appName]) |
| 68 | const queue = [appName] |
| 69 | |
| 70 | while (queue.length > 0) { |
| 71 | const current = queue.shift() |
| 72 | const projectDeps = graph.dependencies[current] || [] |
| 73 | |
| 74 | for (const dep of projectDeps) { |
| 75 | if (dep.target) { |
| 76 | const depName = dep.target |
| 77 | if (!dependencies.has(depName)) { |
| 78 | dependencies.add(depName) |
| 79 | queue.push(depName) |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | await fs.remove('temp-graph.json') |
| 86 | |
| 87 | if (verbose) |
| 88 | console.info(chalk.green(`发现依赖: ${Array.from(dependencies).join(', ')}`)) |
| 89 | return { dependencies: Array.from(dependencies), graph } |
| 90 | } |
| 91 | |
| 92 | async function copyArtifacts(projects, graph, contextDir, appName, verbose = false) { |
| 93 | if (verbose) |
| 94 | console.info(chalk.yellow('构建应用及其依赖...')) |
| 95 | |
| 96 | // 只构建主应用,Nx 会自动构建所有依赖的库 |
| 97 | try { |
| 98 | await $`npx nx build ${appName}` |
| 99 | console.info(chalk.green(`${appName} 及其依赖构建完成`)) |
| 100 | } |
| 101 | catch (error) { |
| 102 | console.error(chalk.red(`${appName} 构建失败:`)) |
| 103 | console.error(chalk.red(` ${error.message}`)) |
| 104 | throw new Error(`项目 ${appName} 构建失败,脚本终止执行`) |
| 105 | } |
| 106 | |
| 107 | // 复制所有构建产物(包括自动构建的依赖) |
| 108 | if (verbose) |
| 109 | console.info(chalk.yellow('复制构建产物...')) |
| 110 | |
| 111 | for (const project of projects) { |
| 112 | const node = graph.nodes[project] |
| 113 | const isApp = node && node.type === 'app' |
| 114 | const src = isApp ? `dist/apps/${project}` : `dist/libs/${project}` |
| 115 | const dest = isApp ? path.join(contextDir, 'apps', project) : path.join(contextDir, 'libs', project) |
| 116 | |
| 117 | // 检查构建产物是否存在 |
| 118 | if (!(await fs.pathExists(src))) { |
| 119 | console.error(chalk.red(`${project} 构建产物不存在: ${src}`)) |
| 120 | throw new Error(`项目 ${project} 构建产物缺失,脚本终止执行`) |
| 121 | } |
| 122 | |
| 123 | await fs.copy(src, dest) |
| 124 | if (verbose) |
| 125 | console.info(chalk.gray(` ${src} -> ${dest}`)) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | async function copyDockerfile(projectName, contextDir, verbose = false) { |
| 130 | if (verbose) |
| 131 | console.info(chalk.yellow('复制 Dockerfile...')) |
| 132 | |
| 133 | const appDockerfile = `apps/${projectName}/Dockerfile` |
| 134 | if (await fs.pathExists(appDockerfile)) { |
| 135 | await fs.copy(appDockerfile, path.join(contextDir, 'Dockerfile')) |
| 136 | if (verbose) |
| 137 | console.info(chalk.gray(` 应用 Dockerfile: ${appDockerfile} -> Dockerfile`)) |
| 138 | return |
| 139 | } |
| 140 | |
| 141 | const rootDockerfile = 'Dockerfile' |
| 142 | if (await fs.pathExists(rootDockerfile)) { |
| 143 | await fs.copy(rootDockerfile, path.join(contextDir, 'Dockerfile')) |
| 144 | if (verbose) |
| 145 | console.info(chalk.gray(` 根目录 Dockerfile: ${rootDockerfile} -> Dockerfile`)) |
| 146 | return |
| 147 | } |
| 148 | |
| 149 | console.warn(chalk.yellow(`警告: 未找到 ${projectName} 的 Dockerfile`)) |
| 150 | } |
| 151 | |
| 152 | async function createDepsWorkspace(projects, graph, contextDir, verbose = false) { |
| 153 | if (verbose) |
| 154 | console.info(chalk.yellow('创建依赖专用 workspace...')) |
| 155 | |
| 156 | const depsDir = path.join(contextDir, 'deps') |
| 157 | await fs.ensureDir(depsDir) |
| 158 | |
| 159 | // 复制根目录配置文件 |
| 160 | const rootFiles = ['package.json', '.npmrc'] |
| 161 | for (const file of rootFiles) { |
| 162 | if (await fs.pathExists(file)) { |
| 163 | await fs.copy(file, path.join(depsDir, file)) |
| 164 | if (verbose) |
| 165 | console.info(chalk.gray(` 复制配置文件: ${file}`)) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // 生成精简的 pnpm-workspace.yaml,去掉 trustPolicy 等开发配置 |
| 170 | if (await fs.pathExists('pnpm-workspace.yaml')) { |
| 171 | const workspaceContent = await fs.readFile('pnpm-workspace.yaml', 'utf-8') |
| 172 | const lines = workspaceContent.split('\n') |
| 173 | const filteredLines = [] |
| 174 | let skipBlock = false |
| 175 | for (const line of lines) { |
| 176 | if (/^(?:trustPolicy|trustPolicyExclude|shellEmulator):/.test(line)) { |
| 177 | skipBlock = /:\s*$/.test(line) |
| 178 | continue |
| 179 | } |
| 180 | if (skipBlock && /^\s+-/.test(line)) { |
| 181 | continue |
| 182 | } |
| 183 | skipBlock = false |
| 184 | filteredLines.push(line) |
| 185 | } |
| 186 | await fs.writeFile(path.join(depsDir, 'pnpm-workspace.yaml'), filteredLines.join('\n')) |
| 187 | if (verbose) |
| 188 | console.info(chalk.gray(' 生成精简 pnpm-workspace.yaml(去掉 trustPolicy)')) |
| 189 | } |
| 190 | |
| 191 | // 为每个项目创建仅包含 package.json 的目录结构 |
| 192 | for (const project of projects) { |
| 193 | const node = graph.nodes[project] |
| 194 | const isApp = node && node.type === 'app' |
| 195 | const srcPkgPath = isApp ? `apps/${project}/package.json` : `libs/${project}/package.json` |
| 196 | const destDir = isApp ? path.join(depsDir, 'apps', project) : path.join(depsDir, 'libs', project) |
| 197 | const destPkgPath = path.join(destDir, 'package.json') |
| 198 | |
| 199 | if (await fs.pathExists(srcPkgPath)) { |
| 200 | await fs.ensureDir(destDir) |
| 201 | await fs.copy(srcPkgPath, destPkgPath) |
| 202 | if (verbose) |
| 203 | console.info(chalk.gray(` 复制 package.json: ${srcPkgPath} -> ${path.relative(contextDir, destPkgPath)}`)) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | if (verbose) |
| 208 | console.info(chalk.green('依赖专用 workspace 创建完成')) |
| 209 | |
| 210 | return depsDir |
| 211 | } |
| 212 | |
| 213 | async function buildImage(projectName, contextDir, options = {}) { |
| 214 | const { |
| 215 | verbose = false, |
| 216 | registries = [], |
| 217 | push = false, |
| 218 | platforms = getDefaultPlatform(), |
| 219 | } = options |
| 220 | |
| 221 | if (push && registries.length === 0) { |
| 222 | throw new Error('推送镜像时必须通过 --registry 指定至少一个镜像仓库') |
| 223 | } |
| 224 | |
| 225 | if (platforms.length > 1 && !push) { |
| 226 | console.error(chalk.red('错误: 多平台构建必须配合 --push 使用(docker buildx 不支持多平台 --load)')) |
| 227 | process.exit(1) |
| 228 | } |
| 229 | |
| 230 | const platformStr = platforms.join(',') |
| 231 | |
| 232 | if (verbose) { |
| 233 | console.info(chalk.yellow(`构建 Docker 镜像: ${projectName}`)) |
| 234 | console.info(chalk.gray(` 目标平台: ${platformStr}`)) |
| 235 | } |
| 236 | |
| 237 | // 获取当前日期 (YYYYMMDD 格式) |
| 238 | const date = new Date().toISOString().slice(0, 10).replace(/-/g, '') |
| 239 | |
| 240 | // 获取 Git 短提交哈希 |
| 241 | const gitHash = await $`git rev-parse --short HEAD` |
| 242 | const shortHash = gitHash.stdout.trim() |
| 243 | |
| 244 | // 生成与 GitHub Actions 一致的标签格式 |
| 245 | const tag = `${date}-${shortHash}` |
| 246 | const localImageName = `${projectName}:${tag}` |
| 247 | |
| 248 | // 为每个 registry 生成 tag 参数 |
| 249 | const remoteImageNames = registries.map(registry => `${registry}/${projectName}:${tag}`) |
| 250 | const imageNames = push ? remoteImageNames : [localImageName, ...remoteImageNames] |
| 251 | const tagArgs = imageNames.flatMap(imageName => ['-t', imageName]) |
| 252 | const pushArgs = push ? ['--push'] : ['--load'] |
| 253 | |
| 254 | try { |
| 255 | // 构建镜像并打所有 tag |
| 256 | await $({ cwd: contextDir })`docker buildx build --build-arg APP_NAME=${projectName} --platform ${platformStr} -t ${localImageName} ${tagArgs} ${pushArgs} .` |
| 257 | console.info(chalk.green(`Docker 镜像构建完成:`)) |
| 258 | if (!push) { |
| 259 | console.info(chalk.gray(` 本地: ${localImageName}`)) |
| 260 | } |
| 261 | for (const imageName of remoteImageNames) { |
| 262 | console.info(chalk.gray(` 远程: ${imageName}`)) |
| 263 | } |
| 264 | } |
| 265 | catch (error) { |
| 266 | console.error(chalk.red(`Docker 镜像操作失败: ${error.message}`)) |
| 267 | throw error |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | async function resetDependencies(projects, contextDir, verbose = false) { |
| 272 | if (verbose) |
| 273 | console.info(chalk.yellow('重置工作区依赖版本为 workspace:* 协议...')) |
| 274 | |
| 275 | const packages = new Set() |
| 276 | for (const project of projects) { |
| 277 | const appPath = path.join(contextDir, 'apps', project, 'package.json') |
| 278 | const libPath = path.join(contextDir, 'libs', project, 'package.json') |
| 279 | |
| 280 | if (await fs.pathExists(appPath)) { |
| 281 | const pkg = await fs.readJson(appPath) |
| 282 | if (pkg.name) |
| 283 | packages.add(pkg.name) |
| 284 | } |
| 285 | |
| 286 | if (await fs.pathExists(libPath)) { |
| 287 | const pkg = await fs.readJson(libPath) |
| 288 | if (pkg.name) |
| 289 | packages.add(pkg.name) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | if (verbose) |
| 294 | console.info(chalk.gray(` 发现工作区包: ${Array.from(packages).join(', ')}`)) |
| 295 | |
| 296 | const processPackage = async (pkgPath) => { |
| 297 | if (!(await fs.pathExists(pkgPath))) |
| 298 | return |
| 299 | |
| 300 | const pkg = await fs.readJson(pkgPath) |
| 301 | let modified = false |
| 302 | |
| 303 | const depTypes = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] |
| 304 | |
| 305 | for (const depType of depTypes) { |
| 306 | if (pkg[depType]) { |
| 307 | for (const [name, version] of Object.entries(pkg[depType])) { |
| 308 | if (packages.has(name) && version !== 'workspace:*') { |
| 309 | pkg[depType][name] = 'workspace:*' |
| 310 | modified = true |
| 311 | if (verbose) |
| 312 | console.info(chalk.gray(` ${path.relative(contextDir, pkgPath)}: ${name} -> workspace:*`)) |
| 313 | } |
| 314 | } |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | if (modified) { |
| 319 | await fs.writeJson(pkgPath, pkg, { spaces: 2 }) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | await processPackage(path.join(contextDir, 'package.json')) |
| 324 | |
| 325 | for (const project of projects) { |
| 326 | await processPackage(path.join(contextDir, 'apps', project, 'package.json')) |
| 327 | await processPackage(path.join(contextDir, 'libs', project, 'package.json')) |
| 328 | } |
| 329 | |
| 330 | if (verbose) |
| 331 | console.info(chalk.green('工作区依赖版本重置完成')) |
| 332 | } |
| 333 | |
| 334 | async function copyAssets(contextDir, verbose = false) { |
| 335 | if (verbose) |
| 336 | console.info(chalk.yellow('复制 assets 目录...')) |
| 337 | |
| 338 | const assetsDir = 'assets' |
| 339 | if (await fs.pathExists(assetsDir)) { |
| 340 | const destPath = path.join(contextDir, 'assets') |
| 341 | await fs.copy(assetsDir, destPath) |
| 342 | if (verbose) |
| 343 | console.info(chalk.gray(` ${assetsDir} -> ${destPath}`)) |
| 344 | if (verbose) |
| 345 | console.info(chalk.green('assets 目录复制完成')) |
| 346 | } |
| 347 | else if (verbose) { |
| 348 | console.info(chalk.gray('未找到 assets 目录,跳过')) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | async function copyConfig(contextDir, projectName, verbose = false) { |
| 353 | if (verbose) |
| 354 | console.info(chalk.yellow('复制 assets 目录...')) |
| 355 | |
| 356 | const config = `apps/${projectName}/config/config.yaml` |
| 357 | if (await fs.pathExists(config)) { |
| 358 | const destPath = path.join(contextDir, 'config.yaml') |
| 359 | await fs.copy(config, destPath) |
| 360 | if (verbose) |
| 361 | console.info(chalk.gray(` ${config} -> ${destPath}`)) |
| 362 | if (verbose) |
| 363 | console.info(chalk.green('config 复制完成')) |
| 364 | } |
| 365 | else if (verbose) { |
| 366 | console.info(chalk.gray('未找到 config ,跳过')) |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | async function generateConfig(projects, graph, contextDir, verbose = false) { |
| 371 | if (verbose) |
| 372 | console.info(chalk.yellow('生成 Monorepo 配置...')) |
| 373 | |
| 374 | const rootFiles = ['package.json', 'pnpm-workspace.yaml', '.npmrc'] |
| 375 | |
| 376 | for (const file of rootFiles) { |
| 377 | if (await fs.pathExists(file)) { |
| 378 | await fs.copy(file, path.join(contextDir, file)) |
| 379 | if (verbose) |
| 380 | console.info(chalk.gray(` 复制配置文件: ${file}`)) |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | await resetDependencies(projects, contextDir, verbose) |
| 385 | |
| 386 | if (verbose) |
| 387 | console.info(chalk.green('Monorepo 配置生成完成')) |
| 388 | } |
| 389 | |
| 390 | if (import.meta.url === `file://${process.argv[1]}`) { |
| 391 | const program = new Command() |
| 392 | |
| 393 | program |
| 394 | .name('build-docker') |
| 395 | .description('为 Nx 应用准备 Docker 构建上下文并构建镜像,使用 --context-only 可仅准备上下文') |
| 396 | .version('1.0.0') |
| 397 | .argument('<app-name>', '应用名称') |
| 398 | .option('-o, --output <dir>', '输出目录', 'tmp/docker-context') |
| 399 | .option('-v, --verbose', '显示详细日志', false) |
| 400 | .option('--context-only', '仅准备 Docker 上下文,不构建镜像', false) |
| 401 | .option('-r, --registry <registry...>', 'Docker 镜像仓库地址(可多次指定)', []) |
| 402 | .option('-p, --push', '构建后推送镜像到仓库', false) |
| 403 | .option('--platform <platforms...>', '目标平台(可多次指定,如 linux/amd64 linux/arm64),默认当前系统架构') |
| 404 | .action(async (appName, options) => { |
| 405 | try { |
| 406 | const finalOptions = { ...options, contextOnly: options.contextOnly } |
| 407 | const platforms = options.platform || getDefaultPlatform() |
| 408 | |
| 409 | const result = await prepareContext(appName, finalOptions) |
| 410 | |
| 411 | if (!options.contextOnly) { |
| 412 | await buildImage(result.projectName, result.outputDir, { |
| 413 | verbose: options.verbose, |
| 414 | registries: options.registry, |
| 415 | push: options.push, |
| 416 | platforms, |
| 417 | }) |
| 418 | } |
| 419 | } |
| 420 | catch (error) { |
| 421 | console.error(chalk.red(`错误: ${error.message}`)) |
| 422 | process.exit(1) |
| 423 | } |
| 424 | }) |
| 425 | |
| 426 | program.parseAsync(process.argv).catch((error) => { |
| 427 | console.error(chalk.red(`错误: ${error.message}`)) |
| 428 | process.exit(1) |
| 429 | }) |
| 430 | } |
| 431 |