dsh-global-todo 0.12.4 → 0.12.6

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/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [0.12.6] — 2026-10-21
2
+
3
+ Saving on Windows never worked. Fixed, with the root cause written down (#2).
4
+
5
+ - **`open(temp, 'r')` → opened for writing.** The atomic write reopened its temp file read-only
6
+ and called `handle.sync()`. On Windows that is `FlushFileBuffers`, which requires a writable
7
+ handle, so **every** save threw `EPERM` — and because the sync sat before `rename()`, the
8
+ target file was never replaced: the list looked empty while `*.tmp` debris accumulated. POSIX
9
+ allows `fsync` on a read-only descriptor, which is why Linux and macOS never showed it.
10
+ - **The sync is best effort now.** A platform that refuses to flush the temp file gets a warning,
11
+ and the rename still happens: losing a save is worse than a save that only reached the page
12
+ cache. The temp file is unlinked when a write fails, so a failure leaves no debris.
13
+ - **The write path is a function with tests.** `atomicWrite()` takes an injectable fs surface; four
14
+ tests pin the invariant that failed: the handle must be opened for writing, a refused sync must
15
+ not lose the save, a failed write must clean up, and the payload must reach the file first.
16
+
17
+ ## [0.12.5] — 2026-10-21
18
+
19
+ A check for the one install mistake that is invisible until the desktop complains.
20
+
21
+ - **`dsh-global-todo-doctor`** (`npx dsh-global-todo-doctor <dir>`, also `npm run doctor`): inspects
22
+ a directory that is supposed to contain the plugin — manifest, `dsh.bundle.patch`, host and
23
+ client entries, `locale/`, `docs/icon.svg`, `lib/` — and exits non-zero when something required
24
+ is missing. The case it exists for: a hand-copied `plugins-local` directory without `locale/`
25
+ still loads, while the desktop app prints `Plugin metadata … ENOENT … \locale`, which points at
26
+ the symptom and not the cause.
27
+ - **Six tests** drive the checker against real temporary directories: complete copy, missing
28
+ `locale/`, missing client entry, missing patch file, missing icon (warning only), and a directory
29
+ that is not the plugin at all.
30
+ - FAQ §15 now starts with the command instead of guessing, and both READMEs document it where a
31
+ hand-copied install would go wrong.
32
+
1
33
  ## [0.12.4] — 2026-10-21
2
34
 
3
35
  The repository leaked its own location. Three real paths, and a guard that could not see them.
package/README.md CHANGED
@@ -182,9 +182,19 @@ The plugin is a Loader row, not a hot-loaded script: **restart the host** (`dsh
182
182
  after installing, then refresh the page. A "待办 / Todos" entry appears in the sidebar.
183
183
 
184
184
  The npm package is the source of truth. A community catalog can lag behind it or point at an
185
- older release; pin the version you want (`dsh plugin --profile web add dsh-global-todo@0.10.0`)
185
+ older release; pin the version you want (`dsh plugin --profile web add dsh-global-todo@0.12.4`)
186
186
  when it matters.
187
187
 
188
+ **Hand-copied a directory instead?** Check it before wondering why the plugins page looks wrong:
189
+
190
+ ```sh
191
+ npx dsh-global-todo-doctor "$HOME/.dsh/plugins-local/全局待办插件"
192
+ ```
193
+
194
+ It verifies the manifest, the patch file, both entries, `locale/`, the icon and `lib/`, and exits
195
+ non-zero when something required is missing — the `locale/` case is the field failure that makes
196
+ the desktop app print `Plugin metadata … ENOENT … \locale` while the plugin still loads.
197
+
188
198
  To roll back:
189
199
 
190
200
  ```sh
package/README.zh.md CHANGED
@@ -162,6 +162,17 @@ dsh plugin --profile web add /绝对路径/dsh-global-todo
162
162
  插件是宿主组合里的**一行 Loader**,不是热加载脚本:**装完要重启宿主**(`dsh web`),
163
163
  再刷新页面。侧栏会出现「待办」入口。
164
164
 
165
+ **如果你是手动拷贝目录(而不是从 npm 装)**,先自检一遍,别靠猜:
166
+
167
+ ```sh
168
+ npx dsh-global-todo-doctor "$HOME/.dsh/plugins-local/全局待办插件"
169
+ # Windows: npx dsh-global-todo-doctor "$env:USERPROFILE\.dsh\plugins-local\全局待办插件"
170
+ ```
171
+
172
+ 它逐项检查 manifest、patch 文件、两个入口、`locale/`、图标和 `lib/`;缺必需项时退出码为 1。
173
+ 最容易漏的是 `locale/`——少了它插件照样加载,但桌面端会报
174
+ `Plugin metadata … ENOENT … \locale`。
175
+
165
176
  npm 上的包才是事实来源。社区目录可能落后于 npm、或指向某个旧 release;需要确定版本时
166
177
  直接钉版本(`dsh plugin --profile web add dsh-global-todo@0.10.0`)。
167
178
 
package/docs/FAQ.md CHANGED
@@ -166,6 +166,16 @@ rm -r "$DSH_HOME/global-todo"
166
166
  也就是说:**你那份本地插件目录缺 `locale/` 文件夹**。常见于把仓库文件夹手动复制到
167
167
  `%USERPROFILE%\.dsh\plugins-local\` 时只拷了 `lib`、`package.json`、`cordis.patch.yml`。
168
168
 
169
+ 先确认到底缺什么,别靠猜:
170
+
171
+ ```sh
172
+ npx dsh-global-todo-doctor "$HOME/.dsh/plugins-local/全局待办插件"
173
+ # Windows: npx dsh-global-todo-doctor "$env:USERPROFILE\.dsh\plugins-local\全局待办插件"
174
+ ```
175
+
176
+ 它逐项检查 `package.json`、patch 文件、两个入口、`locale/`、图标与 `lib/`,缺哪样直接点出来,
177
+ 缺必需项时退出码为 1(可以放进安装脚本里当门禁)。
178
+
169
179
  修法二选一:
170
180
 
171
181
  1. **推荐**:别用本地目录,直接装 npm 包(包里有 `locale/en.json`、`locale/zh.json`):
package/lib/index.js CHANGED
@@ -36,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
36
36
  import { createTools } from './tools.js'
37
37
 
38
38
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
39
- export const VERSION = '0.12.4'
39
+ export const VERSION = '0.12.6'
40
40
 
41
41
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
42
42
  export const name = 'dsh-global-todo'
package/lib/store.js CHANGED
@@ -19,7 +19,48 @@
19
19
  * @module dsh-global-todo/store
20
20
  */
21
21
 
22
- import { open, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
22
+ import { open, mkdir, readFile, rename, stat, unlink } from 'node:fs/promises'
23
+
24
+ /**
25
+ * Replace a file atomically, and survive a platform that refuses `fsync`.
26
+ *
27
+ * Opened for **writing** on purpose: on Windows `handle.sync()` is `FlushFileBuffers`,
28
+ * which needs a writable handle. Opening the temp file read-only and syncing it threw
29
+ * `EPERM` on every write, and because that sync sat before `rename()`, the target file
30
+ * was never replaced — the list looked empty while the data piled up as `*.tmp` debris.
31
+ * That was issue #2.
32
+ *
33
+ * The sync itself is best effort: durability matters, but losing a save because a
34
+ * platform will not flush a handle is strictly worse than a save that reaches the page
35
+ * cache. A failing sync is reported and the rename still happens.
36
+ *
37
+ * @param {string} file absolute path of the target
38
+ * @param {string} payload bytes to write
39
+ * @param {{ fs?: object, onSyncError?: (error: unknown) => void }} [options] injected for tests
40
+ */
41
+ export async function atomicWrite(file, payload, options = {}) {
42
+ const io = options.fs ?? { open, rename, unlink }
43
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`
44
+ let handle = null
45
+ let renamed = false
46
+ try {
47
+ handle = await io.open(temp, 'w', 0o600)
48
+ await handle.writeFile(payload, 'utf8')
49
+ try {
50
+ await handle.sync()
51
+ } catch (error) {
52
+ options.onSyncError?.(error)
53
+ }
54
+ await handle.close()
55
+ handle = null
56
+ await io.rename(temp, file)
57
+ renamed = true
58
+ } finally {
59
+ if (handle !== null) await handle.close().catch(() => {})
60
+ // Only debris is cleaned up: after a successful rename the temp file is gone.
61
+ if (!renamed) await io.unlink(temp).catch(() => {})
62
+ }
63
+ }
23
64
  import { homedir } from 'node:os'
24
65
  import path from 'node:path'
25
66
 
@@ -187,16 +228,12 @@ export function createStore(options) {
187
228
  const directory = path.dirname(file)
188
229
  await mkdir(directory, { recursive: true })
189
230
  const payload = `${JSON.stringify({ version: SCHEMA_VERSION, todos: doc.todos }, null, 2)}\n`
190
- const temp = `${file}.${process.pid}.${now()}.tmp`
191
231
  try {
192
- await writeFile(temp, payload, { mode: 0o600 })
193
- const handle = await open(temp, 'r')
194
- try {
195
- await handle.sync()
196
- } finally {
197
- await handle.close()
198
- }
199
- await rename(temp, file)
232
+ await atomicWrite(file, payload, {
233
+ // A platform that will not flush the temp file is a warning, not a lost save.
234
+ onSyncError: (error) =>
235
+ logger?.warn?.(`dsh-global-todo: 落盘后 fsync 失败(已继续)${file}:${error?.message ?? error}`),
236
+ })
200
237
  } catch (error) {
201
238
  throw new TodoError('store-unwritable', `写入待办文件失败 ${file}:${error?.message ?? error}`, { file })
202
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Cross-session todo list for DeepSeek Harness: one JSON file a person and an agent both write to, with step plans that ship as a numbered hand-off, session read-back, and a human confirmation before an agent's \"done\" counts.",
6
6
  "type": "module",
@@ -52,6 +52,8 @@
52
52
  "docs/FAQ.md",
53
53
  "docs/icon.svg",
54
54
  "docs/screenshots",
55
+ "tools/doctor.mjs",
56
+ "tools/doctor-lib.mjs",
55
57
  "!docs/screenshots/demo.mp4",
56
58
  "!docs/index.html",
57
59
  "!docs/assets",
@@ -63,8 +65,12 @@
63
65
  "CONTRIBUTING.md",
64
66
  "CODE_OF_CONDUCT.md"
65
67
  ],
68
+ "bin": {
69
+ "dsh-global-todo-doctor": "./tools/doctor.mjs"
70
+ },
66
71
  "scripts": {
67
72
  "check": "node scripts/check.mjs",
73
+ "doctor": "node tools/doctor.mjs",
68
74
  "test": "node --test \"test/**/*.test.mjs\"",
69
75
  "verify": "npm run check && npm run check:render && npm test",
70
76
  "preview": "node tools/preview.mjs",
@@ -0,0 +1,123 @@
1
+ /**
2
+ * A pre-flight check for an installed copy of this plugin.
3
+ *
4
+ * The failure this exists for is specific: a hand-copied `plugins-local` directory
5
+ * usually keeps `lib/`, `package.json` and the patch file, and drops `locale/` —
6
+ * the plugin still loads, the panel still works, and the desktop app prints
7
+ * `Plugin metadata … ENOENT … \locale` on the plugins page. Node resolves the
8
+ * `./locale/*.json` export *without* checking that the file exists, and the Host then
9
+ * scans the directory, which is why the symptom appears far from the cause.
10
+ *
11
+ * Everything here is a pure function of the directory contents, so the test suite can
12
+ * call it without spawning anything.
13
+ */
14
+ import { readFile, readdir, stat } from 'node:fs/promises'
15
+ import path from 'node:path'
16
+
17
+ const EXPORT_LOCALE = './locale/*.json'
18
+
19
+ const readJson = async (file) => {
20
+ try {
21
+ return JSON.parse(await readFile(file, 'utf8'))
22
+ } catch {
23
+ return null
24
+ }
25
+ }
26
+
27
+ const exists = async (file) => {
28
+ try {
29
+ const info = await stat(file)
30
+ return info.isFile() || info.isDirectory()
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {string} dir directory that should contain the plugin, e.g. …/plugins-local/全局待办插件
38
+ * @returns {Promise<{ok: boolean, dir: string, findings: {level: 'ok'|'warn'|'error', id: string, message: string, fix?: string}[], version?: string}>}
39
+ */
40
+ export async function doctor(dir) {
41
+ const findings = []
42
+ const add = (level, id, message, fix) => findings.push({ level, id, message, ...(fix === undefined ? {} : { fix }) })
43
+
44
+ const manifestFile = path.join(dir, 'package.json')
45
+ const manifest = await readJson(manifestFile)
46
+ if (manifest === null) {
47
+ add('error', 'package-missing', 'package.json 读不到或不是合法 JSON', '把插件目录指到包含 package.json 的那一层(例如 …/plugins-local/全局待办插件)')
48
+ return { ok: false, dir, findings }
49
+ }
50
+
51
+ if (manifest.name !== 'dsh-global-todo') {
52
+ add('warn', 'package-name', `package.json 里的 name 是 ${manifest.name ?? '(缺失)'},不是 dsh-global-todo`, '这个目录可能不是本插件')
53
+ } else {
54
+ add('ok', 'package-name', `${manifest.name}@${manifest.version ?? '?'}`)
55
+ }
56
+
57
+ // The patch file is what contributes the Loader row. Without it nothing loads.
58
+ const patch = manifest.dsh?.bundle?.patch
59
+ if (typeof patch !== 'string') {
60
+ add('error', 'patch-declared', 'package.json 没有声明 dsh.bundle.patch', '这不是一个可安装的 DSH bundle')
61
+ } else if (!(await exists(path.join(dir, patch)))) {
62
+ add('error', 'patch-file', `dsh.bundle.patch 指向 ${patch},但文件不存在`, '从 npm 包重新拷贝,或 `dsh plugin add dsh-global-todo`')
63
+ } else {
64
+ add('ok', 'patch-file', patch)
65
+ }
66
+
67
+ // Both halves must exist; the client half is what the panel loads.
68
+ const entry = manifest.exports?.['.']
69
+ const client = manifest.exports?.['./client']
70
+ for (const [label, target] of [['host entry', entry], ['client entry', client]]) {
71
+ if (typeof target !== 'string') {
72
+ add('warn', 'entry-declared', `package.json 没有 ${label} 的 exports`, '宿主可能用默认解析,先确认版本是否完整')
73
+ continue
74
+ }
75
+ if (await exists(path.join(dir, target))) add('ok', 'entry', target)
76
+ else add('error', 'entry-file', `${label} 指向 ${target},文件不存在`, '这个目录不是完整拷贝:重新从 npm 包复制 lib/')
77
+ }
78
+
79
+ // The locale export is the one that fails in the field: the export exists, the
80
+ // directory does not, and the Host reports it as plugin-metadata trouble.
81
+ const localeExport = manifest.exports?.[EXPORT_LOCALE]
82
+ const localeDir = path.join(dir, 'locale')
83
+ const localeExists = await exists(localeDir)
84
+ const files = localeExists ? (await readdir(localeDir).catch(() => [])).filter((name) => name.endsWith('.json')) : []
85
+ if (typeof localeExport === 'string' && !localeExists) {
86
+ add(
87
+ 'error',
88
+ 'locale-missing',
89
+ 'package.json 声明了 ./locale/*.json,但 locale/ 目录不存在 —— 桌面端会显示 Plugin metadata … ENOENT … \\locale',
90
+ '拷贝 locale/ 目录,或用 npm 包重装:dsh plugin add dsh-global-todo(插件本身仍能加载,坏的是插件页标题/描述)',
91
+ )
92
+ } else if (typeof localeExport === 'string' && files.length === 0) {
93
+ add('warn', 'locale-empty', 'locale/ 存在但没有 .json 文件', '至少需要 en.json 与 zh.json')
94
+ } else if (files.length > 0) {
95
+ add('ok', 'locale', `locale/ → ${files.sort().join(', ')}`)
96
+ }
97
+
98
+ // The icon is cosmetic: a missing one shows a blank mark, not an error.
99
+ if (await exists(path.join(dir, 'docs', 'icon.svg'))) add('ok', 'icon', 'docs/icon.svg')
100
+ else add('warn', 'icon-missing', 'docs/icon.svg 不存在,插件页会没有图标', '可选:一并拷过去即可,不影响加载')
101
+
102
+ const libDir = path.join(dir, 'lib')
103
+ const libFiles = (await exists(libDir)) ? (await readdir(libDir).catch(() => [])).filter((name) => name.endsWith('.js')) : []
104
+ if (libFiles.length === 0) add('error', 'lib-empty', 'lib/ 里没有 .js 文件', '从 npm 包重新复制 lib/')
105
+ else add('ok', 'lib', `lib/ → ${libFiles.length} 个模块`)
106
+
107
+ return { ok: !findings.some((finding) => finding.level === 'error'), dir, version: manifest.version, findings }
108
+ }
109
+
110
+ /** One line per finding, for a terminal. */
111
+ export function format(report) {
112
+ const mark = { ok: 'OK ', warn: 'WARN', error: 'FAIL' }
113
+ const lines = [`dsh-global-todo doctor — ${report.dir}`, '']
114
+ for (const finding of report.findings) {
115
+ lines.push(`${mark[finding.level]} ${finding.message}`)
116
+ if (finding.fix !== undefined) lines.push(` fix: ${finding.fix}`)
117
+ }
118
+ const errors = report.findings.filter((finding) => finding.level === 'error').length
119
+ const warns = report.findings.filter((finding) => finding.level === 'warn').length
120
+ lines.push('')
121
+ lines.push(errors === 0 ? `OK — ${warns} warning(s)` : `${errors} error(s), ${warns} warning(s)`)
122
+ return lines.join('\n')
123
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-global-todo-doctor — check that a directory really contains this plugin.
4
+ *
5
+ * npx dsh-global-todo-doctor "~/.dsh/plugins-local/全局待办插件"
6
+ * node tools/doctor.mjs . # from a checkout
7
+ *
8
+ * Exists because the field failure is silent-ish: a hand-copied plugin directory
9
+ * without `locale/` still loads, and only the desktop plugins page complains.
10
+ */
11
+ import { doctor, format } from './doctor-lib.mjs'
12
+ import path from 'node:path'
13
+
14
+ const args = process.argv.slice(2).filter((value) => value !== '')
15
+ if (args.includes('--help') || args.includes('-h')) {
16
+ process.stdout.write('用法 / usage:\n dsh-global-todo-doctor [插件目录]\n\n默认检查当前目录。\n')
17
+ process.exit(0)
18
+ }
19
+
20
+ const target = path.resolve(args[0] ?? process.cwd())
21
+ const report = await doctor(target)
22
+ process.stdout.write(format(report) + '\n')
23
+ process.exit(report.ok ? 0 : 1)