@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.
- package/README.md +79 -0
- package/codex/agents/fixer.toml +1 -2
- package/codex/agents/orchestrator.toml +2 -0
- package/codex/agents/reviewer.toml +2 -0
- package/codex/agents/spark.toml +1 -4
- package/codex/skills/doctor/SKILL.md +1 -1
- package/codex/skills/doctor/scripts/doctor.sh +35 -42
- package/lib/backend-artifact-deploy/artifact-builder.js +240 -0
- package/lib/backend-artifact-deploy/config.js +138 -0
- package/lib/backend-artifact-deploy/path-utils.js +18 -0
- package/lib/backend-artifact-deploy/remote-phases.js +13 -0
- package/lib/backend-artifact-deploy/remote-result.js +42 -0
- package/lib/backend-artifact-deploy/remote-script.js +337 -0
- package/lib/backend-artifact-deploy/remote-transport.js +118 -0
- package/lib/backend-artifact-deploy/rollback.js +5 -0
- package/lib/backend-artifact-deploy/runtime-package.js +41 -0
- package/lib/backend-artifact-deploy.js +32 -0
- package/lib/cli/commands/deploy.js +14 -1
- package/lib/cli/flags.js +8 -0
- package/lib/cli/help.js +15 -5
- package/lib/vercel-deploy.js +33 -3
- package/package.json +1 -1
|
@@ -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
|
+
}
|