trekoon 0.3.7 → 0.3.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/.agents/skills/trekoon/SKILL.md +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +40 -14
- package/docs/ai-agents.md +1 -0
- package/docs/commands.md +18 -0
- package/docs/quickstart.md +35 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +8 -25
- package/src/board/assets/state/api.js +5 -6
- package/src/board/assets/state/utils.js +50 -17
- package/src/board/routes.ts +22 -19
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +133 -84
- package/src/commands/board.ts +1 -1
- package/src/commands/epic.ts +84 -1
- package/src/commands/help.ts +19 -1
- package/src/commands/quickstart.ts +13 -0
- package/src/domain/mutation-service.ts +179 -65
- package/src/domain/tracker-domain.ts +16 -2
- package/src/export/build-epic-export-bundle.ts +178 -0
- package/src/export/path.ts +48 -0
- package/src/export/render-markdown.ts +256 -0
- package/src/export/types.ts +61 -0
- package/src/export/write.ts +97 -0
- package/src/storage/migrations.ts +27 -2
- package/src/storage/schema.ts +2 -1
- package/src/sync/event-writes.ts +11 -7
- package/src/sync/service.ts +183 -4
package/docs/commands.md
CHANGED
|
@@ -202,6 +202,24 @@ trekoon epic progress <epic-id>
|
|
|
202
202
|
Returns status counts (`total`, `doneCount`, `inProgressCount`, `blockedCount`,
|
|
203
203
|
`todoCount`), `readyCount`, and `nextCandidate`.
|
|
204
204
|
|
|
205
|
+
## Epic export
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
trekoon epic export <epic-id> [--path <path>] [--overwrite]
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Writes a Markdown snapshot of the epic including tasks, subtasks, dependencies,
|
|
212
|
+
external node stubs, and warnings. The output is a point-in-time artifact; the
|
|
213
|
+
database remains the source of truth.
|
|
214
|
+
|
|
215
|
+
- Default path: `<worktree-root>/plans/<slugified-title>.md`
|
|
216
|
+
- `--path` with a file extension (e.g. `docs/plan.md`) creates that exact file
|
|
217
|
+
- `--path` without an extension (e.g. `docs/plans`) creates the default-named file inside that directory
|
|
218
|
+
- `--overwrite` resaves when the file already exists
|
|
219
|
+
|
|
220
|
+
Returns `epicId`, `path`, `overwritten`, and `summary` counts in structured
|
|
221
|
+
output.
|
|
222
|
+
|
|
205
223
|
## Session scoping
|
|
206
224
|
|
|
207
225
|
```bash
|
package/docs/quickstart.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
Shortest path from zero to a working Trekoon workflow.
|
|
4
4
|
|
|
5
|
+
## Recommended human workflow
|
|
6
|
+
|
|
7
|
+
If you are driving Trekoon with an AI agent, the usual path is:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
trekoon plan <goal>
|
|
11
|
+
trekoon <epic-id>
|
|
12
|
+
trekoon <epic-id> execute
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- Use `plan` after you already have enough context from discussion,
|
|
16
|
+
brainstorming, or research. Trekoon should turn that context into an
|
|
17
|
+
execution-ready epic.
|
|
18
|
+
- Use `trekoon <epic-id>` to inspect the created epic, next ready work, and any
|
|
19
|
+
blockers before starting execution.
|
|
20
|
+
- Use `execute` when you want the agent to keep working through the epic until
|
|
21
|
+
it is done, all remaining work is blocked, or it needs your input.
|
|
22
|
+
|
|
23
|
+
The rest of this page is mostly the lower-level command surface that agents and
|
|
24
|
+
power users rely on.
|
|
25
|
+
|
|
5
26
|
## How storage works
|
|
6
27
|
|
|
7
28
|
Trekoon keeps one SQLite database per repository at `.trekoon/trekoon.db`. In
|
|
@@ -113,6 +134,20 @@ Cascades atomically through all descendants. If any descendant has an unresolved
|
|
|
113
134
|
external dependency, the whole update fails with no partial writes. Works with
|
|
114
135
|
`--status done` and `--status todo` only.
|
|
115
136
|
|
|
137
|
+
## Export an epic to Markdown
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
trekoon epic export <epic-id>
|
|
141
|
+
trekoon epic export <epic-id> --path docs/plan.md # exact file
|
|
142
|
+
trekoon epic export <epic-id> --path docs/plans # default name inside dir
|
|
143
|
+
trekoon epic export <epic-id> --overwrite
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Writes a readable Markdown snapshot under `plans/` by default. With `--path`,
|
|
147
|
+
a file extension means "write this file"; no extension means "put the default-
|
|
148
|
+
named file in this directory". Use `--overwrite` to resave after the plan state
|
|
149
|
+
changes.
|
|
150
|
+
|
|
116
151
|
## Check progress
|
|
117
152
|
|
|
118
153
|
```bash
|
package/package.json
CHANGED
package/src/board/assets/app.js
CHANGED
|
@@ -13,7 +13,6 @@ import { createConfirmDialog } from "./components/ConfirmDialog.js";
|
|
|
13
13
|
import { createEpicsOverview } from "./components/EpicsOverview.js";
|
|
14
14
|
import { panelClasses, renderIcon, sectionLabelClasses, escapeHtml } from "./components/helpers.js";
|
|
15
15
|
|
|
16
|
-
const SESSION_TOKEN_STORAGE_KEY = "trekoon-board-session-token";
|
|
17
16
|
const SEARCH_FOCUS_KEYS = new Set(["/", "s"]);
|
|
18
17
|
const FOCUSABLE_SELECTOR = [
|
|
19
18
|
"a[href]",
|
|
@@ -33,30 +32,15 @@ const FOCUSABLE_SELECTOR = [
|
|
|
33
32
|
// Session token management
|
|
34
33
|
// ---------------------------------------------------------------------------
|
|
35
34
|
|
|
36
|
-
function readSessionTokenFromStorage() {
|
|
37
|
-
try {
|
|
38
|
-
return (sessionStorage.getItem(SESSION_TOKEN_STORAGE_KEY) || "").trim();
|
|
39
|
-
} catch {
|
|
40
|
-
return "";
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function persistSessionToken(token) {
|
|
45
|
-
try {
|
|
46
|
-
sessionStorage.setItem(SESSION_TOKEN_STORAGE_KEY, token);
|
|
47
|
-
return true;
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
35
|
function resolveRuntimeSession() {
|
|
54
36
|
const url = new URL(window.location.href);
|
|
55
37
|
const queryToken = (url.searchParams.get("token") || "").trim();
|
|
56
38
|
if (queryToken.length > 0) {
|
|
57
|
-
return { token: queryToken, shouldScrubAddressBar:
|
|
39
|
+
return { token: queryToken, shouldScrubAddressBar: true };
|
|
58
40
|
}
|
|
59
|
-
|
|
41
|
+
const bootstrap = readJsonScript("trekoon-board-bootstrap") ?? {};
|
|
42
|
+
const bootstrapToken = typeof bootstrap?.token === "string" ? bootstrap.token.trim() : "";
|
|
43
|
+
return { token: bootstrapToken, shouldScrubAddressBar: false };
|
|
60
44
|
}
|
|
61
45
|
|
|
62
46
|
function scrubTokenFromAddressBar() {
|
|
@@ -149,11 +133,10 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
149
133
|
if (runtimeSession.shouldScrubAddressBar) scrubTokenFromAddressBar();
|
|
150
134
|
|
|
151
135
|
// Fetch snapshot
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const response = await fetch("/api/snapshot", { headers });
|
|
136
|
+
const bootstrap = readJsonScript("trekoon-board-bootstrap") ?? {};
|
|
137
|
+
let snapshotPayload = bootstrap?.snapshot ?? readJsonScript("trekoon-board-snapshot") ?? {};
|
|
138
|
+
if ((!snapshotPayload || typeof snapshotPayload !== "object") && runtimeSession.token.length > 0) {
|
|
139
|
+
const response = await fetch("/api/snapshot");
|
|
157
140
|
const payload = await response.json();
|
|
158
141
|
if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
|
|
159
142
|
snapshotPayload = payload?.data?.snapshot ?? {};
|
|
@@ -124,13 +124,12 @@ export function createMutationQueue(model, rerender) {
|
|
|
124
124
|
model.store.notice = null;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// Apply optimistic update
|
|
128
|
-
if (typeof mutation.optimistic === "function") {
|
|
129
|
-
model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
|
|
130
|
-
rerender();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
127
|
try {
|
|
128
|
+
if (typeof mutation.optimistic === "function") {
|
|
129
|
+
model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
|
|
130
|
+
rerender();
|
|
131
|
+
}
|
|
132
|
+
|
|
134
133
|
const data = await mutation.request();
|
|
135
134
|
|
|
136
135
|
if (data?.snapshot) {
|
|
@@ -27,10 +27,10 @@ function normalizeArray(value) {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* @param {{ id?: string }} record
|
|
30
|
-
* @returns {string}
|
|
30
|
+
* @returns {string|null}
|
|
31
31
|
*/
|
|
32
32
|
function getId(record) {
|
|
33
|
-
return typeof record?.id === "string" && record.id.length > 0 ? record.id :
|
|
33
|
+
return typeof record?.id === "string" && record.id.length > 0 ? record.id : null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function normalizeTimestamp(value, fallback) {
|
|
@@ -42,6 +42,10 @@ function normalizeText(value, fallback = "") {
|
|
|
42
42
|
return String(value ?? fallback).replace(/\\n/g, "\n");
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function normalizeOwner(value) {
|
|
46
|
+
return typeof value === "string" ? value : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
/**
|
|
46
50
|
* @param {any[]} tasks
|
|
47
51
|
* @returns {Record<string, number>}
|
|
@@ -68,15 +72,21 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
68
72
|
const taskIndex = new Map();
|
|
69
73
|
const subtaskIndex = new Map();
|
|
70
74
|
|
|
71
|
-
const tasks = rawTasks.
|
|
75
|
+
const tasks = rawTasks.flatMap((task) => {
|
|
76
|
+
const taskId = getId(task);
|
|
77
|
+
if (!taskId) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
const createdAt = normalizeTimestamp(task.createdAt, Date.now());
|
|
73
82
|
const normalizedTask = {
|
|
74
|
-
id:
|
|
83
|
+
id: taskId,
|
|
75
84
|
kind: "task",
|
|
76
85
|
epicId: task.epicId ?? task.epic?.id ?? null,
|
|
77
86
|
title: normalizeText(task.title, "Untitled task"),
|
|
78
87
|
description: normalizeText(task.description),
|
|
79
88
|
status: normalizeStatus(task.status),
|
|
89
|
+
owner: normalizeOwner(task.owner),
|
|
80
90
|
createdAt,
|
|
81
91
|
updatedAt: normalizeTimestamp(task.updatedAt, createdAt),
|
|
82
92
|
blockedBy: [],
|
|
@@ -88,18 +98,24 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
88
98
|
};
|
|
89
99
|
|
|
90
100
|
taskIndex.set(normalizedTask.id, normalizedTask);
|
|
91
|
-
return normalizedTask;
|
|
101
|
+
return [normalizedTask];
|
|
92
102
|
});
|
|
93
103
|
|
|
94
|
-
const subtasks = rawSubtasks.
|
|
104
|
+
const subtasks = rawSubtasks.flatMap((subtask) => {
|
|
105
|
+
const subtaskId = getId(subtask);
|
|
106
|
+
if (!subtaskId) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
95
110
|
const createdAt = normalizeTimestamp(subtask.createdAt, Date.now());
|
|
96
111
|
const normalizedSubtask = {
|
|
97
|
-
id:
|
|
112
|
+
id: subtaskId,
|
|
98
113
|
kind: "subtask",
|
|
99
114
|
taskId: subtask.taskId ?? subtask.task?.id ?? null,
|
|
100
115
|
title: normalizeText(subtask.title, "Untitled subtask"),
|
|
101
116
|
description: normalizeText(subtask.description),
|
|
102
117
|
status: normalizeStatus(subtask.status),
|
|
118
|
+
owner: normalizeOwner(subtask.owner),
|
|
103
119
|
createdAt,
|
|
104
120
|
updatedAt: normalizeTimestamp(subtask.updatedAt, createdAt),
|
|
105
121
|
blockedBy: [],
|
|
@@ -110,7 +126,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
110
126
|
};
|
|
111
127
|
|
|
112
128
|
subtaskIndex.set(normalizedSubtask.id, normalizedSubtask);
|
|
113
|
-
return normalizedSubtask;
|
|
129
|
+
return [normalizedSubtask];
|
|
114
130
|
});
|
|
115
131
|
|
|
116
132
|
for (const subtask of subtasks) {
|
|
@@ -120,13 +136,22 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
138
|
|
|
123
|
-
const dependencies = rawDependencies.
|
|
124
|
-
|
|
125
|
-
sourceId
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
const dependencies = rawDependencies.flatMap((dependency) => {
|
|
140
|
+
const dependencyId = getId(dependency);
|
|
141
|
+
const sourceId = typeof dependency?.sourceId === "string" && dependency.sourceId.length > 0 ? dependency.sourceId : null;
|
|
142
|
+
const dependsOnId = typeof dependency?.dependsOnId === "string" && dependency.dependsOnId.length > 0 ? dependency.dependsOnId : null;
|
|
143
|
+
if (!dependencyId || !sourceId || !dependsOnId) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return [{
|
|
148
|
+
id: dependencyId,
|
|
149
|
+
sourceId,
|
|
150
|
+
sourceKind: dependency.sourceKind === "subtask" ? "subtask" : "task",
|
|
151
|
+
dependsOnId,
|
|
152
|
+
dependsOnKind: dependency.dependsOnKind === "subtask" ? "subtask" : "task",
|
|
153
|
+
}];
|
|
154
|
+
});
|
|
130
155
|
|
|
131
156
|
const lookupNode = (kind, id) => {
|
|
132
157
|
if (kind === "subtask") {
|
|
@@ -148,8 +173,12 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
148
173
|
}
|
|
149
174
|
}
|
|
150
175
|
|
|
151
|
-
const epics = rawEpics.
|
|
176
|
+
const epics = rawEpics.flatMap((epic) => {
|
|
152
177
|
const epicId = getId(epic);
|
|
178
|
+
if (!epicId) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
153
182
|
const epicTasks = tasks.filter((task) => task.epicId === epicId);
|
|
154
183
|
const createdAt = normalizeTimestamp(epic.createdAt, Date.now());
|
|
155
184
|
const normalizedEpic = {
|
|
@@ -165,7 +194,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
165
194
|
};
|
|
166
195
|
|
|
167
196
|
normalizedEpic.searchText = [normalizedEpic.title, normalizedEpic.description, ...epicTasks.map((task) => task.title)].join(" ").toLowerCase();
|
|
168
|
-
return normalizedEpic;
|
|
197
|
+
return [normalizedEpic];
|
|
169
198
|
});
|
|
170
199
|
|
|
171
200
|
for (const subtask of subtasks) {
|
|
@@ -214,6 +243,10 @@ function mergeRecordsById(existingRecords, incomingRecords, deletedIds = []) {
|
|
|
214
243
|
const indexById = new Map(nextRecords.map((record, index) => [record.id, index]));
|
|
215
244
|
|
|
216
245
|
for (const record of incomingRecords) {
|
|
246
|
+
if (typeof record?.id !== "string" || record.id.length === 0) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
217
250
|
const existingIndex = indexById.get(record.id);
|
|
218
251
|
if (existingIndex === undefined) {
|
|
219
252
|
indexById.set(record.id, nextRecords.length);
|
package/src/board/routes.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { safeErrorMessage } from "../commands/error-utils";
|
|
|
4
4
|
import { MutationService } from "../domain/mutation-service";
|
|
5
5
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
6
6
|
import { DomainError } from "../domain/types";
|
|
7
|
-
import { buildBoardSnapshot } from "./snapshot";
|
|
7
|
+
import { buildBoardSnapshot, buildBoardSnapshotDelta } from "./snapshot";
|
|
8
8
|
|
|
9
9
|
interface SnapshotDeltaSelection {
|
|
10
10
|
readonly epicIds?: readonly string[];
|
|
@@ -63,6 +63,25 @@ function jsonResponse(status: number, data: unknown): Response {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function readCookieToken(request: Request): string | null {
|
|
67
|
+
const rawCookie = request.headers.get("cookie");
|
|
68
|
+
if (!rawCookie) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const part of rawCookie.split(";")) {
|
|
73
|
+
const [name, ...valueParts] = part.split("=");
|
|
74
|
+
if (name?.trim() !== "trekoon_board_session") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const value = valueParts.join("=").trim();
|
|
79
|
+
return value.length > 0 ? decodeURIComponent(value) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
function extractToken(request: Request, url: URL): string | null {
|
|
67
86
|
const authorization: string | null = request.headers.get("authorization");
|
|
68
87
|
if (authorization?.startsWith("Bearer ")) {
|
|
@@ -79,7 +98,7 @@ function extractToken(request: Request, url: URL): string | null {
|
|
|
79
98
|
return queryToken.trim();
|
|
80
99
|
}
|
|
81
100
|
|
|
82
|
-
return
|
|
101
|
+
return readCookieToken(request);
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
function isSqliteBusyMessage(message: string): boolean {
|
|
@@ -147,23 +166,7 @@ function buildMutationResponse(_domain: TrackerDomain, data: Record<string, unkn
|
|
|
147
166
|
});
|
|
148
167
|
}
|
|
149
168
|
|
|
150
|
-
|
|
151
|
-
const snapshot = buildBoardSnapshot(domain);
|
|
152
|
-
const epicIdSet = new Set(selection.epicIds ?? []);
|
|
153
|
-
const taskIdSet = new Set(selection.taskIds ?? []);
|
|
154
|
-
const subtaskIdSet = new Set(selection.subtaskIds ?? []);
|
|
155
|
-
const dependencyIdSet = new Set(selection.dependencyIds ?? []);
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
generatedAt: snapshot.generatedAt,
|
|
159
|
-
epics: snapshot.epics.filter((epic) => epicIdSet.has(epic.id)),
|
|
160
|
-
tasks: snapshot.tasks.filter((task) => taskIdSet.has(task.id)),
|
|
161
|
-
subtasks: snapshot.subtasks.filter((subtask) => subtaskIdSet.has(subtask.id)),
|
|
162
|
-
dependencies: snapshot.dependencies.filter((dependency) => dependencyIdSet.has(dependency.id)),
|
|
163
|
-
deletedSubtaskIds: [...(selection.deletedSubtaskIds ?? [])],
|
|
164
|
-
deletedDependencyIds: [...(selection.deletedDependencyIds ?? [])],
|
|
165
|
-
};
|
|
166
|
-
}
|
|
169
|
+
const buildSnapshotDelta = buildBoardSnapshotDelta;
|
|
167
170
|
|
|
168
171
|
function buildMutationDeltaResponse(
|
|
169
172
|
domain: TrackerDomain,
|
package/src/board/server.ts
CHANGED
|
@@ -3,9 +3,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, extname, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { createBoardApiHandler } from "./routes";
|
|
6
|
+
import { buildBoardSnapshot } from "./snapshot";
|
|
6
7
|
|
|
7
8
|
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
8
9
|
import { resolveStoragePaths } from "../storage/path";
|
|
10
|
+
import { TrackerDomain } from "../domain/tracker-domain";
|
|
9
11
|
|
|
10
12
|
const CONTENT_TYPES: Record<string, string> = {
|
|
11
13
|
".css": "text/css; charset=utf-8",
|
|
@@ -87,6 +89,37 @@ function isUnavailablePortError(error: unknown): boolean {
|
|
|
87
89
|
return /^(EADDRINUSE|EACCES)$/i.test(errorCode) || /(EADDRINUSE|EACCES|address already in use|permission denied)/i.test(error.message);
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
function buildBoardSessionCookie(token: string): string {
|
|
93
|
+
return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function serializeInlineJson(value: unknown): string {
|
|
97
|
+
return JSON.stringify(value)
|
|
98
|
+
.replace(/</g, "\\u003c")
|
|
99
|
+
.replace(/>/g, "\\u003e")
|
|
100
|
+
.replace(/&/g, "\\u0026")
|
|
101
|
+
.replace(/\u2028/g, "\\u2028")
|
|
102
|
+
.replace(/\u2029/g, "\\u2029");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildBoardBootstrapPayload(database: TrekoonDatabase, token: string): string {
|
|
106
|
+
const domain = new TrackerDomain(database.db);
|
|
107
|
+
return serializeInlineJson({
|
|
108
|
+
token,
|
|
109
|
+
snapshot: buildBoardSnapshot(domain),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function injectBoardBootstrap(html: string, bootstrapJson: string): string {
|
|
114
|
+
const bootstrapTag = `<script id="trekoon-board-bootstrap" type="application/json">${bootstrapJson}</script>`;
|
|
115
|
+
const closingBodyIndex = html.lastIndexOf("</body>");
|
|
116
|
+
if (closingBodyIndex === -1) {
|
|
117
|
+
return `${html}${bootstrapTag}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return `${html.slice(0, closingBodyIndex)}${bootstrapTag}\n${html.slice(closingBodyIndex)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
90
123
|
export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
|
|
91
124
|
const cwd: string = options.cwd ?? process.cwd();
|
|
92
125
|
const database: TrekoonDatabase = openTrekoonDatabase(cwd);
|
|
@@ -110,6 +143,13 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
110
143
|
return apiHandler(request);
|
|
111
144
|
}
|
|
112
145
|
|
|
146
|
+
const responseHeaders: Record<string, string> = {
|
|
147
|
+
"cache-control": "no-store",
|
|
148
|
+
};
|
|
149
|
+
if ((url.searchParams.get("token") ?? "") === token) {
|
|
150
|
+
responseHeaders["set-cookie"] = buildBoardSessionCookie(token);
|
|
151
|
+
}
|
|
152
|
+
|
|
113
153
|
const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
|
|
114
154
|
if (assetPath === null) {
|
|
115
155
|
const fallbackPath = readAssetPath(boardRoot, "/index.html");
|
|
@@ -117,9 +157,21 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
117
157
|
return new Response("Board assets are not installed", { status: 500 });
|
|
118
158
|
}
|
|
119
159
|
|
|
120
|
-
|
|
160
|
+
const html = injectBoardBootstrap(readFileSync(fallbackPath, "utf8"), buildBoardBootstrapPayload(database, token));
|
|
161
|
+
|
|
162
|
+
return new Response(html, {
|
|
163
|
+
headers: {
|
|
164
|
+
...responseHeaders,
|
|
165
|
+
"content-type": "text/html; charset=utf-8",
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (assetPath.endsWith("/index.html")) {
|
|
171
|
+
const html = injectBoardBootstrap(readFileSync(assetPath, "utf8"), buildBoardBootstrapPayload(database, token));
|
|
172
|
+
return new Response(html, {
|
|
121
173
|
headers: {
|
|
122
|
-
|
|
174
|
+
...responseHeaders,
|
|
123
175
|
"content-type": "text/html; charset=utf-8",
|
|
124
176
|
},
|
|
125
177
|
});
|
|
@@ -127,7 +179,7 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
127
179
|
|
|
128
180
|
return new Response(readFileSync(assetPath), {
|
|
129
181
|
headers: {
|
|
130
|
-
|
|
182
|
+
...responseHeaders,
|
|
131
183
|
"content-type": guessContentType(assetPath),
|
|
132
184
|
},
|
|
133
185
|
});
|
|
@@ -169,11 +221,12 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
169
221
|
|
|
170
222
|
const origin: string = `http://127.0.0.1:${port}`;
|
|
171
223
|
const url: string = `${origin}/?token=${encodeURIComponent(token)}`;
|
|
224
|
+
const fallbackUrl: string = origin;
|
|
172
225
|
|
|
173
226
|
return {
|
|
174
227
|
origin,
|
|
175
228
|
url,
|
|
176
|
-
fallbackUrl
|
|
229
|
+
fallbackUrl,
|
|
177
230
|
token,
|
|
178
231
|
hostname: "127.0.0.1",
|
|
179
232
|
port,
|