trekoon 0.2.7 → 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 +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 +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/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,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
|
+
}
|
package/src/commands/help.ts
CHANGED
|
@@ -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,
|
package/src/commands/init.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { unexpectedFailureResult } from "./error-utils";
|
|
2
2
|
|
|
3
|
+
import { ensureBoardInstalled } from "../board/install";
|
|
4
|
+
import { BoardInstallError } from "../board/types";
|
|
3
5
|
import { DomainError } from "../domain/types";
|
|
4
6
|
import { failResult, okResult } from "../io/output";
|
|
5
7
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
@@ -80,6 +82,11 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
80
82
|
try {
|
|
81
83
|
database = openTrekoonDatabase(context.cwd);
|
|
82
84
|
const diagnostics = database.diagnostics;
|
|
85
|
+
const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
|
|
86
|
+
const board = ensureBoardInstalled({
|
|
87
|
+
workingDirectory: context.cwd,
|
|
88
|
+
...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
|
|
89
|
+
});
|
|
83
90
|
const humanLines: string[] = [
|
|
84
91
|
"Trekoon initialized.",
|
|
85
92
|
`Storage mode: ${diagnostics.storageMode}`,
|
|
@@ -87,6 +94,8 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
87
94
|
`Shared storage root: ${diagnostics.sharedStorageRoot}`,
|
|
88
95
|
`Storage directory: ${database.paths.storageDir}`,
|
|
89
96
|
`Database file: ${database.paths.databaseFile}`,
|
|
97
|
+
`Board assets: ${board.action}`,
|
|
98
|
+
`Board runtime root: ${board.paths.runtimeRoot}`,
|
|
90
99
|
...buildRecoverySummary(database),
|
|
91
100
|
];
|
|
92
101
|
|
|
@@ -101,6 +110,11 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
101
110
|
sharedStorageRoot: diagnostics.sharedStorageRoot,
|
|
102
111
|
storageDir: database.paths.storageDir,
|
|
103
112
|
databaseFile: database.paths.databaseFile,
|
|
113
|
+
board: {
|
|
114
|
+
action: board.action,
|
|
115
|
+
paths: board.paths,
|
|
116
|
+
manifest: board.manifest,
|
|
117
|
+
},
|
|
104
118
|
legacyStateDetected: diagnostics.legacyStateDetected,
|
|
105
119
|
recoveryRequired: diagnostics.recoveryRequired,
|
|
106
120
|
recoveryStatus: diagnostics.recoveryStatus,
|
|
@@ -120,6 +134,21 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
120
134
|
}
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
if (error instanceof BoardInstallError) {
|
|
138
|
+
return failResult({
|
|
139
|
+
command: "init",
|
|
140
|
+
human: error.message,
|
|
141
|
+
data: {
|
|
142
|
+
code: error.code,
|
|
143
|
+
...error.details,
|
|
144
|
+
},
|
|
145
|
+
error: {
|
|
146
|
+
code: error.code,
|
|
147
|
+
message: error.message,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
123
152
|
return unexpectedFailureResult(error, {
|
|
124
153
|
command: "init",
|
|
125
154
|
human: "Unexpected init command failure",
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type SearchField,
|
|
19
19
|
type SearchNode,
|
|
20
20
|
type SearchSummary,
|
|
21
|
+
type StatusCascadeBlocker,
|
|
21
22
|
type StatusCascadePlan,
|
|
22
23
|
type SubtaskRecord,
|
|
23
24
|
type TaskRecord,
|
|
@@ -401,6 +402,45 @@ export class MutationService {
|
|
|
401
402
|
})();
|
|
402
403
|
}
|
|
403
404
|
|
|
405
|
+
describeError(error: unknown): string | undefined {
|
|
406
|
+
if (!(error instanceof DomainError) || error.code !== "dependency_blocked") {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const details = error.details as Record<string, unknown> | undefined;
|
|
411
|
+
const unresolvedDependencies = Array.isArray(details?.unresolvedDependencies)
|
|
412
|
+
? details.unresolvedDependencies
|
|
413
|
+
: [];
|
|
414
|
+
if (unresolvedDependencies.length > 0) {
|
|
415
|
+
const blockers = unresolvedDependencies
|
|
416
|
+
.map((dependency) => {
|
|
417
|
+
if (!dependency || typeof dependency !== "object") {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const id = typeof dependency.id === "string" ? dependency.id : "unknown";
|
|
422
|
+
const kind = typeof dependency.kind === "string" ? dependency.kind : "dependency";
|
|
423
|
+
const status = typeof dependency.status === "string" ? dependency.status : "unknown";
|
|
424
|
+
return `${kind} ${id} is still ${status}`;
|
|
425
|
+
})
|
|
426
|
+
.filter((value): value is string => value !== null);
|
|
427
|
+
|
|
428
|
+
if (blockers.length > 0) {
|
|
429
|
+
return `Resolve dependencies first: ${blockers.join("; ")}.`;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const cascadeBlockers = Array.isArray(details?.blockers) ? details.blockers as StatusCascadeBlocker[] : [];
|
|
434
|
+
if (cascadeBlockers.length > 0) {
|
|
435
|
+
const blockers = cascadeBlockers.map((blocker) =>
|
|
436
|
+
`${blocker.sourceKind} ${blocker.sourceId} is blocked by ${blocker.dependsOnKind} ${blocker.dependsOnId} (${blocker.dependsOnStatus})`
|
|
437
|
+
);
|
|
438
|
+
return `Resolve dependencies first: ${blockers.join("; ")}.`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
|
|
404
444
|
previewEpicReplacement(
|
|
405
445
|
epicId: string,
|
|
406
446
|
searchText: string,
|
|
@@ -138,6 +138,14 @@ function normalizeStatus(value: string | undefined): string {
|
|
|
138
138
|
return assertNonEmpty("status", value);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
function normalizeSubtaskDescription(value: string | undefined): string {
|
|
142
|
+
if (value === undefined) {
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return value.trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
141
149
|
function mapEpic(row: EpicRow): EpicRecord {
|
|
142
150
|
return {
|
|
143
151
|
id: row.id,
|
|
@@ -431,7 +439,7 @@ export class TrackerDomain {
|
|
|
431
439
|
const id: string = randomUUID();
|
|
432
440
|
const taskId: string = assertNonEmpty("taskId", input.taskId);
|
|
433
441
|
const title: string = assertNonEmpty("title", input.title);
|
|
434
|
-
const description: string =
|
|
442
|
+
const description: string = normalizeSubtaskDescription(input.description);
|
|
435
443
|
const status: string = normalizeStatus(input.status);
|
|
436
444
|
|
|
437
445
|
this.getTaskOrThrow(taskId);
|
|
@@ -457,7 +465,7 @@ export class TrackerDomain {
|
|
|
457
465
|
tempKey: assertNonEmpty("tempKey", spec.tempKey),
|
|
458
466
|
taskId,
|
|
459
467
|
title: assertNonEmpty("title", spec.title),
|
|
460
|
-
description:
|
|
468
|
+
description: normalizeSubtaskDescription(spec.description),
|
|
461
469
|
status: normalizeStatus(spec.status),
|
|
462
470
|
};
|
|
463
471
|
});
|
|
@@ -574,7 +582,7 @@ export class TrackerDomain {
|
|
|
574
582
|
const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
|
|
575
583
|
const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
|
|
576
584
|
const nextDescription: string =
|
|
577
|
-
input.description !== undefined ?
|
|
585
|
+
input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
|
|
578
586
|
const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
|
|
579
587
|
this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
|
|
580
588
|
const now: number = Date.now();
|
package/src/runtime/cli-shell.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runBoard } from "../commands/board";
|
|
1
2
|
import { runHelp } from "../commands/help";
|
|
2
3
|
import { runDep } from "../commands/dep";
|
|
3
4
|
import { runEpic } from "../commands/epic";
|
|
@@ -19,6 +20,7 @@ import { resolveStoragePaths } from "../storage/path";
|
|
|
19
20
|
|
|
20
21
|
const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
|
|
21
22
|
"help",
|
|
23
|
+
"board",
|
|
22
24
|
"init",
|
|
23
25
|
"quickstart",
|
|
24
26
|
"session",
|
|
@@ -334,6 +336,9 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
|
|
|
334
336
|
case "help":
|
|
335
337
|
result = await runHelp(context);
|
|
336
338
|
break;
|
|
339
|
+
case "board":
|
|
340
|
+
result = await runBoard(context);
|
|
341
|
+
break;
|
|
337
342
|
case "init":
|
|
338
343
|
result = await runInit(context);
|
|
339
344
|
break;
|