@nickmeriano/task 0.10.0 → 0.12.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 (101) hide show
  1. package/README.md +232 -1
  2. package/dist/asks.test.js +6 -0
  3. package/dist/asks.test.js.map +1 -1
  4. package/dist/board/github.d.ts +68 -0
  5. package/dist/board/github.d.ts.map +1 -0
  6. package/dist/board/github.js +112 -0
  7. package/dist/board/github.js.map +1 -0
  8. package/dist/board/handler.d.ts +50 -0
  9. package/dist/board/handler.d.ts.map +1 -0
  10. package/dist/board/handler.js +183 -0
  11. package/dist/board/handler.js.map +1 -0
  12. package/dist/board/handler.test.d.ts +11 -0
  13. package/dist/board/handler.test.d.ts.map +1 -0
  14. package/dist/board/handler.test.js +229 -0
  15. package/dist/board/handler.test.js.map +1 -0
  16. package/dist/board/pages-function.d.ts +23 -0
  17. package/dist/board/pages-function.d.ts.map +1 -0
  18. package/dist/board/pages-function.js +34 -0
  19. package/dist/board/pages-function.js.map +1 -0
  20. package/dist/board/source.d.ts +118 -0
  21. package/dist/board/source.d.ts.map +1 -0
  22. package/dist/board/source.js +333 -0
  23. package/dist/board/source.js.map +1 -0
  24. package/dist/board/source.test.d.ts +12 -0
  25. package/dist/board/source.test.d.ts.map +1 -0
  26. package/dist/board/source.test.js +165 -0
  27. package/dist/board/source.test.js.map +1 -0
  28. package/dist/board/tar.d.ts +28 -0
  29. package/dist/board/tar.d.ts.map +1 -0
  30. package/dist/board/tar.js +188 -0
  31. package/dist/board/tar.js.map +1 -0
  32. package/dist/board/tar.test.d.ts +9 -0
  33. package/dist/board/tar.test.d.ts.map +1 -0
  34. package/dist/board/tar.test.js +110 -0
  35. package/dist/board/tar.test.js.map +1 -0
  36. package/dist/claim-io.d.ts.map +1 -1
  37. package/dist/claim-io.js +4 -3
  38. package/dist/claim-io.js.map +1 -1
  39. package/dist/claim.d.ts +14 -2
  40. package/dist/claim.d.ts.map +1 -1
  41. package/dist/claim.js +9 -3
  42. package/dist/claim.js.map +1 -1
  43. package/dist/claim.test.js +41 -1
  44. package/dist/claim.test.js.map +1 -1
  45. package/dist/cli.js +127 -7
  46. package/dist/cli.js.map +1 -1
  47. package/dist/export.d.ts +163 -0
  48. package/dist/export.d.ts.map +1 -0
  49. package/dist/export.js +259 -0
  50. package/dist/export.js.map +1 -0
  51. package/dist/export.test.d.ts +12 -0
  52. package/dist/export.test.d.ts.map +1 -0
  53. package/dist/export.test.js +264 -0
  54. package/dist/export.test.js.map +1 -0
  55. package/dist/functions/board.js +709 -0
  56. package/dist/git-serve.d.ts +14 -1
  57. package/dist/git-serve.d.ts.map +1 -1
  58. package/dist/git-serve.js +30 -2
  59. package/dist/git-serve.js.map +1 -1
  60. package/dist/git-serve.test.d.ts +1 -0
  61. package/dist/git-serve.test.d.ts.map +1 -1
  62. package/dist/git-serve.test.js +36 -1
  63. package/dist/git-serve.test.js.map +1 -1
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +6 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/server.d.ts +6 -0
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +12 -3
  71. package/dist/server.js.map +1 -1
  72. package/dist/trailers.d.ts +51 -0
  73. package/dist/trailers.d.ts.map +1 -0
  74. package/dist/trailers.js +32 -0
  75. package/dist/trailers.js.map +1 -0
  76. package/package.json +3 -3
  77. package/skill/SKILL.md +16 -1
  78. package/src/asks.test.ts +15 -0
  79. package/src/board/github.ts +151 -0
  80. package/src/board/handler.test.ts +276 -0
  81. package/src/board/handler.ts +228 -0
  82. package/src/board/pages-function.ts +42 -0
  83. package/src/board/source.test.ts +203 -0
  84. package/src/board/source.ts +422 -0
  85. package/src/board/tar.test.ts +128 -0
  86. package/src/board/tar.ts +199 -0
  87. package/src/claim-io.ts +12 -3
  88. package/src/claim.test.ts +56 -1
  89. package/src/claim.ts +27 -4
  90. package/src/cli.ts +127 -7
  91. package/src/export.test.ts +361 -0
  92. package/src/export.ts +354 -0
  93. package/src/git-serve.test.ts +41 -1
  94. package/src/git-serve.ts +30 -2
  95. package/src/index.ts +10 -0
  96. package/src/server.ts +12 -3
  97. package/src/trailers.ts +65 -0
  98. package/ui/dist/assets/index-B8M_DaOt.js +229 -0
  99. package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
  100. package/ui/dist/index.html +3 -3
  101. package/ui/dist/assets/index-BjsorZOU.js +0 -229
@@ -0,0 +1,422 @@
1
+ /**
2
+ * A repository, read as boards.
3
+ *
4
+ * This is `findBoards` + `FileStore`, re-expressed against a git tree instead
5
+ * of a filesystem, and producing byte-identical API payloads. That
6
+ * equivalence is the whole point: the hosted board and an exported board's
7
+ * live proxy run the *same* SPA as `task serve`, so anything that differs
8
+ * here shows up as a broken screen rather than a type error. The shapes below
9
+ * are pinned to `server.ts` — change one and change both — and the ticket
10
+ * format itself is not restated: parsing comes from `ticket-doc.ts`, the one
11
+ * definition every server shares, in its lenient mode (a hand-edited file
12
+ * that no longer parses becomes a degraded ticket or a skipped comment, never
13
+ * a broken board).
14
+ *
15
+ * Read-only by construction. There is no code here that could write, which is
16
+ * how "the board can't edit your repo" stays true no matter what the routing
17
+ * layer does.
18
+ *
19
+ * Cache-free on purpose. A snapshot is immutable per commit, so *where* to
20
+ * keep one is a deployment question — the hosted worker layers isolate memory
21
+ * and KV over `snapshotAt`, the live proxy keeps a bounded `SnapshotMemory` —
22
+ * and this module answers only "what is at this commit".
23
+ */
24
+
25
+ import {
26
+ parseAskLenient,
27
+ parseCommentLenient,
28
+ parseGoalLenient,
29
+ parseTicketLenient,
30
+ type TicketDoc,
31
+ } from "../ticket-doc.ts"
32
+ import { isTicketKey } from "../id.ts"
33
+ import type { Ask, Comment, Goal, Task as StoredTask } from "../types.ts"
34
+ import { fetchRepoArchive, headCommit, type CommitInfo } from "./github.ts"
35
+
36
+ export { STATUSES, type Status } from "../types.ts"
37
+ export type { Ask, Comment, Goal }
38
+
39
+ const TASK_DIR = ".task"
40
+ const CONFIG_FILE = "config.json"
41
+ const TICKETS_DIR = "tickets"
42
+ const TICKET_FILE = "ticket.md"
43
+ const COMMENTS_DIR = "comments"
44
+ const ASKS_DIR = "asks"
45
+ const GOALS_DIR = "goals"
46
+
47
+ /** Same ceiling as the CLI's `findBoards`, for the same reason. */
48
+ const MAX_DEPTH = 6
49
+
50
+ export interface BoardInfo {
51
+ id: string
52
+ name: string
53
+ prefix: string
54
+ }
55
+
56
+ /** A stored task plus the count `GET /tasks` sends — exactly what `task serve` lists. */
57
+ export interface Task extends StoredTask {
58
+ commentCount: number
59
+ }
60
+
61
+ export interface BoardData {
62
+ info: BoardInfo
63
+ tasks: Task[]
64
+ /** Keyed by task key. */
65
+ comments: Map<string, Comment[]>
66
+ /** Live goals first, archived flagged — the shape `GET /goals` serves. */
67
+ goals: Goal[]
68
+ }
69
+
70
+ export interface Snapshot {
71
+ commit: CommitInfo
72
+ boards: BoardInfo[]
73
+ data: Map<string, BoardData>
74
+ /**
75
+ * Historically "the tree listing hit GitHub's cap". The tarball read is
76
+ * always complete, so this is now always false — kept because the SPA's
77
+ * payloads carry it.
78
+ */
79
+ truncated: boolean
80
+ }
81
+
82
+ export class BoardSourceError extends Error {
83
+ readonly status: number
84
+ /**
85
+ * Machine-readable cause, sent beside the message so the SPA can choose a
86
+ * screen rather than infer one from a status code. Optional: most of these
87
+ * are one of a kind and the message is the whole story.
88
+ */
89
+ readonly reason: string | null
90
+ constructor(status: number, message: string, reason: string | null = null) {
91
+ super(message)
92
+ this.name = "BoardSourceError"
93
+ this.status = status
94
+ this.reason = reason
95
+ }
96
+ }
97
+
98
+ function str(value: unknown, fallback = ""): string {
99
+ return typeof value === "string" ? value : fallback
100
+ }
101
+
102
+ function num(value: unknown, fallback = 0): number {
103
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback
104
+ }
105
+
106
+ interface ParsedConfig {
107
+ name: string
108
+ prefix: string
109
+ version: number
110
+ }
111
+
112
+ /** `.task/config.json`, defensively — it's a file anyone can hand-edit. */
113
+ function parseConfig(bytes: Uint8Array, fallbackName: string): ParsedConfig {
114
+ try {
115
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes))
116
+ if (parsed && typeof parsed === "object") {
117
+ const config = parsed as Record<string, unknown>
118
+ return {
119
+ name: str(config.name, fallbackName),
120
+ prefix: str(config.prefix, "TASK").toUpperCase(),
121
+ version: num(config.version, 1),
122
+ }
123
+ }
124
+ } catch {
125
+ // fall through
126
+ }
127
+ return { name: fallbackName, prefix: "TASK", version: 1 }
128
+ }
129
+
130
+ /**
131
+ * Board directories, from a flat list of repo paths.
132
+ *
133
+ * The CLI finds a board by the presence of `.task/config.json`; so does this.
134
+ * Depth is capped the same way, and the ordering — "." first, then
135
+ * lexicographic — is copied so the default board is the same one you'd get
136
+ * locally.
137
+ */
138
+ export function findBoardDirs(paths: string[]): string[] {
139
+ const marker = `${TASK_DIR}/${CONFIG_FILE}`
140
+ const dirs: string[] = []
141
+ for (const path of paths) {
142
+ // The directory has to be `.task` itself, not merely end in it — a bare
143
+ // suffix match would read `x.task/config.json` as a board at `x`.
144
+ if (path !== marker && !path.endsWith(`/${marker}`)) continue
145
+ const dir = path.slice(0, -(TASK_DIR.length + CONFIG_FILE.length + 2))
146
+ const id = dir === "" ? "." : dir.replace(/\/$/, "")
147
+ if (id !== "." && id.split("/").length > MAX_DEPTH) continue
148
+ dirs.push(id)
149
+ }
150
+ dirs.sort((a, b) => (a === "." ? -1 : b === "." ? 1 : a < b ? -1 : 1))
151
+ return dirs
152
+ }
153
+
154
+ /** `projects/phone/web` → "phone web"-ish fallback name, matching `task init`. */
155
+ function nameFromDir(id: string): string {
156
+ if (id === ".") return "tasks"
157
+ return id.split("/").pop() ?? "tasks"
158
+ }
159
+
160
+ /**
161
+ * One board from its ticket files. `files` is keyed by path relative to the
162
+ * board's `.task/` — `tickets/12/ticket.md`, `tickets/12/comments/<stem>.md` —
163
+ * which is how `snapshotAt` fetches them and how the test feeds them from
164
+ * disk.
165
+ */
166
+ export function readTicketsBoard(
167
+ id: string,
168
+ configBytes: Uint8Array,
169
+ files: Map<string, Uint8Array>,
170
+ ): BoardData {
171
+ const config = parseConfig(configBytes, nameFromDir(id))
172
+ const decoder = new TextDecoder()
173
+
174
+ const docs = new Map<string, TicketDoc>()
175
+ const comments = new Map<string, Comment[]>()
176
+ const asks = new Map<string, Ask[]>()
177
+ const goals: Goal[] = []
178
+ for (const [path, bytes] of files) {
179
+ // Goals: one file per goal, archived ones one level down. Old branches
180
+ // simply have no goals/ — the list stays empty and every reader tolerates
181
+ // a ticket's goal ref not resolving.
182
+ const goalMatch = new RegExp(`^${GOALS_DIR}/(archive/)?([^/]+)\\.md$`).exec(path)
183
+ if (goalMatch) {
184
+ if (goalMatch[2].startsWith(".")) continue
185
+ const doc = parseGoalLenient(decoder.decode(bytes))
186
+ if (doc) {
187
+ goals.push({
188
+ slug: goalMatch[2],
189
+ title: doc.title,
190
+ description: doc.description,
191
+ createdAt: doc.createdAt,
192
+ updatedAt: doc.updatedAt,
193
+ ...(goalMatch[1] ? { archived: true } : {}),
194
+ })
195
+ }
196
+ continue
197
+ }
198
+ const ticketMatch = new RegExp(`^${TICKETS_DIR}/([^/]+)/${TICKET_FILE}$`).exec(path)
199
+ if (ticketMatch && isTicketKey(ticketMatch[1])) {
200
+ const doc = parseTicketLenient(decoder.decode(bytes))
201
+ if (doc) docs.set(ticketMatch[1], doc)
202
+ continue
203
+ }
204
+ const commentMatch = new RegExp(`^${TICKETS_DIR}/([^/]+)/${COMMENTS_DIR}/([^/]+)\\.md$`).exec(
205
+ path,
206
+ )
207
+ if (commentMatch && isTicketKey(commentMatch[1]) && !commentMatch[2].startsWith(".")) {
208
+ const doc = parseCommentLenient(decoder.decode(bytes))
209
+ if (!doc) continue
210
+ const taskKey = commentMatch[1]
211
+ const list = comments.get(taskKey) ?? []
212
+ list.push({
213
+ id: commentMatch[2],
214
+ taskId: `${config.prefix}-${taskKey}`,
215
+ author: doc.author,
216
+ body: doc.body,
217
+ createdAt: doc.createdAt,
218
+ })
219
+ comments.set(taskKey, list)
220
+ continue
221
+ }
222
+ const askMatch = new RegExp(`^${TICKETS_DIR}/([^/]+)/${ASKS_DIR}/([^/]+)\\.md$`).exec(path)
223
+ if (askMatch && isTicketKey(askMatch[1]) && !askMatch[2].startsWith(".")) {
224
+ const doc = parseAskLenient(decoder.decode(bytes))
225
+ if (!doc) continue
226
+ const taskKey = askMatch[1]
227
+ const list = asks.get(taskKey) ?? []
228
+ // Ordinal assigned after the sort below — 0 is a placeholder.
229
+ list.push({
230
+ id: askMatch[2],
231
+ taskId: `${config.prefix}-${taskKey}`,
232
+ ordinal: 0,
233
+ text: doc.body,
234
+ author: doc.author,
235
+ createdAt: doc.createdAt,
236
+ resolvedAt: doc.resolvedAt,
237
+ resolvedBy: doc.resolvedBy,
238
+ })
239
+ asks.set(taskKey, list)
240
+ }
241
+ }
242
+ // Comments whose ticket file is missing or unreadable would count against a
243
+ // ticket that isn't drawn — drop them, and order the rest the CLI's way.
244
+ for (const taskKey of comments.keys()) {
245
+ if (!docs.has(taskKey)) comments.delete(taskKey)
246
+ }
247
+ for (const list of comments.values()) {
248
+ list.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
249
+ }
250
+ // Asks: same orphan rule, and ordinals are creation-order positions — the
251
+ // exact numbering the CLI's `asks()` derives.
252
+ for (const taskKey of asks.keys()) {
253
+ if (!docs.has(taskKey)) asks.delete(taskKey)
254
+ }
255
+ for (const list of asks.values()) {
256
+ list.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
257
+ list.forEach((ask, index) => (ask.ordinal = index + 1))
258
+ }
259
+
260
+ // `blocks` is derived from the other tickets' stored `blocked_by` — the
261
+ // relation lives on one side only, so the two views can't disagree.
262
+ const blocks = new Map<string, string[]>()
263
+ for (const [key, doc] of docs) {
264
+ for (const blocker of doc.blockedBy) {
265
+ blocks.set(blocker, [...(blocks.get(blocker) ?? []), key])
266
+ }
267
+ }
268
+ for (const list of blocks.values()) list.sort((a, b) => a.localeCompare(b))
269
+
270
+ const tasks: Task[] = [...docs.entries()]
271
+ .map(([key, doc]) => ({
272
+ id: `${config.prefix}-${key}`,
273
+ key,
274
+ title: doc.title,
275
+ description: doc.description,
276
+ status: doc.status,
277
+ tags: doc.tags,
278
+ goal: doc.goal,
279
+ needsHuman: (asks.get(key) ?? []).some((a) => !a.resolvedAt),
280
+ asks: asks.get(key) ?? [],
281
+ blocks: blocks.get(key) ?? [],
282
+ blockedBy: doc.blockedBy,
283
+ prs: doc.prs,
284
+ position: doc.position,
285
+ createdAt: doc.createdAt,
286
+ updatedAt: doc.updatedAt,
287
+ commentCount: comments.get(key)?.length ?? 0,
288
+ }))
289
+ .sort(
290
+ (a, b) =>
291
+ a.position - b.position ||
292
+ a.createdAt.localeCompare(b.createdAt) ||
293
+ a.key.localeCompare(b.key),
294
+ )
295
+
296
+ goals.sort(
297
+ (a, b) => Number(a.archived === true) - Number(b.archived === true) || a.slug.localeCompare(b.slug),
298
+ )
299
+ return { info: { id, name: config.name, prefix: config.prefix }, tasks, comments, goals }
300
+ }
301
+
302
+ /**
303
+ * Every board in a repository at a known commit, ready to serve.
304
+ *
305
+ * One request for the whole repository: every path, plus bytes for anything
306
+ * under a .task/ directory. (Earlier shapes — a tree listing plus per-file or
307
+ * batched blob reads — scaled subrequests with board size and tripped
308
+ * Workers' 50-subrequest ceiling; a tarball is one request regardless.)
309
+ *
310
+ * `ref` is only for the error message: told a branch, "this repository has
311
+ * no .task directory" is both wrong and confusing — the repository plainly
312
+ * has one, you were just looking at it — and `task init` is not the fix for
313
+ * previewing a branch that forked before the boards existed.
314
+ */
315
+ export async function snapshotAt(
316
+ token: string,
317
+ owner: string,
318
+ repo: string,
319
+ commit: CommitInfo,
320
+ ref = "HEAD",
321
+ ): Promise<Snapshot> {
322
+ const { paths, files } = await fetchRepoArchive(token, owner, repo, commit.sha, (path) =>
323
+ path.startsWith(`${TASK_DIR}/`) || path.includes(`/${TASK_DIR}/`),
324
+ )
325
+ const dirs = findBoardDirs(paths)
326
+ if (dirs.length === 0) {
327
+ throw new BoardSourceError(
328
+ 404,
329
+ ref === "HEAD"
330
+ ? "no .task directory in this repository — run `task init` and commit it"
331
+ : `no .task directory on ${ref}`,
332
+ "no-boards",
333
+ )
334
+ }
335
+
336
+ const data = new Map<string, BoardData>()
337
+ for (const id of dirs) {
338
+ const base = id === "." ? TASK_DIR : `${id}/${TASK_DIR}`
339
+ const configBytes = files.get(`${base}/${CONFIG_FILE}`)
340
+ if (!configBytes) continue
341
+
342
+ const prefix = `${base}/`
343
+ const ticketPaths = paths.filter(
344
+ (path) =>
345
+ (path.startsWith(`${prefix}${TICKETS_DIR}/`) || path.startsWith(`${prefix}${GOALS_DIR}/`)) &&
346
+ path.endsWith(".md"),
347
+ )
348
+ // A config with no committed tickets can be two very different things: a
349
+ // real (empty) text board, or a pre-0.6 board whose state is a SQLite
350
+ // database this app no longer reads. The config's version says which —
351
+ // skip the legacy one rather than render an empty board that lies.
352
+ if (ticketPaths.length === 0 && parseConfig(configBytes, "").version < 2) continue
353
+
354
+ const tickets = new Map<string, Uint8Array>()
355
+ for (const path of ticketPaths) {
356
+ const bytes = files.get(path)
357
+ if (bytes) tickets.set(path.slice(prefix.length), bytes)
358
+ }
359
+ const board = readTicketsBoard(id, configBytes, tickets)
360
+ data.set(board.info.id, board)
361
+ }
362
+ if (data.size === 0) {
363
+ throw new BoardSourceError(
364
+ 404,
365
+ "found .task/config.json but no committed tickets — are .task/tickets/ gitignored, or is this a pre-0.6 SQLite board that still needs `task migrate` (@nickmeriano/task@0.6)?",
366
+ )
367
+ }
368
+
369
+ return {
370
+ commit,
371
+ boards: [...data.values()].map((board) => board.info),
372
+ data,
373
+ truncated: false,
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Every board at `ref`, cache-free: "what is HEAD" plus one tarball. Callers
379
+ * that serve more than one view put a cache between the two — see
380
+ * `SnapshotMemory` — because the second request is the expensive one and a
381
+ * commit never changes.
382
+ */
383
+ export async function loadSnapshot(
384
+ token: string,
385
+ owner: string,
386
+ repo: string,
387
+ ref: string,
388
+ ): Promise<Snapshot> {
389
+ const commit = await headCommit(token, owner, repo, ref)
390
+ return snapshotAt(token, owner, repo, commit, ref)
391
+ }
392
+
393
+ /**
394
+ * Parsed snapshots, keyed by `owner/repo@sha`, bounded.
395
+ *
396
+ * A commit is immutable, so this never serves anything stale: the only lookup
397
+ * that goes to GitHub on a warm instance is "what is HEAD right now", and a
398
+ * new commit is a new key. Bounded because an isolate's memory is not ours to
399
+ * fill; `Map` iterates in insertion order, so the first key is the oldest.
400
+ */
401
+ export class SnapshotMemory {
402
+ private readonly snapshots = new Map<string, Snapshot>()
403
+ private readonly max: number
404
+
405
+ constructor(max = 24) {
406
+ this.max = max
407
+ }
408
+
409
+ get(key: string): Snapshot | undefined {
410
+ return this.snapshots.get(key)
411
+ }
412
+
413
+ remember(key: string, snapshot: Snapshot): Snapshot {
414
+ this.snapshots.set(key, snapshot)
415
+ while (this.snapshots.size > this.max) {
416
+ const oldest = this.snapshots.keys().next().value
417
+ if (oldest === undefined) break
418
+ this.snapshots.delete(oldest)
419
+ }
420
+ return snapshot
421
+ }
422
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The tar walk is hand-written against a binary format, so it's tested the
3
+ * way the SQLite reader used to be: against archives built independently —
4
+ * here by a minimal tar *writer* that produces the same shapes GitHub's
5
+ * tarballs contain (ustar headers, a pax_global_header, pax path overrides,
6
+ * a wrapping root directory), gzipped through node:zlib.
7
+ */
8
+
9
+ import assert from "node:assert/strict"
10
+ import { test } from "node:test"
11
+ import { gzipSync } from "node:zlib"
12
+ import { readTar, TarError } from "./tar.ts"
13
+
14
+ const encoder = new TextEncoder()
15
+
16
+ interface Entry {
17
+ name: string
18
+ type?: string
19
+ data?: string
20
+ /** Extra pax header naming this entry, the way GitHub emits long paths. */
21
+ paxPath?: string
22
+ }
23
+
24
+ function header(name: string, type: string, size: number): Uint8Array {
25
+ const block = new Uint8Array(512)
26
+ block.set(encoder.encode(name).subarray(0, 100), 0)
27
+ block.set(encoder.encode("0000644\0"), 100)
28
+ block.set(encoder.encode(size.toString(8).padStart(11, "0") + "\0"), 124)
29
+ block.set(encoder.encode("00000000000\0"), 136)
30
+ block[156] = type.charCodeAt(0)
31
+ block.set(encoder.encode("ustar\x0000"), 257)
32
+ // Checksum: field treated as spaces while summing.
33
+ block.set(encoder.encode(" "), 148)
34
+ const sum = block.reduce((a, b) => a + b, 0)
35
+ block.set(encoder.encode(sum.toString(8).padStart(6, "0") + "\0 "), 148)
36
+ return block
37
+ }
38
+
39
+ function tarball(entries: Entry[]): Uint8Array {
40
+ const blocks: Uint8Array[] = []
41
+ for (const entry of entries) {
42
+ if (entry.paxPath) {
43
+ const record = ` path=${entry.paxPath}\n`
44
+ const body = `${record.length + String(record.length + 2).length}${record}`
45
+ const bytes = encoder.encode(body)
46
+ blocks.push(header("./PaxHeaders/x", "x", bytes.length), pad(bytes))
47
+ }
48
+ const data = encoder.encode(entry.data ?? "")
49
+ blocks.push(header(entry.name, entry.type ?? "0", data.length))
50
+ if (data.length > 0) blocks.push(pad(data))
51
+ }
52
+ blocks.push(new Uint8Array(1024))
53
+ const total = blocks.reduce((n, b) => n + b.length, 0)
54
+ const out = new Uint8Array(total)
55
+ let at = 0
56
+ for (const block of blocks) {
57
+ out.set(block, at)
58
+ at += block.length
59
+ }
60
+ return out
61
+ }
62
+
63
+ function pad(data: Uint8Array): Uint8Array {
64
+ const padded = new Uint8Array(Math.ceil(data.length / 512) * 512)
65
+ padded.set(data)
66
+ return padded
67
+ }
68
+
69
+ function stream(bytes: Uint8Array): ReadableStream<Uint8Array> {
70
+ return new Response(gzipSync(bytes)).body!.pipeThrough(
71
+ new DecompressionStream("gzip"),
72
+ ) as ReadableStream<Uint8Array>
73
+ }
74
+
75
+ test("walks a GitHub-shaped tarball: root stripped, kept bytes kept, rest skipped", async () => {
76
+ const bytes = tarball([
77
+ { name: "pax_global_header", type: "g", data: "52 comment=1234\n" },
78
+ { name: "owner-repo-abc123/", type: "5" },
79
+ { name: "owner-repo-abc123/README.md", data: "# hi" },
80
+ { name: "owner-repo-abc123/.task/config.json", data: '{"name":"x","prefix":"X","version":2}' },
81
+ { name: "owner-repo-abc123/.task/tickets/1/ticket.md", data: "---\nstatus: todo\n---\n\n# T\n" },
82
+ { name: "owner-repo-abc123/big/blob.bin", data: "z".repeat(5000) },
83
+ { name: "owner-repo-abc123/pkg/.task/config.json", data: "{}" },
84
+ ])
85
+ const { paths, files } = await readTar(stream(bytes), (path) => path.includes(".task/"))
86
+ assert.deepEqual(paths, [
87
+ "README.md",
88
+ ".task/config.json",
89
+ ".task/tickets/1/ticket.md",
90
+ "big/blob.bin",
91
+ "pkg/.task/config.json",
92
+ ])
93
+ assert.deepEqual([...files.keys()], [
94
+ ".task/config.json",
95
+ ".task/tickets/1/ticket.md",
96
+ "pkg/.task/config.json",
97
+ ])
98
+ assert.equal(new TextDecoder().decode(files.get(".task/tickets/1/ticket.md")), "---\nstatus: todo\n---\n\n# T\n")
99
+ })
100
+
101
+ test("pax path records override the header name, as GitHub does for long paths", async () => {
102
+ const longPath = `owner-repo-abc123/${"deeply/".repeat(20)}.task/config.json`
103
+ const bytes = tarball([
104
+ { name: "owner-repo-abc123/x", paxPath: longPath, data: "{}" },
105
+ ])
106
+ const { paths, files } = await readTar(stream(bytes), () => true)
107
+ assert.deepEqual(paths, [longPath.slice("owner-repo-abc123/".length)])
108
+ assert.equal(files.size, 1)
109
+ })
110
+
111
+ test("ustar prefix fields are joined back onto the name", async () => {
112
+ const bytes = tarball([{ name: "owner-repo-abc123/plain.txt", data: "ok" }])
113
+ // Craft one entry using the prefix field by hand.
114
+ const block = header("tail.txt", "0", 2)
115
+ block.set(encoder.encode("owner-repo-abc123/some/dir"), 345)
116
+ const data = pad(encoder.encode("ok"))
117
+ const joined = new Uint8Array(bytes.length + block.length + data.length)
118
+ joined.set(block, 0)
119
+ joined.set(data, block.length)
120
+ joined.set(bytes, block.length + data.length)
121
+ const { paths } = await readTar(stream(joined), () => false)
122
+ assert.deepEqual(paths, ["some/dir/tail.txt", "plain.txt"])
123
+ })
124
+
125
+ test("a truncated archive throws instead of returning half a repository", async () => {
126
+ const bytes = tarball([{ name: "owner-repo-abc123/a.txt", data: "hello" }])
127
+ await assert.rejects(readTar(stream(bytes.subarray(0, 600)), () => true), TarError)
128
+ })