@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.
- package/README.md +175 -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/cli.js +37 -2
- package/dist/cli.js.map +1 -1
- package/dist/export.d.ts +31 -0
- package/dist/export.d.ts.map +1 -1
- package/dist/export.js +61 -1
- package/dist/export.js.map +1 -1
- package/dist/export.test.js +80 -1
- package/dist/export.test.js.map +1 -1
- 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/package.json +3 -3
- package/skill/SKILL.md +10 -3
- 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/cli.ts +36 -2
- package/src/export.test.ts +108 -1
- package/src/export.ts +79 -1
- package/src/git-serve.test.ts +41 -1
- package/src/git-serve.ts +30 -2
- package/src/index.ts +10 -0
- package/ui/dist/assets/index-B8M_DaOt.js +229 -0
- package/ui/dist/index.html +1 -1
- 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
|
+
})
|
package/src/board/tar.ts
ADDED
|
@@ -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
|
package/src/export.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|