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.
- package/README.md +60 -0
- package/docs/commands.md +100 -0
- package/docs/quickstart.md +74 -1
- package/package.json +2 -1
- package/src/board/assets/app.js +589 -0
- package/src/board/assets/components/ClampedText.js +31 -0
- package/src/board/assets/components/Component.js +271 -0
- package/src/board/assets/components/ConfirmDialog.js +81 -0
- package/src/board/assets/components/EpicRow.js +64 -0
- package/src/board/assets/components/EpicsOverview.js +80 -0
- package/src/board/assets/components/Inspector.js +335 -0
- package/src/board/assets/components/Notice.js +80 -0
- package/src/board/assets/components/SubtaskModal.js +100 -0
- package/src/board/assets/components/TaskCard.js +82 -0
- package/src/board/assets/components/TaskModal.js +99 -0
- package/src/board/assets/components/TopBar.js +167 -0
- package/src/board/assets/components/Workspace.js +308 -0
- package/src/board/assets/components/assetMap.js +80 -0
- package/src/board/assets/components/helpers.js +244 -0
- package/src/board/assets/fonts/inter-latin.woff2 +0 -0
- package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
- package/src/board/assets/index.html +39 -0
- package/src/board/assets/main.js +11 -0
- package/src/board/assets/manifest.json +12 -0
- package/src/board/assets/runtime/delegation.js +309 -0
- package/src/board/assets/state/actions.js +454 -0
- package/src/board/assets/state/api.js +281 -0
- package/src/board/assets/state/store.js +472 -0
- package/src/board/assets/state/url.js +184 -0
- package/src/board/assets/state/utils.js +222 -0
- package/src/board/assets/styles/board.css +1811 -0
- package/src/board/assets/styles/fonts.css +22 -0
- package/src/board/install.ts +196 -0
- package/src/board/open-browser.ts +131 -0
- package/src/board/routes.ts +308 -0
- package/src/board/server.ts +185 -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/help.ts +21 -0
- package/src/commands/init.ts +29 -0
- package/src/domain/mutation-service.ts +40 -0
- package/src/domain/tracker-domain.ts +11 -3
- package/src/runtime/cli-shell.ts +5 -0
- package/src/storage/path.ts +36 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* Inter — variable, Latin subset, weights 400–700 */
|
|
2
|
+
@font-face {
|
|
3
|
+
font-family: "Inter";
|
|
4
|
+
font-style: normal;
|
|
5
|
+
font-weight: 400 700;
|
|
6
|
+
font-display: swap;
|
|
7
|
+
src: url("../fonts/inter-latin.woff2") format("woff2");
|
|
8
|
+
unicode-range: U+0020-007E, U+00A0, U+00A9, U+00AB, U+00AD, U+00AE, U+00B7,
|
|
9
|
+
U+00BB, U+00C0-00C1, U+00C9, U+00CD, U+00D1, U+00D3, U+00D7, U+00DA,
|
|
10
|
+
U+00DC, U+00E0-00E1, U+00E9, U+00ED, U+00F1, U+00F3, U+00F7, U+00FA,
|
|
11
|
+
U+00FC, U+2013-2014, U+2018-2019, U+201C-201D, U+2026, U+2039, U+203A,
|
|
12
|
+
U+20AC, U+2122, U+2212;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Material Symbols Rounded — subset of 16 glyphs used in board UI */
|
|
16
|
+
@font-face {
|
|
17
|
+
font-family: "Material Symbols Rounded";
|
|
18
|
+
font-style: normal;
|
|
19
|
+
font-weight: 400;
|
|
20
|
+
font-display: block;
|
|
21
|
+
src: url("../fonts/material-symbols-rounded.woff2") format("woff2");
|
|
22
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
} from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
cpSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
import { CLI_VERSION } from "../runtime/version";
|
|
18
|
+
import {
|
|
19
|
+
resolveStoragePaths,
|
|
20
|
+
TREKOON_BOARD_ENTRY_FILENAME,
|
|
21
|
+
TREKOON_BOARD_MANIFEST_FILENAME,
|
|
22
|
+
} from "../storage/path";
|
|
23
|
+
import {
|
|
24
|
+
BOARD_ASSET_CONTRACT_VERSION,
|
|
25
|
+
BOARD_BUNDLED_ASSET_DIRNAME,
|
|
26
|
+
BoardInstallError,
|
|
27
|
+
type BoardAssetManifest,
|
|
28
|
+
type BoardInstallAction,
|
|
29
|
+
type BoardInstallResult,
|
|
30
|
+
type EnsureBoardInstalledOptions,
|
|
31
|
+
} from "./types";
|
|
32
|
+
|
|
33
|
+
function resolveBundledBoardAssetRoot(): string {
|
|
34
|
+
return fileURLToPath(new URL(`./${BOARD_BUNDLED_ASSET_DIRNAME}`, import.meta.url));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function listRelativeFiles(rootPath: string, currentPath: string = rootPath): string[] {
|
|
38
|
+
const entries = readdirSync(currentPath, { withFileTypes: true });
|
|
39
|
+
const files: string[] = [];
|
|
40
|
+
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const entryPath: string = join(currentPath, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
files.push(...listRelativeFiles(rootPath, entryPath));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (entry.isFile()) {
|
|
49
|
+
files.push(relative(rootPath, entryPath));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ReadManifestResult {
|
|
57
|
+
readonly manifest: BoardAssetManifest | null;
|
|
58
|
+
readonly damaged: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readManifest(manifestFile: string): ReadManifestResult {
|
|
62
|
+
if (!existsSync(manifestFile)) {
|
|
63
|
+
return {
|
|
64
|
+
manifest: null,
|
|
65
|
+
damaged: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const rawManifest: string = readFileSync(manifestFile, "utf8");
|
|
71
|
+
return {
|
|
72
|
+
manifest: JSON.parse(rawManifest) as BoardAssetManifest,
|
|
73
|
+
damaged: false,
|
|
74
|
+
};
|
|
75
|
+
} catch {
|
|
76
|
+
return {
|
|
77
|
+
manifest: null,
|
|
78
|
+
damaged: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createAssetDigest(sourceRoot: string, files: readonly string[]): string {
|
|
84
|
+
const hash = createHash("sha256");
|
|
85
|
+
|
|
86
|
+
for (const relativeFile of files) {
|
|
87
|
+
hash.update(relativeFile);
|
|
88
|
+
hash.update("\0");
|
|
89
|
+
hash.update(readFileSync(join(sourceRoot, relativeFile)));
|
|
90
|
+
hash.update("\0");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return hash.digest("hex");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createManifest(sourceRoot: string, assetVersion: string, files: readonly string[]): BoardAssetManifest {
|
|
97
|
+
return {
|
|
98
|
+
contractVersion: BOARD_ASSET_CONTRACT_VERSION,
|
|
99
|
+
assetVersion,
|
|
100
|
+
entryFile: TREKOON_BOARD_ENTRY_FILENAME,
|
|
101
|
+
files,
|
|
102
|
+
assetDigest: createAssetDigest(sourceRoot, files),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function installBoardFiles(sourceRoot: string, runtimeRoot: string, manifest: BoardAssetManifest): void {
|
|
107
|
+
rmSync(runtimeRoot, { recursive: true, force: true });
|
|
108
|
+
mkdirSync(dirname(runtimeRoot), { recursive: true });
|
|
109
|
+
cpSync(sourceRoot, runtimeRoot, { recursive: true });
|
|
110
|
+
writeFileSync(join(runtimeRoot, TREKOON_BOARD_MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function determineAction(
|
|
114
|
+
runtimeRoot: string,
|
|
115
|
+
entryFile: string,
|
|
116
|
+
currentManifest: BoardAssetManifest | null,
|
|
117
|
+
manifestDamaged: boolean,
|
|
118
|
+
nextManifest: BoardAssetManifest,
|
|
119
|
+
): BoardInstallAction {
|
|
120
|
+
if (manifestDamaged) {
|
|
121
|
+
return "reinstalled";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!existsSync(runtimeRoot) || !existsSync(entryFile) || currentManifest === null) {
|
|
125
|
+
return currentManifest === null && !existsSync(runtimeRoot) ? "installed" : "reinstalled";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (
|
|
129
|
+
currentManifest.contractVersion !== nextManifest.contractVersion ||
|
|
130
|
+
currentManifest.assetVersion !== nextManifest.assetVersion ||
|
|
131
|
+
currentManifest.entryFile !== nextManifest.entryFile ||
|
|
132
|
+
JSON.stringify(currentManifest.files) !== JSON.stringify(nextManifest.files) ||
|
|
133
|
+
currentManifest.assetDigest !== nextManifest.assetDigest
|
|
134
|
+
) {
|
|
135
|
+
return "updated";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const relativeFile of nextManifest.files) {
|
|
139
|
+
if (!existsSync(join(runtimeRoot, relativeFile))) {
|
|
140
|
+
return "reinstalled";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return "unchanged";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function ensureBoardInstalled(options: EnsureBoardInstalledOptions = {}): BoardInstallResult {
|
|
148
|
+
const paths = resolveStoragePaths(options.workingDirectory);
|
|
149
|
+
const sourceRoot: string = resolve(options.bundledAssetRoot ?? resolveBundledBoardAssetRoot());
|
|
150
|
+
const runtimeRoot: string = paths.boardDir;
|
|
151
|
+
const entryFile: string = paths.boardEntryFile;
|
|
152
|
+
const manifestFile: string = paths.boardManifestFile;
|
|
153
|
+
|
|
154
|
+
if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
|
|
155
|
+
throw new BoardInstallError("missing_asset", `Bundled board asset directory not found at ${sourceRoot}`, {
|
|
156
|
+
sourceRoot,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const sourceFiles: string[] = listRelativeFiles(sourceRoot);
|
|
161
|
+
if (!sourceFiles.includes(TREKOON_BOARD_ENTRY_FILENAME)) {
|
|
162
|
+
throw new BoardInstallError("missing_asset", `Bundled board entry file not found at ${join(sourceRoot, TREKOON_BOARD_ENTRY_FILENAME)}`, {
|
|
163
|
+
sourceRoot,
|
|
164
|
+
missingFile: TREKOON_BOARD_ENTRY_FILENAME,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const manifest: BoardAssetManifest = createManifest(sourceRoot, options.assetVersion ?? CLI_VERSION, sourceFiles);
|
|
169
|
+
const currentManifestResult: ReadManifestResult = readManifest(manifestFile);
|
|
170
|
+
const action: BoardInstallAction = determineAction(
|
|
171
|
+
runtimeRoot,
|
|
172
|
+
entryFile,
|
|
173
|
+
currentManifestResult.manifest,
|
|
174
|
+
currentManifestResult.damaged,
|
|
175
|
+
manifest,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (action !== "unchanged") {
|
|
179
|
+
installBoardFiles(sourceRoot, runtimeRoot, manifest);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
action,
|
|
184
|
+
paths: {
|
|
185
|
+
sourceRoot,
|
|
186
|
+
runtimeRoot,
|
|
187
|
+
entryFile,
|
|
188
|
+
manifestFile,
|
|
189
|
+
},
|
|
190
|
+
manifest,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function updateBoardInstallation(options: EnsureBoardInstalledOptions = {}): BoardInstallResult {
|
|
195
|
+
return ensureBoardInstalled(options);
|
|
196
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface OpenBrowserResult {
|
|
4
|
+
readonly launched: boolean;
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly command: string | null;
|
|
7
|
+
readonly args: readonly string[];
|
|
8
|
+
readonly errorMessage: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type BrowserLaunchEvent = "error" | "exit" | "spawn";
|
|
12
|
+
type BrowserLaunchListener = (eventData?: Error | number | null) => void;
|
|
13
|
+
|
|
14
|
+
interface BrowserLaunchHandle {
|
|
15
|
+
once(event: BrowserLaunchEvent, listener: BrowserLaunchListener): BrowserLaunchHandle;
|
|
16
|
+
removeListener(event: BrowserLaunchEvent, listener: BrowserLaunchListener): BrowserLaunchHandle;
|
|
17
|
+
unref(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type BrowserLauncher = (command: string, args: readonly string[]) => BrowserLaunchHandle;
|
|
21
|
+
|
|
22
|
+
function spawnBrowserProcess(command: string, args: readonly string[]): BrowserLaunchHandle {
|
|
23
|
+
const child = spawn(command, [...args], {
|
|
24
|
+
detached: true,
|
|
25
|
+
stdio: "ignore",
|
|
26
|
+
});
|
|
27
|
+
child.unref();
|
|
28
|
+
return child;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let browserLauncher: BrowserLauncher = spawnBrowserProcess;
|
|
32
|
+
|
|
33
|
+
function resolveOpenCommand(url: string): { command: string; args: readonly string[] } {
|
|
34
|
+
if (process.platform === "darwin") {
|
|
35
|
+
return { command: "open", args: [url] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (process.platform === "win32") {
|
|
39
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { command: "xdg-open", args: [url] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setBrowserLauncherForTests(nextLauncher: BrowserLauncher | null): void {
|
|
46
|
+
browserLauncher = nextLauncher ?? spawnBrowserProcess;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function openBoardInBrowser(url: string): Promise<OpenBrowserResult> {
|
|
50
|
+
const launch = resolveOpenCommand(url);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const child = browserLauncher(launch.command, launch.args);
|
|
54
|
+
|
|
55
|
+
return await new Promise<OpenBrowserResult>((resolve) => {
|
|
56
|
+
let settled = false;
|
|
57
|
+
let spawned = false;
|
|
58
|
+
let launchResultScheduled = false;
|
|
59
|
+
|
|
60
|
+
const complete = (result: OpenBrowserResult): void => {
|
|
61
|
+
if (settled) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
settled = true;
|
|
66
|
+
child.removeListener("spawn", handleSpawn);
|
|
67
|
+
child.removeListener("error", handleError);
|
|
68
|
+
child.removeListener("exit", handleExit);
|
|
69
|
+
resolve(result);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const resolveLaunchSuccess = (): void => {
|
|
73
|
+
complete({
|
|
74
|
+
launched: true,
|
|
75
|
+
url,
|
|
76
|
+
command: launch.command,
|
|
77
|
+
args: launch.args,
|
|
78
|
+
errorMessage: null,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleSpawn = (): void => {
|
|
83
|
+
spawned = true;
|
|
84
|
+
if (launchResultScheduled) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
launchResultScheduled = true;
|
|
89
|
+
setTimeout(resolveLaunchSuccess, 0);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleError = (eventData?: Error | number | null): void => {
|
|
93
|
+
complete({
|
|
94
|
+
launched: false,
|
|
95
|
+
url,
|
|
96
|
+
command: launch.command,
|
|
97
|
+
args: launch.args,
|
|
98
|
+
errorMessage: eventData instanceof Error ? eventData.message : "Unknown browser launch failure",
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleExit = (eventData?: Error | number | null): void => {
|
|
103
|
+
const exitCode = typeof eventData === "number" ? eventData : Number.NaN;
|
|
104
|
+
|
|
105
|
+
if (!spawned || exitCode === 0 || Number.isNaN(exitCode)) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
complete({
|
|
110
|
+
launched: false,
|
|
111
|
+
url,
|
|
112
|
+
command: launch.command,
|
|
113
|
+
args: launch.args,
|
|
114
|
+
errorMessage: `${launch.command} exited with code ${exitCode}`,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
child.once("spawn", handleSpawn);
|
|
119
|
+
child.once("error", handleError);
|
|
120
|
+
child.once("exit", handleExit);
|
|
121
|
+
});
|
|
122
|
+
} catch (error: unknown) {
|
|
123
|
+
return {
|
|
124
|
+
launched: false,
|
|
125
|
+
url,
|
|
126
|
+
command: launch.command,
|
|
127
|
+
args: launch.args,
|
|
128
|
+
errorMessage: error instanceof Error ? error.message : "Unknown browser launch failure",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
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, requestLabel: string): 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: `${requestLabel} failed because the Trekoon database is busy`,
|
|
80
|
+
details: {
|
|
81
|
+
databaseMessage: message,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
status: 500,
|
|
88
|
+
code: "internal_error",
|
|
89
|
+
message: `${requestLabel} failed unexpectedly`,
|
|
90
|
+
details: {
|
|
91
|
+
cause: message,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function describeBoardError(mutations: MutationService, error: unknown, requestLabel: string): BoardRouteError {
|
|
97
|
+
const routeError = toBoardRouteError(error, requestLabel);
|
|
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 requestLabel = `${request.method} ${url.pathname}`;
|
|
182
|
+
const requestToken = extractToken(request, url);
|
|
183
|
+
if (requestToken !== context.token) {
|
|
184
|
+
return jsonResponse(401, {
|
|
185
|
+
ok: false,
|
|
186
|
+
error: {
|
|
187
|
+
code: "unauthorized",
|
|
188
|
+
message: "Missing or invalid board session token",
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const domain = new TrackerDomain(context.db);
|
|
194
|
+
const mutations = new MutationService(context.db, context.cwd);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
if (request.method === "GET" && url.pathname === "/api/snapshot") {
|
|
198
|
+
return jsonResponse(200, {
|
|
199
|
+
ok: true,
|
|
200
|
+
data: {
|
|
201
|
+
snapshot: buildBoardSnapshot(domain),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const epicCascadeMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)\/cascade$/u) : null;
|
|
207
|
+
if (epicCascadeMatch) {
|
|
208
|
+
const body = await parseJsonBody(request);
|
|
209
|
+
const status = readRequiredString(body, "status");
|
|
210
|
+
const plan = mutations.updateEpicStatusCascade(epicCascadeMatch[1] ?? "", status);
|
|
211
|
+
return buildMutationResponse(domain, { plan });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const epicMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)$/u) : null;
|
|
215
|
+
if (epicMatch) {
|
|
216
|
+
const body = await parseJsonBody(request);
|
|
217
|
+
const epic = mutations.updateEpic(epicMatch[1] ?? "", {
|
|
218
|
+
title: readOptionalString(body, "title"),
|
|
219
|
+
description: readOptionalString(body, "description"),
|
|
220
|
+
status: readOptionalString(body, "status"),
|
|
221
|
+
});
|
|
222
|
+
return buildMutationResponse(domain, { epic });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const taskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/tasks\/([^/]+)$/u) : null;
|
|
226
|
+
if (taskMatch) {
|
|
227
|
+
const body = await parseJsonBody(request);
|
|
228
|
+
const task = mutations.updateTask(taskMatch[1] ?? "", {
|
|
229
|
+
title: readOptionalString(body, "title"),
|
|
230
|
+
description: readOptionalString(body, "description"),
|
|
231
|
+
status: readOptionalString(body, "status"),
|
|
232
|
+
});
|
|
233
|
+
return buildMutationResponse(domain, { task });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const subtaskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
|
|
237
|
+
if (subtaskMatch) {
|
|
238
|
+
const body = await parseJsonBody(request);
|
|
239
|
+
const subtask = mutations.updateSubtask(subtaskMatch[1] ?? "", {
|
|
240
|
+
title: readOptionalString(body, "title"),
|
|
241
|
+
description: readOptionalString(body, "description"),
|
|
242
|
+
status: readOptionalString(body, "status"),
|
|
243
|
+
});
|
|
244
|
+
return buildMutationResponse(domain, { subtask });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (request.method === "POST" && url.pathname === "/api/subtasks") {
|
|
248
|
+
const body = await parseJsonBody(request);
|
|
249
|
+
const subtask = mutations.createSubtask({
|
|
250
|
+
taskId: readRequiredString(body, "taskId"),
|
|
251
|
+
title: readRequiredString(body, "title"),
|
|
252
|
+
description: readOptionalString(body, "description"),
|
|
253
|
+
status: readOptionalString(body, "status"),
|
|
254
|
+
});
|
|
255
|
+
return buildMutationResponse(domain, { subtask }, 201);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const deleteSubtaskMatch = request.method === "DELETE" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
|
|
259
|
+
if (deleteSubtaskMatch) {
|
|
260
|
+
const subtaskId = deleteSubtaskMatch[1] ?? "";
|
|
261
|
+
mutations.deleteSubtask(subtaskId);
|
|
262
|
+
return buildMutationResponse(domain, { subtaskId, deleted: true });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (request.method === "POST" && url.pathname === "/api/dependencies") {
|
|
266
|
+
const body = await parseJsonBody(request);
|
|
267
|
+
const dependency = mutations.addDependency(readRequiredString(body, "sourceId"), readRequiredString(body, "dependsOnId"));
|
|
268
|
+
return buildMutationResponse(domain, { dependency }, 201);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (request.method === "DELETE" && url.pathname === "/api/dependencies") {
|
|
272
|
+
const sourceId = url.searchParams.get("sourceId") ?? "";
|
|
273
|
+
const dependsOnId = url.searchParams.get("dependsOnId") ?? "";
|
|
274
|
+
const removed = mutations.removeDependency(sourceId, dependsOnId);
|
|
275
|
+
if (removed === 0) {
|
|
276
|
+
throw new DomainError({
|
|
277
|
+
code: "not_found",
|
|
278
|
+
message: "Dependency edge not found",
|
|
279
|
+
details: {
|
|
280
|
+
sourceId,
|
|
281
|
+
dependsOnId,
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return buildMutationResponse(domain, { sourceId, dependsOnId, removed });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return jsonResponse(404, {
|
|
290
|
+
ok: false,
|
|
291
|
+
error: {
|
|
292
|
+
code: "not_found",
|
|
293
|
+
message: `Unknown board route: ${request.method} ${url.pathname}`,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
} catch (error: unknown) {
|
|
297
|
+
const routeError = describeBoardError(mutations, error, requestLabel);
|
|
298
|
+
return jsonResponse(routeError.status, {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: {
|
|
301
|
+
code: routeError.code,
|
|
302
|
+
message: routeError.message,
|
|
303
|
+
...(routeError.details === undefined ? {} : { details: routeError.details }),
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|