@nickmeriano/task 0.8.0 → 0.10.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.
- package/README.md +137 -22
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +3 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -60
- package/dist/check.js.map +1 -1
- package/dist/check.test.js +99 -60
- package/dist/check.test.js.map +1 -1
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +108 -23
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +320 -86
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +10 -2
- package/dist/claim.test.d.ts.map +1 -1
- package/dist/claim.test.js +282 -96
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +346 -68
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +70 -24
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +319 -141
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- package/dist/overview.d.ts +9 -0
- package/dist/overview.d.ts.map +1 -1
- package/dist/overview.js +19 -11
- package/dist/overview.js.map +1 -1
- package/dist/overview.test.js +6 -5
- package/dist/overview.test.js.map +1 -1
- package/dist/promote.test.d.ts +3 -2
- package/dist/promote.test.d.ts.map +1 -1
- package/dist/promote.test.js +29 -31
- package/dist/promote.test.js.map +1 -1
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +3 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +15 -2
- package/dist/search.js.map +1 -1
- package/dist/search.test.js +9 -9
- package/dist/search.test.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +218 -21
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +28 -14
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/store.test.js +88 -75
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +26 -10
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +71 -27
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +51 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +96 -24
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +129 -72
- package/src/check.ts +101 -58
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +372 -101
- package/src/claim.ts +418 -99
- package/src/cli.ts +370 -67
- package/src/file-store.ts +357 -145
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/overview.test.ts +6 -5
- package/src/overview.ts +25 -10
- package/src/promote.test.ts +40 -28
- package/src/publish.ts +6 -53
- package/src/search.test.ts +11 -11
- package/src/search.ts +17 -3
- package/src/server.ts +221 -19
- package/src/store.test.ts +95 -77
- package/src/store.ts +28 -13
- package/src/ticket-doc.ts +93 -30
- package/src/types.ts +52 -17
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BJmOsOdR.js +0 -229
- 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
|
+
})
|