@ranger1/dx 0.1.52 → 0.1.53

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.
@@ -101,5 +101,9 @@ export async function handleDeploy(cli, args) {
101
101
  ? parseTelegramWebhookFlags(cli.args)
102
102
  : null
103
103
 
104
- await deployToVercel(normalizedTarget, { environment, telegramWebhook })
104
+ const strictContext = process.env.DX_VERCEL_STRICT_CONTEXT != null
105
+ ? !['0', 'false', 'no'].includes(String(process.env.DX_VERCEL_STRICT_CONTEXT).toLowerCase())
106
+ : true
107
+
108
+ await deployToVercel(normalizedTarget, { environment, telegramWebhook, strictContext })
105
109
  }
@@ -1,9 +1,41 @@
1
1
  import { execSync, spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
2
3
  import { join } from 'node:path'
3
4
  import { envManager } from './env.js'
4
5
  import { logger } from './logger.js'
5
6
 
6
7
  const ALLOWED_ENVIRONMENTS = ['development', 'staging', 'production']
8
+ const VALID_TARGETS = ['front', 'admin', 'telegram-bot']
9
+
10
+ const TARGET_CONFIGS = {
11
+ front: {
12
+ configFile: 'vercel.front.json',
13
+ projectIdEnvVar: 'VERCEL_PROJECT_ID_FRONT',
14
+ },
15
+ admin: {
16
+ configFile: 'vercel.admin.json',
17
+ projectIdEnvVar: 'VERCEL_PROJECT_ID_ADMIN',
18
+ },
19
+ 'telegram-bot': {
20
+ configFile: 'vercel.telegram-bot.json',
21
+ projectIdEnvVar: 'VERCEL_PROJECT_ID_TELEGRAM_BOT',
22
+ },
23
+ }
24
+
25
+ const EXPLICIT_ENV_VARS = ['APP_ENV', 'NODE_ENV']
26
+ const PUBLIC_ENV_PREFIXES = ['NEXT_PUBLIC_', 'VITE_']
27
+
28
+ const APP_ENV_MAP = {
29
+ development: 'dev',
30
+ staging: 'staging',
31
+ production: 'prod',
32
+ }
33
+
34
+ const VERCEL_PROJECT_LINK_PATH = '.vercel/project.json'
35
+
36
+ function getTargetConfig(target) {
37
+ return TARGET_CONFIGS[target]
38
+ }
7
39
 
8
40
  function collectErrorText(err) {
9
41
  const parts = []
@@ -22,6 +54,122 @@ function isMissingFilesError(err) {
22
54
  )
23
55
  }
24
56
 
57
+ function listMissingVarKeys(targetConfigs, token, orgId) {
58
+ const missing = []
59
+
60
+ if (!token || envManager.isPlaceholderEnvValue(token)) {
61
+ missing.push('VERCEL_TOKEN')
62
+ }
63
+
64
+ if (!orgId || envManager.isPlaceholderEnvValue(orgId)) {
65
+ missing.push('VERCEL_ORG_ID')
66
+ }
67
+
68
+ for (const config of targetConfigs) {
69
+ const projectId = process.env[config.projectIdEnvVar]
70
+ if (!projectId || envManager.isPlaceholderEnvValue(projectId)) {
71
+ missing.push(config.projectIdEnvVar)
72
+ }
73
+ }
74
+
75
+ return [...new Set(missing)]
76
+ }
77
+
78
+ function listMissingConfigs(targetConfigs, projectRoot) {
79
+ const missing = []
80
+
81
+ for (const config of targetConfigs) {
82
+ const configPath = join(projectRoot, config.configFile)
83
+ if (!existsSync(configPath)) {
84
+ missing.push(config.configFile)
85
+ }
86
+ }
87
+
88
+ return missing
89
+ }
90
+
91
+ function appendTargetArgs(baseArgs, { cwd, orgId, projectId, explicitEnvArgs = [] }) {
92
+ const args = [...baseArgs]
93
+
94
+ if (explicitEnvArgs.length > 0) {
95
+ args.push(...explicitEnvArgs)
96
+ }
97
+
98
+ if (cwd) {
99
+ args.push('--cwd', cwd)
100
+ }
101
+
102
+ if (orgId) {
103
+ args.push('--scope', orgId)
104
+ }
105
+
106
+ if (projectId) {
107
+ args.push('--project', projectId)
108
+ }
109
+
110
+ return args
111
+ }
112
+
113
+ function collectExplicitVercelEnvArgs(sourceEnv = {}) {
114
+ const includeKeys = new Set(EXPLICIT_ENV_VARS)
115
+
116
+ Object.keys(sourceEnv).forEach(name => {
117
+ if (PUBLIC_ENV_PREFIXES.some(prefix => name.startsWith(prefix))) {
118
+ includeKeys.add(name)
119
+ }
120
+ })
121
+
122
+ return [...includeKeys]
123
+ .filter(name => {
124
+ const value = sourceEnv[name]
125
+ return !(
126
+ value === undefined ||
127
+ value === null ||
128
+ envManager.isPlaceholderEnvValue(value)
129
+ )
130
+ })
131
+ .sort()
132
+ .flatMap(name => ['--env', `${name}=${String(sourceEnv[name])}`])
133
+ }
134
+
135
+ function maskIdentifier(value) {
136
+ const raw = String(value || '').trim()
137
+ if (raw.length <= 10) return raw || '-'
138
+ return `${raw.slice(0, 6)}...${raw.slice(-4)}`
139
+ }
140
+
141
+ function readLinkedProjectContext(projectRoot) {
142
+ const path = join(projectRoot, VERCEL_PROJECT_LINK_PATH)
143
+ if (!existsSync(path)) {
144
+ return { exists: false, path, orgId: null, projectId: null, parseError: null }
145
+ }
146
+
147
+ try {
148
+ const content = readFileSync(path, 'utf8')
149
+ const parsed = JSON.parse(content)
150
+ return {
151
+ exists: true,
152
+ path,
153
+ orgId: parsed?.orgId || null,
154
+ projectId: parsed?.projectId || null,
155
+ parseError: null,
156
+ }
157
+ } catch (error) {
158
+ return {
159
+ exists: true,
160
+ path,
161
+ orgId: null,
162
+ projectId: null,
163
+ parseError: error,
164
+ }
165
+ }
166
+ }
167
+
168
+ function clearLinkedProjectContext(projectRoot) {
169
+ const path = join(projectRoot, VERCEL_PROJECT_LINK_PATH)
170
+ rmSync(path, { force: true })
171
+ }
172
+
25
173
  async function runVercel(args, options = {}) {
26
174
  const { env, cwd } = options
27
175
  const MAX_CAPTURE = 20000
@@ -101,7 +249,12 @@ export async function deployPrebuiltWithFallback(options) {
101
249
  }
102
250
 
103
251
  export async function deployToVercel(target, options = {}) {
104
- const { environment = 'staging', telegramWebhook = null } = options
252
+ const {
253
+ environment = 'staging',
254
+ telegramWebhook = null,
255
+ strictContext = true,
256
+ run = runVercel,
257
+ } = options
105
258
 
106
259
  // 校验环境参数
107
260
  if (!ALLOWED_ENVIRONMENTS.includes(environment)) {
@@ -111,18 +264,13 @@ export async function deployToVercel(target, options = {}) {
111
264
  return
112
265
  }
113
266
 
114
- const token = process.env.VERCEL_TOKEN
115
- const orgId = process.env.VERCEL_ORG_ID
116
- const projectIdFront = process.env.VERCEL_PROJECT_ID_FRONT
117
- const projectIdAdmin = process.env.VERCEL_PROJECT_ID_ADMIN
118
- const projectIdTelegramBot = process.env.VERCEL_PROJECT_ID_TELEGRAM_BOT
119
-
120
267
  const normalizedTarget = String(target || '').toLowerCase()
121
268
  const targets = normalizedTarget === 'all' ? ['front', 'admin'] : [normalizedTarget]
269
+ const projectRoot = process.cwd()
122
270
 
123
271
  // 校验目标有效性
124
272
  for (const t of targets) {
125
- if (!['front', 'admin', 'telegram-bot'].includes(t)) {
273
+ if (!VALID_TARGETS.includes(t)) {
126
274
  logger.error(`不支持的部署目标: ${t}`)
127
275
  logger.info('可用目标: front, admin, telegram-bot, all')
128
276
  process.exitCode = 1
@@ -130,47 +278,43 @@ export async function deployToVercel(target, options = {}) {
130
278
  }
131
279
  }
132
280
 
133
- // 收集缺失的环境变量
134
- const missingVars = []
135
-
136
- if (!token || envManager.isPlaceholderEnvValue(token)) {
137
- missingVars.push('VERCEL_TOKEN')
138
- }
139
-
140
- if (!orgId || envManager.isPlaceholderEnvValue(orgId)) {
141
- missingVars.push('VERCEL_ORG_ID')
142
- }
281
+ const targetConfigs = targets.map(getTargetConfig)
282
+ const token = process.env.VERCEL_TOKEN
283
+ const orgId = process.env.VERCEL_ORG_ID
143
284
 
144
- // 根据目标检查对应的 PROJECT_ID
145
- if (targets.includes('front') && (!projectIdFront || envManager.isPlaceholderEnvValue(projectIdFront))) {
146
- missingVars.push('VERCEL_PROJECT_ID_FRONT')
147
- }
285
+ const missingVars = listMissingVarKeys(targetConfigs, token, orgId)
286
+ const missingConfigFiles = listMissingConfigs(targetConfigs, projectRoot)
148
287
 
149
- if (targets.includes('admin') && (!projectIdAdmin || envManager.isPlaceholderEnvValue(projectIdAdmin))) {
150
- missingVars.push('VERCEL_PROJECT_ID_ADMIN')
151
- }
288
+ if (missingVars.length > 0 || missingConfigFiles.length > 0) {
289
+ if (missingVars.length > 0) {
290
+ logger.error('缺少以下 Vercel 环境变量:')
291
+ missingVars.forEach(v => {
292
+ logger.error(` - ${v}`)
293
+ })
294
+ logger.info('')
295
+ logger.info('请在 .env.<env>.local 中配置这些变量,例如:')
296
+ logger.info(' VERCEL_TOKEN=<your-vercel-token>')
297
+ logger.info(' VERCEL_ORG_ID=team_xxx')
298
+ logger.info(' VERCEL_PROJECT_ID_FRONT=prj_xxx')
299
+ logger.info(' VERCEL_PROJECT_ID_ADMIN=prj_xxx')
300
+ logger.info(' VERCEL_PROJECT_ID_TELEGRAM_BOT=prj_xxx')
301
+ logger.info('')
302
+ logger.info('获取方式:')
303
+ logger.info(' 1. VERCEL_TOKEN: vercel login 后查看 ~/Library/Application Support/com.vercel.cli/auth.json')
304
+ logger.info(' 2. PROJECT_ID: vercel project ls --scope <org> 或通过 Vercel Dashboard 获取')
305
+ logger.info('')
306
+ logger.info('提示:部署命令会显式校验 --scope 与 --project,避免环境漂移。')
307
+ }
152
308
 
153
- if (targets.includes('telegram-bot') && (!projectIdTelegramBot || envManager.isPlaceholderEnvValue(projectIdTelegramBot))) {
154
- missingVars.push('VERCEL_PROJECT_ID_TELEGRAM_BOT')
155
- }
309
+ if (missingConfigFiles.length > 0) {
310
+ logger.error('缺少以下 Vercel 配置文件:')
311
+ missingConfigFiles.forEach(name => {
312
+ logger.error(` - ${name}`)
313
+ })
314
+ logger.info('')
315
+ logger.info('请确认同级目录存在对应的 vercel.*.json 文件。')
316
+ }
156
317
 
157
- // 如果有缺失变量,统一报错并退出
158
- if (missingVars.length > 0) {
159
- logger.error('缺少以下 Vercel 环境变量:')
160
- missingVars.forEach(v => {
161
- logger.error(` - ${v}`)
162
- })
163
- logger.info('')
164
- logger.info('请在 .env.<env>.local 中配置这些变量,例如:')
165
- logger.info(' VERCEL_TOKEN=<your-vercel-token>')
166
- logger.info(' VERCEL_ORG_ID=team_xxx')
167
- logger.info(' VERCEL_PROJECT_ID_FRONT=prj_xxx')
168
- logger.info(' VERCEL_PROJECT_ID_ADMIN=prj_xxx')
169
- logger.info(' VERCEL_PROJECT_ID_TELEGRAM_BOT=prj_xxx')
170
- logger.info('')
171
- logger.info('获取方式:')
172
- logger.info(' 1. VERCEL_TOKEN: vercel login 后查看 ~/Library/Application Support/com.vercel.cli/auth.json')
173
- logger.info(' 2. PROJECT_ID: vercel project ls --scope <org> 或通过 Vercel Dashboard 获取')
174
318
  process.exitCode = 1
175
319
  return
176
320
  }
@@ -180,40 +324,64 @@ export async function deployToVercel(target, options = {}) {
180
324
  // - 这样 dx deploy 能兼容不同 monorepo 布局(不强依赖 apps/sdk 等目录)。
181
325
 
182
326
  // 映射环境标识:development -> dev, staging -> staging, production -> prod
183
- const envMap = {
184
- development: 'dev',
185
- staging: 'staging',
186
- production: 'prod',
187
- }
188
- const buildEnv = envMap[environment]
327
+ const buildEnv = APP_ENV_MAP[environment]
189
328
 
190
329
  for (const t of targets) {
191
- // 配置文件映射
192
- const configFileMap = {
193
- front: 'vercel.front.json',
194
- admin: 'vercel.admin.json',
195
- 'telegram-bot': 'vercel.telegram-bot.json',
330
+ const targetConfig = getTargetConfig(t)
331
+ const projectId = process.env[targetConfig.projectIdEnvVar]
332
+ const configFile = targetConfig.configFile
333
+ const configPath = join(projectRoot, configFile)
334
+ const linkedContext = readLinkedProjectContext(projectRoot)
335
+
336
+ const linkedMismatch =
337
+ linkedContext.exists &&
338
+ linkedContext.parseError == null &&
339
+ ((linkedContext.orgId && linkedContext.orgId !== orgId) ||
340
+ (linkedContext.projectId && linkedContext.projectId !== projectId))
341
+
342
+ if (linkedContext.exists && linkedContext.parseError) {
343
+ logger.warn(
344
+ `检测到 ${VERCEL_PROJECT_LINK_PATH} 但解析失败: ${linkedContext.parseError.message}`,
345
+ )
346
+ if (strictContext) {
347
+ logger.error('strictContext 已开启,已阻止继续部署以避免回退污染')
348
+ process.exitCode = 1
349
+ return
350
+ }
196
351
  }
197
352
 
198
- // PROJECT_ID 映射
199
- const projectIdMap = {
200
- front: projectIdFront,
201
- admin: projectIdAdmin,
202
- 'telegram-bot': projectIdTelegramBot,
353
+ if (linkedMismatch) {
354
+ logger.error('检测到 .vercel 链接冲突')
355
+ logger.error(` 当前目标: org=${maskIdentifier(orgId)} project=${maskIdentifier(projectId)}`)
356
+ logger.error(
357
+ ` 本地链接: org=${maskIdentifier(linkedContext.orgId)} project=${maskIdentifier(linkedContext.projectId)}`,
358
+ )
359
+ if (strictContext) {
360
+ logger.error('strictContext 已开启,已阻止部署(请清理 .vercel 或修正环境变量)')
361
+ process.exitCode = 1
362
+ return
363
+ }
364
+ logger.warn('strictContext 已关闭,继续执行(可能存在误部署风险)')
203
365
  }
204
366
 
205
- const configFile = configFileMap[t]
206
- const projectId = projectIdMap[t]
207
- const configPath = join(process.cwd(), configFile)
367
+ logger.info(
368
+ `[deploy-context] env=${environment} target=${t} strict=${strictContext ? 1 : 0} org=${maskIdentifier(orgId)} project=${maskIdentifier(projectId)} linked=${linkedContext.exists ? 'yes' : 'no'} token=env`,
369
+ )
370
+
371
+ const explicitEnvArgs = collectExplicitVercelEnvArgs({
372
+ ...process.env,
373
+ APP_ENV: buildEnv,
374
+ NODE_ENV: envManager.mapAppEnvToNodeEnv(environment),
375
+ VERCEL_ORG_ID: orgId,
376
+ VERCEL_PROJECT_ID: projectId,
377
+ })
208
378
 
209
379
  const envVars = {
210
380
  ...process.env,
211
381
  VERCEL_PROJECT_ID: projectId,
212
382
  APP_ENV: buildEnv,
213
- }
214
-
215
- if (orgId) {
216
- envVars.VERCEL_ORG_ID = orgId
383
+ NODE_ENV: envManager.mapAppEnvToNodeEnv(environment),
384
+ VERCEL_ORG_ID: orgId,
217
385
  }
218
386
 
219
387
  // 不通过 CLI args 传递 token,避免出现在错误信息/日志中
@@ -235,39 +403,52 @@ export async function deployToVercel(target, options = {}) {
235
403
  }
236
404
 
237
405
  try {
406
+ if (strictContext && process.env.DX_VERCEL_KEEP_LINK !== '1') {
407
+ clearLinkedProjectContext(projectRoot)
408
+ }
409
+
238
410
  // 第一步:本地构建
239
411
  logger.step(`本地构建 ${t} (${environment})`)
240
- const buildArgs = ['build', '--local-config', configPath, '--yes']
412
+ const buildArgs = appendTargetArgs(
413
+ ['build', '--local-config', configPath, '--yes'],
414
+ {
415
+ cwd: projectRoot,
416
+ orgId,
417
+ projectId,
418
+ explicitEnvArgs,
419
+ },
420
+ )
241
421
 
242
422
  // staging 和 production 环境需要 --prod 标志,确保构建产物与部署环境匹配
243
423
  if (environment === 'staging' || environment === 'production') {
244
424
  buildArgs.push('--prod')
245
425
  }
246
426
 
247
- if (orgId) {
248
- buildArgs.push('--scope', orgId)
249
- }
250
-
251
- await runVercel(buildArgs, { env: envVars, cwd: process.cwd() })
427
+ await run(buildArgs, { env: envVars, cwd: projectRoot })
252
428
  logger.success(`${t} 本地构建成功`)
253
429
 
254
430
  // 第二步:上传预构建产物
255
431
  logger.step(`部署 ${t} 到 Vercel (${environment})`)
256
- const baseDeployArgs = ['deploy', '--prebuilt', '--local-config', configPath, '--yes']
432
+ const baseDeployArgs = appendTargetArgs(
433
+ ['deploy', '--prebuilt', '--local-config', configPath, '--yes'],
434
+ {
435
+ cwd: projectRoot,
436
+ orgId,
437
+ projectId,
438
+ explicitEnvArgs,
439
+ },
440
+ )
257
441
 
258
442
  // staging 和 production 环境都添加 --prod 标志以绑定固定域名
259
443
  if (environment === 'staging' || environment === 'production') {
260
444
  baseDeployArgs.push('--prod')
261
445
  }
262
446
 
263
- if (orgId) {
264
- baseDeployArgs.push('--scope', orgId)
265
- }
266
-
267
447
  const deployResult = await deployPrebuiltWithFallback({
268
448
  baseArgs: baseDeployArgs,
269
449
  env: envVars,
270
- cwd: process.cwd(),
450
+ cwd: projectRoot,
451
+ run,
271
452
  })
272
453
 
273
454
  const deployOutput = [deployResult?.result?.stdout, deployResult?.result?.stderr]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ranger1/dx",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {