openlearn-next 0.3.6 → 0.3.8
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/cli-cleaner.mjs +131 -0
- package/cli-data.mjs +252 -0
- package/cli-doctor.mjs +189 -0
- package/cli.mjs +186 -16
- package/dist/assets/{BatchPickerModal-C98sjlMF.js → BatchPickerModal-yr3TOeuu.js} +1 -1
- package/dist/assets/{ClassesView-BPR46qdP.js → ClassesView-CJBu14nu.js} +1 -1
- package/dist/assets/{CloudDriveModal-muU449iB.js → CloudDriveModal-BYPpuwx4.js} +1 -1
- package/dist/assets/{CourseManagement-BRucWTbo.js → CourseManagement-D7HitLiU.js} +1 -1
- package/dist/assets/{CourseWizardModal-DTSK7VGp.js → CourseWizardModal-CyMoK1Os.js} +1 -1
- package/dist/assets/{Dashboard-COtcZSPW.js → Dashboard-DjCWV3Qg.js} +1 -1
- package/dist/assets/{ExportWeightModal-Dd7qjZuJ.js → ExportWeightModal-B5Cj-pLX.js} +1 -1
- package/dist/assets/{HelpView-CCmQYk87.js → HelpView-CxTV4J6V.js} +1 -1
- package/dist/assets/{ImportLessonsModal-cNlJ5wwh.js → ImportLessonsModal-CtHd6jIE.js} +1 -1
- package/dist/assets/{InteractiveCoursewareViewer-Cok6OVNk.js → InteractiveCoursewareViewer-CiOJP-Y5.js} +1 -1
- package/dist/assets/{InteractiveWhiteboard-6y5vg8O_.js → InteractiveWhiteboard-DZy7dsGY.js} +1 -1
- package/dist/assets/{LazyCourseware-wkNa0dIq.js → LazyCourseware-BpWRWiC9.js} +1 -1
- package/dist/assets/{LazyWhiteboard-Bjh_fdbw.js → LazyWhiteboard-GuLyGVWI.js} +1 -1
- package/dist/assets/{LessonEditorView-B_b0F9Cw.js → LessonEditorView-BPtkbV8_.js} +1 -1
- package/dist/assets/{LiveClassroomView-vm0rs4p7.js → LiveClassroomView-Cx5jD5H8.js} +1 -1
- package/dist/assets/{NotificationDetailModal-B3_95dku.js → NotificationDetailModal-DTwOttoS.js} +1 -1
- package/dist/assets/{PluginView-CZhQ0Ciy.js → PluginView-BB7sIiJf.js} +1 -1
- package/dist/assets/{QuizGeneratorModal-__riUCfV.js → QuizGeneratorModal-YoT3qVTl.js} +1 -1
- package/dist/assets/{StudentAssignmentView-BAL4_PJC.js → StudentAssignmentView-QIQtCByD.js} +1 -1
- package/dist/assets/{StudentLessonView-fXKI0uN9.js → StudentLessonView-ChP3ZGTo.js} +1 -1
- package/dist/assets/{StudentPreviewModal-DHgqiEHm.js → StudentPreviewModal-BIfgr0MU.js} +1 -1
- package/dist/assets/{StudentView-e7zUBtTA.js → StudentView-Clqr7kxh.js} +1 -1
- package/dist/assets/{SystemResourceLibraryModal-0MIIi5fd.js → SystemResourceLibraryModal-Cy012GB8.js} +1 -1
- package/dist/assets/{TeacherView-HxGS1psV.js → TeacherView-D-7hKKGD.js} +1 -1
- package/dist/assets/{index-CWqYoxFP.js → index-BqJrKVHF.js} +1 -1
- package/dist/assets/{index-CCKb8B5h.js → index-yp4O4BPB.js} +3 -3
- package/dist/index.html +1 -1
- package/dist/server.cjs +53 -5
- package/package.json +4 -1
package/cli-cleaner.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync, unlinkSync, statSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, dirname } from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 安全清理 openlearn-next 在本地的各类运行与包缓存
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {boolean} [options.all] 是否全量清理(NPX 缓存 + 运行缓存 + 重置数据库)
|
|
10
|
+
* @param {boolean} [options.npx] 是否仅清理 NPX 缓存
|
|
11
|
+
* @param {boolean} [options.db] 是否仅清理/重置本地数据库
|
|
12
|
+
* @param {string} [options.customDataDir] 自定义数据目录(可选,主要供测试用)
|
|
13
|
+
* @param {string} [options.customNpxDir] 自定义 NPX 缓存目录(可选,主要供测试用)
|
|
14
|
+
* @param {boolean} [options.silent] 是否静默输出
|
|
15
|
+
* @returns {{ npxCleaned: number, dbReset: boolean, tempCleaned: string[] }}
|
|
16
|
+
*/
|
|
17
|
+
export function runClean(options = {}) {
|
|
18
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
19
|
+
const results = {
|
|
20
|
+
npxCleaned: 0,
|
|
21
|
+
dbReset: false,
|
|
22
|
+
tempCleaned: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isAll = Boolean(options.all);
|
|
26
|
+
const isOnlyNpx = Boolean(options.npx) && !isAll;
|
|
27
|
+
const isOnlyDb = Boolean(options.db) && !isAll;
|
|
28
|
+
const isDefault = !isOnlyNpx && !isOnlyDb && !isAll;
|
|
29
|
+
|
|
30
|
+
// ── 1. 清理 NPX 历史包缓存 ─────────────────────────────────────────────
|
|
31
|
+
if (isAll || isOnlyNpx || isDefault) {
|
|
32
|
+
const npxDir = options.customNpxDir ? resolve(options.customNpxDir) : join(os.homedir(), '.npm', '_npx');
|
|
33
|
+
if (existsSync(npxDir)) {
|
|
34
|
+
try {
|
|
35
|
+
const entries = readdirSync(npxDir);
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const entryPath = join(npxDir, entry);
|
|
38
|
+
try {
|
|
39
|
+
if (statSync(entryPath).isDirectory()) {
|
|
40
|
+
const targetPackage = join(entryPath, 'node_modules', 'openlearn-next');
|
|
41
|
+
if (existsSync(targetPackage)) {
|
|
42
|
+
let ver = 'unknown';
|
|
43
|
+
try {
|
|
44
|
+
const pkgData = JSON.parse(readFileSync(join(targetPackage, 'package.json'), 'utf-8'));
|
|
45
|
+
ver = pkgData.version || 'unknown';
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore JSON parse error
|
|
48
|
+
}
|
|
49
|
+
rmSync(entryPath, { recursive: true, force: true });
|
|
50
|
+
results.npxCleaned++;
|
|
51
|
+
log(`✓ 已清理 NPX 旧版缓存: ~/.npm/_npx/${entry} (openlearn-next@${ver})`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// 单个目录清理失败不阻断整体流程
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
log(`⚠ 读取 NPX 缓存目录失败: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (results.npxCleaned === 0 && !options.silent) {
|
|
63
|
+
log(`ℹ 未发现残留的 openlearn-next NPX 包缓存。`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── 2. 清理本地运行与数据库缓存 ─────────────────────────────────────────
|
|
68
|
+
if (isAll || isOnlyDb || isDefault) {
|
|
69
|
+
const dataDir = options.customDataDir
|
|
70
|
+
? resolve(options.customDataDir)
|
|
71
|
+
: (process.env.OPENLEARN_DB_PATH
|
|
72
|
+
? dirname(resolve(process.env.OPENLEARN_DB_PATH))
|
|
73
|
+
: join(os.homedir(), 'openlearn-next'));
|
|
74
|
+
|
|
75
|
+
const dbPath = process.env.OPENLEARN_DB_PATH
|
|
76
|
+
? resolve(process.env.OPENLEARN_DB_PATH)
|
|
77
|
+
: join(dataDir, 'data.db');
|
|
78
|
+
|
|
79
|
+
const walPath = `${dbPath}-wal`;
|
|
80
|
+
const shmPath = `${dbPath}-shm`;
|
|
81
|
+
|
|
82
|
+
if (existsSync(dataDir)) {
|
|
83
|
+
// (a) 数据库重置模式
|
|
84
|
+
if (isAll || isOnlyDb) {
|
|
85
|
+
if (existsSync(dbPath)) {
|
|
86
|
+
unlinkSync(dbPath);
|
|
87
|
+
results.dbReset = true;
|
|
88
|
+
log(`✓ 已重置本地数据库: ${dbPath}`);
|
|
89
|
+
}
|
|
90
|
+
if (existsSync(walPath)) {
|
|
91
|
+
unlinkSync(walPath);
|
|
92
|
+
log(`✓ 已清理 SQLite 预写日志: ${walPath}`);
|
|
93
|
+
}
|
|
94
|
+
if (existsSync(shmPath)) {
|
|
95
|
+
unlinkSync(shmPath);
|
|
96
|
+
log(`✓ 已清理 SQLite 共享内存: ${shmPath}`);
|
|
97
|
+
}
|
|
98
|
+
log(`ℹ 数据库已重置,下次启动将全新自动初始化。`);
|
|
99
|
+
} else {
|
|
100
|
+
// (b) 默认安全清理:清理 WAL 与临时运行日志,保留主库数据
|
|
101
|
+
if (existsSync(walPath)) {
|
|
102
|
+
unlinkSync(walPath);
|
|
103
|
+
results.tempCleaned.push(walPath);
|
|
104
|
+
log(`✓ 已清理 SQLite 临时预写日志: ${walPath}`);
|
|
105
|
+
}
|
|
106
|
+
if (existsSync(shmPath)) {
|
|
107
|
+
unlinkSync(shmPath);
|
|
108
|
+
results.tempCleaned.push(shmPath);
|
|
109
|
+
log(`✓ 已清理 SQLite 临时共享内存: ${shmPath}`);
|
|
110
|
+
}
|
|
111
|
+
if (!options.silent) {
|
|
112
|
+
log(`ℹ 本地数据库核心数据已保留 (${dbPath})。如需完全重置数据库,请添加 --db 或 --all 参数。`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// (c) 清理临时子目录
|
|
117
|
+
const tempFolders = ['temp', 'scratch', 'cache'];
|
|
118
|
+
for (const folder of tempFolders) {
|
|
119
|
+
const folderPath = join(dataDir, folder);
|
|
120
|
+
if (existsSync(folderPath)) {
|
|
121
|
+
rmSync(folderPath, { recursive: true, force: true });
|
|
122
|
+
results.tempCleaned.push(folderPath);
|
|
123
|
+
log(`✓ 已清理临时数据目录: ${folderPath}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
log(`缓存清理完成。`);
|
|
130
|
+
return results;
|
|
131
|
+
}
|
package/cli-data.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 解析本地目标数据库路径
|
|
7
|
+
* @param {string} [customPath]
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function resolveDbPath(customPath) {
|
|
11
|
+
if (customPath) return path.resolve(customPath);
|
|
12
|
+
if (process.env.OPENLEARN_DB_PATH) return path.resolve(process.env.OPENLEARN_DB_PATH);
|
|
13
|
+
return path.join(os.homedir(), 'openlearn-next', 'data.db');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 一键数据冷备
|
|
18
|
+
* @param {string} [outputFile]
|
|
19
|
+
* @param {Object} [options]
|
|
20
|
+
* @returns {Promise<{ ok: boolean, targetFile?: string, error?: string }>}
|
|
21
|
+
*/
|
|
22
|
+
export async function runBackup(outputFile, options = {}) {
|
|
23
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
24
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(dbPath)) {
|
|
27
|
+
const msg = `数据库文件未找到: ${dbPath}`;
|
|
28
|
+
log(`✗ 备份失败: ${msg}`);
|
|
29
|
+
return { ok: false, error: msg };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const now = new Date();
|
|
33
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
34
|
+
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
35
|
+
const target = outputFile
|
|
36
|
+
? path.resolve(outputFile)
|
|
37
|
+
: path.resolve(process.cwd(), `openlearn_backup_${timestamp}.db`);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const Database = (await import('better-sqlite3')).default;
|
|
41
|
+
const db = new Database(dbPath, { readonly: true });
|
|
42
|
+
await db.backup(target);
|
|
43
|
+
db.close();
|
|
44
|
+
|
|
45
|
+
const stat = fs.statSync(target);
|
|
46
|
+
const sizeKb = Math.round(stat.size / 1024);
|
|
47
|
+
log(`✓ 数据库冷备完成!`);
|
|
48
|
+
log(` 快照文件: ${target} (${sizeKb} KB)`);
|
|
49
|
+
return { ok: true, targetFile: target };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// 降级为物理文件复制
|
|
52
|
+
try {
|
|
53
|
+
fs.copyFileSync(dbPath, target);
|
|
54
|
+
const stat = fs.statSync(target);
|
|
55
|
+
const sizeKb = Math.round(stat.size / 1024);
|
|
56
|
+
log(`✓ 数据库文件冷备完成(直接镜像模式):${target} (${sizeKb} KB)`);
|
|
57
|
+
return { ok: true, targetFile: target };
|
|
58
|
+
} catch (copyErr) {
|
|
59
|
+
log(`✗ 备份写入失败: ${copyErr.message}`);
|
|
60
|
+
return { ok: false, error: copyErr.message };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 数据安全回滚与还原
|
|
67
|
+
* @param {string} sourceFile
|
|
68
|
+
* @param {Object} [options]
|
|
69
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
70
|
+
*/
|
|
71
|
+
export async function runRestore(sourceFile, options = {}) {
|
|
72
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
73
|
+
if (!sourceFile) {
|
|
74
|
+
const msg = '未指定待还原的备份文件。用法: npx openlearn-next restore <backup.db>';
|
|
75
|
+
log(`✗ ${msg}`);
|
|
76
|
+
return { ok: false, error: msg };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const src = path.resolve(sourceFile);
|
|
80
|
+
if (!fs.existsSync(src)) {
|
|
81
|
+
const msg = `指定的备份文件不存在: ${src}`;
|
|
82
|
+
log(`✗ ${msg}`);
|
|
83
|
+
return { ok: false, error: msg };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 校验 SQLite 文件头 (Magic Header: "SQLite format 3\0")
|
|
87
|
+
try {
|
|
88
|
+
const fd = fs.openSync(src, 'r');
|
|
89
|
+
const buffer = Buffer.alloc(16);
|
|
90
|
+
fs.readSync(fd, buffer, 0, 16, 0);
|
|
91
|
+
fs.closeSync(fd);
|
|
92
|
+
const headerStr = buffer.toString('utf-8');
|
|
93
|
+
if (!headerStr.startsWith('SQLite format 3')) {
|
|
94
|
+
const msg = `文件并非有效的 SQLite 数据库备份: ${src}`;
|
|
95
|
+
log(`✗ ${msg}`);
|
|
96
|
+
return { ok: false, error: msg };
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log(`✗ 读取备份文件头失败: ${err.message}`);
|
|
100
|
+
return { ok: false, error: err.message };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
104
|
+
const dbDir = path.dirname(dbPath);
|
|
105
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
106
|
+
|
|
107
|
+
// 创建自动回滚副本
|
|
108
|
+
if (fs.existsSync(dbPath)) {
|
|
109
|
+
const bakFile = `${dbPath}.bak_${Date.now()}`;
|
|
110
|
+
fs.copyFileSync(dbPath, bakFile);
|
|
111
|
+
log(`ℹ 已为现有数据库创建安全回滚镜像: ${bakFile}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
fs.copyFileSync(src, dbPath);
|
|
116
|
+
// 清理旧的 WAL 与 SHM
|
|
117
|
+
const wal = `${dbPath}-wal`;
|
|
118
|
+
const shm = `${dbPath}-shm`;
|
|
119
|
+
if (fs.existsSync(wal)) fs.unlinkSync(wal);
|
|
120
|
+
if (fs.existsSync(shm)) fs.unlinkSync(shm);
|
|
121
|
+
|
|
122
|
+
log(`✓ 数据库还原成功!当前主库已切换为 ${src} 的数据。`);
|
|
123
|
+
return { ok: true };
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log(`✗ 覆盖写入数据库失败: ${err.message}`);
|
|
126
|
+
return { ok: false, error: err.message };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 命令行一键重置管理员 (admin) 密码
|
|
132
|
+
* @param {string} [newPassword='admin']
|
|
133
|
+
* @param {Object} [options]
|
|
134
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
135
|
+
*/
|
|
136
|
+
export async function runResetAdmin(newPassword = 'admin', options = {}) {
|
|
137
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
138
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
139
|
+
|
|
140
|
+
if (!fs.existsSync(dbPath)) {
|
|
141
|
+
const msg = `数据库尚未初始化 (${dbPath})。请直接启动一次平台即可生成默认 admin/admin 账号。`;
|
|
142
|
+
log(`ℹ ${msg}`);
|
|
143
|
+
return { ok: false, error: msg };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
let hash;
|
|
148
|
+
try {
|
|
149
|
+
const bcrypt = (await import('bcryptjs')).default;
|
|
150
|
+
hash = bcrypt.hashSync(newPassword, 10);
|
|
151
|
+
} catch {
|
|
152
|
+
const crypto = await import('node:crypto');
|
|
153
|
+
hash = crypto.createHash('sha256').update(newPassword).digest('hex');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const Database = (await import('better-sqlite3')).default;
|
|
157
|
+
const db = new Database(dbPath);
|
|
158
|
+
|
|
159
|
+
const updateRes = db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run(hash, 'admin');
|
|
160
|
+
|
|
161
|
+
if (updateRes.changes === 0) {
|
|
162
|
+
// 若数据库中无 admin 用户,则插入
|
|
163
|
+
db.prepare('INSERT INTO users (id, username, password_hash, role, name, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
164
|
+
.run(`usr_admin_${Date.now()}`, 'admin', hash, 'administrator', 'System Admin', Date.now());
|
|
165
|
+
log(`✓ 管理员账号不存在,已新建管理员账号: admin (密码: ${newPassword})`);
|
|
166
|
+
} else {
|
|
167
|
+
log(`✓ 管理员 (admin) 密码已成功重置为: ${newPassword}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
db.close();
|
|
171
|
+
return { ok: true };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log(`✗ 重置密码失败: ${err.message}`);
|
|
174
|
+
return { ok: false, error: err.message };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 命令行插件状态速查
|
|
180
|
+
* @param {Object} [options]
|
|
181
|
+
* @returns {Promise<{ ok: boolean, plugins?: Array<any>, error?: string }>}
|
|
182
|
+
*/
|
|
183
|
+
export async function runPluginsList(options = {}) {
|
|
184
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
185
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
186
|
+
|
|
187
|
+
if (!fs.existsSync(dbPath)) {
|
|
188
|
+
log(`数据库文件尚未创建 (${dbPath}),暂无插件安装记录。`);
|
|
189
|
+
return { ok: true, plugins: [] };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const Database = (await import('better-sqlite3')).default;
|
|
194
|
+
const db = new Database(dbPath, { readonly: true });
|
|
195
|
+
|
|
196
|
+
// 检查 plugins 表是否存在
|
|
197
|
+
const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugins'").get();
|
|
198
|
+
if (!tableCheck) {
|
|
199
|
+
log(`未检测到 plugins 数据表,平台尚未加载插件体系。`);
|
|
200
|
+
db.close();
|
|
201
|
+
return { ok: true, plugins: [] };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rows = db.prepare('SELECT * FROM plugins ORDER BY id ASC').all();
|
|
205
|
+
db.close();
|
|
206
|
+
|
|
207
|
+
const plugins = rows.map((p) => {
|
|
208
|
+
let version = '1.0.0';
|
|
209
|
+
if (p.manifest) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(p.manifest);
|
|
212
|
+
if (parsed.version) version = parsed.version;
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
id: p.id,
|
|
217
|
+
name: p.name,
|
|
218
|
+
version,
|
|
219
|
+
status: p.status,
|
|
220
|
+
loader_version: p.loader_version || p.execution_mode || 'inline',
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (!options.silent) {
|
|
225
|
+
const bold = '\x1b[1m';
|
|
226
|
+
const green = '\x1b[32m';
|
|
227
|
+
const yellow = '\x1b[33m';
|
|
228
|
+
const cyan = '\x1b[36m';
|
|
229
|
+
const reset = '\x1b[0m';
|
|
230
|
+
|
|
231
|
+
console.log(`\n${bold}${cyan}已安装插件清单 (${plugins.length} 个):${reset}`);
|
|
232
|
+
console.log(`┌──────────────────────────────────────────────┬─────────┬──────────┬──────────┐`);
|
|
233
|
+
console.log(`│ ${bold}Plugin ID${reset}${' '.repeat(37)}│ ${bold}Version${reset} │ ${bold}Status${reset} │ ${bold}Mode${reset} │`);
|
|
234
|
+
console.log(`├──────────────────────────────────────────────┼─────────┼──────────┼──────────┤`);
|
|
235
|
+
|
|
236
|
+
for (const p of plugins) {
|
|
237
|
+
const idCol = p.id.padEnd(44);
|
|
238
|
+
const verCol = (p.version || '1.0.0').padEnd(7);
|
|
239
|
+
const statusColor = p.status === 'active' ? green : yellow;
|
|
240
|
+
const statusCol = `${statusColor}${p.status.padEnd(8)}${reset}`;
|
|
241
|
+
const modeCol = (p.loader_version || 'inline').padEnd(8);
|
|
242
|
+
console.log(`│ ${idCol} │ ${verCol} │ ${statusCol} │ ${modeCol} │`);
|
|
243
|
+
}
|
|
244
|
+
console.log(`└──────────────────────────────────────────────┴─────────┴──────────┴──────────┘\n`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { ok: true, plugins };
|
|
248
|
+
} catch (err) {
|
|
249
|
+
log(`✗ 查询插件失败: ${err.message}`);
|
|
250
|
+
return { ok: false, error: err.message };
|
|
251
|
+
}
|
|
252
|
+
}
|
package/cli-doctor.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 诊断指定端口是否可用
|
|
8
|
+
* @param {number} port
|
|
9
|
+
* @param {string} host
|
|
10
|
+
* @returns {Promise<boolean>}
|
|
11
|
+
*/
|
|
12
|
+
export function checkPortAvailable(port, host = '0.0.0.0') {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const server = net.createServer();
|
|
15
|
+
server.once('error', (err) => {
|
|
16
|
+
if (err.code === 'EADDRINUSE') {
|
|
17
|
+
resolve(false);
|
|
18
|
+
} else {
|
|
19
|
+
resolve(false);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
server.once('listening', () => {
|
|
23
|
+
server.close(() => resolve(true));
|
|
24
|
+
});
|
|
25
|
+
server.listen(port, host);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 获取本机所有局域网可用 IPv4 地址
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
export function getNetworkIps() {
|
|
34
|
+
const ips = [];
|
|
35
|
+
const interfaces = os.networkInterfaces();
|
|
36
|
+
for (const name of Object.keys(interfaces)) {
|
|
37
|
+
for (const iface of interfaces[name] || []) {
|
|
38
|
+
if (iface.family === 'IPv4' && !iface.internal && !iface.address.startsWith('127.')) {
|
|
39
|
+
ips.push(iface.address);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return ips;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 运行系统环境自检与健康诊断
|
|
48
|
+
* @param {Object} [options]
|
|
49
|
+
* @param {number} [options.port]
|
|
50
|
+
* @param {string} [options.dbPath]
|
|
51
|
+
* @param {boolean} [options.silent]
|
|
52
|
+
* @returns {Promise<{ ok: boolean, checks: Array<{ name: string, status: 'ok'|'warn'|'err', message: string }> }>}
|
|
53
|
+
*/
|
|
54
|
+
export async function runDoctor(options = {}) {
|
|
55
|
+
const checks = [];
|
|
56
|
+
const silent = Boolean(options.silent);
|
|
57
|
+
|
|
58
|
+
// 1. Node.js 版本检查 (>= 20.0.0)
|
|
59
|
+
const nodeVersion = process.versions.node;
|
|
60
|
+
const majorNodeVersion = parseInt(nodeVersion.split('.')[0], 10);
|
|
61
|
+
if (majorNodeVersion >= 20) {
|
|
62
|
+
checks.push({
|
|
63
|
+
name: 'Node.js Runtime',
|
|
64
|
+
status: 'ok',
|
|
65
|
+
message: `v${nodeVersion} (满足 >= 20.0.0 要求)`,
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
checks.push({
|
|
69
|
+
name: 'Node.js Runtime',
|
|
70
|
+
status: 'err',
|
|
71
|
+
message: `v${nodeVersion} 过低!OpenLearn 核心需要 Node.js >= 20.0.0,请升级。`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. 操作系统与硬件
|
|
76
|
+
const cpus = os.cpus() || [];
|
|
77
|
+
const totalMemGb = Math.round((os.totalmem() / 1024 / 1024 / 1024) * 10) / 10;
|
|
78
|
+
const freeMemMb = Math.round(os.freemem() / 1024 / 1024);
|
|
79
|
+
checks.push({
|
|
80
|
+
name: 'Hardware & OS',
|
|
81
|
+
status: 'ok',
|
|
82
|
+
message: `${process.platform} (${process.arch}, ${cpus.length} CPU 核心, ${totalMemGb} GB 内存)`,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (freeMemMb < 256) {
|
|
86
|
+
checks.push({
|
|
87
|
+
name: 'Memory Headroom',
|
|
88
|
+
status: 'warn',
|
|
89
|
+
message: `剩余可用内存偏低 (${freeMemMb} MB),可能影响大课件解析`,
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
checks.push({
|
|
93
|
+
name: 'Memory Headroom',
|
|
94
|
+
status: 'ok',
|
|
95
|
+
message: `充足 (当前剩余 ${freeMemMb} MB 可用)`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 3. 数据库目录与读写权限
|
|
100
|
+
const dbPath = options.dbPath
|
|
101
|
+
? path.resolve(options.dbPath)
|
|
102
|
+
: (process.env.OPENLEARN_DB_PATH
|
|
103
|
+
? path.resolve(process.env.OPENLEARN_DB_PATH)
|
|
104
|
+
: path.join(os.homedir(), 'openlearn-next', 'data.db'));
|
|
105
|
+
const dbDir = path.dirname(dbPath);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
109
|
+
// 测试临时写入
|
|
110
|
+
const testFile = path.join(dbDir, `.doctor_write_test_${Date.now()}`);
|
|
111
|
+
fs.writeFileSync(testFile, 'test');
|
|
112
|
+
fs.unlinkSync(testFile);
|
|
113
|
+
checks.push({
|
|
114
|
+
name: 'Database Storage',
|
|
115
|
+
status: 'ok',
|
|
116
|
+
message: `${dbPath} (目录正常且具备读写权限)`,
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
checks.push({
|
|
120
|
+
name: 'Database Storage',
|
|
121
|
+
status: 'err',
|
|
122
|
+
message: `目录 ${dbDir} 写入失败: ${err.message}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 4. 端口可用性检查
|
|
127
|
+
const targetPort = options.port || parseInt(process.env.PORT || '9000', 10);
|
|
128
|
+
const isPortAvailable = await checkPortAvailable(targetPort);
|
|
129
|
+
if (isPortAvailable) {
|
|
130
|
+
checks.push({
|
|
131
|
+
name: `Port ${targetPort}`,
|
|
132
|
+
status: 'ok',
|
|
133
|
+
message: `可用 (未被其他进程占用)`,
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
checks.push({
|
|
137
|
+
name: `Port ${targetPort}`,
|
|
138
|
+
status: 'warn',
|
|
139
|
+
message: `已被占用!启动时会自动重试或可通过 -p <other_port> 更换端口`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 5. 局域网接入点
|
|
144
|
+
const netIps = getNetworkIps();
|
|
145
|
+
if (netIps.length > 0) {
|
|
146
|
+
checks.push({
|
|
147
|
+
name: 'Network Access',
|
|
148
|
+
status: 'ok',
|
|
149
|
+
message: `检测到局域网 IP: ${netIps.join(', ')}`,
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
checks.push({
|
|
153
|
+
name: 'Network Access',
|
|
154
|
+
status: 'warn',
|
|
155
|
+
message: `未检测到外部局域网 IPv4 地址(仅可通过 localhost 本机访问)`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const allOk = checks.every((c) => c.status !== 'err');
|
|
160
|
+
|
|
161
|
+
if (!silent) {
|
|
162
|
+
const bold = '\x1b[1m';
|
|
163
|
+
const green = '\x1b[32m';
|
|
164
|
+
const yellow = '\x1b[33m';
|
|
165
|
+
const red = '\x1b[31m';
|
|
166
|
+
const cyan = '\x1b[36m';
|
|
167
|
+
const reset = '\x1b[0m';
|
|
168
|
+
|
|
169
|
+
console.log(`\n${bold}${cyan}╔═════════════════════════════════════════════════════════════╗${reset}`);
|
|
170
|
+
console.log(`${bold}${cyan}║ OpenLearn V2 System Diagnostics (doctor) ║${reset}`);
|
|
171
|
+
console.log(`${bold}${cyan}╚═════════════════════════════════════════════════════════════╝${reset}\n`);
|
|
172
|
+
|
|
173
|
+
for (const c of checks) {
|
|
174
|
+
let icon = `${green}✓${reset}`;
|
|
175
|
+
if (c.status === 'warn') icon = `${yellow}⚠${reset}`;
|
|
176
|
+
if (c.status === 'err') icon = `${red}✗${reset}`;
|
|
177
|
+
console.log(` ${icon} ${bold}${c.name.padEnd(18)}${reset} : ${c.message}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log(`\n${cyan}───────────────────────────────────────────────────────────────${reset}`);
|
|
181
|
+
if (allOk) {
|
|
182
|
+
console.log(` ${bold}${green}诊断通过!核心运行环境健康,已具备课堂部署就绪状态。${reset}\n`);
|
|
183
|
+
} else {
|
|
184
|
+
console.log(` ${bold}${red}诊断发现阻断性问题,请根据上述提示处理后重试。${reset}\n`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { ok: allOk, checks };
|
|
189
|
+
}
|