@ranger1/dx 0.1.79 → 0.1.81

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.
@@ -0,0 +1,138 @@
1
+ import { isAbsolute } from 'node:path'
2
+ import { resolveWithinBase } from './path-utils.js'
3
+
4
+ function requireString(value, fieldPath) {
5
+ if (typeof value !== 'string' || value.trim() === '') {
6
+ throw new Error(`缺少必填配置: ${fieldPath}`)
7
+ }
8
+ return value.trim()
9
+ }
10
+
11
+ function requirePositiveInteger(value, fieldPath) {
12
+ const parsed = Number(value)
13
+ if (!Number.isInteger(parsed) || parsed <= 0) {
14
+ throw new Error(`缺少必填配置: ${fieldPath}`)
15
+ }
16
+ return parsed
17
+ }
18
+
19
+ function resolveBuildCommand(buildConfig, environment) {
20
+ if (buildConfig?.commands && typeof buildConfig.commands === 'object') {
21
+ const selected = buildConfig.commands[environment]
22
+ if (!selected || typeof selected !== 'string' || selected.trim() === '') {
23
+ throw new Error(`缺少必填配置: build.commands.${environment}`)
24
+ }
25
+ return selected.trim()
26
+ }
27
+
28
+ return requireString(buildConfig?.command, 'build.command')
29
+ }
30
+
31
+ function resolveProjectPath(projectRoot, relativePath, fieldPath) {
32
+ return resolveWithinBase(projectRoot, requireString(relativePath, fieldPath), fieldPath)
33
+ }
34
+
35
+ function requireRemoteBaseDir(value, fieldPath) {
36
+ const baseDir = requireString(value, fieldPath)
37
+ if (!isAbsolute(baseDir)) {
38
+ throw new Error(`${fieldPath} 必须是绝对路径: ${baseDir}`)
39
+ }
40
+ if (!/^\/[A-Za-z0-9._/-]*$/.test(baseDir)) {
41
+ throw new Error(`${fieldPath} 包含非法字符: ${baseDir}`)
42
+ }
43
+ return baseDir.replace(/\/+$/, '') || '/'
44
+ }
45
+
46
+ export function resolveBackendDeployConfig({ cli, targetConfig, environment, flags = {} }) {
47
+ const deployConfig = targetConfig?.backendDeploy
48
+ if (!deployConfig || typeof deployConfig !== 'object') {
49
+ throw new Error('缺少必填配置: backendDeploy')
50
+ }
51
+
52
+ const buildConfig = deployConfig.build || {}
53
+ const runtimeConfig = deployConfig.runtime || {}
54
+ const artifactConfig = deployConfig.artifact || {}
55
+ const remoteConfig = deployConfig.remote || null
56
+ const startupConfig = deployConfig.startup || {}
57
+ const runConfig = deployConfig.deploy || {}
58
+ const buildOnly = Boolean(flags.buildOnly)
59
+ const startupMode = String(startupConfig.mode || 'pm2').trim()
60
+ const prismaGenerate = runConfig.prismaGenerate !== false
61
+ const prismaMigrateDeploy = runConfig.prismaMigrateDeploy !== false
62
+
63
+ const normalized = {
64
+ projectRoot: cli.projectRoot,
65
+ environment,
66
+ build: {
67
+ app: typeof buildConfig.app === 'string' && buildConfig.app.trim() ? buildConfig.app.trim() : null,
68
+ command: resolveBuildCommand(buildConfig, environment),
69
+ distDir: resolveProjectPath(cli.projectRoot, buildConfig.distDir, 'build.distDir'),
70
+ versionFile: resolveProjectPath(cli.projectRoot, buildConfig.versionFile, 'build.versionFile'),
71
+ },
72
+ runtime: {
73
+ appPackage: resolveProjectPath(cli.projectRoot, runtimeConfig.appPackage, 'runtime.appPackage'),
74
+ rootPackage: resolveProjectPath(cli.projectRoot, runtimeConfig.rootPackage, 'runtime.rootPackage'),
75
+ lockfile: resolveProjectPath(cli.projectRoot, runtimeConfig.lockfile, 'runtime.lockfile'),
76
+ prismaSchemaDir: runtimeConfig.prismaSchemaDir
77
+ ? resolveProjectPath(cli.projectRoot, runtimeConfig.prismaSchemaDir, 'runtime.prismaSchemaDir')
78
+ : null,
79
+ prismaConfig: runtimeConfig.prismaConfig
80
+ ? resolveProjectPath(cli.projectRoot, runtimeConfig.prismaConfig, 'runtime.prismaConfig')
81
+ : null,
82
+ ecosystemConfig: runtimeConfig.ecosystemConfig
83
+ ? resolveProjectPath(cli.projectRoot, runtimeConfig.ecosystemConfig, 'runtime.ecosystemConfig')
84
+ : null,
85
+ },
86
+ artifact: {
87
+ outputDir: resolveProjectPath(cli.projectRoot, artifactConfig.outputDir, 'artifact.outputDir'),
88
+ bundleName: requireString(artifactConfig.bundleName, 'artifact.bundleName'),
89
+ },
90
+ remote: buildOnly
91
+ ? null
92
+ : {
93
+ host: requireString(remoteConfig?.host, 'remote.host'),
94
+ port: remoteConfig?.port == null ? 22 : requirePositiveInteger(remoteConfig.port, 'remote.port'),
95
+ user: requireString(remoteConfig?.user, 'remote.user'),
96
+ baseDir: requireRemoteBaseDir(remoteConfig?.baseDir, 'remote.baseDir'),
97
+ },
98
+ startup: {
99
+ mode: startupMode,
100
+ serviceName:
101
+ typeof startupConfig.serviceName === 'string' && startupConfig.serviceName.trim()
102
+ ? startupConfig.serviceName.trim()
103
+ : null,
104
+ entry:
105
+ typeof startupConfig.entry === 'string' && startupConfig.entry.trim()
106
+ ? startupConfig.entry.trim()
107
+ : null,
108
+ },
109
+ deploy: {
110
+ keepReleases:
111
+ runConfig.keepReleases == null ? 5 : requirePositiveInteger(runConfig.keepReleases, 'deploy.keepReleases'),
112
+ installCommand: requireString(
113
+ runConfig.installCommand || 'pnpm install --prod --no-frozen-lockfile --ignore-workspace',
114
+ 'deploy.installCommand',
115
+ ),
116
+ prismaGenerate,
117
+ prismaMigrateDeploy,
118
+ skipMigration: Boolean(flags.skipMigration),
119
+ },
120
+ }
121
+
122
+ if (!['pm2', 'direct'].includes(normalized.startup.mode)) {
123
+ throw new Error('缺少必填配置: startup.mode')
124
+ }
125
+ if (normalized.startup.mode === 'pm2') {
126
+ requireString(normalized.startup.serviceName, 'startup.serviceName')
127
+ requireString(normalized.runtime.ecosystemConfig, 'runtime.ecosystemConfig')
128
+ } else {
129
+ requireString(normalized.startup.entry, 'startup.entry')
130
+ }
131
+
132
+ if (normalized.deploy.prismaGenerate || normalized.deploy.prismaMigrateDeploy) {
133
+ requireString(normalized.runtime.prismaSchemaDir, 'runtime.prismaSchemaDir')
134
+ requireString(normalized.runtime.prismaConfig, 'runtime.prismaConfig')
135
+ }
136
+
137
+ return normalized
138
+ }
@@ -0,0 +1,18 @@
1
+ import { basename, resolve, sep } from 'node:path'
2
+
3
+ export function resolveWithinBase(baseDir, targetPath, label = 'path') {
4
+ const absoluteBase = resolve(baseDir)
5
+ const absoluteTarget = resolve(absoluteBase, targetPath)
6
+ if (absoluteTarget !== absoluteBase && !absoluteTarget.startsWith(`${absoluteBase}${sep}`)) {
7
+ throw new Error(`${label} 越界,已拒绝: ${absoluteTarget}`)
8
+ }
9
+ return absoluteTarget
10
+ }
11
+
12
+ export function basenameOrThrow(filePath, label = 'path') {
13
+ const name = basename(String(filePath || '').trim())
14
+ if (!name || name === '.' || name === '..') {
15
+ throw new Error(`无效的 ${label}: ${filePath}`)
16
+ }
17
+ return name
18
+ }
@@ -0,0 +1,13 @@
1
+ export function createRemotePhaseModel(payload) {
2
+ return [
3
+ { phase: 'lock', payload },
4
+ { phase: 'extract', payload },
5
+ { phase: 'env', payload },
6
+ { phase: 'install', payload },
7
+ { phase: 'prisma-generate', payload },
8
+ { phase: 'prisma-migrate', payload },
9
+ { phase: 'switch-current', payload },
10
+ { phase: 'startup', payload },
11
+ { phase: 'cleanup', payload },
12
+ ]
13
+ }
@@ -0,0 +1,42 @@
1
+ function parseResultLine(line) {
2
+ if (!line.startsWith('DX_REMOTE_RESULT=')) return null
3
+ return JSON.parse(line.slice('DX_REMOTE_RESULT='.length))
4
+ }
5
+
6
+ function getLastPhase(output = '') {
7
+ const lines = String(output).split('\n')
8
+ let phase = 'cleanup'
9
+ for (const line of lines) {
10
+ if (line.startsWith('DX_REMOTE_PHASE=')) {
11
+ phase = line.slice('DX_REMOTE_PHASE='.length).trim() || phase
12
+ }
13
+ }
14
+ return phase
15
+ }
16
+
17
+ export function parseRemoteResult({ stdout = '', stderr = '', exitCode = 0 }) {
18
+ const allLines = `${stdout}\n${stderr}`.trim().split('\n').filter(Boolean)
19
+ for (let index = allLines.length - 1; index >= 0; index -= 1) {
20
+ const parsed = parseResultLine(allLines[index])
21
+ if (parsed) return parsed
22
+ }
23
+
24
+ if (exitCode === 0) {
25
+ return {
26
+ ok: true,
27
+ phase: getLastPhase(stdout),
28
+ message: 'ok',
29
+ rollbackAttempted: false,
30
+ rollbackSucceeded: null,
31
+ }
32
+ }
33
+
34
+ const message = [stderr, stdout].filter(Boolean).join('\n').trim() || 'remote execution failed'
35
+ return {
36
+ ok: false,
37
+ phase: getLastPhase(`${stdout}\n${stderr}`),
38
+ message,
39
+ rollbackAttempted: false,
40
+ rollbackSucceeded: null,
41
+ }
42
+ }
@@ -0,0 +1,337 @@
1
+ function escapeShell(value) {
2
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
3
+ }
4
+
5
+ export function buildRemoteDeployScript(phaseModel = []) {
6
+ const payload = phaseModel[0]?.payload || {}
7
+ const remote = payload.remote || {}
8
+ const runtime = payload.runtime || {}
9
+ const startup = payload.startup || {}
10
+ const deploy = payload.deploy || {}
11
+ const environment = String(payload.environment || 'production')
12
+ const baseDir = String(remote.baseDir || '.')
13
+ const releaseDir = `${baseDir}/releases/${payload.versionName || 'unknown'}`
14
+ const currentLink = `${baseDir}/current`
15
+ const uploadsDir = `${baseDir}/uploads`
16
+ const uploadedBundlePath = String(payload.uploadedBundlePath || '')
17
+ const envFileName = `.env.${environment}`
18
+ const envLocalFileName = `.env.${environment}.local`
19
+ const prismaSchema = runtime.prismaSchemaDir ? `./${runtime.prismaSchemaDir}` : ''
20
+ const prismaConfig = runtime.prismaConfig ? `./${runtime.prismaConfig}` : ''
21
+ const ecosystemConfig = runtime.ecosystemConfig ? `./${runtime.ecosystemConfig}` : './ecosystem.config.cjs'
22
+ const installCommand = String(deploy.installCommand || 'pnpm install --prod --no-frozen-lockfile --ignore-workspace')
23
+ const startupEntry = String(startup.entry || '')
24
+ const startupMode = String(startup.mode || 'pm2')
25
+ const serviceName = String(startup.serviceName || 'backend')
26
+ const keepReleases = Number(deploy.keepReleases || 5)
27
+ const shouldGenerate = deploy.prismaGenerate !== false
28
+ const shouldMigrate = deploy.prismaMigrateDeploy !== false && deploy.skipMigration !== true
29
+
30
+ return `#!/usr/bin/env bash
31
+ set -euo pipefail
32
+
33
+ APP_ROOT=${escapeShell(baseDir)}
34
+ UPLOADS_DIR=${escapeShell(uploadsDir)}
35
+ ARCHIVE=${escapeShell(uploadedBundlePath)}
36
+ RELEASE_DIR=${escapeShell(releaseDir)}
37
+ CURRENT_LINK=${escapeShell(currentLink)}
38
+ ENV_NAME=${escapeShell(environment)}
39
+ ENV_FILE_NAME=${escapeShell(envFileName)}
40
+ ENV_LOCAL_FILE_NAME=${escapeShell(envLocalFileName)}
41
+ PRISMA_SCHEMA=${escapeShell(prismaSchema)}
42
+ PRISMA_CONFIG=${escapeShell(prismaConfig)}
43
+ ECOSYSTEM_CONFIG=${escapeShell(ecosystemConfig)}
44
+ INSTALL_COMMAND=${escapeShell(installCommand)}
45
+ START_MODE=${escapeShell(startupMode)}
46
+ SERVICE_NAME=${escapeShell(serviceName)}
47
+ START_ENTRY=${escapeShell(startupEntry)}
48
+ KEEP_RELEASES=${keepReleases}
49
+ SHOULD_GENERATE=${shouldGenerate ? '1' : '0'}
50
+ SHOULD_MIGRATE=${shouldMigrate ? '1' : '0'}
51
+
52
+ LOCK_FILE="$APP_ROOT/.deploy.lock"
53
+ LOCK_DIR="$APP_ROOT/.deploy.lock.d"
54
+ SHARED_DIR="$APP_ROOT/shared"
55
+ RELEASES_DIR="$APP_ROOT/releases"
56
+ PREVIOUS_CURRENT_TARGET=""
57
+ BUNDLE_TEMP_DIR=""
58
+ INNER_ARCHIVE=""
59
+ INNER_ARCHIVE_SHA256_FILE=""
60
+ VERSION_NAME=""
61
+ DOTENV_BIN=""
62
+ PRISMA_BIN=""
63
+ CURRENT_PHASE="init"
64
+ RESULT_EMITTED=0
65
+ ROLLBACK_ATTEMPTED=false
66
+ ROLLBACK_SUCCEEDED=null
67
+ MIGRATION_EXECUTED=0
68
+
69
+ emit_result() {
70
+ local ok="$1"
71
+ local phase="$2"
72
+ local message="$3"
73
+ local rollback_attempted="$4"
74
+ local rollback_succeeded="$5"
75
+ if [[ "$RESULT_EMITTED" -eq 1 ]]; then
76
+ return
77
+ fi
78
+ RESULT_EMITTED=1
79
+ message="\${message//\\\\/\\\\\\\\}"
80
+ message="\${message//\"/\\\\\"}"
81
+ message="\${message//$'\\n'/\\\\n}"
82
+ printf 'DX_REMOTE_RESULT={"ok":%s,"phase":"%s","message":"%s","rollbackAttempted":%s,"rollbackSucceeded":%s}\\n' \\
83
+ "$ok" "$phase" "$message" "$rollback_attempted" "$rollback_succeeded"
84
+ }
85
+
86
+ cleanup() {
87
+ rm -rf "$BUNDLE_TEMP_DIR" 2>/dev/null || true
88
+ if [[ -n "$LOCK_DIR" ]]; then
89
+ rmdir "$LOCK_DIR" 2>/dev/null || true
90
+ fi
91
+ }
92
+
93
+ on_error() {
94
+ local code=$?
95
+ emit_result false "$CURRENT_PHASE" "phase failed (exit $code)" "$ROLLBACK_ATTEMPTED" "$ROLLBACK_SUCCEEDED"
96
+ exit "$code"
97
+ }
98
+
99
+ trap cleanup EXIT
100
+ trap on_error ERR
101
+
102
+ validate_path_within_base() {
103
+ local base="$1"
104
+ local target="$2"
105
+ case "$target" in
106
+ "$base"/*|"$base") ;;
107
+ *)
108
+ echo "目标路径越界: $target" >&2
109
+ exit 1
110
+ ;;
111
+ esac
112
+ }
113
+
114
+ validate_archive_entries() {
115
+ local archive="$1"
116
+ local entry
117
+ local tar_line
118
+ local link_target
119
+
120
+ while IFS= read -r entry; do
121
+ if [[ "$entry" == /* ]]; then
122
+ echo "包含绝对路径条目: $entry" >&2
123
+ exit 1
124
+ fi
125
+ if [[ "$entry" =~ (^|/)\\.\\.(/|$) || "$entry" =~ \\.\\.\\\\ ]]; then
126
+ echo "包含可疑路径条目: $entry" >&2
127
+ exit 1
128
+ fi
129
+ done < <(tar -tzf "$archive")
130
+
131
+ while IFS= read -r tar_line; do
132
+ if [[ "$tar_line" == *" -> "* ]]; then
133
+ link_target="\${tar_line##* -> }"
134
+ if [[ "$link_target" == /* ]]; then
135
+ echo "包含可疑链接目标: $link_target" >&2
136
+ exit 1
137
+ fi
138
+ if [[ "$link_target" =~ (^|/)\\.\\.(/|$) || "$link_target" =~ \\.\\.\\\\ ]]; then
139
+ echo "包含可疑链接目标: $link_target" >&2
140
+ exit 1
141
+ fi
142
+ fi
143
+ done < <(tar -tvzf "$archive")
144
+ }
145
+
146
+ find_single_bundle_file() {
147
+ local bundle_dir="$1"
148
+ local pattern="$2"
149
+ shopt -s nullglob
150
+ local matches=("$bundle_dir"/$pattern)
151
+ shopt -u nullglob
152
+ if [[ "\${#matches[@]}" -ne 1 ]]; then
153
+ return 1
154
+ fi
155
+ printf '%s\\n' "\${matches[0]}"
156
+ }
157
+
158
+ sha256_check() {
159
+ local checksum_file="$1"
160
+ if command -v sha256sum >/dev/null 2>&1; then
161
+ sha256sum -c "$checksum_file"
162
+ return
163
+ fi
164
+ local checksum expected file
165
+ checksum="$(awk '{print $1}' "$checksum_file")"
166
+ file="$(awk '{print $2}' "$checksum_file")"
167
+ expected="$(shasum -a 256 "$file" | awk '{print $1}')"
168
+ [[ "$checksum" == "$expected" ]]
169
+ }
170
+
171
+ run_with_env() {
172
+ local cwd="$1"
173
+ shift
174
+ (
175
+ cd "$cwd"
176
+ APP_ENV="$ENV_NAME" "$DOTENV_BIN" -o -e "$ENV_FILE_NAME" -e "$ENV_LOCAL_FILE_NAME" -- "$@"
177
+ )
178
+ }
179
+
180
+ attempt_pm2_restore() {
181
+ if [[ -z "$PREVIOUS_CURRENT_TARGET" || ! -e "$PREVIOUS_CURRENT_TARGET/$ECOSYSTEM_CONFIG" ]]; then
182
+ ROLLBACK_SUCCEEDED=false
183
+ return
184
+ fi
185
+ if (
186
+ cd "$PREVIOUS_CURRENT_TARGET"
187
+ APP_ENV="$ENV_NAME" "$DOTENV_BIN" -o -e "$ENV_FILE_NAME" -e "$ENV_LOCAL_FILE_NAME" -- \\
188
+ pm2 start "$ECOSYSTEM_CONFIG" --only "$SERVICE_NAME" --update-env
189
+ pm2 save
190
+ ); then
191
+ ROLLBACK_SUCCEEDED=true
192
+ else
193
+ ROLLBACK_SUCCEEDED=false
194
+ fi
195
+ }
196
+
197
+ CURRENT_PHASE="lock"
198
+ echo "DX_REMOTE_PHASE=lock"
199
+ mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$UPLOADS_DIR"
200
+ validate_path_within_base "$APP_ROOT" "$ARCHIVE"
201
+ validate_path_within_base "$APP_ROOT" "$RELEASE_DIR"
202
+
203
+ PREVIOUS_CURRENT_TARGET="$(readlink "$CURRENT_LINK" 2>/dev/null || true)"
204
+
205
+ if command -v flock >/dev/null 2>&1; then
206
+ exec 9>"$LOCK_FILE"
207
+ flock -n 9
208
+ else
209
+ mkdir "$LOCK_DIR"
210
+ fi
211
+
212
+ CURRENT_PHASE="extract"
213
+ echo "DX_REMOTE_PHASE=extract"
214
+ validate_archive_entries "$ARCHIVE"
215
+ BUNDLE_TEMP_DIR="$(mktemp -d "$APP_ROOT/.bundle-extract.XXXXXX")"
216
+ tar -xzf "$ARCHIVE" -C "$BUNDLE_TEMP_DIR" --strip-components=1
217
+
218
+ INNER_ARCHIVE="$(find_single_bundle_file "$BUNDLE_TEMP_DIR" 'backend-v*.tgz')"
219
+ INNER_ARCHIVE_SHA256_FILE="$(find_single_bundle_file "$BUNDLE_TEMP_DIR" 'backend-v*.tgz.sha256')"
220
+ VERSION_NAME="$(basename "$INNER_ARCHIVE" .tgz)"
221
+ validate_path_within_base "$RELEASES_DIR" "$RELEASE_DIR"
222
+
223
+ (cd "$BUNDLE_TEMP_DIR" && sha256_check "$(basename "$INNER_ARCHIVE_SHA256_FILE")")
224
+ validate_archive_entries "$INNER_ARCHIVE"
225
+ rm -rf "$RELEASE_DIR"
226
+ mkdir -p "$RELEASE_DIR"
227
+ tar -xzf "$INNER_ARCHIVE" -C "$RELEASE_DIR" --strip-components=1
228
+
229
+ CURRENT_PHASE="env"
230
+ echo "DX_REMOTE_PHASE=env"
231
+ if [[ ! -f "$SHARED_DIR/$ENV_FILE_NAME" ]]; then
232
+ echo "未找到基础环境文件: $SHARED_DIR/$ENV_FILE_NAME" >&2
233
+ exit 1
234
+ fi
235
+ if [[ ! -f "$SHARED_DIR/$ENV_LOCAL_FILE_NAME" ]]; then
236
+ echo "未找到本地覆盖环境文件: $SHARED_DIR/$ENV_LOCAL_FILE_NAME" >&2
237
+ exit 1
238
+ fi
239
+ ln -sfn "$SHARED_DIR/$ENV_FILE_NAME" "$RELEASE_DIR/$ENV_FILE_NAME"
240
+ ln -sfn "$SHARED_DIR/$ENV_LOCAL_FILE_NAME" "$RELEASE_DIR/$ENV_LOCAL_FILE_NAME"
241
+
242
+ CURRENT_PHASE="install"
243
+ echo "DX_REMOTE_PHASE=install"
244
+ command -v node >/dev/null 2>&1
245
+ command -v pnpm >/dev/null 2>&1
246
+ if [[ "$START_MODE" == "pm2" ]]; then
247
+ command -v pm2 >/dev/null 2>&1
248
+ fi
249
+ (
250
+ cd "$RELEASE_DIR"
251
+ bash -lc "$INSTALL_COMMAND"
252
+ )
253
+
254
+ DOTENV_BIN="$RELEASE_DIR/node_modules/.bin/dotenv"
255
+ if [[ ! -x "$DOTENV_BIN" ]]; then
256
+ echo "缺少可执行文件: $DOTENV_BIN" >&2
257
+ exit 1
258
+ fi
259
+
260
+ if [[ "$SHOULD_GENERATE" == "1" ]]; then
261
+ CURRENT_PHASE="prisma-generate"
262
+ echo "DX_REMOTE_PHASE=prisma-generate"
263
+ PRISMA_BIN="$RELEASE_DIR/node_modules/.bin/prisma"
264
+ if [[ ! -x "$PRISMA_BIN" ]]; then
265
+ echo "缺少可执行文件: $PRISMA_BIN" >&2
266
+ exit 1
267
+ fi
268
+ run_with_env "$RELEASE_DIR" "$PRISMA_BIN" generate --schema="$PRISMA_SCHEMA" --config="$PRISMA_CONFIG"
269
+ fi
270
+
271
+ if [[ "$SHOULD_MIGRATE" == "1" ]]; then
272
+ CURRENT_PHASE="prisma-migrate"
273
+ echo "DX_REMOTE_PHASE=prisma-migrate"
274
+ PRISMA_BIN="$RELEASE_DIR/node_modules/.bin/prisma"
275
+ if [[ ! -x "$PRISMA_BIN" ]]; then
276
+ echo "缺少可执行文件: $PRISMA_BIN" >&2
277
+ exit 1
278
+ fi
279
+ run_with_env "$RELEASE_DIR" "$PRISMA_BIN" migrate deploy --schema="$PRISMA_SCHEMA" --config="$PRISMA_CONFIG"
280
+ MIGRATION_EXECUTED=1
281
+ fi
282
+
283
+ CURRENT_PHASE="switch-current"
284
+ echo "DX_REMOTE_PHASE=switch-current"
285
+ ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
286
+
287
+ CURRENT_PHASE="startup"
288
+ echo "DX_REMOTE_PHASE=startup"
289
+ if [[ "$START_MODE" == "pm2" ]]; then
290
+ if ! (
291
+ cd "$CURRENT_LINK"
292
+ pm2 delete "$SERVICE_NAME" || true
293
+ APP_ENV="$ENV_NAME" "$DOTENV_BIN" -o -e "$ENV_FILE_NAME" -e "$ENV_LOCAL_FILE_NAME" -- \\
294
+ pm2 start "$ECOSYSTEM_CONFIG" --only "$SERVICE_NAME" --update-env
295
+ pm2 save
296
+ ); then
297
+ if [[ "$MIGRATION_EXECUTED" -eq 0 && -n "$PREVIOUS_CURRENT_TARGET" ]]; then
298
+ ROLLBACK_ATTEMPTED=true
299
+ ln -sfn "$PREVIOUS_CURRENT_TARGET" "$CURRENT_LINK"
300
+ attempt_pm2_restore
301
+ fi
302
+ emit_result false "startup" "pm2 startup failed" "$ROLLBACK_ATTEMPTED" "$ROLLBACK_SUCCEEDED"
303
+ exit 1
304
+ fi
305
+ else
306
+ if ! (
307
+ cd "$CURRENT_LINK"
308
+ APP_ENV="$ENV_NAME" "$DOTENV_BIN" -o -e "$ENV_FILE_NAME" -e "$ENV_LOCAL_FILE_NAME" -- \\
309
+ node "$START_ENTRY"
310
+ ); then
311
+ emit_result false "startup" "direct startup failed" false null
312
+ exit 1
313
+ fi
314
+ emit_result true "startup" "direct mode attached" false null
315
+ exit 0
316
+ fi
317
+
318
+ CURRENT_PHASE="cleanup"
319
+ echo "DX_REMOTE_PHASE=cleanup"
320
+ release_count=0
321
+ shopt -s nullglob
322
+ release_dirs=("$RELEASES_DIR"/*)
323
+ shopt -u nullglob
324
+ while IFS= read -r old_release; do
325
+ release_count=$((release_count + 1))
326
+ if [[ "$release_count" -gt "$KEEP_RELEASES" ]]; then
327
+ rm -rf "$old_release"
328
+ fi
329
+ done < <(
330
+ if [[ "\${#release_dirs[@]}" -gt 0 ]]; then
331
+ ls -1dt "\${release_dirs[@]}"
332
+ fi
333
+ )
334
+
335
+ emit_result true "cleanup" "ok" false null
336
+ `
337
+ }
@@ -0,0 +1,118 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { basename, relative } from 'node:path'
3
+ import { buildRemoteDeployScript } from './remote-script.js'
4
+ import { createRemotePhaseModel } from './remote-phases.js'
5
+ import { parseRemoteResult } from './remote-result.js'
6
+
7
+ function runProcess(command, args, options = {}) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn(command, args, {
10
+ stdio: ['pipe', 'pipe', 'pipe'],
11
+ ...options,
12
+ })
13
+
14
+ let stdout = ''
15
+ let stderr = ''
16
+ child.stdout.on('data', chunk => {
17
+ stdout += String(chunk)
18
+ })
19
+ child.stderr.on('data', chunk => {
20
+ stderr += String(chunk)
21
+ })
22
+ child.on('error', reject)
23
+ child.on('close', exitCode => resolve({ stdout, stderr, exitCode }))
24
+ })
25
+ }
26
+
27
+ function escapeShellArg(value) {
28
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
29
+ }
30
+
31
+ function normalizeRemoteBaseDir(baseDir) {
32
+ return String(baseDir).replace(/\/+$/, '') || '/'
33
+ }
34
+
35
+ export function buildEnsureRemoteBaseDirsCommand(baseDir) {
36
+ const normalizedBaseDir = normalizeRemoteBaseDir(baseDir)
37
+ const directories = ['releases', 'shared', 'uploads'].map(name => `${normalizedBaseDir}/${name}`)
38
+ return `mkdir -p ${directories.map(escapeShellArg).join(' ')}`
39
+ }
40
+
41
+ async function defaultEnsureRemoteBaseDirs(remote) {
42
+ const target = `${remote.user}@${remote.host}`
43
+ const args = [
44
+ '-p',
45
+ String(remote.port || 22),
46
+ target,
47
+ buildEnsureRemoteBaseDirsCommand(remote.baseDir),
48
+ ]
49
+ const result = await runProcess('ssh', args)
50
+ if (result.exitCode !== 0) {
51
+ throw new Error(result.stderr || `ssh mkdir failed (${result.exitCode})`)
52
+ }
53
+ }
54
+
55
+ async function defaultUploadBundle(remote, bundlePath) {
56
+ const target = `${remote.user}@${remote.host}:${remote.baseDir}/uploads/${basename(bundlePath)}`
57
+ const result = await runProcess('scp', ['-P', String(remote.port || 22), bundlePath, target])
58
+ if (result.exitCode !== 0) {
59
+ throw new Error(result.stderr || `scp failed (${result.exitCode})`)
60
+ }
61
+ }
62
+
63
+ async function defaultRunRemoteScript(remote, script) {
64
+ const target = `${remote.user}@${remote.host}`
65
+ return new Promise((resolve, reject) => {
66
+ const child = spawn('ssh', ['-p', String(remote.port || 22), target, 'bash -s'], {
67
+ stdio: ['pipe', 'pipe', 'pipe'],
68
+ })
69
+ let stdout = ''
70
+ let stderr = ''
71
+ child.stdout.on('data', chunk => {
72
+ stdout += String(chunk)
73
+ })
74
+ child.stderr.on('data', chunk => {
75
+ stderr += String(chunk)
76
+ })
77
+ child.on('error', reject)
78
+ child.on('close', exitCode => resolve({ stdout, stderr, exitCode }))
79
+ child.stdin.write(script)
80
+ child.stdin.end()
81
+ })
82
+ }
83
+
84
+ function createRemotePayload(config, bundle) {
85
+ const toReleaseRelativePath = targetPath => {
86
+ if (!targetPath) return null
87
+ if (!config.projectRoot) return targetPath
88
+ return relative(config.projectRoot, targetPath).replace(/\\/g, '/')
89
+ }
90
+
91
+ return {
92
+ environment: config.environment,
93
+ versionName: bundle.versionName,
94
+ uploadedBundlePath: `${config.remote.baseDir}/uploads/${basename(bundle.bundlePath)}`,
95
+ remote: config.remote,
96
+ runtime: {
97
+ prismaSchemaDir: toReleaseRelativePath(config.runtime.prismaSchemaDir),
98
+ prismaConfig: toReleaseRelativePath(config.runtime.prismaConfig),
99
+ ecosystemConfig: config.runtime.ecosystemConfig ? basename(config.runtime.ecosystemConfig) : null,
100
+ },
101
+ startup: config.startup,
102
+ deploy: config.deploy,
103
+ }
104
+ }
105
+
106
+ export async function deployBackendArtifactRemotely(config, bundle, deps = {}) {
107
+ const ensureRemoteBaseDirs = deps.ensureRemoteBaseDirs || defaultEnsureRemoteBaseDirs
108
+ const uploadBundle = deps.uploadBundle || defaultUploadBundle
109
+ const runRemoteScript = deps.runRemoteScript || defaultRunRemoteScript
110
+
111
+ await ensureRemoteBaseDirs(config.remote)
112
+ await uploadBundle(config.remote, bundle.bundlePath)
113
+ const payload = createRemotePayload(config, bundle)
114
+ const phaseModel = createRemotePhaseModel(payload)
115
+ const script = buildRemoteDeployScript(phaseModel)
116
+ const commandResult = await runRemoteScript(config.remote, script)
117
+ return parseRemoteResult(commandResult)
118
+ }
@@ -0,0 +1,5 @@
1
+ export function shouldAttemptRollback({ migrationExecuted, startupMode }) {
2
+ if (migrationExecuted) return false
3
+ if (startupMode === 'direct') return false
4
+ return true
5
+ }