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/paths.js ADDED
@@ -0,0 +1,76 @@
1
+ /*!
2
+ * dsh-plugin-office-markdown — resolving DSH's own directories (host half)
3
+ *
4
+ * The paths this plugin shares with the host used to be spelled out as
5
+ * `path.join(os.homedir(), '.dsh')` in four separate files. That is right on a
6
+ * stock install and wrong the moment DSH keeps its data somewhere else, so the
7
+ * resolution lives here once, for `env.js`, `convert.js` and `index.js` alike.
8
+ *
9
+ * Order of authority:
10
+ *
11
+ * 1. `setProfileDir()` — the profile directory the host itself reports from
12
+ * `apply(ctx)`. Always correct, and the only source that survives a
13
+ * non-default layout.
14
+ * 2. `DSH_HOME` — the variable the host exports to the processes it spawns.
15
+ * 3. `~/.dsh` — the host's own default, used when nothing else is known.
16
+ */
17
+ import os from 'node:os'
18
+ import path from 'node:path'
19
+
20
+ /** Basename of the removal watchdog's log inside the DSH home. */
21
+ export const REMOVAL_LOG_BASENAME = 'dsh-plugin-office-markdown-removal.log'
22
+
23
+ /** Basename of the single-instance lock that caps the watchdog at one process. */
24
+ export const WATCHDOG_LOCK_BASENAME = 'dsh-plugin-office-markdown-watchdog.lock'
25
+
26
+ let profileDirOverride = ''
27
+
28
+ /**
29
+ * Remember the profile directory the host is running from.
30
+ *
31
+ * Called once from `apply(ctx)`; a no-op for anything that is not a plain
32
+ * non-empty string, so a surprising host value can never break path resolution.
33
+ */
34
+ export function setProfileDir(dir) {
35
+ if (typeof dir !== 'string') return
36
+ const trimmed = dir.trim()
37
+ if (trimmed === '') return
38
+ profileDirOverride = path.resolve(trimmed)
39
+ }
40
+
41
+ /** The profile directory in use, or `''` when the host never told us. */
42
+ export function currentProfileDir() {
43
+ if (profileDirOverride !== '') return profileDirOverride
44
+ const fromEnv = typeof process.env.DSH_PROFILE_DIR === 'string' ? process.env.DSH_PROFILE_DIR.trim() : ''
45
+ return fromEnv === '' ? '' : path.resolve(fromEnv)
46
+ }
47
+
48
+ /** DSH's data directory — `~/.dsh` unless the host says otherwise. */
49
+ export function dshHome() {
50
+ const profileDir = currentProfileDir()
51
+ if (profileDir !== '') return path.dirname(path.dirname(profileDir))
52
+ const fromEnv = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
53
+ return fromEnv === '' ? path.join(os.homedir(), '.dsh') : path.resolve(fromEnv)
54
+ }
55
+
56
+ /** `<DSH home>/profiles` — the directory every profile lives in. */
57
+ export function profilesRoot() {
58
+ const profileDir = currentProfileDir()
59
+ if (profileDir !== '') return path.dirname(profileDir)
60
+ return path.join(dshHome(), 'profiles')
61
+ }
62
+
63
+ /** Where the removal watchdog appends its log. */
64
+ export function removalLogPath() {
65
+ return path.join(dshHome(), REMOVAL_LOG_BASENAME)
66
+ }
67
+
68
+ /** The single-instance lock that keeps at most one removal watchdog alive. */
69
+ export function watchdogLockPath() {
70
+ return path.join(dshHome(), WATCHDOG_LOCK_BASENAME)
71
+ }
72
+
73
+ /** `<DSH home>/dsh-runtimes` — the host's own packaged runtimes. */
74
+ export function runtimesRoot() {
75
+ return path.join(dshHome(), 'dsh-runtimes')
76
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * 卸载看门狗:把「一并清掉本插件带来的 Python 环境」这件事交给一个**脱离
3
+ * DSH 宿主**的进程去做。
4
+ *
5
+ * 为什么要脱离宿主:`@deepseek-ai/dsh-plugin-manager` 的 `removeBundle()`
6
+ * 顺序是 `selectBundle(name, false)` → `reload()` → `pnpm remove <name>`
7
+ * (index.js:1844-1863)。也就是说本插件的 fiber 会先被销毁,几秒之后包目录
8
+ * 才被 pnpm 删掉;在这中间用户完全可能顺手重启一次 harness,一旦宿主退出,
9
+ * 宿主内的定时器就全部消失,环境就永远清不掉了。
10
+ *
11
+ * 所以这里在 dispose 时写一个自包含的 Python 脚本到临时目录,用派生的
12
+ * headless 解释器进程执行它:该进程在自己的父进程退出后依然存活,轮询到
13
+ * 「包目录已消失 **且** 没有任何 profile 配置再引用本插件」时才动手,最后
14
+ * 把环境记录和它自己的临时文件一起删掉。
15
+ *
16
+ * 判定条件与 `lib/index.js` 的 `removalConfirmed()` 刻意保持一致,宁可漏清
17
+ * 也不能误清:关闭插件、停用插件、重载 profile、退出 DSH 都只是 dispose,
18
+ * 都不是卸载。
19
+ *
20
+ * 派发时机由 `lib/index.js` 的 `scheduleRemovalCleanup()` 决定:它只在 dispose
21
+ * 那一刻发现本插件已经从 profile 的 `dsh.profile.bundles` 里被摘掉时才起这个
22
+ * 进程。正常关闭插件、正常重启 DSH 时 bundles 里仍然有本插件,因此根本不会
23
+ * 派发,平时不会有任何常驻进程。
24
+ *
25
+ * 即便真的派发了,也会用 `lockPath` 抢一个单实例锁:同一时间最多只有一个看门狗
26
+ * 在跑,连着重启几次也不会把同一个环境卸载好几遍。
27
+ *
28
+ * 日志(`logPath`)只在**没清干净**的时候才需要:清理成功的那一刻,它就从事后排查
29
+ * 的证据变成了留在用户 `~/.dsh` 里的垃圾,而本插件被卸载之后,这台机器上再没有
30
+ * 任何代码会来收拾它。所以成功路径把日志压成一行摘要,失败 / 超时 / 未确认则保留
31
+ * 完整转录。
32
+ */
33
+ import { spawn } from 'node:child_process'
34
+ import fs from 'node:fs'
35
+ import os from 'node:os'
36
+ import path from 'node:path'
37
+
38
+ /** 看门狗本体。写成 Python 是因为要执行的正是 `pip uninstall`,不必再借道 node。 */
39
+ const WATCHDOG_PY = String.raw`# -*- coding: utf-8 -*-
40
+ """卸载看门狗:等 DSH 真正把 dsh-plugin-office-markdown 移除后,卸载它带来的 Python 包。"""
41
+ import datetime
42
+ import json
43
+ import os
44
+ import subprocess
45
+ import sys
46
+ import time
47
+
48
+ cfg_path = sys.argv[1]
49
+ with open(cfg_path, "r", encoding="utf-8-sig") as fh:
50
+ cfg = json.load(fh)
51
+
52
+ LOG = cfg.get("logPath") or ""
53
+ MAX_LOG_BYTES = int(cfg.get("maxLogBytes") or 65536)
54
+ KEEP_LOG_BYTES = int(cfg.get("keepLogBytes") or 16384)
55
+
56
+
57
+ def _rotate_log():
58
+ """Keep the log bounded.
59
+
60
+ It is opened in append mode and lives outside the plugin package (which is
61
+ deleted on uninstall), so a machine that installs / uninstalls this plugin
62
+ repeatedly would otherwise accumulate one full pip transcript per removal,
63
+ forever. Trim from the front, on a record boundary, before writing anything.
64
+ """
65
+ if not LOG:
66
+ return
67
+ try:
68
+ size = os.path.getsize(LOG)
69
+ except OSError:
70
+ return
71
+ if size <= MAX_LOG_BYTES:
72
+ return
73
+ try:
74
+ with open(LOG, "rb") as fh:
75
+ fh.seek(max(0, size - KEEP_LOG_BYTES))
76
+ tail = fh.read()
77
+ except OSError:
78
+ return
79
+ cut = tail.find(b"\n") # never leave a half-written first line
80
+ if cut >= 0:
81
+ tail = tail[cut + 1:]
82
+ header = ("[%s] (更早的卸载记录已省略,仅保留最近约 %d KB)\n"
83
+ % (datetime.datetime.now().isoformat(timespec="seconds"),
84
+ KEEP_LOG_BYTES // 1024)).encode("utf-8")
85
+ try:
86
+ with open(LOG, "wb") as fh:
87
+ fh.write(header + tail)
88
+ except OSError:
89
+ pass
90
+
91
+
92
+ _rotate_log()
93
+
94
+
95
+ def log(message):
96
+ line = "[%s] %s" % (datetime.datetime.now().isoformat(timespec="seconds"), message)
97
+ if not LOG:
98
+ return
99
+ try:
100
+ with open(LOG, "a", encoding="utf-8") as fh:
101
+ fh.write(line + "\n")
102
+ except OSError:
103
+ pass
104
+
105
+
106
+ def gone():
107
+ """包目录已经不在,并且没有任何 profile 配置还引用本插件。"""
108
+ marker = os.path.join(cfg["packageRoot"], "lib", "index.js")
109
+ try:
110
+ if os.path.exists(marker):
111
+ return False
112
+ except OSError:
113
+ return False
114
+ root = cfg["profilesRoot"]
115
+ try:
116
+ profiles = os.listdir(root)
117
+ except OSError:
118
+ return False
119
+ for profile in profiles:
120
+ for candidate in ("cordis.patch.yml", "cordis.patch.yaml", "cordis.yml", "package.json"):
121
+ target = os.path.join(root, profile, candidate)
122
+ try:
123
+ if not os.path.exists(target):
124
+ continue
125
+ with open(target, "r", encoding="utf-8", errors="replace") as fh:
126
+ if "office-markdown" in fh.read():
127
+ return False
128
+ except OSError:
129
+ return False
130
+ return True
131
+
132
+
133
+ def remove_self_files():
134
+ for item in cfg.get("selfFiles") or []:
135
+ try:
136
+ os.remove(item)
137
+ except OSError:
138
+ pass
139
+
140
+
141
+ def release_lock():
142
+ lock = cfg.get("lockPath") or ""
143
+ if not lock:
144
+ return
145
+ try:
146
+ os.remove(lock)
147
+ except OSError:
148
+ pass
149
+
150
+
151
+ def cleanup_self():
152
+ remove_self_files()
153
+ release_lock()
154
+
155
+
156
+ def compact_log(summary):
157
+ """成功卸载只留一行摘要。
158
+
159
+ 完整转录是给「没清干净」用的排查材料。清理既然成功了,它就从证据变成了
160
+ 插件留在用户 ~/.dsh 里的垃圾 —— 而本插件被卸载之后,这台机器上再没有任何
161
+ 本插件的代码会来收拾它。所以成功时压成一行,失败 / 超时则原样保留。
162
+ """
163
+ if not LOG:
164
+ return
165
+ stamp = datetime.datetime.now().isoformat(timespec="seconds")
166
+ text = ("[%s] %s\n"
167
+ "[%s] (本次卸载成功,只保留这一行摘要;失败或超时会保留完整转录)\n"
168
+ % (stamp, summary or "卸载完成", stamp))
169
+ try:
170
+ with open(LOG, "w", encoding="utf-8") as fh:
171
+ fh.write(text)
172
+ except OSError:
173
+ pass
174
+
175
+
176
+ def finish(success, summary):
177
+ """收尾:清掉自己的临时文件与锁;成功时顺便把日志压成一行。"""
178
+ cleanup_self()
179
+ if success:
180
+ compact_log(summary)
181
+ else:
182
+ log("卸载看门狗结束:清理未完成,完整记录保留供排查。")
183
+
184
+
185
+ def acquire_lock():
186
+ """同一时间只允许一个看门狗活着。
187
+
188
+ 每次 dispose(关闭插件、重载 profile、退出 DSH…)都可能派发一个新进程,
189
+ 所以这里用 O_CREAT|O_EXCL 抢一个锁文件:抢不到就说明已经有一个在跑,本进程
190
+ 立刻退出,免得同一个环境被卸载好几遍。上一个看门狗崩溃留下的过期锁会被
191
+ 忽略并接管。
192
+ """
193
+ lock = cfg.get("lockPath") or ""
194
+ if not lock:
195
+ return True
196
+ stale_after = float(cfg.get("timeoutSec") or 120) + 60
197
+ try:
198
+ handle = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
199
+ os.write(handle, ("%d\n" % os.getpid()).encode("utf-8"))
200
+ os.close(handle)
201
+ return True
202
+ except FileExistsError:
203
+ pass
204
+ except OSError:
205
+ return True
206
+ try:
207
+ age = time.time() - os.path.getmtime(lock)
208
+ except OSError:
209
+ return True
210
+ if age <= stale_after:
211
+ return False
212
+ try:
213
+ os.remove(lock)
214
+ handle = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
215
+ os.write(handle, ("%d\n" % os.getpid()).encode("utf-8"))
216
+ os.close(handle)
217
+ return True
218
+ except OSError:
219
+ return False
220
+
221
+
222
+ if not acquire_lock():
223
+ log("已有一个卸载看门狗在运行,本进程直接退出,不清理任何东西。")
224
+ remove_self_files()
225
+ sys.exit(0)
226
+
227
+ timeout = float(cfg.get("timeoutSec") or 120)
228
+ fast_window = float(cfg.get("pollFastWindowSec") or 30)
229
+ fast_poll = float(cfg.get("pollFastSec") or 2)
230
+ slow_poll = float(cfg.get("pollSlowSec") or 10)
231
+
232
+ started = time.time()
233
+ deadline = started + timeout
234
+ confirmed = False
235
+ while time.time() < deadline:
236
+ try:
237
+ if gone():
238
+ confirmed = True
239
+ break
240
+ except Exception as error: # noqa: BLE001 - 看门狗绝不能自己炸掉
241
+ log("判定卸载状态时出错,继续等待:%r" % (error,))
242
+ # 卸载通常在几秒内完成:前 30 秒查得密一点,之后就松下来省资源。
243
+ delay = fast_poll if (time.time() - started) < fast_window else slow_poll
244
+ remaining = deadline - time.time()
245
+ if remaining <= 0:
246
+ break
247
+ time.sleep(delay if delay < remaining else remaining)
248
+
249
+ if not confirmed:
250
+ log("等待 %d 秒仍未确认插件被移除(包目录或 profile 配置仍引用它),本次不清理任何东西。"
251
+ % int(timeout))
252
+ finish(False, "")
253
+ sys.exit(0)
254
+
255
+ log("已确认 dsh-plugin-office-markdown 从 profile 移除,开始处理 Python 环境。")
256
+
257
+ snapshot = cfg.get("snapshotPath") or ""
258
+ if not snapshot or not os.path.exists(snapshot):
259
+ finish(True, "无需清理:没有环境记录(markitdown 不是由本插件登记或安装的)")
260
+ sys.exit(0)
261
+
262
+ packages = [p for p in (cfg.get("packages") or []) if isinstance(p, str) and p]
263
+ python = cfg.get("python") or ""
264
+ ok = True # 只有「该做的事都做成了」才算成功;否则保留完整转录
265
+ summary = ""
266
+ if not packages:
267
+ log("环境记录里没有需要负责的包,直接删除记录。")
268
+ summary = "无需清理:环境记录里没有需要负责的包"
269
+ elif not python:
270
+ log("环境记录里没有解释器路径,无法自动卸载。请手工执行:")
271
+ log(" <python> -m pip uninstall -y " + " ".join(packages))
272
+ ok = False
273
+ else:
274
+ log("正在用 %s 卸载 %d 个包…" % (python, len(packages)))
275
+ try:
276
+ completed = subprocess.run(
277
+ [python, "-m", "pip", "uninstall", "-y", "--disable-pip-version-check"] + packages,
278
+ stdout=subprocess.PIPE,
279
+ stderr=subprocess.STDOUT,
280
+ timeout=float(cfg.get("pipTimeoutSec") or 1800),
281
+ )
282
+ output = (completed.stdout or b"").decode("utf-8", "replace")
283
+ for line in output.splitlines():
284
+ stripped = line.strip()
285
+ if ("Successfully uninstalled" in stripped or "not installed" in stripped
286
+ or stripped.startswith("ERROR") or stripped.startswith("Skipping")):
287
+ log(" " + stripped)
288
+ log("pip 退出码 %s。" % completed.returncode)
289
+ ok = completed.returncode == 0
290
+ summary = "卸载完成:清理 %d 个包,pip 退出码 %s" % (len(packages), completed.returncode)
291
+ except Exception as error: # noqa: BLE001 - 失败也要留下记录
292
+ log("调用 pip 失败:%r" % (error,))
293
+ ok = False
294
+
295
+ try:
296
+ os.remove(snapshot)
297
+ log("已删除环境记录 %s。" % snapshot)
298
+ except OSError as error:
299
+ log("删除环境记录失败:%r" % (error,))
300
+ ok = False
301
+
302
+ finish(ok, summary)
303
+ `
304
+
305
+ /**
306
+ * 为一个已登记的环境派发看门狗进程。
307
+ *
308
+ * @param {object} options
309
+ * @param {string} options.packageRoot 本插件包目录(被 pnpm 删掉的那个)。
310
+ * @param {string} options.profilesRoot `<DSH home>/profiles`。
311
+ * @param {string} options.snapshotPath 环境记录文件。
312
+ * @param {string} options.lockPath 单实例锁文件,保证同时只有一个看门狗在跑。
313
+ * @param {string} options.python 记录里的解释器绝对路径。
314
+ * @param {string[]} options.packages 记录里由本插件负责的包。
315
+ * @param {string} options.logPath 看门狗的日志文件。
316
+ * @param {number} [options.timeoutSec] 等待卸载确认的上限,默认 120 秒。
317
+ * @returns {{pid: number|undefined, scriptPath: string, configPath: string, logPath: string}}
318
+ */
319
+ export function spawnRemovalWatchdog(options) {
320
+ const stamp = Date.now() + '-' + process.pid + '-' + Math.random().toString(16).slice(2, 8)
321
+ const base = path.join(os.tmpdir(), 'dsh-om-removal-' + stamp)
322
+ const scriptPath = base + '.py'
323
+ const configPath = base + '.json'
324
+ const logPath = options.logPath
325
+
326
+ const config = {
327
+ packageRoot: options.packageRoot,
328
+ profilesRoot: options.profilesRoot,
329
+ snapshotPath: options.snapshotPath,
330
+ lockPath: options.lockPath,
331
+ python: options.python,
332
+ packages: options.packages,
333
+ logPath,
334
+ maxLogBytes: options.maxLogBytes ?? 65536,
335
+ keepLogBytes: options.keepLogBytes ?? 16384,
336
+ timeoutSec: options.timeoutSec ?? 120,
337
+ pollFastSec: 2,
338
+ pollFastWindowSec: 30,
339
+ pollSlowSec: 10,
340
+ pipTimeoutSec: 1800,
341
+ selfFiles: [scriptPath, configPath]
342
+ }
343
+
344
+ fs.writeFileSync(scriptPath, WATCHDOG_PY, 'utf8')
345
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8')
346
+
347
+ const child = spawn(options.python, [scriptPath, configPath], {
348
+ detached: true,
349
+ stdio: 'ignore',
350
+ windowsHide: true
351
+ })
352
+ // A recorded interpreter can be gone by the time we get here (the user may
353
+ // have deleted that Python). Without this listener the `error` event would be
354
+ // unhandled and would take the host process down with it.
355
+ child.on('error', () => {})
356
+ child.unref()
357
+
358
+ return { pid: child.pid, scriptPath, configPath, logPath }
359
+ }