@ranger1/dx 0.1.81 → 0.1.83

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 CHANGED
@@ -362,6 +362,49 @@ dx deploy backend --prod --skip-migration
362
362
 
363
363
  - 生成的 release `package.json` 默认只保留运行时依赖;如果应用把 `prisma` 放在 `devDependencies`,dx 会自动把它提升进 release 依赖,保证远端 `prisma generate` / `prisma migrate deploy` 可执行。
364
364
  - 打包前会递归扫描整个 staged payload;任意层级出现 `.env*` 文件都会直接失败,避免把环境文件误打进制品。
365
+ - 所有本地路径字段都会被解析为相对项目根目录,并且必须留在项目根目录内;例如 `build.distDir`、`runtime.prismaSchemaDir`、`artifact.outputDir` 不能通过 `../` 逃逸到仓库外。
366
+ - `remote.baseDir` 必须是绝对路径,并且只能包含 `/`、字母、数字、`.`、`_`、`-`;不要使用空格或 shell 特殊字符。
367
+
368
+ SSH 认证说明:
369
+
370
+ - `dx deploy backend` 当前直接调用系统 `ssh` / `scp`,不会单独解析 `sshKey`、`identityFile` 之类的 dx 配置项。
371
+ - 因此,发布使用哪把私钥,取决于本机 OpenSSH 的默认认证行为,例如 `ssh-agent`、`~/.ssh/config`、默认私钥文件等。
372
+ - 如果你已经在 `~/.ssh/config` 中配置了主机别名(例如 `Host ai-staging`),推荐直接把 `backendDeploy.remote.host` 写成这个别名,让 OpenSSH 自动匹配对应的 `HostName`、`User`、`Port`、`IdentityFile`。
373
+
374
+ 例如本机 `~/.ssh/config`:
375
+
376
+ ```sshconfig
377
+ Host ai-staging
378
+ HostName 1.2.3.4
379
+ User deploy
380
+ Port 22
381
+ IdentityFile ~/.ssh/your_staging_key
382
+ ```
383
+
384
+ 对应的 `dx/config/commands.json`:
385
+
386
+ ```json
387
+ {
388
+ "deploy": {
389
+ "backend": {
390
+ "internal": "backend-artifact-deploy",
391
+ "backendDeploy": {
392
+ "remote": {
393
+ "host": "ai-staging",
394
+ "port": 22,
395
+ "user": "deploy",
396
+ "baseDir": "/srv/example-app"
397
+ }
398
+ }
399
+ }
400
+ }
401
+ }
402
+ ```
403
+
404
+ 注意:
405
+
406
+ - `remote.host` 写成别名后,dx 仍会显式传入 `remote.user` 和 `remote.port`;如果这两个值与 `~/.ssh/config` 中的 `User` / `Port` 不一致,命令行参数会覆盖 SSH config。
407
+ - 最稳妥的做法是让 `remote.user`、`remote.port` 与 `~/.ssh/config` 保持一致,或者都统一以 SSH config 中的值为准后再同步到 dx 配置。
365
408
 
366
409
  ## 依赖关系约定
367
410
 
@@ -8,6 +8,11 @@ import { basenameOrThrow, resolveWithinBase } from './path-utils.js'
8
8
  import { createRuntimePackage } from './runtime-package.js'
9
9
 
10
10
  const execFileAsync = promisify(execFile)
11
+ const tarEnv = {
12
+ ...process.env,
13
+ COPYFILE_DISABLE: '1',
14
+ COPY_EXTENDED_ATTRIBUTES_DISABLE: '1',
15
+ }
11
16
 
12
17
  function assertSafeNamePart(value, label) {
13
18
  const text = String(value || '').trim()
@@ -32,12 +37,25 @@ async function defaultReadVersion(versionFile) {
32
37
  return String(pkg.version || '').trim()
33
38
  }
34
39
 
35
- async function defaultRunBuild(build) {
40
+ export function buildFlagsForEnvironment(environment) {
41
+ switch (environment || 'development') {
42
+ case 'production':
43
+ return { prod: true }
44
+ case 'staging':
45
+ return { staging: true }
46
+ case 'development':
47
+ default:
48
+ return { dev: true }
49
+ }
50
+ }
51
+
52
+ async function defaultRunBuild(build, environment = 'development') {
36
53
  if (!build?.command) {
37
54
  throw new Error('缺少构建命令: build.command')
38
55
  }
39
56
  await execManager.executeCommand(build.command, {
40
57
  app: build.app || undefined,
58
+ flags: buildFlagsForEnvironment(environment),
41
59
  })
42
60
  }
43
61
 
@@ -112,15 +130,21 @@ async function defaultCreateInnerArchive({ stageDir, innerArchivePath }) {
112
130
  await mkdir(dirname(innerArchivePath), { recursive: true })
113
131
  await execFileAsync('tar', ['-czf', innerArchivePath, '.'], {
114
132
  cwd: stageDir,
133
+ env: tarEnv,
115
134
  })
116
135
  }
117
136
 
118
137
  async function defaultWriteChecksum({ archivePath, checksumPath }) {
138
+ const archiveName = basename(archivePath)
119
139
  try {
120
- const { stdout } = await execFileAsync('sha256sum', [archivePath])
140
+ const { stdout } = await execFileAsync('sha256sum', [archiveName], {
141
+ cwd: dirname(archivePath),
142
+ })
121
143
  await writeFile(checksumPath, stdout)
122
144
  } catch {
123
- const { stdout } = await execFileAsync('shasum', ['-a', '256', archivePath])
145
+ const { stdout } = await execFileAsync('shasum', ['-a', '256', archiveName], {
146
+ cwd: dirname(archivePath),
147
+ })
124
148
  await writeFile(checksumPath, stdout)
125
149
  }
126
150
  }
@@ -129,7 +153,10 @@ async function defaultCreateBundle({ outputDir, bundlePath, innerArchivePath, ch
129
153
  await execFileAsync(
130
154
  'tar',
131
155
  ['-czf', bundlePath, basename(innerArchivePath), basename(checksumPath)],
132
- { cwd: outputDir },
156
+ {
157
+ cwd: outputDir,
158
+ env: tarEnv,
159
+ },
133
160
  )
134
161
  }
135
162
 
@@ -217,7 +244,7 @@ export async function buildBackendArtifact(config, deps = {}) {
217
244
  const checksumPath = resolveWithinBase(outputDir, names.checksumName, 'checksumPath')
218
245
  const bundlePath = resolveWithinBase(outputDir, names.bundleName, 'bundlePath')
219
246
 
220
- await runBuild(config.build)
247
+ await runBuild(config.build, config.environment)
221
248
  await prepareOutputDir(outputDir)
222
249
  await stageFiles({
223
250
  config,
@@ -157,15 +157,16 @@ find_single_bundle_file() {
157
157
 
158
158
  sha256_check() {
159
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
160
+ local checksum file actual
165
161
  checksum="$(awk '{print $1}' "$checksum_file")"
166
162
  file="$(awk '{print $2}' "$checksum_file")"
167
- expected="$(shasum -a 256 "$file" | awk '{print $1}')"
168
- [[ "$checksum" == "$expected" ]]
163
+ file="$(basename "$file")"
164
+ if command -v sha256sum >/dev/null 2>&1; then
165
+ actual="$(sha256sum "$file" | awk '{print $1}')"
166
+ else
167
+ actual="$(shasum -a 256 "$file" | awk '{print $1}')"
168
+ fi
169
+ [[ "$checksum" == "$actual" ]]
169
170
  }
170
171
 
171
172
  run_with_env() {
@@ -213,7 +214,7 @@ CURRENT_PHASE="extract"
213
214
  echo "DX_REMOTE_PHASE=extract"
214
215
  validate_archive_entries "$ARCHIVE"
215
216
  BUNDLE_TEMP_DIR="$(mktemp -d "$APP_ROOT/.bundle-extract.XXXXXX")"
216
- tar -xzf "$ARCHIVE" -C "$BUNDLE_TEMP_DIR" --strip-components=1
217
+ tar -xzf "$ARCHIVE" -C "$BUNDLE_TEMP_DIR"
217
218
 
218
219
  INNER_ARCHIVE="$(find_single_bundle_file "$BUNDLE_TEMP_DIR" 'backend-v*.tgz')"
219
220
  INNER_ARCHIVE_SHA256_FILE="$(find_single_bundle_file "$BUNDLE_TEMP_DIR" 'backend-v*.tgz.sha256')"
@@ -114,5 +114,9 @@ export async function deployBackendArtifactRemotely(config, bundle, deps = {}) {
114
114
  const phaseModel = createRemotePhaseModel(payload)
115
115
  const script = buildRemoteDeployScript(phaseModel)
116
116
  const commandResult = await runRemoteScript(config.remote, script)
117
- return parseRemoteResult(commandResult)
117
+ const result = parseRemoteResult(commandResult)
118
+ if (!result.ok) {
119
+ throw new Error(`远端部署失败(${result.phase}): ${result.message}`)
120
+ }
121
+ return result
118
122
  }
@@ -1,5 +1,5 @@
1
1
  const UNSUPPORTED_LOCAL_DEP_PATTERN = /^(workspace:|file:|link:)/
2
- const REQUIRED_DEPENDENCIES_FROM_DEV = ['prisma']
2
+ const REQUIRED_DEPENDENCIES = ['prisma', 'tslib', 'dotenv-cli', '@prisma/adapter-pg']
3
3
 
4
4
  function assertSupportedDependencies(dependencies = {}) {
5
5
  for (const [name, version] of Object.entries(dependencies)) {
@@ -13,10 +13,15 @@ function assertSupportedDependencies(dependencies = {}) {
13
13
  export function createRuntimePackage({ appPackage, rootPackage }) {
14
14
  const runtimeDependencies = { ...(appPackage?.dependencies || {}) }
15
15
  const appDevDependencies = appPackage?.devDependencies || {}
16
-
17
- for (const dependencyName of REQUIRED_DEPENDENCIES_FROM_DEV) {
18
- if (!runtimeDependencies[dependencyName] && appDevDependencies[dependencyName]) {
19
- runtimeDependencies[dependencyName] = appDevDependencies[dependencyName]
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]
20
25
  }
21
26
  }
22
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ranger1/dx",
3
- "version": "0.1.81",
3
+ "version": "0.1.83",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,122 +0,0 @@
1
- ---
2
- name: backend-artifact-deploy
3
- description: 将后端部署从“目标机拉源码并编译”改造为“本地构建制品、目标机仅安装运行依赖并启动”的标准流程。用于 Node/NestJS/Nx/Prisma 等后端项目,尤其适合需要无源码部署、支持 dev/staging/prod 多环境、要求双层环境文件覆盖(如 .env.production 与 .env.production.local)、以及需要在 pm2 与 direct 启动方式之间切换的场景。
4
- ---
5
-
6
- # 后端制品部署
7
-
8
- ## 概览
9
-
10
- 使用该技能时,先识别项目当前的环境变量加载链路与启动链路,再落地“制品打包脚本 + 服务器发布脚本 + 回滚策略”。
11
- 目标是保证目标机器不需要源码编译,同时保持与项目既有环境覆盖规则一致。
12
-
13
- ## 执行流程
14
-
15
- ### 第一步:确认现状链路
16
-
17
- 依次核对:
18
-
19
- 1. 构建链路是否依赖源码目录(例如 dist 里软链回源码 `node_modules`)。
20
- 2. 运行链路如何加载环境变量(是否是两层覆盖,是否通过 `dotenv -e A -e B`)。
21
- 3. 数据库迁移链路是否依赖运行时环境(Prisma `generate` / `migrate deploy` 前是否已加载 env)。
22
- 4. 进程启动是否仅支持 pm2,是否需要 direct 前台测试模式。
23
-
24
- 如果项目已有统一入口(例如 `dx` 或内部脚手架),优先复用该入口,不要绕开既有环境策略。
25
-
26
- ### 第二步:定义制品边界
27
-
28
- 默认采用“轻制品”模式:
29
-
30
- 1. 本地只打包编译产物与必要运行文件,不打包 `node_modules`。
31
- 2. 目标机解压后再安装生产依赖。
32
- 3. 制品命名固定含版本与时间片,例如 `backend-v<version>-<月-日-时-分>.tgz`。
33
-
34
- 制品最小清单应包含:
35
-
36
- 1. 编译产物目录(如 `dist/backend/**`)。
37
- 2. 数据库 schema 与迁移目录(如 `prisma/schema/**`)。
38
- 3. 生产依赖清单(`package.production.json` 重命名为 `package.json`)。
39
- 4. 锁文件(`pnpm-lock.yaml`)。
40
- 5. 启动配置(如 `ecosystem.config.cjs`)。
41
- 6. 双层环境文件(`.env.<env>` 与 `.env.<env>.local`)。
42
-
43
- ### 第三步:实现打包脚本
44
-
45
- 打包脚本应支持参数:
46
-
47
- 1. `--env dev|staging|prod`。
48
- 2. `--version`(默认取后端 `package.json` 版本)。
49
- 3. `--time`(格式 `MM-DD-HH-mm`)。
50
-
51
- 脚本关键行为:
52
-
53
- 1. 按环境构建(`dev -> --dev`,`staging/prod -> --prod`)。
54
- 2. 复制双层环境文件到制品目录。
55
- 3. 不在本地安装运行依赖。
56
- 4. 生成 `tgz`。
57
-
58
- ### 第四步:实现发布脚本
59
-
60
- 发布脚本应支持参数:
61
-
62
- 1. `--archive`(必填)。
63
- 2. `--env dev|staging|prod`。
64
- 3. `--start-mode pm2|direct`(默认 `pm2`)。
65
- 4. `--env-file` 与 `--env-local-file`(可选覆盖路径)。
66
- 5. `--skip-install`、`--skip-migration`、`--skip-pm2`。
67
-
68
- 发布顺序建议:
69
-
70
- 1. 解压到 `releases/<version>`。
71
- 2. 准备双层 env 文件。
72
- 3. 安装生产依赖。
73
- 4. 执行 `prisma generate`。
74
- 5. 执行 `prisma migrate deploy`。
75
- 6. 切换 `current` 软链。
76
- 7. 启动服务(pm2 或 direct)。
77
- 8. 清理旧版本。
78
-
79
- ### 第五步:双层环境加载规则(必须一致)
80
-
81
- 所有关键步骤统一使用相同加载顺序:
82
-
83
- 1. 基础层 `.env.<env>`。
84
- 2. 覆盖层 `.env.<env>.local`。
85
-
86
- 推荐显式写法:
87
-
88
- ```bash
89
- APP_ENV="<env-name>" pnpm exec dotenv -e ".env.<env-name>" -e ".env.<env-name>.local" -- <command>
90
- ```
91
-
92
- 命令示例中的 `<command>` 包括:
93
-
94
- 1. `pnpm exec prisma generate --schema=...`
95
- 2. `pnpm exec prisma migrate deploy --schema=...`
96
- 3. `pm2 startOrReload ...` 或 `node apps/backend/src/main.js`
97
-
98
- ## 验证清单
99
-
100
- 交付前必须至少验证:
101
-
102
- 1. 打包脚本 `--help` 与语法检查通过。
103
- 2. 发布脚本 `--help` 与语法检查通过。
104
- 3. 制品内同时包含 `.env.<env>` 与 `.env.<env>.local`。
105
- 4. 发布脚本在默认路径下能正确识别并使用两层 env。
106
- 5. `start-mode=direct` 可前台启动。
107
- 6. `start-mode=pm2` 可重载或启动。
108
- 7. 版本目录与 `current` 切换正常,可回滚。
109
-
110
- ## 常见陷阱
111
-
112
- 1. 把 env 文件链接到自身,造成坏链路。
113
- 2. 只加载 `.env.<env>`,遗漏 `.local` 覆盖。
114
- 3. 迁移与启动阶段用不同 env 加载逻辑,导致行为不一致。
115
- 4. staging 构建误用 development 或 production 的 env 层。
116
- 5. 打包包含本机 `node_modules`,跨系统运行失败。
117
-
118
- ## 参考资料
119
-
120
- 需要细化实现时,读取:
121
-
122
- - `references/deployment-checklist.md`
@@ -1,4 +0,0 @@
1
- interface:
2
- display_name: "后端制品部署"
3
- short_description: "跨项目复用的后端制品构建发布与双层环境变量合并流程"
4
- default_prompt: "Use $backend-artifact-deploy to design and implement source-free backend artifact deployment with layered env handling."
@@ -1,66 +0,0 @@
1
- # 后端制品部署检查清单
2
-
3
- ## 一、改造前采样
4
-
5
- 1. 查找构建后是否存在软链回源码依赖:
6
-
7
- ```bash
8
- rg -n "ln -sfn.*node_modules|node_modules.*ln -sfn" <backend-package-json-path>
9
- ```
10
-
11
- 2. 查找环境加载入口:
12
-
13
- ```bash
14
- rg -n "dotenv -e|dotenv --override|ConfigModule.forRoot|loadEnvironment|env-layers" -S <repo-root>
15
- ```
16
-
17
- 3. 查找启动命令:
18
-
19
- ```bash
20
- rg -n "start:prod|pm2|node .*main" -S <repo-root>
21
- ```
22
-
23
- ## 二、打包脚本最低要求
24
-
25
- 1. 接收 `--env`、`--version`、`--time`。
26
- 2. 制品名包含版本与时间片。
27
- 3. 打入 `.env.<env>` 与 `.env.<env>.local`。
28
- 4. 不打入 `node_modules`(轻制品模式)。
29
-
30
- ## 三、发布脚本最低要求
31
-
32
- 1. 解压到 `releases/<version>` 并切换 `current`。
33
- 2. 支持 `pm2` 与 `direct` 两种启动方式。
34
- 3. 在 install、migrate、start 三阶段都用同一套双层 env 加载顺序。
35
- 4. 支持 `--env-file` 与 `--env-local-file` 覆盖路径。
36
-
37
- ## 四、上线前验证命令
38
-
39
- ```bash
40
- bash -n scripts/release/backend-build-release.sh
41
- bash -n scripts/release/backend-deploy-release.sh
42
- scripts/release/backend-build-release.sh --env staging
43
- tar -tzf release/backend/*.tgz | rg "\.env\.staging(\.local)?$"
44
- ```
45
-
46
- ## 五、发布后验证
47
-
48
- 1. 进程检查:
49
-
50
- ```bash
51
- pm2 status
52
- pm2 logs backend --lines 120
53
- ```
54
-
55
- 2. 健康检查:
56
-
57
- ```bash
58
- curl -f http://127.0.0.1:3000/health
59
- ```
60
-
61
- 3. 回滚检查:
62
-
63
- ```bash
64
- ln -sfn /opt/ai-backend/releases/<old-version> /opt/ai-backend/current
65
- pm2 reload backend --update-env
66
- ```