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.
Files changed (40) hide show
  1. package/README.md +102 -708
  2. package/docs/ai-agents.md +198 -0
  3. package/docs/commands.md +226 -0
  4. package/docs/machine-contracts.md +253 -0
  5. package/docs/plans/2026-03-15-trekoon-board-design.md +13 -0
  6. package/docs/quickstart.md +207 -0
  7. package/package.json +3 -1
  8. package/src/board/assets/app.js +1498 -0
  9. package/src/board/assets/components/AppShell.js +17 -0
  10. package/src/board/assets/components/BoardTopbar.js +78 -0
  11. package/src/board/assets/components/ClampedText.js +31 -0
  12. package/src/board/assets/components/EpicRow.js +62 -0
  13. package/src/board/assets/components/EpicsOverview.js +43 -0
  14. package/src/board/assets/components/WorkspaceHeader.js +70 -0
  15. package/src/board/assets/components/assetMap.js +65 -0
  16. package/src/board/assets/index.html +76 -0
  17. package/src/board/assets/main.js +27 -0
  18. package/src/board/assets/manifest.json +12 -0
  19. package/src/board/assets/state/actions.js +334 -0
  20. package/src/board/assets/state/api.js +126 -0
  21. package/src/board/assets/state/store.js +172 -0
  22. package/src/board/assets/styles/board.css +1127 -0
  23. package/src/board/assets/utils/dom.js +308 -0
  24. package/src/board/install.ts +196 -0
  25. package/src/board/open-browser.ts +131 -0
  26. package/src/board/routes.ts +299 -0
  27. package/src/board/server.ts +184 -0
  28. package/src/board/snapshot.ts +277 -0
  29. package/src/board/types.ts +43 -0
  30. package/src/commands/board.ts +158 -0
  31. package/src/commands/epic.ts +104 -3
  32. package/src/commands/help.ts +52 -13
  33. package/src/commands/init.ts +29 -0
  34. package/src/commands/subtask.ts +78 -1
  35. package/src/commands/task.ts +113 -7
  36. package/src/domain/mutation-service.ts +116 -0
  37. package/src/domain/tracker-domain.ts +261 -5
  38. package/src/domain/types.ts +51 -0
  39. package/src/runtime/cli-shell.ts +5 -0
  40. package/src/storage/path.ts +36 -0
@@ -0,0 +1,308 @@
1
+ export const SCROLL_AUTHORITY = Object.freeze({
2
+ page: "page",
3
+ workspace: "workspace",
4
+ inspector: "inspector",
5
+ taskModal: "task-modal",
6
+ subtaskModal: "subtask-modal",
7
+ });
8
+
9
+ const SCROLL_OWNER_CONFIG = {
10
+ [SCROLL_AUTHORITY.page]: {
11
+ containerSelector: ".board-layout",
12
+ scrollSelectors: [".board-layout"],
13
+ defaultFocusSelectors: ["#board-search-input", "[data-nav-board]", "[data-open-epic]"],
14
+ },
15
+ [SCROLL_AUTHORITY.workspace]: {
16
+ containerSelector: "[data-scroll-surface='workspace']",
17
+ scrollSelectors: ["[data-scroll-surface='workspace']"],
18
+ defaultFocusSelectors: [
19
+ "[data-task-id][aria-pressed='true']",
20
+ "[data-task-id]",
21
+ "#board-search-input",
22
+ "[data-open-epic][aria-current='true']",
23
+ "[data-open-epic]",
24
+ ],
25
+ },
26
+ [SCROLL_AUTHORITY.inspector]: {
27
+ containerSelector: "[data-scroll-surface='inspector']",
28
+ scrollSelectors: ["[data-scroll-surface='inspector']"],
29
+ defaultFocusSelectors: ["[data-task-form] [name='title']", "[data-close-task]"],
30
+ },
31
+ [SCROLL_AUTHORITY.taskModal]: {
32
+ containerSelector: "[data-scroll-surface='task-modal']",
33
+ scrollSelectors: ["[data-scroll-surface='task-modal']"],
34
+ defaultFocusSelectors: [".board-task-modal [data-task-form] [name='title']", ".board-task-modal [data-close-task]"],
35
+ },
36
+ [SCROLL_AUTHORITY.subtaskModal]: {
37
+ containerSelector: "[data-scroll-surface='subtask-modal']",
38
+ scrollSelectors: ["[data-scroll-surface='subtask-modal']"],
39
+ defaultFocusSelectors: ["[data-subtask-form] [name='title']", "[data-close-subtask]"],
40
+ },
41
+ };
42
+
43
+ function escapeSelector(value) {
44
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
45
+ return CSS.escape(value);
46
+ }
47
+
48
+ return String(value).replaceAll(/(["\\])/g, "\\$1");
49
+ }
50
+
51
+ function buildAttributeSelector(attribute, value) {
52
+ return `[${attribute}="${escapeSelector(value)}"]`;
53
+ }
54
+
55
+ function captureFieldSelection(element) {
56
+ if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
57
+ return null;
58
+ }
59
+
60
+ return {
61
+ selectionStart: typeof element.selectionStart === "number" ? element.selectionStart : null,
62
+ selectionEnd: typeof element.selectionEnd === "number" ? element.selectionEnd : null,
63
+ };
64
+ }
65
+
66
+ export function resolveFocusSelector(element) {
67
+ if (!(element instanceof HTMLElement)) {
68
+ return null;
69
+ }
70
+
71
+ if (element.id) {
72
+ return { selector: `#${escapeSelector(element.id)}`, selection: captureFieldSelection(element) };
73
+ }
74
+
75
+ const formField = element.closest("[data-task-form], [data-subtask-form], [data-create-subtask-form], [data-dependency-form]");
76
+ if (formField instanceof HTMLElement && (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) && element.name) {
77
+ const attributeName = Array.from(formField.attributes)
78
+ .find((attribute) => attribute.name.startsWith("data-") && attribute.name.endsWith("-form"))?.name;
79
+ const attributeValue = attributeName ? formField.getAttribute(attributeName) : null;
80
+ if (attributeName && attributeValue) {
81
+ return {
82
+ selector: `${buildAttributeSelector(attributeName, attributeValue)} [name="${escapeSelector(element.name)}"]`,
83
+ selection: captureFieldSelection(element),
84
+ };
85
+ }
86
+ }
87
+
88
+ const attributeNames = [
89
+ "data-open-epic",
90
+ "data-task-id",
91
+ "data-open-subtask",
92
+ "data-close-task",
93
+ "data-close-subtask",
94
+ "data-nav",
95
+ "data-nav-board",
96
+ "data-view",
97
+ "data-action",
98
+ "data-delete-subtask",
99
+ "data-remove-dependency-source",
100
+ ];
101
+
102
+ for (const attributeName of attributeNames) {
103
+ const owner = element.closest(`[${attributeName}]`);
104
+ if (!(owner instanceof HTMLElement)) {
105
+ continue;
106
+ }
107
+
108
+ const attributeValue = owner.getAttribute(attributeName);
109
+ if (attributeValue === null || attributeValue === "") {
110
+ return { selector: `[${attributeName}]`, selection: null };
111
+ }
112
+
113
+ return { selector: buildAttributeSelector(attributeName, attributeValue), selection: null };
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ function captureScrollState(root, owner) {
120
+ const scrollSelectors = SCROLL_OWNER_CONFIG[owner]?.scrollSelectors ?? [];
121
+ const scrollState = [];
122
+
123
+ for (const selector of scrollSelectors) {
124
+ const element = root.querySelector(selector);
125
+ if (element instanceof HTMLElement) {
126
+ scrollState.push({ selector, top: element.scrollTop, left: element.scrollLeft });
127
+ }
128
+ }
129
+
130
+ return scrollState;
131
+ }
132
+
133
+ function resolveOwnerContainer(root, owner) {
134
+ const selector = SCROLL_OWNER_CONFIG[owner]?.containerSelector;
135
+ if (!selector) {
136
+ return null;
137
+ }
138
+
139
+ const element = root.querySelector(selector);
140
+ return element instanceof HTMLElement ? element : null;
141
+ }
142
+
143
+ function buildFocusCandidates(owner, runtimeState, focusOverride, fallbackFocusSelectors = []) {
144
+ const focusCandidates = [];
145
+
146
+ if (focusOverride?.selector) {
147
+ focusCandidates.push({ selector: focusOverride.selector, selection: focusOverride.selection ?? null });
148
+ }
149
+
150
+ if (runtimeState?.focusState?.selector) {
151
+ focusCandidates.push({ selector: runtimeState.focusState.selector, selection: runtimeState.focusState.selection ?? null });
152
+ }
153
+
154
+ for (const selector of fallbackFocusSelectors) {
155
+ if (typeof selector === "string" && selector.length > 0) {
156
+ focusCandidates.push({ selector, selection: null });
157
+ }
158
+ }
159
+
160
+ for (const selector of SCROLL_OWNER_CONFIG[owner]?.defaultFocusSelectors ?? []) {
161
+ focusCandidates.push({ selector, selection: null });
162
+ }
163
+
164
+ if (owner === SCROLL_AUTHORITY.workspace || owner === SCROLL_AUTHORITY.page) {
165
+ if (runtimeState?.fallbackTaskId) {
166
+ focusCandidates.push({ selector: buildAttributeSelector("data-task-id", runtimeState.fallbackTaskId), selection: null });
167
+ }
168
+ if (runtimeState?.fallbackEpicId) {
169
+ focusCandidates.push({ selector: buildAttributeSelector("data-open-epic", runtimeState.fallbackEpicId), selection: null });
170
+ }
171
+ focusCandidates.push({ selector: "#board-search-input", selection: null });
172
+ }
173
+
174
+ return focusCandidates;
175
+ }
176
+
177
+ export function resolveScrollAuthorityStack(boardState, options = {}) {
178
+ const useTaskModal = options.useTaskModal === true;
179
+ if (boardState?.screen !== "tasks") {
180
+ return [SCROLL_AUTHORITY.page];
181
+ }
182
+
183
+ const stack = [SCROLL_AUTHORITY.workspace];
184
+ if (boardState?.selectedTask) {
185
+ stack.push(useTaskModal ? SCROLL_AUTHORITY.taskModal : SCROLL_AUTHORITY.inspector);
186
+ }
187
+ if (boardState?.selectedSubtask) {
188
+ stack.push(SCROLL_AUTHORITY.subtaskModal);
189
+ }
190
+ return stack;
191
+ }
192
+
193
+ export function syncScrollAuthority(root, ownerStack) {
194
+ if (!(root instanceof HTMLElement)) {
195
+ return null;
196
+ }
197
+
198
+ const activeOwner = ownerStack.at(-1) ?? SCROLL_AUTHORITY.page;
199
+ root.dataset.scrollOwner = activeOwner;
200
+ root.querySelectorAll("[data-scroll-surface]").forEach((element) => {
201
+ if (!(element instanceof HTMLElement)) {
202
+ return;
203
+ }
204
+
205
+ element.dataset.scrollActive = element.dataset.scrollSurface === activeOwner ? "true" : "false";
206
+ });
207
+ return activeOwner;
208
+ }
209
+
210
+ export function createScrollAuthorityStack(initialStack = [SCROLL_AUTHORITY.page]) {
211
+ const runtimeStates = new Map();
212
+ const returnFocusStates = new Map();
213
+ let ownerStack = [...initialStack];
214
+
215
+ return {
216
+ capture(root, store) {
217
+ const activeOwner = ownerStack.at(-1) ?? SCROLL_AUTHORITY.page;
218
+ const runtimeState = captureRuntimeState(root, store, activeOwner);
219
+ if (runtimeState) {
220
+ runtimeStates.set(activeOwner, runtimeState);
221
+ }
222
+ },
223
+ rememberReturnFocus(owner, focusState) {
224
+ if (!owner || !focusState?.selector) {
225
+ return;
226
+ }
227
+ returnFocusStates.set(owner, focusState);
228
+ },
229
+ transition(nextOwnerStack) {
230
+ ownerStack = Array.isArray(nextOwnerStack) && nextOwnerStack.length > 0
231
+ ? [...nextOwnerStack]
232
+ : [SCROLL_AUTHORITY.page];
233
+ const activeOwner = ownerStack.at(-1) ?? SCROLL_AUTHORITY.page;
234
+ return {
235
+ owner: activeOwner,
236
+ runtimeState: runtimeStates.get(activeOwner) ?? null,
237
+ returnFocusState: returnFocusStates.get(activeOwner) ?? null,
238
+ };
239
+ },
240
+ };
241
+ }
242
+
243
+ export function syncOverlayScrollLock(isLocked) {
244
+ document.documentElement.style.overflow = isLocked ? "hidden" : "";
245
+ document.body.style.overflow = isLocked ? "hidden" : "";
246
+ }
247
+
248
+ export function captureRuntimeState(root, store, owner = SCROLL_AUTHORITY.page) {
249
+ if (!(root instanceof HTMLElement)) {
250
+ return null;
251
+ }
252
+
253
+ const ownerContainer = resolveOwnerContainer(root, owner);
254
+ const activeElement = document.activeElement;
255
+ const focusState = ownerContainer instanceof HTMLElement && ownerContainer.contains(activeElement)
256
+ ? resolveFocusSelector(activeElement)
257
+ : null;
258
+
259
+ return {
260
+ owner,
261
+ focusState,
262
+ scrollState: captureScrollState(root, owner),
263
+ fallbackTaskId: store?.selectedTaskId ?? null,
264
+ fallbackEpicId: store?.selectedEpicId ?? null,
265
+ };
266
+ }
267
+
268
+ export function restoreRuntimeState(root, payload) {
269
+ if (!(root instanceof HTMLElement) || !payload) {
270
+ return;
271
+ }
272
+
273
+ const owner = payload.owner ?? payload.runtimeState?.owner ?? SCROLL_AUTHORITY.page;
274
+ const runtimeState = payload.runtimeState ?? payload;
275
+
276
+ for (const { selector, top, left } of runtimeState.scrollState ?? []) {
277
+ const element = root.querySelector(selector);
278
+ if (element instanceof HTMLElement) {
279
+ element.scrollTop = top;
280
+ element.scrollLeft = left;
281
+ }
282
+ }
283
+
284
+ const ownerContainer = resolveOwnerContainer(root, owner) ?? root;
285
+ const focusCandidates = buildFocusCandidates(
286
+ owner,
287
+ runtimeState,
288
+ payload.returnFocusState,
289
+ payload.fallbackFocusSelectors ?? [],
290
+ );
291
+
292
+ for (const candidate of focusCandidates) {
293
+ const element = root.querySelector(candidate.selector);
294
+ if (!(element instanceof HTMLElement)) {
295
+ continue;
296
+ }
297
+ if (ownerContainer instanceof HTMLElement && !ownerContainer.contains(element) && owner !== SCROLL_AUTHORITY.page) {
298
+ continue;
299
+ }
300
+
301
+ element.focus({ preventScroll: true });
302
+ const selection = candidate.selection;
303
+ if (selection && (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && selection.selectionStart !== null) {
304
+ element.setSelectionRange(selection.selectionStart, selection.selectionEnd ?? selection.selectionStart);
305
+ }
306
+ break;
307
+ }
308
+ }
@@ -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
+ }