@weotro/dx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +755 -0
  3. package/bin/dx-with-version-env.js +8 -0
  4. package/bin/dx.js +187 -0
  5. package/lib/artifact-deploy/artifact-builder.js +144 -0
  6. package/lib/artifact-deploy/config.js +180 -0
  7. package/lib/artifact-deploy/remote-script.js +301 -0
  8. package/lib/artifact-deploy/remote-transport.js +86 -0
  9. package/lib/artifact-deploy.js +70 -0
  10. package/lib/backend-artifact-deploy/artifact-builder.js +267 -0
  11. package/lib/backend-artifact-deploy/config.js +218 -0
  12. package/lib/backend-artifact-deploy/path-utils.js +18 -0
  13. package/lib/backend-artifact-deploy/remote-phases.js +14 -0
  14. package/lib/backend-artifact-deploy/remote-result.js +44 -0
  15. package/lib/backend-artifact-deploy/remote-script.js +507 -0
  16. package/lib/backend-artifact-deploy/remote-transport.js +123 -0
  17. package/lib/backend-artifact-deploy/rollback.js +5 -0
  18. package/lib/backend-artifact-deploy/runtime-package.js +46 -0
  19. package/lib/backend-artifact-deploy.js +91 -0
  20. package/lib/backend-package.js +674 -0
  21. package/lib/cli/args.js +38 -0
  22. package/lib/cli/command-result.js +1 -0
  23. package/lib/cli/commands/contracts.js +60 -0
  24. package/lib/cli/commands/core.js +533 -0
  25. package/lib/cli/commands/db.js +231 -0
  26. package/lib/cli/commands/deploy.js +175 -0
  27. package/lib/cli/commands/env.js +120 -0
  28. package/lib/cli/commands/export.js +39 -0
  29. package/lib/cli/commands/package.js +22 -0
  30. package/lib/cli/commands/release.js +55 -0
  31. package/lib/cli/commands/stack.js +427 -0
  32. package/lib/cli/commands/start.js +58 -0
  33. package/lib/cli/commands/worktree.js +145 -0
  34. package/lib/cli/dx-cli.js +1072 -0
  35. package/lib/cli/flags.js +123 -0
  36. package/lib/cli/help-model.js +222 -0
  37. package/lib/cli/help-renderer.js +137 -0
  38. package/lib/cli/help-schema.js +552 -0
  39. package/lib/cli/help.js +141 -0
  40. package/lib/cli/index.js +4 -0
  41. package/lib/cli/nx-command.js +13 -0
  42. package/lib/codex-initial.js +271 -0
  43. package/lib/confirm.js +213 -0
  44. package/lib/env-policy.js +134 -0
  45. package/lib/env-profile.js +435 -0
  46. package/lib/env.js +261 -0
  47. package/lib/exec.js +692 -0
  48. package/lib/logger.js +239 -0
  49. package/lib/nx-ignore.js +45 -0
  50. package/lib/run-with-version-env.js +163 -0
  51. package/lib/sdk-build.js +424 -0
  52. package/lib/start-dev.js +401 -0
  53. package/lib/telegram-webhook.js +431 -0
  54. package/lib/validate-env.js +317 -0
  55. package/lib/vercel-deploy.js +549 -0
  56. package/lib/version.js +14 -0
  57. package/lib/worktree.js +1052 -0
  58. package/package.json +45 -0
  59. package/skills/create-issue/SKILL.md +90 -0
  60. package/skills/delivering-design-handoff/SKILL.md +290 -0
  61. package/skills/doctor/SKILL.md +76 -0
  62. package/skills/gh-dependabot-cleanup/SKILL.md +54 -0
  63. package/skills/gh-dependabot-cleanup/agents/openai.yaml +7 -0
  64. package/skills/git-release/SKILL.md +194 -0
  65. package/skills/git-release/agents/openai.yaml +7 -0
  66. package/skills/online-debug-guard/SKILL.md +111 -0
  67. package/skills/ship-issue-pr/SKILL.md +676 -0
  68. package/skills/stagewise-ui-debugging/SKILL.md +48 -0
@@ -0,0 +1,674 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 后端部署包构建脚本
5
+ *
6
+ * 功能概览:
7
+ * 1. 根据当前环境构建 NestJS 后端 dist 产物
8
+ * 2. 收集 Prisma schema/migrations、运行时配置等运行所需文件
9
+ * 3. 安装并裁剪生产依赖,生成可直接部署的目录结构
10
+ * 4. 打包为 backend-<version>-<sha>.tar.gz,提供 bin/start.sh 启动脚本
11
+ */
12
+
13
+ import { tmpdir } from 'node:os'
14
+ import { mkdir, rm, writeFile, chmod, stat } from 'node:fs/promises'
15
+ import { existsSync, cpSync, readFileSync, readdirSync, statSync } from 'node:fs'
16
+ import { resolve, join, dirname, relative } from 'node:path'
17
+ import crypto from 'node:crypto'
18
+ import * as child_process from 'node:child_process'
19
+ import { parse as parseYaml } from 'yaml'
20
+ import { logger } from './logger.js'
21
+ import { execManager } from './exec.js'
22
+ import { envManager } from './env.js'
23
+ import {
24
+ loadEnvPolicy,
25
+ resolvePolicyTargetId,
26
+ resolveTargetRequiredVars,
27
+ } from './env-policy.js'
28
+
29
+ class BackendPackager {
30
+ constructor(options = {}) {
31
+ this.projectRoot = process.env.DX_PROJECT_ROOT || process.cwd()
32
+ this.backendRoot = join(this.projectRoot, 'apps/backend')
33
+ this.distRoot = join(this.projectRoot, 'dist/backend')
34
+ this.prismaSrc = join(this.backendRoot, 'prisma')
35
+ this.targetEnv = options.environment || process.env.APP_ENV || 'development'
36
+ this.skipBuild = Boolean(options.skipBuild)
37
+ this.disableCleanup = Boolean(options.keepWorkdir)
38
+ this.layerEnv = envManager.mapAppEnvToLayerEnv(this.targetEnv)
39
+ const { version, packageManager, pnpmConfig, nxVersion } = this.resolveRootMetadata()
40
+ this.repoVersion = version
41
+ this.packageManager = packageManager
42
+ this.rootPnpmConfig = pnpmConfig
43
+ this.rootNxVersion = nxVersion
44
+ this.gitSha = this.runGitCommand('git rev-parse HEAD') || 'unknown'
45
+ this.gitShortSha = this.gitSha === 'unknown' ? 'unknown' : this.gitSha.slice(0, 7)
46
+ this.buildTimestamp = new Date().toISOString()
47
+ this.envSlug = this.createEnvironmentSlug(this.targetEnv)
48
+ this.artifactBase = `backend-${this.repoVersion}-${this.envSlug}-${this.gitShortSha}`
49
+ this.artifactFile = `${this.artifactBase}.tar.gz`
50
+ this.tmpRoot = null
51
+ this.outputRoot = null
52
+ this.outputAppDir = null
53
+ this.nodeVersionConstraint = this.resolveNodeConstraint()
54
+ this.envSnapshot = {}
55
+ this.buildConfiguration = 'skipped'
56
+ this.pnpmVersion = this.resolvePnpmVersion()
57
+ this.workspacePackagesInfo = null
58
+ this.copiedWorkspacePackages = new Set()
59
+ }
60
+
61
+ resolveRootMetadata() {
62
+ try {
63
+ const pkg = JSON.parse(readFileSync(join(this.projectRoot, 'package.json'), 'utf8'))
64
+ const version = typeof pkg.version === 'string' ? pkg.version : '0.0.0'
65
+ const packageManager = pkg.packageManager || 'pnpm'
66
+ const pnpmConfig = pkg.pnpm ? structuredClone(pkg.pnpm) : undefined
67
+ const nxVersion = pkg.devDependencies?.nx || pkg.dependencies?.nx || null
68
+ return { version, packageManager, pnpmConfig, nxVersion }
69
+ } catch (error) {
70
+ logger.warn(`读取根 package.json 失败: ${error.message}`)
71
+ return {
72
+ version: '0.0.0',
73
+ packageManager: 'pnpm',
74
+ pnpmConfig: undefined,
75
+ nxVersion: null,
76
+ }
77
+ }
78
+ }
79
+
80
+ resolvePnpmVersion() {
81
+ try {
82
+ return child_process
83
+ .execSync('pnpm --version', { cwd: this.projectRoot, encoding: 'utf8' })
84
+ .trim()
85
+ } catch (error) {
86
+ logger.warn(`无法获取 pnpm 版本信息: ${error.message}`)
87
+ return null
88
+ }
89
+ }
90
+
91
+ loadWorkspacePackageInfo() {
92
+ if (this.workspacePackagesInfo) return this.workspacePackagesInfo
93
+
94
+ const map = new Map()
95
+ try {
96
+ const workspaceFile = join(this.projectRoot, 'pnpm-workspace.yaml')
97
+ let patterns = ['apps/*', 'packages/*']
98
+ if (existsSync(workspaceFile)) {
99
+ const raw = readFileSync(workspaceFile, 'utf8')
100
+ const parsed = parseYaml(raw)
101
+ if (parsed?.packages && Array.isArray(parsed.packages)) patterns = parsed.packages
102
+ }
103
+
104
+ const collectFromDir = dir => {
105
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return
106
+ const entries = readdirSync(dir, { withFileTypes: true })
107
+ for (const entry of entries) {
108
+ if (!entry.isDirectory()) continue
109
+ const child = join(dir, entry.name)
110
+ const pkgPath = join(child, 'package.json')
111
+ if (!existsSync(pkgPath)) continue
112
+ try {
113
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
114
+ if (pkg?.name && pkg?.version) {
115
+ map.set(pkg.name, {
116
+ version: pkg.version,
117
+ path: relative(this.projectRoot, child),
118
+ })
119
+ }
120
+ } catch {}
121
+ }
122
+ }
123
+
124
+ patterns.forEach(pattern => {
125
+ if (!pattern) return
126
+ const normalized = pattern.replace(/\\/g, '/').trim()
127
+ if (normalized.endsWith('/*')) {
128
+ const base = normalized.slice(0, -2)
129
+ collectFromDir(join(this.projectRoot, base))
130
+ } else {
131
+ const absDir = join(this.projectRoot, normalized)
132
+ const pkgPath = join(absDir, 'package.json')
133
+ if (!existsSync(pkgPath)) return
134
+ try {
135
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
136
+ if (pkg?.name && pkg?.version) {
137
+ map.set(pkg.name, {
138
+ version: pkg.version,
139
+ path: relative(this.projectRoot, absDir),
140
+ })
141
+ }
142
+ } catch {}
143
+ }
144
+ })
145
+ } catch (error) {
146
+ logger.warn(`解析 pnpm workspace 失败: ${error.message}`)
147
+ }
148
+
149
+ this.workspacePackagesInfo = map
150
+ return map
151
+ }
152
+
153
+ createEnvironmentSlug(env) {
154
+ const raw = String(env || 'unknown').toLowerCase()
155
+ const normalized = raw
156
+ .replace(/[^a-z0-9_-]/g, '-')
157
+ .replace(/-{2,}/g, '-')
158
+ .replace(/^-|-$/g, '')
159
+ return normalized || 'unknown'
160
+ }
161
+
162
+ async copyWorkspacePackage(name, info) {
163
+ if (this.copiedWorkspacePackages.has(name)) return
164
+ this.copiedWorkspacePackages.add(name)
165
+
166
+ const sourceDir = join(this.projectRoot, info.path)
167
+ if (!existsSync(sourceDir)) {
168
+ logger.warn(`未找到 workspace 包路径: ${sourceDir}`)
169
+ return
170
+ }
171
+
172
+ try {
173
+ await execManager.executeCommand(`pnpm --filter ${name} build`, {
174
+ cwd: this.projectRoot,
175
+ skipEnvValidation: true,
176
+ })
177
+ } catch (error) {
178
+ logger.warn(`workspace 包 ${name} 构建失败: ${error.message}`)
179
+ }
180
+
181
+ const targetDir = join(this.outputAppDir, 'dist', info.path)
182
+ await mkdir(targetDir, { recursive: true })
183
+
184
+ const packageJsonPath = join(sourceDir, 'package.json')
185
+ if (existsSync(packageJsonPath)) {
186
+ cpSync(packageJsonPath, join(targetDir, 'package.json'))
187
+ }
188
+
189
+ const distDir = join(sourceDir, 'dist')
190
+ if (existsSync(distDir)) {
191
+ cpSync(distDir, join(targetDir, 'dist'), { recursive: true })
192
+ }
193
+ }
194
+
195
+ resolveNodeConstraint() {
196
+ try {
197
+ const pkg = JSON.parse(readFileSync(join(this.projectRoot, 'package.json'), 'utf8'))
198
+ const engines = pkg.engines || {}
199
+ const nodeConstraint = engines.node || '>=20.11.0'
200
+ return String(nodeConstraint)
201
+ } catch {
202
+ return '>=20.11.0'
203
+ }
204
+ }
205
+
206
+ runGitCommand(command) {
207
+ try {
208
+ return child_process.execSync(command, { cwd: this.projectRoot, encoding: 'utf8' }).trim()
209
+ } catch {
210
+ return null
211
+ }
212
+ }
213
+
214
+ async run() {
215
+ logger.step(`后端部署包构建 (${this.targetEnv})`)
216
+
217
+ try {
218
+ envManager.syncEnvironments(this.targetEnv)
219
+ await this.prepareEnvSnapshot()
220
+ await this.prepareWorkdir()
221
+ if (!this.skipBuild) await this.buildBackend()
222
+ await this.ensureDistArtifacts()
223
+ await this.stageRuntimeFiles()
224
+ await this.installProductionDependencies()
225
+ await this.writeManifest()
226
+ await this.createArchive()
227
+ logger.success(`部署包已生成: ${this.getArtifactPath()}`)
228
+ } catch (error) {
229
+ if (envManager.latestEnvWarnings?.length) {
230
+ envManager.latestEnvWarnings.forEach(message => logger.warn(message))
231
+ }
232
+ logger.error(`后端打包失败: ${error.message}`)
233
+ throw error
234
+ } finally {
235
+ await this.cleanup()
236
+ }
237
+ }
238
+
239
+ async prepareEnvSnapshot() {
240
+ logger.info('校验并快照环境变量')
241
+ const policy = loadEnvPolicy(envManager.configDir)
242
+ const targetId = resolvePolicyTargetId(policy, 'backend')
243
+ if (!targetId) {
244
+ throw new Error('缺少 appToTarget.backend 配置(dx 内置逻辑需要 backend target)')
245
+ }
246
+ const requiredVars = resolveTargetRequiredVars(policy, targetId, this.layerEnv)
247
+ const collected = envManager.collectEnvFromLayers('backend', this.layerEnv)
248
+ const effectiveEnv = { ...collected, ...process.env }
249
+ const { valid, missing, placeholders } = envManager.validateRequiredVars(
250
+ requiredVars,
251
+ effectiveEnv,
252
+ )
253
+ if (!valid) {
254
+ const problems = []
255
+ if (missing.length > 0) problems.push(`缺少必填环境变量: ${missing.join(', ')}`)
256
+ if (placeholders.length > 0) problems.push(`以下变量仍为占位值: ${placeholders.join(', ')}`)
257
+ const message = problems.length > 0 ? problems.join('\n') : '环境变量校验未通过'
258
+ throw new Error(message)
259
+ }
260
+ const snapshotKeys = new Set([
261
+ ...Object.keys(collected),
262
+ ...requiredVars,
263
+ 'APP_ENV',
264
+ 'NODE_ENV',
265
+ ])
266
+
267
+ const snapshot = {}
268
+ snapshotKeys.forEach(key => {
269
+ const value = effectiveEnv[key]
270
+ if (value !== undefined && value !== null) {
271
+ snapshot[key] = String(value)
272
+ }
273
+ })
274
+
275
+ snapshot.APP_ENV = this.targetEnv
276
+ snapshot.NODE_ENV = envManager.mapAppEnvToNodeEnv(this.targetEnv)
277
+
278
+ this.envSnapshot = this.stripUndefined(snapshot)
279
+ this.envSnapshot.APP_VERSION = this.repoVersion
280
+ this.envSnapshot.BUILD_GIT_SHA = this.gitSha
281
+ this.envSnapshot.BUILD_TIME = this.buildTimestamp
282
+ }
283
+
284
+ stripUndefined(record) {
285
+ const result = {}
286
+ for (const [key, value] of Object.entries(record)) {
287
+ if (value === undefined || value === null) continue
288
+ result[key] = String(value)
289
+ }
290
+ return result
291
+ }
292
+
293
+ async prepareWorkdir() {
294
+ const randomSuffix = crypto.randomBytes(6).toString('hex')
295
+ const tmpBase = join(tmpdir(), `backend-package-${randomSuffix}`)
296
+ await mkdir(tmpBase, { recursive: true })
297
+ this.tmpRoot = tmpBase
298
+ this.outputRoot = join(tmpBase, this.artifactBase)
299
+ this.outputAppDir = join(this.outputRoot, 'backend')
300
+ await mkdir(this.outputAppDir, { recursive: true })
301
+ }
302
+
303
+ async buildBackend() {
304
+ logger.step('构建后端产物')
305
+ const configuration = ['production', 'staging'].includes(this.targetEnv)
306
+ ? 'production'
307
+ : 'development'
308
+ this.buildConfiguration = configuration
309
+ await execManager.executeCommand(`npx nx build backend --configuration=${configuration}`, {
310
+ app: 'backend',
311
+ })
312
+ }
313
+
314
+ async ensureDistArtifacts() {
315
+ const expectedMain = join(this.distRoot, 'apps/backend/src/main.js')
316
+ try {
317
+ await stat(expectedMain)
318
+ } catch (error) {
319
+ throw new Error(
320
+ `缺少编译产物 ${relative(this.projectRoot, expectedMain)},请检查 build 步骤。`,
321
+ )
322
+ }
323
+ }
324
+
325
+ async stageRuntimeFiles() {
326
+ logger.step('收集运行所需文件')
327
+
328
+ // dist
329
+ await this.copyDistTree()
330
+
331
+ // prisma schema/migrations
332
+ if (existsSync(this.prismaSrc)) {
333
+ cpSync(this.prismaSrc, join(this.outputAppDir, 'prisma'), { recursive: true })
334
+ }
335
+
336
+ // package.json
337
+ const distPackagePath = join(this.distRoot, 'package.json')
338
+ const distPackage = JSON.parse(readFileSync(distPackagePath, 'utf8'))
339
+ const backendPackage = JSON.parse(readFileSync(join(this.backendRoot, 'package.json'), 'utf8'))
340
+
341
+ const runtimeDeps = { ...(distPackage.dependencies || {}) }
342
+ const workspacePackages = this.loadWorkspacePackageInfo()
343
+
344
+ for (const [name, version] of Object.entries(backendPackage.dependencies || {})) {
345
+ if (typeof version !== 'string') continue
346
+ if (version.startsWith('workspace:')) {
347
+ const info = workspacePackages.get(name)
348
+ if (info) {
349
+ await this.copyWorkspacePackage(name, info)
350
+ const relPath = join('dist', info.path).replace(/\\/g, '/')
351
+ runtimeDeps[name] = `file:./${relPath}`
352
+ } else {
353
+ logger.warn(`未能解析 workspace 依赖 ${name},请检查工作区配置`)
354
+ }
355
+ continue
356
+ }
357
+ runtimeDeps[name] = version
358
+ }
359
+ if (backendPackage.devDependencies?.prisma) {
360
+ runtimeDeps.prisma = backendPackage.devDependencies.prisma
361
+ }
362
+
363
+ const devOnlyPackages = new Set([
364
+ 'husky',
365
+ 'lint-staged',
366
+ '@nestjs/cli',
367
+ 'ts-node',
368
+ 'ts-jest',
369
+ 'supertest',
370
+ ])
371
+
372
+ const sanitizedDeps = Object.fromEntries(
373
+ Object.entries(runtimeDeps)
374
+ .filter(([name]) => !name.startsWith('@types/') && !devOnlyPackages.has(name))
375
+ .sort(([a], [b]) => a.localeCompare(b)),
376
+ )
377
+
378
+ const packageJson = {
379
+ name: backendPackage.name || distPackage.name || '@ai/backend',
380
+ version: this.repoVersion,
381
+ private: true,
382
+ type: 'commonjs',
383
+ dependencies: sanitizedDeps,
384
+ scripts: {
385
+ start: 'node dist/apps/backend/src/main.js',
386
+ 'prisma:migrate': 'prisma migrate deploy',
387
+ },
388
+ engines: { node: this.nodeVersionConstraint },
389
+ ...(this.rootPnpmConfig ? { pnpm: this.rootPnpmConfig } : {}),
390
+ }
391
+
392
+ await writeFile(
393
+ join(this.outputAppDir, 'package.json'),
394
+ `${JSON.stringify(packageJson, null, 2)}\n`,
395
+ 'utf8',
396
+ )
397
+
398
+ const rootLockfile = join(this.projectRoot, 'pnpm-lock.yaml')
399
+ if (existsSync(rootLockfile)) {
400
+ cpSync(rootLockfile, join(this.outputAppDir, 'pnpm-lock.yaml'))
401
+ }
402
+
403
+ await this.writeRuntimeEnv()
404
+ await this.writeStartScript()
405
+ await this.writeHealthcheckScript()
406
+ await this.writeDeployReadme()
407
+ }
408
+
409
+ async copyDistTree() {
410
+ const targetDist = join(this.outputAppDir, 'dist')
411
+ await mkdir(targetDist, { recursive: true })
412
+ cpSync(this.distRoot, targetDist, {
413
+ recursive: true,
414
+ filter: (source, destination) => {
415
+ const rel = relative(this.distRoot, source)
416
+ if (!rel || rel === '' || rel === 'package.json') return rel !== 'package.json'
417
+ return true
418
+ },
419
+ })
420
+ }
421
+
422
+ async writeRuntimeEnv() {
423
+ const configDir = join(this.outputAppDir, 'config')
424
+ await mkdir(configDir, { recursive: true })
425
+ const lines = Object.entries(this.envSnapshot)
426
+ .sort(([a], [b]) => a.localeCompare(b))
427
+ .map(([key, value]) => `${key}=${this.escapeEnvValue(value)}`)
428
+ await writeFile(join(configDir, '.env.runtime'), `${lines.join('\n')}\n`, 'utf8')
429
+ }
430
+
431
+ escapeEnvValue(value) {
432
+ if (value === '') return "''"
433
+ if (/[^\w\-./:@]/.test(value)) {
434
+ return `'${value.replace(/'/g, "'\\''")}'`
435
+ }
436
+ return value
437
+ }
438
+
439
+ async writeStartScript() {
440
+ const binDir = join(this.outputAppDir, 'bin')
441
+ await mkdir(binDir, { recursive: true })
442
+ const scriptPath = join(binDir, 'start.sh')
443
+ const script = `#!/usr/bin/env bash
444
+ set -euo pipefail
445
+
446
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
447
+ APP_ROOT="\${SCRIPT_DIR%/bin}"
448
+ ENV_FILE="$APP_ROOT/config/.env.runtime"
449
+ NODE_BIN="$(command -v node || true)"
450
+ REQUIRED_NODE_RAW="${this.nodeVersionConstraint}"
451
+
452
+ if [[ -z "$NODE_BIN" ]]; then
453
+ echo "❌ 未找到 node 命令,请先安装 Node.js (${this.nodeVersionConstraint})" >&2
454
+ exit 1
455
+ fi
456
+
457
+ trim_constraint() {
458
+ local raw="$1"
459
+ echo "\${raw#>=}"
460
+ }
461
+
462
+ version_ge() {
463
+ local current="$1"
464
+ local required="$2"
465
+ local IFS=.
466
+ read -r c1 c2 c3 <<<"\${current//v/}"
467
+ read -r r1 r2 r3 <<<"\${required}"
468
+ c2=\${c2:-0}; c3=\${c3:-0}
469
+ r2=\${r2:-0}; r3=\${r3:-0}
470
+ if (( c1 > r1 )); then return 0; fi
471
+ if (( c1 < r1 )); then return 1; fi
472
+ if (( c2 > r2 )); then return 0; fi
473
+ if (( c2 < r2 )); then return 1; fi
474
+ if (( c3 >= r3 )); then return 0; fi
475
+ return 1
476
+ }
477
+
478
+ CURRENT_NODE_VERSION="$(node -v 2>/dev/null || true)"
479
+ REQUIRED_NODE_VERSION="$(trim_constraint "$REQUIRED_NODE_RAW")"
480
+
481
+ if [[ -z "$CURRENT_NODE_VERSION" ]]; then
482
+ echo "❌ 无法检测到 Node.js 版本,请确认已正确安装" >&2
483
+ exit 1
484
+ fi
485
+
486
+ if ! version_ge "$CURRENT_NODE_VERSION" "$REQUIRED_NODE_VERSION"; then
487
+ echo "❌ 当前 Node.js 版本 $CURRENT_NODE_VERSION 不满足要求 (>= $REQUIRED_NODE_VERSION)" >&2
488
+ exit 1
489
+ fi
490
+
491
+ if [[ ! -f "$ENV_FILE" ]]; then
492
+ echo "❌ 缺少运行时环境文件 $ENV_FILE" >&2
493
+ exit 1
494
+ fi
495
+
496
+ set -a
497
+ source "$ENV_FILE"
498
+ set +a
499
+
500
+ export NODE_ENV="${envManager.mapAppEnvToNodeEnv(this.targetEnv)}"
501
+ export APP_ENV="${this.targetEnv}"
502
+
503
+ echo "🚀 执行 Prisma 数据库迁移"
504
+ npx --yes prisma migrate deploy --schema "$APP_ROOT/prisma/schema" >/dev/null
505
+
506
+ echo "✅ 数据库迁移完成,启动后端服务"
507
+ exec node "$APP_ROOT/dist/apps/backend/src/main.js"
508
+ `
509
+
510
+ await writeFile(scriptPath, script, 'utf8')
511
+ await chmod(scriptPath, 0o755)
512
+ }
513
+
514
+ async writeHealthcheckScript() {
515
+ const binDir = join(this.outputAppDir, 'bin')
516
+ await mkdir(binDir, { recursive: true })
517
+ const scriptPath = join(binDir, 'healthcheck.sh')
518
+ const port = this.envSnapshot.PORT || '3000'
519
+ const apiPrefix = (this.envSnapshot.API_PREFIX || 'api/v1').replace(/^\//, '')
520
+ const script = `#!/usr/bin/env bash
521
+ set -euo pipefail
522
+
523
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
524
+ APP_ROOT="\${SCRIPT_DIR%/bin}"
525
+ ENV_FILE="$APP_ROOT/config/.env.runtime"
526
+
527
+ if [[ ! -f "$ENV_FILE" ]]; then
528
+ echo "❌ 缺少运行时环境文件 $ENV_FILE" >&2
529
+ exit 1
530
+ fi
531
+
532
+ set -a
533
+ source "$ENV_FILE"
534
+ set +a
535
+
536
+ BASE_URL="http://localhost:${port}"
537
+ ENDPOINT="${apiPrefix}/health"
538
+
539
+ curl -sf --connect-timeout 2 --max-time 3 "\${BASE_URL%/}/\${ENDPOINT#/}"
540
+ `
541
+ await writeFile(scriptPath, script, 'utf8')
542
+ await chmod(scriptPath, 0o755)
543
+ }
544
+
545
+ async writeDeployReadme() {
546
+ const content = [
547
+ '# 后端部署包',
548
+ '',
549
+ '## 目录结构',
550
+ '',
551
+ '- bin/start.sh: 启动脚本(自动加载环境变量、执行 migrate deploy、启动服务)',
552
+ '- config/.env.runtime: 打包时固化的环境变量',
553
+ '- dist/: NestJS 编译后的 JavaScript 产物',
554
+ '- node_modules/: 生产依赖',
555
+ '- prisma/: Prisma schema 与 migrations',
556
+ '- manifest.json: 元数据记录(版本、git、环境)',
557
+ '',
558
+ '## 使用步骤',
559
+ '',
560
+ `1. 解压 ${this.artifactFile} 后进入目录:`,
561
+ ` tar -xzf ${this.artifactFile}`,
562
+ ' cd backend',
563
+ '',
564
+ '2. 如需覆盖环境变量,可编辑 config/.env.runtime。',
565
+ '',
566
+ '3. 执行 bin/start.sh 启动服务。脚本会检查 Node 版本、执行 prisma migrate deploy 并启动后端。',
567
+ '',
568
+ '4. (可选)使用进程管理工具(如 pm2/systemd)托管 bin/start.sh。',
569
+ '',
570
+ '## 注意事项',
571
+ '',
572
+ '- 包内已包含生产依赖,无需再执行 pnpm install。',
573
+ '- 如需更新环境变量,请重新执行打包命令或手动维护 config/.env.runtime。',
574
+ '- 启动脚本需在具备数据库与 Redis 连通性的环境下执行。',
575
+ '',
576
+ ].join('\n')
577
+ await writeFile(join(this.outputAppDir, 'README_DEPLOY.md'), content, 'utf8')
578
+ }
579
+
580
+ async installProductionDependencies() {
581
+ logger.step('安装生产依赖')
582
+ const installEnv = { ...process.env }
583
+ installEnv.NX_SKIP_NX_CACHE = 'true'
584
+ const installBaseCmd =
585
+ 'pnpm install --prod --config.node-linker=hoisted --config.package-import-method=copy'
586
+
587
+ await execManager.executeCommand(`${installBaseCmd} --lockfile-only`, {
588
+ cwd: this.outputAppDir,
589
+ skipEnvValidation: true,
590
+ env: installEnv,
591
+ })
592
+
593
+ await execManager.executeCommand(`${installBaseCmd} --frozen-lockfile`, {
594
+ cwd: this.outputAppDir,
595
+ skipEnvValidation: true,
596
+ env: installEnv,
597
+ })
598
+ }
599
+
600
+ async writeManifest() {
601
+ const manifest = {
602
+ app: '@ai/backend',
603
+ version: this.repoVersion,
604
+ gitSha: this.gitSha,
605
+ gitShortSha: this.gitShortSha,
606
+ environment: this.targetEnv,
607
+ buildTime: this.buildTimestamp,
608
+ node: {
609
+ required: this.nodeVersionConstraint,
610
+ runtime: process.version,
611
+ },
612
+ packageManager: this.packageManager,
613
+ tooling: {
614
+ nx: this.rootNxVersion || 'unknown',
615
+ pnpm: this.pnpmVersion || 'unknown',
616
+ },
617
+ build: {
618
+ configuration: this.buildConfiguration || 'unknown',
619
+ },
620
+ }
621
+ await writeFile(
622
+ join(this.outputAppDir, 'manifest.json'),
623
+ `${JSON.stringify(manifest, null, 2)}\n`,
624
+ 'utf8',
625
+ )
626
+ }
627
+
628
+ async createArchive() {
629
+ const artifactPath = this.getArtifactPath()
630
+ await mkdir(dirname(artifactPath), { recursive: true })
631
+ const tarCommand = `tar -czf "${artifactPath}" -C "${this.outputRoot}" backend`
632
+ await execManager.executeCommand(tarCommand, {
633
+ skipEnvValidation: true,
634
+ })
635
+ }
636
+
637
+ getArtifactPath() {
638
+ return join(this.projectRoot, 'dist', 'backend', this.artifactFile)
639
+ }
640
+
641
+ async cleanup() {
642
+ if (this.disableCleanup || !this.tmpRoot) return
643
+ try {
644
+ await rm(this.tmpRoot, { recursive: true, force: true })
645
+ } catch (error) {
646
+ logger.warn(`清理临时目录失败: ${error.message}`)
647
+ }
648
+ }
649
+ }
650
+
651
+ function parseArgs(argv) {
652
+ const options = {}
653
+ for (const arg of argv) {
654
+ if (arg === '--skip-build') options.skipBuild = true
655
+ else if (arg === '--keep-workdir') options.keepWorkdir = true
656
+ else if (arg.startsWith('--env=')) options.environment = arg.slice('--env='.length)
657
+ }
658
+ return options
659
+ }
660
+
661
+ export async function runBackendPackage(argv = []) {
662
+ const options = parseArgs(Array.isArray(argv) ? argv : [])
663
+ const packager = new BackendPackager(options)
664
+ await packager.run()
665
+ }
666
+
667
+ async function main() {
668
+ await runBackendPackage(process.argv.slice(2))
669
+ }
670
+
671
+ main().catch(error => {
672
+ logger.error(`后端部署包构建异常: ${error.message}`)
673
+ process.exit(1)
674
+ })
@@ -0,0 +1,38 @@
1
+ export function getCleanArgs(args = []) {
2
+ const result = []
3
+ for (const arg of args) {
4
+ if (arg === '--') {
5
+ break
6
+ }
7
+ if (arg.startsWith('-')) continue
8
+ result.push(arg)
9
+ }
10
+ return result
11
+ }
12
+
13
+ // Like getCleanArgs(), but also strips values consumed by flags that expect a value.
14
+ // consumedFlagValueIndexes: Set<number> of indexes in the original argv that should be skipped.
15
+ export function getCleanArgsWithConsumedValues(args = [], consumedFlagValueIndexes = new Set()) {
16
+ const result = []
17
+ const consumed = consumedFlagValueIndexes instanceof Set
18
+ ? consumedFlagValueIndexes
19
+ : new Set(consumedFlagValueIndexes || [])
20
+
21
+ for (let i = 0; i < args.length; i++) {
22
+ const arg = args[i]
23
+ if (arg === '--') {
24
+ break
25
+ }
26
+ if (consumed.has(i)) continue
27
+ if (arg.startsWith('-')) continue
28
+ result.push(arg)
29
+ }
30
+
31
+ return result
32
+ }
33
+
34
+ export function getPassthroughArgs(args = []) {
35
+ const doubleDashIndex = args.indexOf('--')
36
+ if (doubleDashIndex === -1) return []
37
+ return args.slice(doubleDashIndex + 1)
38
+ }