@dongfangw/study-kit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +671 -0
- package/README.md +28 -2
- package/cordis.patch.yml +3 -0
- package/ext/adoc-convert/index.mjs +62 -0
- package/lib/client.js +5917 -0
- package/lib/client.js.map +1 -0
- package/lib/docsplit.d.ts +73 -0
- package/lib/docsplit.d.ts.map +1 -0
- package/lib/docsplit.js +241 -0
- package/lib/docsplit.js.map +1 -0
- package/lib/git.d.ts +29 -0
- package/lib/git.d.ts.map +1 -0
- package/lib/git.js +305 -0
- package/lib/git.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +324 -0
- package/lib/index.js.map +1 -0
- package/lib/isolation.d.ts +33 -0
- package/lib/isolation.d.ts.map +1 -0
- package/lib/isolation.js +30 -0
- package/lib/isolation.js.map +1 -0
- package/lib/libraries.d.ts +36 -0
- package/lib/libraries.d.ts.map +1 -0
- package/lib/libraries.js +118 -0
- package/lib/libraries.js.map +1 -0
- package/lib/render.d.ts +22 -0
- package/lib/render.d.ts.map +1 -0
- package/lib/render.js +568 -0
- package/lib/render.js.map +1 -0
- package/lib/session.d.ts +23 -0
- package/lib/session.d.ts.map +1 -0
- package/lib/session.js +35 -0
- package/lib/session.js.map +1 -0
- package/lib/skills.d.ts +24 -0
- package/lib/skills.d.ts.map +1 -0
- package/lib/skills.js +111 -0
- package/lib/skills.js.map +1 -0
- package/lib/slidev.d.ts +13 -0
- package/lib/slidev.d.ts.map +1 -0
- package/lib/slidev.js +755 -0
- package/lib/slidev.js.map +1 -0
- package/lib/slidevBin.d.ts +7 -0
- package/lib/slidevBin.d.ts.map +1 -0
- package/lib/slidevBin.js +130 -0
- package/lib/slidevBin.js.map +1 -0
- package/lib/slidevEnv.d.ts +56 -0
- package/lib/slidevEnv.d.ts.map +1 -0
- package/lib/slidevEnv.js +702 -0
- package/lib/slidevEnv.js.map +1 -0
- package/lib/static.d.ts +8 -0
- package/lib/static.d.ts.map +1 -0
- package/lib/static.js +79 -0
- package/lib/static.js.map +1 -0
- package/lib/tools.d.ts +4 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +160 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/client/DocMarks.d.ts +76 -0
- package/lib/types/client/DocMarks.d.ts.map +1 -0
- package/lib/types/client/DocViewer.d.ts +21 -0
- package/lib/types/client/DocViewer.d.ts.map +1 -0
- package/lib/types/client/GitPanel.d.ts +20 -0
- package/lib/types/client/GitPanel.d.ts.map +1 -0
- package/lib/types/client/LibraryWorkspace.d.ts +5 -0
- package/lib/types/client/LibraryWorkspace.d.ts.map +1 -0
- package/lib/types/client/MyLibraries.d.ts +5 -0
- package/lib/types/client/MyLibraries.d.ts.map +1 -0
- package/lib/types/client/SlidevDeckViewer.d.ts +25 -0
- package/lib/types/client/SlidevDeckViewer.d.ts.map +1 -0
- package/lib/types/client/SlidevEnvPanel.d.ts +11 -0
- package/lib/types/client/SlidevEnvPanel.d.ts.map +1 -0
- package/lib/types/client/annotations.d.ts +161 -0
- package/lib/types/client/annotations.d.ts.map +1 -0
- package/lib/types/client/docEnhance.d.ts +55 -0
- package/lib/types/client/docEnhance.d.ts.map +1 -0
- package/lib/types/client/docKey.d.ts +22 -0
- package/lib/types/client/docKey.d.ts.map +1 -0
- package/lib/types/client/git.module.css.gen.d.ts +3 -0
- package/lib/types/client/git.module.css.gen.d.ts.map +1 -0
- package/lib/types/client/icons.d.ts +50 -0
- package/lib/types/client/icons.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/prose.d.ts +19 -0
- package/lib/types/client/prose.d.ts.map +1 -0
- package/lib/types/client/readerControls.d.ts +55 -0
- package/lib/types/client/readerControls.d.ts.map +1 -0
- package/lib/types/client/readerPrefs.d.ts +73 -0
- package/lib/types/client/readerPrefs.d.ts.map +1 -0
- package/lib/types/client/readingPos.d.ts +82 -0
- package/lib/types/client/readingPos.d.ts.map +1 -0
- package/lib/types/client/styles.module.css.gen.d.ts +3 -0
- package/lib/types/client/styles.module.css.gen.d.ts.map +1 -0
- package/lib/types/client/submitDraft.d.ts +29 -0
- package/lib/types/client/submitDraft.d.ts.map +1 -0
- package/lib/types/client/viewActive.d.ts +55 -0
- package/lib/types/client/viewActive.d.ts.map +1 -0
- package/lib/types/types.d.ts +351 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types.d.ts +351 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +6 -0
- package/lib/types.js.map +1 -0
- package/package.json +101 -6
- package/skills/aero-concept-qa/SKILL.md +65 -0
- package/skills/aero-learning-path/SKILL.md +61 -0
- package/skills/aero-study-guide/SKILL.md +47 -0
- package/skills/aero-think/SKILL.md +59 -0
- package/skills/kb-organize/SKILL.md +73 -0
package/lib/slidevEnv.js
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slidev 课件环境的「体检 + 修复」。
|
|
3
|
+
*
|
|
4
|
+
* 背景:课件能否放映取决于宿主机环境(Node/npm、@slidev/cli、主题依赖),
|
|
5
|
+
* 而这些都不在仓库里。过去只有一句「未找到 slidev CLI」,用户看到的是永远转圈的
|
|
6
|
+
* 「正在准备 Slidev 课件…」,既不知道该装什么,也不知道装到哪里。
|
|
7
|
+
*
|
|
8
|
+
* 本模块提供三件事:
|
|
9
|
+
* 1. checkSlidevEnv() —— 逐项体检(Node / npm / CLI / 版本 / 主题 / 课件包),
|
|
10
|
+
* 返回给人看的结论 + 给机器看的修复动作(含将执行的命令);
|
|
11
|
+
* 2. consentToken(action) —— 把「动作 = 命令」签成一次性票据:客户端必须先把动作
|
|
12
|
+
* 展示给用户并取得同意,再带票据回来,服务端才动手;
|
|
13
|
+
* 3. runEnvFix() —— 真正执行安装(npm/pnpm),带超时与输出采集,
|
|
14
|
+
* 成功后清空体检缓存与 CLI 解析缓存并复检。
|
|
15
|
+
*
|
|
16
|
+
* 安全边界:安装写入的路径全部在响应里明示;全局安装(`npm i -g`)会改宿主机环境,
|
|
17
|
+
* 因此必须经用户同意(票据 + confirm=true)才执行;无票据一律 403/400,绝不「顺手装」。
|
|
18
|
+
*/
|
|
19
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
20
|
+
import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { resetSlidevBinCache, resolveSlidevBin, SLIDEV_CLI_ENTRY } from './slidevBin.js';
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// 常量
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/** 本仓库课件实测验证过的构建链锚点(lint W10 的推荐口径)。 */
|
|
29
|
+
export const SLIDEV_ANCHOR_VERSION = '52.19.1';
|
|
30
|
+
/** 主题 package.json 声明的 slidev 下限(engines.slidev >=52.1.0);低于它才判「过旧不可用」。 */
|
|
31
|
+
export const SLIDEV_MIN_SUPPORTED = '52.1.0';
|
|
32
|
+
/** 安装建议(与 make-ppt / review-ppt 技能中的版本口径一致)。 */
|
|
33
|
+
export const SLIDEV_RECOMMENDED_RANGE = '^52.19';
|
|
34
|
+
/** 一键安装的 npm 包规格。 */
|
|
35
|
+
const SLIDEV_INSTALL_SPEC = `@slidev/cli@${SLIDEV_RECOMMENDED_RANGE}`;
|
|
36
|
+
/** Node 下限(@slidev/cli 52 的 engines 要求)。 */
|
|
37
|
+
const NODE_MIN_MAJOR = 20;
|
|
38
|
+
/** 体检结果缓存时长(避免每次打开课件都跑子进程)。 */
|
|
39
|
+
const PROBE_TTL_MS = 30_000;
|
|
40
|
+
/** 版本探测子进程超时。 */
|
|
41
|
+
const VERSION_TIMEOUT_MS = 30_000;
|
|
42
|
+
/** 安装子进程超时(全局装 CLI 视网络可能需要几分钟;@slidev/cli 依赖较重,10 分钟偏紧)。 */
|
|
43
|
+
const INSTALL_TIMEOUT_MS = 20 * 60_000;
|
|
44
|
+
/** 同意票据有效期。 */
|
|
45
|
+
const TOKEN_TTL_MS = 10 * 60_000;
|
|
46
|
+
/** 安装输出回传上限(字符)。 */
|
|
47
|
+
const MAX_INSTALL_OUTPUT = 20_000;
|
|
48
|
+
/** 票据签名密钥:进程随机生成,重启即失效(不需要跨重启有效)。 */
|
|
49
|
+
const SECRET = randomBytes(32);
|
|
50
|
+
/**
|
|
51
|
+
* 运行子进程并采集合并输出(超时杀进程;永不 reject,错误落在返回值里)。
|
|
52
|
+
*
|
|
53
|
+
* 输出走**临时日志文件**而不是管道:① 避免管道背压拖慢安装;② 某些受限/沙箱环境
|
|
54
|
+
* 禁止创建命名管道,piped stdio 会直接 EPERM —— 落文件在这些环境里仍能工作。
|
|
55
|
+
*/
|
|
56
|
+
function runCommand(cmd, args, opts) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
let dir = null;
|
|
59
|
+
let fd = null;
|
|
60
|
+
/** 超时被我们主动杀掉(区别于进程自己以非 0 码退出)。 */
|
|
61
|
+
let timedOut = false;
|
|
62
|
+
try {
|
|
63
|
+
dir = mkdtempSync(path.join(os.tmpdir(), 'aero-envrun-'));
|
|
64
|
+
const logFile = path.join(dir, 'out.log');
|
|
65
|
+
fd = openSync(logFile, 'w');
|
|
66
|
+
let child;
|
|
67
|
+
try {
|
|
68
|
+
child = spawn(cmd, args, {
|
|
69
|
+
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', GIT_TERMINAL_PROMPT: '0', npm_config_yes: 'true' },
|
|
72
|
+
stdio: ['ignore', fd, fd],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
fd = null;
|
|
78
|
+
rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
resolve({ code: null, out: '', error: e instanceof Error ? e.message : String(e) });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const readLog = () => {
|
|
83
|
+
try {
|
|
84
|
+
return readFileSync(logFile, 'utf8').slice(-MAX_INSTALL_OUTPUT);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const cleanup = () => {
|
|
91
|
+
if (fd !== null) {
|
|
92
|
+
try {
|
|
93
|
+
closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
fd = null;
|
|
99
|
+
}
|
|
100
|
+
if (dir !== null) {
|
|
101
|
+
try {
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* ignore */
|
|
106
|
+
}
|
|
107
|
+
dir = null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
timedOut = true;
|
|
112
|
+
try {
|
|
113
|
+
child.kill('SIGKILL');
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* ignore */
|
|
117
|
+
}
|
|
118
|
+
}, opts.timeoutMs);
|
|
119
|
+
child.on('error', (e) => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
const out = readLog();
|
|
122
|
+
cleanup();
|
|
123
|
+
resolve({ code: null, out, error: e.message });
|
|
124
|
+
});
|
|
125
|
+
child.on('close', (code) => {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
const out = readLog();
|
|
128
|
+
cleanup();
|
|
129
|
+
// 超时被杀时 code 为 null:必须与「进程正常退出但退出码非 0」区分开,
|
|
130
|
+
// 否则界面只会显示一句无从下手的「安装进程退出码 null」。
|
|
131
|
+
// npm 在非 TTY 下不打印进度,超时被杀时输出很可能为空,故错误文案要自带解释。
|
|
132
|
+
resolve(timedOut
|
|
133
|
+
? { code, out, error: `安装超时:超过 ${Math.round(opts.timeoutMs / 60_000)} 分钟仍未结束,已终止进程。若网络较慢,可在终端手动执行 npm i -g @slidev/cli@^52.19 并观察输出。` }
|
|
134
|
+
: { code, out });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
if (fd !== null) {
|
|
139
|
+
try {
|
|
140
|
+
closeSync(fd);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* ignore */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (dir !== null)
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
resolve({ code: null, out: '', error: e instanceof Error ? e.message : String(e) });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/** 从任意命令输出中提取第一个 `x.y.z` 版本号。 */
|
|
153
|
+
function parseVersion(text) {
|
|
154
|
+
const m = /(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?/.exec(text);
|
|
155
|
+
return m === null ? null : m[0];
|
|
156
|
+
}
|
|
157
|
+
/** 数字点分版本比较:a<b 返回 -1,相等 0,a>b 返回 1。 */
|
|
158
|
+
function cmpVersion(a, b) {
|
|
159
|
+
const pa = a.split('-')[0].split('.').map((x) => Number.parseInt(x, 10) || 0);
|
|
160
|
+
const pb = b.split('-')[0].split('.').map((x) => Number.parseInt(x, 10) || 0);
|
|
161
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
162
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
163
|
+
if (d !== 0)
|
|
164
|
+
return d < 0 ? -1 : 1;
|
|
165
|
+
}
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
/** PATH 上是否存在某个命令(Windows 需带 .cmd/.exe 后缀)。 */
|
|
169
|
+
function findOnPath(names) {
|
|
170
|
+
const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat'] : [''];
|
|
171
|
+
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
172
|
+
if (dir === '')
|
|
173
|
+
continue;
|
|
174
|
+
for (const name of names) {
|
|
175
|
+
for (const ext of exts) {
|
|
176
|
+
const cand = path.join(dir, name + ext);
|
|
177
|
+
if (existsSync(cand))
|
|
178
|
+
return cand;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 定位 npm 的 JS 入口(npm-cli.js)。
|
|
186
|
+
*
|
|
187
|
+
* 直接 `spawn('npm.cmd', …)` 在 Windows 上需要 shell 解析(对含空格/中文的路径很脆弱),
|
|
188
|
+
* 因此统一用 `node npm-cli.js` 执行;找不到 JS 入口时返回 null,由调用方回退到 shim。
|
|
189
|
+
*/
|
|
190
|
+
function resolveNpmJs() {
|
|
191
|
+
const nodeDir = path.dirname(process.execPath);
|
|
192
|
+
const candidates = [
|
|
193
|
+
path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
194
|
+
path.join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
195
|
+
];
|
|
196
|
+
const shim = findOnPath(['npm']);
|
|
197
|
+
if (shim !== null) {
|
|
198
|
+
const shimDir = path.dirname(shim);
|
|
199
|
+
candidates.push(path.join(shimDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'));
|
|
200
|
+
// pnpm 安装的 shim:<dir>/../npm/bin/npm-cli.js
|
|
201
|
+
candidates.push(path.join(shimDir, '..', 'npm', 'bin', 'npm-cli.js'));
|
|
202
|
+
}
|
|
203
|
+
for (const c of candidates) {
|
|
204
|
+
if (existsSync(c))
|
|
205
|
+
return c;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
/** 从已解析的 CLI 入口反推 @slidev/cli 包目录(<pkg>/bin/slidev.mjs → <pkg>)。 */
|
|
210
|
+
function cliPackageDir(bin) {
|
|
211
|
+
let dir = path.dirname(bin);
|
|
212
|
+
for (let i = 0; i < 6; i++) {
|
|
213
|
+
const pj = path.join(dir, 'package.json');
|
|
214
|
+
if (existsSync(pj)) {
|
|
215
|
+
try {
|
|
216
|
+
const pkg = JSON.parse(readFileSync(pj, 'utf8'));
|
|
217
|
+
if (pkg.name === '@slidev/cli' && typeof pkg.version === 'string')
|
|
218
|
+
return dir;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* 继续向上找 */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const parent = path.dirname(dir);
|
|
225
|
+
if (parent === dir)
|
|
226
|
+
break;
|
|
227
|
+
dir = parent;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function dirHasEntries(dir) {
|
|
232
|
+
try {
|
|
233
|
+
return existsSync(dir) && statSync(dir).isDirectory();
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** 读取主题 package.json 的 engines.slidev(用于版本口径提示)。 */
|
|
240
|
+
function themeSlidevEngine(themeDir) {
|
|
241
|
+
try {
|
|
242
|
+
const pkg = JSON.parse(readFileSync(path.join(themeDir, 'package.json'), 'utf8'));
|
|
243
|
+
const v = pkg.engines?.slidev;
|
|
244
|
+
return typeof v === 'string' ? v : null;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** 从 slides.md 头部读 theme 字段(与 slidev.ts 同口径,只读前 4KB)。 */
|
|
251
|
+
function readThemeRef(slidesAbs) {
|
|
252
|
+
try {
|
|
253
|
+
const head = readFileSync(slidesAbs, 'utf8').slice(0, 4096);
|
|
254
|
+
const m = /^\s*theme:\s*(\S+)\s*$/m.exec(head);
|
|
255
|
+
if (m === null || m[1] === undefined)
|
|
256
|
+
return null;
|
|
257
|
+
return m[1].replace(/^['"]|['"]$/g, '');
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// 同意票据
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
function signToken(payload) {
|
|
267
|
+
return createHmac('sha256', SECRET).update(payload).digest('hex');
|
|
268
|
+
}
|
|
269
|
+
/** 为动作生成同意票据:绑定动作 id + 命令 + 签发时间,有效期 TOKEN_TTL_MS。 */
|
|
270
|
+
function consentToken(actionId, command) {
|
|
271
|
+
const issuedAt = Date.now();
|
|
272
|
+
const payload = `${actionId}\n${command}\n${issuedAt}`;
|
|
273
|
+
return `${issuedAt}.${signToken(payload)}`;
|
|
274
|
+
}
|
|
275
|
+
/** 校验票据:签名不匹配 / 过期 / 与当前动作不符 → false。 */
|
|
276
|
+
function verifyToken(token, actionId, command) {
|
|
277
|
+
const dot = token.indexOf('.');
|
|
278
|
+
if (dot <= 0)
|
|
279
|
+
return false;
|
|
280
|
+
const issuedAt = Number.parseInt(token.slice(0, dot), 10);
|
|
281
|
+
if (!Number.isFinite(issuedAt))
|
|
282
|
+
return false;
|
|
283
|
+
if (Math.abs(Date.now() - issuedAt) > TOKEN_TTL_MS)
|
|
284
|
+
return false;
|
|
285
|
+
const expect = Buffer.from(signToken(`${actionId}\n${command}\n${issuedAt}`), 'utf8');
|
|
286
|
+
const got = Buffer.from(token.slice(dot + 1), 'utf8');
|
|
287
|
+
if (expect.length !== got.length)
|
|
288
|
+
return false;
|
|
289
|
+
try {
|
|
290
|
+
return timingSafeEqual(expect, got);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// 体检
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
function check(id, label, status, detail, hint = '', version = '') {
|
|
300
|
+
return { id, label, status, detail, hint, version };
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 探测 @slidev/cli 版本。
|
|
304
|
+
*
|
|
305
|
+
* 优先读 CLI 入口所属包的 package.json(无子进程、最快,沙箱环境也适用);
|
|
306
|
+
* 读不到时才回退到 `slidev --version`。
|
|
307
|
+
*/
|
|
308
|
+
async function probeCliVersion(bin) {
|
|
309
|
+
const pkgDir = cliPackageDir(bin);
|
|
310
|
+
if (pkgDir !== null) {
|
|
311
|
+
try {
|
|
312
|
+
const pkg = JSON.parse(readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
313
|
+
const v = typeof pkg.version === 'string' ? parseVersion(pkg.version) : null;
|
|
314
|
+
if (v !== null)
|
|
315
|
+
return { version: v, error: '' };
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
/* 回退到子进程探测 */
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const r = await runCommand(process.execPath, [bin, '--version'], { timeoutMs: VERSION_TIMEOUT_MS });
|
|
322
|
+
if (r.error !== undefined)
|
|
323
|
+
return { version: null, error: r.error };
|
|
324
|
+
const v = parseVersion(r.out);
|
|
325
|
+
if (v !== null)
|
|
326
|
+
return { version: v, error: '' };
|
|
327
|
+
return {
|
|
328
|
+
version: null,
|
|
329
|
+
error: r.code === 0
|
|
330
|
+
? '`slidev --version` 没有输出版本号(CLI 可能损坏或版本过旧)'
|
|
331
|
+
: `\`slidev --version\` 退出码 ${String(r.code)}:${r.out.trim().slice(-300) || '无输出'}`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* 环境体检。返回:
|
|
336
|
+
* - checks:逐项结论(ok/warn/fail),前端直接渲染成清单;
|
|
337
|
+
* - actions:可执行的修复动作(含将执行的命令与写入位置),需要用户同意;
|
|
338
|
+
* - ready:课件构建链是否就绪(只有 fail 会影响它,warn 不影响);
|
|
339
|
+
* - summary:一句话结论(给 no-toolchain 提示复用)。
|
|
340
|
+
*/
|
|
341
|
+
export async function checkSlidevEnv(opts = {}) {
|
|
342
|
+
const checks = [];
|
|
343
|
+
const actions = [];
|
|
344
|
+
/** CLI 完全缺失或不可运行(会阻断构建)。 */
|
|
345
|
+
let cliBroken = false;
|
|
346
|
+
// ① Node:直接用宿主进程的版本(构建子进程与宿主同版本)。
|
|
347
|
+
const nodeVersion = process.versions.node;
|
|
348
|
+
const nodeMajor = Number.parseInt(nodeVersion.split('.')[0] ?? '0', 10) || 0;
|
|
349
|
+
if (nodeMajor >= NODE_MIN_MAJOR) {
|
|
350
|
+
checks.push(check('node', `Node.js(运行 Slidev 的运行时)`, 'ok', `v${nodeVersion}`, '', nodeVersion));
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
checks.push(check('node', 'Node.js(运行 Slidev 的运行时)', 'fail', `v${nodeVersion} 过低(需要 ≥ ${NODE_MIN_MAJOR})`, '请升级 Node.js 后重启 DSH。', nodeVersion));
|
|
354
|
+
}
|
|
355
|
+
// ② npm / pnpm:安装能力(缺失只影响「一键安装」,不影响已有 CLI 的构建)。
|
|
356
|
+
const npmPath = findOnPath(['npm']);
|
|
357
|
+
const pnpmPath = findOnPath(['pnpm']);
|
|
358
|
+
if (npmPath !== null) {
|
|
359
|
+
checks.push(check('npm', 'npm(用于安装依赖)', 'ok', npmPath, ''));
|
|
360
|
+
}
|
|
361
|
+
else if (pnpmPath !== null) {
|
|
362
|
+
checks.push(check('pnpm', 'npm / pnpm(用于安装依赖)', 'ok', `pnpm:${pnpmPath}`, ''));
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
checks.push(check('npm', 'npm / pnpm(用于安装依赖)', 'fail', 'PATH 中未找到 npm 或 pnpm', '请安装 Node.js(自带 npm)后重启 DSH,否则无法自动安装缺失依赖。'));
|
|
366
|
+
}
|
|
367
|
+
// ③ @slidev/cli:路径 + 版本。
|
|
368
|
+
const bin = resolveSlidevBin();
|
|
369
|
+
let cliVersion = null;
|
|
370
|
+
/** 版本低于仓库锚点:只告警(实测仍能构建),不当作缺失。 */
|
|
371
|
+
let cliOutdated = false;
|
|
372
|
+
if (bin === null) {
|
|
373
|
+
cliBroken = true;
|
|
374
|
+
checks.push(check('slidev-cli', '@slidev/cli(课件构建器)', 'fail', '未找到 slidev 命令(PATH 与 AERO_SLIDEV_BIN 均无)', `安装:npm i -g ${SLIDEV_INSTALL_SPEC};或设 AERO_SLIDEV_BIN 指向已有 CLI 入口。`));
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
const probe = await probeCliVersion(bin);
|
|
378
|
+
cliVersion = probe.version;
|
|
379
|
+
if (probe.version === null) {
|
|
380
|
+
cliBroken = true;
|
|
381
|
+
checks.push(check('slidev-cli', '@slidev/cli(课件构建器)', 'fail', probe.error, `可尝试重装:npm i -g ${SLIDEV_INSTALL_SPEC}`, ''));
|
|
382
|
+
}
|
|
383
|
+
else if (cmpVersion(probe.version, SLIDEV_MIN_SUPPORTED) < 0) {
|
|
384
|
+
cliBroken = true;
|
|
385
|
+
checks.push(check('slidev-cli', '@slidev/cli(课件构建器)', 'fail', `v${probe.version} 过旧(低于主题声明的 slidev 下限 ${SLIDEV_MIN_SUPPORTED})`, `请升级:npm i -g ${SLIDEV_INSTALL_SPEC}`, probe.version));
|
|
386
|
+
}
|
|
387
|
+
else if (cmpVersion(probe.version, SLIDEV_ANCHOR_VERSION) < 0) {
|
|
388
|
+
// 版本可用但低于仓库实测锚点:只提示,不阻断(本机 52.9.1 实测可正常构建)。
|
|
389
|
+
cliOutdated = true;
|
|
390
|
+
checks.push(check('slidev-cli', '@slidev/cli(课件构建器)', 'warn', `v${probe.version}(可构建;仓库实测锚点为 ${SLIDEV_ANCHOR_VERSION},旧构建链在个别布局上可能有样式差异)`, `若遇到双栏/样式异常,可升级:npm i -g ${SLIDEV_INSTALL_SPEC}`, probe.version));
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
checks.push(check('slidev-cli', '@slidev/cli(课件构建器)', 'ok', `v${probe.version}`, bin, probe.version));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// ④ 课件级:主题目录 + 主题依赖 + 课件本地 CLI。
|
|
397
|
+
let themeDir = null;
|
|
398
|
+
let deckDir = null;
|
|
399
|
+
if (opts.slidesAbs !== undefined && existsSync(opts.slidesAbs)) {
|
|
400
|
+
deckDir = path.dirname(opts.slidesAbs);
|
|
401
|
+
const themeRef = readThemeRef(opts.slidesAbs);
|
|
402
|
+
if (themeRef === null) {
|
|
403
|
+
checks.push(check('deck-theme', '课件主题声明', 'warn', 'slides.md 未声明 theme(将使用 Slidev 内置布局)', '', ''));
|
|
404
|
+
}
|
|
405
|
+
else if (!themeRef.startsWith('.')) {
|
|
406
|
+
checks.push(check('deck-theme', '课件主题声明', 'warn', `theme: ${themeRef}(npm 包名主题,插件暂只支持本地路径主题)`, '', ''));
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
const abs = path.resolve(deckDir, themeRef);
|
|
410
|
+
if (!existsSync(path.join(abs, 'package.json'))) {
|
|
411
|
+
checks.push(check('deck-theme', '课件主题目录', 'fail', `主题目录不存在:${abs}`, '课件与主题需一起从仓库取全(teaching-case/mdPPT/theme-neversink)。'));
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
themeDir = abs;
|
|
415
|
+
const engine = themeSlidevEngine(abs);
|
|
416
|
+
checks.push(check('deck-theme', '课件主题目录', 'ok', abs, engine !== null ? `主题要求 slidev ${engine}` : ''));
|
|
417
|
+
const nm = path.join(abs, 'node_modules');
|
|
418
|
+
if (dirHasEntries(nm) && existsSync(path.join(nm, '@slidev', 'types'))) {
|
|
419
|
+
checks.push(check('deck-theme-deps', '主题依赖(node_modules/@slidev/types)', 'ok', nm, ''));
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
checks.push(check('deck-theme-deps', '主题依赖(node_modules/@slidev/types)', 'fail', dirHasEntries(nm) ? `${nm} 不完整(缺 @slidev/types)` : `${nm} 不存在`, `在主题目录执行 npm install:${abs}`));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const localCli = path.join(deckDir, 'node_modules', '@slidev', 'cli', SLIDEV_CLI_ENTRY);
|
|
427
|
+
if (existsSync(localCli)) {
|
|
428
|
+
checks.push(check('deck-local-cli', '课件本地 CLI(可选)', 'ok', localCli, ''));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// ---- 修复动作(不自动执行) ----
|
|
432
|
+
// 只有「完全找不到 CLI」或「CLI 过旧到已知不可用」才给安装动作;
|
|
433
|
+
// 版本低于锚点但仍能构建的情况只告警,不诱导用户改动全局环境。
|
|
434
|
+
if (cliBroken) {
|
|
435
|
+
actions.push({
|
|
436
|
+
id: 'install-cli-global',
|
|
437
|
+
label: `全局安装 ${SLIDEV_INSTALL_SPEC}`,
|
|
438
|
+
reason: bin === null ? '未找到 slidev 命令' : '现有 @slidev/cli 无法运行或版本过旧',
|
|
439
|
+
command: `npm i -g ${SLIDEV_INSTALL_SPEC}`,
|
|
440
|
+
cwd: '',
|
|
441
|
+
scope: 'global',
|
|
442
|
+
writes: '写入 npm 全局前缀(npm prefix -g 下的 node_modules 与 bin),影响本机所有项目',
|
|
443
|
+
token: '',
|
|
444
|
+
disabled: npmPath === null,
|
|
445
|
+
disabledReason: npmPath === null ? 'PATH 中未找到 npm,无法自动安装' : '',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
else if (cliOutdated) {
|
|
449
|
+
actions.push({
|
|
450
|
+
id: 'install-cli-global',
|
|
451
|
+
label: `升级到 ${SLIDEV_INSTALL_SPEC}(可选)`,
|
|
452
|
+
reason: `当前 v${cliVersion ?? '?'} 可以构建,升级到仓库实测锚点可避免旧构建链的样式问题`,
|
|
453
|
+
command: `npm i -g ${SLIDEV_INSTALL_SPEC}`,
|
|
454
|
+
cwd: '',
|
|
455
|
+
scope: 'global',
|
|
456
|
+
writes: '写入 npm 全局前缀(npm prefix -g 下的 node_modules 与 bin),影响本机所有项目',
|
|
457
|
+
token: '',
|
|
458
|
+
disabled: npmPath === null,
|
|
459
|
+
disabledReason: npmPath === null ? 'PATH 中未找到 npm,无法自动安装' : '',
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (themeDir !== null) {
|
|
463
|
+
const nm = path.join(themeDir, 'node_modules');
|
|
464
|
+
if (!dirHasEntries(nm) || !existsSync(path.join(nm, '@slidev', 'types'))) {
|
|
465
|
+
actions.push({
|
|
466
|
+
id: 'install-theme-deps',
|
|
467
|
+
label: '安装课件主题依赖',
|
|
468
|
+
reason: '主题 node_modules 缺失或不完整,vite 构建会直接失败',
|
|
469
|
+
command: 'npm install',
|
|
470
|
+
cwd: themeDir,
|
|
471
|
+
scope: 'workspace',
|
|
472
|
+
writes: `写入 ${nm}(仅主题目录内,不动系统环境)`,
|
|
473
|
+
token: '',
|
|
474
|
+
disabled: npmPath === null,
|
|
475
|
+
disabledReason: npmPath === null ? 'PATH 中未找到 npm,无法自动安装' : '',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const failed = checks.filter((c) => c.status === 'fail');
|
|
480
|
+
const ready = !failed.some((c) => c.id === 'node' || c.id === 'slidev-cli' || c.id === 'deck-theme' || c.id === 'deck-theme-deps');
|
|
481
|
+
const summary = ready
|
|
482
|
+
? (cliVersion !== null ? `环境就绪(@slidev/cli v${cliVersion})` : '环境就绪')
|
|
483
|
+
: failed.map((c) => c.label.split('(')[0] + ':' + c.detail).join(';');
|
|
484
|
+
return {
|
|
485
|
+
ok: true,
|
|
486
|
+
ready,
|
|
487
|
+
platform: process.platform,
|
|
488
|
+
nodeVersion,
|
|
489
|
+
checks,
|
|
490
|
+
actions: actions.map((a) => ({ ...a, token: a.disabled ? '' : consentToken(a.id, `${a.command}\n${a.cwd}`) })),
|
|
491
|
+
summary,
|
|
492
|
+
probedAt: Date.now(),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
// 体检缓存(meta 路由也要用,避免重复跑子进程)
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
let cachedReport = null;
|
|
499
|
+
/** 缓存对应的课件(主题/依赖检查是课件级的,换课件必须重探,否则会张冠李戴)。 */
|
|
500
|
+
let cachedFor = null;
|
|
501
|
+
/** 重复请求合并成一次探测。 */
|
|
502
|
+
let inflight = null;
|
|
503
|
+
/** 体检(带 TTL 缓存;仅同一课件复用)。force=true 强制重新探测。 */
|
|
504
|
+
export async function getSlidevEnv(opts = {}, force = false) {
|
|
505
|
+
const key = opts.slidesAbs ?? '';
|
|
506
|
+
if (!force && cachedReport !== null && cachedFor === key && Date.now() - cachedReport.probedAt < PROBE_TTL_MS)
|
|
507
|
+
return cachedReport;
|
|
508
|
+
if (inflight !== null)
|
|
509
|
+
return inflight;
|
|
510
|
+
inflight = checkSlidevEnv(opts)
|
|
511
|
+
.then((r) => {
|
|
512
|
+
cachedReport = r;
|
|
513
|
+
cachedFor = key;
|
|
514
|
+
return r;
|
|
515
|
+
})
|
|
516
|
+
.finally(() => {
|
|
517
|
+
inflight = null;
|
|
518
|
+
});
|
|
519
|
+
return inflight;
|
|
520
|
+
}
|
|
521
|
+
/** 清空体检缓存(安装完成后调用)。 */
|
|
522
|
+
export function resetSlidevEnvCache() {
|
|
523
|
+
cachedReport = null;
|
|
524
|
+
cachedFor = null;
|
|
525
|
+
}
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// 安装执行
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
/** 当前是否有安装在进行(一次只允许一个,避免并发写 npm 全局目录)。 */
|
|
530
|
+
let install = { running: false, actionId: '', startedAt: 0, finishedAt: 0, output: '', code: null, error: '' };
|
|
531
|
+
export function currentInstallState() {
|
|
532
|
+
return install;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* 执行一个修复动作。必须带合法票据且 confirm=true,否则拒绝——
|
|
536
|
+
* 服务端从不在用户未同意的情况下改动环境。
|
|
537
|
+
*/
|
|
538
|
+
export async function runEnvFix(opts) {
|
|
539
|
+
if (!opts.confirm)
|
|
540
|
+
return { ok: false, code: 400, error: '未确认:安装需要用户在界面中明确同意后才能执行。' };
|
|
541
|
+
if (install.running)
|
|
542
|
+
return { ok: false, code: 409, error: `已有安装任务在执行(${install.actionId}),请等它结束。` };
|
|
543
|
+
// 用「当前环境」重建动作清单来校验票据:票据绑定动作 id + 命令,避免被构造任意命令。
|
|
544
|
+
const report = await checkSlidevEnv({ slidesAbs: opts.slidesAbs });
|
|
545
|
+
const action = report.actions.find((a) => a.id === opts.actionId);
|
|
546
|
+
if (action === undefined)
|
|
547
|
+
return { ok: false, code: 404, error: `未知或当前不再需要的修复动作:${opts.actionId}(环境可能已变化,请刷新体检结果)。` };
|
|
548
|
+
if (action.disabled)
|
|
549
|
+
return { ok: false, code: 400, error: action.disabledReason !== '' ? action.disabledReason : '该动作当前不可执行。' };
|
|
550
|
+
if (!verifyToken(opts.token, action.id, `${action.command}\n${action.cwd}`)) {
|
|
551
|
+
return { ok: false, code: 403, error: '安装票据无效或已过期:请刷新体检结果并重新确认。' };
|
|
552
|
+
}
|
|
553
|
+
const npmPath = findOnPath(['npm']);
|
|
554
|
+
if (npmPath === null)
|
|
555
|
+
return { ok: false, code: 400, error: 'PATH 中未找到 npm,无法自动安装。请手动执行面板中显示的命令。' };
|
|
556
|
+
const npmJs = resolveNpmJs();
|
|
557
|
+
const npmCmd = npmJs === null ? npmPath : process.execPath;
|
|
558
|
+
const npmArgs = npmJs === null ? [] : [npmJs];
|
|
559
|
+
install = { running: true, actionId: action.id, startedAt: Date.now(), finishedAt: 0, output: '', code: null, error: '' };
|
|
560
|
+
const args = action.id === 'install-cli-global'
|
|
561
|
+
? [...npmArgs, 'install', '-g', SLIDEV_INSTALL_SPEC]
|
|
562
|
+
: [...npmArgs, 'install'];
|
|
563
|
+
let r;
|
|
564
|
+
try {
|
|
565
|
+
r = await runCommand(npmCmd, args, {
|
|
566
|
+
...(action.cwd === '' ? {} : { cwd: action.cwd }),
|
|
567
|
+
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
install = { ...install, running: false, finishedAt: Date.now() };
|
|
572
|
+
}
|
|
573
|
+
const output = r.out.slice(-MAX_INSTALL_OUTPUT);
|
|
574
|
+
const failed = r.error !== undefined || r.code !== 0;
|
|
575
|
+
install = {
|
|
576
|
+
running: false,
|
|
577
|
+
actionId: action.id,
|
|
578
|
+
startedAt: install.startedAt,
|
|
579
|
+
finishedAt: Date.now(),
|
|
580
|
+
output,
|
|
581
|
+
code: r.code,
|
|
582
|
+
error: r.error ?? (failed ? `安装进程退出码 ${String(r.code)}` : ''),
|
|
583
|
+
};
|
|
584
|
+
// 安装可能带来新 CLI / 新依赖:清缓存并复检(复检结果一并回给客户端)。
|
|
585
|
+
resetSlidevBinCache();
|
|
586
|
+
resetSlidevEnvCache();
|
|
587
|
+
const after = await checkSlidevEnv({ slidesAbs: opts.slidesAbs });
|
|
588
|
+
const result = {
|
|
589
|
+
ok: !failed,
|
|
590
|
+
actionId: action.id,
|
|
591
|
+
command: `${action.command}${action.cwd === '' ? '' : `(在 ${action.cwd})`}`,
|
|
592
|
+
code: r.code,
|
|
593
|
+
output,
|
|
594
|
+
error: r.error ?? (failed ? `安装进程退出码 ${String(r.code)}` : ''),
|
|
595
|
+
report: after,
|
|
596
|
+
};
|
|
597
|
+
return { ok: true, result };
|
|
598
|
+
}
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
// HTTP 处理:GET /aero-kb/slidev/env?p=<rel>&cwd=…&force=1
|
|
601
|
+
// POST /aero-kb/slidev/env {actionId, token, confirm, p, cwd}
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
function sendJson(res, code, body) {
|
|
604
|
+
res.statusCode = code;
|
|
605
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
606
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
607
|
+
res.end(JSON.stringify(body));
|
|
608
|
+
}
|
|
609
|
+
async function readJsonBody(req, limit = 64 * 1024) {
|
|
610
|
+
const chunks = [];
|
|
611
|
+
let size = 0;
|
|
612
|
+
for await (const chunk of req) {
|
|
613
|
+
const buf = chunk;
|
|
614
|
+
size += buf.length;
|
|
615
|
+
if (size > limit)
|
|
616
|
+
throw new Error('请求体过大');
|
|
617
|
+
chunks.push(buf);
|
|
618
|
+
}
|
|
619
|
+
if (size === 0)
|
|
620
|
+
return {};
|
|
621
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
622
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
623
|
+
throw new Error('请求体必须是 JSON 对象');
|
|
624
|
+
return parsed;
|
|
625
|
+
}
|
|
626
|
+
/** 只接受本页发起的请求(同源校验,防止其它站点借道触发安装)。 */
|
|
627
|
+
function sameOrigin(req) {
|
|
628
|
+
const origin = req.headers.origin;
|
|
629
|
+
if (typeof origin !== 'string' || origin === '')
|
|
630
|
+
return true; // 同源 fetch / 非浏览器客户端不带 Origin
|
|
631
|
+
const host = req.headers.host;
|
|
632
|
+
if (typeof host !== 'string' || host === '')
|
|
633
|
+
return false;
|
|
634
|
+
try {
|
|
635
|
+
const u = new URL(origin);
|
|
636
|
+
return u.host === host && (u.protocol === 'http:' || u.protocol === 'https:');
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/** slides.md 相对路径 → 绝对路径(限 KB 根内)。 */ function slidesAbsOf(root, rel) {
|
|
643
|
+
if (rel === '')
|
|
644
|
+
return undefined;
|
|
645
|
+
const abs = path.resolve(root, rel);
|
|
646
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
647
|
+
if (abs !== root && !abs.startsWith(prefix))
|
|
648
|
+
return undefined;
|
|
649
|
+
return path.basename(abs).toLowerCase() === 'slides.md' ? abs : undefined;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* /aero-kb/slidev/env 路由:
|
|
653
|
+
* GET → 体检报告(?p=<slides.md 相对路径> 时附带该课件的主题/依赖检查;?force=1 强制复检)
|
|
654
|
+
* POST → 执行修复动作(必须带体检返回的 token 且 confirm=true)
|
|
655
|
+
*/
|
|
656
|
+
export async function handleSlidevEnv(req, res, opts) {
|
|
657
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
658
|
+
const hintCwd = url.searchParams.get('cwd') ?? undefined;
|
|
659
|
+
const root = opts.resolveKbRoot(hintCwd);
|
|
660
|
+
try {
|
|
661
|
+
if (req.method === 'POST') {
|
|
662
|
+
if (!sameOrigin(req)) {
|
|
663
|
+
sendJson(res, 403, { ok: false, error: '拒绝跨站请求。' });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const body = await readJsonBody(req);
|
|
667
|
+
const rel = typeof body.p === 'string' ? body.p : (url.searchParams.get('p') ?? '');
|
|
668
|
+
const slidesAbs = root === null ? undefined : slidesAbsOf(root, rel);
|
|
669
|
+
const outcome = await runEnvFix({
|
|
670
|
+
actionId: typeof body.actionId === 'string' ? body.actionId : '',
|
|
671
|
+
token: typeof body.token === 'string' ? body.token : '',
|
|
672
|
+
confirm: body.confirm === true,
|
|
673
|
+
slidesAbs,
|
|
674
|
+
});
|
|
675
|
+
if (!outcome.ok) {
|
|
676
|
+
sendJson(res, outcome.code, { ok: false, error: outcome.error, install: currentInstallState() });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
sendJson(res, 200, { ok: true, result: outcome.result, install: currentInstallState() });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (req.method !== 'GET') {
|
|
683
|
+
res.statusCode = 405;
|
|
684
|
+
res.setHeader('Allow', 'GET, POST');
|
|
685
|
+
res.end('method not allowed');
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const rel = url.searchParams.get('p') ?? '';
|
|
689
|
+
const slidesAbs = root === null ? undefined : slidesAbsOf(root, rel);
|
|
690
|
+
// force=1 是用户的「重新体检」动作:必须连 CLI 解析缓存一起丢。
|
|
691
|
+
// 解析失败会在模块级永久缓存 null,只清体检缓存的话,用户装好 CLI 后点「重新体检」
|
|
692
|
+
// 仍会看到「未找到 slidev 命令」,只能重启 DSH 才能恢复。
|
|
693
|
+
if (url.searchParams.get('force') === '1')
|
|
694
|
+
resetSlidevBinCache();
|
|
695
|
+
const report = await getSlidevEnv({ slidesAbs }, url.searchParams.get('force') === '1');
|
|
696
|
+
sendJson(res, 200, { ok: true, report, install: currentInstallState() });
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
//# sourceMappingURL=slidevEnv.js.map
|