goalbuddy 0.3.2 → 0.3.5
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 +28 -3
- package/RELEASE-0.3.5.md +324 -0
- package/goalbuddy/SKILL.md +8 -2
- package/goalbuddy/agents/goal_judge.toml +29 -17
- package/goalbuddy/agents/goal_scout.toml +34 -14
- package/goalbuddy/agents/goal_worker.toml +32 -15
- package/goalbuddy/extend/local-goal-board/README.md +8 -4
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
- package/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
- package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +940 -24
- package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
- package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +420 -4
- package/goalbuddy/scripts/check-goal-state.mjs +116 -6
- package/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/goalbuddy/scripts/render-task-prompt.mjs +248 -0
- package/goalbuddy/templates/agents.md +2 -2
- package/goalbuddy/templates/state.yaml +8 -0
- package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
- package/internal/cli/goal-maker.mjs +64 -1
- package/package.json +3 -2
- package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
- package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
- package/plugins/goalbuddy/README.md +5 -3
- package/plugins/goalbuddy/agents/goal-judge.md +31 -16
- package/plugins/goalbuddy/agents/goal-scout.md +38 -13
- package/plugins/goalbuddy/agents/goal-worker.md +35 -14
- package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +8 -2
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +29 -17
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +32 -15
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +940 -24
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +420 -4
- package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +116 -6
- package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +248 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +2 -2
- package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +8 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
-
import { existsSync, readFileSync, realpathSync, watch } from "node:fs";
|
|
4
|
-
import {
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, watch, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { createBoardPayload, writeBoardApp } from "./lib/goal-board.mjs";
|
|
7
8
|
|
|
@@ -13,6 +14,26 @@ const textTypes = {
|
|
|
13
14
|
".png": "image/png",
|
|
14
15
|
};
|
|
15
16
|
|
|
17
|
+
const SETTINGS_VERSION = 1;
|
|
18
|
+
const SETTINGS_DEFAULTS = {
|
|
19
|
+
theme: "system",
|
|
20
|
+
density: "comfortable",
|
|
21
|
+
completedVisibility: "show",
|
|
22
|
+
boardOpenBehavior: "last",
|
|
23
|
+
motion: "system",
|
|
24
|
+
lastBoardPath: "",
|
|
25
|
+
};
|
|
26
|
+
const SETTINGS_OPTIONS = {
|
|
27
|
+
theme: new Set(["system", "light", "dark"]),
|
|
28
|
+
density: new Set(["comfortable", "compact"]),
|
|
29
|
+
completedVisibility: new Set(["show", "collapse"]),
|
|
30
|
+
boardOpenBehavior: new Set(["last", "newest"]),
|
|
31
|
+
motion: new Set(["system", "reduce", "allow"]),
|
|
32
|
+
};
|
|
33
|
+
const DEFAULT_BIND_HOST = "127.0.0.1";
|
|
34
|
+
const DEFAULT_PUBLIC_HOST = "goalbuddy.localhost";
|
|
35
|
+
const DEFAULT_PORT = 41737;
|
|
36
|
+
|
|
16
37
|
if (isDirectRun()) {
|
|
17
38
|
main().catch((error) => {
|
|
18
39
|
console.error(error.message);
|
|
@@ -45,19 +66,35 @@ export async function main() {
|
|
|
45
66
|
return { goalDir, appDir, board };
|
|
46
67
|
}
|
|
47
68
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
let server = null;
|
|
70
|
+
try {
|
|
71
|
+
server = await startBoardServer({
|
|
72
|
+
goalDir,
|
|
73
|
+
appDir,
|
|
74
|
+
host: options.host,
|
|
75
|
+
publicHost: options.publicHost,
|
|
76
|
+
port: options.port,
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code !== "EADDRINUSE") throw error;
|
|
80
|
+
server = await registerWithBoardHub({
|
|
81
|
+
goalDir,
|
|
82
|
+
host: options.host,
|
|
83
|
+
port: options.port,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
54
86
|
|
|
55
87
|
if (options.json) {
|
|
56
|
-
console.log(JSON.stringify({ goalDir, appDir, url: server.url }, null, 2));
|
|
88
|
+
console.log(JSON.stringify({ goalDir, appDir: server.appDir || appDir, url: server.url, hubUrl: server.hubUrl, apiUrl: server.apiUrl, registered: Boolean(server.registered) }, null, 2));
|
|
57
89
|
} else {
|
|
58
90
|
console.log(`GoalBuddy local board: ${server.url}`);
|
|
59
|
-
console.log(`
|
|
60
|
-
|
|
91
|
+
console.log(`GoalBuddy local hub: ${server.hubUrl}`);
|
|
92
|
+
if (server.registered) {
|
|
93
|
+
console.log("Registered with the existing GoalBuddy local board hub.");
|
|
94
|
+
} else {
|
|
95
|
+
console.log(`Watching: ${join(goalDir, "state.yaml")}`);
|
|
96
|
+
console.log("Press Ctrl-C to stop.");
|
|
97
|
+
}
|
|
61
98
|
}
|
|
62
99
|
|
|
63
100
|
return server;
|
|
@@ -66,8 +103,9 @@ export async function main() {
|
|
|
66
103
|
export function parseArgs(args) {
|
|
67
104
|
const options = {
|
|
68
105
|
goal: "",
|
|
69
|
-
host:
|
|
70
|
-
|
|
106
|
+
host: DEFAULT_BIND_HOST,
|
|
107
|
+
publicHost: DEFAULT_PUBLIC_HOST,
|
|
108
|
+
port: DEFAULT_PORT,
|
|
71
109
|
once: false,
|
|
72
110
|
json: false,
|
|
73
111
|
};
|
|
@@ -80,8 +118,10 @@ export function parseArgs(args) {
|
|
|
80
118
|
options.goal = arg.slice("--goal=".length);
|
|
81
119
|
} else if (arg === "--host") {
|
|
82
120
|
options.host = args[++index] || options.host;
|
|
121
|
+
options.publicHost = options.host;
|
|
83
122
|
} else if (arg.startsWith("--host=")) {
|
|
84
123
|
options.host = arg.slice("--host=".length);
|
|
124
|
+
options.publicHost = options.host;
|
|
85
125
|
} else if (arg === "--port") {
|
|
86
126
|
options.port = Number(args[++index] || options.port);
|
|
87
127
|
} else if (arg.startsWith("--port=")) {
|
|
@@ -105,38 +145,115 @@ export function parseArgs(args) {
|
|
|
105
145
|
return options;
|
|
106
146
|
}
|
|
107
147
|
|
|
108
|
-
export async function startBoardServer(
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
148
|
+
export async function startBoardServer(options = {}) {
|
|
149
|
+
const {
|
|
150
|
+
goalDir,
|
|
151
|
+
appDir = "",
|
|
152
|
+
host = DEFAULT_BIND_HOST,
|
|
153
|
+
publicHost = Object.hasOwn(options, "host") ? host : DEFAULT_PUBLIC_HOST,
|
|
154
|
+
port = DEFAULT_PORT,
|
|
155
|
+
} = options;
|
|
156
|
+
const boards = new Map();
|
|
157
|
+
let baseUrl = "";
|
|
158
|
+
let initialBoard = null;
|
|
117
159
|
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
sendJson(response, safePayload(root));
|
|
123
|
-
return;
|
|
160
|
+
const addBoard = (candidateGoalDir, candidateAppDir = "") => {
|
|
161
|
+
const root = resolve(candidateGoalDir);
|
|
162
|
+
if (!existsSync(join(root, "state.yaml"))) {
|
|
163
|
+
throw new Error(`Missing state.yaml in ${root}`);
|
|
124
164
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
132
|
-
response.write("retry: 1000\n\n");
|
|
133
|
-
clients.add(response);
|
|
134
|
-
sendEvent(response, lastPayload);
|
|
135
|
-
request.on("close", () => clients.delete(response));
|
|
136
|
-
return;
|
|
165
|
+
|
|
166
|
+
const existing = [...boards.values()].find((board) => board.root === root);
|
|
167
|
+
if (existing) {
|
|
168
|
+
existing.appDir = candidateAppDir || writeBoardApp(root);
|
|
169
|
+
existing.lastPayload = safePayload(root);
|
|
170
|
+
return boardSummary(existing, baseUrl);
|
|
137
171
|
}
|
|
138
172
|
|
|
139
|
-
|
|
173
|
+
const payload = safePayload(root);
|
|
174
|
+
const board = {
|
|
175
|
+
root,
|
|
176
|
+
appDir: candidateAppDir || writeBoardApp(root),
|
|
177
|
+
boardPath: nextBoardPath(root, payload, boards),
|
|
178
|
+
clients: new Set(),
|
|
179
|
+
lastPayload: payload,
|
|
180
|
+
watcher: null,
|
|
181
|
+
startedAt: new Date().toISOString(),
|
|
182
|
+
};
|
|
183
|
+
board.watcher = watchGoal(root, () => {
|
|
184
|
+
board.lastPayload = safePayload(root);
|
|
185
|
+
for (const client of board.clients) sendEvent(client, board.lastPayload);
|
|
186
|
+
board.watcher.refresh();
|
|
187
|
+
});
|
|
188
|
+
boards.set(board.boardPath, board);
|
|
189
|
+
return boardSummary(board, baseUrl);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const server = createServer(async (request, response) => {
|
|
193
|
+
try {
|
|
194
|
+
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
|
|
195
|
+
if (request.method === "POST" && url.pathname === "/api/boards") {
|
|
196
|
+
const payload = await readJsonRequest(request);
|
|
197
|
+
sendJson(response, addBoard(payload.goalDir || ""));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (url.pathname === "/" || url.pathname === "/boards") {
|
|
201
|
+
redirectToFirstBoard(response, boards, baseUrl, readBoardSettings());
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (url.pathname === "/api/boards") {
|
|
205
|
+
sendJson(response, { boards: [...boards.values()].map((board) => boardSummary(board, baseUrl)) });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (url.pathname === "/api/settings") {
|
|
209
|
+
if (request.method === "GET") {
|
|
210
|
+
sendJson(response, { version: SETTINGS_VERSION, settings: readBoardSettings() });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (request.method === "PUT") {
|
|
214
|
+
const payload = await readJsonRequest(request);
|
|
215
|
+
sendJson(response, { version: SETTINGS_VERSION, settings: writeBoardSettings(payload.settings || payload) });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
response.writeHead(405, { "Allow": "GET, PUT" });
|
|
219
|
+
response.end("Method not allowed");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const slashUrl = boardTrailingSlashUrl(url.pathname, boards, baseUrl);
|
|
224
|
+
if (slashUrl) {
|
|
225
|
+
redirect(response, slashUrl);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const route = routeBoardRequest(url.pathname, boards, initialBoard);
|
|
230
|
+
if (!route.board) {
|
|
231
|
+
response.writeHead(404);
|
|
232
|
+
response.end("Not found");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (route.pathname === "/api/board") {
|
|
236
|
+
sendJson(response, safePayload(route.board.root));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (route.pathname === "/events") {
|
|
240
|
+
response.writeHead(200, {
|
|
241
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
242
|
+
"Cache-Control": "no-cache, no-transform",
|
|
243
|
+
"Connection": "keep-alive",
|
|
244
|
+
"X-Accel-Buffering": "no",
|
|
245
|
+
});
|
|
246
|
+
response.write("retry: 1000\n\n");
|
|
247
|
+
route.board.clients.add(response);
|
|
248
|
+
sendEvent(response, route.board.lastPayload);
|
|
249
|
+
request.on("close", () => route.board.clients.delete(response));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
serveStatic(route.board.appDir, route.pathname, response);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
sendError(response, error);
|
|
256
|
+
}
|
|
140
257
|
});
|
|
141
258
|
|
|
142
259
|
await new Promise((resolveListen, rejectListen) => {
|
|
@@ -149,32 +266,184 @@ export async function startBoardServer({ goalDir, appDir = writeBoardApp(goalDir
|
|
|
149
266
|
|
|
150
267
|
const address = server.address();
|
|
151
268
|
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
269
|
+
baseUrl = `http://${publicHost || host}:${actualPort}`;
|
|
270
|
+
const initialSummary = addBoard(goalDir, appDir);
|
|
271
|
+
initialBoard = boards.get(new URL(initialSummary.url).pathname);
|
|
152
272
|
|
|
153
273
|
return {
|
|
154
|
-
|
|
274
|
+
...initialSummary,
|
|
155
275
|
close: () => new Promise((resolveClose, rejectClose) => {
|
|
156
|
-
|
|
157
|
-
|
|
276
|
+
for (const board of boards.values()) {
|
|
277
|
+
board.watcher.close();
|
|
278
|
+
for (const client of board.clients) client.end();
|
|
279
|
+
}
|
|
158
280
|
server.close((error) => error ? rejectClose(error) : resolveClose());
|
|
159
281
|
}),
|
|
160
282
|
};
|
|
161
283
|
}
|
|
162
284
|
|
|
285
|
+
async function registerWithBoardHub({ goalDir, host, port }) {
|
|
286
|
+
const response = await fetch(`http://${host}:${port}/api/boards`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: { "Content-Type": "application/json" },
|
|
289
|
+
body: JSON.stringify({ goalDir }),
|
|
290
|
+
});
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
const message = await response.text();
|
|
293
|
+
if (response.status === 404) {
|
|
294
|
+
throw new Error(`Port ${port} is already in use, but it is not the GoalBuddy multi-board hub. Stop the existing local board process on ${host}:${port}, then retry.`);
|
|
295
|
+
}
|
|
296
|
+
throw new Error(`GoalBuddy local board hub rejected ${goalDir}: ${message}`);
|
|
297
|
+
}
|
|
298
|
+
return { ...(await response.json()), registered: true };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function redirectToFirstBoard(response, boards, baseUrl, settings = {}) {
|
|
302
|
+
const board = preferredBoard(boards, settings);
|
|
303
|
+
if (!board) {
|
|
304
|
+
response.writeHead(404, {
|
|
305
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
306
|
+
"Cache-Control": "no-store",
|
|
307
|
+
});
|
|
308
|
+
response.end("No GoalBuddy boards are registered.");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
redirect(response, `${baseUrl}${board.boardPath}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function preferredBoard(boards, settings = {}) {
|
|
316
|
+
const allBoards = [...boards.values()];
|
|
317
|
+
if (allBoards.length === 0) return null;
|
|
318
|
+
const normalized = normalizeSettings(settings);
|
|
319
|
+
if (normalized.boardOpenBehavior === "last" && normalized.lastBoardPath) {
|
|
320
|
+
const remembered = allBoards.find((board) => board.boardPath === normalized.lastBoardPath);
|
|
321
|
+
if (remembered) return remembered;
|
|
322
|
+
}
|
|
323
|
+
if (normalized.boardOpenBehavior === "newest") {
|
|
324
|
+
return allBoards
|
|
325
|
+
.slice()
|
|
326
|
+
.sort((left, right) => right.startedAt.localeCompare(left.startedAt))[0];
|
|
327
|
+
}
|
|
328
|
+
return allBoards[0];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function boardTrailingSlashUrl(pathname, boards, baseUrl) {
|
|
332
|
+
for (const board of boards.values()) {
|
|
333
|
+
const prefix = board.boardPath.endsWith("/") ? board.boardPath.slice(0, -1) : board.boardPath;
|
|
334
|
+
if (pathname === prefix) return `${baseUrl}${board.boardPath}`;
|
|
335
|
+
}
|
|
336
|
+
return "";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function redirect(response, location) {
|
|
340
|
+
response.writeHead(302, {
|
|
341
|
+
"Location": location,
|
|
342
|
+
"Cache-Control": "no-store",
|
|
343
|
+
});
|
|
344
|
+
response.end();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function boardPathFor(goalDir, payload) {
|
|
348
|
+
const slug = slugifyPathSegment(payload?.goal?.slug || basename(goalDir));
|
|
349
|
+
return `/${slug || "goal"}/`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function nextBoardPath(goalDir, payload, boards) {
|
|
353
|
+
const existing = [...boards.values()].find((board) => board.root === goalDir);
|
|
354
|
+
if (existing) return existing.boardPath;
|
|
355
|
+
|
|
356
|
+
const basePath = boardPathFor(goalDir, payload);
|
|
357
|
+
if (!boards.has(basePath)) return basePath;
|
|
358
|
+
|
|
359
|
+
const prefix = basePath.slice(0, -1);
|
|
360
|
+
for (let index = 2; index < 1000; index += 1) {
|
|
361
|
+
const candidate = `${prefix}-${index}/`;
|
|
362
|
+
if (!boards.has(candidate)) return candidate;
|
|
363
|
+
}
|
|
364
|
+
throw new Error(`Could not allocate a board path for ${goalDir}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function boardSummary(board, baseUrl) {
|
|
368
|
+
const slug = slugifyPathSegment(board.lastPayload.goal?.slug || basename(board.root)) || "goal";
|
|
369
|
+
return {
|
|
370
|
+
goalDir: board.root,
|
|
371
|
+
appDir: board.appDir,
|
|
372
|
+
title: board.lastPayload.goal?.title || basename(board.root),
|
|
373
|
+
slug,
|
|
374
|
+
url: `${baseUrl}${board.boardPath}`,
|
|
375
|
+
hubUrl: `${baseUrl}/`,
|
|
376
|
+
indexUrl: `${baseUrl}/`,
|
|
377
|
+
apiUrl: `${baseUrl}/api/boards`,
|
|
378
|
+
startedAt: board.startedAt,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function slugifyPathSegment(value) {
|
|
383
|
+
return String(value || "")
|
|
384
|
+
.trim()
|
|
385
|
+
.toLowerCase()
|
|
386
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
387
|
+
.replace(/^-+|-+$/g, "");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function routeBoardRequest(pathname, boards, initialBoard) {
|
|
391
|
+
if ((pathname === "/api/board" || pathname === "/events") && initialBoard) {
|
|
392
|
+
return { board: initialBoard, pathname };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const matches = [...boards.values()]
|
|
396
|
+
.map((board) => ({ board, pathname: stripBoardPathPrefix(pathname, board.boardPath) }))
|
|
397
|
+
.filter((route) => route.pathname !== pathname || pathname === route.board.boardPath.slice(0, -1))
|
|
398
|
+
.sort((left, right) => right.board.boardPath.length - left.board.boardPath.length);
|
|
399
|
+
|
|
400
|
+
return matches[0] || { board: null, pathname };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function stripBoardPathPrefix(pathname, boardPath) {
|
|
404
|
+
const prefix = boardPath.endsWith("/") ? boardPath.slice(0, -1) : boardPath;
|
|
405
|
+
if (pathname === prefix) return "/";
|
|
406
|
+
if (pathname.startsWith(`${prefix}/`)) {
|
|
407
|
+
return pathname.slice(prefix.length) || "/";
|
|
408
|
+
}
|
|
409
|
+
return pathname;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function readJsonRequest(request) {
|
|
413
|
+
let body = "";
|
|
414
|
+
for await (const chunk of request) {
|
|
415
|
+
body += chunk;
|
|
416
|
+
if (body.length > 1_000_000) throw new Error("Request body is too large.");
|
|
417
|
+
}
|
|
418
|
+
return JSON.parse(body || "{}");
|
|
419
|
+
}
|
|
420
|
+
|
|
163
421
|
function watchGoal(goalDir, onChange) {
|
|
164
422
|
const watchers = [];
|
|
165
423
|
const schedule = debounce(onChange, 80);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
424
|
+
let watchedDirs = new Set();
|
|
425
|
+
|
|
426
|
+
const rebuild = () => {
|
|
427
|
+
for (const watcher of watchers.splice(0)) watcher.close();
|
|
428
|
+
watchedDirs = goalDirsForPayload(goalDir);
|
|
429
|
+
for (const dir of watchedDirs) {
|
|
430
|
+
watchers.push(watch(dir, { persistent: true }, (_event, filename) => {
|
|
431
|
+
if (!filename || filename === "state.yaml" || filename === "notes") schedule();
|
|
432
|
+
}));
|
|
433
|
+
const notesDir = join(dir, "notes");
|
|
434
|
+
if (existsSync(notesDir)) watchers.push(watch(notesDir, { persistent: true }, schedule));
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
rebuild();
|
|
174
439
|
return {
|
|
175
440
|
close() {
|
|
176
441
|
for (const watcher of watchers) watcher.close();
|
|
177
442
|
},
|
|
443
|
+
refresh() {
|
|
444
|
+
const next = goalDirsForPayload(goalDir);
|
|
445
|
+
if (!sameSet(watchedDirs, next)) rebuild();
|
|
446
|
+
},
|
|
178
447
|
};
|
|
179
448
|
}
|
|
180
449
|
|
|
@@ -198,6 +467,31 @@ function safePayload(goalDir) {
|
|
|
198
467
|
}
|
|
199
468
|
}
|
|
200
469
|
|
|
470
|
+
function goalDirsForPayload(goalDir) {
|
|
471
|
+
const dirs = new Set([resolve(goalDir)]);
|
|
472
|
+
try {
|
|
473
|
+
collectPayloadGoalDirs(createBoardPayload(goalDir), dirs);
|
|
474
|
+
} catch {
|
|
475
|
+
// Keep watching the parent when the board is temporarily invalid.
|
|
476
|
+
}
|
|
477
|
+
return dirs;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function collectPayloadGoalDirs(payload, dirs) {
|
|
481
|
+
if (payload?.source?.goalDir) dirs.add(resolve(payload.source.goalDir));
|
|
482
|
+
for (const task of payload?.tasks || []) {
|
|
483
|
+
if (task.subgoal?.board) collectPayloadGoalDirs(task.subgoal.board, dirs);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function sameSet(left, right) {
|
|
488
|
+
if (left.size !== right.size) return false;
|
|
489
|
+
for (const value of left) {
|
|
490
|
+
if (!right.has(value)) return false;
|
|
491
|
+
}
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
|
|
201
495
|
function sendJson(response, payload) {
|
|
202
496
|
response.writeHead(200, {
|
|
203
497
|
"Content-Type": "application/json; charset=utf-8",
|
|
@@ -206,6 +500,14 @@ function sendJson(response, payload) {
|
|
|
206
500
|
response.end(JSON.stringify(payload, null, 2));
|
|
207
501
|
}
|
|
208
502
|
|
|
503
|
+
function sendError(response, error) {
|
|
504
|
+
response.writeHead(400, {
|
|
505
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
506
|
+
"Cache-Control": "no-store",
|
|
507
|
+
});
|
|
508
|
+
response.end(error.message || "Request failed");
|
|
509
|
+
}
|
|
510
|
+
|
|
209
511
|
function sendEvent(response, payload) {
|
|
210
512
|
response.write(`event: board\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
211
513
|
}
|
|
@@ -241,6 +543,39 @@ function debounce(fn, delay) {
|
|
|
241
543
|
};
|
|
242
544
|
}
|
|
243
545
|
|
|
546
|
+
function readBoardSettings() {
|
|
547
|
+
try {
|
|
548
|
+
if (!existsSync(settingsPath())) return { ...SETTINGS_DEFAULTS };
|
|
549
|
+
return normalizeSettings(JSON.parse(readFileSync(settingsPath(), "utf8")));
|
|
550
|
+
} catch {
|
|
551
|
+
return { ...SETTINGS_DEFAULTS };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function writeBoardSettings(settings) {
|
|
556
|
+
const normalized = normalizeSettings(settings);
|
|
557
|
+
const path = settingsPath();
|
|
558
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
559
|
+
writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`);
|
|
560
|
+
return normalized;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function normalizeSettings(settings) {
|
|
564
|
+
const normalized = { ...SETTINGS_DEFAULTS };
|
|
565
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return normalized;
|
|
566
|
+
for (const [key, allowed] of Object.entries(SETTINGS_OPTIONS)) {
|
|
567
|
+
if (allowed.has(settings[key])) normalized[key] = settings[key];
|
|
568
|
+
}
|
|
569
|
+
if (typeof settings.lastBoardPath === "string" && /^\/[a-z0-9][a-z0-9-]*\/$/.test(settings.lastBoardPath)) {
|
|
570
|
+
normalized.lastBoardPath = settings.lastBoardPath;
|
|
571
|
+
}
|
|
572
|
+
return normalized;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function settingsPath() {
|
|
576
|
+
return process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH || join(homedir(), ".goalbuddy", "local-board-settings.json");
|
|
577
|
+
}
|
|
578
|
+
|
|
244
579
|
function usage() {
|
|
245
580
|
console.log(`GoalBuddy Local Goal Board
|
|
246
581
|
|
|
@@ -250,8 +585,8 @@ Usage:
|
|
|
250
585
|
|
|
251
586
|
Options:
|
|
252
587
|
--goal <path> Goal directory containing state.yaml.
|
|
253
|
-
--host <host> Local server host. Default: 127.0.0.1.
|
|
254
|
-
--port <port> Local server port.
|
|
588
|
+
--host <host> Local server bind host. Default: 127.0.0.1, advertised as goalbuddy.localhost.
|
|
589
|
+
--port <port> Local server port. Default: 41737 shared board hub.
|
|
255
590
|
--once Generate .goalbuddy-board and exit.
|
|
256
591
|
--json Print structured output.
|
|
257
592
|
`);
|