@nickmeriano/task 0.1.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/LICENSE +21 -0
- package/README.md +68 -0
- package/dist/author.d.ts +17 -0
- package/dist/author.d.ts.map +1 -0
- package/dist/author.js +52 -0
- package/dist/author.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +415 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +292 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +63 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +346 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +81 -0
- package/skill/SKILL.md +109 -0
- package/src/author.ts +57 -0
- package/src/cli.ts +419 -0
- package/src/index.ts +6 -0
- package/src/server.ts +311 -0
- package/src/store.ts +418 -0
- package/src/types.ts +75 -0
- package/ui/dist/assets/index-BopXdeSy.js +229 -0
- package/ui/dist/assets/index-Cl_P2tLU.css +1 -0
- package/ui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/ui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/ui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/ui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/ui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/ui/dist/icon.svg +4 -0
- package/ui/dist/index.html +21 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
|
2
|
+
import { watch, existsSync, readFileSync, statSync, type FSWatcher } from "node:fs"
|
|
3
|
+
import { extname, join, normalize } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { resolveAuthor } from "./author.js"
|
|
6
|
+
import { findBoards, TaskStore, type BoardRef } from "./store.js"
|
|
7
|
+
import { STATUSES, isStatus, type TaskPatch } from "./types.js"
|
|
8
|
+
|
|
9
|
+
/** The prebuilt SPA, shipped inside the package next to dist/. */
|
|
10
|
+
const UI_DIR = fileURLToPath(new URL("../ui/dist", import.meta.url))
|
|
11
|
+
|
|
12
|
+
const MIME: Record<string, string> = {
|
|
13
|
+
".html": "text/html; charset=utf-8",
|
|
14
|
+
".js": "text/javascript",
|
|
15
|
+
".css": "text/css",
|
|
16
|
+
".svg": "image/svg+xml",
|
|
17
|
+
".json": "application/json",
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
".ico": "image/x-icon",
|
|
20
|
+
".woff2": "font/woff2",
|
|
21
|
+
".map": "application/json",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
25
|
+
const data = JSON.stringify(body)
|
|
26
|
+
res.writeHead(status, { "Content-Type": "application/json" })
|
|
27
|
+
res.end(data)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
31
|
+
const chunks: Buffer[] = []
|
|
32
|
+
for await (const chunk of req) chunks.push(chunk as Buffer)
|
|
33
|
+
const text = Buffer.concat(chunks).toString("utf8")
|
|
34
|
+
if (!text) return {}
|
|
35
|
+
return JSON.parse(text) as Record<string, unknown>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parsePatch(body: Record<string, unknown>): TaskPatch {
|
|
39
|
+
const patch: TaskPatch = {}
|
|
40
|
+
if (typeof body.title === "string") patch.title = body.title
|
|
41
|
+
if (typeof body.description === "string") patch.description = body.description
|
|
42
|
+
if (typeof body.status === "string") {
|
|
43
|
+
if (!isStatus(body.status)) throw new Error(`invalid status: ${body.status}`)
|
|
44
|
+
patch.status = body.status
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(body.tags)) patch.tags = body.tags.map(String)
|
|
47
|
+
if (typeof body.milestone === "string" || body.milestone === null) {
|
|
48
|
+
patch.milestone = body.milestone === "" ? null : body.milestone
|
|
49
|
+
}
|
|
50
|
+
if (typeof body.needsHuman === "boolean") patch.needsHuman = body.needsHuman
|
|
51
|
+
if (typeof body.position === "number") patch.position = body.position
|
|
52
|
+
return patch
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `task serve` — one plain node:http server for the static UI, the JSON API,
|
|
57
|
+
* and an SSE stream that pings whenever anything writes to a `.task/` (the UI
|
|
58
|
+
* itself, another terminal, or an agent running the CLI). The UI refetches on
|
|
59
|
+
* every ping, so "realtime" is just fs.watch + a debounce — no sockets, no
|
|
60
|
+
* state to reconcile.
|
|
61
|
+
*
|
|
62
|
+
* Serves every board found at or below `serveRoot` (see `findBoards`): API
|
|
63
|
+
* requests pick one with `?board=<id>`, defaulting to the first. One board is
|
|
64
|
+
* the common case and behaves exactly as before.
|
|
65
|
+
*/
|
|
66
|
+
export function createTaskServer(serveRoot: string): Server {
|
|
67
|
+
const clients = new Set<ServerResponse>()
|
|
68
|
+
|
|
69
|
+
// Identity is resolved here, server-side, once: the board runs on your own
|
|
70
|
+
// machine as you, so there is no login — and nothing a browser sends about
|
|
71
|
+
// who it is can be trusted or is worth trusting. All boards live in the
|
|
72
|
+
// same checkout, so one resolution covers them all.
|
|
73
|
+
const author = resolveAuthor(undefined, serveRoot).name
|
|
74
|
+
|
|
75
|
+
const changed = new Set<string>()
|
|
76
|
+
let pending: NodeJS.Timeout | null = null
|
|
77
|
+
const noteChange = (id: string) => {
|
|
78
|
+
changed.add(id)
|
|
79
|
+
if (pending) return
|
|
80
|
+
pending = setTimeout(() => {
|
|
81
|
+
pending = null
|
|
82
|
+
const ids = [...changed]
|
|
83
|
+
changed.clear()
|
|
84
|
+
for (const id of ids) {
|
|
85
|
+
const message = `data: ${JSON.stringify({ type: "change", board: id })}\n\n`
|
|
86
|
+
for (const client of clients) client.write(message)
|
|
87
|
+
}
|
|
88
|
+
}, 80)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Board {
|
|
92
|
+
store: TaskStore
|
|
93
|
+
watcher: FSWatcher
|
|
94
|
+
}
|
|
95
|
+
const boards = new Map<string, Board>()
|
|
96
|
+
let ordered: string[] = []
|
|
97
|
+
|
|
98
|
+
const openBoard = (ref: BoardRef): void => {
|
|
99
|
+
const store = new TaskStore(ref.root)
|
|
100
|
+
const watcher = watch(store.taskDir, () => noteChange(ref.id))
|
|
101
|
+
// A deleted board dir can make fs.watch emit; the next rescan cleans up.
|
|
102
|
+
watcher.on("error", () => {})
|
|
103
|
+
boards.set(ref.id, { store, watcher })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Reconcile open boards with what's on disk; cheap enough to run per request. */
|
|
107
|
+
const syncBoards = (): BoardRef[] => {
|
|
108
|
+
const found = findBoards(serveRoot)
|
|
109
|
+
const ids = new Set(found.map((ref) => ref.id))
|
|
110
|
+
for (const [id, board] of boards) {
|
|
111
|
+
if (!ids.has(id)) {
|
|
112
|
+
board.watcher.close()
|
|
113
|
+
board.store.close()
|
|
114
|
+
boards.delete(id)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const ref of found) if (!boards.has(ref.id)) openBoard(ref)
|
|
118
|
+
ordered = found.map((ref) => ref.id)
|
|
119
|
+
return found
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
syncBoards()
|
|
123
|
+
if (boards.size === 0) {
|
|
124
|
+
throw new Error(`no .task directory found at or below ${serveRoot} — run \`task init\` first`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const heartbeat = setInterval(() => {
|
|
128
|
+
for (const client of clients) client.write(`: ping\n\n`)
|
|
129
|
+
}, 30_000)
|
|
130
|
+
|
|
131
|
+
const server = createServer((req, res) => {
|
|
132
|
+
try {
|
|
133
|
+
route(req, res)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
server.on("close", () => {
|
|
140
|
+
if (pending) clearTimeout(pending)
|
|
141
|
+
clearInterval(heartbeat)
|
|
142
|
+
for (const board of boards.values()) {
|
|
143
|
+
board.watcher.close()
|
|
144
|
+
board.store.close()
|
|
145
|
+
}
|
|
146
|
+
for (const client of clients) client.end()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
function route(req: IncomingMessage, res: ServerResponse): void {
|
|
150
|
+
const url = new URL(req.url ?? "/", "http://localhost")
|
|
151
|
+
const path = url.pathname
|
|
152
|
+
|
|
153
|
+
if (path === "/api/events") {
|
|
154
|
+
res.writeHead(200, {
|
|
155
|
+
"Content-Type": "text/event-stream",
|
|
156
|
+
"Cache-Control": "no-cache",
|
|
157
|
+
Connection: "keep-alive",
|
|
158
|
+
})
|
|
159
|
+
res.write(`retry: 1000\n\n`)
|
|
160
|
+
clients.add(res)
|
|
161
|
+
req.on("close", () => clients.delete(res))
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (path === "/api/boards") {
|
|
166
|
+
const found = syncBoards()
|
|
167
|
+
json(res, 200, {
|
|
168
|
+
boards: found.map((ref) => {
|
|
169
|
+
const { config } = boards.get(ref.id)!.store
|
|
170
|
+
return { id: ref.id, name: config.name, prefix: config.prefix }
|
|
171
|
+
}),
|
|
172
|
+
})
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (path.startsWith("/api/")) {
|
|
177
|
+
const id = url.searchParams.get("board") ?? ordered[0]
|
|
178
|
+
const board = boards.get(id)
|
|
179
|
+
if (!board) {
|
|
180
|
+
json(res, 404, { error: `no such board: ${id}` })
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
routeApi(board.store, req, res, path)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
serveStatic(path, res)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function routeApi(
|
|
191
|
+
store: TaskStore,
|
|
192
|
+
req: IncomingMessage,
|
|
193
|
+
res: ServerResponse,
|
|
194
|
+
path: string,
|
|
195
|
+
): void {
|
|
196
|
+
if (path === "/api/project") {
|
|
197
|
+
json(res, 200, {
|
|
198
|
+
name: store.config.name,
|
|
199
|
+
prefix: store.config.prefix,
|
|
200
|
+
statuses: STATUSES,
|
|
201
|
+
author,
|
|
202
|
+
})
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (path === "/api/tasks" && req.method === "GET") {
|
|
207
|
+
const counts = store.commentCounts()
|
|
208
|
+
const tasks = store
|
|
209
|
+
.list()
|
|
210
|
+
.map((t) => ({ ...t, commentCount: counts.get(t.number) ?? 0 }))
|
|
211
|
+
json(res, 200, { tasks })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (path === "/api/tasks" && req.method === "POST") {
|
|
216
|
+
void readBody(req)
|
|
217
|
+
.then((body) => {
|
|
218
|
+
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
219
|
+
throw new Error("title is required")
|
|
220
|
+
}
|
|
221
|
+
const patch = parsePatch(body)
|
|
222
|
+
const task = store.create({ title: body.title.trim(), ...patch })
|
|
223
|
+
json(res, 201, { task })
|
|
224
|
+
})
|
|
225
|
+
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/(\d+))?)?$/.exec(path)
|
|
230
|
+
if (taskMatch) {
|
|
231
|
+
const number = store.parseId(decodeURIComponent(taskMatch[1]))
|
|
232
|
+
if (taskMatch[3] && req.method === "DELETE") {
|
|
233
|
+
const commentId = Number(taskMatch[3])
|
|
234
|
+
const existing = store.comments(number).find((c) => c.id === commentId)
|
|
235
|
+
if (!existing) {
|
|
236
|
+
json(res, 404, { error: `no such comment: ${commentId}` })
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
// Same identity rule as posting: the server's resolved author is the
|
|
240
|
+
// only author it trusts, so you can only delete what it says is yours.
|
|
241
|
+
if (existing.author !== author) {
|
|
242
|
+
json(res, 403, { error: "only your own comments can be deleted" })
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
store.deleteComment(number, commentId)
|
|
246
|
+
json(res, 200, { ok: true })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
if (taskMatch[2] && !taskMatch[3] && req.method === "POST") {
|
|
250
|
+
void readBody(req)
|
|
251
|
+
.then((body) => {
|
|
252
|
+
if (typeof body.body !== "string" || !body.body.trim()) {
|
|
253
|
+
throw new Error("body is required")
|
|
254
|
+
}
|
|
255
|
+
// `body.author` is ignored on purpose — unauthenticated noise.
|
|
256
|
+
const comment = store.addComment(number, body.body.trim(), author)
|
|
257
|
+
json(res, 201, { comment })
|
|
258
|
+
})
|
|
259
|
+
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
if (!taskMatch[2]) {
|
|
263
|
+
if (req.method === "GET") {
|
|
264
|
+
const task = store.get(number)
|
|
265
|
+
if (!task) {
|
|
266
|
+
json(res, 404, { error: `no such task: ${store.displayId(number)}` })
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
json(res, 200, { task, comments: store.comments(number) })
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
if (req.method === "PATCH") {
|
|
273
|
+
void readBody(req)
|
|
274
|
+
.then((body) => {
|
|
275
|
+
const task = store.update(number, parsePatch(body))
|
|
276
|
+
json(res, 200, { task })
|
|
277
|
+
})
|
|
278
|
+
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (req.method === "DELETE") {
|
|
282
|
+
store.delete(number)
|
|
283
|
+
json(res, 200, { ok: true })
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
json(res, 404, { error: "not found" })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function serveStatic(path: string, res: ServerResponse): void {
|
|
293
|
+
const safe = normalize(path).replace(/^(\.\.[/\\])+/, "")
|
|
294
|
+
let file = join(UI_DIR, safe)
|
|
295
|
+
if (!existsSync(file) || statSync(file).isDirectory()) {
|
|
296
|
+
file = join(UI_DIR, "index.html") // SPA fallback
|
|
297
|
+
}
|
|
298
|
+
if (!existsSync(file)) {
|
|
299
|
+
res.writeHead(404, { "Content-Type": "text/plain" })
|
|
300
|
+
res.end("UI build not found — run the package build (vite build ui).")
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
res.writeHead(200, {
|
|
304
|
+
"Content-Type": MIME[extname(file)] ?? "application/octet-stream",
|
|
305
|
+
"Cache-Control": "no-cache",
|
|
306
|
+
})
|
|
307
|
+
res.end(readFileSync(file))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return server
|
|
311
|
+
}
|