@kne/fastify-app-manager 0.1.11 → 0.1.13

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
@@ -64,6 +64,8 @@ deploy(versionId)
64
64
  ↓
65
65
  目录缺失则按 fileId 解压 + npm install → 数据库 SQL 覆盖写回
66
66
  ↓
67
+ 按 env.schema.json 校验环境变量(不通过即 400,不切版本、不启动)
68
+ ↓
67
69
  status=deploying → 可选 SQL 迁移 → inject entryHtml → pm2.start → 清理其它版本目录
68
70
  ↓(立即返回)
69
71
  后台 healthCheck → running / error
@@ -111,6 +113,7 @@ deploy(versionId)
111
113
  | 应用自有库 | 应用 `env` 含完整 `DB_*` 且 `DB_DEDICATED` 不为 `false` 时为独享库(`dbScope=dedicated`);`DB_DEDICATED=false` 保留配置但回落共享库。`dbScope` 始终由 env 推导,与启动注入的连接一致;独享库若带 `DB_TABLE_PREFIX`(与其它系统共用库)只认该前缀的表 |
112
114
  | 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_` 并由系统写回应用 env(接口不返回、不可修改);子应用 sequelize 须读取该变量建表 |
113
115
  | 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
116
+ | 变量声明 | 子应用在 `server/env.schema.json` 声明所需变量(标准 JSON Schema,必填放顶层 `required`,敏感值 `writeOnly`);上传缺失即拒绝,启动前(deploy / start / restart / 恢复)按它校验最终变量;`GET .../app/env/schema` 列出需要用户填写的变量,隐藏 `PORT`、托管库 `DB_*`、系统变量等应用中心注入的键 |
114
117
 
115
118
  ##### SQL 迁移
116
119
 
@@ -122,7 +125,7 @@ deploy(versionId)
122
125
  |------|------|
123
126
  | 版本上传 | Zip Slip 防护、条目数/体积上限、解压丢弃任意层级 `node_modules`、Fullstack 包格式校验、server 生产依赖安装 |
124
127
  | 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
125
- | 部署进度 | `deploy` 按「准备部署包 → SQL 迁移 → 启动进程 → 等待就绪」上报阶段,经 `GET .../app/deploy/stream` SSE 推送(含下载 / 解压 / 安装依赖等子阶段) |
128
+ | 部署进度 | `deploy` 按「准备部署包 → 校验环境变量 → SQL 迁移 → 启动进程 → 等待就绪」上报阶段,经 `GET .../app/deploy/stream` SSE 推送(含下载 / 解压 / 安装依赖等子阶段) |
126
129
  | 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
127
130
  | 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
128
131
  | 日志 | PM2 落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
@@ -171,6 +174,7 @@ await fastify.listen({ port: 3000 });
171
174
  | `package.json` | 根包描述 |
172
175
  | `server/package.json` | 服务端依赖 |
173
176
  | `server/index.js` | PM2 入口 |
177
+ | `server/env.schema.json` | 环境变量声明(必需);规范见 API 文档「环境变量 schema」 |
174
178
  | `build/` | 已构建前端;含 `index.html` / `entry.html` / `entry-prod.html` 之一 |
175
179
  | `server/sql/*.sql` | 可选;存在则 `hasMigration=true` |
176
180
 
@@ -286,11 +290,52 @@ await fastify.listen({ port: 3000 });
286
290
  | 参数名 | 类型 | 必填 | 说明 |
287
291
  |--------|------|------|------|
288
292
  | name | string | 是 | 应用 slug |
289
- | env | object | 是 | patch:`null` 删键;secret 键值为 `********` 时保持原值 |
293
+ | env | object | 是 | patch:`null` 删键;secret 键值为 `********` 时保持原值;`PORT` 与 `systemEnvKeys` 中的键忽略 |
290
294
  | secretEnvKeys | array | 否 | 新增的显式密钥键名,与原列表合并写入 `options.secretEnvKeys`(仅保留仍存在于 env 的键)。密钥类型不可撤销,只有删除该变量才会移出列表 |
291
295
 
292
296
  > **关键设计**:密钥判定 = 键名匹配 `secretEnvKeyPattern` **或** 出现在 `options.secretEnvKeys`。响应顶层 `secretEnvKeys` 为二者并集,便于管理端把普通变量标成密钥而不改键名。
293
297
 
298
+ ##### GET `{prefix}/app/env/schema`
299
+
300
+ 按版本的 `server/env.schema.json` 列出需要用户填写的环境变量,供环境变量管理页针对性补充。
301
+
302
+ | 参数名 | 类型 | 必填 | 说明 |
303
+ |--------|------|------|------|
304
+ | name | string | 是 | 应用 slug |
305
+ | versionId | string | 否 | 默认取当前版本,没有则取最新上传的版本 |
306
+
307
+ 返回:
308
+
309
+ ```json
310
+ {
311
+ "name": "demo",
312
+ "versionId": "354173577374729216",
313
+ "version": "1.0.0",
314
+ "hasSchema": true,
315
+ "valid": false,
316
+ "declared": [
317
+ {
318
+ "key": "API_TOKEN",
319
+ "type": "string",
320
+ "required": true,
321
+ "default": null,
322
+ "description": "接口令牌",
323
+ "enum": null,
324
+ "secret": true,
325
+ "configured": false,
326
+ "value": null,
327
+ "error": "必填,未配置"
328
+ }
329
+ ],
330
+ "others": [{ "key": "EXTRA", "secret": false, "value": "x" }]
331
+ }
332
+ ```
333
+
334
+ - `declared`:schema `properties` 中的变量;`secret` 来自 `writeOnly: true` 或密钥判定;`value` 为当前值(密钥掩码);`error` 为按当前配置校验的结果。
335
+ - `others`:已配置但 schema 未声明的变量。
336
+ - 两者都**不含应用中心注入的变量**(见 `services.app.listInjectedEnvKeys`):`PORT`、`DB_TABLE_PREFIX`、`systemEnvKeys`、`passthroughEnvKeys`、托管库连接 `DB_DIALECT` / `DB_HOST` / `DB_PORT` / `DB_DATABASE` / `DB_USERNAME` / `DB_PASSWORD` / `DB_STORAGE` 与 `DB_DEDICATED`(自有库在数据页签配置),以及最近一次启动时 `resolveSystemEnv` 返回的键(记录在 `options.injectedEnvKeys`)。注入的变量照常参与启动前校验。
337
+ - 版本没有 schema 时 `hasSchema=false`、`declared=[]`,该版本无法部署。
338
+
294
339
  ##### 数据运维与表生命周期
295
340
 
296
341
  解析后的应用库:应用 `env` 的 `DB_*` 优先,否则注入 `defaultAppDb`。共享库(未自配 `DB_*`)仅能操作 `options.ownedTables`。
@@ -337,7 +382,7 @@ await fastify.listen({ port: 3000 });
337
382
  | version | string | 是 | 版本号;同应用内唯一 |
338
383
  | label | string | 否 | 版本说明 |
339
384
 
340
- 成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`、`fileId`、`sqlFiles` 等)。包中 `server/{sqlPath}/*.sql` 的内容写入版本记录(数据库)。传 `fileId` 时只解压校验、不装依赖,也不保留版本目录(部署时再从宿主文件解压);multipart 上传无处重建,解压并 `npm install` 后保留目录。包在临时目录处理完才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
385
+ 成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`、`hasEnvSchema`、`fileId`、`sqlFiles` 等)。包中 `server/{sqlPath}/*.sql` 的内容与 `server/env.schema.json` 写入版本记录(数据库);缺少 `server/env.schema.json` 或其不符合规范的包直接拒绝(400)。传 `fileId` 时只解压校验、不装依赖,也不保留版本目录(部署时再从宿主文件解压);multipart 上传无处重建,解压并 `npm install` 后保留目录。包在临时目录处理完才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
341
386
 
342
387
  ##### GET `{prefix}/app/version/list`
343
388
 
@@ -379,7 +424,7 @@ await fastify.listen({ port: 3000 });
379
424
 
380
425
  ##### POST `{prefix}/app/deploy`
381
426
 
382
- 选择版本部署。版本目录不在磁盘时先经 `resolveVersionFile` 取回 zip 解压并 `npm install`(耗时,取不回返回 400 且不改应用状态),再以数据库中的 SQL 覆盖目录内 `.sql`;随后立即返回 `deploying`,后台做健康检查。进程启动后删除该应用其它有 `fileId` 的版本目录(只保留当前版本)。
427
+ 选择版本部署。版本目录不在磁盘时先经 `resolveVersionFile` 取回 zip 解压并 `npm install`(耗时,取不回返回 400 且不改应用状态),再以数据库中的 SQL 覆盖目录内 `.sql`;接着按版本的 `env.schema.json` 校验最终环境变量(含注入的变量),版本没有 schema 或变量不合规时返回 400,且不切换版本、不执行迁移、不启动进程;随后立即返回 `deploying`,后台做健康检查。进程启动后删除该应用其它有 `fileId` 的版本目录(只保留当前版本)。
383
428
 
384
429
  | 参数名 | 类型 | 必填 | 默认值 | 说明 |
385
430
  |--------|------|------|--------|------|
@@ -426,6 +471,7 @@ SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署
426
471
  | step | 说明 | `detail`(`status=running` 时) |
427
472
  |------|------|------|
428
473
  | `package` | 准备版本目录 | `reuse` 目录已在磁盘 / `download` 取回 zip / `extract` 解压 / `install` 安装依赖 |
474
+ | `env` | 按 `env.schema.json` 校验环境变量;失败时 `message` 列出每个不合规的键与原因 | - |
429
475
  | `migrate` | 预跑 SQL 迁移;未开启 `runMigration`、`migrateBeforeStart` 或版本无脚本时为 `skipped` | - |
430
476
  | `start` | PM2 拉起进程 | - |
431
477
  | `ready` | 健康检查;通过为 `done`,否则 `error`(`message` 为原因,如 `health check timeout`) | - |
@@ -434,7 +480,7 @@ SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署
434
480
 
435
481
  ```text
436
482
  event: snapshot
437
- data: {"steps":["package","migrate","start","ready"],"events":[]}
483
+ data: {"steps":["package","env","migrate","start","ready"],"events":[]}
438
484
 
439
485
  event: progress
440
486
  data: {"progressId":"p-1","step":"package","status":"running","detail":"download","message":null,"at":"2026-10-10T10:00:00.000Z"}
@@ -509,9 +555,9 @@ data: {"progressId":"p-1","step":"ready","status":"done","detail":null,"message"
509
555
 
510
556
  | 动作 | 行为摘要 |
511
557
  |------|----------|
512
- | start | 需已有 `currentVersionId`;拉起进程并后台健康检查 |
558
+ | start | 需已有 `currentVersionId`;先按 `env.schema.json` 校验环境变量,不通过返回 400 且不改状态;拉起进程并后台健康检查 |
513
559
  | stop | PM2 stop,状态 `stopped` |
514
- | restart | 优先 PM2 restart,失败则重新 startProcess |
560
+ | restart | 先校验环境变量(不通过返回 400,正在运行的进程不受影响),再停止并重新 startProcess |
515
561
  | remove | 删 PM2 进程、版本记录、落盘目录与 App 行 |
516
562
 
517
563
  ##### GET `{prefix}/app/logs`
@@ -686,6 +732,9 @@ data: {"name":"demo","ts":1791456608533,...}
686
732
  | `listVersions({ name, perPage, currentPage })` | 版本列表 |
687
733
  | `deploy({ name, versionId, version, runMigration, migrations?, progressId? })` | 部署;版本目录缺失时先从宿主文件重建,启动后清理其它版本目录;立即 `deploying`,后台 `syncRealStatus`;各阶段进度写入 `getDeployProgress()` |
688
734
  | `getDeployProgress()` | 部署进度中心:`emitter`(事件名 `deploy:{name}`)、`snapshot(name)` 返回最近一次部署 `{ progressId, events }` |
735
+ | `waitDeployProgress({ name, progressId, timeoutMs? })` | 等到该次部署出错或 `ready` 结束(默认超时 120000ms),返回 `{ progressId, ok, timedOut, events }`;供 MCP 等同步调用方使用 |
736
+ | `getEnvDeclaration({ name, versionId? })` | 同 `GET .../app/env/schema` |
737
+ | `listInjectedEnvKeys(app)` | 应用中心注入(或在其它页签维护)的变量名 `Set`,环境变量声明中不展示 |
689
738
  | `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
690
739
  | `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
691
740
  | `syncAllStatuses({ recoverIfMissing?, excludeIds? })` | 批量同步(父应用启动 reconcile 使用);`excludeIds` 中的应用跳过 |
@@ -777,6 +826,7 @@ data: {"name":"demo","ts":1791456608533,...}
777
826
  | migrationPath | string | 相对 server 的 sql 目录,默认 `sql` |
778
827
  | fileId | string / null | 宿主文件 id(经 `fileId` 上传时写入);版本目录据此按需重建,非当前版本不常驻磁盘 |
779
828
  | migrationFiles | json / null | SQL 迁移脚本 `[{ name, content, updatedAt }]`,接口不返回(列表只给 `sqlFiles` 文件名) |
829
+ | envSchema | json / null | `server/env.schema.json` 内容,接口不返回(列表只给 `hasEnvSchema`);老版本为空时从版本目录回填 |
780
830
 
781
831
  唯一约束:`(app_name, version)`。
782
832
 
@@ -840,9 +890,31 @@ rename 为 {stream}-{起始时间}.log ──→ pm2 reloadLogs ──→ gzip
840
890
  | 非 Buffer / 超 `maxZipSize` / 超 `maxZipEntries` | 抛错 |
841
891
  | 条目含 `..` 或逃出目标目录 | Zip Slip,抛错 |
842
892
  | 缺根/server package、server/index、build 入口 html | `invalid package` |
893
+ | 缺 `server/env.schema.json`、JSON 非法或不符合规范 | `invalid package`(从宿主文件还原老版本目录时不检查,由启动前校验提示) |
843
894
 
844
895
  部署时会将根 `build/` 复制到 `server/build/`,再对 `server/build` 做 entryHtml 注入。
845
896
 
897
+ ##### 环境变量 schema(server/env.schema.json)
898
+
899
+ 标准 JSON Schema,子应用 `@fastify/env` 直接 `require` 使用;为兼容 ajv 严格模式只用标准关键字:
900
+
901
+ - 顶层 `"type": "object"`,必填项写进顶层 `required`(其中的键必须在 `properties` 中声明)。
902
+ - `properties.<KEY>` 内用 `type` / `default` / `enum` / `pattern` / `description` / `title`;敏感值标 `"writeOnly": true`(环境变量管理按密钥处理)。不使用 `x-` 等自定义关键字。
903
+ - 校验规则与 `@fastify/env` 一致(`coerceTypes`、`useDefaults`);空字符串视为未配置。
904
+
905
+ 启动前(deploy 的 `env` 阶段、start、restart、宿主重启后的恢复)都会校验,不通过时不启动进程。
906
+
907
+ ```json
908
+ {
909
+ "type": "object",
910
+ "required": ["API_TOKEN"],
911
+ "properties": {
912
+ "API_TOKEN": { "type": "string", "writeOnly": true, "description": "接口令牌" },
913
+ "PORT": { "type": "number", "default": 8040, "description": "服务端口(应用中心注入)" }
914
+ }
915
+ }
916
+ ```
917
+
846
918
  ##### 环境合并顺序
847
919
 
848
920
  ```
@@ -114,6 +114,25 @@ module.exports = fp(async (fastify, options) => {
114
114
  async request => services.app.saveEnv(request.body)
115
115
  );
116
116
 
117
+ fastify.get(
118
+ `${options.prefix}/app/env/schema`,
119
+ {
120
+ onRequest: auth(),
121
+ schema: {
122
+ summary: '环境变量声明:按版本 env.schema.json 列出需要填写的变量(不含应用中心注入的变量)',
123
+ query: {
124
+ type: 'object',
125
+ required: ['name'],
126
+ properties: {
127
+ name: { type: 'string' },
128
+ versionId: { type: 'string', description: '默认取当前版本,没有则取最新上传的版本' }
129
+ }
130
+ }
131
+ }
132
+ },
133
+ async request => services.app.getEnvDeclaration(request.query)
134
+ );
135
+
117
136
  fastify.post(
118
137
  `${options.prefix}/app/version/upload`,
119
138
  {
@@ -1,5 +1,7 @@
1
1
  const fp = require('fastify-plugin');
2
+ const http = require('node:http');
2
3
  const { Readable } = require('node:stream');
4
+ const httpProxy = require('http-proxy');
3
5
  const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody, tagEtag, restoreConditionalHeaders } = require('../utils/pathRewrite');
4
6
  const { wantsHtml, renderUnavailablePage, getStatusText } = require('../utils/unavailablePage');
5
7
 
@@ -7,17 +9,20 @@ module.exports = fp(async (fastify, options) => {
7
9
  const ns = options.name;
8
10
  const pathPrefix = options.pathPrefix.replace(/\/$/, '') || '/app';
9
11
 
10
- const proxyToApp = async (request, reply, app, { stripPrefix } = {}) => {
11
- let targetPath = request.raw.url || '/';
12
- if (stripPrefix) {
13
- const mount = `${pathPrefix}/${app.name}`;
14
- if (targetPath === mount || targetPath.startsWith(mount + '/') || targetPath.startsWith(mount + '?')) {
15
- targetPath = targetPath.slice(mount.length) || '/';
16
- if (!targetPath.startsWith('/')) {
17
- targetPath = `/${targetPath}`;
18
- }
19
- }
12
+ const pathMatcher = new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/?#]+)`);
13
+
14
+ const stripMountPrefix = (url, appName) => {
15
+ const mount = `${pathPrefix}/${appName}`;
16
+ if (url === mount || url.startsWith(mount + '/') || url.startsWith(mount + '?')) {
17
+ const rest = url.slice(mount.length) || '/';
18
+ return rest.startsWith('/') ? rest : `/${rest}`;
20
19
  }
20
+ return url;
21
+ };
22
+
23
+ const proxyToApp = async (request, reply, app, { stripPrefix } = {}) => {
24
+ const rawUrl = request.raw.url || '/';
25
+ const targetPath = stripPrefix ? stripMountPrefix(rawUrl, app.name) : rawUrl;
21
26
 
22
27
  const dest = `http://127.0.0.1:${app.port}${targetPath}`;
23
28
  const mountPrefix = `${pathPrefix}/${app.name}`;
@@ -128,7 +133,7 @@ module.exports = fp(async (fastify, options) => {
128
133
  return byHost.status === 'running' ? proxyToApp(request, reply, byHost, { stripPrefix: false }) : replyUnavailable(request, reply, byHost);
129
134
  }
130
135
 
131
- const match = url.match(new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/?#]+)`));
136
+ const match = url.match(pathMatcher);
132
137
  if (match) {
133
138
  const appName = decodeName(match[1]);
134
139
  const app = await services.app.findByPathName(appName, { running: false });
@@ -138,4 +143,68 @@ module.exports = fp(async (fastify, options) => {
138
143
  return replyUnavailable(request, reply, { name: appName, status: 'missing' });
139
144
  }
140
145
  });
146
+
147
+ const wsProxy = httpProxy.createProxyServer({ changeOrigin: true });
148
+
149
+ const rejectUpgrade = (socket, statusCode) => {
150
+ if (!socket.writable) {
151
+ return socket.destroy();
152
+ }
153
+ socket.end(`HTTP/1.1 ${statusCode} ${http.STATUS_CODES[statusCode]}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
154
+ };
155
+
156
+ const resolveUpgradeTarget = async req => {
157
+ const url = req.url || '/';
158
+ if (url.startsWith(options.prefix)) {
159
+ return null;
160
+ }
161
+ const services = fastify[ns]?.services;
162
+ if (!services?.app) {
163
+ return null;
164
+ }
165
+ const byHost = await services.app.findByHost(req.headers.host, { running: false });
166
+ if (byHost) {
167
+ return { app: byHost, targetPath: url };
168
+ }
169
+ const match = url.match(pathMatcher);
170
+ if (!match) {
171
+ return null;
172
+ }
173
+ const appName = decodeName(match[1]);
174
+ const app = await services.app.findByPathName(appName, { running: false });
175
+ return { app: app || { name: appName, status: 'missing' }, targetPath: stripMountPrefix(url, appName) };
176
+ };
177
+
178
+ // reply-from only speaks plain HTTP, so WebSocket handshakes under an app go through http-proxy instead.
179
+ const onUpgrade = async (req, socket, head) => {
180
+ socket.on('error', () => socket.destroy());
181
+ let target;
182
+ try {
183
+ target = await resolveUpgradeTarget(req);
184
+ } catch (error) {
185
+ fastify.log.error(error);
186
+ return rejectUpgrade(socket, 502);
187
+ }
188
+ if (!target) {
189
+ return;
190
+ }
191
+ // Node servers allow half-open sockets; once our side has finished writing (tunnel ended or upgrade declined) drop the connection.
192
+ socket.once('finish', () => socket.destroy());
193
+ const { app, targetPath } = target;
194
+ if (app.status !== 'running') {
195
+ return rejectUpgrade(socket, app.status === 'missing' ? 404 : 503);
196
+ }
197
+
198
+ req.url = targetPath;
199
+ wsProxy.ws(req, socket, head, { target: `http://127.0.0.1:${app.port}` }, error => {
200
+ fastify.log.warn({ err: error, app: app.name }, 'websocket upgrade proxy failed');
201
+ rejectUpgrade(socket, 502);
202
+ });
203
+ };
204
+
205
+ fastify.server.on('upgrade', onUpgrade);
206
+ fastify.addHook('onClose', async () => {
207
+ fastify.server.removeListener('upgrade', onUpgrade);
208
+ wsProxy.close();
209
+ });
141
210
  });
@@ -43,6 +43,11 @@ module.exports = ({ DataTypes }) => {
43
43
  type: DataTypes.JSON,
44
44
  allowNull: true,
45
45
  comment: 'SQL 迁移文件 [{ name, content, updatedAt }];版本目录可随时删除,部署时写回磁盘'
46
+ },
47
+ envSchema: {
48
+ type: DataTypes.JSON,
49
+ allowNull: true,
50
+ comment: 'server/env.schema.json 内容;启动前按它校验环境变量'
46
51
  }
47
52
  },
48
53
  options: {
@@ -3,7 +3,22 @@ const path = require('node:path');
3
3
  const crypto = require('node:crypto');
4
4
  const fs = require('fs-extra');
5
5
  const portPool = require('../utils/portPool');
6
- const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys, isSystemEnvKey, omitSystemEnv } = require('../utils/env');
6
+ const {
7
+ mergeEnv,
8
+ maskEnvForResponse,
9
+ applyEnvPatch,
10
+ mergePm2Config,
11
+ normalizeSecretKeys,
12
+ collectSecretKeys,
13
+ isSystemEnvKey,
14
+ isSecretKey,
15
+ omitSystemEnv,
16
+ SECRET_MASK,
17
+ SYSTEM_ENV_KEYS,
18
+ DB_CONNECTION_KEYS,
19
+ DB_DEDICATED_KEY
20
+ } = require('../utils/env');
21
+ const { readEnvSchema, validateEnv, ENV_SCHEMA_FILE } = require('../utils/envSchema');
7
22
  const { prepareVersionArtifact } = require('../utils/version');
8
23
  const { injectEntryHtml } = require('../utils/entryInject');
9
24
  const { ensureLogFiles, readLogTail, readLastLines, appendLog, countLines } = require('../utils/logFiles');
@@ -198,7 +213,9 @@ module.exports = fp(async (fastify, options) => {
198
213
  const app = await getByName(name);
199
214
  // 密钥类型不可撤销,只随变量删除而移除
200
215
  const secretEnvKeys = normalizeSecretKeys([...resolveSecretEnvKeys(app), ...(nextSecretKeys || [])]);
201
- const nextEnv = applyEnvPatch(app.env || {}, env || {}, Object.assign({}, options, { secretEnvKeys }));
216
+ // PORT 由端口池分配,启动时总会覆盖,不接受外部写入
217
+ const { PORT, ...envPatch } = env || {};
218
+ const nextEnv = applyEnvPatch(app.env || {}, envPatch, Object.assign({}, options, { secretEnvKeys }));
202
219
  const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key, options.systemEnvKeys) && Object.prototype.hasOwnProperty.call(nextEnv, key));
203
220
  await app.update({
204
221
  env: nextEnv,
@@ -279,13 +296,31 @@ module.exports = fp(async (fastify, options) => {
279
296
  };
280
297
 
281
298
  const toPublicVersion = (row, files = row.migrationFiles) => {
282
- const { migrationFiles, ...json } = row.toJSON();
299
+ const { migrationFiles, envSchema, ...json } = row.toJSON();
283
300
  if (Array.isArray(files) && files.length) {
284
301
  json.sqlFiles = files.map(item => item.name);
285
302
  }
303
+ json.hasEnvSchema = !!envSchema;
286
304
  return json;
287
305
  };
288
306
 
307
+ /** schema 以数据库为准;旧版本行(envSchema 为空)且版本目录在磁盘时从目录回填 */
308
+ const getVersionEnvSchema = async ver => {
309
+ if (ver.envSchema) {
310
+ return ver.envSchema;
311
+ }
312
+ if (!(await hasArtifact(ver))) {
313
+ return null;
314
+ }
315
+ try {
316
+ const schema = await readEnvSchema(path.join(ver.artifactPath, 'server'));
317
+ await ver.update({ envSchema: schema });
318
+ return schema;
319
+ } catch (e) {
320
+ return null;
321
+ }
322
+ };
323
+
289
324
  const uploadVersion = async ({ name, version, label, zipBuffer, fileId }) => {
290
325
  if (!version) {
291
326
  httpError(400, 'version is required');
@@ -338,7 +373,8 @@ module.exports = fp(async (fastify, options) => {
338
373
  hasMigration: migrationFiles.length > 0,
339
374
  migrationPath: prepared.migrationPath,
340
375
  fileId: fileId ? String(fileId) : null,
341
- migrationFiles
376
+ migrationFiles,
377
+ envSchema: prepared.envSchema
342
378
  },
343
379
  { transaction }
344
380
  );
@@ -579,13 +615,9 @@ module.exports = fp(async (fastify, options) => {
579
615
  return results.filter(Boolean);
580
616
  };
581
617
 
582
- const startProcess = async app => {
583
- const version = await models.appVersion.findByPk(app.currentVersionId);
584
- if (!version) {
585
- throw new Error('current version missing');
586
- }
618
+ /** 子进程最终环境变量:宿主透传 → 托管库 → 应用变量 → PORT → resolveSystemEnv;顺带持久化表前缀与系统变量 */
619
+ const buildProcessEnv = async (app, version) => {
587
620
  const serverDir = path.join(version.artifactPath, 'server');
588
- const { outFile, errFile } = await ensureLogFiles(app.rootPath);
589
621
  const env = mergeEnv({
590
622
  passthroughEnvKeys: options.passthroughEnvKeys,
591
623
  appEnv: app.env,
@@ -604,6 +636,7 @@ module.exports = fp(async (fastify, options) => {
604
636
 
605
637
  if (typeof options.resolveSystemEnv === 'function') {
606
638
  const systemEnv = await options.resolveSystemEnv({ app, version, serverDir });
639
+ const injectedKeys = [];
607
640
  if (systemEnv && typeof systemEnv === 'object') {
608
641
  const persisted = {};
609
642
  for (const [key, value] of Object.entries(systemEnv)) {
@@ -611,6 +644,7 @@ module.exports = fp(async (fastify, options) => {
611
644
  continue;
612
645
  }
613
646
  env[key] = String(value);
647
+ injectedKeys.push(key);
614
648
  if ((options.systemEnvKeys || []).includes(key) && app.env?.[key] !== env[key]) {
615
649
  persisted[key] = env[key];
616
650
  }
@@ -620,7 +654,143 @@ module.exports = fp(async (fastify, options) => {
620
654
  await app.reload();
621
655
  }
622
656
  }
657
+ // 记下本次由系统注入的键,供环境变量声明接口隐藏(如未配置 ORIGIN 时由宿主补上的值)
658
+ const recorded = app.options?.injectedEnvKeys || [];
659
+ if (recorded.length !== injectedKeys.length || recorded.some(key => !injectedKeys.includes(key))) {
660
+ await app.update({ options: Object.assign({}, app.options || {}, { injectedEnvKeys: injectedKeys }) });
661
+ await app.reload();
662
+ }
623
663
  }
664
+ return env;
665
+ };
666
+
667
+ const envSchemaMissingError = () => {
668
+ const err = new Error(`该版本缺少 server/${ENV_SCHEMA_FILE},请补充后重新上传`);
669
+ err.envErrors = [];
670
+ return err;
671
+ };
672
+
673
+ /** 按版本的 env.schema.json 校验最终环境变量,不通过时抛错(err.envErrors 为 [{ key, message }]) */
674
+ const assertEnvValid = async (app, version, env) => {
675
+ const schema = await getVersionEnvSchema(version);
676
+ if (!schema) {
677
+ throw envSchemaMissingError();
678
+ }
679
+ const errors = validateEnv(schema, env);
680
+ if (errors.length) {
681
+ const injected = listInjectedEnvKeys(app);
682
+ const detail = errors.map(({ key, message }) => `${key}${injected.has(key) ? '(应用中心注入)' : ''} ${message}`).join(';');
683
+ const err = new Error(`环境变量不符合 ${ENV_SCHEMA_FILE}:${detail}`);
684
+ err.envErrors = errors;
685
+ throw err;
686
+ }
687
+ };
688
+
689
+ /** 应用中心自动注入或在其它页签维护的变量名:环境变量管理中不展示 */
690
+ const listInjectedEnvKeys = app =>
691
+ new Set([
692
+ 'PORT',
693
+ ...SYSTEM_ENV_KEYS,
694
+ ...(options.systemEnvKeys || []),
695
+ ...(options.passthroughEnvKeys || []),
696
+ // 托管库连接由宿主注入,自有库在数据页签配置
697
+ ...DB_CONNECTION_KEYS,
698
+ DB_DEDICATED_KEY,
699
+ ...(app.options?.injectedEnvKeys || [])
700
+ ]);
701
+
702
+ const resolveDeclarationVersion = async (app, versionId) => {
703
+ if (versionId) {
704
+ const ver = await models.appVersion.findByPk(versionId);
705
+ if (!ver || ver.appName !== app.name) {
706
+ httpError(404, 'version not found');
707
+ }
708
+ return ver;
709
+ }
710
+ if (app.currentVersionId) {
711
+ const ver = await models.appVersion.findByPk(app.currentVersionId);
712
+ if (ver) {
713
+ return ver;
714
+ }
715
+ }
716
+ return models.appVersion.findOne({ where: { appName: app.name }, order: [['createdAt', 'DESC']] });
717
+ };
718
+
719
+ /**
720
+ * 环境变量声明:declared 为 schema 中需要用户填写的变量,others 为已配置但未声明的变量;
721
+ * 两者都不含应用中心注入的变量,密钥值掩码
722
+ */
723
+ const getEnvDeclaration = async ({ name, versionId } = {}) => {
724
+ const app = await getByName(name);
725
+ const ver = await resolveDeclarationVersion(app, versionId);
726
+ const schema = ver ? await getVersionEnvSchema(ver) : null;
727
+ const injected = listInjectedEnvKeys(app);
728
+ const appEnv = omitSystemEnv(app.env || {}, options.systemEnvKeys);
729
+ const secretOpts = Object.assign({}, options, { secretEnvKeys: resolveSecretEnvKeys(app) });
730
+ const isSecret = (key, prop) => !!prop?.writeOnly || isSecretKey(key, options.secretEnvKeyPattern, secretOpts.secretEnvKeys);
731
+ const maskValue = (key, prop) => {
732
+ const value = appEnv[key];
733
+ if (value == null || value === '') {
734
+ return null;
735
+ }
736
+ return isSecret(key, prop) ? SECRET_MASK : value;
737
+ };
738
+
739
+ const properties = schema?.properties || {};
740
+ const required = new Set(schema?.required || []);
741
+ const errorMap = new Map();
742
+ if (schema) {
743
+ const previewEnv = mergeEnv({
744
+ passthroughEnvKeys: options.passthroughEnvKeys,
745
+ appEnv: app.env,
746
+ port: app.port,
747
+ defaultAppDb: options.defaultAppDb,
748
+ appName: app.name
749
+ });
750
+ validateEnv(schema, previewEnv)
751
+ .filter(item => !injected.has(item.key))
752
+ .forEach(item => errorMap.set(item.key, item.message));
753
+ }
754
+
755
+ const declared = Object.entries(properties)
756
+ .filter(([key]) => !injected.has(key))
757
+ .map(([key, prop]) => ({
758
+ key,
759
+ type: prop?.type || null,
760
+ required: required.has(key),
761
+ default: prop?.default === undefined ? null : prop.default,
762
+ description: prop?.description || prop?.title || null,
763
+ enum: Array.isArray(prop?.enum) ? prop.enum : null,
764
+ secret: isSecret(key, prop),
765
+ configured: appEnv[key] != null && appEnv[key] !== '',
766
+ value: maskValue(key, prop),
767
+ error: errorMap.get(key) || null
768
+ }));
769
+
770
+ const others = Object.keys(appEnv)
771
+ .filter(key => !injected.has(key) && !Object.prototype.hasOwnProperty.call(properties, key))
772
+ .map(key => ({ key, secret: isSecret(key), value: maskValue(key) }));
773
+
774
+ return {
775
+ name: app.name,
776
+ versionId: ver ? String(ver.id) : null,
777
+ version: ver ? ver.version : null,
778
+ hasSchema: !!schema,
779
+ valid: !!schema && errorMap.size === 0,
780
+ declared,
781
+ others
782
+ };
783
+ };
784
+
785
+ const startProcess = async (app, { env: preparedEnv } = {}) => {
786
+ const version = await models.appVersion.findByPk(app.currentVersionId);
787
+ if (!version) {
788
+ throw new Error('current version missing');
789
+ }
790
+ const serverDir = path.join(version.artifactPath, 'server');
791
+ const env = preparedEnv || (await buildProcessEnv(app, version));
792
+ await assertEnvValid(app, version, env);
793
+ const { outFile, errFile } = await ensureLogFiles(app.rootPath);
624
794
 
625
795
  const pathBase = mountPrefix(app.name);
626
796
  // Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
@@ -688,6 +858,8 @@ module.exports = fp(async (fastify, options) => {
688
858
  maxZipEntries: options.maxZipEntries,
689
859
  npmInstallTimeoutMs: options.npmInstallTimeoutMs,
690
860
  sqlPath: options.sqlPath,
861
+ // 老版本可能没有 env.schema.json:照常还原目录,由启动前的环境变量校验给出提示
862
+ requireEnvSchema: false,
691
863
  onProgress
692
864
  });
693
865
  await fs.move(stagingPath, ver.artifactPath, { overwrite: true });
@@ -777,6 +949,19 @@ module.exports = fp(async (fastify, options) => {
777
949
  }
778
950
  progress.report(step, 'done');
779
951
 
952
+ // 变量不合规时不切换版本、不执行迁移、不启动进程
953
+ step = 'env';
954
+ progress.report(step, 'running');
955
+ let processEnv;
956
+ try {
957
+ processEnv = await buildProcessEnv(app, ver);
958
+ await assertEnvValid(app, ver, processEnv);
959
+ } catch (e) {
960
+ progress.report(step, 'error', { message: e.message });
961
+ httpError(400, e.message);
962
+ }
963
+ progress.report(step, 'done');
964
+
780
965
  await app.update({
781
966
  status: 'deploying',
782
967
  currentVersionId: String(ver.id),
@@ -830,13 +1015,13 @@ module.exports = fp(async (fastify, options) => {
830
1015
 
831
1016
  if (runMigration === false) {
832
1017
  // child may honor RUN_SQL_ON_SYNC
833
- app.env = Object.assign({}, app.env, { RUN_SQL_ON_SYNC: 'false' });
1018
+ processEnv.RUN_SQL_ON_SYNC = 'false';
834
1019
  }
835
1020
 
836
1021
  step = 'start';
837
1022
  progress.report(step, 'running');
838
1023
  const beforeForReady = await snapshotTablesSafe(app);
839
- await startProcess(app);
1024
+ await startProcess(app, { env: processEnv });
840
1025
  progress.report(step, 'done');
841
1026
  step = 'ready';
842
1027
  progress.report(step, 'running');
@@ -872,15 +1057,28 @@ module.exports = fp(async (fastify, options) => {
872
1057
  return toPublicApp(app);
873
1058
  };
874
1059
 
875
- const start = async ({ name }) => {
876
- const app = await getByName(name);
877
- if (!app.currentVersionId) {
1060
+ /** 启动 / 重启前先按 schema 校验,不通过直接报错,不改动正在运行的进程与状态 */
1061
+ const prepareStartEnv = async app => {
1062
+ const version = app.currentVersionId ? await models.appVersion.findByPk(app.currentVersionId) : null;
1063
+ if (!version) {
878
1064
  httpError(400, 'no version deployed');
879
1065
  }
1066
+ const env = await buildProcessEnv(app, version);
1067
+ try {
1068
+ await assertEnvValid(app, version, env);
1069
+ } catch (e) {
1070
+ httpError(400, e.message);
1071
+ }
1072
+ return env;
1073
+ };
1074
+
1075
+ const start = async ({ name }) => {
1076
+ const app = await getByName(name);
1077
+ const env = await prepareStartEnv(app);
880
1078
  await app.update({ status: 'deploying', message: null });
881
1079
  try {
882
1080
  const beforeTables = await snapshotTablesSafe(app);
883
- await startProcess(app);
1081
+ await startProcess(app, { env });
884
1082
  scheduleStatusSync(app.id, { beforeTables });
885
1083
  return toPublicApp(await getByName(name));
886
1084
  } catch (e) {
@@ -891,6 +1089,7 @@ module.exports = fp(async (fastify, options) => {
891
1089
 
892
1090
  const restart = async ({ name }) => {
893
1091
  const app = await getByName(name);
1092
+ const env = await prepareStartEnv(app);
894
1093
  await app.update({ status: 'deploying', message: null });
895
1094
  try {
896
1095
  const beforeTables = await snapshotTablesSafe(app);
@@ -900,7 +1099,7 @@ module.exports = fp(async (fastify, options) => {
900
1099
  } catch (e) {
901
1100
  // ignore if not running
902
1101
  }
903
- await startProcess(app);
1102
+ await startProcess(app, { env });
904
1103
  scheduleStatusSync(app.id, { beforeTables });
905
1104
  return toPublicApp(await getByName(name));
906
1105
  } catch (e) {
@@ -1100,6 +1299,9 @@ module.exports = fp(async (fastify, options) => {
1100
1299
  load,
1101
1300
  getLoadStore,
1102
1301
  getDeployProgress: () => deployProgress,
1302
+ waitDeployProgress: ({ name, progressId, timeoutMs }) => deployProgress.wait(name, progressId, { timeoutMs }),
1303
+ getEnvDeclaration,
1304
+ listInjectedEnvKeys,
1103
1305
  toPublicApp,
1104
1306
  mountPrefix,
1105
1307
  findByHost,
@@ -42,7 +42,7 @@ module.exports = fp(async (fastify, options) => {
42
42
  const queryInterface = model.sequelize.getQueryInterface();
43
43
  const table = model.getTableName();
44
44
  const columns = await queryInterface.describeTable(table);
45
- for (const name of ['fileId', 'migrationFiles']) {
45
+ for (const name of ['fileId', 'migrationFiles', 'envSchema']) {
46
46
  const attr = model.rawAttributes[name];
47
47
  if (!columns[attr.field]) {
48
48
  await queryInterface.addColumn(table, attr.field, { type: attr.type, allowNull: true, comment: attr.comment });
@@ -1,7 +1,7 @@
1
1
  const { EventEmitter } = require('node:events');
2
2
 
3
- /** 部署阶段,按顺序推进:准备部署包 → SQL 迁移 → 启动进程 → 等待就绪 */
4
- const DEPLOY_STEPS = ['package', 'migrate', 'start', 'ready'];
3
+ /** 部署阶段,按顺序推进:准备部署包 → 校验环境变量 → SQL 迁移 → 启动进程 → 等待就绪 */
4
+ const DEPLOY_STEPS = ['package', 'env', 'migrate', 'start', 'ready'];
5
5
 
6
6
  /**
7
7
  * 每个应用只保留最近一次部署的进度事件,供 SSE 订阅与断线重连回放。
@@ -28,7 +28,36 @@ const createDeployProgress = () => {
28
28
 
29
29
  const clear = name => latest.delete(name);
30
30
 
31
- return { emitter, begin, snapshot, clear };
31
+ const isFinished = event => event.status === 'error' || (event.step === 'ready' && event.status !== 'running');
32
+
33
+ /** 等到该次部署出错或就绪阶段结束(或超时);返回 { progressId, ok, timedOut, events } */
34
+ const wait = (name, progressId, { timeoutMs = 120000 } = {}) =>
35
+ new Promise(resolve => {
36
+ const collect = () => {
37
+ const state = latest.get(name);
38
+ return state && state.progressId === progressId ? state.events.slice() : [];
39
+ };
40
+ let timer = null;
41
+ const listener = event => {
42
+ if (event.progressId === progressId && isFinished(event)) {
43
+ finish(false);
44
+ }
45
+ };
46
+ const finish = timedOut => {
47
+ clearTimeout(timer);
48
+ emitter.off(`deploy:${name}`, listener);
49
+ const events = collect();
50
+ resolve({ progressId, ok: !timedOut && !events.some(event => event.status === 'error'), timedOut, events });
51
+ };
52
+ if (collect().some(isFinished)) {
53
+ finish(false);
54
+ return;
55
+ }
56
+ emitter.on(`deploy:${name}`, listener);
57
+ timer = setTimeout(() => finish(true), timeoutMs);
58
+ });
59
+
60
+ return { emitter, begin, snapshot, clear, wait };
32
61
  };
33
62
 
34
63
  module.exports = {
package/libs/utils/env.js CHANGED
@@ -194,6 +194,7 @@ module.exports = {
194
194
  pickHostEnv,
195
195
  hasAppDbConfig,
196
196
  DB_DEDICATED_KEY,
197
+ DB_CONNECTION_KEYS,
197
198
  isAppDbEnabled,
198
199
  buildDefaultAppDbEnv,
199
200
  resolveAppDbEnv,
@@ -0,0 +1,105 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const Ajv = require('ajv');
4
+
5
+ const ENV_SCHEMA_FILE = 'env.schema.json';
6
+
7
+ // 与 @fastify/env(env-schema)一致:类型转换、填充默认值,separator 为其内置关键字
8
+ const createAjv = () => {
9
+ const ajv = new Ajv({ allErrors: true, coerceTypes: true, useDefaults: true, allowUnionTypes: true });
10
+ ajv.addKeyword('separator');
11
+ return ajv;
12
+ };
13
+
14
+ /** 检查 schema 是否符合规范:顶层 object、有 properties、required 中的键都已声明、能被 ajv 编译 */
15
+ const checkEnvSchema = schema => {
16
+ const errors = [];
17
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
18
+ return ['env.schema.json must be a JSON object'];
19
+ }
20
+ if (schema.type !== 'object') {
21
+ errors.push('env.schema.json: "type" must be "object"');
22
+ }
23
+ if (!schema.properties || typeof schema.properties !== 'object' || Array.isArray(schema.properties)) {
24
+ errors.push('env.schema.json: "properties" must be an object');
25
+ }
26
+ if (schema.required !== undefined) {
27
+ if (!Array.isArray(schema.required) || schema.required.some(key => typeof key !== 'string')) {
28
+ errors.push('env.schema.json: "required" must be an array of strings');
29
+ } else if (schema.properties && typeof schema.properties === 'object') {
30
+ const undeclared = schema.required.filter(key => !Object.prototype.hasOwnProperty.call(schema.properties, key));
31
+ if (undeclared.length) {
32
+ errors.push(`env.schema.json: required keys not declared in properties: ${undeclared.join(', ')}`);
33
+ }
34
+ }
35
+ }
36
+ if (!errors.length) {
37
+ try {
38
+ createAjv().compile(schema);
39
+ } catch (e) {
40
+ errors.push(`env.schema.json: ${e.message}`);
41
+ }
42
+ }
43
+ return errors;
44
+ };
45
+
46
+ /** 读取 server/env.schema.json;文件缺失、JSON 非法或不符合规范时抛错 */
47
+ const readEnvSchema = async serverDir => {
48
+ const file = path.join(serverDir, ENV_SCHEMA_FILE);
49
+ if (!(await fs.pathExists(file))) {
50
+ throw new Error(`missing server/${ENV_SCHEMA_FILE}`);
51
+ }
52
+ let schema;
53
+ try {
54
+ schema = JSON.parse(await fs.readFile(file, 'utf8'));
55
+ } catch (e) {
56
+ throw new Error(`server/${ENV_SCHEMA_FILE} is not valid JSON: ${e.message}`);
57
+ }
58
+ const errors = checkEnvSchema(schema);
59
+ if (errors.length) {
60
+ throw new Error(errors.join('; '));
61
+ }
62
+ return schema;
63
+ };
64
+
65
+ const formatError = error => {
66
+ const { keyword, params = {} } = error;
67
+ if (keyword === 'required') {
68
+ return { key: params.missingProperty, message: '必填,未配置' };
69
+ }
70
+ const key = String(error.instancePath || '')
71
+ .replace(/^\//, '')
72
+ .split('/')[0];
73
+ if (keyword === 'type') {
74
+ return { key, message: `类型应为 ${params.type}` };
75
+ }
76
+ if (keyword === 'enum') {
77
+ return { key, message: `取值应为 ${(params.allowedValues || []).join(' / ')}` };
78
+ }
79
+ if (keyword === 'pattern') {
80
+ return { key, message: `格式应匹配 ${params.pattern}` };
81
+ }
82
+ return { key, message: error.message };
83
+ };
84
+
85
+ /** 按 schema 校验环境变量;空字符串视为未配置。返回 [{ key, message }],空数组表示通过 */
86
+ const validateEnv = (schema, env = {}) => {
87
+ const data = {};
88
+ for (const [key, value] of Object.entries(env || {})) {
89
+ if (value != null && value !== '') {
90
+ data[key] = value;
91
+ }
92
+ }
93
+ const validate = createAjv().compile(schema);
94
+ if (validate(data)) {
95
+ return [];
96
+ }
97
+ return (validate.errors || []).map(formatError);
98
+ };
99
+
100
+ module.exports = {
101
+ ENV_SCHEMA_FILE,
102
+ checkEnvSchema,
103
+ readEnvSchema,
104
+ validateEnv
105
+ };
@@ -1,9 +1,11 @@
1
1
  const path = require('node:path');
2
2
  const fs = require('fs-extra');
3
+ const { readEnvSchema } = require('./envSchema');
3
4
 
4
5
  const ENTRY_CANDIDATES = ['index.html', 'entry.html', 'entry-prod.html'];
5
6
 
6
- const validatePackageRoot = async rootDir => {
7
+ /** requireEnvSchema=false 时缺少 / 非法的 env.schema.json 不算错误,envSchema 返回 null(用于从宿主文件还原老版本) */
8
+ const validatePackageRoot = async (rootDir, { requireEnvSchema = true } = {}) => {
7
9
  const errors = [];
8
10
  const rootPkg = path.join(rootDir, 'package.json');
9
11
  const serverPkg = path.join(rootDir, 'server', 'package.json');
@@ -28,9 +30,19 @@ const validatePackageRoot = async rootDir => {
28
30
  }
29
31
  }
30
32
 
33
+ let envSchema = null;
34
+ try {
35
+ envSchema = await readEnvSchema(path.join(rootDir, 'server'));
36
+ } catch (e) {
37
+ if (requireEnvSchema) {
38
+ errors.push(e.message);
39
+ }
40
+ }
41
+
31
42
  return {
32
43
  ok: errors.length === 0,
33
44
  errors,
45
+ envSchema,
34
46
  hasMigration: await hasSqlMigrations(path.join(rootDir, 'server', 'sql'))
35
47
  };
36
48
  };
@@ -34,14 +34,14 @@ const runNpmInstall = (cwd, timeoutMs) =>
34
34
  });
35
35
 
36
36
  /** install=false 时只解压校验,不装依赖(用于不常驻磁盘的版本上传) */
37
- const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath, install = true, onProgress }) => {
37
+ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath, install = true, requireEnvSchema = true, onProgress }) => {
38
38
  await fs.emptyDir(artifactPath);
39
39
  onProgress && onProgress('extract');
40
40
  const extractDir = path.join(artifactPath, '_extract');
41
41
  await extractZipSafe(zipBuffer, extractDir, { maxZipSize, maxZipEntries });
42
42
  const packageRoot = await resolvePackageRoot(extractDir);
43
43
 
44
- const validation = await validatePackageRoot(packageRoot);
44
+ const validation = await validatePackageRoot(packageRoot, { requireEnvSchema });
45
45
  if (!validation.ok) {
46
46
  await fs.remove(artifactPath);
47
47
  throw new Error(`invalid package: ${validation.errors.join('; ')}`);
@@ -72,7 +72,8 @@ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, max
72
72
  return {
73
73
  hasMigration: sqlFiles.length > 0,
74
74
  migrationPath: sqlPath || 'sql',
75
- sqlFiles
75
+ sqlFiles,
76
+ envSchema: validation.envSchema
76
77
  };
77
78
  };
78
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-app-manager",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -69,7 +69,9 @@
69
69
  "@fastify/reply-from": "^12.6.5",
70
70
  "@kne/ensure-slash": "^0.1.0",
71
71
  "adm-zip": "^0.6.0",
72
+ "ajv": "^8.17.1",
72
73
  "fs-extra": "^11.4.0",
74
+ "http-proxy": "^1.18.1",
73
75
  "jsdom": "^29.1.1",
74
76
  "lodash": "^4.18.1",
75
77
  "pm2": "^7.0.4",