dsh-plugin-office-markdown 1.2.1

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/lib/env.js ADDED
@@ -0,0 +1,602 @@
1
+ /*!
2
+ * dsh-plugin-office-markdown — Python environment manager (host half)
3
+ *
4
+ * Owns the "check / install / uninstall" lifecycle for MarkItDown, so the
5
+ * plugin never installs anything on its own:
6
+ *
7
+ * - `inspectEnvironments()` reports every Python candidate the plugin can
8
+ * see, whether it has pip, and whether it already carries markitdown.
9
+ * This is what the settings page renders.
10
+ * - `installMarkitdown()` runs `pip install "markitdown[all]"` into ONE
11
+ * chosen interpreter and writes a snapshot of the package set before and
12
+ * after, so the restore step touches only what this plugin added.
13
+ * - `uninstallMarkitdown()` uninstalls exactly the packages named in that
14
+ * snapshot and then deletes it. A machine where the user installed
15
+ * markitdown themselves has no snapshot, so nothing is ever removed.
16
+ *
17
+ * The snapshot lives at `~/.dsh/dsh-plugin-office-markdown.env.json` — a fixed
18
+ * location both this module and `install.ps1` agree on, independent of which
19
+ * profile is in use.
20
+ */
21
+ import fs from 'node:fs'
22
+ import path from 'node:path'
23
+
24
+ import { pythonCandidates, pythonSourceLabel, run } from './convert.js'
25
+ import { dshHome } from './paths.js'
26
+
27
+ export const SNAPSHOT_BASENAME = 'dsh-plugin-office-markdown.env.json'
28
+ export const MARKITDOWN_REQUIREMENT = 'markitdown[all]'
29
+
30
+ /* `pip freeze` / `pip show` on a cold interpreter can genuinely take a while. */
31
+ const PROBE_TIMEOUT = 60000
32
+ const INSTALL_TIMEOUT = 900000
33
+
34
+ /* A bare `python --version` must not: if it has not answered in five seconds,
35
+ * something is very wrong (a broken shim, an antivirus scan, a Windows Store
36
+ * stub waiting on input) and waiting longer only freezes the settings page.
37
+ * Each candidate additionally gets a total budget, because five hangs of five
38
+ * seconds add up to twenty-five. */
39
+ const PROBE_SPAWN_TIMEOUT = 5000
40
+ const PROBE_CANDIDATE_BUDGET = 9000
41
+ const DEFAULT_PROBE_TTL_MS = 600000
42
+
43
+ /** Resolve `promise`, or give up after `ms` and use `onTimeout()`. */
44
+ function withBudget(promise, ms, onTimeout) {
45
+ let timer = null
46
+ const guard = new Promise((resolve) => {
47
+ timer = setTimeout(() => resolve(onTimeout()), ms)
48
+ if (timer && typeof timer.unref === 'function') timer.unref()
49
+ })
50
+ return Promise.race([promise, guard]).finally(() => {
51
+ if (timer) clearTimeout(timer)
52
+ })
53
+ }
54
+
55
+ function firstLine(text) {
56
+ const s = String(text || '').replace(/\r/g, '')
57
+ const line = s.split('\n').find((l) => l.trim())
58
+ return line ? line.trim().slice(0, 300) : ''
59
+ }
60
+
61
+ function tailLines(text, n) {
62
+ const lines = String(text || '').replace(/\r/g, '').split('\n').filter((l) => l.trim())
63
+ return lines.slice(-n).join('\n')
64
+ }
65
+
66
+ /** Absolute path of the env snapshot shared with `install.ps1`. */
67
+ export function snapshotPath() {
68
+ return path.join(dshHome(), SNAPSHOT_BASENAME)
69
+ }
70
+
71
+ export function readSnapshot() {
72
+ try {
73
+ const p = snapshotPath()
74
+ if (!fs.existsSync(p)) return null
75
+ const obj = JSON.parse(fs.readFileSync(p, 'utf8'))
76
+ return obj && typeof obj === 'object' ? obj : null
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ export function writeSnapshot(obj) {
83
+ const p = snapshotPath()
84
+ fs.mkdirSync(path.dirname(p), { recursive: true })
85
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf8')
86
+ return p
87
+ }
88
+
89
+ export function removeSnapshot() {
90
+ try {
91
+ fs.rmSync(snapshotPath(), { force: true })
92
+ return true
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ export function snapshotSummary() {
99
+ const snap = readSnapshot()
100
+ if (!snap) return { present: false }
101
+ return {
102
+ present: true,
103
+ adopted: snap.adopted === true,
104
+ path: snapshotPath(),
105
+ python: snap.python || snap.pythonCommand || '',
106
+ pythonSourceLabel: pythonSourceLabel(snap.pythonSource),
107
+ installedAt: snap.installedAt || '',
108
+ added: Array.isArray(snap.added) ? snap.added : [],
109
+ keep: Array.isArray(snap.keep) ? snap.keep : []
110
+ }
111
+ }
112
+
113
+ /** Display form of a candidate, e.g. `py -3` — also its identity in the API. */
114
+ export function commandOf(cand) {
115
+ const prefix = cand && Array.isArray(cand.prefix) ? cand.prefix : []
116
+ return [cand.cmd, ...prefix].join(' ')
117
+ }
118
+
119
+ async function pipFreeze(cand, options = {}) {
120
+ const r = await run(cand.cmd, [...(cand.prefix || []), '-m', 'pip', 'freeze'], {
121
+ timeoutMs: options.timeoutMs || PROBE_TIMEOUT,
122
+ signal: options.signal
123
+ })
124
+ if (!r.ok) return null
125
+ const names = []
126
+ for (const line of String(r.stdout || '').replace(/\r/g, '').split('\n')) {
127
+ const t = line.trim()
128
+ if (!t || t.startsWith('#') || t.startsWith('-')) continue
129
+ const at = t.indexOf('==')
130
+ names.push(at > 0 ? t.slice(0, at) : t)
131
+ }
132
+ return names
133
+ }
134
+
135
+ /**
136
+ * Inspect ONE Python candidate: does it run, does it have pip, does it already
137
+ * carry markitdown. Never throws — a broken candidate comes back with `error`.
138
+ */
139
+ async function inspectCandidateSteps(cand, options = {}) {
140
+ const out = {
141
+ command: commandOf(cand),
142
+ source: cand.source,
143
+ sourceLabel: pythonSourceLabel(cand.source),
144
+ usable: false,
145
+ hasPip: false,
146
+ hasMarkitdown: false,
147
+ markitdownVersion: '',
148
+ pythonVersion: '',
149
+ executable: '',
150
+ pending: false,
151
+ error: ''
152
+ }
153
+ const prefix = cand.prefix || []
154
+ const timeoutMs = options.timeoutMs || PROBE_SPAWN_TIMEOUT
155
+
156
+ const ver = await run(cand.cmd, [...prefix, '--version'], { timeoutMs, signal: options.signal })
157
+ if (!ver.ok) {
158
+ out.error = firstLine(ver.stderr) || firstLine(ver.stdout) || '无法执行'
159
+ return out
160
+ }
161
+ out.usable = true
162
+ out.pythonVersion = firstLine(ver.stdout) || firstLine(ver.stderr)
163
+
164
+ const exe = await run(cand.cmd, [...prefix, '-c', 'import sys; print(sys.executable)'], { timeoutMs, signal: options.signal })
165
+ if (exe.ok) out.executable = firstLine(exe.stdout)
166
+
167
+ const pip = await run(cand.cmd, [...prefix, '-m', 'pip', '--version'], { timeoutMs, signal: options.signal })
168
+ out.hasPip = !!pip.ok
169
+ if (!out.hasPip) {
170
+ out.error = firstLine(pip.stderr) || '没有 pip'
171
+ return out
172
+ }
173
+
174
+ const mkd = await run(cand.cmd, [...prefix, '-m', 'markitdown', '--help'], { timeoutMs, signal: options.signal })
175
+ out.hasMarkitdown = !!mkd.ok
176
+ if (out.hasMarkitdown) {
177
+ const show = await run(cand.cmd, [...prefix, '-m', 'pip', 'show', 'markitdown'], { timeoutMs, signal: options.signal })
178
+ const m = String(show.stdout || '').match(/^Version:\s*(\S+)/m)
179
+ out.markitdownVersion = m ? m[1] : ''
180
+ } else {
181
+ out.error = firstLine(mkd.stderr) || '未安装 markitdown'
182
+ }
183
+ return out
184
+ }
185
+
186
+ /** A candidate that never answered, described well enough for the page. */
187
+ function pendingCandidate(cand, note) {
188
+ return {
189
+ command: commandOf(cand),
190
+ source: cand.source,
191
+ sourceLabel: pythonSourceLabel(cand.source),
192
+ usable: false,
193
+ hasPip: false,
194
+ hasMarkitdown: false,
195
+ markitdownVersion: '',
196
+ pythonVersion: '',
197
+ executable: '',
198
+ pending: true,
199
+ error: note
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Inspect ONE Python candidate: does it run, does it have pip, does it already
205
+ * carry markitdown. Never throws and never hangs: a broken candidate comes back
206
+ * with `error`, a stuck one with `pending` and `error`.
207
+ */
208
+ export async function inspectCandidate(cand, options = {}) {
209
+ const budget = Number(options.budgetMs) || PROBE_CANDIDATE_BUDGET
210
+ const steps = inspectCandidateSteps(cand, options)
211
+ /* Keep the steps alive so an abandoned probe cannot surface as an unhandled
212
+ * rejection once the budget has already answered. */
213
+ steps.catch(() => {})
214
+ return withBudget(steps, budget, () => pendingCandidate(cand, '探测超时(' + Math.round(budget / 1000) + 's 未响应),可点「重新检查」再试。'))
215
+ }
216
+
217
+ /** Key that identifies "the same set of candidates in the same order". */
218
+ function probeCacheKey(cfg) {
219
+ const commands = pythonCandidates(cfg).map((cand) => commandOf(cand))
220
+ return String((cfg && cfg.pythonPrefer) || 'auto') + '|' + commands.join(',')
221
+ }
222
+
223
+ let probeCache = null
224
+
225
+ /**
226
+ * Drop the cached probe result, so the next inspection really re-runs.
227
+ * Used when the interpreter list itself changed (install / uninstall).
228
+ */
229
+ export function invalidateProbeCache() {
230
+ probeCache = null
231
+ }
232
+
233
+ /**
234
+ * Inspect every candidate.
235
+ *
236
+ * Candidates are probed in parallel — serially, one stuck interpreter used to
237
+ * hold the whole settings page hostage — while each candidate's own five
238
+ * process spawns stay sequential, because they depend on each other. The result
239
+ * is cached for `cfg.probeTtlMs`, so opening the settings page twice does not
240
+ * spawn twenty processes twice.
241
+ */
242
+ export async function inspectEnvironments(cfg, options = {}) {
243
+ const ttl = Math.max(0, Number((cfg && cfg.probeTtlMs) || DEFAULT_PROBE_TTL_MS))
244
+ const key = probeCacheKey(cfg)
245
+ if (!options.force && ttl > 0 && probeCache && probeCache.key === key && Date.now() - probeCache.at < ttl) {
246
+ return { ...probeCache.value, cached: true, budgetMs: 0 }
247
+ }
248
+
249
+ const candidates = pythonCandidates(cfg)
250
+ const settled = await Promise.allSettled(
251
+ candidates.map((cand) => inspectCandidate(cand, options))
252
+ )
253
+ const list = settled.map((entry, i) => {
254
+ if (entry.status === 'fulfilled' && entry.value) return entry.value
255
+ const reason = entry.status === 'rejected' ? entry.reason : null
256
+ return pendingCandidate(
257
+ candidates[i],
258
+ '探测失败:' + (firstLine(String((reason && reason.message) || reason)) || '未知原因')
259
+ )
260
+ })
261
+
262
+ const active = list.find((e) => e.hasMarkitdown) || null
263
+ const installable = list.find((e) => e.usable && e.hasPip) || null
264
+ const value = {
265
+ pythonPrefer: String((cfg && cfg.pythonPrefer) || 'auto'),
266
+ candidates: list,
267
+ active: active ? active.command : '',
268
+ installTarget: installable ? installable.command : '',
269
+ snapshot: snapshotSummary()
270
+ }
271
+ probeCache = { key, at: Date.now(), value }
272
+ return { ...value, cached: false, budgetMs: PROBE_CANDIDATE_BUDGET }
273
+ }
274
+
275
+ /** The interpreter a fresh install would go into (first usable one with pip). */
276
+ export function pickInstallTarget(cfg) {
277
+ for (const cand of pythonCandidates(cfg)) {
278
+ const command = commandOf(cand)
279
+ if (command) return cand
280
+ }
281
+ return null
282
+ }
283
+
284
+ /* ------------------------------------------------------------------ */
285
+ /* recording an environment that was already there */
286
+ /* ------------------------------------------------------------------ */
287
+
288
+ /** PEP 503 normalised name: lowercase, runs of `-_.` collapse into one `-`. */
289
+ export function normName(name) {
290
+ return String(name || '').trim().toLowerCase().replace(/[-_.]+/g, '-')
291
+ }
292
+
293
+ /**
294
+ * Packages that belong to the interpreter rather than to us.
295
+ *
296
+ * A DSH bundled runtime ships a `runtime.json` that lists exactly what the
297
+ * runtime is expected to contain (numpy, pandas, python-docx, Pillow, lxml …).
298
+ * Those are shared with other DSH features, so they must never end up in the
299
+ * removal list even when markitdown happens to depend on them.
300
+ */
301
+ export function protectedPackages(cand, executable) {
302
+ const names = new Set(['pip', 'setuptools', 'wheel'])
303
+ const abs = String(executable || (cand && cand.cmd) || '')
304
+ if (abs) {
305
+ let dir = path.dirname(abs)
306
+ for (let i = 0; i < 4 && dir && dir !== path.dirname(dir); i += 1) {
307
+ const manifest = path.join(dir, 'runtime.json')
308
+ try {
309
+ if (fs.existsSync(manifest)) {
310
+ const obj = JSON.parse(fs.readFileSync(manifest, 'utf8').replace(/^\uFEFF/, ''))
311
+ const pkgs = obj && obj.pythonPackages
312
+ if (pkgs && typeof pkgs === 'object') {
313
+ for (const key of Object.keys(pkgs)) names.add(normName(key))
314
+ }
315
+ }
316
+ } catch { /* unreadable manifest — keep walking up */ }
317
+ dir = path.dirname(dir)
318
+ }
319
+ }
320
+ return [...names]
321
+ }
322
+
323
+ /**
324
+ * Python program that answers: "if markitdown were removed, which installed
325
+ * distributions would nobody else still need?" It walks the dependency closure
326
+ * of markitdown, then keeps back anything the runtime manifest protects or
327
+ * anything an installed package outside the closure still requires.
328
+ */
329
+ const REMOVABLE_SCRIPT = String.raw`
330
+ import json, re, sys
331
+ from importlib import metadata as md
332
+
333
+ payload = json.loads(sys.argv[1])
334
+
335
+ def norm(n):
336
+ return re.sub(r"[-_.]+", "-", str(n)).lower()
337
+
338
+ def rname(s):
339
+ m = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", str(s))
340
+ return norm(m.group(1)) if m else ""
341
+
342
+ roots = [norm(r) for r in payload.get("roots", ["markitdown"])]
343
+ protected = set(norm(p) for p in payload.get("protected", []))
344
+
345
+ dists = {}
346
+ for d in md.distributions():
347
+ try:
348
+ nm = d.metadata["Name"]
349
+ except Exception:
350
+ nm = None
351
+ if nm and norm(nm) not in dists:
352
+ dists[norm(nm)] = d
353
+
354
+ requires = {}
355
+ for n, d in dists.items():
356
+ kids = []
357
+ try:
358
+ raw = list(d.requires or [])
359
+ except Exception:
360
+ raw = []
361
+ for r in raw:
362
+ k = rname(r)
363
+ if k and k not in kids:
364
+ kids.append(k)
365
+ requires[n] = kids
366
+
367
+ required_by = {}
368
+ for n, kids in requires.items():
369
+ for k in kids:
370
+ required_by.setdefault(k, set()).add(n)
371
+
372
+ closure = set()
373
+ stack = [r for r in roots if r in dists]
374
+ while stack:
375
+ n = stack.pop()
376
+ if n in closure:
377
+ continue
378
+ closure.add(n)
379
+ for k in requires.get(n, []):
380
+ if k not in closure and k in dists:
381
+ stack.append(k)
382
+
383
+ removable, keep = [], []
384
+ for n in sorted(closure):
385
+ if n in protected:
386
+ keep.append({"name": n, "reason": "\u8fd0\u884c\u65f6\u81ea\u5e26\uff0c\u4e0d\u6e05\u7406"})
387
+ continue
388
+ outside = sorted(x for x in required_by.get(n, set()) if x not in closure)
389
+ if outside:
390
+ keep.append({"name": n, "reason": "\u88ab\u5176\u5b83\u7ec4\u4ef6\u4f9d\u8d56\uff1a" + ", ".join(outside[:4])})
391
+ else:
392
+ removable.append(n)
393
+
394
+ print(json.dumps({
395
+ "ok": True,
396
+ "roots": roots,
397
+ "missingRoots": [r for r in roots if r not in dists],
398
+ "closure": sorted(closure),
399
+ "removable": removable,
400
+ "keep": keep,
401
+ }))
402
+ `
403
+
404
+ /** Run {@link REMOVABLE_SCRIPT} against one interpreter. */
405
+ async function computeRemovableSet(cand, options = {}) {
406
+ const prefix = cand.prefix || []
407
+ const exe = await run(cand.cmd, [...prefix, '-c', 'import sys; print(sys.executable)'], {
408
+ timeoutMs: PROBE_TIMEOUT,
409
+ signal: options.signal
410
+ })
411
+ const executable = firstLine(exe.stdout) || ''
412
+
413
+ const payload = JSON.stringify({
414
+ roots: ['markitdown'],
415
+ protected: protectedPackages(cand, executable)
416
+ })
417
+ const r = await run(cand.cmd, [...prefix, '-c', REMOVABLE_SCRIPT, payload], {
418
+ timeoutMs: options.timeoutMs || PROBE_TIMEOUT * 3,
419
+ signal: options.signal
420
+ })
421
+ if (!r.ok) {
422
+ return {
423
+ ok: false,
424
+ executable,
425
+ error: firstLine(r.stderr) || '无法计算依赖关系(退出码 ' + r.code + ')'
426
+ }
427
+ }
428
+ try {
429
+ const line = String(r.stdout || '').trim().split('\n').pop()
430
+ const data = JSON.parse(line)
431
+ return { ok: true, executable, data }
432
+ } catch (error) {
433
+ return {
434
+ ok: false,
435
+ executable,
436
+ error: '无法解析依赖计算结果:' + firstLine(String((error && error.message) || error))
437
+ }
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Record an interpreter that already carries markitdown but has no snapshot, so
443
+ * that removing the plugin can also take those packages back out.
444
+ *
445
+ * This is what the settings page does when the user presses "one-click setup"
446
+ * on an interpreter that already has markitdown: nothing is installed, only a
447
+ * removal manifest is written. `adopted: true` marks it as such.
448
+ *
449
+ * @returns {Promise<{ok: boolean, adopted?: boolean, target: string, added?: string[], keep?: Array<object>, error?: string}>}
450
+ */
451
+ export async function adoptMarkitdown(cand, options = {}) {
452
+ const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}
453
+ const calc = await computeRemovableSet(cand, options)
454
+ if (!calc.ok) return { ok: false, target: commandOf(cand), error: calc.error }
455
+
456
+ const removable = calc.data.removable || []
457
+ const keep = calc.data.keep || []
458
+ if (!removable.length) {
459
+ return {
460
+ ok: false,
461
+ target: commandOf(cand),
462
+ error: '没能算出可以安全卸载的包(可能 markitdown 的依赖全部由运行时提供),未写入环境记录。'
463
+ }
464
+ }
465
+
466
+ const snapPath = writeSnapshot({
467
+ version: 1,
468
+ adopted: true,
469
+ packageName: 'dsh-plugin-office-markdown',
470
+ requirement: MARKITDOWN_REQUIREMENT,
471
+ python: calc.executable || commandOf(cand),
472
+ pythonCommand: commandOf(cand),
473
+ pythonSource: cand.source,
474
+ installedAt: new Date().toISOString(),
475
+ added: removable,
476
+ keep,
477
+ closure: calc.data.closure || [],
478
+ before: [],
479
+ after: []
480
+ })
481
+
482
+ onLog('已登记现有 MarkItDown 环境:' + removable.length + ' 个包可在卸载插件时一并移除,'
483
+ + keep.length + ' 个包因被其它组件共用而保留。')
484
+ return {
485
+ ok: true,
486
+ adopted: true,
487
+ target: commandOf(cand),
488
+ executable: calc.executable,
489
+ added: removable,
490
+ keep,
491
+ snapshotPath: snapPath
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Install markitdown into `cand` and snapshot the delta.
497
+ * Returns `{ ok, added, target, output, error }`.
498
+ */
499
+ export async function installMarkitdown(cfg, cand, options = {}) {
500
+ const prefix = cand.prefix || []
501
+ const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}
502
+ const timeoutMs = options.timeoutMs || INSTALL_TIMEOUT
503
+
504
+ onLog('检查 ' + commandOf(cand) + ' 的现有包清单…')
505
+ const before = await pipFreeze(cand, { timeoutMs: PROBE_TIMEOUT, signal: options.signal })
506
+ if (before === null) {
507
+ return { ok: false, target: commandOf(cand), added: [], error: '无法执行 pip freeze(该解释器可能没有 pip)' }
508
+ }
509
+
510
+ onLog('正在安装 ' + MARKITDOWN_REQUIREMENT + '(约 90 MB,需要联网,可能需要几分钟)…')
511
+ const r = await run(
512
+ cand.cmd,
513
+ [...prefix, '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '--progress-bar', 'off', MARKITDOWN_REQUIREMENT],
514
+ { timeoutMs, signal: options.signal }
515
+ )
516
+ if (!r.ok) {
517
+ return {
518
+ ok: false,
519
+ target: commandOf(cand),
520
+ added: [],
521
+ error: (r.timedOut ? '安装超时。\n' : '') + (tailLines(r.stderr, 8) || tailLines(r.stdout, 8) || 'pip 退出码 ' + r.code)
522
+ }
523
+ }
524
+
525
+ const after = await pipFreeze(cand, { timeoutMs: PROBE_TIMEOUT, signal: options.signal })
526
+ const beforeSet = new Set(before)
527
+ const added = (after || []).filter((n) => !beforeSet.has(n))
528
+
529
+ const exe = await run(cand.cmd, [...prefix, '-c', 'import sys; print(sys.executable)'], { timeoutMs: PROBE_TIMEOUT, signal: options.signal })
530
+ const executable = firstLine(exe.stdout) || ''
531
+
532
+ const verified = await run(cand.cmd, [...prefix, '-m', 'markitdown', '--help'], { timeoutMs: PROBE_TIMEOUT, signal: options.signal })
533
+ if (!verified.ok) {
534
+ return { ok: false, target: commandOf(cand), added, error: '安装命令成功,但随后无法运行 markitdown:' + firstLine(verified.stderr) }
535
+ }
536
+
537
+ const snapPath = writeSnapshot({
538
+ version: 1,
539
+ adopted: false,
540
+ packageName: 'dsh-plugin-office-markdown',
541
+ requirement: MARKITDOWN_REQUIREMENT,
542
+ python: executable || commandOf(cand),
543
+ pythonCommand: commandOf(cand),
544
+ pythonSource: cand.source,
545
+ installedAt: new Date().toISOString(),
546
+ added,
547
+ keep: [],
548
+ before,
549
+ after: after || []
550
+ })
551
+
552
+ onLog('已安装 ' + added.length + ' 个新包,环境快照写入 ' + snapPath)
553
+ return { ok: true, target: commandOf(cand), executable, added, snapshotPath: snapPath, output: tailLines(r.stdout, 4) }
554
+ }
555
+
556
+ /**
557
+ * Uninstall exactly what the snapshot recorded. No snapshot (or an empty
558
+ * delta) means the user installed markitdown themselves — nothing is removed.
559
+ */
560
+ export async function uninstallMarkitdown(options = {}) {
561
+ const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}
562
+ const snap = readSnapshot()
563
+ if (!snap) {
564
+ return { ok: true, skipped: true, reason: '没有环境快照:markitdown 不是由本插件安装或登记的,不会卸载任何东西。' }
565
+ }
566
+ const adopted = snap.adopted === true
567
+ const added = (Array.isArray(snap.added) ? snap.added : []).filter((n) => typeof n === 'string' && n)
568
+ if (added.length === 0) {
569
+ removeSnapshot()
570
+ return { ok: true, skipped: true, reason: '快照里没有可移除的包,已删除快照。', snapshotPath: snapshotPath() }
571
+ }
572
+ const python = snap.python || snap.pythonCommand
573
+ if (!python) {
574
+ return { ok: false, adopted, error: '快照里没有记录 Python 解释器路径,无法自动卸载。', added }
575
+ }
576
+
577
+ onLog((adopted ? '正在移除本插件登记过的 ' : '正在从 ') + python + ' 卸载 ' + added.length + ' 个包…')
578
+ const r = await run(python, ['-m', 'pip', 'uninstall', '-y', '--disable-pip-version-check', ...added], {
579
+ timeoutMs: options.timeoutMs || INSTALL_TIMEOUT,
580
+ signal: options.signal
581
+ })
582
+
583
+ const interesting = String((r.stdout || '') + '\n' + (r.stderr || ''))
584
+ .replace(/\r/g, '')
585
+ .split('\n')
586
+ .filter((l) => /Successfully uninstalled|not installed|^ERROR|Skipping/i.test(l))
587
+ .map((l) => l.trim())
588
+ .slice(0, 40)
589
+
590
+ if (r.ok) {
591
+ removeSnapshot()
592
+ return { ok: true, adopted, removed: added, python, output: interesting, snapshotPath: snapshotPath() }
593
+ }
594
+ return {
595
+ ok: false,
596
+ adopted,
597
+ added,
598
+ python,
599
+ output: interesting,
600
+ error: (r.timedOut ? '卸载超时。\n' : '') + (tailLines(r.stderr, 6) || 'pip 退出码 ' + r.code)
601
+ }
602
+ }