@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.
- package/README.md +232 -1
- package/dist/asks.test.js +6 -0
- package/dist/asks.test.js.map +1 -1
- package/dist/board/github.d.ts +68 -0
- package/dist/board/github.d.ts.map +1 -0
- package/dist/board/github.js +112 -0
- package/dist/board/github.js.map +1 -0
- package/dist/board/handler.d.ts +50 -0
- package/dist/board/handler.d.ts.map +1 -0
- package/dist/board/handler.js +183 -0
- package/dist/board/handler.js.map +1 -0
- package/dist/board/handler.test.d.ts +11 -0
- package/dist/board/handler.test.d.ts.map +1 -0
- package/dist/board/handler.test.js +229 -0
- package/dist/board/handler.test.js.map +1 -0
- package/dist/board/pages-function.d.ts +23 -0
- package/dist/board/pages-function.d.ts.map +1 -0
- package/dist/board/pages-function.js +34 -0
- package/dist/board/pages-function.js.map +1 -0
- package/dist/board/source.d.ts +118 -0
- package/dist/board/source.d.ts.map +1 -0
- package/dist/board/source.js +333 -0
- package/dist/board/source.js.map +1 -0
- package/dist/board/source.test.d.ts +12 -0
- package/dist/board/source.test.d.ts.map +1 -0
- package/dist/board/source.test.js +165 -0
- package/dist/board/source.test.js.map +1 -0
- package/dist/board/tar.d.ts +28 -0
- package/dist/board/tar.d.ts.map +1 -0
- package/dist/board/tar.js +188 -0
- package/dist/board/tar.js.map +1 -0
- package/dist/board/tar.test.d.ts +9 -0
- package/dist/board/tar.test.d.ts.map +1 -0
- package/dist/board/tar.test.js +110 -0
- package/dist/board/tar.test.js.map +1 -0
- package/dist/claim-io.d.ts.map +1 -1
- package/dist/claim-io.js +4 -3
- package/dist/claim-io.js.map +1 -1
- package/dist/claim.d.ts +14 -2
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +9 -3
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.js +41 -1
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +127 -7
- package/dist/cli.js.map +1 -1
- package/dist/export.d.ts +163 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +259 -0
- package/dist/export.js.map +1 -0
- package/dist/export.test.d.ts +12 -0
- package/dist/export.test.d.ts.map +1 -0
- package/dist/export.test.js +264 -0
- package/dist/export.test.js.map +1 -0
- package/dist/functions/board.js +709 -0
- package/dist/git-serve.d.ts +14 -1
- package/dist/git-serve.d.ts.map +1 -1
- package/dist/git-serve.js +30 -2
- package/dist/git-serve.js.map +1 -1
- package/dist/git-serve.test.d.ts +1 -0
- package/dist/git-serve.test.d.ts.map +1 -1
- package/dist/git-serve.test.js +36 -1
- package/dist/git-serve.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +12 -3
- package/dist/server.js.map +1 -1
- package/dist/trailers.d.ts +51 -0
- package/dist/trailers.d.ts.map +1 -0
- package/dist/trailers.js +32 -0
- package/dist/trailers.js.map +1 -0
- package/package.json +3 -3
- package/skill/SKILL.md +16 -1
- package/src/asks.test.ts +15 -0
- package/src/board/github.ts +151 -0
- package/src/board/handler.test.ts +276 -0
- package/src/board/handler.ts +228 -0
- package/src/board/pages-function.ts +42 -0
- package/src/board/source.test.ts +203 -0
- package/src/board/source.ts +422 -0
- package/src/board/tar.test.ts +128 -0
- package/src/board/tar.ts +199 -0
- package/src/claim-io.ts +12 -3
- package/src/claim.test.ts +56 -1
- package/src/claim.ts +27 -4
- package/src/cli.ts +127 -7
- package/src/export.test.ts +361 -0
- package/src/export.ts +354 -0
- package/src/git-serve.test.ts +41 -1
- package/src/git-serve.ts +30 -2
- package/src/index.ts +10 -0
- package/src/server.ts +12 -3
- package/src/trailers.ts +65 -0
- package/ui/dist/assets/index-B8M_DaOt.js +229 -0
- package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
- package/ui/dist/index.html +3 -3
- 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
|
+
}
|
package/src/git-serve.test.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
-
|
|
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
|
package/src/trailers.ts
ADDED
|
@@ -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
|
+
}
|