trekoon 0.2.7 → 0.2.9

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/README.md +60 -0
  2. package/docs/commands.md +100 -0
  3. package/docs/quickstart.md +74 -1
  4. package/package.json +2 -1
  5. package/src/board/assets/app.js +589 -0
  6. package/src/board/assets/components/ClampedText.js +31 -0
  7. package/src/board/assets/components/Component.js +271 -0
  8. package/src/board/assets/components/ConfirmDialog.js +81 -0
  9. package/src/board/assets/components/EpicRow.js +64 -0
  10. package/src/board/assets/components/EpicsOverview.js +80 -0
  11. package/src/board/assets/components/Inspector.js +335 -0
  12. package/src/board/assets/components/Notice.js +80 -0
  13. package/src/board/assets/components/SubtaskModal.js +100 -0
  14. package/src/board/assets/components/TaskCard.js +82 -0
  15. package/src/board/assets/components/TaskModal.js +99 -0
  16. package/src/board/assets/components/TopBar.js +167 -0
  17. package/src/board/assets/components/Workspace.js +308 -0
  18. package/src/board/assets/components/assetMap.js +80 -0
  19. package/src/board/assets/components/helpers.js +244 -0
  20. package/src/board/assets/fonts/inter-latin.woff2 +0 -0
  21. package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
  22. package/src/board/assets/index.html +39 -0
  23. package/src/board/assets/main.js +11 -0
  24. package/src/board/assets/manifest.json +12 -0
  25. package/src/board/assets/runtime/delegation.js +309 -0
  26. package/src/board/assets/state/actions.js +454 -0
  27. package/src/board/assets/state/api.js +281 -0
  28. package/src/board/assets/state/store.js +472 -0
  29. package/src/board/assets/state/url.js +184 -0
  30. package/src/board/assets/state/utils.js +222 -0
  31. package/src/board/assets/styles/board.css +1811 -0
  32. package/src/board/assets/styles/fonts.css +22 -0
  33. package/src/board/install.ts +196 -0
  34. package/src/board/open-browser.ts +131 -0
  35. package/src/board/routes.ts +308 -0
  36. package/src/board/server.ts +185 -0
  37. package/src/board/snapshot.ts +277 -0
  38. package/src/board/types.ts +43 -0
  39. package/src/commands/board.ts +158 -0
  40. package/src/commands/help.ts +21 -0
  41. package/src/commands/init.ts +29 -0
  42. package/src/domain/mutation-service.ts +40 -0
  43. package/src/domain/tracker-domain.ts +11 -3
  44. package/src/runtime/cli-shell.ts +5 -0
  45. package/src/storage/path.ts +36 -0
@@ -0,0 +1,185 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, extname, resolve } from "node:path";
4
+
5
+ import { createBoardApiHandler } from "./routes";
6
+
7
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
8
+ import { resolveStoragePaths } from "../storage/path";
9
+
10
+ const CONTENT_TYPES: Record<string, string> = {
11
+ ".css": "text/css; charset=utf-8",
12
+ ".html": "text/html; charset=utf-8",
13
+ ".js": "text/javascript; charset=utf-8",
14
+ ".json": "application/json; charset=utf-8",
15
+ ".svg": "image/svg+xml",
16
+ ".txt": "text/plain; charset=utf-8",
17
+ ".woff2": "font/woff2",
18
+ };
19
+
20
+ const BOARD_SERVER_STATE_FILENAME = "board-server.json";
21
+
22
+ interface BoardServerState {
23
+ readonly preferredPort: number;
24
+ }
25
+
26
+ export interface BoardServerInfo {
27
+ readonly origin: string;
28
+ readonly url: string;
29
+ readonly fallbackUrl: string;
30
+ readonly token: string;
31
+ readonly hostname: "127.0.0.1";
32
+ readonly port: number;
33
+ stop(): void;
34
+ }
35
+
36
+ export interface StartBoardServerOptions {
37
+ readonly cwd?: string;
38
+ readonly token?: string;
39
+ }
40
+
41
+ function guessContentType(pathname: string): string {
42
+ return CONTENT_TYPES[extname(pathname).toLowerCase()] ?? "application/octet-stream";
43
+ }
44
+
45
+ function readAssetPath(boardRoot: string, pathname: string): string | null {
46
+ const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
47
+ const candidate = resolve(boardRoot, relativePath);
48
+ if (!candidate.startsWith(resolve(boardRoot))) {
49
+ return null;
50
+ }
51
+
52
+ return existsSync(candidate) ? candidate : null;
53
+ }
54
+
55
+ function readPreferredBoardPort(stateFile: string): number | null {
56
+ if (!existsSync(stateFile)) {
57
+ return null;
58
+ }
59
+
60
+ try {
61
+ const rawState: string = readFileSync(stateFile, "utf8");
62
+ const state = JSON.parse(rawState) as Partial<BoardServerState>;
63
+ const preferredPort = state.preferredPort;
64
+
65
+ if (typeof preferredPort !== "number" || !Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) {
66
+ return null;
67
+ }
68
+
69
+ return preferredPort;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function persistPreferredBoardPort(stateFile: string, port: number): void {
76
+ mkdirSync(dirname(stateFile), { recursive: true });
77
+ writeFileSync(stateFile, `${JSON.stringify({ preferredPort: port }, null, 2)}\n`, "utf8");
78
+ }
79
+
80
+ function isUnavailablePortError(error: unknown): boolean {
81
+ if (!(error instanceof Error)) {
82
+ return false;
83
+ }
84
+
85
+ const errorWithCode = error as Error & { code?: unknown };
86
+ const errorCode = typeof errorWithCode.code === "string" ? errorWithCode.code : "";
87
+ return /^(EADDRINUSE|EACCES)$/i.test(errorCode) || /(EADDRINUSE|EACCES|address already in use|permission denied)/i.test(error.message);
88
+ }
89
+
90
+ export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
91
+ const cwd: string = options.cwd ?? process.cwd();
92
+ const database: TrekoonDatabase = openTrekoonDatabase(cwd);
93
+ const paths = resolveStoragePaths(cwd);
94
+ const boardRoot: string = paths.boardDir;
95
+ const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
96
+ const token: string = options.token ?? randomBytes(18).toString("hex");
97
+ const apiHandler = createBoardApiHandler({
98
+ db: database.db,
99
+ cwd,
100
+ token,
101
+ });
102
+
103
+ const serveBoard = (port: number) =>
104
+ Bun.serve({
105
+ hostname: "127.0.0.1",
106
+ port,
107
+ fetch(request: Request): Promise<Response> | Response {
108
+ const url = new URL(request.url);
109
+ if (url.pathname.startsWith("/api/")) {
110
+ return apiHandler(request);
111
+ }
112
+
113
+ const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
114
+ if (assetPath === null) {
115
+ const fallbackPath = readAssetPath(boardRoot, "/index.html");
116
+ if (fallbackPath === null) {
117
+ return new Response("Board assets are not installed", { status: 500 });
118
+ }
119
+
120
+ return new Response(readFileSync(fallbackPath), {
121
+ headers: {
122
+ "cache-control": "no-store",
123
+ "content-type": "text/html; charset=utf-8",
124
+ },
125
+ });
126
+ }
127
+
128
+ return new Response(readFileSync(assetPath), {
129
+ headers: {
130
+ "cache-control": "no-store",
131
+ "content-type": guessContentType(assetPath),
132
+ },
133
+ });
134
+ },
135
+ error(error: Error): Response {
136
+ return new Response(`Board server error: ${error.message}`, { status: 500 });
137
+ },
138
+ });
139
+
140
+ const preferredPort: number | null = readPreferredBoardPort(stateFile);
141
+
142
+ let server;
143
+ try {
144
+ server = serveBoard(preferredPort ?? 0);
145
+ } catch (error) {
146
+ if (preferredPort === null || !isUnavailablePortError(error)) {
147
+ database.close();
148
+ throw error;
149
+ }
150
+
151
+ server = serveBoard(0);
152
+ }
153
+
154
+ const port: number | undefined = server.port;
155
+ if (port === undefined) {
156
+ server.stop(true);
157
+ database.close();
158
+ throw new Error("Board server did not expose a listening port");
159
+ }
160
+
161
+ try {
162
+ persistPreferredBoardPort(stateFile, port);
163
+ } catch (error) {
164
+ server.stop(true);
165
+ database.close();
166
+ const message = error instanceof Error ? error.message : String(error);
167
+ throw new Error(`Board server could not persist preferred port at ${stateFile}: ${message}`);
168
+ }
169
+
170
+ const origin: string = `http://127.0.0.1:${port}`;
171
+ const url: string = `${origin}/?token=${encodeURIComponent(token)}`;
172
+
173
+ return {
174
+ origin,
175
+ url,
176
+ fallbackUrl: url,
177
+ token,
178
+ hostname: "127.0.0.1",
179
+ port,
180
+ stop(): void {
181
+ server.stop(true);
182
+ database.close();
183
+ },
184
+ };
185
+ }
@@ -0,0 +1,277 @@
1
+ import { TrackerDomain } from "../domain/tracker-domain";
2
+ import { type DependencyRecord, type EpicRecord, type SubtaskRecord, type TaskRecord } from "../domain/types";
3
+
4
+ interface SearchFields {
5
+ readonly title: string;
6
+ readonly description: string;
7
+ readonly text: string;
8
+ }
9
+
10
+ interface StatusCounts {
11
+ readonly total: number;
12
+ readonly todo: number;
13
+ readonly blocked: number;
14
+ readonly inProgress: number;
15
+ readonly done: number;
16
+ readonly other: number;
17
+ }
18
+
19
+ export interface BoardSnapshotEpic {
20
+ readonly id: string;
21
+ readonly title: string;
22
+ readonly description: string;
23
+ readonly status: string;
24
+ readonly createdAt: number;
25
+ readonly updatedAt: number;
26
+ readonly taskIds: readonly string[];
27
+ readonly counts: {
28
+ readonly tasks: StatusCounts;
29
+ readonly subtasks: StatusCounts;
30
+ };
31
+ readonly search: SearchFields;
32
+ }
33
+
34
+ export interface BoardSnapshotTask {
35
+ readonly id: string;
36
+ readonly epicId: string;
37
+ readonly title: string;
38
+ readonly description: string;
39
+ readonly status: string;
40
+ readonly createdAt: number;
41
+ readonly updatedAt: number;
42
+ readonly subtaskIds: readonly string[];
43
+ readonly dependencyIds: readonly string[];
44
+ readonly dependentIds: readonly string[];
45
+ readonly counts: {
46
+ readonly subtasks: StatusCounts;
47
+ readonly dependencies: number;
48
+ readonly dependents: number;
49
+ };
50
+ readonly search: SearchFields;
51
+ }
52
+
53
+ export interface BoardSnapshotSubtask {
54
+ readonly id: string;
55
+ readonly taskId: string;
56
+ readonly title: string;
57
+ readonly description: string;
58
+ readonly status: string;
59
+ readonly createdAt: number;
60
+ readonly updatedAt: number;
61
+ readonly dependencyIds: readonly string[];
62
+ readonly dependentIds: readonly string[];
63
+ readonly counts: {
64
+ readonly dependencies: number;
65
+ readonly dependents: number;
66
+ };
67
+ readonly search: SearchFields;
68
+ }
69
+
70
+ export interface BoardSnapshotDependency {
71
+ readonly id: string;
72
+ readonly sourceId: string;
73
+ readonly sourceKind: "task" | "subtask";
74
+ readonly dependsOnId: string;
75
+ readonly dependsOnKind: "task" | "subtask";
76
+ readonly createdAt: number;
77
+ readonly updatedAt: number;
78
+ }
79
+
80
+ export interface BoardSnapshot {
81
+ readonly generatedAt: number;
82
+ readonly epics: readonly BoardSnapshotEpic[];
83
+ readonly tasks: readonly BoardSnapshotTask[];
84
+ readonly subtasks: readonly BoardSnapshotSubtask[];
85
+ readonly dependencies: readonly BoardSnapshotDependency[];
86
+ readonly counts: {
87
+ readonly epics: StatusCounts;
88
+ readonly tasks: StatusCounts;
89
+ readonly subtasks: StatusCounts;
90
+ readonly dependencies: number;
91
+ };
92
+ }
93
+
94
+ function normalizeStatusBucket(status: string): keyof Omit<StatusCounts, "total"> {
95
+ if (status === "todo") {
96
+ return "todo";
97
+ }
98
+
99
+ if (status === "blocked") {
100
+ return "blocked";
101
+ }
102
+
103
+ if (status === "in_progress" || status === "in-progress") {
104
+ return "inProgress";
105
+ }
106
+
107
+ if (status === "done") {
108
+ return "done";
109
+ }
110
+
111
+ return "other";
112
+ }
113
+
114
+ function countStatuses(records: readonly { readonly status: string }[]): StatusCounts {
115
+ const counts: {
116
+ total: number;
117
+ todo: number;
118
+ blocked: number;
119
+ inProgress: number;
120
+ done: number;
121
+ other: number;
122
+ } = {
123
+ total: records.length,
124
+ todo: 0,
125
+ blocked: 0,
126
+ inProgress: 0,
127
+ done: 0,
128
+ other: 0,
129
+ };
130
+
131
+ for (const record of records) {
132
+ const bucket = normalizeStatusBucket(record.status);
133
+ counts[bucket] += 1;
134
+ }
135
+
136
+ return counts;
137
+ }
138
+
139
+ function buildSearchFields(title: string, description: string): SearchFields {
140
+ return {
141
+ title,
142
+ description,
143
+ text: `${title}\n${description}`.trim(),
144
+ };
145
+ }
146
+
147
+ function mapDependency(record: DependencyRecord): BoardSnapshotDependency {
148
+ return {
149
+ id: record.id,
150
+ sourceId: record.sourceId,
151
+ sourceKind: record.sourceKind,
152
+ dependsOnId: record.dependsOnId,
153
+ dependsOnKind: record.dependsOnKind,
154
+ createdAt: record.createdAt,
155
+ updatedAt: record.updatedAt,
156
+ };
157
+ }
158
+
159
+ export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
160
+ const generatedAt: number = Date.now();
161
+ const epics: readonly EpicRecord[] = domain.listEpics();
162
+ const tasks: readonly TaskRecord[] = domain.listTasks();
163
+ const subtasks: readonly SubtaskRecord[] = domain.listSubtasks();
164
+ const dependencies: BoardSnapshotDependency[] = [];
165
+
166
+ const tasksByEpic = new Map<string, TaskRecord[]>();
167
+ for (const task of tasks) {
168
+ const existing = tasksByEpic.get(task.epicId) ?? [];
169
+ existing.push(task);
170
+ tasksByEpic.set(task.epicId, existing);
171
+ }
172
+
173
+ const subtasksByTask = new Map<string, SubtaskRecord[]>();
174
+ for (const subtask of subtasks) {
175
+ const existing = subtasksByTask.get(subtask.taskId) ?? [];
176
+ existing.push(subtask);
177
+ subtasksByTask.set(subtask.taskId, existing);
178
+ }
179
+
180
+ const dependencyIdsBySource = new Map<string, string[]>();
181
+ const dependentIdsByTarget = new Map<string, string[]>();
182
+ for (const task of tasks) {
183
+ for (const dependency of domain.listDependencies(task.id)) {
184
+ dependencies.push(mapDependency(dependency));
185
+ const sourceIds = dependencyIdsBySource.get(dependency.sourceId) ?? [];
186
+ sourceIds.push(dependency.id);
187
+ dependencyIdsBySource.set(dependency.sourceId, sourceIds);
188
+ const dependentIds = dependentIdsByTarget.get(dependency.dependsOnId) ?? [];
189
+ dependentIds.push(dependency.id);
190
+ dependentIdsByTarget.set(dependency.dependsOnId, dependentIds);
191
+ }
192
+ }
193
+
194
+ for (const subtask of subtasks) {
195
+ for (const dependency of domain.listDependencies(subtask.id)) {
196
+ dependencies.push(mapDependency(dependency));
197
+ const sourceIds = dependencyIdsBySource.get(dependency.sourceId) ?? [];
198
+ sourceIds.push(dependency.id);
199
+ dependencyIdsBySource.set(dependency.sourceId, sourceIds);
200
+ const dependentIds = dependentIdsByTarget.get(dependency.dependsOnId) ?? [];
201
+ dependentIds.push(dependency.id);
202
+ dependentIdsByTarget.set(dependency.dependsOnId, dependentIds);
203
+ }
204
+ }
205
+
206
+ return {
207
+ generatedAt,
208
+ epics: epics.map((epic) => {
209
+ const epicTasks = tasksByEpic.get(epic.id) ?? [];
210
+ const epicSubtasks = epicTasks.flatMap((task) => subtasksByTask.get(task.id) ?? []);
211
+ return {
212
+ id: epic.id,
213
+ title: epic.title,
214
+ description: epic.description,
215
+ status: epic.status,
216
+ createdAt: epic.createdAt,
217
+ updatedAt: epic.updatedAt,
218
+ taskIds: epicTasks.map((task) => task.id),
219
+ counts: {
220
+ tasks: countStatuses(epicTasks),
221
+ subtasks: countStatuses(epicSubtasks),
222
+ },
223
+ search: buildSearchFields(epic.title, epic.description),
224
+ };
225
+ }),
226
+ tasks: tasks.map((task) => {
227
+ const taskSubtasks = subtasksByTask.get(task.id) ?? [];
228
+ const dependencyIds = dependencyIdsBySource.get(task.id) ?? [];
229
+ const dependentIds = dependentIdsByTarget.get(task.id) ?? [];
230
+ return {
231
+ id: task.id,
232
+ epicId: task.epicId,
233
+ title: task.title,
234
+ description: task.description,
235
+ status: task.status,
236
+ createdAt: task.createdAt,
237
+ updatedAt: task.updatedAt,
238
+ subtaskIds: taskSubtasks.map((subtask) => subtask.id),
239
+ dependencyIds,
240
+ dependentIds,
241
+ counts: {
242
+ subtasks: countStatuses(taskSubtasks),
243
+ dependencies: dependencyIds.length,
244
+ dependents: dependentIds.length,
245
+ },
246
+ search: buildSearchFields(task.title, task.description),
247
+ };
248
+ }),
249
+ subtasks: subtasks.map((subtask) => {
250
+ const dependencyIds = dependencyIdsBySource.get(subtask.id) ?? [];
251
+ const dependentIds = dependentIdsByTarget.get(subtask.id) ?? [];
252
+ return {
253
+ id: subtask.id,
254
+ taskId: subtask.taskId,
255
+ title: subtask.title,
256
+ description: subtask.description,
257
+ status: subtask.status,
258
+ createdAt: subtask.createdAt,
259
+ updatedAt: subtask.updatedAt,
260
+ dependencyIds,
261
+ dependentIds,
262
+ counts: {
263
+ dependencies: dependencyIds.length,
264
+ dependents: dependentIds.length,
265
+ },
266
+ search: buildSearchFields(subtask.title, subtask.description),
267
+ };
268
+ }),
269
+ dependencies,
270
+ counts: {
271
+ epics: countStatuses(epics),
272
+ tasks: countStatuses(tasks),
273
+ subtasks: countStatuses(subtasks),
274
+ dependencies: dependencies.length,
275
+ },
276
+ };
277
+ }
@@ -0,0 +1,43 @@
1
+ export const BOARD_ASSET_CONTRACT_VERSION = "1.0.0";
2
+ export const BOARD_BUNDLED_ASSET_DIRNAME = "assets";
3
+
4
+ export interface BoardAssetManifest {
5
+ readonly contractVersion: string;
6
+ readonly assetVersion: string;
7
+ readonly entryFile: string;
8
+ readonly files: readonly string[];
9
+ readonly assetDigest: string;
10
+ }
11
+
12
+ export interface BoardAssetPaths {
13
+ readonly sourceRoot: string;
14
+ readonly runtimeRoot: string;
15
+ readonly entryFile: string;
16
+ readonly manifestFile: string;
17
+ }
18
+
19
+ export type BoardInstallAction = "installed" | "reinstalled" | "updated" | "unchanged";
20
+
21
+ export interface BoardInstallResult {
22
+ readonly action: BoardInstallAction;
23
+ readonly paths: BoardAssetPaths;
24
+ readonly manifest: BoardAssetManifest;
25
+ }
26
+
27
+ export interface EnsureBoardInstalledOptions {
28
+ readonly workingDirectory?: string;
29
+ readonly assetVersion?: string;
30
+ readonly bundledAssetRoot?: string;
31
+ }
32
+
33
+ export class BoardInstallError extends Error {
34
+ readonly code: string;
35
+ readonly details: Record<string, unknown>;
36
+
37
+ constructor(code: string, message: string, details: Record<string, unknown> = {}) {
38
+ super(message);
39
+ this.name = "BoardInstallError";
40
+ this.code = code;
41
+ this.details = details;
42
+ }
43
+ }
@@ -0,0 +1,158 @@
1
+ import { parseArgs, readUnexpectedPositionals } from "./arg-parser";
2
+
3
+ import { ensureBoardInstalled, updateBoardInstallation } from "../board/install";
4
+ import { openBoardInBrowser, type OpenBrowserResult } from "../board/open-browser";
5
+ import { startBoardServer, type BoardServerInfo } from "../board/server";
6
+ import { BoardInstallError, type EnsureBoardInstalledOptions } from "../board/types";
7
+ import { failResult, okResult } from "../io/output";
8
+ import { type CliContext, type CliResult } from "../runtime/command-types";
9
+
10
+ type EnsureBoardInstalledFn = (options: EnsureBoardInstalledOptions) => ReturnType<typeof ensureBoardInstalled>;
11
+ type StartBoardServerFn = (options: { cwd: string }) => BoardServerInfo;
12
+ type OpenBoardInBrowserFn = (url: string) => Promise<OpenBrowserResult> | OpenBrowserResult;
13
+
14
+ let ensureInstalledImpl: EnsureBoardInstalledFn = ensureBoardInstalled;
15
+ let updateInstalledImpl: EnsureBoardInstalledFn = updateBoardInstallation;
16
+ let startBoardServerImpl: StartBoardServerFn = (options) => startBoardServer(options);
17
+ let openBoardInBrowserImpl: OpenBoardInBrowserFn = openBoardInBrowser;
18
+
19
+ function usageResult(): CliResult {
20
+ return failResult({
21
+ command: "board",
22
+ human: "Usage: trekoon board <open|update>",
23
+ data: {},
24
+ error: {
25
+ code: "invalid_subcommand",
26
+ message: "Invalid board subcommand",
27
+ },
28
+ });
29
+ }
30
+
31
+ function boardInstallOptions(context: CliContext): EnsureBoardInstalledOptions {
32
+ const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
33
+ return {
34
+ workingDirectory: context.cwd,
35
+ ...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
36
+ };
37
+ }
38
+
39
+ function boardInstallFailure(command: string, error: BoardInstallError): CliResult {
40
+ return failResult({
41
+ command,
42
+ human: error.message,
43
+ data: {
44
+ code: error.code,
45
+ ...error.details,
46
+ },
47
+ error: {
48
+ code: error.code,
49
+ message: error.message,
50
+ },
51
+ });
52
+ }
53
+
54
+ export function setBoardCommandHooksForTests(hooks: {
55
+ ensureInstalled?: EnsureBoardInstalledFn;
56
+ updateInstalled?: EnsureBoardInstalledFn;
57
+ startBoardServer?: StartBoardServerFn;
58
+ openBoardInBrowser?: OpenBoardInBrowserFn;
59
+ } | null): void {
60
+ ensureInstalledImpl = hooks?.ensureInstalled ?? ensureBoardInstalled;
61
+ updateInstalledImpl = hooks?.updateInstalled ?? updateBoardInstallation;
62
+ startBoardServerImpl = hooks?.startBoardServer ?? ((options) => startBoardServer(options));
63
+ openBoardInBrowserImpl = hooks?.openBoardInBrowser ?? openBoardInBrowser;
64
+ }
65
+
66
+ export async function runBoard(context: CliContext): Promise<CliResult> {
67
+ const parsed = parseArgs(context.args);
68
+ const subcommand: string | undefined = parsed.positional[0];
69
+
70
+ if (parsed.options.size > 0 || parsed.flags.size > 0) {
71
+ return failResult({
72
+ command: subcommand ? `board.${subcommand}` : "board",
73
+ human: "Board commands do not accept options yet.",
74
+ data: {
75
+ options: [...parsed.providedOptions].map((option) => `--${option}`),
76
+ },
77
+ error: {
78
+ code: "invalid_input",
79
+ message: "Board commands do not accept options",
80
+ },
81
+ });
82
+ }
83
+
84
+ if (!subcommand) {
85
+ return usageResult();
86
+ }
87
+
88
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, 1);
89
+ if (unexpectedPositionals.length > 0) {
90
+ return failResult({
91
+ command: `board.${subcommand}`,
92
+ human: `Unexpected positional arguments: ${unexpectedPositionals.join(", ")}.`,
93
+ data: {
94
+ unexpectedPositionals,
95
+ },
96
+ error: {
97
+ code: "invalid_input",
98
+ message: "Unexpected positional arguments",
99
+ },
100
+ });
101
+ }
102
+
103
+ try {
104
+ switch (subcommand) {
105
+ case "update": {
106
+ const install = updateInstalledImpl(boardInstallOptions(context));
107
+ return okResult({
108
+ command: "board.update",
109
+ human: `Board assets ${install.action} at ${install.paths.runtimeRoot}`,
110
+ data: {
111
+ action: install.action,
112
+ paths: install.paths,
113
+ manifest: install.manifest,
114
+ },
115
+ });
116
+ }
117
+ case "open": {
118
+ const install = ensureInstalledImpl(boardInstallOptions(context));
119
+ const server = startBoardServerImpl({ cwd: context.cwd });
120
+ const launch = await openBoardInBrowserImpl(server.url);
121
+ return okResult({
122
+ command: "board.open",
123
+ human: [
124
+ `Board ready at ${server.url}`,
125
+ launch.launched
126
+ ? `Browser launched with ${launch.command}`
127
+ : `Browser launch failed: ${launch.errorMessage ?? "unknown failure"}`,
128
+ `Open manually if needed: ${server.fallbackUrl}`,
129
+ ].join("\n"),
130
+ data: {
131
+ install: {
132
+ action: install.action,
133
+ paths: install.paths,
134
+ manifest: install.manifest,
135
+ },
136
+ server: {
137
+ origin: server.origin,
138
+ url: server.url,
139
+ fallbackUrl: server.fallbackUrl,
140
+ hostname: server.hostname,
141
+ port: server.port,
142
+ token: server.token,
143
+ },
144
+ launch,
145
+ },
146
+ });
147
+ }
148
+ default:
149
+ return usageResult();
150
+ }
151
+ } catch (error: unknown) {
152
+ if (error instanceof BoardInstallError) {
153
+ return boardInstallFailure(`board.${subcommand}`, error);
154
+ }
155
+
156
+ throw error;
157
+ }
158
+ }
@@ -21,6 +21,7 @@ const ROOT_HELP = [
21
21
  " init Initialize repo-shared .trekoon storage and DB",
22
22
  " quickstart Show shared-storage bootstrap + AI execution loop",
23
23
  " wipe Remove repo-shared Trekoon state (requires --yes)",
24
+ " board Local board asset and browser commands",
24
25
  " epic Epic lifecycle commands",
25
26
  " task Task lifecycle commands",
26
27
  " subtask Subtask lifecycle commands",
@@ -83,6 +84,25 @@ const WIPE_HELP = [
83
84
  " trekoon wipe --yes",
84
85
  ].join("\n");
85
86
 
87
+ const BOARD_HELP = [
88
+ "Usage: trekoon board <open|update>",
89
+ "",
90
+ "Subcommands:",
91
+ " open",
92
+ " Ensure board assets are installed, start a 127.0.0.1 board server,",
93
+ " and launch the browser. Machine output includes server URL, fallback URL,",
94
+ " and launch metadata.",
95
+ " update",
96
+ " Refresh board runtime assets only. Does not start the server or open a browser.",
97
+ "",
98
+ "Environment overrides:",
99
+ " TREKOON_BOARD_ASSET_ROOT Optional asset source override for tests and local development.",
100
+ "",
101
+ "Examples:",
102
+ " trekoon board open",
103
+ " trekoon --json board update",
104
+ ].join("\n");
105
+
86
106
  const EPIC_HELP = [
87
107
  "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete> [options]",
88
108
  "",
@@ -394,6 +414,7 @@ const SKILLS_HELP = [
394
414
 
395
415
  const COMMAND_HELP: Record<string, string> = {
396
416
  init: INIT_HELP,
417
+ board: BOARD_HELP,
397
418
  quickstart: QUICKSTART_HELP,
398
419
  session: SESSION_HELP,
399
420
  wipe: WIPE_HELP,