dsh-taskboard 0.5.0 → 0.5.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.
Files changed (48) hide show
  1. package/README.md +15 -0
  2. package/lib/client.js +219 -161
  3. package/lib/host/execution.js +80 -33
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +49 -5
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/routes.js +180 -109
  8. package/lib/host/routes.js.map +1 -1
  9. package/lib/host/scheduler.js +50 -28
  10. package/lib/host/scheduler.js.map +1 -1
  11. package/lib/host/sdk.js +7 -2
  12. package/lib/host/sdk.js.map +1 -1
  13. package/lib/host/store.js +41 -8
  14. package/lib/host/store.js.map +1 -1
  15. package/lib/host/templates.js +10 -3
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +124 -93
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/index.js +3 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/shared/api.js.map +1 -1
  22. package/lib/shared/protocol.js +23 -2
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +3 -2
  25. package/src/client/api.ts +19 -9
  26. package/src/client/board/ImportModal.tsx +1 -1
  27. package/src/client/board/TaskBoard.tsx +7 -38
  28. package/src/client/board/TaskCard.tsx +3 -5
  29. package/src/client/board/TaskDetail.tsx +30 -21
  30. package/src/client/board/TaskFormModal.tsx +30 -23
  31. package/src/client/board/format.ts +26 -0
  32. package/src/client/board/labels.ts +44 -0
  33. package/src/client/controller.ts +60 -13
  34. package/src/client/index.ts +7 -5
  35. package/src/client/sidebar-entry.ts +5 -1
  36. package/src/host/execution.ts +90 -16
  37. package/src/host/git.ts +39 -10
  38. package/src/host/routes.ts +227 -126
  39. package/src/host/scheduler.ts +62 -36
  40. package/src/host/sdk.ts +12 -1
  41. package/src/host/store.ts +53 -7
  42. package/src/host/templates.ts +12 -3
  43. package/src/host/tools.ts +180 -123
  44. package/src/index.ts +10 -1
  45. package/src/shared/api.ts +1 -1
  46. package/src/shared/protocol.ts +35 -1
  47. package/src/shared/version.ts +1 -1
  48. package/src/client/board/NewTaskModal.tsx +0 -8
package/src/host/git.ts CHANGED
@@ -22,7 +22,8 @@
22
22
  *
23
23
  * @module dsh-taskboard/host/git
24
24
  */
25
- import type { CommitInfo } from '../shared/protocol.ts'
25
+ import { resolve } from 'node:path'
26
+ import { isValidTaskId, type CommitInfo } from '../shared/protocol.ts'
26
27
 
27
28
  /** Timeout for quick read-only queries (rev-parse / status / log / diff). */
28
29
  const QUICK_TIMEOUT_MS = 2_000
@@ -123,8 +124,12 @@ export interface GitFace {
123
124
  merge(root: string, branch: string): Promise<void>
124
125
  /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */
125
126
  isAncestor(root: string, branch: string): Promise<boolean>
126
- /** Remove a worktree; THROWS when it still has uncommitted changes. */
127
- removeWorktree(root: string, worktreePath: string): Promise<void>
127
+ /**
128
+ * Remove a worktree. Resolves 'removed' on success, 'unregistered' when git
129
+ * no longer knows the path (an orphaned directory). THROWS when it still
130
+ * has uncommitted changes, or on any other git failure (readable reason).
131
+ */
132
+ removeWorktree(root: string, worktreePath: string): Promise<'removed' | 'unregistered'>
128
133
  /** Delete a branch; THROWS (e.g. still checked out in a worktree). */
129
134
  deleteBranch(root: string, branch: string): Promise<void>
130
135
  /**
@@ -162,8 +167,16 @@ export function sanitizeBranchName(title: string, taskId: string): string {
162
167
  return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`
163
168
  }
164
169
 
165
- /** The canonical worktree path of a task inside its workspace (forward slashes). */
170
+ /**
171
+ * The canonical worktree path of a task inside its workspace (forward
172
+ * slashes). R4②: the id is validated HERE so every present and future call
173
+ * site is covered — a traversal-shaped id must never ride into a filesystem
174
+ * path (the cleanup/purge flows `rm -rf` what this returns).
175
+ */
166
176
  export function worktreePathOf(workspacePath: string, taskId: string): string {
177
+ if (!isValidTaskId(taskId)) {
178
+ throw new Error(`Error: invalid_input: illegal task id ${JSON.stringify(taskId.slice(0, 40))}`)
179
+ }
167
180
  const root = workspacePath.replace(/[\\/]+$/, '').replaceAll('\\', '/')
168
181
  return `${root}/${WORKTREE_DIR}/${taskId}`
169
182
  }
@@ -219,10 +232,15 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
219
232
  // worktree's own HEAD so evidence covers only the new run.
220
233
  if (mode === 'reuse') {
221
234
  const wtHead = await quick(['rev-parse', 'HEAD'], path)
222
- if (wtHead.ok && wtHead.stdout.trim().length > 0) {
235
+ // S14: a readable HEAD is not enough — the worktree must be on OUR
236
+ // branch, otherwise a user-created repo at the path would be silently
237
+ // taken over. Foreign or detached → fall through to fresh preparation.
238
+ const wtBranch = wtHead.ok ? await quick(['rev-parse', '--abbrev-ref', 'HEAD'], path) : undefined
239
+ if (wtHead.ok && wtHead.stdout.trim().length > 0
240
+ && wtBranch !== undefined && wtBranch.ok && wtBranch.stdout.trim() === branch) {
223
241
  return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }
224
242
  }
225
- // No live worktree → fall through to a fresh preparation.
243
+ // No live worktree on our branch → fall through to a fresh preparation.
226
244
  }
227
245
 
228
246
  // Baseline: the main worktree's current HEAD (also validates the repo).
@@ -302,7 +320,8 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
302
320
  return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)
303
321
  })
304
322
  if (dirtyLines.length > 0) {
305
- throw new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`)
323
+ // Machine-readable tag: callers classify without parsing zh-CN text.
324
+ throw Object.assign(new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: 'dirty-tree' })
306
325
  }
307
326
  }
308
327
  const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)
@@ -320,14 +339,24 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
320
339
  return r.ok
321
340
  },
322
341
 
323
- removeWorktree: (root, worktreePath) => withRootLock(root, async () => {
342
+ removeWorktree: (root, worktreePath) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
324
343
  const status = await quick(['status', '--porcelain'], worktreePath)
325
344
  if (status.ok && status.stdout.trim().length > 0) {
326
345
  const lines = status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
327
- throw new Error(`worktree ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`)
346
+ // Machine-readable tag: purge flows classify without parsing zh-CN text.
347
+ throw Object.assign(new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`), { code: 'dirty-worktree' })
328
348
  }
329
349
  const removed = await heavy(['worktree', 'remove', worktreePath], root)
330
- if (!removed.ok) throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)
350
+ if (removed.ok) return 'removed'
351
+ // S3: classify the failure WITHOUT parsing git's (localizable) stderr —
352
+ // a path absent from `worktree list` is an unregistered leftover, not
353
+ // an error the caller should relay verbatim.
354
+ const list = await quick(['worktree', 'list', '--porcelain'], root)
355
+ const registered = list.ok && list.stdout.split('\n')
356
+ .some(l => l.startsWith('worktree ')
357
+ && resolve(l.slice('worktree '.length).trim()).toLowerCase() === resolve(worktreePath).toLowerCase())
358
+ if (!registered) return 'unregistered'
359
+ throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)
331
360
  }),
332
361
 
333
362
  deleteBranch: (root, branch) => withRootLock(root, async () => {