dsh-global-todo 0.12.6 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ ## [0.12.8] — 2026-10-21
2
+
3
+ The Windows runner kept finding things after 0.12.7. Four more, all real.
4
+
5
+ - **`mkdir` over a file is `EEXIST` on Windows**, not `ENOTDIR`. The store translated only the
6
+ POSIX code, so an unwritable location could surface as a raw error; both now become
7
+ `store-unwritable`.
8
+ - **The diagnostics trim could lose its rename** while a reader held the log. The atomic write is
9
+ now shared (`lib/atomic.js`) by the store, settings and diagnostics, with a longer retry budget
10
+ for a file that is read constantly.
11
+ - **`tools/preview.mjs` created a directory named `panel.html`** on Windows: it stripped the file
12
+ name with a SQL-style regex on `/`, which matched nothing there. `path.dirname` instead — this is
13
+ what made `npm pack --dry-run` fail on the Windows runner.
14
+ - **Two test assertions were POSIX literals** (a default store path and a `path.resolve` result).
15
+ They now compare resolved paths, which is what the contract actually says.
16
+ - **CI is green on `windows-latest`** — tests, static checks, package shape and
17
+ `node tools/windows-check.mjs` all run there.
18
+
19
+ ## [0.12.7] — 2026-10-21
20
+
21
+ Windows, tested instead of assumed.
22
+
23
+ - **CI runs on windows-latest.** Issue #2 shipped for months because only Linux was ever
24
+ exercised; the suite now runs on both, plus `node tools/windows-check.mjs` on Windows.
25
+ - **`npm run check:windows`** — a real-filesystem smoke test (no faked `fs`): create a store,
26
+ update/complete/review then reload, assert no `.tmp` debris, write and read 500 todos, import a
27
+ CRLF Markdown file, use a path with spaces and non-ASCII characters, and rename over an existing
28
+ file. `docs/WINDOWS.md` says what to report when one of them fails.
29
+ - **Windows paths are exempt-able.** Settings compared exemptions with `startsWith('/')`, so every
30
+ workspace a Windows user configured was silently dropped. Now `isAbsolutePath` (drive and UNC),
31
+ with case-insensitive comparison on Windows and trailing separators of either kind removed.
32
+ - **CRLF Markdown imports.** `fromMarkdown` split on `\n` and left `\r` on every line; a document
33
+ saved on Windows now parses exactly like the same document saved on Linux.
34
+ - **Transient locks are retried.** Antivirus and the indexer can hold a just-written file for a few
35
+ milliseconds; `EPERM`/`EBUSY`/`EACCES` during the final rename are retried (bounded), other
36
+ errors still fail at once, and a real failure cleans up its temp file.
37
+ - **Settings writes are atomic too.** A half-written `settings.json` would silently reset every
38
+ switch; it now goes through the same temp-file-and-rename path as the todo list.
39
+
1
40
  ## [0.12.6] — 2026-10-21
2
41
 
3
42
  Saving on Windows never worked. Fixed, with the root cause written down (#2).
@@ -0,0 +1,59 @@
1
+ # Windows
2
+
3
+ Issue #2 was a Windows-only save failure that shipped for months because CI only ever ran Linux:
4
+ the atomic write reopened its temp file read-only and called `fsync`, which on Windows is
5
+ `FlushFileBuffers` and requires a writable handle. Every save threw `EPERM` **before** the rename,
6
+ so the list looked empty while the data piled up as `*.tmp` debris. Fixed in 0.12.6.
7
+
8
+ This file is the checklist that keeps that from happening again, and the script a tester on a real
9
+ Windows machine should run.
10
+
11
+ ## What the test suite already covers on every platform
12
+
13
+ | Area | Where | What it pins |
14
+ |---|---|---|
15
+ | atomic write | `test/atomic-write.test.mjs` | the temp file is opened for **writing**; a refused `fsync` warns instead of losing the save; a failed write leaves no debris |
16
+ | rename under a lock | `test/windows.test.mjs` | `EPERM`/`EBUSY`/`EACCES` are retried (bounded), other errors fail at once, no temp file survives a real failure |
17
+ | workspace paths | `test/windows.test.mjs` | `C:\\dev\\demo`, `C:/dev/demo` and `\\\\server\\share` are accepted as exemptions; relative paths are not |
18
+ | CRLF markdown | `test/windows.test.mjs` | a document saved on Windows imports like the same document saved on Linux |
19
+ | install shape | `test/manifest.test.mjs` | `locale/` ships and the `./locale/*.json` export exists (the desktop metadata error) |
20
+
21
+ CI runs the suite on **ubuntu-latest and windows-latest** (node 22.19 on both, node 24 on Linux).
22
+ Windows runs `node tools/windows-check.mjs` as an extra step.
23
+
24
+ That matrix earned its keep on its first day: it found four things Linux could not see — a `mkdir`
25
+ over a file reporting `EEXIST` instead of `ENOTDIR`, the diagnostics trim losing a rename to a busy
26
+ reader, `tools/preview.mjs` creating a directory named `panel.html` (a POSIX-only regex), and two
27
+ test assertions that pinned POSIX literals. All fixed in 0.12.8.
28
+
29
+ ## The real-filesystem check
30
+
31
+ ```powershell
32
+ git clone https://github.com/LUgABeEEE/dsh-global-todo
33
+ cd dsh-global-todo
34
+ node tools/windows-check.mjs # or: npm run check:windows
35
+ ```
36
+
37
+ It uses `os.tmpdir()` and does seven things a faked `fs` cannot: create a store, update/complete/
38
+ review then reload, assert no `.tmp` debris, write and read back 500 todos, import a CRLF Markdown
39
+ file, write into a path with spaces and non-ASCII characters, and rename over an existing file. It
40
+ prints one line per check and exits non-zero on the first failure.
41
+
42
+ ## What to report if something fails
43
+
44
+ 1. the check name from the `FAIL` line and the full message;
45
+ 2. `node -v`, `dsh --version`, Windows build (`winver`);
46
+ 3. whether the data directory is on a network drive, OneDrive, or a synced folder — those change
47
+ `fsync`/`rename` semantics more than the OS version does;
48
+ 4. the contents of `%USERPROFILE%\.dsh\global-todo\` (file names are enough; redact titles if you
49
+ prefer) — `*.tmp` files are evidence of a write that never renamed.
50
+
51
+ ## Known Windows behaviours that are *not* bugs
52
+
53
+ - **A `*.tmp` file can briefly appear** while a write is in flight. After a successful save it is
54
+ gone; after a failed one it is removed by the writer.
55
+ - **Antivirus can hold a handle for a few milliseconds.** That is why the rename is retried rather
56
+ than reported on the first `EPERM`.
57
+ - **Paths longer than 260 characters** depend on the machine's `LongPathsEnabled` policy; the plugin
58
+ uses normal Node file APIs, so it inherits that limit rather than working around it.
59
+ - **Case**: workspace exemptions are compared case-insensitively on Windows only.
package/lib/atomic.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Small, shared file-system guarantees.
3
+ *
4
+ * Both the todo file and the diagnostics log are replaced by "write a sibling, then
5
+ * rename". That pattern has two platform traps this module exists to absorb:
6
+ *
7
+ * 1. `fsync` on Windows is `FlushFileBuffers`, which needs a **writable** handle. A
8
+ * read-only handle always throws `EPERM`, and a save that throws before its rename
9
+ * is a save that never happened (issue #2).
10
+ * 2. Antivirus, the search indexer and a concurrent reader can hold a handle for a few
11
+ * milliseconds, which makes the rename fail with `EPERM`/`EBUSY`/`EACCES`. Retrying
12
+ * those turns a spurious failure into a write; other errors still surface at once.
13
+ *
14
+ * @module dsh-global-todo/atomic
15
+ */
16
+
17
+ import { open, rename, unlink } from 'node:fs/promises'
18
+
19
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
20
+
21
+ /**
22
+ * Rename, retrying only the errors that mean "someone else has it for a moment".
23
+ *
24
+ * @param {{ rename: (from: string, to: string) => Promise<void> }} io
25
+ * @param {string} from temporary file
26
+ * @param {string} to target file
27
+ */
28
+ export async function renameWithRetry(io, from, to, attempts = 4) {
29
+ for (let attempt = 0; ; attempt += 1) {
30
+ try {
31
+ await io.rename(from, to)
32
+ return
33
+ } catch (error) {
34
+ if (attempt >= attempts || !TRANSIENT_RENAME_CODES.has(error?.code)) throw error
35
+ await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)))
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Replace a file atomically, and survive a platform that refuses `fsync`.
42
+ *
43
+ * The handle is opened for **writing** on purpose: on Windows `handle.sync()` needs a
44
+ * writable handle, and opening the temp file read-only made every write throw before the
45
+ * rename (the todo list looked empty while `*.tmp` debris accumulated).
46
+ *
47
+ * The sync itself is best effort: durability matters, but losing a save because a
48
+ * platform will not flush a handle is worse than a save that reaches the page cache.
49
+ *
50
+ * @param {string} file absolute path of the target
51
+ * @param {string} payload bytes to write
52
+ * @param {{ fs?: object, onSyncError?: (error: unknown) => void, renameAttempts?: number }} [options]
53
+ * injected for tests; `renameAttempts` is there for callers whose target is read constantly
54
+ */
55
+ export async function atomicWrite(file, payload, options = {}) {
56
+ const io = options.fs ?? { open, rename, unlink }
57
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`
58
+ let handle = null
59
+ let renamed = false
60
+ try {
61
+ handle = await io.open(temp, 'w', 0o600)
62
+ await handle.writeFile(payload, 'utf8')
63
+ try {
64
+ await handle.sync()
65
+ } catch (error) {
66
+ options.onSyncError?.(error)
67
+ }
68
+ await handle.close()
69
+ handle = null
70
+ await renameWithRetry(io, temp, file, options.renameAttempts)
71
+ renamed = true
72
+ } finally {
73
+ if (handle !== null) await handle.close().catch(() => {})
74
+ // Only debris is cleaned up: after a successful rename the temp file is gone.
75
+ if (!renamed) await io.unlink(temp).catch(() => {})
76
+ }
77
+ }
@@ -9,7 +9,9 @@
9
9
  * @module dsh-global-todo/diagnostics
10
10
  */
11
11
 
12
- import { appendFile, readFile, rename, rm, writeFile } from 'node:fs/promises'
12
+ import { appendFile, readFile, rm } from 'node:fs/promises'
13
+
14
+ import { atomicWrite } from './atomic.js'
13
15
 
14
16
  /** How many entries `/health` shows, how many are kept in memory, how many on disk. */
15
17
  const RECENT = 5
@@ -66,15 +68,13 @@ export function createDiagnostics(options) {
66
68
  * @param {string[]} lines
67
69
  */
68
70
  const rewrite = async (lines) => {
69
- const temp = `${options.file}.${process.pid}.${(rewrites += 1)}.tmp`
70
- try {
71
- await writeFile(temp, `${lines.slice(-ON_DISK).join('\n')}\n`, 'utf8')
72
- await rename(temp, options.file)
73
- } catch (error) {
74
- // A failed trim must not leave litter beside the log it failed to trim.
75
- await rm(temp, { force: true }).catch(() => {})
76
- throw error
77
- }
71
+ rewrites += 1
72
+ // Same helper as the todo file: opened for writing (Windows cannot fsync a read-only
73
+ // handle), rename retried while an antivirus or a reader holds the file for a moment,
74
+ // temp file removed if the replace fails.
75
+ // A debug reader (a tail, an editor, this test) may hold the file almost continuously,
76
+ // so the trim gets a longer retry budget than a normal save.
77
+ await atomicWrite(options.file, `${lines.slice(-ON_DISK).join('\n')}\n`, { renameAttempts: 12 })
78
78
  }
79
79
 
80
80
  return {
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.6'
39
+ export const VERSION = '0.12.8'
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/markdown.js CHANGED
@@ -79,7 +79,10 @@ export function toMarkdown(doc, options = {}) {
79
79
  export function fromMarkdown(text) {
80
80
  const todos = []
81
81
  let current = null
82
- for (const raw of clean(text).split('\n')) {
82
+ // A file saved on Windows arrives with CRLF; split the same way everywhere instead of
83
+ // leaving a trailing \r on every line for the matchers to trip over.
84
+ const normalized = clean(text).replace(/\r\n?/g, '\n')
85
+ for (const raw of normalized.split('\n')) {
83
86
  const line = raw.replace(/\s+$/, '')
84
87
  const task = /^- \[([ xX])\] (.+)$/.exec(line)
85
88
  if (task !== null) {
package/lib/settings.js CHANGED
@@ -8,7 +8,9 @@
8
8
  * @module dsh-global-todo/settings
9
9
  */
10
10
 
11
- import { chmod, readFile, writeFile } from 'node:fs/promises'
11
+ import { chmod, readFile } from 'node:fs/promises'
12
+ import { isAbsolutePath } from './links.js'
13
+ import { atomicWrite } from './store.js'
12
14
 
13
15
  /** How many sessions the cooldown bookkeeping remembers. */
14
16
  const ASKED_LIMIT = 50
@@ -91,13 +93,19 @@ export function normalizeSettings(raw) {
91
93
  // Newest first, so the cap drops the oldest bookkeeping rather than the newest.
92
94
  entries.sort((left, right) => right[1] - left[1])
93
95
  for (const [sessionId, at] of entries.slice(0, ASKED_LIMIT)) asked[sessionId] = at
94
- // Trailing slashes come off so `/x/` and `/x` cannot both be listed and both match.
96
+ // Trailing separators come off so `/x/` and `/x` (or `C:\\x\\` and `C:\\x`) cannot both be
97
+ // listed and both match. Windows workspaces are drive or UNC paths, so the test is
98
+ // `isAbsolutePath`, not "starts with a slash" — the latter silently rejected every
99
+ // exempt workspace a Windows user configured.
95
100
  const exemptWorkspaces = []
96
101
  for (const entry of Array.isArray(source.exemptWorkspaces) ? source.exemptWorkspaces : []) {
97
102
  if (typeof entry !== 'string') continue
98
- const dir = entry.trim().replace(/\/+$/, '')
103
+ const dir = entry.trim().replace(/[\\/]+$/, '')
99
104
  // A relative path would match by accident; an exemption has to name a place.
100
- if (dir === '' || !dir.startsWith('/') || exemptWorkspaces.includes(dir)) continue
105
+ if (dir === '' || !isAbsolutePath(dir)) continue
106
+ // Windows paths are case-insensitive, so `C:\\Dev` and `c:\\dev` are one place.
107
+ const key = process.platform === 'win32' ? dir.toLowerCase() : dir
108
+ if (exemptWorkspaces.some((listed) => (process.platform === 'win32' ? listed.toLowerCase() : listed) === key)) continue
101
109
  if (exemptWorkspaces.length >= EXEMPT_LIMIT) break
102
110
  exemptWorkspaces.push(dir)
103
111
  }
@@ -161,7 +169,9 @@ export function createSettings(options) {
161
169
 
162
170
  const persist = async () => {
163
171
  try {
164
- await writeFile(options.file, `${JSON.stringify({ version: 1, ...state }, null, 2)}\n`, 'utf8')
172
+ // Same atomic path as the todo file: a half-written settings file on Windows (the
173
+ // kind a crash or a locked rename leaves behind) would silently reset every switch.
174
+ await atomicWrite(options.file, `${JSON.stringify({ version: 1, ...state }, null, 2)}\n`)
165
175
  // The file can hold an ingest token; keep it readable by the Host user only. Best-effort:
166
176
  // a filesystem without POSIX modes must not fail the write.
167
177
  await chmod(options.file, 0o600).catch(() => {})
package/lib/store.js CHANGED
@@ -19,54 +19,18 @@
19
19
  * @module dsh-global-todo/store
20
20
  */
21
21
 
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
- }
22
+ import { mkdir, open, readFile, stat } from 'node:fs/promises'
64
23
  import { homedir } from 'node:os'
65
24
  import path from 'node:path'
66
25
 
26
+ import { atomicWrite } from './atomic.js'
67
27
  import { TodoError } from './errors.js'
68
28
  import { LIMITS, SCHEMA_VERSION, normalizeStored } from './model.js'
69
29
 
30
+ // Re-exported because the tests (and anyone poking at the store) reach for it here; the
31
+ // implementation lives in atomic.js so the diagnostics log can share it.
32
+ export { atomicWrite }
33
+
70
34
  /**
71
35
  * @param {Record<string, string | undefined>} [env]
72
36
  * @returns {string} the DSH home directory
@@ -226,7 +190,13 @@ export function createStore(options) {
226
190
  */
227
191
  async function write(doc) {
228
192
  const directory = path.dirname(file)
229
- await mkdir(directory, { recursive: true })
193
+ try {
194
+ await mkdir(directory, { recursive: true })
195
+ } catch (error) {
196
+ // A file where the directory should be reports ENOTDIR on POSIX and EEXIST on
197
+ // Windows; both mean the same thing to the caller, so both become store-unwritable.
198
+ throw new TodoError('store-unwritable', `建不了待办目录 ${directory}:${error?.message ?? error}`, { file })
199
+ }
230
200
  const payload = `${JSON.stringify({ version: SCHEMA_VERSION, todos: doc.todos }, null, 2)}\n`
231
201
  try {
232
202
  await atomicWrite(file, payload, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
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",
@@ -50,6 +50,7 @@
50
50
  "docs/ROADMAP.md",
51
51
  "docs/QUICKSTART.md",
52
52
  "docs/FAQ.md",
53
+ "docs/WINDOWS.md",
53
54
  "docs/icon.svg",
54
55
  "docs/screenshots",
55
56
  "tools/doctor.mjs",
@@ -71,6 +72,7 @@
71
72
  "scripts": {
72
73
  "check": "node scripts/check.mjs",
73
74
  "doctor": "node tools/doctor.mjs",
75
+ "check:windows": "node tools/windows-check.mjs",
74
76
  "test": "node --test \"test/**/*.test.mjs\"",
75
77
  "verify": "npm run check && npm run check:render && npm test",
76
78
  "preview": "node tools/preview.mjs",