openlearn-next 0.3.8 → 0.3.10

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.
Files changed (34) hide show
  1. package/README.md +9 -5
  2. package/cli-cleaner.mjs +72 -27
  3. package/cli-data.mjs +10 -0
  4. package/cli-doctor.mjs +471 -12
  5. package/cli.mjs +282 -159
  6. package/dist/assets/{BatchPickerModal-yr3TOeuu.js → BatchPickerModal-BhIFzqMZ.js} +1 -1
  7. package/dist/assets/{ClassesView-CJBu14nu.js → ClassesView-BWzkFR-C.js} +1 -1
  8. package/dist/assets/{CloudDriveModal-BYPpuwx4.js → CloudDriveModal-Dy_toaqC.js} +1 -1
  9. package/dist/assets/{CourseManagement-D7HitLiU.js → CourseManagement-DTIt-D1_.js} +1 -1
  10. package/dist/assets/{CourseWizardModal-CyMoK1Os.js → CourseWizardModal-DEx5wbVt.js} +1 -1
  11. package/dist/assets/{Dashboard-DjCWV3Qg.js → Dashboard-DrD2ZUG2.js} +1 -1
  12. package/dist/assets/{ExportWeightModal-B5Cj-pLX.js → ExportWeightModal-D3bGjA61.js} +1 -1
  13. package/dist/assets/{HelpView-CxTV4J6V.js → HelpView-DOQ_FtPQ.js} +1 -1
  14. package/dist/assets/{ImportLessonsModal-CtHd6jIE.js → ImportLessonsModal-R3T0dy5k.js} +1 -1
  15. package/dist/assets/{InteractiveCoursewareViewer-CiOJP-Y5.js → InteractiveCoursewareViewer-CL1yz-wN.js} +1 -1
  16. package/dist/assets/{InteractiveWhiteboard-DZy7dsGY.js → InteractiveWhiteboard-Cua0Ksff.js} +1 -1
  17. package/dist/assets/{LazyCourseware-BpWRWiC9.js → LazyCourseware-CjwoXy7i.js} +1 -1
  18. package/dist/assets/{LazyWhiteboard-GuLyGVWI.js → LazyWhiteboard-C1kc33rY.js} +1 -1
  19. package/dist/assets/{LessonEditorView-BPtkbV8_.js → LessonEditorView-K0YB64km.js} +1 -1
  20. package/dist/assets/{LiveClassroomView-Cx5jD5H8.js → LiveClassroomView-D4IQGl2d.js} +1 -1
  21. package/dist/assets/{NotificationDetailModal-DTwOttoS.js → NotificationDetailModal-C7n-UNti.js} +1 -1
  22. package/dist/assets/{PluginView-BB7sIiJf.js → PluginView-4aUL9Ma3.js} +4 -4
  23. package/dist/assets/{QuizGeneratorModal-YoT3qVTl.js → QuizGeneratorModal-Cwv80A2_.js} +1 -1
  24. package/dist/assets/{StudentAssignmentView-QIQtCByD.js → StudentAssignmentView-DTFVSpw7.js} +1 -1
  25. package/dist/assets/{StudentLessonView-ChP3ZGTo.js → StudentLessonView-CETCftTC.js} +1 -1
  26. package/dist/assets/{StudentPreviewModal-BIfgr0MU.js → StudentPreviewModal-CIQx5-dG.js} +1 -1
  27. package/dist/assets/{StudentView-Clqr7kxh.js → StudentView-DtWzM17Q.js} +1 -1
  28. package/dist/assets/{SystemResourceLibraryModal-Cy012GB8.js → SystemResourceLibraryModal-Bv3wwXDH.js} +1 -1
  29. package/dist/assets/{TeacherView-D-7hKKGD.js → TeacherView-BEXx-Vwl.js} +1 -1
  30. package/dist/assets/{index-BqJrKVHF.js → index-PM-cSFwO.js} +1 -1
  31. package/dist/assets/{index-yp4O4BPB.js → index-kqgYec9C.js} +3 -3
  32. package/dist/index.html +1 -1
  33. package/dist/server.cjs +47 -7
  34. package/package.json +2 -2
package/cli-doctor.mjs CHANGED
@@ -2,6 +2,430 @@ import os from 'node:os';
2
2
  import net from 'node:net';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createRequire } from 'node:module';
7
+
8
+ import { scanNpxCacheEntries, runClean } from './cli-cleaner.mjs';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ /** 版本号比较(不含 semver 依赖,支持带前缀 v/^/~ 处理,prerelease 后缀截断后按数字逐段比较)。 */
13
+ export function compareVersions(a, b) {
14
+ const parse = (v) =>
15
+ String(v)
16
+ .replace(/^[v^~]/, '')
17
+ .split('-')[0]
18
+ .split('.')
19
+ .map((n) => parseInt(n, 10) || 0);
20
+ const pa = parse(a);
21
+ const pb = parse(b);
22
+ for (let i = 0; i < 3; i++) {
23
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0) ? 1 : -1;
24
+ }
25
+ return 0;
26
+ }
27
+
28
+ /**
29
+ * 校验版本号是否满足指定的版本范围(无外部依赖实现,支持 >=, >, <=, <, =, ^, ~, * 等)
30
+ * @param {string} ver 待校验的版本号(例如 '0.3.9')
31
+ * @param {string} range 版本范围约束(例如 '>=0.2.5', '^0.3.0', '>=0.2.0 <1.0.0', '*')
32
+ * @returns {boolean}
33
+ */
34
+ export function satisfiesVersionRange(ver, range) {
35
+ if (!range || range === '*' || range === 'latest' || range.trim() === '') return true;
36
+ // 规整比较符号后的多余空格: '>= 0.2.5' -> '>=0.2.5'
37
+ const normalized = range.replace(/([><=^~]+)\s+/g, '$1').trim();
38
+ const parts = normalized.split(/\s+/).filter(Boolean);
39
+
40
+ const parse = (v) =>
41
+ String(v)
42
+ .replace(/^[v]/, '')
43
+ .split('-')[0]
44
+ .split('.')
45
+ .map((n) => parseInt(n, 10) || 0);
46
+
47
+ for (const part of parts) {
48
+ if (part.startsWith('>=')) {
49
+ const target = part.slice(2);
50
+ if (compareVersions(ver, target) < 0) return false;
51
+ } else if (part.startsWith('>')) {
52
+ const target = part.slice(1);
53
+ if (compareVersions(ver, target) <= 0) return false;
54
+ } else if (part.startsWith('<=')) {
55
+ const target = part.slice(2);
56
+ if (compareVersions(ver, target) > 0) return false;
57
+ } else if (part.startsWith('<')) {
58
+ const target = part.slice(1);
59
+ if (compareVersions(ver, target) >= 0) return false;
60
+ } else if (part.startsWith('=')) {
61
+ const target = part.slice(1);
62
+ if (compareVersions(ver, target) !== 0) return false;
63
+ } else if (part.startsWith('^')) {
64
+ const target = part.slice(1);
65
+ if (compareVersions(ver, target) < 0) return false;
66
+ const pv = parse(ver);
67
+ const pt = parse(target);
68
+ if (pt[0] > 0) {
69
+ if (pv[0] !== pt[0]) return false;
70
+ } else if (pt[1] > 0) {
71
+ if (pv[0] !== 0 || pv[1] !== pt[1]) return false;
72
+ } else {
73
+ if (pv[0] !== 0 || pv[1] !== 0 || pv[2] !== pt[2]) return false;
74
+ }
75
+ } else if (part.startsWith('~')) {
76
+ const target = part.slice(1);
77
+ if (compareVersions(ver, target) < 0) return false;
78
+ const pv = parse(ver);
79
+ const pt = parse(target);
80
+ if (pv[0] !== pt[0] || pv[1] !== pt[1]) return false;
81
+ } else {
82
+ if (compareVersions(ver, part) !== 0) return false;
83
+ }
84
+ }
85
+ return true;
86
+ }
87
+
88
+ /**
89
+ * 检查 @openlearn/plugin-sdk 与 @openlearn/plugin-test-kit 的声明版本与实际解析版本(防依赖版本漂移)
90
+ * 兼容 npm/pnpm/npx 依赖提升结构与 workspace 链接模式。
91
+ */
92
+ export function checkSdkConsistency(_fix = false, _fixes = []) {
93
+ const SDK = '@openlearn/plugin-sdk';
94
+ const check = (status, message, fixable = false) => ({ name: 'SDK Version', status, message, fixable });
95
+ try {
96
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'));
97
+ const spec = pkg.dependencies?.[SDK];
98
+ if (!spec) return check('warn', `${SDK} 未在 package.json dependencies 中声明`);
99
+
100
+ let pkgDir = null;
101
+ try {
102
+ const require = createRequire(import.meta.url);
103
+ const pkgJsonPath = require.resolve(`${SDK}/package.json`);
104
+ pkgDir = path.dirname(pkgJsonPath);
105
+ } catch {
106
+ try {
107
+ const require = createRequire(import.meta.url);
108
+ const entry = require.resolve(SDK);
109
+ let dir = path.dirname(entry);
110
+ while (dir !== path.dirname(dir)) {
111
+ if (fs.existsSync(path.join(dir, 'package.json'))) {
112
+ pkgDir = dir;
113
+ break;
114
+ }
115
+ dir = path.dirname(dir);
116
+ }
117
+ } catch {}
118
+ }
119
+
120
+ if (!pkgDir) {
121
+ const fallbackPath = path.join(__dirname, 'node_modules', ...SDK.split('/'));
122
+ if (fs.existsSync(fallbackPath)) {
123
+ pkgDir = fs.realpathSync(fallbackPath);
124
+ }
125
+ }
126
+
127
+ if (!pkgDir) {
128
+ return check('err', `无法解析 ${SDK},请执行 pnpm install 或重新安装包`);
129
+ }
130
+
131
+ const installed = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8')).version;
132
+ const isWorkspace = fs.existsSync(path.join(__dirname, 'pnpm-workspace.yaml')) && pkgDir.includes('packages/plugin-sdk');
133
+
134
+ // 尝试识别 @openlearn/plugin-test-kit 版本
135
+ let testKitInfo = '';
136
+ const testKitPkg = path.join(__dirname, 'packages', 'plugin-test-kit', 'package.json');
137
+ if (fs.existsSync(testKitPkg)) {
138
+ try {
139
+ const tkv = JSON.parse(fs.readFileSync(testKitPkg, 'utf-8')).version;
140
+ if (tkv) testKitInfo = `, test-kit v${tkv}`;
141
+ } catch {}
142
+ } else {
143
+ try {
144
+ const require = createRequire(import.meta.url);
145
+ const tkJson = require.resolve('@openlearn/plugin-test-kit/package.json');
146
+ const tkv = JSON.parse(fs.readFileSync(tkJson, 'utf-8')).version;
147
+ if (tkv) testKitInfo = `, test-kit v${tkv}`;
148
+ } catch {}
149
+ }
150
+
151
+ if (spec === 'workspace:*' || spec.startsWith('workspace:')) {
152
+ if (isWorkspace) return check('ok', `workspace 链接 → v${installed}${testKitInfo}`);
153
+ return check('err', `声明 ${spec} 但 node_modules 中是独立安装的 v${installed},请执行 pnpm install 修复`, true);
154
+ }
155
+ if (spec.startsWith('^')) {
156
+ const base = spec.slice(1);
157
+ const sameMajor = installed.split('.')[0] === base.split('.')[0];
158
+ if (sameMajor && compareVersions(installed, base) >= 0) {
159
+ return check('ok', `v${installed} 满足 ${spec}${isWorkspace ? ' (workspace 链接)' : ''}${testKitInfo}`);
160
+ }
161
+ return check('err', `解析到 v${installed},不满足声明范围 ${spec},存在版本漂移,请重新安装依赖`, true);
162
+ }
163
+ if (compareVersions(installed, spec) === 0) {
164
+ return check('ok', `v${installed} 与精确 pin ${spec} 一致${testKitInfo}`);
165
+ }
166
+ return check('err', `解析到 v${installed},与精确 pin ${spec} 不一致(版本漂移)— server.cjs 构建所用 SDK 与运行时不符,请 npm install 修复`, true);
167
+ } catch (e) {
168
+ return check('err', `无法解析 ${SDK}: ${e.message}`);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * 检查官方内置 7 个核心系统插件的版本与当前平台版本引擎兼容性(防插件互锁)
174
+ */
175
+ export function checkCorePluginsCompatibility() {
176
+ const check = (status, message, fixable = false) => ({ name: 'Core Plugins', status, message, fixable });
177
+ try {
178
+ const pkgPath = path.join(__dirname, 'package.json');
179
+ const curVer = fs.existsSync(pkgPath) ? JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version : '0.3.9';
180
+
181
+ const CORE_PLUGINS = [
182
+ { id: '@openlearn/plugin-builtin', name: 'Classroom Builtin Plugin', file: 'builtin.ts', defaultEngine: '>=0.2.5' },
183
+ { id: '@openlearn/plugin-vfs', name: 'Virtual File System Plugin', file: 'vfs.ts', defaultEngine: '>=0.2.5' },
184
+ { id: '@openlearn/plugin-process', name: 'Background Process Plugin', file: 'process.ts', defaultEngine: '>=0.2.5' },
185
+ { id: '@openlearn/plugin-management', name: 'LMS Management Plugin', file: 'management.ts', defaultEngine: '>=0.2.5' },
186
+ { id: '@openlearn/plugin-ai-planner', name: 'AI Planner Plugin', file: 'ai-planner.ts', defaultEngine: '>=0.2.5' },
187
+ { id: '@openlearn/plugin-ai-submit-injector', name: 'AI Submit Injector Plugin', file: 'ai-submit-injector.ts', defaultEngine: '>=0.2.5' },
188
+ { id: '@openlearn/plugin-assignment-eval', name: 'Assignment Evaluation Plugin', file: 'assignment-eval.ts', defaultEngine: '>=0.2.5' },
189
+ ];
190
+
191
+ const incompatible = [];
192
+ const scanned = [];
193
+ const pluginsDir = path.join(__dirname, 'packages', 'plugins');
194
+ const hasSource = fs.existsSync(pluginsDir);
195
+
196
+ for (const cp of CORE_PLUGINS) {
197
+ let engine = cp.defaultEngine;
198
+ let version = '1.0.0';
199
+
200
+ if (hasSource) {
201
+ const filePath = path.join(pluginsDir, cp.file);
202
+ if (fs.existsSync(filePath)) {
203
+ const content = fs.readFileSync(filePath, 'utf-8');
204
+ const engineMatch = content.match(/openlearn\s*:\s*['"]([^'"]+)['"]/);
205
+ if (engineMatch) engine = engineMatch[1];
206
+ const verMatch = content.match(/version\s*:\s*['"]([^'"]+)['"]/);
207
+ if (verMatch) version = verMatch[1];
208
+ }
209
+ }
210
+
211
+ scanned.push({ id: cp.id, version, engine });
212
+ if (!satisfiesVersionRange(curVer, engine)) {
213
+ incompatible.push(`${cp.id} (v${version}) 引擎约束 "${engine}" 无法被平台当前版本 v${curVer} 满足`);
214
+ }
215
+ }
216
+
217
+ if (incompatible.length > 0) {
218
+ return check('err', `发现 ${incompatible.length} 个核心插件与当前平台版本冲突: ${incompatible.join('; ')}`);
219
+ }
220
+
221
+ const firstEngine = scanned[0]?.engine || '>=0.2.5';
222
+ return check('ok', `全部 7 个核心内置插件满足平台引擎约束 (engines.openlearn ${firstEngine})`);
223
+ } catch (e) {
224
+ return check('warn', `核心插件版本检测异常: ${e.message}`);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * 检查数据库中已安装的扩展插件与本地插件清单的版本与引擎兼容性
230
+ * @param {Object} [options]
231
+ * @param {string} [options.dbPath]
232
+ * @param {boolean} [options.fix]
233
+ * @param {string[]} [options.fixes]
234
+ * @param {boolean} [options.scanLocalPlugins]
235
+ * @param {string} [options.pluginsDir]
236
+ */
237
+ export async function checkInstalledPluginsCompatibility(options = {}) {
238
+ const check = (status, message, fixable = false) => ({ name: 'Installed Plugins', status, message, fixable });
239
+ try {
240
+ const pkgPath = path.join(__dirname, 'package.json');
241
+ const curVer = fs.existsSync(pkgPath) ? JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version : '0.3.9';
242
+
243
+ const dbPath = options.dbPath
244
+ ? path.resolve(options.dbPath)
245
+ : (process.env.OPENLEARN_DB_PATH
246
+ ? path.resolve(process.env.OPENLEARN_DB_PATH)
247
+ : path.join(os.homedir(), 'openlearn-next', 'data.db'));
248
+
249
+ const plugins = [];
250
+ const incompatible = [];
251
+ const invalid = [];
252
+
253
+ // 1. 从 SQLite 数据库读取已安装的非系统扩展插件
254
+ if (fs.existsSync(dbPath)) {
255
+ try {
256
+ const Database = (await import('better-sqlite3')).default;
257
+ const db = new Database(dbPath, { readonly: true });
258
+ const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugins'").get();
259
+ if (tableCheck) {
260
+ const rows = db.prepare("SELECT id, name, manifest, status FROM plugins WHERE id NOT LIKE '@openlearn/plugin-%'").all();
261
+ for (const row of rows) {
262
+ let manifest = null;
263
+ if (row.manifest) {
264
+ try {
265
+ manifest = JSON.parse(row.manifest);
266
+ } catch {
267
+ invalid.push(`${row.id} (manifest 损坏)`);
268
+ continue;
269
+ }
270
+ }
271
+ plugins.push({
272
+ id: row.id,
273
+ name: row.name || manifest?.name || row.id,
274
+ version: manifest?.version || '1.0.0',
275
+ engines: manifest?.engines,
276
+ source: 'db',
277
+ });
278
+ }
279
+ }
280
+ db.close();
281
+ } catch {
282
+ // 读取失败降级
283
+ }
284
+ }
285
+
286
+ // 2. 扫描本地 plugins/ 目录中的扩展插件 (支持 options.scanLocalPlugins 控制)
287
+ const scanLocal = options.scanLocalPlugins !== false;
288
+ const localPluginsDir = options.pluginsDir || path.join(__dirname, 'plugins');
289
+ if (scanLocal && fs.existsSync(localPluginsDir)) {
290
+ try {
291
+ const entries = fs.readdirSync(localPluginsDir, { withFileTypes: true });
292
+ for (const ent of entries) {
293
+ if (ent.isDirectory()) {
294
+ const mfPath = path.join(localPluginsDir, ent.name, 'manifest.json');
295
+ if (fs.existsSync(mfPath)) {
296
+ try {
297
+ const mf = JSON.parse(fs.readFileSync(mfPath, 'utf-8'));
298
+ if (mf.id && !plugins.some((p) => p.id === mf.id)) {
299
+ plugins.push({
300
+ id: mf.id,
301
+ name: mf.name || mf.id,
302
+ version: mf.version || '1.0.0',
303
+ engines: mf.engines,
304
+ source: 'filesystem',
305
+ });
306
+ }
307
+ } catch {
308
+ invalid.push(`${ent.name} (本地 manifest 损坏)`);
309
+ }
310
+ }
311
+ }
312
+ }
313
+ } catch {}
314
+ }
315
+
316
+ if (invalid.length > 0) {
317
+ return check('warn', `检测到 ${invalid.length} 个损坏的插件清单: ${invalid.join(', ')}`);
318
+ }
319
+
320
+ if (plugins.length === 0) {
321
+ return check('ok', '0 个外部扩展插件 (纯净核心运行环境)');
322
+ }
323
+
324
+ // 3. 校验每个扩展插件的 SemVer 格式与平台兼容性
325
+ for (const p of plugins) {
326
+ // 检查版本号格式
327
+ if (!/^\d+\.\d+\.\d+/.test(p.version)) {
328
+ invalid.push(`${p.id} 版本号 "${p.version}" 不符合语义化版本格式`);
329
+ }
330
+
331
+ // 检查 engines.openlearn
332
+ const openlearnReq = p.engines?.openlearn;
333
+ if (openlearnReq) {
334
+ if (!satisfiesVersionRange(curVer, openlearnReq)) {
335
+ incompatible.push(`${p.id} (v${p.version}) 要求平台 ${openlearnReq},当前为 v${curVer}`);
336
+ }
337
+ }
338
+ }
339
+
340
+ if (incompatible.length > 0) {
341
+ return check('warn', `发现 ${incompatible.length} 个插件与当前平台版本不兼容: ${incompatible.join('; ')}`);
342
+ }
343
+
344
+ const sample = plugins.slice(0, 2).map((p) => `${p.id} v${p.version}`).join(', ');
345
+ const more = plugins.length > 2 ? ` 等 ${plugins.length} 个` : '';
346
+ return check('ok', `${plugins.length} 个扩展插件 (${sample}${more}) 全部与平台 v${curVer} 兼容`);
347
+ } catch (e) {
348
+ return check('warn', `扩展插件版本检测异常: ${e.message}`);
349
+ }
350
+ }
351
+
352
+ /**
353
+ * 检查平台内核核心版本是否漂移(package.json 与 packages/core/version.ts)
354
+ */
355
+ export function checkCoreVersionDrift(fix = false, fixes = []) {
356
+ const check = (status, message, fixable = false) => ({ name: 'Core Version Drift', status, message, fixable });
357
+ try {
358
+ const pkgPath = path.join(__dirname, 'package.json');
359
+ if (!fs.existsSync(pkgPath)) return check('ok', '独立发行包,已对齐构建单体');
360
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
361
+ const pkgVer = pkg.version;
362
+
363
+ const coreVersionFile = path.join(__dirname, 'packages', 'core', 'version.ts');
364
+ if (!fs.existsSync(coreVersionFile)) {
365
+ return check('ok', `v${pkgVer} (独立发行包,已对齐统一构建版本)`);
366
+ }
367
+
368
+ const coreContent = fs.readFileSync(coreVersionFile, 'utf-8');
369
+ const match = coreContent.match(/PLATFORM_VERSION\s*=\s*['"]([^'"]+)['"]/);
370
+ if (!match) {
371
+ return check('warn', `未在 packages/core/version.ts 中解析到 PLATFORM_VERSION`);
372
+ }
373
+
374
+ const coreVer = match[1];
375
+ if (coreVer === pkgVer) {
376
+ return check('ok', `v${pkgVer} (package.json 与 packages/core/version.ts 严格一致)`);
377
+ }
378
+
379
+ if (fix) {
380
+ const updated = coreContent.replace(
381
+ /PLATFORM_VERSION\s*=\s*['"][^'"]+['"]/,
382
+ `PLATFORM_VERSION = '${pkgVer}'`
383
+ );
384
+ fs.writeFileSync(coreVersionFile, updated, 'utf-8');
385
+ fixes.push(`已自动将 packages/core/version.ts 同步为 v${pkgVer}`);
386
+ return check('ok', `v${pkgVer} (已自动修复版本漂移: 从 v${coreVer} 同步为 v${pkgVer})`);
387
+ }
388
+
389
+ return check('err', `检测到内核版本漂移: package.json (v${pkgVer}) 与 packages/core/version.ts (v${coreVer}) 不一致!`, true);
390
+ } catch (e) {
391
+ return check('err', `版本一致性检查失败: ${e.message}`);
392
+ }
393
+ }
394
+
395
+ /**
396
+ * 检查 NPX 缓存中是否存在旧版 openlearn-next 历史包(防执行版本漂移)
397
+ */
398
+ export function checkNpxCacheDrift(fix = false, fixes = []) {
399
+ const check = (status, message, fixable = false) => ({ name: 'NPX Cache Drift', status, message, fixable });
400
+ try {
401
+ const pkgPath = path.join(__dirname, 'package.json');
402
+ const curVer = fs.existsSync(pkgPath) ? JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version : null;
403
+ const entries = scanNpxCacheEntries();
404
+ if (entries.length === 0) {
405
+ return check('ok', `未发现残留的 NPX 旧版历史包`);
406
+ }
407
+
408
+ const stale = curVer ? entries.filter((e) => e.version !== curVer) : entries;
409
+ if (stale.length === 0) {
410
+ return check('ok', `发现 ${entries.length} 处 NPX 缓存,均为当前版本 v${curVer},无漂移`);
411
+ }
412
+
413
+ const staleVersions = [...new Set(stale.map((s) => s.version))];
414
+ if (fix) {
415
+ const cleanRes = runClean({ npx: true, silent: true });
416
+ fixes.push(`已自动清理 ${cleanRes.npxCleaned} 个 NPX 历史旧版本缓存 (${staleVersions.join(', ')})`);
417
+ return check('ok', `已自动清理 ${cleanRes.npxCleaned} 个旧版缓存 (${staleVersions.join(', ')}),消除了版本漂移`);
418
+ }
419
+
420
+ return check(
421
+ 'warn',
422
+ `发现 ${stale.length} 个旧版 NPX 缓存 (${staleVersions.join(', ')}),npx 执行可能因缓存发生版本漂移`,
423
+ true
424
+ );
425
+ } catch (e) {
426
+ return check('warn', `NPX 缓存扫描失败: ${e.message}`);
427
+ }
428
+ }
5
429
 
6
430
  /**
7
431
  * 诊断指定端口是否可用
@@ -12,12 +436,8 @@ import path from 'node:path';
12
436
  export function checkPortAvailable(port, host = '0.0.0.0') {
13
437
  return new Promise((resolve) => {
14
438
  const server = net.createServer();
15
- server.once('error', (err) => {
16
- if (err.code === 'EADDRINUSE') {
17
- resolve(false);
18
- } else {
19
- resolve(false);
20
- }
439
+ server.once('error', () => {
440
+ resolve(false);
21
441
  });
22
442
  server.once('listening', () => {
23
443
  server.close(() => resolve(true));
@@ -44,16 +464,19 @@ export function getNetworkIps() {
44
464
  }
45
465
 
46
466
  /**
47
- * 运行系统环境自检与健康诊断
467
+ * 运行系统环境自检、版本防漂移诊断与自动自愈
48
468
  * @param {Object} [options]
49
469
  * @param {number} [options.port]
50
470
  * @param {string} [options.dbPath]
471
+ * @param {boolean} [options.fix] 是否开启自动修复模式
51
472
  * @param {boolean} [options.silent]
52
- * @returns {Promise<{ ok: boolean, checks: Array<{ name: string, status: 'ok'|'warn'|'err', message: string }> }>}
473
+ * @returns {Promise<{ ok: boolean, checks: Array<{ name: string, status: 'ok'|'warn'|'err', message: string, fixable?: boolean }>, fixes: string[] }>}
53
474
  */
54
475
  export async function runDoctor(options = {}) {
55
476
  const checks = [];
477
+ const fixes = [];
56
478
  const silent = Boolean(options.silent);
479
+ const fix = Boolean(options.fix);
57
480
 
58
481
  // 1. Node.js 版本检查 (>= 20.0.0)
59
482
  const nodeVersion = process.versions.node;
@@ -105,7 +528,12 @@ export async function runDoctor(options = {}) {
105
528
  const dbDir = path.dirname(dbPath);
106
529
 
107
530
  try {
108
- fs.mkdirSync(dbDir, { recursive: true });
531
+ if (!fs.existsSync(dbDir) && fix) {
532
+ fs.mkdirSync(dbDir, { recursive: true });
533
+ fixes.push(`已自动创建数据库存储目录: ${dbDir}`);
534
+ } else {
535
+ fs.mkdirSync(dbDir, { recursive: true });
536
+ }
109
537
  // 测试临时写入
110
538
  const testFile = path.join(dbDir, `.doctor_write_test_${Date.now()}`);
111
539
  fs.writeFileSync(testFile, 'test');
@@ -120,6 +548,7 @@ export async function runDoctor(options = {}) {
120
548
  name: 'Database Storage',
121
549
  status: 'err',
122
550
  message: `目录 ${dbDir} 写入失败: ${err.message}`,
551
+ fixable: true,
123
552
  });
124
553
  }
125
554
 
@@ -156,6 +585,22 @@ export async function runDoctor(options = {}) {
156
585
  });
157
586
  }
158
587
 
588
+ // 6. 版本防漂移与插件生态综合自检
589
+ // 6.1 SDK 依赖版本一致性(防版本漂移)
590
+ checks.push(checkSdkConsistency(fix, fixes));
591
+
592
+ // 6.2 平台内核核心版本漂移检查与修正
593
+ checks.push(checkCoreVersionDrift(fix, fixes));
594
+
595
+ // 6.3 核心内置插件版本与平台引擎兼容性
596
+ checks.push(checkCorePluginsCompatibility());
597
+
598
+ // 6.4 已安装与本地扩展插件版本与引擎兼容性
599
+ checks.push(await checkInstalledPluginsCompatibility({ dbPath, fix, fixes }));
600
+
601
+ // 6.5 NPX 缓存旧包版本漂移检查与修正
602
+ checks.push(checkNpxCacheDrift(fix, fixes));
603
+
159
604
  const allOk = checks.every((c) => c.status !== 'err');
160
605
 
161
606
  if (!silent) {
@@ -174,16 +619,30 @@ export async function runDoctor(options = {}) {
174
619
  let icon = `${green}✓${reset}`;
175
620
  if (c.status === 'warn') icon = `${yellow}⚠${reset}`;
176
621
  if (c.status === 'err') icon = `${red}✗${reset}`;
177
- console.log(` ${icon} ${bold}${c.name.padEnd(18)}${reset} : ${c.message}`);
622
+ console.log(` ${icon} ${bold}${c.name.padEnd(20)}${reset} : ${c.message}`);
623
+ }
624
+
625
+ if (fixes.length > 0) {
626
+ console.log(`\n${cyan}───────────────────────────────────────────────────────────────${reset}`);
627
+ console.log(` ${bold}${green}🔧 已自动执行修复 (Auto-Fix):${reset}`);
628
+ for (const f of fixes) {
629
+ console.log(` ${green}✓${reset} ${f}`);
630
+ }
631
+ }
632
+
633
+ const hasFixableIssues = checks.some((c) => c.status !== 'ok' && c.fixable);
634
+ if (!fix && hasFixableIssues) {
635
+ console.log(`\n ${yellow}💡 提示: 检测到可通过自愈修复的问题,运行 'npx openlearn-next doctor --fix' 即可一键自动修复。${reset}`);
178
636
  }
179
637
 
180
638
  console.log(`\n${cyan}───────────────────────────────────────────────────────────────${reset}`);
181
639
  if (allOk) {
182
- console.log(` ${bold}${green}诊断通过!核心运行环境健康,已具备课堂部署就绪状态。${reset}\n`);
640
+ console.log(` ${bold}${green}诊断通过!核心运行环境健康,已消除版本漂移风险。${reset}\n`);
183
641
  } else {
184
642
  console.log(` ${bold}${red}诊断发现阻断性问题,请根据上述提示处理后重试。${reset}\n`);
185
643
  }
186
644
  }
187
645
 
188
- return { ok: allOk, checks };
646
+ return { ok: allOk, checks, fixes };
189
647
  }
648
+