@weotro/dx 0.1.19 → 0.2.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 (71) hide show
  1. package/README.md +77 -718
  2. package/bin/dx.js +100 -173
  3. package/example/.env.development +1 -0
  4. package/example/README.md +24 -0
  5. package/example/dx/scripts/build +2 -0
  6. package/example/dx/scripts/db +2 -0
  7. package/example/dx/scripts/deploy +2 -0
  8. package/example/dx/scripts/setup +2 -0
  9. package/example/dx/scripts/start +12 -0
  10. package/example/dx/scripts/test +2 -0
  11. package/lib/env.js +40 -257
  12. package/lib/logger.js +5 -238
  13. package/lib/setup.js +72 -0
  14. package/package.json +27 -13
  15. package/skills/ask_3th/SKILL.md +2 -0
  16. package/skills/delegate_3th/SKILL.md +4 -2
  17. package/skills/delegate_3th/scripts/delegate_3th.py +37 -41
  18. package/skills/delegate_3th/scripts/test_delegate_3th.py +43 -17
  19. package/bin/dx-with-version-env.js +0 -8
  20. package/lib/artifact-deploy/artifact-builder.js +0 -144
  21. package/lib/artifact-deploy/config.js +0 -180
  22. package/lib/artifact-deploy/remote-script.js +0 -301
  23. package/lib/artifact-deploy/remote-transport.js +0 -86
  24. package/lib/artifact-deploy.js +0 -70
  25. package/lib/backend-artifact-deploy/artifact-builder.js +0 -267
  26. package/lib/backend-artifact-deploy/config.js +0 -218
  27. package/lib/backend-artifact-deploy/path-utils.js +0 -18
  28. package/lib/backend-artifact-deploy/remote-phases.js +0 -14
  29. package/lib/backend-artifact-deploy/remote-result.js +0 -44
  30. package/lib/backend-artifact-deploy/remote-script.js +0 -507
  31. package/lib/backend-artifact-deploy/remote-transport.js +0 -123
  32. package/lib/backend-artifact-deploy/rollback.js +0 -5
  33. package/lib/backend-artifact-deploy/runtime-package.js +0 -46
  34. package/lib/backend-artifact-deploy.js +0 -91
  35. package/lib/backend-package.js +0 -674
  36. package/lib/cli/args.js +0 -38
  37. package/lib/cli/command-result.js +0 -1
  38. package/lib/cli/commands/contracts.js +0 -60
  39. package/lib/cli/commands/core.js +0 -533
  40. package/lib/cli/commands/db.js +0 -231
  41. package/lib/cli/commands/deploy.js +0 -175
  42. package/lib/cli/commands/env.js +0 -120
  43. package/lib/cli/commands/export.js +0 -39
  44. package/lib/cli/commands/package.js +0 -22
  45. package/lib/cli/commands/release.js +0 -55
  46. package/lib/cli/commands/stack.js +0 -427
  47. package/lib/cli/commands/start.js +0 -58
  48. package/lib/cli/commands/worktree.js +0 -145
  49. package/lib/cli/dx-cli.js +0 -1072
  50. package/lib/cli/flags.js +0 -123
  51. package/lib/cli/help-model.js +0 -222
  52. package/lib/cli/help-renderer.js +0 -137
  53. package/lib/cli/help-schema.js +0 -552
  54. package/lib/cli/help.js +0 -141
  55. package/lib/cli/index.js +0 -4
  56. package/lib/cli/nx-command.js +0 -13
  57. package/lib/confirm.js +0 -213
  58. package/lib/env-policy.js +0 -134
  59. package/lib/env-profile.js +0 -531
  60. package/lib/exec.js +0 -692
  61. package/lib/nx-ignore.js +0 -45
  62. package/lib/run-with-version-env.js +0 -163
  63. package/lib/sdk-build.js +0 -424
  64. package/lib/start-dev.js +0 -401
  65. package/lib/telegram-webhook.js +0 -431
  66. package/lib/validate-env.js +0 -317
  67. package/lib/vercel-deploy.js +0 -549
  68. package/lib/worktree.js +0 -1052
  69. package/skills/delegate_3th/scripts/__pycache__/delegate_3th.cpython-314.pyc +0 -0
  70. package/skills/delegate_3th/scripts/__pycache__/delegate_cc.cpython-314.pyc +0 -0
  71. package/skills/delegate_3th/scripts/__pycache__/test_delegate_cc.cpython-314.pyc +0 -0
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { runWithVersionEnv } from '../lib/run-with-version-env.js'
4
-
5
- runWithVersionEnv(process.argv.slice(2)).catch(error => {
6
- console.error('[dx-with-version-env] failed:', error?.message || String(error))
7
- process.exit(1)
8
- })
@@ -1,144 +0,0 @@
1
- import { execFile } from 'node:child_process'
2
- import { existsSync } from 'node:fs'
3
- import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
4
- import { basename, dirname, join } from 'node:path'
5
- import { promisify } from 'node:util'
6
- import { execManager } from '../exec.js'
7
- import { resolveWithinBase } from '../backend-artifact-deploy/path-utils.js'
8
-
9
- const execFileAsync = promisify(execFile)
10
- const tarEnv = {
11
- ...process.env,
12
- COPYFILE_DISABLE: '1',
13
- COPY_EXTENDED_ATTRIBUTES_DISABLE: '1',
14
- }
15
-
16
- function assertSafeNamePart(value, label) {
17
- const text = String(value || '').trim()
18
- if (!text || text.includes('/') || text.includes('\\') || text.includes('..')) {
19
- throw new Error(`${label} 越界,已拒绝: ${text}`)
20
- }
21
- return text
22
- }
23
-
24
- function defaultNowTag() {
25
- const now = new Date()
26
- const pad = value => String(value).padStart(2, '0')
27
- return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
28
- }
29
-
30
- async function defaultRunBuild(build, environment) {
31
- await execManager.executeCommand(build.command, {
32
- app: build.app || undefined,
33
- skipEnvValidation: !build.app,
34
- flags: environment === 'production'
35
- ? { prod: true }
36
- : environment === 'staging'
37
- ? { staging: true }
38
- : { dev: true },
39
- })
40
- }
41
-
42
- async function defaultReadVersion(config) {
43
- if (config.artifact.version) return config.artifact.version
44
- if (config.build.versionCommand) {
45
- const { stdout } = await execFileAsync('bash', ['-lc', config.build.versionCommand], {
46
- cwd: config.projectRoot,
47
- env: process.env,
48
- })
49
- return String(stdout).trim()
50
- }
51
-
52
- const parsed = JSON.parse(await readFile(config.build.versionFile, 'utf8'))
53
- return String(parsed.version || '').trim()
54
- }
55
-
56
- async function defaultStageFiles(sourceDir, stageDir) {
57
- if (!existsSync(sourceDir)) throw new Error(`缺少待打包目录: ${sourceDir}`)
58
- await rm(stageDir, { recursive: true, force: true })
59
- await mkdir(stageDir, { recursive: true })
60
- for (const entry of await readdir(sourceDir)) {
61
- await cp(join(sourceDir, entry), join(stageDir, entry), { recursive: true })
62
- }
63
- }
64
-
65
- async function defaultAssertNoEnvFiles(stageDir) {
66
- const queue = ['.']
67
- const envFiles = []
68
- while (queue.length > 0) {
69
- const relativeDir = queue.shift()
70
- const currentDir = relativeDir === '.' ? stageDir : join(stageDir, relativeDir)
71
- for (const entry of await readdir(currentDir, { withFileTypes: true })) {
72
- const relativePath = relativeDir === '.' ? entry.name : join(relativeDir, entry.name)
73
- if (entry.name.startsWith('.env')) envFiles.push(relativePath.replace(/\\/g, '/'))
74
- if (entry.isDirectory()) queue.push(relativePath)
75
- }
76
- }
77
- if (envFiles.length > 0) throw new Error(`制品目录包含 .env* 文件: ${envFiles.join(', ')}`)
78
- }
79
-
80
- export function createArtifactNames({ version, timeTag, bundleName, releaseName }) {
81
- const safeVersion = assertSafeNamePart(version, 'version')
82
- const safeTimeTag = assertSafeNamePart(timeTag, 'timeTag')
83
- const safeBundleName = assertSafeNamePart(bundleName, 'bundleName')
84
- const safeReleaseName = assertSafeNamePart(releaseName, 'releaseName')
85
- const versionName = `${safeReleaseName}-v${safeVersion}-${safeTimeTag}`
86
- const innerArchiveName = `${versionName}.tgz`
87
- return {
88
- versionName,
89
- innerArchiveName,
90
- checksumName: `${innerArchiveName}.sha256`,
91
- bundleName: `${safeBundleName}-v${safeVersion}-${safeTimeTag}.tgz`,
92
- }
93
- }
94
-
95
- export async function buildArtifact(config, deps = {}) {
96
- const runBuild = deps.runBuild || defaultRunBuild
97
- const readVersion = deps.readVersion || defaultReadVersion
98
- const nowTag = deps.nowTag || defaultNowTag
99
- const stageFiles = deps.stageFiles || defaultStageFiles
100
- const assertNoEnvFiles = deps.assertNoEnvFiles || defaultAssertNoEnvFiles
101
- const version = await readVersion(config)
102
- if (!version) throw new Error('无法解析制品版本')
103
-
104
- const names = createArtifactNames({
105
- version,
106
- timeTag: nowTag(),
107
- bundleName: config.artifact.bundleName,
108
- releaseName: config.artifact.releaseName,
109
- })
110
- const outputDir = resolveWithinBase(config.artifact.outputDir, '.', 'artifact.outputDir')
111
- const stageDir = resolveWithinBase(outputDir, names.versionName, 'stageDir')
112
- const innerArchivePath = resolveWithinBase(outputDir, names.innerArchiveName, 'innerArchivePath')
113
- const checksumPath = resolveWithinBase(outputDir, names.checksumName, 'checksumPath')
114
- const bundlePath = resolveWithinBase(outputDir, names.bundleName, 'bundlePath')
115
-
116
- await runBuild(config.build, config.environment)
117
- await mkdir(outputDir, { recursive: true })
118
- await stageFiles(config.build.sourceDir, stageDir)
119
- await assertNoEnvFiles(stageDir)
120
- await execFileAsync('tar', ['-czf', innerArchivePath, '.'], { cwd: stageDir, env: tarEnv })
121
-
122
- const archiveName = basename(innerArchivePath)
123
- let checksum
124
- try {
125
- checksum = await execFileAsync('sha256sum', [archiveName], { cwd: dirname(innerArchivePath) })
126
- } catch {
127
- checksum = await execFileAsync('shasum', ['-a', '256', archiveName], { cwd: dirname(innerArchivePath) })
128
- }
129
- await writeFile(checksumPath, checksum.stdout)
130
- await execFileAsync('tar', ['-czf', bundlePath, archiveName, basename(checksumPath)], {
131
- cwd: outputDir,
132
- env: tarEnv,
133
- })
134
-
135
- return {
136
- version,
137
- versionName: names.versionName,
138
- innerArchiveName: names.innerArchiveName,
139
- checksumName: names.checksumName,
140
- bundlePath,
141
- innerArchivePath,
142
- checksumPath,
143
- }
144
- }
@@ -1,180 +0,0 @@
1
- import { isAbsolute } from 'node:path'
2
- import { resolveWithinBase } from '../backend-artifact-deploy/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 optionalString(value) {
12
- return typeof value === 'string' && value.trim() ? value.trim() : null
13
- }
14
-
15
- function requireSafeName(value, fieldPath) {
16
- const name = requireString(value, fieldPath)
17
- if (!/^[A-Za-z0-9._-]+$/.test(name) || name.includes('..')) {
18
- throw new Error(`${fieldPath} 包含非法字符: ${name}`)
19
- }
20
- return name
21
- }
22
-
23
- function requirePositiveInteger(value, fieldPath) {
24
- const parsed = Number(value)
25
- if (!Number.isInteger(parsed) || parsed <= 0) {
26
- throw new Error(`缺少必填配置: ${fieldPath}`)
27
- }
28
- return parsed
29
- }
30
-
31
- function resolveProjectPath(projectRoot, value, fieldPath) {
32
- return resolveWithinBase(projectRoot, requireString(value, fieldPath), fieldPath)
33
- }
34
-
35
- function requireRemoteBaseDir(value, fieldPath) {
36
- const baseDir = requireString(value, fieldPath)
37
- if (!isAbsolute(baseDir)) throw new Error(`${fieldPath} 必须是绝对路径: ${baseDir}`)
38
- if (!/^\/[A-Za-z0-9._/-]*$/.test(baseDir)) {
39
- throw new Error(`${fieldPath} 包含非法字符: ${baseDir}`)
40
- }
41
- return baseDir.replace(/\/+$/, '') || '/'
42
- }
43
-
44
- function resolveRemoteConfig(remoteConfig, environment) {
45
- if (!remoteConfig || typeof remoteConfig !== 'object') return null
46
- if (typeof remoteConfig.host === 'string') return remoteConfig
47
-
48
- const selected = remoteConfig[environment]
49
- if (!selected || typeof selected !== 'object') {
50
- throw new Error(`缺少必填配置: remote.${environment}`)
51
- }
52
- return selected
53
- }
54
-
55
- function resolveBuildCommand(buildConfig, environment) {
56
- if (buildConfig?.commands && typeof buildConfig.commands === 'object') {
57
- return requireString(buildConfig.commands[environment], `build.commands.${environment}`)
58
- }
59
- return requireString(buildConfig?.command, 'build.command')
60
- }
61
-
62
- function resolveHealthCheck(healthCheckConfig) {
63
- if (healthCheckConfig == null) return null
64
- const url = requireString(healthCheckConfig.url, 'verify.healthCheck.url')
65
- try {
66
- new URL(url)
67
- } catch {
68
- throw new Error('缺少必填配置: verify.healthCheck.url')
69
- }
70
-
71
- return {
72
- url,
73
- timeoutSeconds: healthCheckConfig.timeoutSeconds == null
74
- ? 10
75
- : requirePositiveInteger(healthCheckConfig.timeoutSeconds, 'verify.healthCheck.timeoutSeconds'),
76
- maxWaitSeconds: healthCheckConfig.maxWaitSeconds == null
77
- ? 24
78
- : requirePositiveInteger(healthCheckConfig.maxWaitSeconds, 'verify.healthCheck.maxWaitSeconds'),
79
- retryIntervalSeconds: healthCheckConfig.retryIntervalSeconds == null
80
- ? 2
81
- : requirePositiveInteger(
82
- healthCheckConfig.retryIntervalSeconds,
83
- 'verify.healthCheck.retryIntervalSeconds',
84
- ),
85
- }
86
- }
87
-
88
- export function resolveArtifactDeployConfig({ cli, target, targetConfig, environment, flags = {} }) {
89
- const deployConfig = targetConfig?.artifactDeploy
90
- if (!deployConfig || typeof deployConfig !== 'object') {
91
- throw new Error('缺少必填配置: artifactDeploy')
92
- }
93
-
94
- const buildConfig = deployConfig.build || {}
95
- const artifactConfig = deployConfig.artifact || {}
96
- const startupConfig = deployConfig.startup || {}
97
- const runConfig = deployConfig.deploy || {}
98
- const verifyConfig = deployConfig.verify || {}
99
- const buildOnly = Boolean(flags.buildOnly)
100
- const remoteConfig = resolveRemoteConfig(deployConfig.remote, environment)
101
- const startupMode = optionalString(startupConfig.mode) || 'command'
102
-
103
- if (!['systemd', 'command'].includes(startupMode)) {
104
- throw new Error('startup.mode 仅支持 systemd 或 command')
105
- }
106
-
107
- const versionFile = optionalString(buildConfig.versionFile)
108
- const versionCommand = optionalString(buildConfig.versionCommand)
109
- const version = optionalString(artifactConfig.version)
110
- if (!versionFile && !versionCommand && !version) {
111
- throw new Error('缺少必填配置: build.versionFile、build.versionCommand 或 artifact.version')
112
- }
113
-
114
- const serviceName = optionalString(startupConfig.serviceName)
115
- const startupCommand = optionalString(startupConfig.command)
116
- if (serviceName && !/^[A-Za-z0-9_.@-]+$/.test(serviceName)) {
117
- throw new Error(`startup.serviceName 包含非法字符: ${serviceName}`)
118
- }
119
- if (startupMode === 'systemd' && !serviceName) {
120
- throw new Error('缺少必填配置: startup.serviceName')
121
- }
122
- if (startupMode === 'command' && !startupCommand) {
123
- throw new Error('缺少必填配置: startup.command')
124
- }
125
- const healthCheck = resolveHealthCheck(verifyConfig.healthCheck)
126
-
127
- return {
128
- projectRoot: cli.projectRoot,
129
- target,
130
- environment,
131
- build: {
132
- app: optionalString(buildConfig.app),
133
- command: resolveBuildCommand(buildConfig, environment),
134
- sourceDir: resolveProjectPath(cli.projectRoot, buildConfig.sourceDir, 'build.sourceDir'),
135
- versionFile: versionFile
136
- ? resolveProjectPath(cli.projectRoot, versionFile, 'build.versionFile')
137
- : null,
138
- versionCommand,
139
- },
140
- artifact: {
141
- outputDir: resolveProjectPath(cli.projectRoot, artifactConfig.outputDir, 'artifact.outputDir'),
142
- bundleName: requireSafeName(artifactConfig.bundleName, 'artifact.bundleName'),
143
- releaseName: requireSafeName(
144
- optionalString(artifactConfig.releaseName) || String(target),
145
- 'artifact.releaseName',
146
- ),
147
- version,
148
- },
149
- remote: buildOnly
150
- ? null
151
- : {
152
- host: requireString(remoteConfig?.host, 'remote.host'),
153
- port: remoteConfig?.port == null ? 22 : requirePositiveInteger(remoteConfig.port, 'remote.port'),
154
- user: requireString(remoteConfig?.user, 'remote.user'),
155
- baseDir: requireRemoteBaseDir(remoteConfig?.baseDir, 'remote.baseDir'),
156
- },
157
- startup: {
158
- mode: startupMode,
159
- serviceName,
160
- command: startupCommand,
161
- rollbackCommand: optionalString(startupConfig.rollbackCommand),
162
- },
163
- deploy: {
164
- keepReleases: runConfig.keepReleases == null
165
- ? 5
166
- : requirePositiveInteger(runConfig.keepReleases, 'deploy.keepReleases'),
167
- installCommand: optionalString(runConfig.installCommand),
168
- },
169
- verify: {
170
- command: optionalString(verifyConfig.command),
171
- maxWaitSeconds: verifyConfig.maxWaitSeconds == null
172
- ? healthCheck?.maxWaitSeconds || 24
173
- : requirePositiveInteger(verifyConfig.maxWaitSeconds, 'verify.maxWaitSeconds'),
174
- retryIntervalSeconds: verifyConfig.retryIntervalSeconds == null
175
- ? healthCheck?.retryIntervalSeconds || 2
176
- : requirePositiveInteger(verifyConfig.retryIntervalSeconds, 'verify.retryIntervalSeconds'),
177
- healthCheck,
178
- },
179
- }
180
- }
@@ -1,301 +0,0 @@
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
- }