@ranger1/dx 0.1.52 → 0.1.54

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,127 @@ 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 collectExplicitVercelEnvEntries(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
+ .map(name => `${name}=${String(sourceEnv[name])}`)
133
+ }
134
+
135
+ function withEnvFlag(entries, flag) {
136
+ if (!Array.isArray(entries) || entries.length === 0) return []
137
+ return entries.flatMap(entry => [flag, entry])
138
+ }
139
+
140
+ function maskIdentifier(value) {
141
+ const raw = String(value || '').trim()
142
+ if (raw.length <= 10) return raw || '-'
143
+ return `${raw.slice(0, 6)}...${raw.slice(-4)}`
144
+ }
145
+
146
+ function readLinkedProjectContext(projectRoot) {
147
+ const path = join(projectRoot, VERCEL_PROJECT_LINK_PATH)
148
+ if (!existsSync(path)) {
149
+ return { exists: false, path, orgId: null, projectId: null, parseError: null }
150
+ }
151
+
152
+ try {
153
+ const content = readFileSync(path, 'utf8')
154
+ const parsed = JSON.parse(content)
155
+ return {
156
+ exists: true,
157
+ path,
158
+ orgId: parsed?.orgId || null,
159
+ projectId: parsed?.projectId || null,
160
+ parseError: null,
161
+ }
162
+ } catch (error) {
163
+ return {
164
+ exists: true,
165
+ path,
166
+ orgId: null,
167
+ projectId: null,
168
+ parseError: error,
169
+ }
170
+ }
171
+ }
172
+
173
+ function clearLinkedProjectContext(projectRoot) {
174
+ const path = join(projectRoot, VERCEL_PROJECT_LINK_PATH)
175
+ rmSync(path, { force: true })
176
+ }
177
+
25
178
  async function runVercel(args, options = {}) {
26
179
  const { env, cwd } = options
27
180
  const MAX_CAPTURE = 20000
@@ -101,7 +254,12 @@ export async function deployPrebuiltWithFallback(options) {
101
254
  }
102
255
 
103
256
  export async function deployToVercel(target, options = {}) {
104
- const { environment = 'staging', telegramWebhook = null } = options
257
+ const {
258
+ environment = 'staging',
259
+ telegramWebhook = null,
260
+ strictContext = true,
261
+ run = runVercel,
262
+ } = options
105
263
 
106
264
  // 校验环境参数
107
265
  if (!ALLOWED_ENVIRONMENTS.includes(environment)) {
@@ -111,18 +269,13 @@ export async function deployToVercel(target, options = {}) {
111
269
  return
112
270
  }
113
271
 
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
272
  const normalizedTarget = String(target || '').toLowerCase()
121
273
  const targets = normalizedTarget === 'all' ? ['front', 'admin'] : [normalizedTarget]
274
+ const projectRoot = process.cwd()
122
275
 
123
276
  // 校验目标有效性
124
277
  for (const t of targets) {
125
- if (!['front', 'admin', 'telegram-bot'].includes(t)) {
278
+ if (!VALID_TARGETS.includes(t)) {
126
279
  logger.error(`不支持的部署目标: ${t}`)
127
280
  logger.info('可用目标: front, admin, telegram-bot, all')
128
281
  process.exitCode = 1
@@ -130,47 +283,43 @@ export async function deployToVercel(target, options = {}) {
130
283
  }
131
284
  }
132
285
 
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
- }
286
+ const targetConfigs = targets.map(getTargetConfig)
287
+ const token = process.env.VERCEL_TOKEN
288
+ const orgId = process.env.VERCEL_ORG_ID
143
289
 
144
- // 根据目标检查对应的 PROJECT_ID
145
- if (targets.includes('front') && (!projectIdFront || envManager.isPlaceholderEnvValue(projectIdFront))) {
146
- missingVars.push('VERCEL_PROJECT_ID_FRONT')
147
- }
290
+ const missingVars = listMissingVarKeys(targetConfigs, token, orgId)
291
+ const missingConfigFiles = listMissingConfigs(targetConfigs, projectRoot)
148
292
 
149
- if (targets.includes('admin') && (!projectIdAdmin || envManager.isPlaceholderEnvValue(projectIdAdmin))) {
150
- missingVars.push('VERCEL_PROJECT_ID_ADMIN')
151
- }
293
+ if (missingVars.length > 0 || missingConfigFiles.length > 0) {
294
+ if (missingVars.length > 0) {
295
+ logger.error('缺少以下 Vercel 环境变量:')
296
+ missingVars.forEach(v => {
297
+ logger.error(` - ${v}`)
298
+ })
299
+ logger.info('')
300
+ logger.info('请在 .env.<env>.local 中配置这些变量,例如:')
301
+ logger.info(' VERCEL_TOKEN=<your-vercel-token>')
302
+ logger.info(' VERCEL_ORG_ID=team_xxx')
303
+ logger.info(' VERCEL_PROJECT_ID_FRONT=prj_xxx')
304
+ logger.info(' VERCEL_PROJECT_ID_ADMIN=prj_xxx')
305
+ logger.info(' VERCEL_PROJECT_ID_TELEGRAM_BOT=prj_xxx')
306
+ logger.info('')
307
+ logger.info('获取方式:')
308
+ logger.info(' 1. VERCEL_TOKEN: vercel login 后查看 ~/Library/Application Support/com.vercel.cli/auth.json')
309
+ logger.info(' 2. PROJECT_ID: vercel project ls --scope <org> 或通过 Vercel Dashboard 获取')
310
+ logger.info('')
311
+ logger.info('提示:部署命令会显式校验 --scope 与 --project,避免环境漂移。')
312
+ }
152
313
 
153
- if (targets.includes('telegram-bot') && (!projectIdTelegramBot || envManager.isPlaceholderEnvValue(projectIdTelegramBot))) {
154
- missingVars.push('VERCEL_PROJECT_ID_TELEGRAM_BOT')
155
- }
314
+ if (missingConfigFiles.length > 0) {
315
+ logger.error('缺少以下 Vercel 配置文件:')
316
+ missingConfigFiles.forEach(name => {
317
+ logger.error(` - ${name}`)
318
+ })
319
+ logger.info('')
320
+ logger.info('请确认同级目录存在对应的 vercel.*.json 文件。')
321
+ }
156
322
 
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
323
  process.exitCode = 1
175
324
  return
176
325
  }
@@ -180,40 +329,67 @@ export async function deployToVercel(target, options = {}) {
180
329
  // - 这样 dx deploy 能兼容不同 monorepo 布局(不强依赖 apps/sdk 等目录)。
181
330
 
182
331
  // 映射环境标识:development -> dev, staging -> staging, production -> prod
183
- const envMap = {
184
- development: 'dev',
185
- staging: 'staging',
186
- production: 'prod',
187
- }
188
- const buildEnv = envMap[environment]
332
+ const buildEnv = APP_ENV_MAP[environment]
189
333
 
190
334
  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',
335
+ const targetConfig = getTargetConfig(t)
336
+ const projectId = process.env[targetConfig.projectIdEnvVar]
337
+ const configFile = targetConfig.configFile
338
+ const configPath = join(projectRoot, configFile)
339
+ const linkedContext = readLinkedProjectContext(projectRoot)
340
+
341
+ const linkedMismatch =
342
+ linkedContext.exists &&
343
+ linkedContext.parseError == null &&
344
+ ((linkedContext.orgId && linkedContext.orgId !== orgId) ||
345
+ (linkedContext.projectId && linkedContext.projectId !== projectId))
346
+
347
+ if (linkedContext.exists && linkedContext.parseError) {
348
+ logger.warn(
349
+ `检测到 ${VERCEL_PROJECT_LINK_PATH} 但解析失败: ${linkedContext.parseError.message}`,
350
+ )
351
+ if (strictContext) {
352
+ logger.error('strictContext 已开启,已阻止继续部署以避免回退污染')
353
+ process.exitCode = 1
354
+ return
355
+ }
196
356
  }
197
357
 
198
- // PROJECT_ID 映射
199
- const projectIdMap = {
200
- front: projectIdFront,
201
- admin: projectIdAdmin,
202
- 'telegram-bot': projectIdTelegramBot,
358
+ if (linkedMismatch) {
359
+ logger.error('检测到 .vercel 链接冲突')
360
+ logger.error(` 当前目标: org=${maskIdentifier(orgId)} project=${maskIdentifier(projectId)}`)
361
+ logger.error(
362
+ ` 本地链接: org=${maskIdentifier(linkedContext.orgId)} project=${maskIdentifier(linkedContext.projectId)}`,
363
+ )
364
+ if (strictContext) {
365
+ logger.error('strictContext 已开启,已阻止部署(请清理 .vercel 或修正环境变量)')
366
+ process.exitCode = 1
367
+ return
368
+ }
369
+ logger.warn('strictContext 已关闭,继续执行(可能存在误部署风险)')
203
370
  }
204
371
 
205
- const configFile = configFileMap[t]
206
- const projectId = projectIdMap[t]
207
- const configPath = join(process.cwd(), configFile)
372
+ logger.info(
373
+ `[deploy-context] env=${environment} target=${t} strict=${strictContext ? 1 : 0} org=${maskIdentifier(orgId)} project=${maskIdentifier(projectId)} linked=${linkedContext.exists ? 'yes' : 'no'} token=env`,
374
+ )
375
+
376
+ const explicitEnvEntries = collectExplicitVercelEnvEntries({
377
+ ...process.env,
378
+ APP_ENV: buildEnv,
379
+ NODE_ENV: envManager.mapAppEnvToNodeEnv(environment),
380
+ VERCEL_ORG_ID: orgId,
381
+ VERCEL_PROJECT_ID: projectId,
382
+ })
383
+
384
+ const buildEnvArgs = withEnvFlag(explicitEnvEntries, '--build-env')
385
+ const deployEnvArgs = withEnvFlag(explicitEnvEntries, '--env')
208
386
 
209
387
  const envVars = {
210
388
  ...process.env,
211
389
  VERCEL_PROJECT_ID: projectId,
212
390
  APP_ENV: buildEnv,
213
- }
214
-
215
- if (orgId) {
216
- envVars.VERCEL_ORG_ID = orgId
391
+ NODE_ENV: envManager.mapAppEnvToNodeEnv(environment),
392
+ VERCEL_ORG_ID: orgId,
217
393
  }
218
394
 
219
395
  // 不通过 CLI args 传递 token,避免出现在错误信息/日志中
@@ -235,39 +411,52 @@ export async function deployToVercel(target, options = {}) {
235
411
  }
236
412
 
237
413
  try {
414
+ if (strictContext && process.env.DX_VERCEL_KEEP_LINK !== '1') {
415
+ clearLinkedProjectContext(projectRoot)
416
+ }
417
+
238
418
  // 第一步:本地构建
239
419
  logger.step(`本地构建 ${t} (${environment})`)
240
- const buildArgs = ['build', '--local-config', configPath, '--yes']
420
+ const buildArgs = appendTargetArgs(
421
+ ['build', '--local-config', configPath, '--yes'],
422
+ {
423
+ cwd: projectRoot,
424
+ orgId,
425
+ projectId,
426
+ explicitEnvArgs: buildEnvArgs,
427
+ },
428
+ )
241
429
 
242
430
  // staging 和 production 环境需要 --prod 标志,确保构建产物与部署环境匹配
243
431
  if (environment === 'staging' || environment === 'production') {
244
432
  buildArgs.push('--prod')
245
433
  }
246
434
 
247
- if (orgId) {
248
- buildArgs.push('--scope', orgId)
249
- }
250
-
251
- await runVercel(buildArgs, { env: envVars, cwd: process.cwd() })
435
+ await run(buildArgs, { env: envVars, cwd: projectRoot })
252
436
  logger.success(`${t} 本地构建成功`)
253
437
 
254
438
  // 第二步:上传预构建产物
255
439
  logger.step(`部署 ${t} 到 Vercel (${environment})`)
256
- const baseDeployArgs = ['deploy', '--prebuilt', '--local-config', configPath, '--yes']
440
+ const baseDeployArgs = appendTargetArgs(
441
+ ['deploy', '--prebuilt', '--local-config', configPath, '--yes'],
442
+ {
443
+ cwd: projectRoot,
444
+ orgId,
445
+ projectId,
446
+ explicitEnvArgs: deployEnvArgs,
447
+ },
448
+ )
257
449
 
258
450
  // staging 和 production 环境都添加 --prod 标志以绑定固定域名
259
451
  if (environment === 'staging' || environment === 'production') {
260
452
  baseDeployArgs.push('--prod')
261
453
  }
262
454
 
263
- if (orgId) {
264
- baseDeployArgs.push('--scope', orgId)
265
- }
266
-
267
455
  const deployResult = await deployPrebuiltWithFallback({
268
456
  baseArgs: baseDeployArgs,
269
457
  env: envVars,
270
- cwd: process.cwd(),
458
+ cwd: projectRoot,
459
+ run,
271
460
  })
272
461
 
273
462
  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.54",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {