@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/dist/author.d.ts +17 -0
  4. package/dist/author.d.ts.map +1 -0
  5. package/dist/author.js +52 -0
  6. package/dist/author.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +415 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.d.ts +5 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +7 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/server.d.ts +14 -0
  16. package/dist/server.d.ts.map +1 -0
  17. package/dist/server.js +292 -0
  18. package/dist/server.js.map +1 -0
  19. package/dist/store.d.ts +63 -0
  20. package/dist/store.d.ts.map +1 -0
  21. package/dist/store.js +346 -0
  22. package/dist/store.js.map +1 -0
  23. package/dist/types.d.ts +61 -0
  24. package/dist/types.d.ts.map +1 -0
  25. package/dist/types.js +13 -0
  26. package/dist/types.js.map +1 -0
  27. package/package.json +81 -0
  28. package/skill/SKILL.md +109 -0
  29. package/src/author.ts +57 -0
  30. package/src/cli.ts +419 -0
  31. package/src/index.ts +6 -0
  32. package/src/server.ts +311 -0
  33. package/src/store.ts +418 -0
  34. package/src/types.ts +75 -0
  35. package/ui/dist/assets/index-BopXdeSy.js +229 -0
  36. package/ui/dist/assets/index-Cl_P2tLU.css +1 -0
  37. package/ui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  38. package/ui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  39. package/ui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  40. package/ui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  41. package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  42. package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  43. package/ui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  44. package/ui/dist/icon.svg +4 -0
  45. 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
+ }