trekoon 0.2.6 → 0.2.8
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 +102 -708
- package/docs/ai-agents.md +198 -0
- package/docs/commands.md +226 -0
- package/docs/machine-contracts.md +253 -0
- package/docs/plans/2026-03-15-trekoon-board-design.md +13 -0
- package/docs/quickstart.md +207 -0
- package/package.json +3 -1
- package/src/board/assets/app.js +1498 -0
- package/src/board/assets/components/AppShell.js +17 -0
- package/src/board/assets/components/BoardTopbar.js +78 -0
- package/src/board/assets/components/ClampedText.js +31 -0
- package/src/board/assets/components/EpicRow.js +62 -0
- package/src/board/assets/components/EpicsOverview.js +43 -0
- package/src/board/assets/components/WorkspaceHeader.js +70 -0
- package/src/board/assets/components/assetMap.js +65 -0
- package/src/board/assets/index.html +76 -0
- package/src/board/assets/main.js +27 -0
- package/src/board/assets/manifest.json +12 -0
- package/src/board/assets/state/actions.js +334 -0
- package/src/board/assets/state/api.js +126 -0
- package/src/board/assets/state/store.js +172 -0
- package/src/board/assets/styles/board.css +1127 -0
- package/src/board/assets/utils/dom.js +308 -0
- package/src/board/install.ts +196 -0
- package/src/board/open-browser.ts +131 -0
- package/src/board/routes.ts +299 -0
- package/src/board/server.ts +184 -0
- package/src/board/snapshot.ts +277 -0
- package/src/board/types.ts +43 -0
- package/src/commands/board.ts +158 -0
- package/src/commands/epic.ts +104 -3
- package/src/commands/help.ts +52 -13
- package/src/commands/init.ts +29 -0
- package/src/commands/subtask.ts +78 -1
- package/src/commands/task.ts +113 -7
- package/src/domain/mutation-service.ts +116 -0
- package/src/domain/tracker-domain.ts +261 -5
- package/src/domain/types.ts +51 -0
- package/src/runtime/cli-shell.ts +5 -0
- package/src/storage/path.ts +36 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { type Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
import { safeErrorMessage } from "../commands/error-utils";
|
|
4
|
+
import { MutationService } from "../domain/mutation-service";
|
|
5
|
+
import { TrackerDomain } from "../domain/tracker-domain";
|
|
6
|
+
import { DomainError } from "../domain/types";
|
|
7
|
+
import { buildBoardSnapshot } from "./snapshot";
|
|
8
|
+
|
|
9
|
+
interface BoardRouteContext {
|
|
10
|
+
readonly db: Database;
|
|
11
|
+
readonly cwd: string;
|
|
12
|
+
readonly token: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface BoardRouteError {
|
|
16
|
+
readonly status: number;
|
|
17
|
+
readonly code: string;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
readonly details?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function jsonResponse(status: number, data: unknown): Response {
|
|
23
|
+
return new Response(JSON.stringify(data), {
|
|
24
|
+
status,
|
|
25
|
+
headers: {
|
|
26
|
+
"cache-control": "no-store",
|
|
27
|
+
"content-type": "application/json; charset=utf-8",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractToken(request: Request, url: URL): string | null {
|
|
33
|
+
const authorization: string | null = request.headers.get("authorization");
|
|
34
|
+
if (authorization?.startsWith("Bearer ")) {
|
|
35
|
+
return authorization.slice("Bearer ".length).trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const headerToken: string | null = request.headers.get("x-trekoon-token");
|
|
39
|
+
if (headerToken && headerToken.trim().length > 0) {
|
|
40
|
+
return headerToken.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const queryToken: string | null = url.searchParams.get("token");
|
|
44
|
+
if (queryToken && queryToken.trim().length > 0) {
|
|
45
|
+
return queryToken.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isSqliteBusyMessage(message: string): boolean {
|
|
52
|
+
const normalized = message.toLowerCase();
|
|
53
|
+
return normalized.includes("database is locked") || normalized.includes("database schema is locked");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toBoardRouteError(error: unknown): BoardRouteError {
|
|
57
|
+
if (error instanceof DomainError) {
|
|
58
|
+
const status =
|
|
59
|
+
error.code === "not_found"
|
|
60
|
+
? 404
|
|
61
|
+
: error.code === "invalid_input"
|
|
62
|
+
? 400
|
|
63
|
+
: error.code === "invalid_dependency" || error.code === "dependency_blocked"
|
|
64
|
+
? 409
|
|
65
|
+
: 400;
|
|
66
|
+
return {
|
|
67
|
+
status,
|
|
68
|
+
code: error.code,
|
|
69
|
+
message: error.message,
|
|
70
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const message = safeErrorMessage(error, "Unexpected board API failure");
|
|
75
|
+
if (isSqliteBusyMessage(message)) {
|
|
76
|
+
return {
|
|
77
|
+
status: 503,
|
|
78
|
+
code: "database_busy",
|
|
79
|
+
message: "Trekoon database is busy",
|
|
80
|
+
details: {
|
|
81
|
+
databaseMessage: message,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
status: 500,
|
|
88
|
+
code: "internal_error",
|
|
89
|
+
message: "Unexpected board API failure",
|
|
90
|
+
details: {
|
|
91
|
+
cause: message,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function describeBoardError(mutations: MutationService, error: unknown): BoardRouteError {
|
|
97
|
+
const routeError = toBoardRouteError(error);
|
|
98
|
+
const readableMessage = mutations.describeError(error);
|
|
99
|
+
if (readableMessage === undefined) {
|
|
100
|
+
return routeError;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
...routeError,
|
|
105
|
+
message: readableMessage,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildMutationResponse(domain: TrackerDomain, data: Record<string, unknown>, status = 200): Response {
|
|
110
|
+
return jsonResponse(status, {
|
|
111
|
+
ok: true,
|
|
112
|
+
data: {
|
|
113
|
+
...data,
|
|
114
|
+
snapshot: buildBoardSnapshot(domain),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function parseJsonBody(request: Request): Promise<Record<string, unknown>> {
|
|
120
|
+
const contentType: string = request.headers.get("content-type") ?? "";
|
|
121
|
+
if (!contentType.includes("application/json")) {
|
|
122
|
+
throw new DomainError({
|
|
123
|
+
code: "invalid_input",
|
|
124
|
+
message: "Expected application/json request body",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let body: unknown;
|
|
129
|
+
try {
|
|
130
|
+
body = await request.json();
|
|
131
|
+
} catch {
|
|
132
|
+
throw new DomainError({
|
|
133
|
+
code: "invalid_input",
|
|
134
|
+
message: "Malformed JSON request body",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
139
|
+
throw new DomainError({
|
|
140
|
+
code: "invalid_input",
|
|
141
|
+
message: "Expected JSON object request body",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return body as Record<string, unknown>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readOptionalString(body: Record<string, unknown>, field: string): string | undefined {
|
|
149
|
+
const value = body[field];
|
|
150
|
+
if (value === undefined) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (typeof value !== "string") {
|
|
155
|
+
throw new DomainError({
|
|
156
|
+
code: "invalid_input",
|
|
157
|
+
message: `${field} must be a string`,
|
|
158
|
+
details: { field },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readRequiredString(body: Record<string, unknown>, field: string): string {
|
|
166
|
+
const value = readOptionalString(body, field);
|
|
167
|
+
if (value === undefined) {
|
|
168
|
+
throw new DomainError({
|
|
169
|
+
code: "invalid_input",
|
|
170
|
+
message: `${field} is required`,
|
|
171
|
+
details: { field },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function createBoardApiHandler(context: BoardRouteContext): (request: Request) => Promise<Response> {
|
|
179
|
+
return async (request: Request): Promise<Response> => {
|
|
180
|
+
const url = new URL(request.url);
|
|
181
|
+
const requestToken = extractToken(request, url);
|
|
182
|
+
if (requestToken !== context.token) {
|
|
183
|
+
return jsonResponse(401, {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: {
|
|
186
|
+
code: "unauthorized",
|
|
187
|
+
message: "Missing or invalid board session token",
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const domain = new TrackerDomain(context.db);
|
|
193
|
+
const mutations = new MutationService(context.db, context.cwd);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
if (request.method === "GET" && url.pathname === "/api/snapshot") {
|
|
197
|
+
return jsonResponse(200, {
|
|
198
|
+
ok: true,
|
|
199
|
+
data: {
|
|
200
|
+
snapshot: buildBoardSnapshot(domain),
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const epicMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)$/u) : null;
|
|
206
|
+
if (epicMatch) {
|
|
207
|
+
const body = await parseJsonBody(request);
|
|
208
|
+
const epic = mutations.updateEpic(epicMatch[1] ?? "", {
|
|
209
|
+
title: readOptionalString(body, "title"),
|
|
210
|
+
description: readOptionalString(body, "description"),
|
|
211
|
+
status: readOptionalString(body, "status"),
|
|
212
|
+
});
|
|
213
|
+
return buildMutationResponse(domain, { epic });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const taskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/tasks\/([^/]+)$/u) : null;
|
|
217
|
+
if (taskMatch) {
|
|
218
|
+
const body = await parseJsonBody(request);
|
|
219
|
+
const task = mutations.updateTask(taskMatch[1] ?? "", {
|
|
220
|
+
title: readOptionalString(body, "title"),
|
|
221
|
+
description: readOptionalString(body, "description"),
|
|
222
|
+
status: readOptionalString(body, "status"),
|
|
223
|
+
});
|
|
224
|
+
return buildMutationResponse(domain, { task });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const subtaskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
|
|
228
|
+
if (subtaskMatch) {
|
|
229
|
+
const body = await parseJsonBody(request);
|
|
230
|
+
const subtask = mutations.updateSubtask(subtaskMatch[1] ?? "", {
|
|
231
|
+
title: readOptionalString(body, "title"),
|
|
232
|
+
description: readOptionalString(body, "description"),
|
|
233
|
+
status: readOptionalString(body, "status"),
|
|
234
|
+
});
|
|
235
|
+
return buildMutationResponse(domain, { subtask });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (request.method === "POST" && url.pathname === "/api/subtasks") {
|
|
239
|
+
const body = await parseJsonBody(request);
|
|
240
|
+
const subtask = mutations.createSubtask({
|
|
241
|
+
taskId: readRequiredString(body, "taskId"),
|
|
242
|
+
title: readRequiredString(body, "title"),
|
|
243
|
+
description: readOptionalString(body, "description"),
|
|
244
|
+
status: readOptionalString(body, "status"),
|
|
245
|
+
});
|
|
246
|
+
return buildMutationResponse(domain, { subtask }, 201);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const deleteSubtaskMatch = request.method === "DELETE" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
|
|
250
|
+
if (deleteSubtaskMatch) {
|
|
251
|
+
const subtaskId = deleteSubtaskMatch[1] ?? "";
|
|
252
|
+
mutations.deleteSubtask(subtaskId);
|
|
253
|
+
return buildMutationResponse(domain, { subtaskId, deleted: true });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (request.method === "POST" && url.pathname === "/api/dependencies") {
|
|
257
|
+
const body = await parseJsonBody(request);
|
|
258
|
+
const dependency = mutations.addDependency(readRequiredString(body, "sourceId"), readRequiredString(body, "dependsOnId"));
|
|
259
|
+
return buildMutationResponse(domain, { dependency }, 201);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (request.method === "DELETE" && url.pathname === "/api/dependencies") {
|
|
263
|
+
const sourceId = url.searchParams.get("sourceId") ?? "";
|
|
264
|
+
const dependsOnId = url.searchParams.get("dependsOnId") ?? "";
|
|
265
|
+
const removed = mutations.removeDependency(sourceId, dependsOnId);
|
|
266
|
+
if (removed === 0) {
|
|
267
|
+
throw new DomainError({
|
|
268
|
+
code: "not_found",
|
|
269
|
+
message: "Dependency edge not found",
|
|
270
|
+
details: {
|
|
271
|
+
sourceId,
|
|
272
|
+
dependsOnId,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return buildMutationResponse(domain, { sourceId, dependsOnId, removed });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return jsonResponse(404, {
|
|
281
|
+
ok: false,
|
|
282
|
+
error: {
|
|
283
|
+
code: "not_found",
|
|
284
|
+
message: `Unknown board route: ${request.method} ${url.pathname}`,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
} catch (error: unknown) {
|
|
288
|
+
const routeError = describeBoardError(mutations, error);
|
|
289
|
+
return jsonResponse(routeError.status, {
|
|
290
|
+
ok: false,
|
|
291
|
+
error: {
|
|
292
|
+
code: routeError.code,
|
|
293
|
+
message: routeError.message,
|
|
294
|
+
...(routeError.details === undefined ? {} : { details: routeError.details }),
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
};
|
|
18
|
+
|
|
19
|
+
const BOARD_SERVER_STATE_FILENAME = "board-server.json";
|
|
20
|
+
|
|
21
|
+
interface BoardServerState {
|
|
22
|
+
readonly preferredPort: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BoardServerInfo {
|
|
26
|
+
readonly origin: string;
|
|
27
|
+
readonly url: string;
|
|
28
|
+
readonly fallbackUrl: string;
|
|
29
|
+
readonly token: string;
|
|
30
|
+
readonly hostname: "127.0.0.1";
|
|
31
|
+
readonly port: number;
|
|
32
|
+
stop(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StartBoardServerOptions {
|
|
36
|
+
readonly cwd?: string;
|
|
37
|
+
readonly token?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function guessContentType(pathname: string): string {
|
|
41
|
+
return CONTENT_TYPES[extname(pathname).toLowerCase()] ?? "application/octet-stream";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readAssetPath(boardRoot: string, pathname: string): string | null {
|
|
45
|
+
const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
|
|
46
|
+
const candidate = resolve(boardRoot, relativePath);
|
|
47
|
+
if (!candidate.startsWith(resolve(boardRoot))) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return existsSync(candidate) ? candidate : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readPreferredBoardPort(stateFile: string): number | null {
|
|
55
|
+
if (!existsSync(stateFile)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const rawState: string = readFileSync(stateFile, "utf8");
|
|
61
|
+
const state = JSON.parse(rawState) as Partial<BoardServerState>;
|
|
62
|
+
const preferredPort = state.preferredPort;
|
|
63
|
+
|
|
64
|
+
if (typeof preferredPort !== "number" || !Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return preferredPort;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function persistPreferredBoardPort(stateFile: string, port: number): void {
|
|
75
|
+
mkdirSync(dirname(stateFile), { recursive: true });
|
|
76
|
+
writeFileSync(stateFile, `${JSON.stringify({ preferredPort: port }, null, 2)}\n`, "utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isUnavailablePortError(error: unknown): boolean {
|
|
80
|
+
if (!(error instanceof Error)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const errorWithCode = error as Error & { code?: unknown };
|
|
85
|
+
const errorCode = typeof errorWithCode.code === "string" ? errorWithCode.code : "";
|
|
86
|
+
return /^(EADDRINUSE|EACCES)$/i.test(errorCode) || /(EADDRINUSE|EACCES|address already in use|permission denied)/i.test(error.message);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
|
|
90
|
+
const cwd: string = options.cwd ?? process.cwd();
|
|
91
|
+
const database: TrekoonDatabase = openTrekoonDatabase(cwd);
|
|
92
|
+
const paths = resolveStoragePaths(cwd);
|
|
93
|
+
const boardRoot: string = paths.boardDir;
|
|
94
|
+
const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
|
|
95
|
+
const token: string = options.token ?? randomBytes(18).toString("hex");
|
|
96
|
+
const apiHandler = createBoardApiHandler({
|
|
97
|
+
db: database.db,
|
|
98
|
+
cwd,
|
|
99
|
+
token,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const serveBoard = (port: number) =>
|
|
103
|
+
Bun.serve({
|
|
104
|
+
hostname: "127.0.0.1",
|
|
105
|
+
port,
|
|
106
|
+
fetch(request: Request): Promise<Response> | Response {
|
|
107
|
+
const url = new URL(request.url);
|
|
108
|
+
if (url.pathname.startsWith("/api/")) {
|
|
109
|
+
return apiHandler(request);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
|
|
113
|
+
if (assetPath === null) {
|
|
114
|
+
const fallbackPath = readAssetPath(boardRoot, "/index.html");
|
|
115
|
+
if (fallbackPath === null) {
|
|
116
|
+
return new Response("Board assets are not installed", { status: 500 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return new Response(readFileSync(fallbackPath), {
|
|
120
|
+
headers: {
|
|
121
|
+
"cache-control": "no-store",
|
|
122
|
+
"content-type": "text/html; charset=utf-8",
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return new Response(readFileSync(assetPath), {
|
|
128
|
+
headers: {
|
|
129
|
+
"cache-control": "no-store",
|
|
130
|
+
"content-type": guessContentType(assetPath),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
error(error: Error): Response {
|
|
135
|
+
return new Response(`Board server error: ${error.message}`, { status: 500 });
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const preferredPort: number | null = readPreferredBoardPort(stateFile);
|
|
140
|
+
|
|
141
|
+
let server;
|
|
142
|
+
try {
|
|
143
|
+
server = serveBoard(preferredPort ?? 0);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (preferredPort === null || !isUnavailablePortError(error)) {
|
|
146
|
+
database.close();
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
server = serveBoard(0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const port: number | undefined = server.port;
|
|
154
|
+
if (port === undefined) {
|
|
155
|
+
server.stop(true);
|
|
156
|
+
database.close();
|
|
157
|
+
throw new Error("Board server did not expose a listening port");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
persistPreferredBoardPort(stateFile, port);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
server.stop(true);
|
|
164
|
+
database.close();
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
throw new Error(`Board server could not persist preferred port at ${stateFile}: ${message}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const origin: string = `http://127.0.0.1:${port}`;
|
|
170
|
+
const url: string = `${origin}/?token=${encodeURIComponent(token)}`;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
origin,
|
|
174
|
+
url,
|
|
175
|
+
fallbackUrl: url,
|
|
176
|
+
token,
|
|
177
|
+
hostname: "127.0.0.1",
|
|
178
|
+
port,
|
|
179
|
+
stop(): void {
|
|
180
|
+
server.stop(true);
|
|
181
|
+
database.close();
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|