@kne/fastify-app-manager 0.1.10 → 0.1.12

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.
@@ -35,8 +35,28 @@ module.exports = fp(async (fastify, options) => {
35
35
  return models.app.findOne({ where: { pm2Name } });
36
36
  };
37
37
 
38
+ /** 宿主 sync() 不会给已有表加列:老库升级时补齐后续新增的可空列 */
39
+ const ensureSchema = async () => {
40
+ const { models } = fastify[options.name];
41
+ const model = models.appVersion;
42
+ const queryInterface = model.sequelize.getQueryInterface();
43
+ const table = model.getTableName();
44
+ const columns = await queryInterface.describeTable(table);
45
+ for (const name of ['fileId', 'migrationFiles', 'envSchema']) {
46
+ const attr = model.rawAttributes[name];
47
+ if (!columns[attr.field]) {
48
+ await queryInterface.addColumn(table, attr.field, { type: attr.type, allowNull: true, comment: attr.comment });
49
+ }
50
+ }
51
+ };
52
+
38
53
  const onReady = async () => {
39
54
  await fsEnsureAppsRoot();
55
+ try {
56
+ await ensureSchema();
57
+ } catch (e) {
58
+ fastify.log.warn({ err: e }, 'ensure app-manager schema failed');
59
+ }
40
60
  try {
41
61
  await pm2Util.connect();
42
62
  connected = true;
@@ -87,9 +107,9 @@ module.exports = fp(async (fastify, options) => {
87
107
  loadTimer.unref?.();
88
108
  }
89
109
 
90
- // 父应用重启后不阻塞 ready:后台按 PM2 真实状态异步对齐 DB
110
+ // 父应用重启后不阻塞 ready:后台按 PM2 真实状态对齐 DB,缺部署目录的应用另行补回后再拉起
91
111
  setImmediate(() => {
92
- reconcile().catch(err => {
112
+ recoverOnStartup().catch(err => {
93
113
  fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
94
114
  });
95
115
  rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
@@ -103,14 +123,57 @@ module.exports = fp(async (fastify, options) => {
103
123
  await fs.ensureDir(options.appsRoot);
104
124
  };
105
125
 
106
- const reconcile = async () => {
126
+ const reconcile = async ({ excludeIds } = {}) => {
107
127
  const { services } = fastify[options.name];
108
128
  if (typeof services.app.syncAllStatuses === 'function') {
109
- return services.app.syncAllStatuses({ recoverIfMissing: true });
129
+ return services.app.syncAllStatuses({ recoverIfMissing: true, excludeIds });
110
130
  }
111
131
  return [];
112
132
  };
113
133
 
134
+ /** 逐个补回缺失的部署目录(npm install 耗时),每补完一个就单独对齐状态并按需拉起 */
135
+ const restoreArtifacts = async missing => {
136
+ const { services } = fastify[options.name];
137
+ const results = [];
138
+ for (const item of missing) {
139
+ const result = await services.app.restoreArtifact(item);
140
+ results.push(result);
141
+ const { app } = item;
142
+ const wasActive = app.status === 'running' || app.status === 'deploying';
143
+ try {
144
+ const synced = await services.app.syncStatus({ name: app.name, recoverIfMissing: result.action === 'restored' && wasActive });
145
+ if (result.action !== 'restored' && wasActive && synced?.status !== 'running') {
146
+ await app.update({ status: 'error', message: `current version artifact missing: ${result.reason}` });
147
+ }
148
+ } catch (e) {
149
+ fastify.log.warn({ err: e, app: app.name }, 'sync status after artifact restore failed');
150
+ }
151
+ }
152
+ return results;
153
+ };
154
+
155
+ const recoverOnStartup = async () => {
156
+ const { services } = fastify[options.name];
157
+ const missing = await services.app.listMissingArtifacts();
158
+ // 两路并行:部署目录完好的应用不必等其它应用重装依赖
159
+ const [reconciled, restored] = await Promise.all([
160
+ reconcile({ excludeIds: missing.map(({ app }) => app.id) }).catch(err => {
161
+ fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
162
+ return [];
163
+ }),
164
+ restoreArtifacts(missing).catch(err => {
165
+ fastify.log.warn({ err }, 'restore missing artifacts failed');
166
+ return [];
167
+ })
168
+ ]);
169
+ // 只保留当前版本目录,其余版本部署时再从宿主文件解压
170
+ const pruned = await services.app.pruneAllVersionArtifacts().catch(err => {
171
+ fastify.log.warn({ err }, 'prune version artifacts failed');
172
+ return [];
173
+ });
174
+ return { reconciled, restored, pruned };
175
+ };
176
+
114
177
  const runRotation = async ({ now = new Date() } = {}) => {
115
178
  const { models } = fastify[options.name];
116
179
  const apps = await models.app.findAll({ attributes: ['name', 'rootPath'] });
@@ -244,6 +307,6 @@ module.exports = fp(async (fastify, options) => {
244
307
  };
245
308
 
246
309
  Object.assign(fastify[options.name].services, {
247
- bootstrap: { onReady, onClose, reconcile, rotateLogs, sampleLoad }
310
+ bootstrap: { onReady, onClose, ensureSchema, recoverOnStartup, reconcile, rotateLogs, sampleLoad }
248
311
  });
249
312
  });
@@ -1,12 +1,10 @@
1
1
  const fp = require('fastify-plugin');
2
- const path = require('node:path');
3
- const fs = require('fs-extra');
4
- const { listSqlFiles } = require('../utils/validatePackage');
5
2
  const { isValidSqlFileName, readExecutedMigrations, recordMigration, removeMigrationRecord } = require('../utils/migrate');
6
3
 
7
4
  module.exports = fp(async (fastify, options) => {
8
5
  const { models } = fastify[options.name];
9
6
  const getDbops = () => fastify[options.name].services.dbops;
7
+ const getAppService = () => fastify[options.name].services.app;
10
8
 
11
9
  const httpError = (statusCode, message) => {
12
10
  if (fastify.httpErrors) {
@@ -27,8 +25,7 @@ module.exports = fp(async (fastify, options) => {
27
25
  if (!ver || ver.appName !== name) {
28
26
  httpError(404, 'version not found');
29
27
  }
30
- const sqlDir = path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath);
31
- return { app, ver, sqlDir };
28
+ return { app, ver };
32
29
  };
33
30
 
34
31
  const assertFileName = file => {
@@ -37,25 +34,20 @@ module.exports = fp(async (fastify, options) => {
37
34
  }
38
35
  };
39
36
 
40
- const assertFileExists = async (sqlDir, file) => {
37
+ const getFiles = ver => getAppService().getMigrationFiles(ver);
38
+
39
+ const findFile = async (ver, file) => {
41
40
  assertFileName(file);
42
- const filePath = path.join(sqlDir, file);
43
- if (!(await fs.pathExists(filePath))) {
41
+ const item = (await getFiles(ver)).find(f => f.name === file);
42
+ if (!item) {
44
43
  httpError(404, `sql file ${file} not found`);
45
44
  }
46
- return filePath;
47
- };
48
-
49
- const syncHasMigration = async (ver, sqlDir) => {
50
- const hasMigration = (await listSqlFiles(sqlDir)).length > 0;
51
- if (ver.hasMigration !== hasMigration) {
52
- await ver.update({ hasMigration });
53
- }
45
+ return item;
54
46
  };
55
47
 
56
48
  const list = async ({ name, versionId }) => {
57
- const { app, ver, sqlDir } = await resolveVersion({ name, versionId });
58
- const files = await listSqlFiles(sqlDir);
49
+ const { app, ver } = await resolveVersion({ name, versionId });
50
+ const files = await getFiles(ver);
59
51
  let executedMap = new Map();
60
52
  let dbError = null;
61
53
  try {
@@ -63,18 +55,13 @@ module.exports = fp(async (fastify, options) => {
63
55
  } catch (e) {
64
56
  dbError = e.message;
65
57
  }
66
- const pageData = await Promise.all(
67
- files.map(async file => {
68
- const stat = await fs.stat(path.join(sqlDir, file));
69
- return {
70
- name: file,
71
- size: stat.size,
72
- updatedAt: stat.mtime,
73
- executed: dbError ? null : executedMap.has(file),
74
- executedAt: executedMap.get(file) || null
75
- };
76
- })
77
- );
58
+ const pageData = files.map(item => ({
59
+ name: item.name,
60
+ size: Buffer.byteLength(item.content || '', 'utf8'),
61
+ updatedAt: item.updatedAt || null,
62
+ executed: dbError ? null : executedMap.has(item.name),
63
+ executedAt: executedMap.get(item.name) || null
64
+ }));
78
65
  return {
79
66
  versionId: String(ver.id),
80
67
  version: ver.version,
@@ -87,32 +74,33 @@ module.exports = fp(async (fastify, options) => {
87
74
  };
88
75
 
89
76
  const content = async ({ name, versionId, file }) => {
90
- const { sqlDir } = await resolveVersion({ name, versionId });
91
- const filePath = await assertFileExists(sqlDir, file);
92
- return { name: file, content: await fs.readFile(filePath, 'utf8') };
77
+ const { ver } = await resolveVersion({ name, versionId });
78
+ const item = await findFile(ver, file);
79
+ return { name: file, content: item.content || '' };
93
80
  };
94
81
 
95
82
  const save = async ({ name, versionId, file, content: sql }) => {
96
83
  assertFileName(file);
97
- const { ver, sqlDir } = await resolveVersion({ name, versionId });
98
- await fs.ensureDir(sqlDir);
99
- await fs.writeFile(path.join(sqlDir, file), sql || '', 'utf8');
100
- await syncHasMigration(ver, sqlDir);
84
+ const { ver } = await resolveVersion({ name, versionId });
85
+ const files = (await getFiles(ver)).filter(f => f.name !== file);
86
+ files.push({ name: file, content: sql || '', updatedAt: new Date().toISOString() });
87
+ await getAppService().saveMigrationFiles(ver, files);
101
88
  return { name: file };
102
89
  };
103
90
 
104
91
  const remove = async ({ name, versionId, file }) => {
105
- const { ver, sqlDir } = await resolveVersion({ name, versionId });
106
- const filePath = await assertFileExists(sqlDir, file);
107
- await fs.remove(filePath);
108
- await syncHasMigration(ver, sqlDir);
92
+ const { ver } = await resolveVersion({ name, versionId });
93
+ await findFile(ver, file);
94
+ await getAppService().saveMigrationFiles(
95
+ ver,
96
+ (await getFiles(ver)).filter(f => f.name !== file)
97
+ );
109
98
  return { name: file };
110
99
  };
111
100
 
112
101
  const execute = async ({ name, versionId, file }) => {
113
- const { app, sqlDir } = await resolveVersion({ name, versionId });
114
- const filePath = await assertFileExists(sqlDir, file);
115
- const sql = await fs.readFile(filePath, 'utf8');
102
+ const { app, ver } = await resolveVersion({ name, versionId });
103
+ const { content: sql } = await findFile(ver, file);
116
104
  const dbops = getDbops();
117
105
  let beforeTables = [];
118
106
  let afterTables = [];
@@ -131,8 +119,8 @@ module.exports = fp(async (fastify, options) => {
131
119
  };
132
120
 
133
121
  const mark = async ({ name, versionId, file }) => {
134
- const { app, sqlDir } = await resolveVersion({ name, versionId });
135
- await assertFileExists(sqlDir, file);
122
+ const { app, ver } = await resolveVersion({ name, versionId });
123
+ await findFile(ver, file);
136
124
  await getDbops().withSequelize(app, sequelize => recordMigration(sequelize, file));
137
125
  return { name: file, executed: true };
138
126
  };
@@ -0,0 +1,66 @@
1
+ const { EventEmitter } = require('node:events');
2
+
3
+ /** 部署阶段,按顺序推进:准备部署包 → 校验环境变量 → SQL 迁移 → 启动进程 → 等待就绪 */
4
+ const DEPLOY_STEPS = ['package', 'env', 'migrate', 'start', 'ready'];
5
+
6
+ /**
7
+ * 每个应用只保留最近一次部署的进度事件,供 SSE 订阅与断线重连回放。
8
+ * 事件:{ progressId, step, status: 'running' | 'done' | 'skipped' | 'error', detail?, message?, at }
9
+ */
10
+ const createDeployProgress = () => {
11
+ const emitter = new EventEmitter();
12
+ emitter.setMaxListeners(0);
13
+ const latest = new Map();
14
+
15
+ const begin = (name, progressId) => {
16
+ const state = { progressId, events: [] };
17
+ latest.set(name, state);
18
+ const report = (step, status, { detail = null, message = null } = {}) => {
19
+ const event = { progressId, step, status, detail, message, at: new Date().toISOString() };
20
+ state.events.push(event);
21
+ emitter.emit(`deploy:${name}`, event);
22
+ return event;
23
+ };
24
+ return { progressId, report };
25
+ };
26
+
27
+ const snapshot = name => latest.get(name) || null;
28
+
29
+ const clear = name => latest.delete(name);
30
+
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 };
61
+ };
62
+
63
+ module.exports = {
64
+ DEPLOY_STEPS,
65
+ createDeployProgress
66
+ };
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
  };
@@ -33,13 +33,15 @@ const runNpmInstall = (cwd, timeoutMs) =>
33
33
  });
34
34
  });
35
35
 
36
- const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath }) => {
36
+ /** install=false 时只解压校验,不装依赖(用于不常驻磁盘的版本上传) */
37
+ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath, install = true, requireEnvSchema = true, onProgress }) => {
37
38
  await fs.emptyDir(artifactPath);
39
+ onProgress && onProgress('extract');
38
40
  const extractDir = path.join(artifactPath, '_extract');
39
41
  await extractZipSafe(zipBuffer, extractDir, { maxZipSize, maxZipEntries });
40
42
  const packageRoot = await resolvePackageRoot(extractDir);
41
43
 
42
- const validation = await validatePackageRoot(packageRoot);
44
+ const validation = await validatePackageRoot(packageRoot, { requireEnvSchema });
43
45
  if (!validation.ok) {
44
46
  await fs.remove(artifactPath);
45
47
  throw new Error(`invalid package: ${validation.errors.join('; ')}`);
@@ -60,14 +62,18 @@ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, max
60
62
  });
61
63
 
62
64
  const serverDir = path.join(artifactPath, 'server');
63
- await runNpmInstall(serverDir, npmInstallTimeoutMs);
65
+ if (install) {
66
+ onProgress && onProgress('install');
67
+ await runNpmInstall(serverDir, npmInstallTimeoutMs);
68
+ }
64
69
 
65
70
  const sqlDir = path.join(serverDir, sqlPath || 'sql');
66
71
  const sqlFiles = await listSqlFiles(sqlDir);
67
72
  return {
68
73
  hasMigration: sqlFiles.length > 0,
69
74
  migrationPath: sqlPath || 'sql',
70
- sqlFiles
75
+ sqlFiles,
76
+ envSchema: validation.envSchema
71
77
  };
72
78
  };
73
79
 
@@ -0,0 +1,33 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const { listSqlFiles } = require('./validatePackage');
4
+
5
+ /** 读取目录内的 .sql 为 [{ name, content, updatedAt }] */
6
+ const readSqlDir = async sqlDir => {
7
+ const files = [];
8
+ for (const name of await listSqlFiles(sqlDir)) {
9
+ const filePath = path.join(sqlDir, name);
10
+ const [content, stat] = await Promise.all([fs.readFile(filePath, 'utf8'), fs.stat(filePath)]);
11
+ files.push({ name, content, updatedAt: stat.mtime.toISOString() });
12
+ }
13
+ return files;
14
+ };
15
+
16
+ /** 以 files 为准覆盖目录内的 .sql:删掉多余的,写入全部 */
17
+ const writeSqlDir = async (sqlDir, files) => {
18
+ await fs.ensureDir(sqlDir);
19
+ const keep = new Set(files.map(item => item.name));
20
+ for (const name of await listSqlFiles(sqlDir)) {
21
+ if (!keep.has(name)) {
22
+ await fs.remove(path.join(sqlDir, name));
23
+ }
24
+ }
25
+ for (const item of files) {
26
+ await fs.writeFile(path.join(sqlDir, item.name), item.content || '', 'utf8');
27
+ }
28
+ };
29
+
30
+ module.exports = {
31
+ readSqlDir,
32
+ writeSqlDir
33
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-app-manager",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -69,6 +69,7 @@
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",
73
74
  "jsdom": "^29.1.1",
74
75
  "lodash": "^4.18.1",