@nickmeriano/task 0.11.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 (73) hide show
  1. package/README.md +175 -1
  2. package/dist/board/github.d.ts +68 -0
  3. package/dist/board/github.d.ts.map +1 -0
  4. package/dist/board/github.js +112 -0
  5. package/dist/board/github.js.map +1 -0
  6. package/dist/board/handler.d.ts +50 -0
  7. package/dist/board/handler.d.ts.map +1 -0
  8. package/dist/board/handler.js +183 -0
  9. package/dist/board/handler.js.map +1 -0
  10. package/dist/board/handler.test.d.ts +11 -0
  11. package/dist/board/handler.test.d.ts.map +1 -0
  12. package/dist/board/handler.test.js +229 -0
  13. package/dist/board/handler.test.js.map +1 -0
  14. package/dist/board/pages-function.d.ts +23 -0
  15. package/dist/board/pages-function.d.ts.map +1 -0
  16. package/dist/board/pages-function.js +34 -0
  17. package/dist/board/pages-function.js.map +1 -0
  18. package/dist/board/source.d.ts +118 -0
  19. package/dist/board/source.d.ts.map +1 -0
  20. package/dist/board/source.js +333 -0
  21. package/dist/board/source.js.map +1 -0
  22. package/dist/board/source.test.d.ts +12 -0
  23. package/dist/board/source.test.d.ts.map +1 -0
  24. package/dist/board/source.test.js +165 -0
  25. package/dist/board/source.test.js.map +1 -0
  26. package/dist/board/tar.d.ts +28 -0
  27. package/dist/board/tar.d.ts.map +1 -0
  28. package/dist/board/tar.js +188 -0
  29. package/dist/board/tar.js.map +1 -0
  30. package/dist/board/tar.test.d.ts +9 -0
  31. package/dist/board/tar.test.d.ts.map +1 -0
  32. package/dist/board/tar.test.js +110 -0
  33. package/dist/board/tar.test.js.map +1 -0
  34. package/dist/cli.js +37 -2
  35. package/dist/cli.js.map +1 -1
  36. package/dist/export.d.ts +31 -0
  37. package/dist/export.d.ts.map +1 -1
  38. package/dist/export.js +61 -1
  39. package/dist/export.js.map +1 -1
  40. package/dist/export.test.js +80 -1
  41. package/dist/export.test.js.map +1 -1
  42. package/dist/functions/board.js +709 -0
  43. package/dist/git-serve.d.ts +14 -1
  44. package/dist/git-serve.d.ts.map +1 -1
  45. package/dist/git-serve.js +30 -2
  46. package/dist/git-serve.js.map +1 -1
  47. package/dist/git-serve.test.d.ts +1 -0
  48. package/dist/git-serve.test.d.ts.map +1 -1
  49. package/dist/git-serve.test.js +36 -1
  50. package/dist/git-serve.test.js.map +1 -1
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -0
  54. package/dist/index.js.map +1 -1
  55. package/package.json +3 -3
  56. package/skill/SKILL.md +10 -3
  57. package/src/board/github.ts +151 -0
  58. package/src/board/handler.test.ts +276 -0
  59. package/src/board/handler.ts +228 -0
  60. package/src/board/pages-function.ts +42 -0
  61. package/src/board/source.test.ts +203 -0
  62. package/src/board/source.ts +422 -0
  63. package/src/board/tar.test.ts +128 -0
  64. package/src/board/tar.ts +199 -0
  65. package/src/cli.ts +36 -2
  66. package/src/export.test.ts +108 -1
  67. package/src/export.ts +79 -1
  68. package/src/git-serve.test.ts +41 -1
  69. package/src/git-serve.ts +30 -2
  70. package/src/index.ts +10 -0
  71. package/ui/dist/assets/index-B8M_DaOt.js +229 -0
  72. package/ui/dist/index.html +1 -1
  73. package/ui/dist/assets/index-mJmm4sWq.js +0 -229
@@ -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
+ })
@@ -0,0 +1,199 @@
1
+ /**
2
+ * A minimal streaming tar reader — just enough to walk a GitHub repository
3
+ * tarball. One pass, bounded memory: every entry's *path* is recorded, but
4
+ * bytes are kept only for the paths the caller's `keep` predicate selects;
5
+ * everything else is discarded chunk by chunk as it streams through, so a big
6
+ * repository costs no more memory than its boards.
7
+ *
8
+ * Understands what GitHub's tarballs actually contain: ustar headers with the
9
+ * split `prefix` field, a leading `pax_global_header`, and pax extended
10
+ * headers (`x`) or GNU longname entries (`L`) for paths longer than the
11
+ * header's 100 bytes. Checksums are not verified — the bytes come off a TLS
12
+ * connection from GitHub, not a tape from 1988.
13
+ */
14
+
15
+ export class TarError extends Error {}
16
+
17
+ const BLOCK = 512
18
+
19
+ /** Reads exact byte counts off a stream, buffering only what's unconsumed. */
20
+ class ByteReader {
21
+ private reader: ReadableStreamDefaultReader<Uint8Array>
22
+ private chunks: Uint8Array[] = []
23
+ private have = 0
24
+ private done = false
25
+
26
+ constructor(stream: ReadableStream<Uint8Array>) {
27
+ this.reader = stream.getReader()
28
+ }
29
+
30
+ private async pull(): Promise<boolean> {
31
+ if (this.done) return false
32
+ const { done, value } = await this.reader.read()
33
+ if (done) {
34
+ this.done = true
35
+ return false
36
+ }
37
+ if (value && value.length > 0) {
38
+ this.chunks.push(value)
39
+ this.have += value.length
40
+ }
41
+ return true
42
+ }
43
+
44
+ /** Exactly `n` bytes; null at a clean end-of-stream, throws mid-entry. */
45
+ async read(n: number): Promise<Uint8Array | null> {
46
+ while (this.have < n) {
47
+ if (!(await this.pull())) {
48
+ if (this.have === 0) return null
49
+ throw new TarError("truncated archive")
50
+ }
51
+ }
52
+ const out = new Uint8Array(n)
53
+ let filled = 0
54
+ while (filled < n) {
55
+ const head = this.chunks[0]
56
+ const take = Math.min(head.length, n - filled)
57
+ out.set(head.subarray(0, take), filled)
58
+ filled += take
59
+ if (take === head.length) this.chunks.shift()
60
+ else this.chunks[0] = head.subarray(take)
61
+ }
62
+ this.have -= n
63
+ return out
64
+ }
65
+
66
+ /** Discard `n` bytes without accumulating them. */
67
+ async skip(n: number): Promise<void> {
68
+ while (n > 0) {
69
+ if (this.chunks.length === 0) {
70
+ if (!(await this.pull())) throw new TarError("truncated archive")
71
+ continue
72
+ }
73
+ const head = this.chunks[0]
74
+ const take = Math.min(head.length, n)
75
+ if (take === head.length) this.chunks.shift()
76
+ else this.chunks[0] = head.subarray(take)
77
+ this.have -= take
78
+ n -= take
79
+ }
80
+ }
81
+ }
82
+
83
+ const decoder = new TextDecoder()
84
+
85
+ function field(header: Uint8Array, start: number, length: number): string {
86
+ const slice = header.subarray(start, start + length)
87
+ const nul = slice.indexOf(0)
88
+ return decoder.decode(nul < 0 ? slice : slice.subarray(0, nul))
89
+ }
90
+
91
+ function octal(header: Uint8Array, start: number, length: number): number {
92
+ const text = field(header, start, length).trim()
93
+ if (text === "") return 0
94
+ const value = parseInt(text, 8)
95
+ if (Number.isNaN(value) || value < 0) throw new TarError("bad size field")
96
+ return value
97
+ }
98
+
99
+ /** The `path=` record of a pax extended header, if it has one. */
100
+ function paxPath(data: Uint8Array): string | null {
101
+ // Records are "<len> <key>=<value>\n", len covering the whole record.
102
+ const text = decoder.decode(data)
103
+ for (let at = 0; at < text.length; ) {
104
+ const space = text.indexOf(" ", at)
105
+ if (space < 0) break
106
+ const length = Number(text.slice(at, space))
107
+ if (!Number.isInteger(length) || length <= 0) break
108
+ const record = text.slice(space + 1, at + length)
109
+ if (record.startsWith("path=")) return record.slice(5).replace(/\n$/, "")
110
+ at += length
111
+ }
112
+ return null
113
+ }
114
+
115
+ export interface TarContents {
116
+ /** Every regular file's path, root directory stripped. */
117
+ paths: string[]
118
+ /** Bytes of the paths `keep` selected. */
119
+ files: Map<string, Uint8Array>
120
+ }
121
+
122
+ /**
123
+ * Walk a (decompressed) tar stream. Paths are reported with the archive's
124
+ * root directory stripped — GitHub tarballs wrap everything in
125
+ * `<owner>-<repo>-<sha>/` — matching what a git tree listing would say.
126
+ */
127
+ export async function readTar(
128
+ stream: ReadableStream<Uint8Array>,
129
+ keep: (path: string) => boolean,
130
+ ): Promise<TarContents> {
131
+ const bytes = new ByteReader(stream)
132
+ const paths: string[] = []
133
+ const files = new Map<string, Uint8Array>()
134
+
135
+ let overridePath: string | null = null
136
+ for (;;) {
137
+ const header = await bytes.read(BLOCK)
138
+ if (header === null) break
139
+ if (header.every((b) => b === 0)) {
140
+ // End marker: two zero blocks, then maybe padding. Nothing left to parse.
141
+ break
142
+ }
143
+
144
+ const size = octal(header, 124, 12)
145
+ const padded = Math.ceil(size / BLOCK) * BLOCK
146
+ const type = header[156]
147
+
148
+ // Extended headers name the *next* entry; the global one ('g') is skipped.
149
+ if (type === 0x78 /* x */ || type === 0x4c /* L */) {
150
+ const data = await bytes.read(size)
151
+ if (data === null) throw new TarError("truncated archive")
152
+ overridePath =
153
+ type === 0x78 ? (paxPath(data) ?? overridePath) : decoder.decode(data).replace(/\0+$/, "")
154
+ await bytes.skip(padded - size)
155
+ continue
156
+ }
157
+
158
+ let name = field(header, 0, 100)
159
+ // ustar splits long paths into prefix + name.
160
+ const prefix = field(header, 257, 6).startsWith("ustar") ? field(header, 345, 155) : ""
161
+ if (prefix) name = `${prefix}/${name}`
162
+ if (overridePath !== null) {
163
+ name = overridePath
164
+ overridePath = null
165
+ }
166
+
167
+ // Regular file entries only — directories, links and the global header
168
+ // carry no board bytes. Type '0' or the ancient NUL both mean "file".
169
+ if (type !== 0x30 && type !== 0) {
170
+ await bytes.skip(padded)
171
+ continue
172
+ }
173
+
174
+ // Strip the tarball's root directory. A path without one (no slash) is
175
+ // the metadata GitHub puts at the top level — not repository content.
176
+ const slash = name.indexOf("/")
177
+ if (slash < 0) {
178
+ await bytes.skip(padded)
179
+ continue
180
+ }
181
+ const path = name.slice(slash + 1)
182
+ if (path === "") {
183
+ await bytes.skip(padded)
184
+ continue
185
+ }
186
+
187
+ paths.push(path)
188
+ if (keep(path)) {
189
+ const data = await bytes.read(size)
190
+ if (data === null) throw new TarError("truncated archive")
191
+ files.set(path, data)
192
+ await bytes.skip(padded - size)
193
+ } else {
194
+ await bytes.skip(padded)
195
+ }
196
+ }
197
+
198
+ return { paths, files }
199
+ }
package/src/cli.ts CHANGED
@@ -96,6 +96,7 @@ const BOOLEAN_FLAGS = new Set([
96
96
  "fix",
97
97
  "next",
98
98
  "force",
99
+ "no-functions",
99
100
  "reopen",
100
101
  "lock-only",
101
102
  "dry-run",
@@ -1334,6 +1335,22 @@ function cmdExport(args: Args): void {
1334
1335
  const out = str(args.flags, "out") ?? join(serveRoot, DEFAULT_OUT_DIR)
1335
1336
  const outDir = isAbsolute(out) ? out : resolve(process.cwd(), out)
1336
1337
 
1338
+ // The live proxy is written unless told not to. It is inert until the host
1339
+ // has a token — it answers 503 and the board reads the snapshot — so the
1340
+ // default costs nothing, and turning a deploy live becomes one secret
1341
+ // rather than a re-export. The repository it reads is baked in: the export
1342
+ // already knows (the same remote detection `task publish` uses), and one
1343
+ // fewer setting on the host is one fewer thing to get wrong.
1344
+ let functions: { repo: string } | undefined
1345
+ let noRemote = false
1346
+ if (!args.flags["no-functions"]) {
1347
+ const slug = str(args.flags, "repo")
1348
+ const ref = slug ? parseSlug(slug) : detectRepo(serveRoot)
1349
+ if (slug && !ref) fail(`--repo must be owner/name — got ${JSON.stringify(slug)}`)
1350
+ if (ref) functions = { repo: `${ref.owner}/${ref.repo}` }
1351
+ else noRemote = true
1352
+ }
1353
+
1337
1354
  let summary
1338
1355
  try {
1339
1356
  summary = writeExport({
@@ -1342,6 +1359,7 @@ function cmdExport(args: Args): void {
1342
1359
  uiDir: UI_DIR,
1343
1360
  force: Boolean(args.flags.force),
1344
1361
  base: str(args.flags, "base"),
1362
+ functions,
1345
1363
  })
1346
1364
  } catch (error) {
1347
1365
  fail(error instanceof Error ? error.message : String(error))
@@ -1355,6 +1373,15 @@ function cmdExport(args: Args): void {
1355
1373
  console.log(`Exported ${what}${summary.commit ? ` at ${summary.commit.slice(0, 7)}` : ""}`)
1356
1374
  console.log(` ${summary.outDir}`)
1357
1375
  console.log("Serve that directory from any static host — the board reads snapshot.json.")
1376
+ if (summary.functions && functions) {
1377
+ // One line, every run: this tier has no viewer auth, and the moment the
1378
+ // function is written is the moment to say so. The README says the rest.
1379
+ console.log(
1380
+ ` ${summary.functions} reads ${functions.repo} live once the host has a TASK_GITHUB_TOKEN secret — to anyone who can reach it (README: "Live data for an exported board")`,
1381
+ )
1382
+ } else if (noRemote) {
1383
+ console.log(" no live function written — no GitHub remote to read from; pass --repo owner/name to add one")
1384
+ }
1358
1385
  }
1359
1386
 
1360
1387
  /**
@@ -1648,7 +1675,7 @@ Usage
1648
1675
  fails instead). Serves every board at or below
1649
1676
  here — in a monorepo the header becomes a
1650
1677
  board switcher
1651
- task export [--out <dir>] [--base <path>] [--force]
1678
+ task export [--out <dir>] [--base <path>] [--force] [--no-functions | --repo <owner/name>]
1652
1679
  write the board out as static files — the same
1653
1680
  UI plus a snapshot.json it reads instead of an
1654
1681
  API, so any static host serves it read-only
@@ -1660,7 +1687,14 @@ Usage
1660
1687
  domain root. Re-exporting refreshes an earlier
1661
1688
  export in place and leaves anything else in
1662
1689
  there alone; a non-empty directory that isn't
1663
- one needs --force
1690
+ one needs --force. Also writes a Cloudflare
1691
+ Pages Function that reads the repo live from
1692
+ GitHub: inert until the host has a
1693
+ TASK_GITHUB_TOKEN secret (a fine-grained PAT,
1694
+ Contents: read-only), at which point the same
1695
+ deploy upgrades itself — no viewer auth, mind.
1696
+ The repo comes from your remotes; --repo sets
1697
+ it, --no-functions skips the function
1664
1698
  task publish [--public | --private] [--repo <owner/name>]
1665
1699
  give this repo's board a URL at
1666
1700
  task.nickmeriano.com, read-only, updated from
@@ -23,7 +23,15 @@ import {
23
23
  import { tmpdir } from "node:os"
24
24
  import { join } from "node:path"
25
25
  import { initProject, openBoard } from "./file-store.ts"
26
- import { buildSnapshot, writeExport, SNAPSHOT_FILE, SNAPSHOT_VERSION, type Snapshot } from "./export.ts"
26
+ import {
27
+ buildSnapshot,
28
+ functionPath,
29
+ renderFunction,
30
+ writeExport,
31
+ SNAPSHOT_FILE,
32
+ SNAPSHOT_VERSION,
33
+ type Snapshot,
34
+ } from "./export.ts"
27
35
  import { STATUSES } from "./types.ts"
28
36
 
29
37
  function tempRoot(): string {
@@ -159,6 +167,7 @@ test("the export is the assets plus the snapshot", () => {
159
167
  tasks: snapshot.data["."].tasks.length,
160
168
  commit: null,
161
169
  builtAt: snapshot.meta.builtAt,
170
+ functions: null,
162
171
  })
163
172
  })
164
173
 
@@ -252,3 +261,101 @@ test("a base that isn't a plain URL path is refused", () => {
252
261
  )
253
262
  }
254
263
  })
264
+
265
+ // ── The live proxy, written beside the snapshot ─────────────────────────────
266
+
267
+ /** A stand-in for `dist/functions/board.js`: the placeholders are the contract. */
268
+ function fakeBundle(): string {
269
+ const dir = tempRoot()
270
+ const path = join(dir, "board.js")
271
+ writeFileSync(
272
+ path,
273
+ 'const REPO = "__TASK_EXPORT_REPO__";\nconst BASE_PATH = "__TASK_EXPORT_BASE_PATH__";\nexport const onRequest = () => [REPO, BASE_PATH];\n',
274
+ )
275
+ return path
276
+ }
277
+
278
+ test("--functions writes the Pages Function where the SPA will look for its API", () => {
279
+ const root = board()
280
+ const out = join(tempRoot(), "board")
281
+ const summary = writeExport({
282
+ serveRoot: root,
283
+ outDir: out,
284
+ uiDir: fakeUi(),
285
+ functions: { repo: "acme/widgets", bundle: fakeBundle() },
286
+ })
287
+ assert.equal(summary.functions, join("functions", "api", "[[path]].js"))
288
+ const written = readFileSync(join(out, summary.functions!), "utf8")
289
+ assert.match(written, /const REPO = "acme\/widgets";/)
290
+ assert.match(written, /const BASE_PATH = "\/api";/)
291
+ assert.ok(!written.includes("__TASK_EXPORT_"))
292
+ // The snapshot is still there: the function is an upgrade, not a replacement.
293
+ assert.ok(existsSync(join(out, SNAPSHOT_FILE)))
294
+ })
295
+
296
+ test("under --base the function moves with the mount point", () => {
297
+ assert.equal(functionPath("/"), join("functions", "api", "[[path]].js"))
298
+ assert.equal(functionPath("board"), join("functions", "board", "api", "[[path]].js"))
299
+ assert.equal(functionPath("/a/b/"), join("functions", "a", "b", "api", "[[path]].js"))
300
+
301
+ const out = join(tempRoot(), "sub")
302
+ const summary = writeExport({
303
+ serveRoot: board(),
304
+ outDir: out,
305
+ uiDir: fakeUi(),
306
+ base: "/board/",
307
+ functions: { repo: "acme/widgets", bundle: fakeBundle() },
308
+ })
309
+ assert.equal(summary.functions, join("functions", "board", "api", "[[path]].js"))
310
+ assert.match(readFileSync(join(out, summary.functions!), "utf8"), /const BASE_PATH = "\/board\/api";/)
311
+ })
312
+
313
+ test("a re-export without --functions leaves the function as deployed; with it, refreshes it", () => {
314
+ const root = board()
315
+ const out = join(tempRoot(), "board")
316
+ const ui = fakeUi()
317
+ const first = writeExport({
318
+ serveRoot: root,
319
+ outDir: out,
320
+ uiDir: ui,
321
+ functions: { repo: "acme/widgets", bundle: fakeBundle() },
322
+ })
323
+ const file = join(out, first.functions!)
324
+ writeFileSync(file, "// hand-edited on the host")
325
+
326
+ const plain = writeExport({ serveRoot: root, outDir: out, uiDir: ui })
327
+ assert.equal(plain.functions, null)
328
+ assert.equal(readFileSync(file, "utf8"), "// hand-edited on the host")
329
+
330
+ writeExport({ serveRoot: root, outDir: out, uiDir: ui, functions: { repo: "acme/gadgets", bundle: fakeBundle() } })
331
+ assert.match(readFileSync(file, "utf8"), /acme\/gadgets/)
332
+ })
333
+
334
+ test("a repo that isn't owner/name, or a missing bundle, fails before anything is written", () => {
335
+ const out = join(tempRoot(), "board")
336
+ assert.throws(
337
+ () =>
338
+ writeExport({
339
+ serveRoot: board(),
340
+ outDir: out,
341
+ uiDir: fakeUi(),
342
+ functions: { repo: 'acme/"; fetch("evil', bundle: fakeBundle() },
343
+ }),
344
+ /owner\/name/,
345
+ )
346
+ assert.ok(!existsSync(out))
347
+ assert.throws(
348
+ () =>
349
+ writeExport({
350
+ serveRoot: board(),
351
+ outDir: out,
352
+ uiDir: fakeUi(),
353
+ functions: { repo: "acme/widgets", bundle: join(tempRoot(), "missing.js") },
354
+ }),
355
+ /function bundle not found/,
356
+ )
357
+ assert.ok(!existsSync(out))
358
+ // A bundle without the placeholders is not the function — refuse rather
359
+ // than ship a file that reads the wrong repository.
360
+ assert.throws(() => renderFunction("export const onRequest = 1", "acme/widgets", "/"), /placeholder|__TASK_EXPORT_/)
361
+ })
package/src/export.ts CHANGED
@@ -19,8 +19,10 @@
19
19
 
20
20
  import { execFileSync } from "node:child_process"
21
21
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
22
- import { join, resolve } from "node:path"
22
+ import { dirname, join, resolve } from "node:path"
23
+ import { fileURLToPath } from "node:url"
23
24
  import { resolveAuthor } from "./author.ts"
25
+ import { REPO_PATTERN } from "./board/handler.ts"
24
26
  import { openBoard } from "./file-store.ts"
25
27
  import { findBoards } from "./store.ts"
26
28
  import { STATUSES, type Comment, type Goal, type Status, type Task } from "./types.ts"
@@ -40,6 +42,17 @@ export const SNAPSHOT_FILE = "snapshot.json"
40
42
  /** Where an export goes when `--out` doesn't say. */
41
43
  export const DEFAULT_OUT_DIR = "task-export"
42
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
+
43
56
  /** The sentence `serveStatic` prints for the same missing directory. */
44
57
  const NO_UI_BUILD = "UI build not found — run the package build (vite build ui)."
45
58
 
@@ -211,6 +224,13 @@ export interface WriteExportOptions {
211
224
  /** URL path the export will be served under (default `/`) — see `injectBase`. */
212
225
  base?: string
213
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 }
214
234
  }
215
235
 
216
236
  export interface ExportSummary {
@@ -220,6 +240,39 @@ export interface ExportSummary {
220
240
  tasks: number
221
241
  commit: string | null
222
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)
223
276
  }
224
277
 
225
278
  /**
@@ -243,6 +296,18 @@ export function writeExport(options: WriteExportOptions): ExportSummary {
243
296
  }
244
297
 
245
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
246
311
 
247
312
  const existing = existsSync(outDir) ? readdirSync(outDir) : null
248
313
  if (existing && existing.length > 0 && !existing.includes(SNAPSHOT_FILE) && !force) {
@@ -264,6 +329,13 @@ export function writeExport(options: WriteExportOptions): ExportSummary {
264
329
  // Pretty-printed: this lands in CI artifacts and gets diffed, and at this
265
330
  // size the whitespace costs nothing.
266
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
+ }
267
339
 
268
340
  return {
269
341
  outDir,
@@ -272,5 +344,11 @@ export function writeExport(options: WriteExportOptions): ExportSummary {
272
344
  tasks: Object.values(snapshot.data).reduce((n, board) => n + board.tasks.length, 0),
273
345
  commit: snapshot.meta.commit,
274
346
  builtAt: snapshot.meta.builtAt,
347
+ functions: functions?.path ?? null,
275
348
  }
276
349
  }
350
+
351
+ function readFunctionBundle(path: string): string {
352
+ if (!existsSync(path)) throw new Error(NO_FUNCTION_BUILD)
353
+ return readFileSync(path, "utf8")
354
+ }