@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,301 @@
|
|
|
1
|
+
function escapeShell(value) {
|
|
2
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function buildRemoteDeployScript(payload = {}) {
|
|
6
|
+
const remote = payload.remote || {}
|
|
7
|
+
const artifact = payload.artifact || {}
|
|
8
|
+
const startup = payload.startup || {}
|
|
9
|
+
const deploy = payload.deploy || {}
|
|
10
|
+
const verify = payload.verify || {}
|
|
11
|
+
const healthCheck = verify.healthCheck || null
|
|
12
|
+
const baseDir = String(remote.baseDir || '.')
|
|
13
|
+
const versionName = String(payload.versionName || 'unknown')
|
|
14
|
+
const releaseDir = `${baseDir}/releases/${versionName}`
|
|
15
|
+
const currentLink = `${baseDir}/current`
|
|
16
|
+
const serviceName = String(startup.serviceName || '')
|
|
17
|
+
const startupMode = String(startup.mode || 'command')
|
|
18
|
+
const startupCommand = String(
|
|
19
|
+
startup.command || (startupMode === 'systemd' ? `sudo systemctl restart ${serviceName}` : ''),
|
|
20
|
+
)
|
|
21
|
+
const rollbackCommand = String(startup.rollbackCommand || startupCommand)
|
|
22
|
+
const verifyCommand = String(
|
|
23
|
+
verify.command || (startupMode === 'systemd' ? `sudo systemctl is-active --quiet ${serviceName}` : ''),
|
|
24
|
+
)
|
|
25
|
+
const installCommand = String(deploy.installCommand || '')
|
|
26
|
+
const healthCheckUrl = String(healthCheck?.url || '')
|
|
27
|
+
const healthCheckTimeoutSeconds = Number(healthCheck?.timeoutSeconds || 10)
|
|
28
|
+
const healthCheckMaxWaitSeconds = Number(verify.maxWaitSeconds || healthCheck?.maxWaitSeconds || 24)
|
|
29
|
+
const retryIntervalSeconds = Number(
|
|
30
|
+
verify.retryIntervalSeconds || healthCheck?.retryIntervalSeconds || 2,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return `#!/usr/bin/env bash
|
|
34
|
+
set -euo pipefail
|
|
35
|
+
|
|
36
|
+
APP_ROOT=${escapeShell(baseDir)}
|
|
37
|
+
ARCHIVE=${escapeShell(payload.uploadedBundlePath || '')}
|
|
38
|
+
RELEASE_DIR=${escapeShell(releaseDir)}
|
|
39
|
+
CURRENT_LINK=${escapeShell(currentLink)}
|
|
40
|
+
ENV_NAME=${escapeShell(payload.environment || 'production')}
|
|
41
|
+
VERSION_NAME=${escapeShell(versionName)}
|
|
42
|
+
INNER_ARCHIVE_NAME=${escapeShell(artifact.innerArchiveName || `${versionName}.tgz`)}
|
|
43
|
+
CHECKSUM_NAME=${escapeShell(artifact.checksumName || `${versionName}.tgz.sha256`)}
|
|
44
|
+
START_MODE=${escapeShell(startupMode)}
|
|
45
|
+
SERVICE_NAME=${escapeShell(serviceName)}
|
|
46
|
+
INSTALL_COMMAND=${escapeShell(installCommand)}
|
|
47
|
+
START_COMMAND=${escapeShell(startupCommand)}
|
|
48
|
+
ROLLBACK_COMMAND=${escapeShell(rollbackCommand)}
|
|
49
|
+
VERIFY_COMMAND=${escapeShell(verifyCommand)}
|
|
50
|
+
HEALTHCHECK_URL=${escapeShell(healthCheckUrl)}
|
|
51
|
+
HEALTHCHECK_TIMEOUT_SECONDS=${healthCheckTimeoutSeconds}
|
|
52
|
+
VERIFY_MAX_WAIT_SECONDS=${healthCheckMaxWaitSeconds}
|
|
53
|
+
VERIFY_RETRY_DELAY_SECONDS=${retryIntervalSeconds}
|
|
54
|
+
KEEP_RELEASES=${Number(deploy.keepReleases || 5)}
|
|
55
|
+
|
|
56
|
+
LOCK_FILE="$APP_ROOT/.deploy.lock"
|
|
57
|
+
LOCK_DIR="$APP_ROOT/.deploy.lock.d"
|
|
58
|
+
SHARED_DIR="$APP_ROOT/shared"
|
|
59
|
+
RELEASES_DIR="$APP_ROOT/releases"
|
|
60
|
+
UPLOADS_DIR="$APP_ROOT/uploads"
|
|
61
|
+
PREVIOUS_CURRENT_TARGET=""
|
|
62
|
+
BUNDLE_TEMP_DIR=""
|
|
63
|
+
CURRENT_PHASE="init"
|
|
64
|
+
RESULT_EMITTED=0
|
|
65
|
+
ROLLBACK_ATTEMPTED=false
|
|
66
|
+
ROLLBACK_SUCCEEDED=null
|
|
67
|
+
CURRENT_SWITCHED=0
|
|
68
|
+
|
|
69
|
+
json_escape() {
|
|
70
|
+
local value="\${1-}"
|
|
71
|
+
value="\${value//\\\\/\\\\\\\\}"
|
|
72
|
+
value="\${value//\"/\\\\\"}"
|
|
73
|
+
value="\${value//$'\\n'/\\\\n}"
|
|
74
|
+
value="\${value//$'\\r'/\\\\r}"
|
|
75
|
+
value="\${value//$'\\t'/\\\\t}"
|
|
76
|
+
printf '%s' "$value"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
build_summary_json() {
|
|
80
|
+
local current_release="$1"
|
|
81
|
+
printf '{"releaseName":"%s","currentRelease":"%s","serviceName":"%s","startupMode":"%s","healthUrl":"%s"}' \\
|
|
82
|
+
"$(json_escape "$VERSION_NAME")" \\
|
|
83
|
+
"$(json_escape "$current_release")" \\
|
|
84
|
+
"$(json_escape "$SERVICE_NAME")" \\
|
|
85
|
+
"$(json_escape "$START_MODE")" \\
|
|
86
|
+
"$(json_escape "$HEALTHCHECK_URL")"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
emit_result() {
|
|
90
|
+
local ok="$1"
|
|
91
|
+
local phase="$2"
|
|
92
|
+
local message="$3"
|
|
93
|
+
local summary_json="\${4:-null}"
|
|
94
|
+
if [[ "$RESULT_EMITTED" -eq 1 ]]; then return; fi
|
|
95
|
+
RESULT_EMITTED=1
|
|
96
|
+
message="\${message//\\\\/\\\\\\\\}"
|
|
97
|
+
message="\${message//\"/\\\\\"}"
|
|
98
|
+
message="\${message//$'\\n'/\\\\n}"
|
|
99
|
+
printf 'DX_REMOTE_RESULT={"ok":%s,"phase":"%s","message":"%s","rollbackAttempted":%s,"rollbackSucceeded":%s,"summary":%s}\\n' \\
|
|
100
|
+
"$ok" "$phase" "$message" "$ROLLBACK_ATTEMPTED" "$ROLLBACK_SUCCEEDED" "$summary_json"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
cleanup() {
|
|
104
|
+
rm -rf "$BUNDLE_TEMP_DIR" 2>/dev/null || true
|
|
105
|
+
rmdir "$LOCK_DIR" 2>/dev/null || true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
run_command_at() {
|
|
109
|
+
local cwd="$1"
|
|
110
|
+
local command="$2"
|
|
111
|
+
if [[ -z "$command" ]]; then return 0; fi
|
|
112
|
+
(
|
|
113
|
+
cd "$cwd"
|
|
114
|
+
DX_RELEASE_DIR="$cwd" \\
|
|
115
|
+
DX_CURRENT_LINK="$CURRENT_LINK" \\
|
|
116
|
+
DX_PREVIOUS_RELEASE="$PREVIOUS_CURRENT_TARGET" \\
|
|
117
|
+
DX_ENVIRONMENT="$ENV_NAME" \\
|
|
118
|
+
DX_SERVICE_NAME="$SERVICE_NAME" \\
|
|
119
|
+
bash -lc "$command"
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
attempt_rollback() {
|
|
124
|
+
if [[ -z "$PREVIOUS_CURRENT_TARGET" || ! -d "$PREVIOUS_CURRENT_TARGET" ]]; then
|
|
125
|
+
return
|
|
126
|
+
fi
|
|
127
|
+
ROLLBACK_ATTEMPTED=true
|
|
128
|
+
if ln -sfn "$PREVIOUS_CURRENT_TARGET" "$CURRENT_LINK" && run_command_at "$CURRENT_LINK" "$ROLLBACK_COMMAND"; then
|
|
129
|
+
ROLLBACK_SUCCEEDED=true
|
|
130
|
+
else
|
|
131
|
+
ROLLBACK_SUCCEEDED=false
|
|
132
|
+
fi
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fail_after_switch() {
|
|
136
|
+
local phase="$1"
|
|
137
|
+
local message="$2"
|
|
138
|
+
attempt_rollback
|
|
139
|
+
emit_result false "$phase" "$message"
|
|
140
|
+
exit 1
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
on_error() {
|
|
144
|
+
local code=$?
|
|
145
|
+
if [[ "$CURRENT_SWITCHED" -eq 1 && "$ROLLBACK_ATTEMPTED" == "false" ]]; then
|
|
146
|
+
attempt_rollback
|
|
147
|
+
fi
|
|
148
|
+
emit_result false "$CURRENT_PHASE" "phase failed (exit $code)"
|
|
149
|
+
exit "$code"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
trap cleanup EXIT
|
|
153
|
+
trap on_error ERR
|
|
154
|
+
|
|
155
|
+
validate_path_within_base() {
|
|
156
|
+
local base="$1"
|
|
157
|
+
local target="$2"
|
|
158
|
+
case "$target" in
|
|
159
|
+
"$base"/*|"$base") ;;
|
|
160
|
+
*) echo "目标路径越界: $target" >&2; exit 1 ;;
|
|
161
|
+
esac
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
validate_archive_entries() {
|
|
165
|
+
local archive="$1"
|
|
166
|
+
local entry
|
|
167
|
+
local tar_line
|
|
168
|
+
local link_target
|
|
169
|
+
while IFS= read -r entry; do
|
|
170
|
+
if [[ "$entry" == /* || "$entry" =~ (^|/)\\.\\.(/|$) || "$entry" =~ \\.\\.\\\\ ]]; then
|
|
171
|
+
echo "包含可疑路径条目: $entry" >&2
|
|
172
|
+
exit 1
|
|
173
|
+
fi
|
|
174
|
+
done < <(tar -tzf "$archive")
|
|
175
|
+
while IFS= read -r tar_line; do
|
|
176
|
+
if [[ "$tar_line" == *" -> "* ]]; then
|
|
177
|
+
link_target="\${tar_line##* -> }"
|
|
178
|
+
if [[ "$link_target" == /* || "$link_target" =~ (^|/)\\.\\.(/|$) || "$link_target" =~ \\.\\.\\\\ ]]; then
|
|
179
|
+
echo "包含可疑链接目标: $link_target" >&2
|
|
180
|
+
exit 1
|
|
181
|
+
fi
|
|
182
|
+
fi
|
|
183
|
+
done < <(tar -tvzf "$archive")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
sha256_check() {
|
|
187
|
+
local checksum_file="$1"
|
|
188
|
+
local expected actual file
|
|
189
|
+
expected="$(awk '{print $1}' "$checksum_file")"
|
|
190
|
+
file="$(basename "$(awk '{print $2}' "$checksum_file")")"
|
|
191
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
192
|
+
actual="$(sha256sum "$file" | awk '{print $1}')"
|
|
193
|
+
else
|
|
194
|
+
actual="$(shasum -a 256 "$file" | awk '{print $1}')"
|
|
195
|
+
fi
|
|
196
|
+
[[ "$expected" == "$actual" ]]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
retry_command() {
|
|
200
|
+
local command="$1"
|
|
201
|
+
local label="$2"
|
|
202
|
+
local started_at elapsed
|
|
203
|
+
if [[ -z "$command" ]]; then return 0; fi
|
|
204
|
+
started_at="$(date +%s)"
|
|
205
|
+
until run_command_at "$CURRENT_LINK" "$command"; do
|
|
206
|
+
elapsed=$(( $(date +%s) - started_at ))
|
|
207
|
+
if [[ "$elapsed" -ge "$VERIFY_MAX_WAIT_SECONDS" ]]; then
|
|
208
|
+
echo "$label failed within $VERIFY_MAX_WAIT_SECONDS seconds" >&2
|
|
209
|
+
return 1
|
|
210
|
+
fi
|
|
211
|
+
sleep "$VERIFY_RETRY_DELAY_SECONDS"
|
|
212
|
+
done
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
CURRENT_PHASE="lock"
|
|
216
|
+
echo "DX_REMOTE_PHASE=lock"
|
|
217
|
+
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$UPLOADS_DIR"
|
|
218
|
+
validate_path_within_base "$APP_ROOT" "$ARCHIVE"
|
|
219
|
+
validate_path_within_base "$APP_ROOT" "$RELEASE_DIR"
|
|
220
|
+
PREVIOUS_CURRENT_TARGET="$(readlink -f "$CURRENT_LINK" 2>/dev/null || true)"
|
|
221
|
+
if command -v flock >/dev/null 2>&1; then
|
|
222
|
+
exec 9>"$LOCK_FILE"
|
|
223
|
+
flock -n 9
|
|
224
|
+
else
|
|
225
|
+
mkdir "$LOCK_DIR"
|
|
226
|
+
fi
|
|
227
|
+
|
|
228
|
+
CURRENT_PHASE="extract"
|
|
229
|
+
echo "DX_REMOTE_PHASE=extract"
|
|
230
|
+
validate_archive_entries "$ARCHIVE"
|
|
231
|
+
BUNDLE_TEMP_DIR="$(mktemp -d "$APP_ROOT/.bundle-extract.XXXXXX")"
|
|
232
|
+
tar -xzf "$ARCHIVE" -C "$BUNDLE_TEMP_DIR"
|
|
233
|
+
INNER_ARCHIVE="$BUNDLE_TEMP_DIR/$INNER_ARCHIVE_NAME"
|
|
234
|
+
CHECKSUM_FILE="$BUNDLE_TEMP_DIR/$CHECKSUM_NAME"
|
|
235
|
+
if [[ ! -f "$INNER_ARCHIVE" || ! -f "$CHECKSUM_FILE" ]]; then
|
|
236
|
+
echo "制品包缺少 $INNER_ARCHIVE_NAME 或 $CHECKSUM_NAME" >&2
|
|
237
|
+
exit 1
|
|
238
|
+
fi
|
|
239
|
+
(cd "$BUNDLE_TEMP_DIR" && sha256_check "$CHECKSUM_NAME")
|
|
240
|
+
validate_archive_entries "$INNER_ARCHIVE"
|
|
241
|
+
rm -rf "$RELEASE_DIR"
|
|
242
|
+
mkdir -p "$RELEASE_DIR"
|
|
243
|
+
tar -xzf "$INNER_ARCHIVE" -C "$RELEASE_DIR" --strip-components=1
|
|
244
|
+
|
|
245
|
+
CURRENT_PHASE="install"
|
|
246
|
+
echo "DX_REMOTE_PHASE=install"
|
|
247
|
+
run_command_at "$RELEASE_DIR" "$INSTALL_COMMAND"
|
|
248
|
+
|
|
249
|
+
CURRENT_PHASE="switch-current"
|
|
250
|
+
echo "DX_REMOTE_PHASE=switch-current"
|
|
251
|
+
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
|
|
252
|
+
CURRENT_SWITCHED=1
|
|
253
|
+
|
|
254
|
+
CURRENT_PHASE="startup"
|
|
255
|
+
echo "DX_REMOTE_PHASE=startup"
|
|
256
|
+
if ! run_command_at "$CURRENT_LINK" "$START_COMMAND"; then
|
|
257
|
+
attempt_rollback
|
|
258
|
+
emit_result false "startup" "startup command failed"
|
|
259
|
+
exit 1
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
CURRENT_PHASE="verify"
|
|
263
|
+
echo "DX_REMOTE_PHASE=verify"
|
|
264
|
+
current_release="$(readlink -f "$CURRENT_LINK")"
|
|
265
|
+
expected_release="$(readlink -f "$RELEASE_DIR")"
|
|
266
|
+
if [[ -z "$current_release" || "$current_release" != "$expected_release" ]]; then
|
|
267
|
+
echo "current 软链接未指向本次 release: expected=$expected_release actual=\${current_release:-<empty>}" >&2
|
|
268
|
+
fail_after_switch "verify" "current symlink verification failed"
|
|
269
|
+
fi
|
|
270
|
+
|
|
271
|
+
retry_command "$VERIFY_COMMAND" "verify command"
|
|
272
|
+
if [[ -n "$HEALTHCHECK_URL" ]]; then
|
|
273
|
+
command -v curl >/dev/null 2>&1
|
|
274
|
+
healthcheck_started_at="$(date +%s)"
|
|
275
|
+
until curl -fsS --max-time "$HEALTHCHECK_TIMEOUT_SECONDS" "$HEALTHCHECK_URL" >/dev/null; do
|
|
276
|
+
healthcheck_elapsed_seconds=$(( $(date +%s) - healthcheck_started_at ))
|
|
277
|
+
if [[ "$healthcheck_elapsed_seconds" -ge "$VERIFY_MAX_WAIT_SECONDS" ]]; then
|
|
278
|
+
echo "health check failed within $VERIFY_MAX_WAIT_SECONDS seconds: $HEALTHCHECK_URL" >&2
|
|
279
|
+
fail_after_switch "verify" "health check failed"
|
|
280
|
+
fi
|
|
281
|
+
sleep "$VERIFY_RETRY_DELAY_SECONDS"
|
|
282
|
+
done
|
|
283
|
+
fi
|
|
284
|
+
|
|
285
|
+
CURRENT_PHASE="cleanup"
|
|
286
|
+
echo "DX_REMOTE_PHASE=cleanup"
|
|
287
|
+
release_count=0
|
|
288
|
+
shopt -s nullglob
|
|
289
|
+
release_dirs=("$RELEASES_DIR"/*)
|
|
290
|
+
shopt -u nullglob
|
|
291
|
+
while IFS= read -r old_release; do
|
|
292
|
+
release_count=$((release_count + 1))
|
|
293
|
+
if [[ "$release_count" -gt "$KEEP_RELEASES" ]]; then rm -rf "$old_release"; fi
|
|
294
|
+
done < <(
|
|
295
|
+
if [[ "\${#release_dirs[@]}" -gt 0 ]]; then ls -1dt "\${release_dirs[@]}"; fi
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
summary_json="$(build_summary_json "$current_release")"
|
|
299
|
+
emit_result true "cleanup" "ok" "$summary_json"
|
|
300
|
+
`
|
|
301
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { basename } from 'node:path'
|
|
3
|
+
import { parseRemoteResult } from '../backend-artifact-deploy/remote-result.js'
|
|
4
|
+
import { buildRemoteDeployScript } from './remote-script.js'
|
|
5
|
+
|
|
6
|
+
function runProcess(command, args, options = {}) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], ...options })
|
|
9
|
+
let stdout = ''
|
|
10
|
+
let stderr = ''
|
|
11
|
+
child.stdout.on('data', chunk => { stdout += String(chunk) })
|
|
12
|
+
child.stderr.on('data', chunk => { stderr += String(chunk) })
|
|
13
|
+
child.on('error', reject)
|
|
14
|
+
child.on('close', exitCode => resolve({ stdout, stderr, exitCode }))
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function escapeShellArg(value) {
|
|
19
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildEnsureRemoteBaseDirsCommand(baseDir) {
|
|
23
|
+
const normalizedBaseDir = String(baseDir).replace(/\/+$/, '') || '/'
|
|
24
|
+
const directories = ['releases', 'shared', 'uploads'].map(name => `${normalizedBaseDir}/${name}`)
|
|
25
|
+
return `mkdir -p ${directories.map(escapeShellArg).join(' ')}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function defaultEnsureRemoteBaseDirs(remote) {
|
|
29
|
+
const target = `${remote.user}@${remote.host}`
|
|
30
|
+
const result = await runProcess('ssh', [
|
|
31
|
+
'-p',
|
|
32
|
+
String(remote.port || 22),
|
|
33
|
+
target,
|
|
34
|
+
buildEnsureRemoteBaseDirsCommand(remote.baseDir),
|
|
35
|
+
])
|
|
36
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || `ssh mkdir failed (${result.exitCode})`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function defaultUploadBundle(remote, bundlePath) {
|
|
40
|
+
const target = `${remote.user}@${remote.host}:${remote.baseDir}/uploads/${basename(bundlePath)}`
|
|
41
|
+
const result = await runProcess('scp', ['-P', String(remote.port || 22), bundlePath, target])
|
|
42
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || `scp failed (${result.exitCode})`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function defaultRunRemoteScript(remote, script) {
|
|
46
|
+
const target = `${remote.user}@${remote.host}`
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const child = spawn('ssh', ['-p', String(remote.port || 22), target, 'bash -s'], {
|
|
49
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
+
})
|
|
51
|
+
let stdout = ''
|
|
52
|
+
let stderr = ''
|
|
53
|
+
child.stdout.on('data', chunk => { stdout += String(chunk) })
|
|
54
|
+
child.stderr.on('data', chunk => { stderr += String(chunk) })
|
|
55
|
+
child.on('error', reject)
|
|
56
|
+
child.on('close', exitCode => resolve({ stdout, stderr, exitCode }))
|
|
57
|
+
child.stdin.write(script)
|
|
58
|
+
child.stdin.end()
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function deployArtifactRemotely(config, bundle, deps = {}) {
|
|
63
|
+
const ensureRemoteBaseDirs = deps.ensureRemoteBaseDirs || defaultEnsureRemoteBaseDirs
|
|
64
|
+
const uploadBundle = deps.uploadBundle || defaultUploadBundle
|
|
65
|
+
const runRemoteScript = deps.runRemoteScript || defaultRunRemoteScript
|
|
66
|
+
|
|
67
|
+
await ensureRemoteBaseDirs(config.remote)
|
|
68
|
+
await uploadBundle(config.remote, bundle.bundlePath)
|
|
69
|
+
const payload = {
|
|
70
|
+
environment: config.environment,
|
|
71
|
+
versionName: bundle.versionName,
|
|
72
|
+
uploadedBundlePath: `${config.remote.baseDir}/uploads/${basename(bundle.bundlePath)}`,
|
|
73
|
+
remote: config.remote,
|
|
74
|
+
artifact: {
|
|
75
|
+
innerArchiveName: bundle.innerArchiveName || `${bundle.versionName}.tgz`,
|
|
76
|
+
checksumName: bundle.checksumName || `${bundle.versionName}.tgz.sha256`,
|
|
77
|
+
},
|
|
78
|
+
startup: config.startup,
|
|
79
|
+
deploy: config.deploy,
|
|
80
|
+
verify: config.verify,
|
|
81
|
+
}
|
|
82
|
+
const commandResult = await runRemoteScript(config.remote, buildRemoteDeployScript(payload))
|
|
83
|
+
const result = parseRemoteResult(commandResult)
|
|
84
|
+
if (!result.ok) throw new Error(`远端部署失败(${result.phase}): ${result.message}`)
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises'
|
|
2
|
+
import { basename, resolve } from 'node:path'
|
|
3
|
+
import { buildArtifact } from './artifact-deploy/artifact-builder.js'
|
|
4
|
+
import { resolveArtifactDeployConfig } from './artifact-deploy/config.js'
|
|
5
|
+
import { deployArtifactRemotely } from './artifact-deploy/remote-transport.js'
|
|
6
|
+
import { logger as defaultLogger } from './logger.js'
|
|
7
|
+
|
|
8
|
+
export async function loadArtifact(config, artifactPath, deps = {}) {
|
|
9
|
+
const ensureReadable = deps.ensureArtifactReadable || access
|
|
10
|
+
const bundlePath = resolve(config.projectRoot, artifactPath)
|
|
11
|
+
await ensureReadable(bundlePath)
|
|
12
|
+
|
|
13
|
+
const bundleFile = basename(bundlePath)
|
|
14
|
+
const prefix = `${config.artifact.bundleName}-v`
|
|
15
|
+
if (!bundleFile.startsWith(prefix) || !bundleFile.endsWith('.tgz')) {
|
|
16
|
+
throw new Error(`制品文件名必须匹配 ${prefix}<version>-<timestamp>.tgz`)
|
|
17
|
+
}
|
|
18
|
+
const versionTag = bundleFile.slice(prefix.length, -'.tgz'.length)
|
|
19
|
+
if (!versionTag) throw new Error('无法从制品文件名解析版本')
|
|
20
|
+
const versionName = `${config.artifact.releaseName}-v${versionTag}`
|
|
21
|
+
return {
|
|
22
|
+
bundlePath,
|
|
23
|
+
versionName,
|
|
24
|
+
innerArchiveName: `${versionName}.tgz`,
|
|
25
|
+
checksumName: `${versionName}.tgz.sha256`,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printSuccessfulDeploySummary(result, logger) {
|
|
30
|
+
const summary = result?.summary
|
|
31
|
+
if (!summary) return
|
|
32
|
+
logger.success(`制品部署成功: ${summary.releaseName || 'unknown-release'}`)
|
|
33
|
+
if (summary.currentRelease) logger.info(`[deploy-summary] current=${summary.currentRelease}`)
|
|
34
|
+
if (summary.serviceName || summary.startupMode) {
|
|
35
|
+
logger.info(
|
|
36
|
+
`[deploy-summary] service=${summary.serviceName || 'custom-command'} mode=${summary.startupMode || 'unknown'}`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
if (summary.healthUrl) logger.info(`[deploy-summary] health=${summary.healthUrl}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runArtifactDeploy({ cli, target, args, environment, deps = {} }) {
|
|
43
|
+
const logger = deps.logger || defaultLogger
|
|
44
|
+
const resolveConfig = deps.resolveConfig || resolveArtifactDeployConfig
|
|
45
|
+
const build = deps.buildArtifact || buildArtifact
|
|
46
|
+
const deployRemotely = deps.deployRemotely || deployArtifactRemotely
|
|
47
|
+
const config = resolveConfig({
|
|
48
|
+
cli,
|
|
49
|
+
target,
|
|
50
|
+
targetConfig: cli?.commands?.deploy?.[target],
|
|
51
|
+
environment,
|
|
52
|
+
flags: cli?.flags || {},
|
|
53
|
+
args,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const artifactPath = cli?.flags?.artifact
|
|
57
|
+
if (cli?.flags?.buildOnly && artifactPath) {
|
|
58
|
+
throw new Error('--build-only 与 --artifact 不能同时使用')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const load = deps.loadArtifact || loadArtifact
|
|
62
|
+
const bundle = artifactPath
|
|
63
|
+
? await load(config, artifactPath, deps)
|
|
64
|
+
: await build(config, deps)
|
|
65
|
+
if (cli?.flags?.buildOnly) return bundle
|
|
66
|
+
|
|
67
|
+
const result = await deployRemotely(config, bundle, deps)
|
|
68
|
+
if (result?.ok) printSuccessfulDeploySummary(result, logger)
|
|
69
|
+
return result
|
|
70
|
+
}
|