@realsee/vr-cli 0.1.5 → 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.
- package/README.md +45 -24
- package/agent-assets/AGENTS.md +7 -5
- package/dist/cli.mjs +5 -5
- package/package.json +1 -1
- package/skills/realsee-vr-app/SKILL.md +1 -1
- package/skills/realsee-vr-app/references/dnalogel-openapi-recipes.md +90 -26
- package/skills/realsee-vr-app/references/five-runtime.md +2 -2
- package/skills/realsee-vr-app/scripts/verify-project.mjs +186 -15
- package/templates/base/.env.example +2 -2
- package/templates/base/.gitignore.template +2 -0
- package/templates/base/.prettierignore +2 -0
- package/templates/base/README.md +32 -6
- package/templates/base/deploy.json +11 -0
- package/templates/base/eslint.config.mjs +2 -0
- package/templates/base/package-lock.json +11 -0
- package/templates/base/package.json +4 -1
- package/templates/base/scripts/package-vibe-release.mjs +569 -0
- package/templates/base/scripts/pairing.mjs +564 -0
- package/templates/base/scripts/pairing.test.mjs +227 -0
- package/templates/base/scripts/project-identity.mjs +160 -0
- package/templates/base/scripts/project-identity.test.mjs +119 -0
- package/templates/base/scripts/setup-config.mjs +96 -23
- package/templates/base/scripts/setup-config.test.mjs +49 -9
- package/templates/base/scripts/setup.mjs +161 -7
- package/templates/base/server/app.ts +1 -1
- package/templates/base/server/config.ts +5 -4
- package/templates/base/server/types.ts +1 -1
- package/templates/base/server/work-route.ts +14 -3
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"@types/react": ">=18.3.12",
|
|
24
24
|
"@types/react-dom": ">=18.3.1",
|
|
25
25
|
"@vitejs/plugin-react": ">=4.3.1",
|
|
26
|
+
"adm-zip": "0.6.0",
|
|
26
27
|
"concurrently": ">=10.0.5",
|
|
27
28
|
"eslint": ">=10.9.1",
|
|
28
29
|
"eslint-plugin-react-hooks": ">=7.1.1",
|
|
@@ -6427,6 +6428,16 @@
|
|
|
6427
6428
|
"url": "https://github.com/sponsors/colinhacks"
|
|
6428
6429
|
}
|
|
6429
6430
|
},
|
|
6431
|
+
"node_modules/adm-zip": {
|
|
6432
|
+
"version": "0.6.0",
|
|
6433
|
+
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
|
|
6434
|
+
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
|
|
6435
|
+
"dev": true,
|
|
6436
|
+
"license": "MIT",
|
|
6437
|
+
"engines": {
|
|
6438
|
+
"node": ">=14.0"
|
|
6439
|
+
}
|
|
6440
|
+
},
|
|
6430
6441
|
"node_modules/zod-validation-error": {
|
|
6431
6442
|
"version": "4.0.2",
|
|
6432
6443
|
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"packageManager": "npm@11.19.0",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"setup": "node scripts/setup.mjs",
|
|
9
|
-
"
|
|
9
|
+
"setup:complete": "node scripts/setup.mjs --complete",
|
|
10
|
+
"test:setup": "node --test scripts/setup-config.test.mjs scripts/project-identity.test.mjs scripts/pairing.test.mjs",
|
|
10
11
|
"dev": "npm run check:env && concurrently --kill-others-on-fail --names bff,web \"npm:dev:bff\" \"npm:dev:web\"",
|
|
11
12
|
"dev:bff": "node --env-file-if-exists=.env.development --watch server/index.ts",
|
|
12
13
|
"dev:web": "vite",
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"deps:relock": "npm install --package-lock-only",
|
|
23
24
|
"deps:install": "npm ci",
|
|
24
25
|
"build": "npm run check && vite build",
|
|
26
|
+
"package:vibe-release": "node scripts/package-vibe-release.mjs",
|
|
25
27
|
"start": "node --env-file-if-exists=.env.development server/index.ts --serve-frontend",
|
|
26
28
|
"preview": "npm run start"
|
|
27
29
|
},
|
|
@@ -41,6 +43,7 @@
|
|
|
41
43
|
"@types/react": ">=18.3.12",
|
|
42
44
|
"@types/react-dom": ">=18.3.1",
|
|
43
45
|
"@vitejs/plugin-react": ">=4.3.1",
|
|
46
|
+
"adm-zip": "0.6.0",
|
|
44
47
|
"concurrently": ">=10.0.5",
|
|
45
48
|
"eslint": ">=10.9.1",
|
|
46
49
|
"eslint-plugin-react-hooks": ">=7.1.1",
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import {
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
stat,
|
|
12
|
+
} from 'node:fs/promises'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..')
|
|
16
|
+
const FORMAT = 'node-vite-bff-v1'
|
|
17
|
+
const NODE_PIN = '22.23.2'
|
|
18
|
+
const NPM_PIN = '11.19.0'
|
|
19
|
+
const ZIP_PACKAGE = 'adm-zip'
|
|
20
|
+
const ZIP_VERSION = '0.6.0'
|
|
21
|
+
const MAX_FILES = 20_000
|
|
22
|
+
const MAX_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024
|
|
23
|
+
const MAX_ZIP_BYTES = 200 * 1024 * 1024
|
|
24
|
+
const NPM_COMMAND = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
25
|
+
const REQUIRED_FILES = [
|
|
26
|
+
'deploy.json',
|
|
27
|
+
'package.json',
|
|
28
|
+
'package-lock.json',
|
|
29
|
+
'.npmrc',
|
|
30
|
+
'.node-version',
|
|
31
|
+
'.nvmrc',
|
|
32
|
+
'index.html',
|
|
33
|
+
'vite.config.ts',
|
|
34
|
+
'tsconfig.json',
|
|
35
|
+
'tsconfig.server.json',
|
|
36
|
+
'eslint.config.mjs',
|
|
37
|
+
'prettier.config.mjs',
|
|
38
|
+
'scripts/package-vibe-release.mjs',
|
|
39
|
+
'server/index.ts',
|
|
40
|
+
'server/app.ts',
|
|
41
|
+
'server/config.ts',
|
|
42
|
+
]
|
|
43
|
+
const REQUIRED_DIRECTORIES = ['src', 'server']
|
|
44
|
+
const EXCLUDED_DIRECTORIES = new Set([
|
|
45
|
+
'.git',
|
|
46
|
+
'release',
|
|
47
|
+
'node_modules',
|
|
48
|
+
'dist',
|
|
49
|
+
'.realsee',
|
|
50
|
+
'.kiro',
|
|
51
|
+
'.agent',
|
|
52
|
+
'.agents',
|
|
53
|
+
'.claude',
|
|
54
|
+
'.cursor',
|
|
55
|
+
'.vscode',
|
|
56
|
+
'.idea',
|
|
57
|
+
'__MACOSX',
|
|
58
|
+
])
|
|
59
|
+
const FORBIDDEN_CONTAINER_FILES = new Set([
|
|
60
|
+
'Dockerfile',
|
|
61
|
+
'compose.yaml',
|
|
62
|
+
'compose.yml',
|
|
63
|
+
'docker-compose.yaml',
|
|
64
|
+
'docker-compose.yml',
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
function isRecord(value) {
|
|
68
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readJson(relativePath) {
|
|
72
|
+
try {
|
|
73
|
+
const value = JSON.parse(
|
|
74
|
+
await readFile(path.join(PROJECT_ROOT, relativePath), 'utf8'),
|
|
75
|
+
)
|
|
76
|
+
if (!isRecord(value)) throw new Error('必须是 JSON 对象')
|
|
77
|
+
return value
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${relativePath} 解析失败:${error instanceof Error ? error.message : String(error)}`,
|
|
81
|
+
{ cause: error },
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function requireFile(relativePath) {
|
|
87
|
+
try {
|
|
88
|
+
const value = await stat(path.join(PROJECT_ROOT, relativePath))
|
|
89
|
+
if (!value.isFile()) throw new Error('不是普通文件')
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`统一发布包缺少文件 ${relativePath}:${error instanceof Error ? error.message : String(error)}`,
|
|
93
|
+
{ cause: error },
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function requireDirectory(relativePath) {
|
|
99
|
+
try {
|
|
100
|
+
const absolutePath = path.join(PROJECT_ROOT, relativePath)
|
|
101
|
+
const value = await stat(absolutePath)
|
|
102
|
+
if (!value.isDirectory()) throw new Error('不是目录')
|
|
103
|
+
if ((await readdir(absolutePath)).length === 0) throw new Error('目录为空')
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`统一发布包缺少目录 ${relativePath}/:${error instanceof Error ? error.message : String(error)}`,
|
|
107
|
+
{ cause: error },
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateNpmrc(source) {
|
|
113
|
+
const expected = new Map([
|
|
114
|
+
['engine-strict', 'true'],
|
|
115
|
+
['package-lock', 'true'],
|
|
116
|
+
['save-prefix', '>='],
|
|
117
|
+
])
|
|
118
|
+
const actual = new Map()
|
|
119
|
+
for (const raw of source.split(/\r?\n/)) {
|
|
120
|
+
const line = raw.trim()
|
|
121
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue
|
|
122
|
+
const separator = line.indexOf('=')
|
|
123
|
+
if (separator <= 0) throw new Error('.npmrc 只允许 key=value 配置')
|
|
124
|
+
const key = line.slice(0, separator).trim().toLowerCase()
|
|
125
|
+
const value = line.slice(separator + 1).trim()
|
|
126
|
+
if (!expected.has(key)) throw new Error(`.npmrc 不允许配置 ${key}`)
|
|
127
|
+
if (actual.has(key)) throw new Error(`.npmrc 重复配置 ${key}`)
|
|
128
|
+
if (/(_auth|token|password|registry|userconfig|cafile)|\$\{/i.test(line)) {
|
|
129
|
+
throw new Error('.npmrc 不允许注册表、凭据、路径或环境变量插值')
|
|
130
|
+
}
|
|
131
|
+
actual.set(key, value)
|
|
132
|
+
}
|
|
133
|
+
for (const [key, value] of expected) {
|
|
134
|
+
if (actual.get(key) !== value) {
|
|
135
|
+
throw new Error(`.npmrc 必须包含 ${key}=${value}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (actual.size !== expected.size) {
|
|
139
|
+
throw new Error('.npmrc 包含统一构建规则之外的配置')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validateManifest(manifest) {
|
|
144
|
+
const allowedFields = new Set([
|
|
145
|
+
'packageFormat',
|
|
146
|
+
'name',
|
|
147
|
+
'requiredEnv',
|
|
148
|
+
'resources',
|
|
149
|
+
'domain',
|
|
150
|
+
'healthCheck',
|
|
151
|
+
])
|
|
152
|
+
if (manifest.packageFormat !== FORMAT) {
|
|
153
|
+
throw new Error(`deploy.json.packageFormat 必须是 ${FORMAT}`)
|
|
154
|
+
}
|
|
155
|
+
for (const field of Object.keys(manifest)) {
|
|
156
|
+
if (!allowedFields.has(field)) {
|
|
157
|
+
throw new Error(`deploy.json 不支持字段:${field}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (
|
|
161
|
+
!Array.isArray(manifest.requiredEnv) ||
|
|
162
|
+
manifest.requiredEnv.length > 20
|
|
163
|
+
) {
|
|
164
|
+
throw new Error('deploy.json.requiredEnv 必须是最多 20 项的数组')
|
|
165
|
+
}
|
|
166
|
+
const names = new Set()
|
|
167
|
+
for (const raw of manifest.requiredEnv) {
|
|
168
|
+
const name = String(raw ?? '').trim()
|
|
169
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
170
|
+
throw new Error(`deploy.json.requiredEnv 名称不合法:${name || '(空)'}`)
|
|
171
|
+
}
|
|
172
|
+
if (names.has(name)) {
|
|
173
|
+
throw new Error(`deploy.json.requiredEnv 包含重复项:${name}`)
|
|
174
|
+
}
|
|
175
|
+
names.add(name)
|
|
176
|
+
}
|
|
177
|
+
if (manifest.healthCheck !== undefined) {
|
|
178
|
+
if (!isRecord(manifest.healthCheck)) {
|
|
179
|
+
throw new Error('deploy.json.healthCheck 必须是对象')
|
|
180
|
+
}
|
|
181
|
+
const allowedHealthFields = new Set([
|
|
182
|
+
'enabled',
|
|
183
|
+
'path',
|
|
184
|
+
'timeoutMs',
|
|
185
|
+
'retries',
|
|
186
|
+
])
|
|
187
|
+
for (const field of Object.keys(manifest.healthCheck)) {
|
|
188
|
+
if (!allowedHealthFields.has(field)) {
|
|
189
|
+
throw new Error(`deploy.json.healthCheck 不支持字段:${field}`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
manifest.healthCheck.enabled !== undefined &&
|
|
194
|
+
manifest.healthCheck.enabled !== true
|
|
195
|
+
) {
|
|
196
|
+
throw new Error('node-vite-bff-v1 健康检查必须启用')
|
|
197
|
+
}
|
|
198
|
+
if (
|
|
199
|
+
manifest.healthCheck.path !== undefined &&
|
|
200
|
+
manifest.healthCheck.path !== '/api/health'
|
|
201
|
+
) {
|
|
202
|
+
throw new Error('node-vite-bff-v1 健康检查路径固定为 /api/health')
|
|
203
|
+
}
|
|
204
|
+
if (manifest.healthCheck.timeoutMs !== undefined) {
|
|
205
|
+
const timeoutMs = Number(manifest.healthCheck.timeoutMs)
|
|
206
|
+
if (
|
|
207
|
+
!Number.isFinite(timeoutMs) ||
|
|
208
|
+
timeoutMs < 500 ||
|
|
209
|
+
timeoutMs > 30_000
|
|
210
|
+
) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
'deploy.json.healthCheck.timeoutMs 必须在 500-30000 之间',
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (manifest.healthCheck.retries !== undefined) {
|
|
217
|
+
const retries = Number(manifest.healthCheck.retries)
|
|
218
|
+
if (!Number.isInteger(retries) || retries < 1 || retries > 60) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
'deploy.json.healthCheck.retries 必须是 1-60 之间的整数',
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function validateSource() {
|
|
228
|
+
await Promise.all(
|
|
229
|
+
REQUIRED_FILES.map((relativePath) => requireFile(relativePath)),
|
|
230
|
+
)
|
|
231
|
+
await Promise.all(
|
|
232
|
+
REQUIRED_DIRECTORIES.map((relativePath) => requireDirectory(relativePath)),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const manifest = await readJson('deploy.json')
|
|
236
|
+
validateManifest(manifest)
|
|
237
|
+
const packageJson = await readJson('package.json')
|
|
238
|
+
if (packageJson.type !== 'module') {
|
|
239
|
+
throw new Error('package.json.type 必须是 module')
|
|
240
|
+
}
|
|
241
|
+
if (packageJson.packageManager !== `npm@${NPM_PIN}`) {
|
|
242
|
+
throw new Error(`package.json.packageManager 必须是 npm@${NPM_PIN}`)
|
|
243
|
+
}
|
|
244
|
+
if (
|
|
245
|
+
packageJson.engines?.node !== '>=22.18.0' ||
|
|
246
|
+
packageJson.engines?.npm !== `>=${NPM_PIN}`
|
|
247
|
+
) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`package.json.engines 必须声明 node >=22.18.0、npm >=${NPM_PIN}`,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
if (packageJson.scripts?.build !== 'npm run check && vite build') {
|
|
253
|
+
throw new Error(
|
|
254
|
+
'package.json build 脚本必须是:npm run check && vite build',
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
packageJson.scripts?.start !==
|
|
259
|
+
'node --env-file-if-exists=.env.development server/index.ts --serve-frontend'
|
|
260
|
+
) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
'package.json start 脚本必须运行 server/index.ts --serve-frontend',
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
if (
|
|
266
|
+
packageJson.scripts?.['package:vibe-release'] !==
|
|
267
|
+
'node scripts/package-vibe-release.mjs'
|
|
268
|
+
) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
'package.json 必须提供 package:vibe-release -> node scripts/package-vibe-release.mjs',
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
if (packageJson.devDependencies?.[ZIP_PACKAGE] !== ZIP_VERSION) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`package.json.devDependencies.${ZIP_PACKAGE} 必须精确固定为 ${ZIP_VERSION}`,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
for (const dependency of ['fastify', '@fastify/static']) {
|
|
279
|
+
if (typeof packageJson.dependencies?.[dependency] !== 'string') {
|
|
280
|
+
throw new Error(`package.json.dependencies 缺少 ${dependency}`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const dependency of ['vite', 'typescript']) {
|
|
284
|
+
if (typeof packageJson.devDependencies?.[dependency] !== 'string') {
|
|
285
|
+
throw new Error(`package.json.devDependencies 缺少 ${dependency}`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const lockfile = await readJson('package-lock.json')
|
|
290
|
+
if (lockfile.lockfileVersion !== 3 || !isRecord(lockfile.packages?.[''])) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
'package-lock.json 必须使用 lockfileVersion 3 并包含根包记录',
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
if (lockfile.packages[''].devDependencies?.[ZIP_PACKAGE] !== ZIP_VERSION) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`package-lock.json 根包必须锁定 ${ZIP_PACKAGE}@${ZIP_VERSION}`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
if (
|
|
301
|
+
lockfile.packages[`node_modules/${ZIP_PACKAGE}`]?.version !== ZIP_VERSION
|
|
302
|
+
) {
|
|
303
|
+
throw new Error(`package-lock.json 必须解析 ${ZIP_PACKAGE}@${ZIP_VERSION}`)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const relativePath of ['.node-version', '.nvmrc']) {
|
|
307
|
+
const value = (
|
|
308
|
+
await readFile(path.join(PROJECT_ROOT, relativePath), 'utf8')
|
|
309
|
+
).trim()
|
|
310
|
+
if (value !== NODE_PIN) {
|
|
311
|
+
throw new Error(`${relativePath} 必须固定为 ${NODE_PIN}`)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
validateNpmrc(await readFile(path.join(PROJECT_ROOT, '.npmrc'), 'utf8'))
|
|
315
|
+
return { manifest, packageJson }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function validateBuiltOutput() {
|
|
319
|
+
await requireFile('dist/index.html')
|
|
320
|
+
await requireDirectory('dist/assets')
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function secretLikeFile(name) {
|
|
324
|
+
const lower = name.toLowerCase()
|
|
325
|
+
return (
|
|
326
|
+
lower.startsWith('.env') ||
|
|
327
|
+
[
|
|
328
|
+
'.pypirc',
|
|
329
|
+
'.netrc',
|
|
330
|
+
'id_rsa',
|
|
331
|
+
'id_dsa',
|
|
332
|
+
'id_ecdsa',
|
|
333
|
+
'id_ed25519',
|
|
334
|
+
].includes(lower) ||
|
|
335
|
+
/\.(pem|key|p12|pfx|jks|keystore)$/.test(lower) ||
|
|
336
|
+
/(service[-_]?account|credentials?|secrets?).*\.json$/.test(lower)
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function excludedFile(name) {
|
|
341
|
+
const lower = name.toLowerCase()
|
|
342
|
+
return (
|
|
343
|
+
name === '.DS_Store' ||
|
|
344
|
+
name.startsWith('.tmp-') ||
|
|
345
|
+
lower.endsWith('.log') ||
|
|
346
|
+
lower.startsWith('npm-debug.log') ||
|
|
347
|
+
lower.startsWith('yarn-debug.log') ||
|
|
348
|
+
lower.startsWith('yarn-error.log') ||
|
|
349
|
+
secretLikeFile(name)
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function collectArchiveFiles(current = PROJECT_ROOT) {
|
|
354
|
+
const files = []
|
|
355
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
356
|
+
const absolutePath = path.join(current, entry.name)
|
|
357
|
+
const relativePath = path
|
|
358
|
+
.relative(PROJECT_ROOT, absolutePath)
|
|
359
|
+
.split(path.sep)
|
|
360
|
+
.join('/')
|
|
361
|
+
const information = await lstat(absolutePath)
|
|
362
|
+
if (information.isSymbolicLink()) {
|
|
363
|
+
throw new Error(`统一发布包不允许符号链接:${relativePath}`)
|
|
364
|
+
}
|
|
365
|
+
if (information.isDirectory()) {
|
|
366
|
+
if (
|
|
367
|
+
EXCLUDED_DIRECTORIES.has(entry.name) ||
|
|
368
|
+
entry.name.startsWith('.tmp-')
|
|
369
|
+
) {
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
files.push(...(await collectArchiveFiles(absolutePath)))
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
if (!information.isFile()) {
|
|
376
|
+
throw new Error(`统一发布包包含不支持的文件类型:${relativePath}`)
|
|
377
|
+
}
|
|
378
|
+
if (FORBIDDEN_CONTAINER_FILES.has(relativePath)) {
|
|
379
|
+
throw new Error(`node-vite-bff-v1 不接受 ${relativePath}`)
|
|
380
|
+
}
|
|
381
|
+
if (excludedFile(entry.name)) continue
|
|
382
|
+
files.push({
|
|
383
|
+
absolutePath,
|
|
384
|
+
relativePath,
|
|
385
|
+
size: information.size,
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
return files
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function run(command, args) {
|
|
392
|
+
return new Promise((resolve, reject) => {
|
|
393
|
+
const child = spawn(command, args, {
|
|
394
|
+
cwd: PROJECT_ROOT,
|
|
395
|
+
stdio: 'inherit',
|
|
396
|
+
shell: false,
|
|
397
|
+
})
|
|
398
|
+
child.once('error', reject)
|
|
399
|
+
child.once('exit', (code, signal) => {
|
|
400
|
+
if (code === 0) resolve()
|
|
401
|
+
else {
|
|
402
|
+
reject(
|
|
403
|
+
new Error(
|
|
404
|
+
`${command} ${args.join(' ')} 失败(code=${code ?? 'null'}, signal=${signal ?? 'none'})`,
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function capture(command, args) {
|
|
413
|
+
return new Promise((resolve, reject) => {
|
|
414
|
+
const child = spawn(command, args, {
|
|
415
|
+
cwd: PROJECT_ROOT,
|
|
416
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
417
|
+
shell: false,
|
|
418
|
+
})
|
|
419
|
+
let stdout = ''
|
|
420
|
+
let stderr = ''
|
|
421
|
+
child.stdout.setEncoding('utf8')
|
|
422
|
+
child.stderr.setEncoding('utf8')
|
|
423
|
+
child.stdout.on('data', (chunk) => {
|
|
424
|
+
stdout += chunk
|
|
425
|
+
})
|
|
426
|
+
child.stderr.on('data', (chunk) => {
|
|
427
|
+
stderr += chunk
|
|
428
|
+
})
|
|
429
|
+
child.once('error', reject)
|
|
430
|
+
child.once('exit', (code) => {
|
|
431
|
+
if (code === 0) resolve(stdout.trim())
|
|
432
|
+
else reject(new Error(stderr.trim() || `${command} 执行失败`))
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function checkToolchain() {
|
|
438
|
+
const [major, minor] = process.versions.node.split('.').map(Number)
|
|
439
|
+
if (major < 22 || (major === 22 && minor < 18)) {
|
|
440
|
+
throw new Error('统一构建需要 Node.js >=22.18.0')
|
|
441
|
+
}
|
|
442
|
+
const npmVersion = await capture(NPM_COMMAND, ['--version'])
|
|
443
|
+
if (npmVersion !== NPM_PIN) {
|
|
444
|
+
throw new Error(`统一构建需要 npm ${NPM_PIN},当前为 ${npmVersion}`)
|
|
445
|
+
}
|
|
446
|
+
if (process.version !== `v${NODE_PIN}`) {
|
|
447
|
+
console.warn(
|
|
448
|
+
`提示:当前 Node ${process.version};项目与平台镜像固定使用 v${NODE_PIN}。`,
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function validateArchiveEntries(entries) {
|
|
454
|
+
const names = new Set()
|
|
455
|
+
for (const entry of entries) {
|
|
456
|
+
if (entry.isDirectory) continue
|
|
457
|
+
const name = entry.entryName
|
|
458
|
+
if (
|
|
459
|
+
!name ||
|
|
460
|
+
name.startsWith('/') ||
|
|
461
|
+
name.includes('\\') ||
|
|
462
|
+
name.split('/').includes('..')
|
|
463
|
+
) {
|
|
464
|
+
throw new Error(`ZIP 包含非法路径:${name || '(空)'}`)
|
|
465
|
+
}
|
|
466
|
+
if (names.has(name)) throw new Error(`ZIP 包含重复路径:${name}`)
|
|
467
|
+
names.add(name)
|
|
468
|
+
const segments = name.split('/')
|
|
469
|
+
if (segments.some((segment) => EXCLUDED_DIRECTORIES.has(segment))) {
|
|
470
|
+
throw new Error(`ZIP 包含应排除目录:${name}`)
|
|
471
|
+
}
|
|
472
|
+
if (segments.some((segment) => segment.startsWith('.tmp-'))) {
|
|
473
|
+
throw new Error(`ZIP 包含临时产物:${name}`)
|
|
474
|
+
}
|
|
475
|
+
if (secretLikeFile(path.posix.basename(name))) {
|
|
476
|
+
throw new Error(`ZIP 包含疑似秘密文件:${name}`)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
for (const required of REQUIRED_FILES) {
|
|
480
|
+
if (!names.has(required)) throw new Error(`ZIP 缺少文件:${required}`)
|
|
481
|
+
}
|
|
482
|
+
if (names.size > MAX_FILES) {
|
|
483
|
+
throw new Error(`ZIP 文件数超过限制(${MAX_FILES})`)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function writeArchive(files, temporaryPath) {
|
|
488
|
+
if (files.length > MAX_FILES) {
|
|
489
|
+
throw new Error(`项目文件数超过限制(${MAX_FILES})`)
|
|
490
|
+
}
|
|
491
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
|
|
492
|
+
if (totalBytes > MAX_UNCOMPRESSED_BYTES) {
|
|
493
|
+
throw new Error('项目源码超过解压限制(1024MB)')
|
|
494
|
+
}
|
|
495
|
+
const imported = await import(ZIP_PACKAGE)
|
|
496
|
+
const AdmZip = imported.default
|
|
497
|
+
const archive = new AdmZip()
|
|
498
|
+
for (const file of files) {
|
|
499
|
+
archive.addFile(file.relativePath, await readFile(file.absolutePath))
|
|
500
|
+
}
|
|
501
|
+
await new Promise((resolve, reject) => {
|
|
502
|
+
archive.writeZip(temporaryPath, (error) => {
|
|
503
|
+
if (error) reject(error)
|
|
504
|
+
else resolve()
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
const archiveSize = (await stat(temporaryPath)).size
|
|
508
|
+
if (archiveSize > MAX_ZIP_BYTES) {
|
|
509
|
+
throw new Error('ZIP 超过上传限制(200MB)')
|
|
510
|
+
}
|
|
511
|
+
const inspection = new AdmZip(temporaryPath)
|
|
512
|
+
validateArchiveEntries(inspection.getEntries())
|
|
513
|
+
return archiveSize
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function outputName(packageName) {
|
|
517
|
+
const safeName = packageName
|
|
518
|
+
.replace(/^@/, '')
|
|
519
|
+
.replaceAll('/', '-')
|
|
520
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
521
|
+
if (!safeName) throw new Error('package.json.name 无法生成发布包文件名')
|
|
522
|
+
return `${safeName}-${FORMAT}.zip`
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function main() {
|
|
526
|
+
await checkToolchain()
|
|
527
|
+
const { manifest, packageJson } = await validateSource()
|
|
528
|
+
await collectArchiveFiles()
|
|
529
|
+
|
|
530
|
+
console.log('执行干净依赖安装:npm ci')
|
|
531
|
+
await run(NPM_COMMAND, ['ci', '--no-audit', '--no-fund'])
|
|
532
|
+
console.log('执行生产检查与构建:npm run build')
|
|
533
|
+
await run(NPM_COMMAND, ['run', 'build'])
|
|
534
|
+
await validateBuiltOutput()
|
|
535
|
+
|
|
536
|
+
const files = await collectArchiveFiles()
|
|
537
|
+
const outputDirectory = path.join(PROJECT_ROOT, 'release')
|
|
538
|
+
const outputPath = path.join(outputDirectory, outputName(packageJson.name))
|
|
539
|
+
const temporaryPath = path.join(
|
|
540
|
+
outputDirectory,
|
|
541
|
+
`.tmp-${process.pid}-${Date.now()}.zip`,
|
|
542
|
+
)
|
|
543
|
+
await mkdir(outputDirectory, { recursive: true })
|
|
544
|
+
try {
|
|
545
|
+
const archiveSize = await writeArchive(files, temporaryPath)
|
|
546
|
+
await rename(temporaryPath, outputPath)
|
|
547
|
+
console.log(
|
|
548
|
+
`打包完成:${outputPath} (${(archiveSize / 1024 / 1024).toFixed(1)}MB)`,
|
|
549
|
+
)
|
|
550
|
+
console.log(
|
|
551
|
+
`格式:${FORMAT} · Node ${NODE_PIN} · npm ${NPM_PIN} · 健康检查 /api/health`,
|
|
552
|
+
)
|
|
553
|
+
console.log(
|
|
554
|
+
'已排除:node_modules、dist、release、.git、.env*、密钥/凭据、日志和编辑器/Agent 配置。',
|
|
555
|
+
)
|
|
556
|
+
console.log(
|
|
557
|
+
`运行前请配置:${manifest.requiredEnv.length ? manifest.requiredEnv.join(', ') : '(无额外变量)'}`,
|
|
558
|
+
)
|
|
559
|
+
} finally {
|
|
560
|
+
await rm(temporaryPath, { force: true })
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
main().catch((error) => {
|
|
565
|
+
console.error(
|
|
566
|
+
`打包失败:${error instanceof Error ? error.message : String(error)}`,
|
|
567
|
+
)
|
|
568
|
+
process.exitCode = 1
|
|
569
|
+
})
|