dsh-tower 2.0.2 → 2.0.4

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.en.md +22 -16
  3. package/README.md +22 -16
  4. package/SECURITY.md +4 -1
  5. package/docs/README.md +30 -0
  6. package/docs/architecture/overview.md +3 -2
  7. package/docs/examples/first-mission.md +2 -1
  8. package/docs/faq.md +15 -6
  9. package/docs/guide/install-upgrade-migration.md +14 -5
  10. package/docs/guide/quickstart.md +12 -7
  11. package/docs/guide/scenarios-and-philosophy.md +150 -62
  12. package/docs/operations/security.md +23 -12
  13. package/docs/reference/blockers.md +11 -10
  14. package/docs/reference/config.md +8 -6
  15. package/docs/reference/errors.md +7 -1
  16. package/docs/reference/events.md +7 -4
  17. package/docs/reference/testing.md +2 -1
  18. package/docs/reference/tools.md +7 -6
  19. package/docs/zh/README.md +1 -1
  20. package/docs/zh/faq.md +12 -4
  21. package/docs/zh/guide/install-upgrade-migration.md +11 -4
  22. package/docs/zh/guide/quickstart.md +13 -8
  23. package/docs/zh/guide/scenarios-and-philosophy.md +108 -36
  24. package/docs/zh/operations/security.md +16 -6
  25. package/docs/zh/reference/blockers.md +10 -10
  26. package/docs/zh/reference/config.md +8 -6
  27. package/docs/zh/reference/errors.md +2 -1
  28. package/docs/zh/reference/events.md +6 -3
  29. package/docs/zh/reference/testing.md +3 -2
  30. package/docs/zh/reference/tools.md +7 -6
  31. package/index.js +26 -17
  32. package/lib/config.js +103 -6
  33. package/lib/doctor.js +11 -5
  34. package/lib/errors.js +14 -2
  35. package/lib/git.js +36 -2
  36. package/lib/modeStore.js +54 -20
  37. package/lib/store.js +65 -10
  38. package/lib/tests.js +35 -1
  39. package/lib/tools.js +257 -47
  40. package/lib/tower.js +281 -55
  41. package/lib/workspaceLock.js +44 -20
  42. package/package.json +3 -2
  43. package/scripts/check-docs.mjs +35 -0
  44. package/skills/tower-recovery.md +7 -5
  45. package/ui-bundle/README.md +111 -0
  46. package/ui-bundle/client.js +1980 -0
  47. package/ui-bundle/cordis.patch.yml +3 -0
  48. package/ui-bundle/icon.svg +6 -0
  49. package/ui-bundle/index.js +2 -0
  50. package/ui-bundle/locale/en.json +6 -0
  51. package/ui-bundle/locale/zh.json +6 -0
  52. package/ui-bundle/package.json +35 -0
package/lib/modeStore.js CHANGED
@@ -62,33 +62,67 @@ export function loadModes(baseDir) {
62
62
  return modes
63
63
  }
64
64
 
65
- /** Persist the current modes atomically; failures never break the command. */
65
+ /** Persist the current modes atomically with a short cross-process lock. */
66
66
  export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
67
+ const dir = storeDir(baseDir)
68
+ const file = storeFile(baseDir)
69
+ const lock = path.join(dir, 'modes.lock')
67
70
  try {
68
- const dir = storeDir(baseDir)
69
71
  fs.mkdirSync(dir, { recursive: true })
70
- const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
71
- const entries = [...modes.entries()]
72
- .sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
73
- .slice(0, limit)
74
- const payload = { version: 1, modes: Object.fromEntries(entries) }
75
- const file = storeFile(baseDir)
76
- const tmp = `${file}.tmp-${process.pid}`
77
- fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
78
- let lastError = null
79
- for (let attempt = 0; attempt < 5; attempt += 1) {
72
+ let acquired = false
73
+ for (let attempt = 0; attempt < 40; attempt += 1) {
80
74
  try {
81
- fs.renameSync(tmp, file)
82
- return true
75
+ const fd = fs.openSync(lock, 'wx')
76
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }))
77
+ fs.closeSync(fd)
78
+ acquired = true
79
+ break
83
80
  } catch (error) {
84
- lastError = error
85
- if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) break
86
- sleepSync(10 * (attempt + 1))
81
+ if (error?.code !== 'EEXIST') throw error
82
+ sleepSync(5)
87
83
  }
88
84
  }
89
- try { fs.unlinkSync(tmp) } catch {}
90
- if (lastError) return false
91
- return true
85
+ if (!acquired) return false
86
+ try {
87
+ const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
88
+ let disk = {}
89
+ try {
90
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
91
+ if (parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object') disk = parsed.modes
92
+ } catch {
93
+ disk = {}
94
+ }
95
+ // Merge by timestamp so two sessions saving concurrently do not erase
96
+ // each other's mode changes. A disk-only entry from another process is
97
+ // preserved unless this process has a newer record for the same key.
98
+ const merged = { ...disk }
99
+ for (const [key, value] of modes.entries()) {
100
+ const previous = merged[key]
101
+ if (!previous || (Number(value?.at) || 0) >= (Number(previous?.at) || 0)) merged[key] = value
102
+ }
103
+ const entries = Object.entries(merged)
104
+ .sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
105
+ .slice(0, limit)
106
+ const payload = { version: 1, modes: Object.fromEntries(entries) }
107
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
108
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
109
+ let lastError = null
110
+ for (let attempt = 0; attempt < 5; attempt += 1) {
111
+ try {
112
+ fs.renameSync(tmp, file)
113
+ return true
114
+ } catch (error) {
115
+ lastError = error
116
+ if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) break
117
+ sleepSync(10 * (attempt + 1))
118
+ }
119
+ }
120
+ try { fs.unlinkSync(tmp) } catch {}
121
+ if (lastError) return false
122
+ return true
123
+ } finally {
124
+ try { fs.unlinkSync(lock) } catch {}
125
+ }
92
126
  } catch {
93
127
  return false
94
128
  }
package/lib/store.js CHANGED
@@ -98,15 +98,73 @@ function sleep(ms) {
98
98
  return new Promise((resolve) => setTimeout(resolve, ms))
99
99
  }
100
100
 
101
+ const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
102
+
103
+ function processAlive(pid) {
104
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null
105
+ try {
106
+ process.kill(Number(pid), 0)
107
+ return true
108
+ } catch (error) {
109
+ if (error?.code === 'ESRCH') return false
110
+ // EPERM means the process exists but belongs to another user.
111
+ return true
112
+ }
113
+ }
114
+
115
+ /** Read the current lock record without acquiring the lock. */
116
+ export async function readStateLock(paths) {
117
+ try {
118
+ const text = await fsp.readFile(paths.lock, 'utf8')
119
+ const record = JSON.parse(text)
120
+ const alive = processAlive(record?.pid)
121
+ return { path: paths.lock, record, alive, raw: text }
122
+ } catch (error) {
123
+ if (error?.code === 'ENOENT') return { path: paths.lock, record: null, alive: null, raw: null }
124
+ return { path: paths.lock, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
125
+ }
126
+ }
127
+
128
+ async function reclaimDeadLock(paths) {
129
+ let original
130
+ try {
131
+ original = await fsp.readFile(paths.lock, 'utf8')
132
+ } catch {
133
+ return false
134
+ }
135
+ let parsed
136
+ try {
137
+ parsed = JSON.parse(original)
138
+ } catch {
139
+ return false
140
+ }
141
+ if (processAlive(parsed?.pid) !== false) return false
142
+ const quarantine = `${paths.lock}.stale-${process.pid}-${crypto.randomUUID()}`
143
+ try {
144
+ await fsp.rename(paths.lock, quarantine)
145
+ } catch {
146
+ return false
147
+ }
148
+ const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
149
+ if (moved === null || moved !== original) {
150
+ // A concurrent writer replaced the lock between read and rename. Put the
151
+ // newer record back rather than deleting it.
152
+ if (moved !== null) await fsp.rename(quarantine, paths.lock).catch(() => {})
153
+ return false
154
+ }
155
+ await fsp.unlink(quarantine).catch(() => {})
156
+ return true
157
+ }
158
+
101
159
  /**
102
- * Hold the repo-wide tower lock for one mutation. Stale locks are reported,
103
- * never silently stolen (matches the fail-closed protocol).
160
+ * Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
161
+ * provably dead is reclaimed atomically; a live lock is reported, never stolen.
104
162
  */
105
163
  export async function withLock(paths, fn, options = {}) {
106
164
  const timeoutMs = options.timeoutMs ?? 5000
107
165
  const pollMs = options.pollMs ?? 25
108
166
  const token = crypto.randomUUID()
109
- const record = JSON.stringify({ token, pid: process.pid, at: Date.now() })
167
+ const record = JSON.stringify({ token, pid: process.pid, startedAt: PROCESS_STARTED_AT, at: Date.now() })
110
168
  const deadline = Date.now() + timeoutMs
111
169
 
112
170
  for (;;) {
@@ -115,15 +173,12 @@ export async function withLock(paths, fn, options = {}) {
115
173
  break
116
174
  } catch (error) {
117
175
  if (error.code !== 'EEXIST') throw error
176
+ if (await reclaimDeadLock(paths)) continue
118
177
  if (Date.now() > deadline) {
119
- let owner = null
120
- try {
121
- owner = await fsp.readFile(paths.lock, 'utf8')
122
- } catch {
123
- owner = null
124
- }
178
+ const owner = await readStateLock(paths)
179
+ const payload = owner.raw ?? (owner.record ? JSON.stringify(owner.record) : null)
125
180
  throw new Error(
126
- `tower state is locked by another writer${owner ? ` (${owner})` : ''}; retry after it finishes`,
181
+ `tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
127
182
  )
128
183
  }
129
184
  await sleep(pollMs)
package/lib/tests.js CHANGED
@@ -253,6 +253,40 @@ export function parseJsonArray(stdout) {
253
253
  return { tests, ok: failed === 0, count: tests.length, failed, error: null }
254
254
  }
255
255
 
256
+ /** Test discovery is repository-controlled code: do not hand it secrets. */
257
+ const TEST_ENV_ALLOW = new Set([
258
+ 'PATH',
259
+ 'Path',
260
+ 'PATHEXT',
261
+ 'SystemRoot',
262
+ 'WINDIR',
263
+ 'ComSpec',
264
+ 'HOME',
265
+ 'USERPROFILE',
266
+ 'TMP',
267
+ 'TEMP',
268
+ 'TMPDIR',
269
+ 'LANG',
270
+ 'LC_ALL',
271
+ 'LC_CTYPE',
272
+ 'SHELL',
273
+ 'TERM',
274
+ 'APPDATA',
275
+ 'LOCALAPPDATA',
276
+ 'ProgramFiles',
277
+ 'ProgramFiles(x86)',
278
+ 'NODE_PATH',
279
+ ])
280
+
281
+ export function testProcessEnv(source = process.env) {
282
+ const env = {}
283
+ for (const [key, value] of Object.entries(source)) {
284
+ if (!TEST_ENV_ALLOW.has(key)) continue
285
+ env[key] = value
286
+ }
287
+ return env
288
+ }
289
+
256
290
  /** Platform shell for discovery commands (PowerShell 7 on Windows, bash on POSIX). */
257
291
  function shellSpec(platform = process.platform) {
258
292
  if (platform === 'win32') {
@@ -379,7 +413,7 @@ function spawnToFiles({ command, cwd, timeoutMs, maxBytes, stdoutPath, stderrPat
379
413
  windowsHide: true,
380
414
  // POSIX: a process group lets a timeout kill grandchildren too.
381
415
  detached: process.platform !== 'win32',
382
- env: process.env,
416
+ env: testProcessEnv(),
383
417
  })
384
418
  } catch (error) {
385
419
  spawnError = error