@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
package/src/export.ts ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * `task export` — the board as a directory of files.
3
+ *
4
+ * The third deployment of this app (after `task serve` and the hosted board)
5
+ * is a static host: the same prebuilt UI the package already ships for serve,
6
+ * plus a `snapshot.json` this module writes out of the local `.task/`. There
7
+ * is no server behind it, so the app reads its answers out of that file —
8
+ * `ui/src/snapshot.ts` is the reader, and this is the writer.
9
+ *
10
+ * The whole correctness question is one sentence: **the snapshot must answer
11
+ * what `routeApi` answers**. Every field below is sourced from the same store
12
+ * call the matching `/api/…` route makes, so a board rendered from a file and
13
+ * a board rendered from the server differ only in how stale they are.
14
+ *
15
+ * Split in two on purpose: `buildSnapshot` is the shape (no writes, so it is
16
+ * testable without a built UI, and the UI's own test can round-trip it through
17
+ * `parseSnapshot`), `writeExport` is the directory.
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process"
21
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
22
+ import { dirname, join, resolve } from "node:path"
23
+ import { fileURLToPath } from "node:url"
24
+ import { resolveAuthor } from "./author.ts"
25
+ import { REPO_PATTERN } from "./board/handler.ts"
26
+ import { openBoard } from "./file-store.ts"
27
+ import { findBoards } from "./store.ts"
28
+ import { STATUSES, type Comment, type Goal, type Status, type Task } from "./types.ts"
29
+
30
+ /**
31
+ * The version the reader accepts, restated from `ui/src/snapshot.ts`. The UI
32
+ * is built separately from this NodeNext package, so the contract is mirrored
33
+ * rather than cross-imported — the same precedent `ui/src/api.ts` sets for
34
+ * `types.ts`. `ui/src/deployment.test.ts` imports `buildSnapshot` and feeds
35
+ * the result to the real `parseSnapshot`, which is what keeps the two honest.
36
+ */
37
+ export const SNAPSHOT_VERSION = 2
38
+
39
+ /** The file's name in the output directory, matching `SNAPSHOT_PATH`. */
40
+ export const SNAPSHOT_FILE = "snapshot.json"
41
+
42
+ /** Where an export goes when `--out` doesn't say. */
43
+ export const DEFAULT_OUT_DIR = "task-export"
44
+
45
+ /**
46
+ * The live-proxy function, bundled by `vite.functions.config.ts` into one
47
+ * dependency-free file. Resolved from this module the way `UI_DIR` is: the
48
+ * same relative path works from `src/` (run from source) and from `dist/`.
49
+ */
50
+ export const FUNCTION_BUNDLE = fileURLToPath(new URL("../dist/functions/board.js", import.meta.url))
51
+
52
+ /** The sentence for a missing bundle, in the shape `NO_UI_BUILD` sets. */
53
+ const NO_FUNCTION_BUILD =
54
+ "function bundle not found — run the package build (vite build -c vite.functions.config.ts)."
55
+
56
+ /** The sentence `serveStatic` prints for the same missing directory. */
57
+ const NO_UI_BUILD = "UI build not found — run the package build (vite build ui)."
58
+
59
+ /**
60
+ * The assets are built with relative refs (`base: "./"` in ui/vite.config.ts)
61
+ * because one build ships three ways and can't know where it will live. The
62
+ * `<base href>` each deployment stamps into index.html is what completes them:
63
+ * the browser resolves `./assets/…` — and the app resolves its routes, API and
64
+ * snapshot fetch — against it instead of the page's own URL, which is what
65
+ * keeps deep links working under any mount point.
66
+ */
67
+
68
+ /** `board` / `/board` / `board/` → `/board/`; `""` and `/` → `/`. */
69
+ export function normalizeBase(base: string): string {
70
+ let path = base.trim()
71
+ if (path === "" || path === "/") return "/"
72
+ if (!path.startsWith("/")) path = `/${path}`
73
+ if (!path.endsWith("/")) path = `${path}/`
74
+ if (path.includes("//") || /[\s<>"']/.test(path)) {
75
+ throw new Error(`--base must be a plain URL path like /board/ — got ${JSON.stringify(base)}`)
76
+ }
77
+ return path
78
+ }
79
+
80
+ /**
81
+ * Stamp the deployment's mount point into the built page. The built
82
+ * index.html always has exactly one `<head>` for this to anchor on; refusing
83
+ * a page without one beats silently shipping a board that 404s its assets.
84
+ */
85
+ export function injectBase(html: string, base: string): string {
86
+ if (!html.includes("<head>")) {
87
+ throw new Error("index.html has no <head> to carry the <base> tag — is this the built UI?")
88
+ }
89
+ return html.replace("<head>", `<head><base href="${normalizeBase(base)}" />`)
90
+ }
91
+
92
+ export interface SnapshotMeta {
93
+ /** The commit exported from (`git rev-parse HEAD`), or null outside a repo. */
94
+ commit: string | null
95
+ /** When the export ran, ISO-8601 — the other half of the staleness banner. */
96
+ builtAt: string
97
+ }
98
+
99
+ /** What `/api/boards` answers, per board. */
100
+ export interface SnapshotBoardInfo {
101
+ id: string
102
+ name: string
103
+ prefix: string
104
+ }
105
+
106
+ /** What `/api/project` answers. `readOnly` is the reader's call, not ours. */
107
+ export interface SnapshotProject {
108
+ name: string
109
+ prefix: string
110
+ statuses: readonly Status[]
111
+ author: string
112
+ }
113
+
114
+ /** One board's baked answers, in the shapes the serve API returns. */
115
+ export interface SnapshotBoard {
116
+ project: SnapshotProject
117
+ tasks: Task[]
118
+ goals: Goal[]
119
+ /** Task key to its comments, oldest first; absent means none. */
120
+ comments: Record<string, Comment[]>
121
+ }
122
+
123
+ export interface Snapshot {
124
+ version: number
125
+ meta: SnapshotMeta
126
+ boards: SnapshotBoardInfo[]
127
+ data: Record<string, SnapshotBoard>
128
+ }
129
+
130
+ export interface BuildSnapshotOptions {
131
+ /** Overridable so tests get a fixed build time. */
132
+ builtAt?: string
133
+ }
134
+
135
+ /**
136
+ * The commit this export is of.
137
+ *
138
+ * Every way this can fail — git not installed, not a repository, a repo with
139
+ * no commits yet — is the same non-answer, so they all collapse to null. The
140
+ * reader's type already says `string | null` and its banner degrades to
141
+ * "snapshot, built …", which is the honest thing to show.
142
+ */
143
+ export function exportedCommit(cwd: string): string | null {
144
+ try {
145
+ const out = execFileSync("git", ["rev-parse", "HEAD"], {
146
+ cwd,
147
+ encoding: "utf8",
148
+ timeout: 2000,
149
+ stdio: ["ignore", "pipe", "ignore"],
150
+ })
151
+ return out.trim() || null
152
+ } catch {
153
+ return null
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Bake every board at or below `serveRoot` into one snapshot.
159
+ *
160
+ * Anchoring matches `cmdServe` exactly (`findRoot` ?? cwd, then `findBoards`),
161
+ * so "export what serve would serve" is true by construction — one board in a
162
+ * package, every nested board at a monorepo root, `.` first either way.
163
+ */
164
+ export function buildSnapshot(serveRoot: string, options: BuildSnapshotOptions = {}): Snapshot {
165
+ const refs = findBoards(serveRoot)
166
+ if (refs.length === 0) {
167
+ throw new Error(
168
+ `no .task directory found at or below ${serveRoot} — run \`task init\` first`,
169
+ )
170
+ }
171
+ // Resolved once, server-side, exactly as `createTaskServer` does it: all the
172
+ // boards live in one checkout, so one resolution covers them.
173
+ const author = resolveAuthor(undefined, serveRoot).name
174
+
175
+ const boards: SnapshotBoardInfo[] = []
176
+ const data: Record<string, SnapshotBoard> = {}
177
+ for (const ref of refs) {
178
+ const store = openBoard(ref.root)
179
+ try {
180
+ boards.push({ id: ref.id, name: store.config.name, prefix: store.config.prefix })
181
+ const counts = store.commentCounts()
182
+ const tasks = store.list().map((task) => ({
183
+ ...task,
184
+ commentCount: counts.get(task.key) ?? 0,
185
+ }))
186
+ const comments: Record<string, Comment[]> = {}
187
+ for (const task of tasks) {
188
+ const list = store.comments(task.key)
189
+ // Omitted rather than empty: the reader defaults a missing key to [],
190
+ // and a snapshot is a file people open.
191
+ if (list.length > 0) comments[task.key] = list
192
+ }
193
+ data[ref.id] = {
194
+ project: { name: store.config.name, prefix: store.config.prefix, statuses: STATUSES, author },
195
+ tasks,
196
+ // Archived goals ride along flagged, as `/api/goals` sends them, so an
197
+ // old chip on a task can still resolve a title.
198
+ goals: [...store.goals(), ...store.goals(true)],
199
+ comments,
200
+ }
201
+ } finally {
202
+ store.close()
203
+ }
204
+ }
205
+
206
+ return {
207
+ version: SNAPSHOT_VERSION,
208
+ meta: {
209
+ commit: exportedCommit(serveRoot),
210
+ builtAt: options.builtAt ?? new Date().toISOString(),
211
+ },
212
+ boards,
213
+ data,
214
+ }
215
+ }
216
+
217
+ export interface WriteExportOptions {
218
+ serveRoot: string
219
+ outDir: string
220
+ /** The built SPA to copy. Injectable so tests never need a vite build. */
221
+ uiDir: string
222
+ /** Write into a directory that isn't a previous export. Never deletes. */
223
+ force?: boolean
224
+ /** URL path the export will be served under (default `/`) — see `injectBase`. */
225
+ base?: string
226
+ builtAt?: string
227
+ /**
228
+ * Also write the live-proxy function — `renderFunction` at `functionPath`.
229
+ * `repo` is the
230
+ * GitHub repository it will read, `owner/name`; `bundle` is the built
231
+ * function to copy, injectable so tests never need a vite build.
232
+ */
233
+ functions?: { repo: string; bundle?: string }
234
+ }
235
+
236
+ export interface ExportSummary {
237
+ outDir: string
238
+ base: string
239
+ boards: number
240
+ tasks: number
241
+ commit: string | null
242
+ builtAt: string
243
+ /** Path of the function written, relative to `outDir`; null when none was. */
244
+ functions: string | null
245
+ }
246
+
247
+ /**
248
+ * Where a Cloudflare Pages Function has to sit to answer `<base>api/*`: the
249
+ * `functions/` tree mirrors URL paths, and `[[path]]` is Pages' catch-all.
250
+ */
251
+ export function functionPath(base: string): string {
252
+ const mount = normalizeBase(base).slice(1) // "" or "board/"
253
+ return join("functions", ...mount.split("/").filter(Boolean), "api", "[[path]].js")
254
+ }
255
+
256
+ /**
257
+ * The bundled function, with the two things that are per-deploy filled in.
258
+ *
259
+ * Both replacements are validated first because they land inside string
260
+ * literals in a file that gets executed: `REPO_PATTERN` admits no quote, and
261
+ * `normalizeBase` refuses one.
262
+ */
263
+ export function renderFunction(bundle: string, repo: string, base: string): string {
264
+ if (!REPO_PATTERN.test(repo)) {
265
+ throw new Error(`--repo must be owner/name — got ${JSON.stringify(repo)}`)
266
+ }
267
+ const basePath = `${normalizeBase(base)}api`
268
+ for (const placeholder of ["__TASK_EXPORT_REPO__", "__TASK_EXPORT_BASE_PATH__"]) {
269
+ if (!bundle.includes(placeholder)) {
270
+ throw new Error(`function bundle has no ${placeholder} to fill — is this the built function?`)
271
+ }
272
+ }
273
+ return bundle
274
+ .replace("__TASK_EXPORT_REPO__", repo)
275
+ .replace("__TASK_EXPORT_BASE_PATH__", basePath)
276
+ }
277
+
278
+ /**
279
+ * Write the assets and the snapshot into `outDir`.
280
+ *
281
+ * Refresh is the interesting case. Re-exporting over a previous export
282
+ * replaces the UI's own entries and `snapshot.json` and leaves everything else
283
+ * alone — notably a `functions/` directory, so a deployed live-proxy function
284
+ * survives a re-export. Anything else in a non-empty directory is refused
285
+ * rather than overwritten: `--out .` against a repo is the one genuinely
286
+ * destructive mistake available here.
287
+ */
288
+ export function writeExport(options: WriteExportOptions): ExportSummary {
289
+ const { serveRoot, outDir, uiDir, force = false } = options
290
+ const base = normalizeBase(options.base ?? "/")
291
+ if (!existsSync(uiDir)) throw new Error(NO_UI_BUILD)
292
+ // Exporting onto the assets it copies from would clear them first and then
293
+ // copy an empty directory — the one way this can destroy the package itself.
294
+ if (resolve(outDir) === resolve(uiDir)) {
295
+ throw new Error(`${outDir} is the UI build itself — export somewhere else`)
296
+ }
297
+
298
+ const snapshot = buildSnapshot(serveRoot, { builtAt: options.builtAt })
299
+ // Rendered before anything is written, so a bad --repo or a missing bundle
300
+ // fails the export rather than leaving half of one behind.
301
+ const functions = options.functions
302
+ ? {
303
+ path: functionPath(base),
304
+ source: renderFunction(
305
+ readFunctionBundle(options.functions.bundle ?? FUNCTION_BUNDLE),
306
+ options.functions.repo,
307
+ base,
308
+ ),
309
+ }
310
+ : null
311
+
312
+ const existing = existsSync(outDir) ? readdirSync(outDir) : null
313
+ if (existing && existing.length > 0 && !existing.includes(SNAPSHOT_FILE) && !force) {
314
+ throw new Error(
315
+ `${outDir} is not empty and isn't a previous export — pass --force to write into it anyway`,
316
+ )
317
+ }
318
+
319
+ mkdirSync(outDir, { recursive: true })
320
+ // Clear only what this export is about to write: a stale `assets/` full of
321
+ // last build's hashed bundles is dead weight, and everything else in there
322
+ // belongs to whoever put it there.
323
+ for (const entry of readdirSync(uiDir)) {
324
+ rmSync(join(outDir, entry), { recursive: true, force: true })
325
+ }
326
+ cpSync(uiDir, outDir, { recursive: true })
327
+ const indexPath = join(outDir, "index.html")
328
+ writeFileSync(indexPath, injectBase(readFileSync(indexPath, "utf8"), base))
329
+ // Pretty-printed: this lands in CI artifacts and gets diffed, and at this
330
+ // size the whitespace costs nothing.
331
+ writeFileSync(join(outDir, SNAPSHOT_FILE), `${JSON.stringify(snapshot, null, 2)}\n`)
332
+ // The function is the one thing under `functions/` this export owns; the
333
+ // rest of that directory is whoever's it was. Without `functions`, a
334
+ // function from an earlier export stays exactly as deployed.
335
+ if (functions) {
336
+ mkdirSync(dirname(join(outDir, functions.path)), { recursive: true })
337
+ writeFileSync(join(outDir, functions.path), functions.source)
338
+ }
339
+
340
+ return {
341
+ outDir,
342
+ base,
343
+ boards: snapshot.boards.length,
344
+ tasks: Object.values(snapshot.data).reduce((n, board) => n + board.tasks.length, 0),
345
+ commit: snapshot.meta.commit,
346
+ builtAt: snapshot.meta.builtAt,
347
+ functions: functions?.path ?? null,
348
+ }
349
+ }
350
+
351
+ function readFunctionBundle(path: string): string {
352
+ if (!existsSync(path)) throw new Error(NO_FUNCTION_BUILD)
353
+ return readFileSync(path, "utf8")
354
+ }
@@ -11,11 +11,12 @@
11
11
  * runs; the board's own origin and origin-less clients pass.
12
12
  * 4. Degradation: a repo with no origin advertises no `git` capability and
13
13
  * 404s the whole route group — the self-hiding rule the UI builds on.
14
+ * 5. A board reached through a symlinked path is still a board (TAS-qn8td).
14
15
  */
15
16
 
16
17
  import assert from "node:assert/strict"
17
18
  import { after, test } from "node:test"
18
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
19
+ import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
19
20
  import { tmpdir } from "node:os"
20
21
  import { join } from "node:path"
21
22
  import { spawnSync } from "node:child_process"
@@ -232,6 +233,45 @@ test("a plain directory (no git at all) behaves the same", async () => {
232
233
  assert.equal("git" in project.body, false)
233
234
  })
234
235
 
236
+ test("a repo reached through a symlink still finds its boards", async () => {
237
+ // `git rev-parse --show-toplevel` resolves symlinks and a Store's taskDir
238
+ // does not, so comparing them unresolved drops every board — commit 400s and
239
+ // status calls board files `otherDirty`, quietly. The symlink is explicit
240
+ // here rather than inherited from tmpdir(): on macOS /var/folders is already
241
+ // one (which is how this was found), on Linux it is not, and the property
242
+ // has to hold on both.
243
+ const home = tempDir()
244
+ const real = join(home, "real")
245
+ mkdirSync(real)
246
+ const bare = join(real, "origin.git")
247
+ sh(real, "git", "init", "--quiet", "--bare", "-b", "main", bare)
248
+ sh(real, "git", "clone", "--quiet", bare, join(real, "clone"))
249
+ const link = join(home, "link")
250
+ symlinkSync(real, link)
251
+
252
+ const clone = join(link, "clone")
253
+ sh(clone, "git", "config", "user.name", "Test")
254
+ sh(clone, "git", "config", "user.email", "test@example.com")
255
+ const store = initProject(clone, { name: "linked", prefix: "LNK" })
256
+ const first = store.create({ title: "First", status: "todo" }).key
257
+ sh(clone, "git", "add", "-A")
258
+ sh(clone, "git", "commit", "--quiet", "-m", "board")
259
+ sh(clone, "git", "push", "--quiet", "-u", "origin", "main")
260
+
261
+ const base = await serve(clone)
262
+ await call(base, `/api/tasks/${first}`, Object.assign(post({ status: "in_progress" }), { method: "PATCH" }))
263
+
264
+ const status = await call(base, "/api/git/status")
265
+ assert.equal(status.status, 200)
266
+ const dirty = status.body.dirty as { path: string }[]
267
+ assert.ok(dirty.length > 0, `board edits must read as board dirt: ${JSON.stringify(status.body)}`)
268
+ assert.ok(dirty.every((f) => f.path.startsWith(".task/")))
269
+ assert.equal(status.body.otherDirty, 0, "and must not be counted as other people's mess")
270
+
271
+ const commit = await call(base, "/api/git/commit", post({ message: "chore(board): through a link" }))
272
+ assert.equal(commit.status, 200, JSON.stringify(commit.body))
273
+ })
274
+
235
275
  test("the gh-less PR fallback is the exact GitHub compare URL, branches escaped", () => {
236
276
  assert.equal(
237
277
  compareUrl({ owner: "nicmeriano", repo: "nickmeriano.com" }, "main", "board/2026-09-01"),
package/src/git-serve.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { spawnSync } from "node:child_process"
22
+ import { realpathSync } from "node:fs"
22
23
  import { relative, sep } from "node:path"
23
24
  import { claimBranch, claimNamespace, release } from "./claim.ts"
24
25
  import { remoteClaimTips, syncClaimRefs } from "./claim-io.ts"
@@ -31,6 +32,19 @@ export interface ServedBoard {
31
32
  store: Store
32
33
  }
33
34
 
35
+ /**
36
+ * A path with its symlinks resolved, or the path itself when it cannot be —
37
+ * a board directory that does not exist on disk yet makes `realpathSync`
38
+ * throw ENOENT, and that is not a reason to lose the board.
39
+ */
40
+ function realPath(path: string): string {
41
+ try {
42
+ return realpathSync(path)
43
+ } catch {
44
+ return path
45
+ }
46
+ }
47
+
34
48
  /** A failure the route turns into a JSON error with this status. */
35
49
  export class GitServeError extends Error {
36
50
  readonly status: number
@@ -194,11 +208,25 @@ export class GitServe {
194
208
  return records
195
209
  }
196
210
 
197
- /** Each board's `.task` dir as a toplevel-relative posix pathspec. */
211
+ /**
212
+ * Each board's `.task` dir as a toplevel-relative posix pathspec.
213
+ *
214
+ * Both sides are resolved through {@link realPath} before they are
215
+ * compared. `git rev-parse --show-toplevel` already returns a symlink-free
216
+ * path, while a store's `taskDir` is built from whatever path the process
217
+ * was given — so on a checkout reached through a symlink the two disagree,
218
+ * `relative()` answers `../../…`, and the guard below silently drops every
219
+ * board. That is not a corner case on macOS, where `os.tmpdir()` is
220
+ * `/var/folders/…` → `/private/var/folders/…`; it is also any repo under a
221
+ * symlinked parent. Dropping every board turns `commit` into a 400 and,
222
+ * worse, makes `status` report board files as `otherDirty` with an empty
223
+ * `dirty` list — a board that looks clean while `.task/` is full of edits.
224
+ */
198
225
  private boardDirs(top: string): Map<string, string> {
226
+ const root = realPath(top)
199
227
  const dirs = new Map<string, string>()
200
228
  for (const { id, store } of this.boards()) {
201
- const rel = relative(top, store.taskDir).split(sep).join("/")
229
+ const rel = relative(root, realPath(store.taskDir)).split(sep).join("/")
202
230
  if (rel === "" || rel.startsWith("..")) continue
203
231
  dirs.set(id, rel)
204
232
  }
package/src/index.ts CHANGED
@@ -42,5 +42,15 @@ export {
42
42
  type ReleaseResult,
43
43
  } from "./claim.ts"
44
44
  export { createTaskServer } from "./server.ts"
45
+ // The live proxy for an exported board: the same read routes `task serve`
46
+ // answers, from a GitHub repository, as one platform-neutral fetch handler.
47
+ // `task export` ships it as a Cloudflare Pages Function beside the snapshot;
48
+ // any other host gets this and a three-line adapter — see the README.
49
+ export {
50
+ createBoardHandler,
51
+ type BoardHandler,
52
+ type BoardHandlerOptions,
53
+ } from "./board/handler.ts"
54
+ export { buildSnapshot, writeExport, type ExportSummary, type WriteExportOptions } from "./export.ts"
45
55
  export { resolveAuthor, ANONYMOUS, type Author, type AuthorSource } from "./author.ts"
46
56
  export * from "./types.ts"
package/src/server.ts CHANGED
@@ -4,14 +4,19 @@ import { extname, join, normalize } from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
5
  import { resolveAuthor } from "./author.ts"
6
6
  import { addAskRouted, deleteAskRouted, setAskResolvedRouted } from "./claim-io.ts"
7
+ import { injectBase } from "./export.ts"
7
8
  import { openBoard } from "./file-store.ts"
8
9
  import { GitServe, GitServeError } from "./git-serve.ts"
9
10
  import { buildInbox } from "./inbox.ts"
10
11
  import { boardConfig, findBoards, type BoardRef, type Store } from "./store.ts"
11
12
  import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
12
13
 
13
- /** The prebuilt SPA, shipped inside the package next to dist/. */
14
- const UI_DIR = fileURLToPath(new URL("../ui/dist", import.meta.url))
14
+ /**
15
+ * The prebuilt SPA, shipped inside the package next to dist/. Exported because
16
+ * `task export` copies the same assets it serves — one definition of where the
17
+ * UI lives, or the two deployments drift.
18
+ */
19
+ export const UI_DIR = fileURLToPath(new URL("../ui/dist", import.meta.url))
15
20
 
16
21
  const MIME: Record<string, string> = {
17
22
  ".html": "text/html; charset=utf-8",
@@ -535,7 +540,11 @@ export function createTaskServer(serveRoot: string): Server {
535
540
  "Content-Type": MIME[extname(file)] ?? "application/octet-stream",
536
541
  "Cache-Control": "no-cache",
537
542
  })
538
- res.end(readFileSync(file))
543
+ // The build's asset refs are relative (ui/vite.config.ts) and the page —
544
+ // this one included, when it's the SPA fallback for a deep link — needs a
545
+ // `<base>` to resolve them against. serve always mounts the app at the
546
+ // origin root; `task export --base` is where any other answer lives.
547
+ res.end(file.endsWith("index.html") ? injectBase(readFileSync(file, "utf8"), "/") : readFileSync(file))
539
548
  }
540
549
 
541
550
  return server
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The commit-trailer contract: every commit the CLI itself authors for a
3
+ * board operation carries machine-readable git trailers naming the operation
4
+ * and the ticket, so automation can react to board events without parsing
5
+ * human prose. Subjects like "chore(board): resolve ask [1] on TAS-x" are
6
+ * presentation and may be reworded freely; the trailers are the interface.
7
+ *
8
+ * chore(board): resolve ask [1] on TAS-x7k4m
9
+ *
10
+ * Task-Op: resolve-ask
11
+ * Task-Id: TAS-x7k4m
12
+ *
13
+ * Git parses trailers natively — consumers read them with
14
+ * `git log -1 --format='%(trailers:key=Task-Op,valueonly)'`, or match
15
+ * `Task-Op: <op>` in a forge webhook's head-commit message (each routed
16
+ * write is its own single-commit push, so the head commit is the operation).
17
+ *
18
+ * STABILITY: `Task-Op` and `Task-Id` are a documented, test-pinned contract
19
+ * (see the "commit trailers" tests). Existing op names never change meaning
20
+ * or disappear; new ops may be added. Renaming an op or dropping a trailer
21
+ * is a breaking change to every consumer's automation.
22
+ */
23
+
24
+ /**
25
+ * The operation vocabulary. Emitted today:
26
+ *
27
+ * - `claim` — the claim branch's first commit, the in_progress flip
28
+ * (plain and --lock-only alike)
29
+ * - `ask` — an ask filed onto a claim branch (routed write)
30
+ * - `resolve-ask` — an ask checked off on a claim branch: the signal that a
31
+ * stalled claim may be implementable again
32
+ * - `reopen-ask` — a resolved ask put back
33
+ * - `delete-ask` — an ask removed from a claim branch
34
+ *
35
+ * Reserved, not yet emitted:
36
+ *
37
+ * - `begin-implement` — a worker marking the start of an implement pass on a
38
+ * claim (an empty commit pushed at tick start), so "this claim is being
39
+ * worked right now" becomes board state a dispatcher can read instead of a
40
+ * blind spot. Designed for the mid-flight-visibility problem; deliberately
41
+ * deferred until collisions prove worth the extra moving part. Consumers
42
+ * should treat unknown ops as "not the op I'm looking for", never an error.
43
+ */
44
+ export type TaskOp =
45
+ | "claim"
46
+ | "ask"
47
+ | "resolve-ask"
48
+ | "reopen-ask"
49
+ | "delete-ask"
50
+ | "begin-implement"
51
+
52
+ /**
53
+ * Compose a board commit message: the human subject, then one trailer block.
54
+ * `extra` rides in the same block (e.g. lock-only's `Delivered-By:`) — git
55
+ * only recognizes a *contiguous* trailing block, so every trailer must go
56
+ * through this one seam.
57
+ */
58
+ export function boardCommitMessage(
59
+ subject: string,
60
+ op: TaskOp,
61
+ id: string,
62
+ extra: string[] = [],
63
+ ): string {
64
+ return `${subject}\n\n${[...extra, `Task-Op: ${op}`, `Task-Id: ${id}`].join("\n")}`
65
+ }