返回 AiToEarn
build-docker.mjs
根目录 / project / aitoearn-backend / scripts / build-docker.mjs
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
431 lines Plain Text