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