@nickmeriano/task 0.8.0 → 0.9.0

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 (114) hide show
  1. package/README.md +65 -22
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +21 -22
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +58 -83
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +2 -2
  21. package/dist/claim.test.js +111 -94
  22. package/dist/claim.test.js.map +1 -1
  23. package/dist/cli.js +241 -64
  24. package/dist/cli.js.map +1 -1
  25. package/dist/file-store.d.ts +70 -24
  26. package/dist/file-store.d.ts.map +1 -1
  27. package/dist/file-store.js +319 -141
  28. package/dist/file-store.js.map +1 -1
  29. package/dist/git-serve.d.ts +183 -0
  30. package/dist/git-serve.d.ts.map +1 -0
  31. package/dist/git-serve.js +503 -0
  32. package/dist/git-serve.js.map +1 -0
  33. package/dist/git-serve.test.d.ts +16 -0
  34. package/dist/git-serve.test.d.ts.map +1 -0
  35. package/dist/git-serve.test.js +183 -0
  36. package/dist/git-serve.test.js.map +1 -0
  37. package/dist/git.d.ts +65 -0
  38. package/dist/git.d.ts.map +1 -0
  39. package/dist/git.js +114 -0
  40. package/dist/git.js.map +1 -0
  41. package/dist/id.d.ts +39 -0
  42. package/dist/id.d.ts.map +1 -0
  43. package/dist/id.js +67 -0
  44. package/dist/id.js.map +1 -0
  45. package/dist/inbox.d.ts +41 -0
  46. package/dist/inbox.d.ts.map +1 -0
  47. package/dist/inbox.js +56 -0
  48. package/dist/inbox.js.map +1 -0
  49. package/dist/overview.d.ts +9 -0
  50. package/dist/overview.d.ts.map +1 -1
  51. package/dist/overview.js +19 -11
  52. package/dist/overview.js.map +1 -1
  53. package/dist/overview.test.js +6 -5
  54. package/dist/overview.test.js.map +1 -1
  55. package/dist/promote.test.d.ts +3 -2
  56. package/dist/promote.test.d.ts.map +1 -1
  57. package/dist/promote.test.js +29 -31
  58. package/dist/promote.test.js.map +1 -1
  59. package/dist/publish.d.ts +2 -17
  60. package/dist/publish.d.ts.map +1 -1
  61. package/dist/publish.js +4 -49
  62. package/dist/publish.js.map +1 -1
  63. package/dist/search.d.ts +3 -1
  64. package/dist/search.d.ts.map +1 -1
  65. package/dist/search.js +15 -2
  66. package/dist/search.js.map +1 -1
  67. package/dist/search.test.js +9 -9
  68. package/dist/search.test.js.map +1 -1
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +218 -21
  71. package/dist/server.js.map +1 -1
  72. package/dist/store.d.ts +28 -14
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js.map +1 -1
  75. package/dist/store.test.js +88 -75
  76. package/dist/store.test.js.map +1 -1
  77. package/dist/ticket-doc.d.ts +26 -10
  78. package/dist/ticket-doc.d.ts.map +1 -1
  79. package/dist/ticket-doc.js +71 -27
  80. package/dist/ticket-doc.js.map +1 -1
  81. package/dist/types.d.ts +51 -17
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js.map +1 -1
  84. package/package.json +1 -1
  85. package/skill/SKILL.md +70 -23
  86. package/src/asks.test.ts +355 -0
  87. package/src/check.test.ts +129 -72
  88. package/src/check.ts +101 -58
  89. package/src/claim-io.ts +401 -0
  90. package/src/claim.test.ts +141 -99
  91. package/src/claim.ts +69 -96
  92. package/src/cli.ts +251 -63
  93. package/src/file-store.ts +357 -145
  94. package/src/git-serve.test.ts +240 -0
  95. package/src/git-serve.ts +595 -0
  96. package/src/git.ts +141 -0
  97. package/src/id.ts +68 -0
  98. package/src/inbox.ts +77 -0
  99. package/src/overview.test.ts +6 -5
  100. package/src/overview.ts +25 -10
  101. package/src/promote.test.ts +40 -28
  102. package/src/publish.ts +6 -53
  103. package/src/search.test.ts +11 -11
  104. package/src/search.ts +17 -3
  105. package/src/server.ts +221 -19
  106. package/src/store.test.ts +95 -77
  107. package/src/store.ts +28 -13
  108. package/src/ticket-doc.ts +93 -30
  109. package/src/types.ts +52 -17
  110. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  111. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  112. package/ui/dist/index.html +2 -2
  113. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  114. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The git surface's contract (TAS-42), checked over real HTTP against real
3
+ * repositories — the properties that must hold before a board UI is allowed
4
+ * to run git:
5
+ *
6
+ * 1. Board commits are pathspec-scoped: with board AND non-board files dirty,
7
+ * committing from the API commits exactly the board files.
8
+ * 2. Checkout is refused while tracked non-board files are dirty — no
9
+ * stashing magic, the refusal says why.
10
+ * 3. The CSRF gate: a cross-origin browser write is 403 before any handler
11
+ * runs; the board's own origin and origin-less clients pass.
12
+ * 4. Degradation: a repo with no origin advertises no `git` capability and
13
+ * 404s the whole route group — the self-hiding rule the UI builds on.
14
+ */
15
+
16
+ import assert from "node:assert/strict"
17
+ import { after, test } from "node:test"
18
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
19
+ import { tmpdir } from "node:os"
20
+ import { join } from "node:path"
21
+ import { spawnSync } from "node:child_process"
22
+ import type { AddressInfo } from "node:net"
23
+ import type { Server } from "node:http"
24
+ import { initProject } from "./file-store.ts"
25
+ import { compareUrl } from "./git-serve.ts"
26
+ import { createTaskServer } from "./server.ts"
27
+
28
+ function tempDir(): string {
29
+ const dir = mkdtempSync(join(tmpdir(), "task-git-serve-test-"))
30
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
31
+ return dir
32
+ }
33
+
34
+ function sh(cwd: string, command: string, ...args: string[]): string {
35
+ const result = spawnSync(command, args, { cwd, encoding: "utf8" })
36
+ if (result.status !== 0) {
37
+ throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr}`)
38
+ }
39
+ return result.stdout.trim()
40
+ }
41
+
42
+ /** A clone with a board, its origin bare — the shape `task serve` runs in. */
43
+ function repoWithOrigin(): { clone: string; first: string } {
44
+ const home = tempDir()
45
+ const bare = join(home, "origin.git")
46
+ sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
47
+ const clone = join(home, "clone")
48
+ sh(home, "git", "clone", "--quiet", bare, clone)
49
+ sh(clone, "git", "config", "user.name", "Test")
50
+ sh(clone, "git", "config", "user.email", "test@example.com")
51
+ const store = initProject(clone, { name: "git board", prefix: "GIT" })
52
+ const first = store.create({ title: "First", status: "todo" }).key
53
+ writeFileSync(join(clone, "code.txt"), "original\n")
54
+ sh(clone, "git", "add", "-A")
55
+ sh(clone, "git", "commit", "--quiet", "-m", "board")
56
+ sh(clone, "git", "push", "--quiet", "-u", "origin", "main")
57
+ return { clone, first }
58
+ }
59
+
60
+ const servers: Server[] = []
61
+ after(() => {
62
+ for (const server of servers) server.close()
63
+ })
64
+
65
+ async function serve(root: string): Promise<string> {
66
+ const server = createTaskServer(root)
67
+ servers.push(server)
68
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
69
+ const { port } = server.address() as AddressInfo
70
+ return `http://127.0.0.1:${port}`
71
+ }
72
+
73
+ async function call(
74
+ base: string,
75
+ path: string,
76
+ init?: RequestInit,
77
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
78
+ const res = await fetch(`${base}${path}`, init)
79
+ return { status: res.status, body: (await res.json()) as Record<string, unknown> }
80
+ }
81
+
82
+ function post(body: unknown, headers: Record<string, string> = {}): RequestInit {
83
+ return {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json", ...headers },
86
+ body: JSON.stringify(body),
87
+ }
88
+ }
89
+
90
+ test("board commits are pathspec-scoped: mixed dirty tree, only board files land", async () => {
91
+ const { clone: repo, first } = repoWithOrigin()
92
+ const base = await serve(repo)
93
+
94
+ // Board dirt: edit a ticket and add a brand-new (untracked) one. Non-board
95
+ // dirt: a tracked file edited and an untracked scratch file.
96
+ const status = await call(base, "/api/git/status")
97
+ assert.equal(status.status, 200)
98
+ const patch = await call(
99
+ base,
100
+ `/api/tasks/${first}`,
101
+ Object.assign(post({ status: "in_progress" }), { method: "PATCH" }),
102
+ )
103
+ assert.equal(patch.status, 200)
104
+ const created = await call(base, "/api/tasks", post({ title: "Second" }))
105
+ assert.equal(created.status, 201)
106
+ const second = (created.body.task as { key: string }).key
107
+ writeFileSync(join(repo, "code.txt"), "changed\n")
108
+ writeFileSync(join(repo, "scratch.txt"), "notes\n")
109
+
110
+ const before = await call(base, "/api/git/status")
111
+ const dirty = before.body.dirty as { path: string; ticket: string | null }[]
112
+ assert.ok(dirty.length >= 2, JSON.stringify(before.body))
113
+ assert.ok(dirty.every((f) => f.path.startsWith(".task/")))
114
+ assert.deepEqual(
115
+ [...new Set(dirty.map((f) => f.ticket))].sort(),
116
+ [first, second].sort(),
117
+ )
118
+ assert.equal(before.body.otherDirty, 1) // code.txt — tracked; scratch.txt is untracked noise
119
+
120
+ const commit = await call(base, "/api/git/commit", post({ message: "chore(board): test" }))
121
+ assert.equal(commit.status, 200, JSON.stringify(commit.body))
122
+
123
+ // The commit holds board files only; the tree keeps its non-board dirt.
124
+ const committed = sh(repo, "git", "show", "--name-only", "--format=", "HEAD")
125
+ .split("\n")
126
+ .filter(Boolean)
127
+ assert.ok(committed.length >= 2)
128
+ assert.ok(committed.every((f) => f.startsWith(".task/")), committed.join(","))
129
+ const remaining = sh(repo, "git", "status", "--porcelain")
130
+ assert.ok(remaining.includes("code.txt"))
131
+ assert.ok(remaining.includes("scratch.txt"))
132
+ assert.ok(!remaining.includes(".task/"))
133
+
134
+ const after = await call(base, "/api/git/status")
135
+ assert.deepEqual(after.body.dirty, [])
136
+ assert.equal(after.body.ahead, 1) // committed, not pushed
137
+
138
+ // Nothing left to commit → clean refusal, not an empty commit.
139
+ const again = await call(base, "/api/git/commit", post({ message: "chore(board): empty" }))
140
+ assert.equal(again.status, 400)
141
+ assert.match(String(again.body.error), /nothing to commit/)
142
+ })
143
+
144
+ test("commit with newBranch cuts the branch first; checkout back is refused while code is dirty", async () => {
145
+ const { clone: repo, first } = repoWithOrigin()
146
+ const base = await serve(repo)
147
+
148
+ const patch = await call(
149
+ base,
150
+ `/api/tasks/${first}`,
151
+ Object.assign(post({ tags: ["x"] }), { method: "PATCH" }),
152
+ )
153
+ assert.equal(patch.status, 200)
154
+ writeFileSync(join(repo, "code.txt"), "work in flight\n")
155
+
156
+ const commit = await call(
157
+ base,
158
+ "/api/git/commit",
159
+ post({ message: "chore(board): branch it", newBranch: "board/test-1", base: "main" }),
160
+ )
161
+ assert.equal(commit.status, 200, JSON.stringify(commit.body))
162
+ assert.equal(commit.body.branch, "board/test-1")
163
+ assert.equal(sh(repo, "git", "branch", "--show-current"), "board/test-1")
164
+
165
+ // code.txt (tracked, dirty, outside every board dir) blocks the switch.
166
+ const refused = await call(base, "/api/git/checkout", post({ branch: "main" }))
167
+ assert.equal(refused.status, 409, JSON.stringify(refused.body))
168
+ assert.match(String(refused.body.error), /code\.txt/)
169
+ assert.equal(sh(repo, "git", "branch", "--show-current"), "board/test-1")
170
+
171
+ // Clean the code file and the same switch goes through.
172
+ sh(repo, "git", "checkout", "--", "code.txt")
173
+ const ok = await call(base, "/api/git/checkout", post({ branch: "main" }))
174
+ assert.equal(ok.status, 200, JSON.stringify(ok.body))
175
+ assert.equal(sh(repo, "git", "branch", "--show-current"), "main")
176
+ })
177
+
178
+ test("cross-origin writes are refused before any handler runs", async () => {
179
+ const { clone: repo } = repoWithOrigin()
180
+ const base = await serve(repo)
181
+
182
+ // A drive-by page POSTing at localhost: Origin names the attacker.
183
+ const evil = await call(
184
+ base,
185
+ "/api/git/commit",
186
+ post({ message: "pwn" }, { Origin: "http://evil.example" }),
187
+ )
188
+ assert.equal(evil.status, 403)
189
+ // The board's own pages pass — Origin matches the Host it was served from.
190
+ const own = await call(
191
+ base,
192
+ "/api/tasks",
193
+ post({ title: "From the board" }, { Origin: base }),
194
+ )
195
+ assert.equal(own.status, 201)
196
+ // And so do origin-less clients (curl, scripts) — the local-trust case.
197
+ const cli = await call(base, "/api/tasks", post({ title: "From a script" }))
198
+ assert.equal(cli.status, 201)
199
+ // Reads are uncontested either way.
200
+ const read = await call(base, "/api/git/status", { headers: { Origin: "http://evil.example" } })
201
+ assert.equal(read.status, 200)
202
+ })
203
+
204
+ test("a repo with no origin serves boards but no git surface at all", async () => {
205
+ const root = tempDir()
206
+ sh(root, "git", "init", "--quiet", "-b", "main", root)
207
+ sh(root, "git", "config", "user.name", "Test")
208
+ sh(root, "git", "config", "user.email", "test@example.com")
209
+ const store = initProject(root, { name: "loner", prefix: "LON" })
210
+ store.create({ title: "Only", status: "todo" })
211
+ const base = await serve(root)
212
+
213
+ const project = await call(base, "/api/project")
214
+ assert.equal(project.status, 200)
215
+ assert.equal("git" in project.body, false, JSON.stringify(project.body))
216
+
217
+ const status = await call(base, "/api/git/status")
218
+ assert.equal(status.status, 404)
219
+ const branches = await call(base, "/api/git/branches")
220
+ assert.equal(branches.status, 404)
221
+ })
222
+
223
+ test("a plain directory (no git at all) behaves the same", async () => {
224
+ const root = tempDir()
225
+ mkdirSync(join(root, "inner"))
226
+ const store = initProject(join(root, "inner"), { name: "bare", prefix: "BAR" })
227
+ store.create({ title: "Only", status: "todo" })
228
+ // Guard against the test machine's own enclosing repo: this only proves
229
+ // something when the temp dir is genuinely outside one, which tmpdir() is.
230
+ const base = await serve(join(root, "inner"))
231
+ const project = await call(base, "/api/project")
232
+ assert.equal("git" in project.body, false)
233
+ })
234
+
235
+ test("the gh-less PR fallback is the exact GitHub compare URL, branches escaped", () => {
236
+ assert.equal(
237
+ compareUrl({ owner: "nicmeriano", repo: "nickmeriano.com" }, "main", "board/2026-09-01"),
238
+ "https://github.com/nicmeriano/nickmeriano.com/compare/main...board%2F2026-09-01?expand=1",
239
+ )
240
+ })