@ranger1/dx 0.1.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.
@@ -0,0 +1,284 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const ROOT_DIR = process.env.DX_PROJECT_ROOT || process.cwd()
5
+ const CONFIG_DIR = process.env.DX_CONFIG_DIR || join(ROOT_DIR, 'dx', 'config')
6
+ const ROOT_ENV_FILE = join(ROOT_DIR, '.env')
7
+ const EXTRA_ENV_IGNORED_DIRS = new Set([
8
+ '.git',
9
+ '.vercel',
10
+ 'node_modules',
11
+ '.nx',
12
+ 'dist',
13
+ 'logs',
14
+ 'tmp',
15
+ '.schaltwerk',
16
+ ])
17
+ const EXTRA_ENV_ALLOWED_PATHS = new Set(['docker/.env', 'docker/.env.example'])
18
+ const LOCAL_ALLOWLIST_CONFIG = join(CONFIG_DIR, 'local-env-allowlist.jsonc')
19
+ const EXEMPTED_KEYS_CONFIG = join(CONFIG_DIR, 'exempted-keys.jsonc')
20
+ const ENV_EXAMPLE_FILE = join(ROOT_DIR, '.env.example')
21
+ const PLACEHOLDER_TOKEN = '__SET_IN_env.local__'
22
+
23
+ const LOCAL_ENV_FILES = [
24
+ '.env.development.local',
25
+ '.env.production.local',
26
+ '.env.test.local',
27
+ '.env.e2e.local',
28
+ '.env.staging.local',
29
+ ]
30
+
31
+ let cachedAllowlist = null
32
+ let cachedExemptedKeys = null
33
+
34
+ export function validateEnvironment() {
35
+ if (!process.env.NODE_ENV) {
36
+ console.warn('⚠️ NODE_ENV 未设置,默认使用 development')
37
+ process.env.NODE_ENV = 'development'
38
+ }
39
+
40
+ if (typeof process.env.APP_ENV === 'string' && process.env.APP_ENV.trim() === '') {
41
+ delete process.env.APP_ENV
42
+ }
43
+
44
+ enforceRootOnlyEnvFiles()
45
+ enforceLocalSecretWhitelist()
46
+ enforceGlobalLocalFileProhibited()
47
+ enforceNonLocalSecretPlaceholders()
48
+ enforceEnvExampleSecrets()
49
+
50
+ return { nodeEnv: process.env.NODE_ENV, appEnv: process.env.APP_ENV }
51
+ }
52
+
53
+ function enforceRootOnlyEnvFiles() {
54
+ if (existsSync(ROOT_ENV_FILE)) {
55
+ throw new Error(
56
+ '检测到根目录存在 .env 文件,请迁移到 .env.<env> / .env.<env>.local 并删除 .env',
57
+ )
58
+ }
59
+
60
+ const violations = []
61
+ const queue = ['.']
62
+
63
+ while (queue.length > 0) {
64
+ const current = queue.pop()
65
+ const dirPath = join(ROOT_DIR, current)
66
+ const entries = readdirSync(dirPath, { withFileTypes: true })
67
+
68
+ for (const entry of entries) {
69
+ if (entry.isDirectory()) {
70
+ if (EXTRA_ENV_IGNORED_DIRS.has(entry.name)) continue
71
+ const next = current === '.' ? entry.name : `${current}/${entry.name}`
72
+ queue.push(next)
73
+ continue
74
+ }
75
+
76
+ if (!entry.isFile()) continue
77
+ if (!entry.name.startsWith('.env')) continue
78
+
79
+ const relativePath = current === '.' ? entry.name : `${current}/${entry.name}`
80
+
81
+ if (!relativePath.includes('/')) {
82
+ continue
83
+ }
84
+
85
+ if (EXTRA_ENV_ALLOWED_PATHS.has(relativePath)) continue
86
+
87
+ violations.push(relativePath)
88
+ }
89
+ }
90
+
91
+ if (violations.length > 0) {
92
+ const list = violations.join(', ')
93
+ throw new Error(
94
+ `检测到非根目录下的 env 文件: ${list}\n请将这些文件迁移到根目录或删除,再重试命令。`,
95
+ )
96
+ }
97
+ }
98
+
99
+ function enforceLocalSecretWhitelist() {
100
+ const allowlist = loadLocalAllowlist()
101
+ const errors = []
102
+
103
+ for (const file of LOCAL_ENV_FILES) {
104
+ const fullPath = join(ROOT_DIR, file)
105
+ if (!existsSync(fullPath)) continue
106
+
107
+ const entries = parseEnvFile(fullPath)
108
+ const invalidKeys = []
109
+
110
+ for (const key of entries.keys()) {
111
+ if (!allowlist.has(key)) {
112
+ invalidKeys.push(key)
113
+ }
114
+ }
115
+
116
+ if (invalidKeys.length > 0) {
117
+ errors.push(`${file}: ${invalidKeys.join(', ')}`)
118
+ }
119
+ }
120
+
121
+ if (errors.length > 0) {
122
+ const message = errors.join('\n')
123
+ throw new Error(
124
+ `检测到 *.local 文件包含非白名单键:\n${message}\n请将这些键迁移到对应的 .env.<env> 文件,仅保留白名单内的机密信息在 *.local 中。`,
125
+ )
126
+ }
127
+ }
128
+
129
+ function enforceGlobalLocalFileProhibited() {
130
+ const legacyLocal = join(ROOT_DIR, '.env.local')
131
+ if (existsSync(legacyLocal)) {
132
+ throw new Error('项目已弃用 .env.local,请改用 .env.<env>.local 存放机密信息并删除 .env.local')
133
+ }
134
+ }
135
+
136
+ function enforceNonLocalSecretPlaceholders() {
137
+ const allowlist = loadLocalAllowlist()
138
+ const exemptedKeys = loadExemptedKeys()
139
+ const violations = []
140
+
141
+ for (const file of listRootEnvFiles()) {
142
+ if (file === '.env.example') continue
143
+ if (file.includes('.local')) continue
144
+
145
+ const fullPath = join(ROOT_DIR, file)
146
+ if (!existsSync(fullPath)) continue
147
+
148
+ const entries = parseEnvFile(fullPath)
149
+
150
+ for (const [key, value] of entries.entries()) {
151
+ if (!allowlist.has(key)) continue
152
+ if (exemptedKeys.has(key)) continue // 豁免的键允许使用非占位符值
153
+
154
+ const normalized = value.trim()
155
+ if (normalized !== PLACEHOLDER_TOKEN) {
156
+ violations.push(`${file}: ${key}`)
157
+ }
158
+ }
159
+ }
160
+
161
+ if (violations.length > 0) {
162
+ const message = violations.join('\n')
163
+ throw new Error(
164
+ `检测到非 *.local 文件包含敏感键但未使用占位符 ${PLACEHOLDER_TOKEN}:\n${message}\n` +
165
+ `请仅在 .env.<env>.local 系列中设置真实值,并在其他文件中使用占位符。`,
166
+ )
167
+ }
168
+ }
169
+
170
+ function enforceEnvExampleSecrets() {
171
+ if (!existsSync(ENV_EXAMPLE_FILE)) return
172
+
173
+ const allowlist = loadLocalAllowlist()
174
+ const entries = parseEnvFile(ENV_EXAMPLE_FILE)
175
+ const disallowedKeys = []
176
+ const invalidPlaceholders = []
177
+
178
+ for (const [key, value] of entries.entries()) {
179
+ const normalized = value.trim()
180
+
181
+ if (!allowlist.has(key)) {
182
+ disallowedKeys.push(key)
183
+ continue
184
+ }
185
+
186
+ if (normalized !== PLACEHOLDER_TOKEN) {
187
+ invalidPlaceholders.push(key)
188
+ }
189
+ }
190
+
191
+ if (disallowedKeys.length > 0) {
192
+ throw new Error(
193
+ `.env.example 仅允许包含 scripts/config/local-env-allowlist.jsonc 中的键,检测到非法键: ${disallowedKeys.join(', ')}`,
194
+ )
195
+ }
196
+
197
+ if (invalidPlaceholders.length > 0) {
198
+ throw new Error(
199
+ `.env.example 中以下键未使用占位符 ${PLACEHOLDER_TOKEN}: ${invalidPlaceholders.join(', ')}`,
200
+ )
201
+ }
202
+ }
203
+
204
+ function listRootEnvFiles() {
205
+ return readdirSync(ROOT_DIR, { withFileTypes: true })
206
+ .filter(entry => entry.isFile() && entry.name.startsWith('.env'))
207
+ .map(entry => entry.name)
208
+ }
209
+
210
+ function parseEnvFile(filePath) {
211
+ const content = readFileSync(filePath, 'utf8')
212
+ const map = new Map()
213
+ const lines = content.split(/\r?\n/)
214
+
215
+ for (const raw of lines) {
216
+ const trimmed = raw.trim()
217
+ if (!trimmed || trimmed.startsWith('#')) continue
218
+
219
+ const eqIdx = trimmed.indexOf('=')
220
+ if (eqIdx <= 0) continue
221
+
222
+ const key = trimmed.slice(0, eqIdx).trim()
223
+ if (!key) continue
224
+
225
+ map.set(key, trimmed.slice(eqIdx + 1))
226
+ }
227
+
228
+ return map
229
+ }
230
+
231
+ function loadLocalAllowlist() {
232
+ if (cachedAllowlist) return cachedAllowlist
233
+
234
+ if (!existsSync(LOCAL_ALLOWLIST_CONFIG)) {
235
+ throw new Error('缺少配置文件 scripts/config/local-env-allowlist.jsonc,请补充后重试')
236
+ }
237
+
238
+ const raw = readFileSync(LOCAL_ALLOWLIST_CONFIG, 'utf8')
239
+ const sanitized = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
240
+ const parsed = JSON.parse(sanitized || '{}') || {}
241
+
242
+ const allowedValues = Array.isArray(parsed.allowed) ? parsed.allowed : []
243
+ if (allowedValues.length === 0) {
244
+ throw new Error('local-env-allowlist.jsonc 中的 allowed 不能为空,请至少保留一个键')
245
+ }
246
+
247
+ const invalid = allowedValues.filter(
248
+ value => typeof value !== 'string' || value.trim().length === 0,
249
+ )
250
+ if (invalid.length > 0) {
251
+ throw new Error('local-env-allowlist.jsonc.allowed 中存在非法键,请使用非空字符串')
252
+ }
253
+
254
+ cachedAllowlist = new Set(allowedValues)
255
+ return cachedAllowlist
256
+ }
257
+
258
+ function loadExemptedKeys() {
259
+ if (cachedExemptedKeys) return cachedExemptedKeys
260
+
261
+ // 如果豁免配置文件不存在,返回空集合(可选配置)
262
+ if (!existsSync(EXEMPTED_KEYS_CONFIG)) {
263
+ cachedExemptedKeys = new Set()
264
+ return cachedExemptedKeys
265
+ }
266
+
267
+ const raw = readFileSync(EXEMPTED_KEYS_CONFIG, 'utf8')
268
+ const sanitized = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
269
+ const parsed = JSON.parse(sanitized || '{}') || {}
270
+
271
+ const exemptedValues = Array.isArray(parsed.exempted) ? parsed.exempted : []
272
+
273
+ const invalid = exemptedValues.filter(
274
+ value => typeof value !== 'string' || value.trim().length === 0,
275
+ )
276
+ if (invalid.length > 0) {
277
+ throw new Error('exempted-keys.jsonc.exempted 中存在非法键,请使用非空字符串')
278
+ }
279
+
280
+ cachedExemptedKeys = new Set(exemptedValues)
281
+ return cachedExemptedKeys
282
+ }
283
+
284
+ export default { validateEnvironment }
@@ -0,0 +1,237 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { join } from 'node:path'
3
+ import { envManager } from './env.js'
4
+ import { execManager } from './exec.js'
5
+ import { logger } from './logger.js'
6
+
7
+ const ALLOWED_ENVIRONMENTS = ['development', 'staging', 'production']
8
+
9
+ export async function deployToVercel(target, options = {}) {
10
+ const { environment = 'staging' } = options
11
+
12
+ // 校验环境参数
13
+ if (!ALLOWED_ENVIRONMENTS.includes(environment)) {
14
+ logger.error(`不支持的部署环境: ${environment}`)
15
+ logger.info(`可用环境: ${ALLOWED_ENVIRONMENTS.join(', ')}`)
16
+ process.exitCode = 1
17
+ return
18
+ }
19
+
20
+ const token = process.env.VERCEL_TOKEN
21
+ const orgId = process.env.VERCEL_ORG_ID
22
+ const projectIdFront = process.env.VERCEL_PROJECT_ID_FRONT
23
+ const projectIdAdmin = process.env.VERCEL_PROJECT_ID_ADMIN
24
+ const projectIdTelegramBot = process.env.VERCEL_PROJECT_ID_TELEGRAM_BOT
25
+
26
+ const normalizedTarget = String(target || '').toLowerCase()
27
+ const targets = normalizedTarget === 'all' ? ['front', 'admin'] : [normalizedTarget]
28
+
29
+ // 校验目标有效性
30
+ for (const t of targets) {
31
+ if (!['front', 'admin', 'telegram-bot'].includes(t)) {
32
+ logger.error(`不支持的部署目标: ${t}`)
33
+ logger.info('可用目标: front, admin, telegram-bot, all')
34
+ process.exitCode = 1
35
+ return
36
+ }
37
+ }
38
+
39
+ // 收集缺失的环境变量
40
+ const missingVars = []
41
+
42
+ if (!token || envManager.isPlaceholderEnvValue(token)) {
43
+ missingVars.push('VERCEL_TOKEN')
44
+ }
45
+
46
+ if (!orgId || envManager.isPlaceholderEnvValue(orgId)) {
47
+ missingVars.push('VERCEL_ORG_ID')
48
+ }
49
+
50
+ // 根据目标检查对应的 PROJECT_ID
51
+ if (targets.includes('front') && (!projectIdFront || envManager.isPlaceholderEnvValue(projectIdFront))) {
52
+ missingVars.push('VERCEL_PROJECT_ID_FRONT')
53
+ }
54
+
55
+ if (targets.includes('admin') && (!projectIdAdmin || envManager.isPlaceholderEnvValue(projectIdAdmin))) {
56
+ missingVars.push('VERCEL_PROJECT_ID_ADMIN')
57
+ }
58
+
59
+ if (targets.includes('telegram-bot') && (!projectIdTelegramBot || envManager.isPlaceholderEnvValue(projectIdTelegramBot))) {
60
+ missingVars.push('VERCEL_PROJECT_ID_TELEGRAM_BOT')
61
+ }
62
+
63
+ // 如果有缺失变量,统一报错并退出
64
+ if (missingVars.length > 0) {
65
+ logger.error('缺少以下 Vercel 环境变量:')
66
+ missingVars.forEach(v => logger.error(` - ${v}`))
67
+ logger.info('')
68
+ logger.info('请在 .env.<env>.local 中配置这些变量,例如:')
69
+ logger.info(' VERCEL_TOKEN=<your-vercel-token>')
70
+ logger.info(' VERCEL_ORG_ID=team_xxx')
71
+ logger.info(' VERCEL_PROJECT_ID_FRONT=prj_xxx')
72
+ logger.info(' VERCEL_PROJECT_ID_ADMIN=prj_xxx')
73
+ logger.info(' VERCEL_PROJECT_ID_TELEGRAM_BOT=prj_xxx')
74
+ logger.info('')
75
+ logger.info('获取方式:')
76
+ logger.info(' 1. VERCEL_TOKEN: vercel login 后查看 ~/Library/Application Support/com.vercel.cli/auth.json')
77
+ logger.info(' 2. PROJECT_ID: vercel project ls --scope <org> 或通过 Vercel Dashboard 获取')
78
+ process.exitCode = 1
79
+ return
80
+ }
81
+
82
+ // 部署前先编译 backend 和 sdk
83
+ // staging 复用 production 构建配置
84
+ try {
85
+ logger.step('编译 backend...')
86
+ const backendConfig = environment === 'production' || environment === 'staging'
87
+ ? 'production'
88
+ : 'development'
89
+ await execManager.executeCommand(`npx nx build backend --configuration=${backendConfig}`, {
90
+ app: 'backend',
91
+ flags: {
92
+ ...(environment === 'production' ? { prod: true } : {}),
93
+ ...(environment === 'staging' ? { staging: true } : {}),
94
+ ...(environment === 'development' ? { dev: true } : {}),
95
+ },
96
+ })
97
+ logger.success('backend 编译成功')
98
+
99
+ logger.step('编译 sdk...')
100
+ const { runSdkBuild } = await import('./sdk-build.js')
101
+ const sdkArgs = environment === 'production' ? [] : ['dev']
102
+ await runSdkBuild(sdkArgs)
103
+ logger.success('sdk 编译成功')
104
+ } catch (error) {
105
+ const message = error?.message || String(error)
106
+ logger.error(`编译失败: ${message}`)
107
+ logger.error('部署已终止,请先修复编译错误')
108
+ process.exitCode = 1
109
+ return
110
+ }
111
+
112
+ // 映射环境标识:development -> dev, staging -> staging, production -> prod
113
+ const envMap = {
114
+ development: 'dev',
115
+ staging: 'staging',
116
+ production: 'prod',
117
+ }
118
+ const buildEnv = envMap[environment]
119
+
120
+ for (const t of targets) {
121
+ // 配置文件映射
122
+ const configFileMap = {
123
+ front: 'vercel.front.json',
124
+ admin: 'vercel.admin.json',
125
+ 'telegram-bot': 'vercel.telegram-bot.json',
126
+ }
127
+
128
+ // PROJECT_ID 映射
129
+ const projectIdMap = {
130
+ front: projectIdFront,
131
+ admin: projectIdAdmin,
132
+ 'telegram-bot': projectIdTelegramBot,
133
+ }
134
+
135
+ const configFile = configFileMap[t]
136
+ const projectId = projectIdMap[t]
137
+ const configPath = join(process.cwd(), configFile)
138
+
139
+ const envVars = {
140
+ ...process.env,
141
+ VERCEL_PROJECT_ID: projectId,
142
+ APP_ENV: buildEnv,
143
+ }
144
+
145
+ if (orgId) {
146
+ envVars.VERCEL_ORG_ID = orgId
147
+ }
148
+
149
+ // 绕过 Vercel Git author 权限检查:临时修改最新 commit 的 author
150
+ const authorEmail = process.env.VERCEL_GIT_COMMIT_AUTHOR_EMAIL
151
+ let originalAuthor = null
152
+ if (authorEmail && !envManager.isPlaceholderEnvValue(authorEmail)) {
153
+ try {
154
+ originalAuthor = execSync('git log -1 --format="%an <%ae>"', { encoding: 'utf8' }).trim()
155
+ const authorName = authorEmail.split('@')[0]
156
+ execSync(`git commit --amend --author="${authorName} <${authorEmail}>" --no-edit`, { stdio: 'ignore' })
157
+ logger.info(`临时修改 commit author: ${originalAuthor} -> ${authorName} <${authorEmail}>`)
158
+ } catch (e) {
159
+ logger.warn(`修改 commit author 失败: ${e.message}`)
160
+ originalAuthor = null
161
+ }
162
+ }
163
+
164
+ try {
165
+ // 第一步:本地构建
166
+ logger.step(`本地构建 ${t} (${environment})`)
167
+ const buildCmd = [
168
+ 'vercel build',
169
+ `--local-config="${configPath}"`,
170
+ '--yes',
171
+ `--token=${token}`,
172
+ ]
173
+
174
+ // staging 和 production 环境需要 --prod 标志,确保构建产物与部署环境匹配
175
+ if (environment === 'staging' || environment === 'production') {
176
+ buildCmd.push('--prod')
177
+ }
178
+
179
+ if (orgId) {
180
+ buildCmd.push(`--scope=${orgId}`)
181
+ }
182
+
183
+ execSync(buildCmd.join(' '), {
184
+ stdio: 'inherit',
185
+ cwd: process.cwd(),
186
+ env: envVars,
187
+ })
188
+ logger.success(`${t} 本地构建成功`)
189
+
190
+ // 第二步:上传预构建产物
191
+ logger.step(`部署 ${t} 到 Vercel (${environment})`)
192
+ const deployCmd = [
193
+ 'vercel deploy',
194
+ '--prebuilt',
195
+ `--local-config="${configPath}"`,
196
+ '--yes',
197
+ `--token=${token}`,
198
+ ]
199
+
200
+ // staging 和 production 环境都添加 --prod 标志以绑定固定域名
201
+ if (environment === 'staging' || environment === 'production') {
202
+ deployCmd.push('--prod')
203
+ }
204
+
205
+ if (orgId) {
206
+ deployCmd.push(`--scope=${orgId}`)
207
+ }
208
+
209
+ execSync(deployCmd.join(' '), {
210
+ stdio: 'inherit',
211
+ cwd: process.cwd(),
212
+ env: envVars,
213
+ })
214
+ logger.success(`${t} 部署成功`)
215
+
216
+ // Telegram Bot 部署成功后自动设置 Webhook
217
+ if (t === 'telegram-bot') {
218
+ const { handleTelegramBotDeploy } = await import('./telegram-webhook.js')
219
+ await handleTelegramBotDeploy(environment, projectId, orgId, token)
220
+ }
221
+ } catch (error) {
222
+ const message = error?.message || String(error)
223
+ logger.error(`${t} 构建或部署失败: ${message}`)
224
+ process.exitCode = 1
225
+ } finally {
226
+ // 恢复原 commit author
227
+ if (originalAuthor) {
228
+ try {
229
+ execSync(`git commit --amend --author="${originalAuthor}" --no-edit`, { stdio: 'ignore' })
230
+ logger.info(`已恢复 commit author: ${originalAuthor}`)
231
+ } catch {
232
+ // 忽略错误
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }