@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,507 @@
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 verify = payload.verify || {}
12
+ const healthCheck = verify.healthCheck || null
13
+ const environment = String(payload.environment || 'production')
14
+ const expectedAppEnv = environment
15
+ const expectedNodeEnv = environment === 'development' ? 'development' : 'production'
16
+ const baseDir = String(remote.baseDir || '.')
17
+ const releaseDir = `${baseDir}/releases/${payload.versionName || 'unknown'}`
18
+ const currentLink = `${baseDir}/current`
19
+ const uploadsDir = `${baseDir}/uploads`
20
+ const uploadedBundlePath = String(payload.uploadedBundlePath || '')
21
+ const envFileName = `.env.${environment}`
22
+ const envLocalFileName = `.env.${environment}.local`
23
+ const prismaSchema = runtime.prismaSchemaDir ? `./${runtime.prismaSchemaDir}` : ''
24
+ const prismaConfig = runtime.prismaConfig ? `./${runtime.prismaConfig}` : ''
25
+ const ecosystemConfig = runtime.ecosystemConfig ? `./${runtime.ecosystemConfig}` : './ecosystem.config.cjs'
26
+ const installCommand = String(deploy.installCommand || 'pnpm install --prod --no-frozen-lockfile --ignore-workspace')
27
+ const startupEntry = String(startup.entry || '')
28
+ const startupMode = String(startup.mode || 'pm2')
29
+ const serviceName = String(startup.serviceName || 'backend')
30
+ const keepReleases = Number(deploy.keepReleases || 5)
31
+ const shouldGenerate = deploy.prismaGenerate !== false
32
+ const shouldMigrate = deploy.prismaMigrateDeploy !== false && deploy.skipMigration !== true
33
+ const shouldSeed = deploy.prismaSeed === true
34
+ const healthCheckUrl = healthCheck?.url ? String(healthCheck.url) : ''
35
+ const healthCheckEnvPort = healthCheck?.envPort ? String(healthCheck.envPort) : ''
36
+ const healthCheckPath = healthCheck?.path ? String(healthCheck.path) : ''
37
+ const healthCheckTimeoutSeconds = Number(healthCheck?.timeoutSeconds || 10)
38
+ const healthCheckMaxWaitSeconds = Number(healthCheck?.maxWaitSeconds || 24)
39
+ const healthCheckRetryIntervalSeconds = Number(healthCheck?.retryIntervalSeconds || 2)
40
+
41
+ return `#!/usr/bin/env bash
42
+ set -euo pipefail
43
+
44
+ APP_ROOT=${escapeShell(baseDir)}
45
+ UPLOADS_DIR=${escapeShell(uploadsDir)}
46
+ ARCHIVE=${escapeShell(uploadedBundlePath)}
47
+ RELEASE_DIR=${escapeShell(releaseDir)}
48
+ CURRENT_LINK=${escapeShell(currentLink)}
49
+ ENV_NAME=${escapeShell(environment)}
50
+ EXPECTED_APP_ENV=${escapeShell(expectedAppEnv)}
51
+ EXPECTED_NODE_ENV=${escapeShell(expectedNodeEnv)}
52
+ ENV_FILE_NAME=${escapeShell(envFileName)}
53
+ ENV_LOCAL_FILE_NAME=${escapeShell(envLocalFileName)}
54
+ PRISMA_SCHEMA=${escapeShell(prismaSchema)}
55
+ PRISMA_CONFIG=${escapeShell(prismaConfig)}
56
+ ECOSYSTEM_CONFIG=${escapeShell(ecosystemConfig)}
57
+ INSTALL_COMMAND=${escapeShell(installCommand)}
58
+ START_MODE=${escapeShell(startupMode)}
59
+ SERVICE_NAME=${escapeShell(serviceName)}
60
+ START_ENTRY=${escapeShell(startupEntry)}
61
+ HEALTHCHECK_URL=${escapeShell(healthCheckUrl)}
62
+ HEALTHCHECK_ENV_PORT=${escapeShell(healthCheckEnvPort)}
63
+ HEALTHCHECK_PATH=${escapeShell(healthCheckPath)}
64
+ HEALTHCHECK_TIMEOUT_SECONDS=${healthCheckTimeoutSeconds}
65
+ HEALTHCHECK_MAX_WAIT_SECONDS=${healthCheckMaxWaitSeconds}
66
+ HEALTHCHECK_RETRY_DELAY_SECONDS=${healthCheckRetryIntervalSeconds}
67
+ KEEP_RELEASES=${keepReleases}
68
+ SHOULD_GENERATE=${shouldGenerate ? '1' : '0'}
69
+ SHOULD_MIGRATE=${shouldMigrate ? '1' : '0'}
70
+ SHOULD_SEED=${shouldSeed ? '1' : '0'}
71
+
72
+ LOCK_FILE="$APP_ROOT/.deploy.lock"
73
+ LOCK_DIR="$APP_ROOT/.deploy.lock.d"
74
+ SHARED_DIR="$APP_ROOT/shared"
75
+ RELEASES_DIR="$APP_ROOT/releases"
76
+ PREVIOUS_CURRENT_TARGET=""
77
+ BUNDLE_TEMP_DIR=""
78
+ INNER_ARCHIVE=""
79
+ INNER_ARCHIVE_SHA256_FILE=""
80
+ VERSION_NAME=""
81
+ DOTENV_BIN=""
82
+ PRISMA_BIN=""
83
+ CURRENT_PHASE="init"
84
+ RESULT_EMITTED=0
85
+ ROLLBACK_ATTEMPTED=false
86
+ ROLLBACK_SUCCEEDED=null
87
+ MIGRATION_EXECUTED=0
88
+
89
+ emit_result() {
90
+ local ok="$1"
91
+ local phase="$2"
92
+ local message="$3"
93
+ local rollback_attempted="$4"
94
+ local rollback_succeeded="$5"
95
+ local summary_json="\${6:-null}"
96
+ if [[ "$RESULT_EMITTED" -eq 1 ]]; then
97
+ return
98
+ fi
99
+ RESULT_EMITTED=1
100
+ message="\${message//\\\\/\\\\\\\\}"
101
+ message="\${message//\"/\\\\\"}"
102
+ message="\${message//$'\\n'/\\\\n}"
103
+ printf 'DX_REMOTE_RESULT={"ok":%s,"phase":"%s","message":"%s","rollbackAttempted":%s,"rollbackSucceeded":%s,"summary":%s}\\n' \\
104
+ "$ok" "$phase" "$message" "$rollback_attempted" "$rollback_succeeded" "$summary_json"
105
+ }
106
+
107
+ cleanup() {
108
+ rm -rf "$BUNDLE_TEMP_DIR" 2>/dev/null || true
109
+ if [[ -n "$LOCK_DIR" ]]; then
110
+ rmdir "$LOCK_DIR" 2>/dev/null || true
111
+ fi
112
+ }
113
+
114
+ on_error() {
115
+ local code=$?
116
+ emit_result false "$CURRENT_PHASE" "phase failed (exit $code)" "$ROLLBACK_ATTEMPTED" "$ROLLBACK_SUCCEEDED"
117
+ exit "$code"
118
+ }
119
+
120
+ trap cleanup EXIT
121
+ trap on_error ERR
122
+
123
+ validate_path_within_base() {
124
+ local base="$1"
125
+ local target="$2"
126
+ case "$target" in
127
+ "$base"/*|"$base") ;;
128
+ *)
129
+ echo "目标路径越界: $target" >&2
130
+ exit 1
131
+ ;;
132
+ esac
133
+ }
134
+
135
+ validate_archive_entries() {
136
+ local archive="$1"
137
+ local entry
138
+ local tar_line
139
+ local link_target
140
+
141
+ while IFS= read -r entry; do
142
+ if [[ "$entry" == /* ]]; then
143
+ echo "包含绝对路径条目: $entry" >&2
144
+ exit 1
145
+ fi
146
+ if [[ "$entry" =~ (^|/)\\.\\.(/|$) || "$entry" =~ \\.\\.\\\\ ]]; then
147
+ echo "包含可疑路径条目: $entry" >&2
148
+ exit 1
149
+ fi
150
+ done < <(tar -tzf "$archive")
151
+
152
+ while IFS= read -r tar_line; do
153
+ if [[ "$tar_line" == *" -> "* ]]; then
154
+ link_target="\${tar_line##* -> }"
155
+ if [[ "$link_target" == /* ]]; then
156
+ echo "包含可疑链接目标: $link_target" >&2
157
+ exit 1
158
+ fi
159
+ if [[ "$link_target" =~ (^|/)\\.\\.(/|$) || "$link_target" =~ \\.\\.\\\\ ]]; then
160
+ echo "包含可疑链接目标: $link_target" >&2
161
+ exit 1
162
+ fi
163
+ fi
164
+ done < <(tar -tvzf "$archive")
165
+ }
166
+
167
+ find_single_bundle_file() {
168
+ local bundle_dir="$1"
169
+ local pattern="$2"
170
+ shopt -s nullglob
171
+ local matches=("$bundle_dir"/$pattern)
172
+ shopt -u nullglob
173
+ if [[ "\${#matches[@]}" -ne 1 ]]; then
174
+ return 1
175
+ fi
176
+ printf '%s\\n' "\${matches[0]}"
177
+ }
178
+
179
+ sha256_check() {
180
+ local checksum_file="$1"
181
+ local checksum file actual
182
+ checksum="$(awk '{print $1}' "$checksum_file")"
183
+ file="$(awk '{print $2}' "$checksum_file")"
184
+ file="$(basename "$file")"
185
+ if command -v sha256sum >/dev/null 2>&1; then
186
+ actual="$(sha256sum "$file" | awk '{print $1}')"
187
+ else
188
+ actual="$(shasum -a 256 "$file" | awk '{print $1}')"
189
+ fi
190
+ [[ "$checksum" == "$actual" ]]
191
+ }
192
+
193
+ run_with_env() {
194
+ local cwd="$1"
195
+ shift
196
+ (
197
+ cd "$cwd"
198
+ APP_ENV="$EXPECTED_APP_ENV" NODE_ENV="$EXPECTED_NODE_ENV" \\
199
+ "$DOTENV_BIN" -o -e "$ENV_FILE_NAME" -e "$ENV_LOCAL_FILE_NAME" -- "$@"
200
+ )
201
+ }
202
+
203
+ json_escape() {
204
+ local value="\${1-}"
205
+ value="\${value//\\\\/\\\\\\\\}"
206
+ value="\${value//\"/\\\\\"}"
207
+ value="\${value//$'\\n'/\\\\n}"
208
+ value="\${value//$'\\r'/\\\\r}"
209
+ value="\${value//$'\\t'/\\\\t}"
210
+ printf '%s' "$value"
211
+ }
212
+
213
+ read_pm2_env_var() {
214
+ local key="$1"
215
+ pm2 jlist | node -e '
216
+ const fs = require("node:fs")
217
+ const key = process.argv[1]
218
+ const list = JSON.parse(fs.readFileSync(0, "utf8"))
219
+ const app = list.find(item => item?.name === process.argv[2])
220
+ process.stdout.write(String(app?.pm2_env?.[key] || ""))
221
+ ' "$key" "$SERVICE_NAME"
222
+ }
223
+
224
+ read_pm2_status() {
225
+ pm2 jlist | node -e '
226
+ const fs = require("node:fs")
227
+ const list = JSON.parse(fs.readFileSync(0, "utf8"))
228
+ const app = list.find(item => item?.name === process.argv[1])
229
+ process.stdout.write(String(app?.pm2_env?.status || ""))
230
+ ' "$SERVICE_NAME"
231
+ }
232
+
233
+ resolve_healthcheck_url() {
234
+ if [[ -n "$HEALTHCHECK_URL" ]]; then
235
+ printf '%s\n' "$HEALTHCHECK_URL"
236
+ return
237
+ fi
238
+
239
+ if [[ -z "$HEALTHCHECK_ENV_PORT" ]]; then
240
+ return
241
+ fi
242
+
243
+ local healthcheck_port
244
+ healthcheck_port="$(run_with_env "$CURRENT_LINK" env | awk -F= -v key="$HEALTHCHECK_ENV_PORT" '$1 == key { print substr($0, index($0, "=") + 1); exit }')"
245
+ if ! [[ "$healthcheck_port" =~ ^[0-9]+$ ]]; then
246
+ echo "health check port env $HEALTHCHECK_ENV_PORT is missing or invalid" >&2
247
+ exit 1
248
+ fi
249
+
250
+ if [[ -z "$HEALTHCHECK_PATH" ]]; then
251
+ HEALTHCHECK_PATH="/health"
252
+ fi
253
+ if [[ "$HEALTHCHECK_PATH" != /* ]]; then
254
+ HEALTHCHECK_PATH="/$HEALTHCHECK_PATH"
255
+ fi
256
+
257
+ HEALTHCHECK_URL="http://127.0.0.1:\${healthcheck_port}\${HEALTHCHECK_PATH}"
258
+ printf '%s\n' "$HEALTHCHECK_URL"
259
+ }
260
+
261
+ build_summary_json() {
262
+ local release_name="$1"
263
+ local current_release_path="$2"
264
+ local service_status="$3"
265
+ local app_env="$4"
266
+ local node_env="$5"
267
+ local health_url="$6"
268
+ printf '{"releaseName":"%s","currentRelease":"%s","serviceName":"%s","serviceStatus":"%s","appEnv":"%s","nodeEnv":"%s","healthUrl":"%s"}' \
269
+ "$(json_escape "$release_name")" \
270
+ "$(json_escape "$current_release_path")" \
271
+ "$(json_escape "$SERVICE_NAME")" \
272
+ "$(json_escape "$service_status")" \
273
+ "$(json_escape "$app_env")" \
274
+ "$(json_escape "$node_env")" \
275
+ "$(json_escape "$health_url")"
276
+ }
277
+
278
+ attempt_pm2_restore() {
279
+ if [[ -z "$PREVIOUS_CURRENT_TARGET" || ! -e "$PREVIOUS_CURRENT_TARGET/$ECOSYSTEM_CONFIG" ]]; then
280
+ ROLLBACK_SUCCEEDED=false
281
+ return
282
+ fi
283
+ if (
284
+ cd "$PREVIOUS_CURRENT_TARGET"
285
+ APP_ENV="$EXPECTED_APP_ENV" NODE_ENV="$EXPECTED_NODE_ENV" \\
286
+ "$DOTENV_BIN" -o -e "$ENV_FILE_NAME" -e "$ENV_LOCAL_FILE_NAME" -- \\
287
+ pm2 start "$ECOSYSTEM_CONFIG" --only "$SERVICE_NAME" --update-env
288
+ pm2 save
289
+ ); then
290
+ ROLLBACK_SUCCEEDED=true
291
+ else
292
+ ROLLBACK_SUCCEEDED=false
293
+ fi
294
+ }
295
+
296
+ CURRENT_PHASE="lock"
297
+ echo "DX_REMOTE_PHASE=lock"
298
+ mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$UPLOADS_DIR"
299
+ validate_path_within_base "$APP_ROOT" "$ARCHIVE"
300
+ validate_path_within_base "$APP_ROOT" "$RELEASE_DIR"
301
+
302
+ PREVIOUS_CURRENT_TARGET="$(readlink "$CURRENT_LINK" 2>/dev/null || true)"
303
+
304
+ if command -v flock >/dev/null 2>&1; then
305
+ exec 9>"$LOCK_FILE"
306
+ flock -n 9
307
+ else
308
+ mkdir "$LOCK_DIR"
309
+ fi
310
+
311
+ CURRENT_PHASE="extract"
312
+ echo "DX_REMOTE_PHASE=extract"
313
+ validate_archive_entries "$ARCHIVE"
314
+ BUNDLE_TEMP_DIR="$(mktemp -d "$APP_ROOT/.bundle-extract.XXXXXX")"
315
+ tar -xzf "$ARCHIVE" -C "$BUNDLE_TEMP_DIR"
316
+
317
+ INNER_ARCHIVE="$(find_single_bundle_file "$BUNDLE_TEMP_DIR" 'backend-v*.tgz')"
318
+ INNER_ARCHIVE_SHA256_FILE="$(find_single_bundle_file "$BUNDLE_TEMP_DIR" 'backend-v*.tgz.sha256')"
319
+ VERSION_NAME="$(basename "$INNER_ARCHIVE" .tgz)"
320
+ validate_path_within_base "$RELEASES_DIR" "$RELEASE_DIR"
321
+
322
+ (cd "$BUNDLE_TEMP_DIR" && sha256_check "$(basename "$INNER_ARCHIVE_SHA256_FILE")")
323
+ validate_archive_entries "$INNER_ARCHIVE"
324
+ rm -rf "$RELEASE_DIR"
325
+ mkdir -p "$RELEASE_DIR"
326
+ tar -xzf "$INNER_ARCHIVE" -C "$RELEASE_DIR" --strip-components=1
327
+
328
+ CURRENT_PHASE="env"
329
+ echo "DX_REMOTE_PHASE=env"
330
+ if [[ ! -f "$SHARED_DIR/$ENV_FILE_NAME" ]]; then
331
+ echo "未找到基础环境文件: $SHARED_DIR/$ENV_FILE_NAME" >&2
332
+ exit 1
333
+ fi
334
+ if [[ ! -f "$SHARED_DIR/$ENV_LOCAL_FILE_NAME" ]]; then
335
+ echo "未找到本地覆盖环境文件: $SHARED_DIR/$ENV_LOCAL_FILE_NAME" >&2
336
+ exit 1
337
+ fi
338
+ ln -sfn "$SHARED_DIR/$ENV_FILE_NAME" "$RELEASE_DIR/$ENV_FILE_NAME"
339
+ ln -sfn "$SHARED_DIR/$ENV_LOCAL_FILE_NAME" "$RELEASE_DIR/$ENV_LOCAL_FILE_NAME"
340
+
341
+ CURRENT_PHASE="install"
342
+ echo "DX_REMOTE_PHASE=install"
343
+ command -v node >/dev/null 2>&1
344
+ command -v pnpm >/dev/null 2>&1
345
+ if [[ "$START_MODE" == "pm2" ]]; then
346
+ command -v pm2 >/dev/null 2>&1
347
+ fi
348
+ (
349
+ cd "$RELEASE_DIR"
350
+ bash -lc "$INSTALL_COMMAND"
351
+ )
352
+
353
+ DOTENV_BIN="$RELEASE_DIR/node_modules/.bin/dotenv"
354
+ if [[ ! -x "$DOTENV_BIN" ]]; then
355
+ echo "缺少可执行文件: $DOTENV_BIN" >&2
356
+ exit 1
357
+ fi
358
+
359
+ if [[ "$SHOULD_GENERATE" == "1" ]]; then
360
+ CURRENT_PHASE="prisma-generate"
361
+ echo "DX_REMOTE_PHASE=prisma-generate"
362
+ PRISMA_BIN="$RELEASE_DIR/node_modules/.bin/prisma"
363
+ if [[ ! -x "$PRISMA_BIN" ]]; then
364
+ echo "缺少可执行文件: $PRISMA_BIN" >&2
365
+ exit 1
366
+ fi
367
+ run_with_env "$RELEASE_DIR" "$PRISMA_BIN" generate --schema="$PRISMA_SCHEMA" --config="$PRISMA_CONFIG"
368
+ fi
369
+
370
+ if [[ "$SHOULD_MIGRATE" == "1" ]]; then
371
+ CURRENT_PHASE="prisma-migrate"
372
+ echo "DX_REMOTE_PHASE=prisma-migrate"
373
+ PRISMA_BIN="$RELEASE_DIR/node_modules/.bin/prisma"
374
+ if [[ ! -x "$PRISMA_BIN" ]]; then
375
+ echo "缺少可执行文件: $PRISMA_BIN" >&2
376
+ exit 1
377
+ fi
378
+ run_with_env "$RELEASE_DIR" "$PRISMA_BIN" migrate deploy --schema="$PRISMA_SCHEMA" --config="$PRISMA_CONFIG"
379
+ MIGRATION_EXECUTED=1
380
+ fi
381
+
382
+ if [[ "$SHOULD_SEED" == "1" ]]; then
383
+ CURRENT_PHASE="prisma-seed"
384
+ echo "DX_REMOTE_PHASE=prisma-seed"
385
+ PRISMA_BIN="$RELEASE_DIR/node_modules/.bin/prisma"
386
+ if [[ ! -x "$PRISMA_BIN" ]]; then
387
+ echo "缺少可执行文件: $PRISMA_BIN" >&2
388
+ exit 1
389
+ fi
390
+ run_with_env "$RELEASE_DIR" "$PRISMA_BIN" db seed --schema="$PRISMA_SCHEMA" --config="$PRISMA_CONFIG"
391
+ fi
392
+
393
+ CURRENT_PHASE="switch-current"
394
+ echo "DX_REMOTE_PHASE=switch-current"
395
+ ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
396
+
397
+ CURRENT_PHASE="startup"
398
+ echo "DX_REMOTE_PHASE=startup"
399
+ if [[ "$START_MODE" == "pm2" ]]; then
400
+ if ! (
401
+ cd "$CURRENT_LINK"
402
+ pm2 delete "$SERVICE_NAME" || true
403
+ APP_ENV="$EXPECTED_APP_ENV" NODE_ENV="$EXPECTED_NODE_ENV" \\
404
+ "$DOTENV_BIN" -o -e "$ENV_FILE_NAME" -e "$ENV_LOCAL_FILE_NAME" -- \\
405
+ pm2 start "$ECOSYSTEM_CONFIG" --only "$SERVICE_NAME" --update-env
406
+ pm2 save
407
+ ); then
408
+ if [[ "$MIGRATION_EXECUTED" -eq 0 && -n "$PREVIOUS_CURRENT_TARGET" ]]; then
409
+ ROLLBACK_ATTEMPTED=true
410
+ ln -sfn "$PREVIOUS_CURRENT_TARGET" "$CURRENT_LINK"
411
+ attempt_pm2_restore
412
+ fi
413
+ emit_result false "startup" "pm2 startup failed" "$ROLLBACK_ATTEMPTED" "$ROLLBACK_SUCCEEDED"
414
+ exit 1
415
+ fi
416
+ else
417
+ if ! (
418
+ cd "$CURRENT_LINK"
419
+ APP_ENV="$EXPECTED_APP_ENV" NODE_ENV="$EXPECTED_NODE_ENV" \\
420
+ "$DOTENV_BIN" -o -e "$ENV_FILE_NAME" -e "$ENV_LOCAL_FILE_NAME" -- \\
421
+ node "$START_ENTRY"
422
+ ); then
423
+ emit_result false "startup" "direct startup failed" false null
424
+ exit 1
425
+ fi
426
+ emit_result true "startup" "direct mode attached" false null
427
+ exit 0
428
+ fi
429
+
430
+ CURRENT_PHASE="verify"
431
+ echo "DX_REMOTE_PHASE=verify"
432
+ if [[ ! -L "$CURRENT_LINK" ]]; then
433
+ echo "current 软链接不存在: $CURRENT_LINK" >&2
434
+ exit 1
435
+ fi
436
+
437
+ current_release="$(readlink -f "$CURRENT_LINK")"
438
+ expected_release="$(readlink -f "$RELEASE_DIR")"
439
+ if [[ -z "$current_release" || ! -d "$current_release" ]]; then
440
+ echo "current 软链接未指向有效目录: \${current_release:-<empty>}" >&2
441
+ exit 1
442
+ fi
443
+ if [[ "$current_release" != "$expected_release" ]]; then
444
+ echo "current 软链接未指向本次 release: expected=$expected_release actual=$current_release" >&2
445
+ exit 1
446
+ fi
447
+
448
+ if [[ "$START_MODE" == "pm2" ]]; then
449
+ if ! pm2 describe "$SERVICE_NAME" >/dev/null 2>&1; then
450
+ echo "PM2 进程不存在: $SERVICE_NAME" >&2
451
+ pm2 list || true
452
+ exit 1
453
+ fi
454
+
455
+ pm2_service_status="$(read_pm2_status)"
456
+ pm2_app_env="$(read_pm2_env_var APP_ENV)"
457
+ if [[ "$pm2_app_env" != "$EXPECTED_APP_ENV" ]]; then
458
+ echo "APP_ENV 不匹配,期望=$EXPECTED_APP_ENV,实际=\${pm2_app_env:-<empty>}" >&2
459
+ pm2 describe "$SERVICE_NAME" || true
460
+ exit 1
461
+ fi
462
+
463
+ pm2_node_env="$(read_pm2_env_var NODE_ENV)"
464
+ if [[ "$pm2_node_env" != "$EXPECTED_NODE_ENV" ]]; then
465
+ echo "NODE_ENV 不匹配,期望=$EXPECTED_NODE_ENV,实际=\${pm2_node_env:-<empty>}" >&2
466
+ pm2 describe "$SERVICE_NAME" || true
467
+ exit 1
468
+ fi
469
+ fi
470
+
471
+ HEALTHCHECK_URL="$(resolve_healthcheck_url)"
472
+
473
+ if [[ -n "$HEALTHCHECK_URL" ]]; then
474
+ healthcheck_started_at="$(date +%s)"
475
+ until curl -fsS --max-time "$HEALTHCHECK_TIMEOUT_SECONDS" "$HEALTHCHECK_URL" >/dev/null; do
476
+ healthcheck_elapsed_seconds=$(( $(date +%s) - healthcheck_started_at ))
477
+ if [[ "$healthcheck_elapsed_seconds" -ge "$HEALTHCHECK_MAX_WAIT_SECONDS" ]]; then
478
+ echo "health check failed within $HEALTHCHECK_MAX_WAIT_SECONDS seconds: $HEALTHCHECK_URL" >&2
479
+ exit 1
480
+ fi
481
+ sleep "$HEALTHCHECK_RETRY_DELAY_SECONDS"
482
+ done
483
+ fi
484
+
485
+ summary_service_status="\${pm2_service_status:-direct-attached}"
486
+ summary_json="$(build_summary_json "$VERSION_NAME" "$current_release" "$summary_service_status" "$EXPECTED_APP_ENV" "$EXPECTED_NODE_ENV" "$HEALTHCHECK_URL")"
487
+
488
+ CURRENT_PHASE="cleanup"
489
+ echo "DX_REMOTE_PHASE=cleanup"
490
+ release_count=0
491
+ shopt -s nullglob
492
+ release_dirs=("$RELEASES_DIR"/*)
493
+ shopt -u nullglob
494
+ while IFS= read -r old_release; do
495
+ release_count=$((release_count + 1))
496
+ if [[ "$release_count" -gt "$KEEP_RELEASES" ]]; then
497
+ rm -rf "$old_release"
498
+ fi
499
+ done < <(
500
+ if [[ "\${#release_dirs[@]}" -gt 0 ]]; then
501
+ ls -1dt "\${release_dirs[@]}"
502
+ fi
503
+ )
504
+
505
+ emit_result true "cleanup" "ok" false null "$summary_json"
506
+ `
507
+ }
@@ -0,0 +1,123 @@
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
+ verify: config.verify,
104
+ }
105
+ }
106
+
107
+ export async function deployBackendArtifactRemotely(config, bundle, deps = {}) {
108
+ const ensureRemoteBaseDirs = deps.ensureRemoteBaseDirs || defaultEnsureRemoteBaseDirs
109
+ const uploadBundle = deps.uploadBundle || defaultUploadBundle
110
+ const runRemoteScript = deps.runRemoteScript || defaultRunRemoteScript
111
+
112
+ await ensureRemoteBaseDirs(config.remote)
113
+ await uploadBundle(config.remote, bundle.bundlePath)
114
+ const payload = createRemotePayload(config, bundle)
115
+ const phaseModel = createRemotePhaseModel(payload)
116
+ const script = buildRemoteDeployScript(phaseModel)
117
+ const commandResult = await runRemoteScript(config.remote, script)
118
+ const result = parseRemoteResult(commandResult)
119
+ if (!result.ok) {
120
+ throw new Error(`远端部署失败(${result.phase}): ${result.message}`)
121
+ }
122
+ return result
123
+ }
@@ -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
+ }
@@ -0,0 +1,46 @@
1
+ const UNSUPPORTED_LOCAL_DEP_PATTERN = /^(workspace:|file:|link:)/
2
+ const REQUIRED_DEPENDENCIES = ['prisma', 'tslib', 'dotenv-cli', '@prisma/adapter-pg']
3
+
4
+ function assertSupportedDependencies(dependencies = {}) {
5
+ for (const [name, version] of Object.entries(dependencies)) {
6
+ if (typeof version !== 'string') continue
7
+ if (UNSUPPORTED_LOCAL_DEP_PATTERN.test(version)) {
8
+ throw new Error(`检测到不支持的本地依赖引用: ${name} -> ${version}`)
9
+ }
10
+ }
11
+ }
12
+
13
+ export function createRuntimePackage({ appPackage, rootPackage }) {
14
+ const runtimeDependencies = { ...(appPackage?.dependencies || {}) }
15
+ const appDevDependencies = appPackage?.devDependencies || {}
16
+ const rootDependencies = rootPackage?.dependencies || {}
17
+ const rootDevDependencies = rootPackage?.devDependencies || {}
18
+
19
+ for (const dependencyName of REQUIRED_DEPENDENCIES) {
20
+ if (!runtimeDependencies[dependencyName]) {
21
+ runtimeDependencies[dependencyName] =
22
+ appDevDependencies[dependencyName]
23
+ || rootDependencies[dependencyName]
24
+ || rootDevDependencies[dependencyName]
25
+ }
26
+ }
27
+
28
+ assertSupportedDependencies(runtimeDependencies)
29
+
30
+ const runtimePackage = {
31
+ name: appPackage?.name,
32
+ version: appPackage?.version,
33
+ dependencies: runtimeDependencies,
34
+ }
35
+
36
+ if (appPackage?.private !== undefined) runtimePackage.private = appPackage.private
37
+ if (appPackage?.type) runtimePackage.type = appPackage.type
38
+ if (rootPackage?.packageManager) runtimePackage.packageManager = rootPackage.packageManager
39
+
40
+ const nodeEngine = rootPackage?.engines?.node || appPackage?.engines?.node
41
+ if (nodeEngine) {
42
+ runtimePackage.engines = { node: nodeEngine }
43
+ }
44
+
45
+ return runtimePackage
46
+ }