@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.
- package/LICENSE +21 -0
- package/README.md +755 -0
- package/bin/dx-with-version-env.js +8 -0
- package/bin/dx.js +187 -0
- package/lib/artifact-deploy/artifact-builder.js +144 -0
- package/lib/artifact-deploy/config.js +180 -0
- package/lib/artifact-deploy/remote-script.js +301 -0
- package/lib/artifact-deploy/remote-transport.js +86 -0
- package/lib/artifact-deploy.js +70 -0
- package/lib/backend-artifact-deploy/artifact-builder.js +267 -0
- package/lib/backend-artifact-deploy/config.js +218 -0
- package/lib/backend-artifact-deploy/path-utils.js +18 -0
- package/lib/backend-artifact-deploy/remote-phases.js +14 -0
- package/lib/backend-artifact-deploy/remote-result.js +44 -0
- package/lib/backend-artifact-deploy/remote-script.js +507 -0
- package/lib/backend-artifact-deploy/remote-transport.js +123 -0
- package/lib/backend-artifact-deploy/rollback.js +5 -0
- package/lib/backend-artifact-deploy/runtime-package.js +46 -0
- package/lib/backend-artifact-deploy.js +91 -0
- package/lib/backend-package.js +674 -0
- package/lib/cli/args.js +38 -0
- package/lib/cli/command-result.js +1 -0
- package/lib/cli/commands/contracts.js +60 -0
- package/lib/cli/commands/core.js +533 -0
- package/lib/cli/commands/db.js +231 -0
- package/lib/cli/commands/deploy.js +175 -0
- package/lib/cli/commands/env.js +120 -0
- package/lib/cli/commands/export.js +39 -0
- package/lib/cli/commands/package.js +22 -0
- package/lib/cli/commands/release.js +55 -0
- package/lib/cli/commands/stack.js +427 -0
- package/lib/cli/commands/start.js +58 -0
- package/lib/cli/commands/worktree.js +145 -0
- package/lib/cli/dx-cli.js +1072 -0
- package/lib/cli/flags.js +123 -0
- package/lib/cli/help-model.js +222 -0
- package/lib/cli/help-renderer.js +137 -0
- package/lib/cli/help-schema.js +552 -0
- package/lib/cli/help.js +141 -0
- package/lib/cli/index.js +4 -0
- package/lib/cli/nx-command.js +13 -0
- package/lib/codex-initial.js +271 -0
- package/lib/confirm.js +213 -0
- package/lib/env-policy.js +134 -0
- package/lib/env-profile.js +435 -0
- package/lib/env.js +261 -0
- package/lib/exec.js +692 -0
- package/lib/logger.js +239 -0
- package/lib/nx-ignore.js +45 -0
- package/lib/run-with-version-env.js +163 -0
- package/lib/sdk-build.js +424 -0
- package/lib/start-dev.js +401 -0
- package/lib/telegram-webhook.js +431 -0
- package/lib/validate-env.js +317 -0
- package/lib/vercel-deploy.js +549 -0
- package/lib/version.js +14 -0
- package/lib/worktree.js +1052 -0
- package/package.json +45 -0
- package/skills/create-issue/SKILL.md +90 -0
- package/skills/delivering-design-handoff/SKILL.md +290 -0
- package/skills/doctor/SKILL.md +76 -0
- package/skills/gh-dependabot-cleanup/SKILL.md +54 -0
- package/skills/gh-dependabot-cleanup/agents/openai.yaml +7 -0
- package/skills/git-release/SKILL.md +194 -0
- package/skills/git-release/agents/openai.yaml +7 -0
- package/skills/online-debug-guard/SKILL.md +111 -0
- package/skills/ship-issue-pr/SKILL.md +676 -0
- 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,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
|
+
}
|