trekoon 0.4.3 → 0.4.4
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 +8 -3
- package/docs/commands.md +32 -23
- package/docs/machine-contracts.md +1 -1
- package/docs/quickstart.md +6 -6
- package/package.json +2 -2
- package/src/board/asset-root.ts +73 -0
- package/src/board/assets/app.js +1 -1
- package/src/board/event-bus.ts +8 -2
- package/src/board/routes.ts +1 -1
- package/src/board/server.ts +21 -1
- package/src/board/types.ts +2 -34
- package/src/board/wal-watcher.ts +154 -23
- package/src/commands/board.ts +20 -42
- package/src/commands/help.ts +5 -6
- package/src/commands/init.ts +0 -29
- package/src/commands/quickstart.ts +1 -1
- package/src/runtime/version.ts +10 -2
- package/src/storage/path.ts +0 -36
- package/src/board/install.ts +0 -196
package/README.md
CHANGED
|
@@ -196,14 +196,19 @@ call it from any non-done status.
|
|
|
196
196
|
|
|
197
197
|
## Local board
|
|
198
198
|
|
|
199
|
-
Trekoon includes a browser-based board for humans who like having visual
|
|
200
|
-
No build step, no framework dependencies, works offline.
|
|
199
|
+
Trekoon includes a browser-based board for humans who like having a visual
|
|
200
|
+
overview. No build step, no framework dependencies, works offline.
|
|
201
201
|
|
|
202
202
|
```bash
|
|
203
203
|
trekoon board open # starts a local server, opens browser
|
|
204
|
-
trekoon board update # refresh assets only
|
|
205
204
|
```
|
|
206
205
|
|
|
206
|
+
Board code (HTML, JS, CSS, fonts) comes from the running Trekoon install.
|
|
207
|
+
Board data comes from the repo where `trekoon board open` is invoked. Updating
|
|
208
|
+
Trekoon updates the board bundle; no per-repo asset copy is needed.
|
|
209
|
+
Repos with an old ignored `.trekoon/board` directory keep those files on disk,
|
|
210
|
+
but Trekoon no longer reads or refreshes them.
|
|
211
|
+
|
|
207
212
|
Binds to `127.0.0.1` only with a per-session token. Gives you an epics
|
|
208
213
|
overview, kanban workspace per epic, task detail modals, and search. The board
|
|
209
214
|
client subscribes to `/api/snapshot/stream` (SSE), so mutations from another
|
package/docs/commands.md
CHANGED
|
@@ -6,7 +6,7 @@ the quickest way to get started, read [Quickstart](quickstart.md) first.
|
|
|
6
6
|
## Command surface
|
|
7
7
|
|
|
8
8
|
- `trekoon init`
|
|
9
|
-
- `trekoon board <open
|
|
9
|
+
- `trekoon board <open>`
|
|
10
10
|
- `trekoon help [command]`
|
|
11
11
|
- `trekoon quickstart`
|
|
12
12
|
- `trekoon epic <create|expand|list|show|search|replace|update|delete|progress>`
|
|
@@ -50,25 +50,31 @@ also accept `-h` and `-v`.
|
|
|
50
50
|
|
|
51
51
|
## Board commands
|
|
52
52
|
|
|
53
|
-
`trekoon board open`
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
`trekoon board open` starts a loopback-only server on `127.0.0.1` with a
|
|
54
|
+
random port, opens the browser, and returns the board URL plus a manual
|
|
55
|
+
fallback URL.
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
Board code (HTML, JS, CSS, fonts) comes from the running Trekoon install —
|
|
58
|
+
a global install serves the global package assets; a project-local install
|
|
59
|
+
serves the project-local package assets. Board data comes from the repo where
|
|
60
|
+
`trekoon board open` is invoked. Updating Trekoon updates the board bundle;
|
|
61
|
+
no per-repo asset copy is needed.
|
|
60
62
|
|
|
61
63
|
Security model:
|
|
62
64
|
|
|
63
65
|
- Every `board open` session gets a unique token
|
|
64
|
-
-
|
|
65
|
-
`
|
|
66
|
+
- Initial board HTML bootstrap accepts `?token={token}` once, sets an
|
|
67
|
+
HttpOnly `trekoon_board_session` cookie, and redirects to the same URL
|
|
68
|
+
without the token query parameter
|
|
69
|
+
- Board API requests must authenticate with the session cookie,
|
|
70
|
+
`Authorization: Bearer {token}`, or `x-trekoon-token` header
|
|
71
|
+
- API routes reject token query parameters
|
|
72
|
+
- `/api/snapshot/stream` is authenticated by the session cookie
|
|
66
73
|
- Invalid tokens return `401`
|
|
67
74
|
- Static responses use `cache-control: no-store`
|
|
68
75
|
|
|
69
76
|
The board is a self-hosted single-page app (vanilla JS, bundled CSS and fonts,
|
|
70
|
-
no framework or CDN dependencies)
|
|
71
|
-
offline once initialized.
|
|
77
|
+
no framework or CDN dependencies). Works fully offline.
|
|
72
78
|
|
|
73
79
|
Board API endpoints (all require token authentication):
|
|
74
80
|
|
|
@@ -84,27 +90,30 @@ Board API endpoints (all require token authentication):
|
|
|
84
90
|
| `POST` | `/api/dependencies` | Add dependency edge (sourceId, dependsOnId) |
|
|
85
91
|
| `DELETE` | `/api/dependencies?sourceId=...&dependsOnId=...` | Remove dependency |
|
|
86
92
|
|
|
87
|
-
**Note:** the SSE auth token rides as a `?token=` query parameter on
|
|
88
|
-
`/api/snapshot/stream` (EventSource cannot set custom headers), so it may
|
|
89
|
-
appear in reverse-proxy access logs; treat access logs as sensitive if the
|
|
90
|
-
board is exposed beyond localhost.
|
|
91
|
-
|
|
92
93
|
Board mutations from any source — board UI, CLI in another shell, or another
|
|
93
94
|
worktree — propagate to every connected client through the SSE stream. The CLI
|
|
94
95
|
side is driven by a WAL-watcher that diffs the snapshot when
|
|
95
96
|
`.trekoon/trekoon.db-wal` changes; in-process mutations publish deltas
|
|
96
|
-
directly. PATCH endpoints accept `If-Match: <
|
|
97
|
-
concurrency
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
directly. PATCH endpoints accept `If-Match: <version>` for optimistic
|
|
98
|
+
concurrency; RFC 7232 strong or `W/`-prefixed weak ETag forms are accepted,
|
|
99
|
+
but the `*` wildcard is not. A stale value returns `409` with
|
|
100
|
+
`currentVersion` and `providedVersion`. Missing `If-Match` is allowed for
|
|
101
|
+
back-compat.
|
|
102
|
+
|
|
103
|
+
Repos that already have an ignored `.trekoon/board` directory keep those files
|
|
104
|
+
on disk, but current Trekoon versions no longer read, create, refresh, or
|
|
105
|
+
delete them. Remove that directory manually only if you want to clean up the
|
|
106
|
+
old copied board bundle.
|
|
107
|
+
|
|
108
|
+
`trekoon board open` accepts `--reveal-token` to include the raw tokenized URL,
|
|
109
|
+
server token, and browser launch arguments in machine output. Other board
|
|
110
|
+
subcommands and flags are rejected. `TREKOON_BOARD_ASSET_ROOT` overrides the
|
|
111
|
+
asset source for tests and local development.
|
|
102
112
|
|
|
103
113
|
```bash
|
|
104
114
|
trekoon init
|
|
105
115
|
trekoon board open
|
|
106
116
|
trekoon --json board open
|
|
107
|
-
trekoon board update
|
|
108
117
|
```
|
|
109
118
|
|
|
110
119
|
## Human views
|
|
@@ -647,7 +647,7 @@ any `ok: false` response will be one of the following strings.
|
|
|
647
647
|
| `orphaned_external_node` | Dependency graph contains a node that belongs to a different epic. |
|
|
648
648
|
| `outside_repo_target` | Skill install target path is outside the repository root. |
|
|
649
649
|
| `permission_denied` | File-system permission denied for the requested path. |
|
|
650
|
-
| `precondition_failed` | `If-Match` precondition header did not match the entity's current `
|
|
650
|
+
| `precondition_failed` | `If-Match` precondition header did not match the entity's current version. Error details include `currentVersion` and `providedVersion`. |
|
|
651
651
|
| `row_not_found` | Sync resolve target row no longer exists in the database. |
|
|
652
652
|
| `status_transition_invalid` | Requested status transition is not permitted by the status machine. |
|
|
653
653
|
| `stream_unavailable` | SSE snapshot stream is not available (board not initialised or shutting down). |
|
package/docs/quickstart.md
CHANGED
|
@@ -50,9 +50,8 @@ trekoon --toon init
|
|
|
50
50
|
trekoon --toon sync status
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Run `init` once per repository. It creates the shared storage root
|
|
54
|
-
|
|
55
|
-
`recoveryRequired` or a tracked/ignored mismatch, fix the setup before
|
|
53
|
+
Run `init` once per repository. It creates the shared storage root. If `sync status`
|
|
54
|
+
reports `recoveryRequired` or a tracked/ignored mismatch, fix the setup before
|
|
56
55
|
continuing.
|
|
57
56
|
|
|
58
57
|
## Open the board
|
|
@@ -62,9 +61,10 @@ trekoon board open
|
|
|
62
61
|
```
|
|
63
62
|
|
|
64
63
|
Starts a loopback-only server on `127.0.0.1`, opens the browser, and prints a
|
|
65
|
-
fallback URL.
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
fallback URL. Board code (HTML, JS, CSS, fonts) comes from the running Trekoon
|
|
65
|
+
install; board data comes from the repo where the command is invoked. The board
|
|
66
|
+
works fully offline. To get an updated board UI, update Trekoon itself. Old
|
|
67
|
+
ignored `.trekoon/board` copies are left on disk but are no longer used.
|
|
68
68
|
|
|
69
69
|
## Create work
|
|
70
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trekoon",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "AI-first task tracking that lives in your repo. You describe what to build, your agent plans it as a dependency graph, then executes it task by task",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
],
|
|
41
41
|
"scripts": {
|
|
42
42
|
"run": "bun run ./src/index.ts",
|
|
43
|
-
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
43
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun && cp ./package.json ./dist/package.json && rm -rf ./dist/assets && cp -R ./src/board/assets ./dist/assets",
|
|
44
44
|
"test": "bun test ./tests",
|
|
45
45
|
"lint": "bunx tsc --noEmit"
|
|
46
46
|
},
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { BoardAssetError } from "./types";
|
|
6
|
+
|
|
7
|
+
export const BOARD_BUNDLED_ASSET_DIRNAME = "assets";
|
|
8
|
+
export const BOARD_ENTRY_FILENAME = "index.html";
|
|
9
|
+
export const BOARD_ASSET_ROOT_ENV_VAR = "TREKOON_BOARD_ASSET_ROOT";
|
|
10
|
+
|
|
11
|
+
export type BoardAssetRootSource = "override" | "environment" | "package";
|
|
12
|
+
|
|
13
|
+
export interface BoardAssetRoot {
|
|
14
|
+
readonly assetRoot: string;
|
|
15
|
+
readonly entryFile: string;
|
|
16
|
+
readonly source: BoardAssetRootSource;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResolveBoardAssetRootOptions {
|
|
20
|
+
readonly assetRootOverride?: string;
|
|
21
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolvePackageBoardAssetRoot(): string {
|
|
25
|
+
return fileURLToPath(new URL(`./${BOARD_BUNDLED_ASSET_DIRNAME}`, import.meta.url));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SelectedAssetRoot {
|
|
29
|
+
readonly rootPath: string;
|
|
30
|
+
readonly source: BoardAssetRootSource;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function selectAssetRoot(options: ResolveBoardAssetRootOptions): SelectedAssetRoot {
|
|
34
|
+
if (options.assetRootOverride !== undefined) {
|
|
35
|
+
return { rootPath: resolve(options.assetRootOverride), source: "override" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const env = options.env ?? process.env;
|
|
39
|
+
const envOverrideRaw = env[BOARD_ASSET_ROOT_ENV_VAR];
|
|
40
|
+
const envOverride = typeof envOverrideRaw === "string" ? envOverrideRaw.trim() : "";
|
|
41
|
+
if (envOverride.length > 0) {
|
|
42
|
+
return { rootPath: resolve(envOverride), source: "environment" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { rootPath: resolvePackageBoardAssetRoot(), source: "package" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveBoardAssetRoot(options: ResolveBoardAssetRootOptions = {}): BoardAssetRoot {
|
|
49
|
+
const { rootPath, source } = selectAssetRoot(options);
|
|
50
|
+
|
|
51
|
+
if (!existsSync(rootPath) || !statSync(rootPath).isDirectory()) {
|
|
52
|
+
throw new BoardAssetError("missing_asset", `Board asset root not found at ${rootPath}`, {
|
|
53
|
+
assetRoot: rootPath,
|
|
54
|
+
source,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const entryFile = resolve(rootPath, BOARD_ENTRY_FILENAME);
|
|
59
|
+
if (!existsSync(entryFile)) {
|
|
60
|
+
throw new BoardAssetError("missing_asset", `Board entry file not found at ${entryFile}`, {
|
|
61
|
+
assetRoot: rootPath,
|
|
62
|
+
entryFile,
|
|
63
|
+
source,
|
|
64
|
+
missingFile: BOARD_ENTRY_FILENAME,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
assetRoot: rootPath,
|
|
70
|
+
entryFile,
|
|
71
|
+
source,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/board/assets/app.js
CHANGED
|
@@ -156,7 +156,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
156
156
|
</div>
|
|
157
157
|
<span class="${sectionLabelClasses()}">Board ready</span>
|
|
158
158
|
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-[var(--board-text)]">No work has been published yet</h1>
|
|
159
|
-
<p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">
|
|
159
|
+
<p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">Create or sync an epic in this repository and it will appear here.</p>
|
|
160
160
|
</div>
|
|
161
161
|
</section>
|
|
162
162
|
`;
|
package/src/board/event-bus.ts
CHANGED
|
@@ -18,9 +18,10 @@ export type BoardEventListener = (event: BoardEvent) => void;
|
|
|
18
18
|
|
|
19
19
|
export interface BoardEventBus {
|
|
20
20
|
publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent;
|
|
21
|
-
markInProcessWrite(timestamp?: number): void;
|
|
21
|
+
markInProcessWrite(timestamp?: number, snapshotDelta?: Record<string, unknown>): void;
|
|
22
22
|
subscribe(listener: BoardEventListener): () => void;
|
|
23
23
|
readonly lastInProcessWriteAt: number;
|
|
24
|
+
readonly lastInProcessSnapshotDelta: Record<string, unknown> | null;
|
|
24
25
|
readonly subscriberCount: number;
|
|
25
26
|
close(): void;
|
|
26
27
|
}
|
|
@@ -30,6 +31,7 @@ export function createBoardEventBus(): BoardEventBus {
|
|
|
30
31
|
let nextId = 1;
|
|
31
32
|
let closed = false;
|
|
32
33
|
let lastInProcessWriteAt = 0;
|
|
34
|
+
let lastInProcessSnapshotDelta: Record<string, unknown> | null = null;
|
|
33
35
|
|
|
34
36
|
return {
|
|
35
37
|
publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent {
|
|
@@ -54,8 +56,9 @@ export function createBoardEventBus(): BoardEventBus {
|
|
|
54
56
|
|
|
55
57
|
return event;
|
|
56
58
|
},
|
|
57
|
-
markInProcessWrite(timestamp = Date.now()): void {
|
|
59
|
+
markInProcessWrite(timestamp = Date.now(), snapshotDelta: Record<string, unknown> | undefined = undefined): void {
|
|
58
60
|
lastInProcessWriteAt = timestamp;
|
|
61
|
+
lastInProcessSnapshotDelta = snapshotDelta ?? null;
|
|
59
62
|
},
|
|
60
63
|
subscribe(listener: BoardEventListener): () => void {
|
|
61
64
|
if (closed) {
|
|
@@ -73,6 +76,9 @@ export function createBoardEventBus(): BoardEventBus {
|
|
|
73
76
|
get lastInProcessWriteAt(): number {
|
|
74
77
|
return lastInProcessWriteAt;
|
|
75
78
|
},
|
|
79
|
+
get lastInProcessSnapshotDelta(): Record<string, unknown> | null {
|
|
80
|
+
return lastInProcessSnapshotDelta;
|
|
81
|
+
},
|
|
76
82
|
close(): void {
|
|
77
83
|
closed = true;
|
|
78
84
|
listeners.clear();
|
package/src/board/routes.ts
CHANGED
package/src/board/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, extname, resolve } from "node:path";
|
|
4
4
|
|
|
5
|
+
import { resolveBoardAssetRoot, type BoardAssetRoot } from "./asset-root";
|
|
5
6
|
import { createBoardEventBus, type BoardEventBus } from "./event-bus";
|
|
6
7
|
import { createBoardApiHandler } from "./routes";
|
|
7
8
|
import { startWalWatcher, type WalWatcher } from "./wal-watcher";
|
|
@@ -38,6 +39,14 @@ export interface BoardServerInfo {
|
|
|
38
39
|
export interface StartBoardServerOptions {
|
|
39
40
|
readonly cwd?: string;
|
|
40
41
|
readonly token?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Optional override for the directory the board server reads static assets
|
|
44
|
+
* from. When unset, the server resolves the asset root via
|
|
45
|
+
* `resolveBoardAssetRoot()`, which honours `TREKOON_BOARD_ASSET_ROOT` and
|
|
46
|
+
* falls back to the assets shipped alongside the running Trekoon package.
|
|
47
|
+
* Database, API, SSE, and WAL watcher paths continue to use `cwd`.
|
|
48
|
+
*/
|
|
49
|
+
readonly assetRootOverride?: string;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
function guessContentType(pathname: string): string {
|
|
@@ -159,9 +168,20 @@ function injectBoardBootstrap(html: string, bootstrapJson: string): string {
|
|
|
159
168
|
|
|
160
169
|
export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
|
|
161
170
|
const cwd: string = options.cwd ?? process.cwd();
|
|
171
|
+
// Resolve the asset root BEFORE generating a token or opening the database.
|
|
172
|
+
// If the installed assets are missing, `resolveBoardAssetRoot` raises a
|
|
173
|
+
// `BoardAssetError` carrying only path/source metadata — never the auth
|
|
174
|
+
// token, which has not been generated yet. The CLI's `instanceof
|
|
175
|
+
// BoardAssetError` branch translates this into a clean operator error
|
|
176
|
+
// without leaking secrets through machine output or logs.
|
|
177
|
+
const assetRoot: BoardAssetRoot = resolveBoardAssetRoot(
|
|
178
|
+
options.assetRootOverride === undefined
|
|
179
|
+
? {}
|
|
180
|
+
: { assetRootOverride: options.assetRootOverride },
|
|
181
|
+
);
|
|
182
|
+
const boardRoot: string = assetRoot.assetRoot;
|
|
162
183
|
const database: TrekoonDatabase = openTrekoonDatabase(cwd);
|
|
163
184
|
const paths = resolveStoragePaths(cwd);
|
|
164
|
-
const boardRoot: string = paths.boardDir;
|
|
165
185
|
const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
|
|
166
186
|
const token: string = options.token ?? randomBytes(32).toString("hex");
|
|
167
187
|
const eventBus: BoardEventBus = createBoardEventBus();
|
package/src/board/types.ts
CHANGED
|
@@ -1,42 +1,10 @@
|
|
|
1
|
-
export
|
|
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 {
|
|
1
|
+
export class BoardAssetError extends Error {
|
|
34
2
|
readonly code: string;
|
|
35
3
|
readonly details: Record<string, unknown>;
|
|
36
4
|
|
|
37
5
|
constructor(code: string, message: string, details: Record<string, unknown> = {}) {
|
|
38
6
|
super(message);
|
|
39
|
-
this.name = "
|
|
7
|
+
this.name = "BoardAssetError";
|
|
40
8
|
this.code = code;
|
|
41
9
|
this.details = details;
|
|
42
10
|
}
|
package/src/board/wal-watcher.ts
CHANGED
|
@@ -66,6 +66,65 @@ function changeKeyEqual(
|
|
|
66
66
|
return a.version === b.version && a.updatedAt === b.updatedAt;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
function derivedRecordFingerprint(value: unknown): string {
|
|
70
|
+
if (!value || typeof value !== "object") {
|
|
71
|
+
return JSON.stringify(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const record = value as Record<string, unknown>;
|
|
75
|
+
const kind = typeof record.kind === "string" ? record.kind : "";
|
|
76
|
+
|
|
77
|
+
if (kind === "task") {
|
|
78
|
+
return JSON.stringify({
|
|
79
|
+
blockedBy: record.blockedBy,
|
|
80
|
+
blocks: record.blocks,
|
|
81
|
+
dependencyIds: record.dependencyIds,
|
|
82
|
+
dependentIds: record.dependentIds,
|
|
83
|
+
subtasks: record.subtasks,
|
|
84
|
+
searchText: record.searchText,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (kind === "subtask") {
|
|
89
|
+
return JSON.stringify({
|
|
90
|
+
blockedBy: record.blockedBy,
|
|
91
|
+
blocks: record.blocks,
|
|
92
|
+
dependencyIds: record.dependencyIds,
|
|
93
|
+
dependentIds: record.dependentIds,
|
|
94
|
+
searchText: record.searchText,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if ("taskIds" in record || "counts" in record) {
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
taskIds: record.taskIds,
|
|
101
|
+
counts: record.counts,
|
|
102
|
+
searchText: record.searchText,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return JSON.stringify(record);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function recordMatchesPublishedDelta(record: unknown, publishedRecord: unknown): boolean {
|
|
110
|
+
const recordKey = recordChangeKey(record);
|
|
111
|
+
const publishedKey = recordChangeKey(publishedRecord);
|
|
112
|
+
return changeKeyEqual(recordKey, publishedKey) &&
|
|
113
|
+
derivedRecordFingerprint(record) === derivedRecordFingerprint(publishedRecord);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function recordChanged(previousRecord: unknown, currentRecord: unknown): boolean {
|
|
117
|
+
if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(currentRecord))) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The board snapshot includes derived parent fields (for example epic task
|
|
122
|
+
// counts/search text and task subtask lists). Child writes do not bump the
|
|
123
|
+
// parent row version, but those derived fields still need to reach connected
|
|
124
|
+
// boards through WAL deltas.
|
|
125
|
+
return derivedRecordFingerprint(previousRecord) !== derivedRecordFingerprint(currentRecord);
|
|
126
|
+
}
|
|
127
|
+
|
|
69
128
|
function diffById(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined): CollectionDiff {
|
|
70
129
|
const previousIndex = new Map<string, unknown>();
|
|
71
130
|
for (const record of previous ?? []) {
|
|
@@ -89,9 +148,7 @@ function diffById(previous: readonly unknown[] | undefined, current: readonly un
|
|
|
89
148
|
upserted.push(record);
|
|
90
149
|
continue;
|
|
91
150
|
}
|
|
92
|
-
|
|
93
|
-
// domain write. Equal tuple → no content change → skip the upsert.
|
|
94
|
-
if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(record))) {
|
|
151
|
+
if (recordChanged(previousRecord, record)) {
|
|
95
152
|
upserted.push(record);
|
|
96
153
|
}
|
|
97
154
|
}
|
|
@@ -106,6 +163,55 @@ function diffById(previous: readonly unknown[] | undefined, current: readonly un
|
|
|
106
163
|
return { upserted, deletedIds };
|
|
107
164
|
}
|
|
108
165
|
|
|
166
|
+
function recordsByIdFromDelta(delta: Record<string, unknown> | null, key: string): Map<string, unknown> {
|
|
167
|
+
const records = delta?.[key];
|
|
168
|
+
const index = new Map<string, unknown>();
|
|
169
|
+
if (!Array.isArray(records)) {
|
|
170
|
+
return index;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const record of records) {
|
|
174
|
+
const id = recordId(record);
|
|
175
|
+
if (id !== null) {
|
|
176
|
+
index.set(id, record);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return index;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function deletedIdsFromDelta(delta: Record<string, unknown> | null, key: string): Set<string> {
|
|
184
|
+
const ids = delta?.[key];
|
|
185
|
+
if (!Array.isArray(ids)) {
|
|
186
|
+
return new Set();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function suppressAlreadyPublishedDiff(
|
|
193
|
+
diff: CollectionDiff,
|
|
194
|
+
publishedRecords: Map<string, unknown>,
|
|
195
|
+
publishedDeletedIds: Set<string>,
|
|
196
|
+
): CollectionDiff {
|
|
197
|
+
return {
|
|
198
|
+
upserted: diff.upserted.filter((record) => {
|
|
199
|
+
const id = recordId(record);
|
|
200
|
+
if (id === null) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const publishedRecord = publishedRecords.get(id);
|
|
205
|
+
return publishedRecord === undefined || !recordMatchesPublishedDelta(record, publishedRecord);
|
|
206
|
+
}),
|
|
207
|
+
deletedIds: diff.deletedIds.filter((id) => !publishedDeletedIds.has(id)),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasDiffChanges(...diffs: readonly CollectionDiff[]): boolean {
|
|
212
|
+
return diffs.some((diff) => diff.upserted.length > 0 || diff.deletedIds.length > 0);
|
|
213
|
+
}
|
|
214
|
+
|
|
109
215
|
function readMtime(path: string): number {
|
|
110
216
|
if (!existsSync(path)) {
|
|
111
217
|
return 0;
|
|
@@ -188,27 +294,52 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
|
|
|
188
294
|
return;
|
|
189
295
|
}
|
|
190
296
|
const inProcessWriteAt = options.eventBus.lastInProcessWriteAt;
|
|
191
|
-
|
|
297
|
+
const shouldSuppressInProcessTick =
|
|
192
298
|
inProcessWriteAt > lastSuppressedInProcessWriteAt &&
|
|
193
|
-
Date.now() - inProcessWriteAt <= IN_PROCESS_WAL_SUPPRESS_MS
|
|
194
|
-
) {
|
|
195
|
-
lastSuppressedInProcessWriteAt = inProcessWriteAt;
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
299
|
+
Date.now() - inProcessWriteAt <= IN_PROCESS_WAL_SUPPRESS_MS;
|
|
198
300
|
|
|
199
301
|
try {
|
|
200
302
|
const fresh = buildSnapshot(domain);
|
|
201
|
-
|
|
202
303
|
const epicsDiff = diffById(lastSnapshot.epics, fresh.epics);
|
|
203
304
|
const tasksDiff = diffById(lastSnapshot.tasks, fresh.tasks);
|
|
204
305
|
const subtasksDiff = diffById(lastSnapshot.subtasks, fresh.subtasks);
|
|
205
306
|
const dependenciesDiff = diffById(lastSnapshot.dependencies, fresh.dependencies);
|
|
206
307
|
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
308
|
+
const shouldSuppressDiff = shouldSuppressInProcessTick
|
|
309
|
+
? {
|
|
310
|
+
epics: suppressAlreadyPublishedDiff(
|
|
311
|
+
epicsDiff,
|
|
312
|
+
recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "epics"),
|
|
313
|
+
deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedEpicIds"),
|
|
314
|
+
),
|
|
315
|
+
tasks: suppressAlreadyPublishedDiff(
|
|
316
|
+
tasksDiff,
|
|
317
|
+
recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "tasks"),
|
|
318
|
+
deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedTaskIds"),
|
|
319
|
+
),
|
|
320
|
+
subtasks: suppressAlreadyPublishedDiff(
|
|
321
|
+
subtasksDiff,
|
|
322
|
+
recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "subtasks"),
|
|
323
|
+
deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedSubtaskIds"),
|
|
324
|
+
),
|
|
325
|
+
dependencies: suppressAlreadyPublishedDiff(
|
|
326
|
+
dependenciesDiff,
|
|
327
|
+
recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "dependencies"),
|
|
328
|
+
deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedDependencyIds"),
|
|
329
|
+
),
|
|
330
|
+
}
|
|
331
|
+
: null;
|
|
332
|
+
|
|
333
|
+
if (shouldSuppressInProcessTick) {
|
|
334
|
+
lastSuppressedInProcessWriteAt = inProcessWriteAt;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const publishEpicsDiff = shouldSuppressDiff?.epics ?? epicsDiff;
|
|
338
|
+
const publishTasksDiff = shouldSuppressDiff?.tasks ?? tasksDiff;
|
|
339
|
+
const publishSubtasksDiff = shouldSuppressDiff?.subtasks ?? subtasksDiff;
|
|
340
|
+
const publishDependenciesDiff = shouldSuppressDiff?.dependencies ?? dependenciesDiff;
|
|
341
|
+
|
|
342
|
+
const hasChanges = hasDiffChanges(publishEpicsDiff, publishTasksDiff, publishSubtasksDiff, publishDependenciesDiff);
|
|
212
343
|
|
|
213
344
|
lastSnapshot = fresh;
|
|
214
345
|
|
|
@@ -219,14 +350,14 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
|
|
|
219
350
|
options.eventBus.publishSnapshotDelta({
|
|
220
351
|
generatedAt: Date.now(),
|
|
221
352
|
source: "wal-watcher",
|
|
222
|
-
epics:
|
|
223
|
-
tasks:
|
|
224
|
-
subtasks:
|
|
225
|
-
dependencies:
|
|
226
|
-
deletedEpicIds:
|
|
227
|
-
deletedTaskIds:
|
|
228
|
-
deletedSubtaskIds:
|
|
229
|
-
deletedDependencyIds:
|
|
353
|
+
epics: publishEpicsDiff.upserted,
|
|
354
|
+
tasks: publishTasksDiff.upserted,
|
|
355
|
+
subtasks: publishSubtasksDiff.upserted,
|
|
356
|
+
dependencies: publishDependenciesDiff.upserted,
|
|
357
|
+
deletedEpicIds: publishEpicsDiff.deletedIds,
|
|
358
|
+
deletedTaskIds: publishTasksDiff.deletedIds,
|
|
359
|
+
deletedSubtaskIds: publishSubtasksDiff.deletedIds,
|
|
360
|
+
deletedDependencyIds: publishDependenciesDiff.deletedIds,
|
|
230
361
|
});
|
|
231
362
|
} catch (error) {
|
|
232
363
|
// Reconciliation must never crash the server. Errors here usually mean
|
package/src/commands/board.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { parseArgs, readUnexpectedPositionals } from "./arg-parser";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { resolveBoardAssetRoot, type BoardAssetRoot, type ResolveBoardAssetRootOptions } from "../board/asset-root";
|
|
4
4
|
import { openBoardInBrowser, type OpenBrowserResult } from "../board/open-browser";
|
|
5
5
|
import { startBoardServer, type BoardServerInfo } from "../board/server";
|
|
6
|
-
import {
|
|
6
|
+
import { BoardAssetError } from "../board/types";
|
|
7
7
|
import { failResult, okResult } from "../io/output";
|
|
8
8
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
9
9
|
|
|
@@ -11,26 +11,23 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
|
11
11
|
// command modules: each subcommand declares the set of flags (and options)
|
|
12
12
|
// it accepts; everything else is rejected up-front.
|
|
13
13
|
const OPEN_FLAGS = ["reveal-token"] as const;
|
|
14
|
-
const UPDATE_FLAGS: readonly string[] = [];
|
|
15
14
|
|
|
16
15
|
const FLAGS_BY_SUBCOMMAND: Readonly<Record<string, readonly string[]>> = {
|
|
17
16
|
open: OPEN_FLAGS,
|
|
18
|
-
update: UPDATE_FLAGS,
|
|
19
17
|
};
|
|
20
18
|
|
|
21
|
-
type
|
|
22
|
-
type StartBoardServerFn = (options: { cwd: string }) => BoardServerInfo;
|
|
19
|
+
type ResolveAssetRootFn = (options: ResolveBoardAssetRootOptions) => BoardAssetRoot;
|
|
20
|
+
type StartBoardServerFn = (options: { cwd: string; assetRootOverride?: string }) => BoardServerInfo;
|
|
23
21
|
type OpenBoardInBrowserFn = (url: string) => Promise<OpenBrowserResult> | OpenBrowserResult;
|
|
24
22
|
|
|
25
|
-
let
|
|
26
|
-
let updateInstalledImpl: EnsureBoardInstalledFn = updateBoardInstallation;
|
|
23
|
+
let resolveAssetRootImpl: ResolveAssetRootFn = resolveBoardAssetRoot;
|
|
27
24
|
let startBoardServerImpl: StartBoardServerFn = (options) => startBoardServer(options);
|
|
28
25
|
let openBoardInBrowserImpl: OpenBoardInBrowserFn = openBoardInBrowser;
|
|
29
26
|
|
|
30
27
|
function usageResult(): CliResult {
|
|
31
28
|
return failResult({
|
|
32
29
|
command: "board",
|
|
33
|
-
human: "Usage: trekoon board <open
|
|
30
|
+
human: "Usage: trekoon board <open>",
|
|
34
31
|
data: {},
|
|
35
32
|
error: {
|
|
36
33
|
code: "invalid_subcommand",
|
|
@@ -39,15 +36,7 @@ function usageResult(): CliResult {
|
|
|
39
36
|
});
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
function
|
|
43
|
-
const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
|
|
44
|
-
return {
|
|
45
|
-
workingDirectory: context.cwd,
|
|
46
|
-
...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function boardInstallFailure(command: string, error: BoardInstallError): CliResult {
|
|
39
|
+
function boardAssetFailure(command: string, error: BoardAssetError): CliResult {
|
|
51
40
|
return failResult({
|
|
52
41
|
command,
|
|
53
42
|
human: error.message,
|
|
@@ -63,13 +52,11 @@ function boardInstallFailure(command: string, error: BoardInstallError): CliResu
|
|
|
63
52
|
}
|
|
64
53
|
|
|
65
54
|
export function setBoardCommandHooksForTests(hooks: {
|
|
66
|
-
|
|
67
|
-
updateInstalled?: EnsureBoardInstalledFn;
|
|
55
|
+
resolveAssetRoot?: ResolveAssetRootFn;
|
|
68
56
|
startBoardServer?: StartBoardServerFn;
|
|
69
57
|
openBoardInBrowser?: OpenBoardInBrowserFn;
|
|
70
58
|
} | null): void {
|
|
71
|
-
|
|
72
|
-
updateInstalledImpl = hooks?.updateInstalled ?? updateBoardInstallation;
|
|
59
|
+
resolveAssetRootImpl = hooks?.resolveAssetRoot ?? resolveBoardAssetRoot;
|
|
73
60
|
startBoardServerImpl = hooks?.startBoardServer ?? ((options) => startBoardServer(options));
|
|
74
61
|
openBoardInBrowserImpl = hooks?.openBoardInBrowser ?? openBoardInBrowser;
|
|
75
62
|
}
|
|
@@ -121,21 +108,12 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
|
|
|
121
108
|
|
|
122
109
|
try {
|
|
123
110
|
switch (subcommand) {
|
|
124
|
-
case "update": {
|
|
125
|
-
const install = updateInstalledImpl(boardInstallOptions(context));
|
|
126
|
-
return okResult({
|
|
127
|
-
command: "board.update",
|
|
128
|
-
human: `Board assets ${install.action} at ${install.paths.runtimeRoot}`,
|
|
129
|
-
data: {
|
|
130
|
-
action: install.action,
|
|
131
|
-
paths: install.paths,
|
|
132
|
-
manifest: install.manifest,
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
111
|
case "open": {
|
|
137
|
-
const
|
|
138
|
-
const server = startBoardServerImpl({
|
|
112
|
+
const assetRoot = resolveAssetRootImpl({});
|
|
113
|
+
const server = startBoardServerImpl({
|
|
114
|
+
cwd: context.cwd,
|
|
115
|
+
assetRootOverride: assetRoot.assetRoot,
|
|
116
|
+
});
|
|
139
117
|
const launch = await openBoardInBrowserImpl(server.url);
|
|
140
118
|
const humanLines: string[] = [
|
|
141
119
|
`Board ready at ${server.fallbackUrl}`,
|
|
@@ -170,10 +148,10 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
|
|
|
170
148
|
command: "board.open",
|
|
171
149
|
human: humanLines.join("\n"),
|
|
172
150
|
data: {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
151
|
+
assetRoot: {
|
|
152
|
+
path: assetRoot.assetRoot,
|
|
153
|
+
entryFile: assetRoot.entryFile,
|
|
154
|
+
source: assetRoot.source,
|
|
177
155
|
},
|
|
178
156
|
server: serverData,
|
|
179
157
|
launch: launchData,
|
|
@@ -184,8 +162,8 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
|
|
|
184
162
|
return usageResult();
|
|
185
163
|
}
|
|
186
164
|
} catch (error: unknown) {
|
|
187
|
-
if (error instanceof
|
|
188
|
-
return
|
|
165
|
+
if (error instanceof BoardAssetError) {
|
|
166
|
+
return boardAssetFailure(`board.${subcommand}`, error);
|
|
189
167
|
}
|
|
190
168
|
|
|
191
169
|
throw error;
|
package/src/commands/help.ts
CHANGED
|
@@ -22,7 +22,7 @@ const ROOT_HELP = [
|
|
|
22
22
|
" init Create or re-bootstrap .trekoon storage",
|
|
23
23
|
" quickstart Storage model, agent loop, and common patterns",
|
|
24
24
|
" wipe Delete repo-wide .trekoon state (requires --yes)",
|
|
25
|
-
" board Open
|
|
25
|
+
" board Open the local board",
|
|
26
26
|
" epic Create, list, update, search, and track epics",
|
|
27
27
|
" task Create, list, update, search, and complete tasks",
|
|
28
28
|
" subtask Create, list, update, and search subtasks",
|
|
@@ -89,12 +89,12 @@ const WIPE_HELP = [
|
|
|
89
89
|
].join("\n");
|
|
90
90
|
|
|
91
91
|
const BOARD_HELP = [
|
|
92
|
-
"Usage: trekoon board <open
|
|
92
|
+
"Usage: trekoon board <open>",
|
|
93
93
|
"",
|
|
94
94
|
"Subcommands:",
|
|
95
|
-
" open
|
|
96
|
-
"
|
|
97
|
-
"
|
|
95
|
+
" open Start a local board server on 127.0.0.1 and open the browser.",
|
|
96
|
+
" Returns the board URL and a fallback URL. Board assets are served",
|
|
97
|
+
" directly from the running Trekoon package; no per-repo install.",
|
|
98
98
|
"",
|
|
99
99
|
"Token visibility:",
|
|
100
100
|
" By default the board token is redacted from machine output (shown as ****).",
|
|
@@ -108,7 +108,6 @@ const BOARD_HELP = [
|
|
|
108
108
|
"Examples:",
|
|
109
109
|
" trekoon board open",
|
|
110
110
|
" trekoon board open --reveal-token",
|
|
111
|
-
" trekoon --json board update",
|
|
112
111
|
].join("\n");
|
|
113
112
|
|
|
114
113
|
const EPIC_HELP = [
|
package/src/commands/init.ts
CHANGED
|
@@ -3,8 +3,6 @@ import { resolve } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { unexpectedFailureResult } from "./error-utils";
|
|
5
5
|
|
|
6
|
-
import { ensureBoardInstalled } from "../board/install";
|
|
7
|
-
import { BoardInstallError } from "../board/types";
|
|
8
6
|
import { DomainError } from "../domain/types";
|
|
9
7
|
import { failResult, okResult } from "../io/output";
|
|
10
8
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
@@ -108,11 +106,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
108
106
|
try {
|
|
109
107
|
database = openTrekoonDatabase(context.cwd);
|
|
110
108
|
const diagnostics = database.diagnostics;
|
|
111
|
-
const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
|
|
112
|
-
const board = ensureBoardInstalled({
|
|
113
|
-
workingDirectory: context.cwd,
|
|
114
|
-
...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
|
|
115
|
-
});
|
|
116
109
|
const gitignoreAction: GitignoreAction = ensureGitignore(
|
|
117
110
|
database.paths.storageDir,
|
|
118
111
|
diagnostics.storageMode,
|
|
@@ -125,8 +118,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
125
118
|
`Shared storage root: ${diagnostics.sharedStorageRoot}`,
|
|
126
119
|
`Storage directory: ${database.paths.storageDir}`,
|
|
127
120
|
`Database file: ${database.paths.databaseFile}`,
|
|
128
|
-
`Board assets: ${board.action}`,
|
|
129
|
-
`Board runtime root: ${board.paths.runtimeRoot}`,
|
|
130
121
|
`Gitignore: ${gitignoreAction}`,
|
|
131
122
|
...buildRecoverySummary(database),
|
|
132
123
|
];
|
|
@@ -142,11 +133,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
142
133
|
sharedStorageRoot: diagnostics.sharedStorageRoot,
|
|
143
134
|
storageDir: database.paths.storageDir,
|
|
144
135
|
databaseFile: database.paths.databaseFile,
|
|
145
|
-
board: {
|
|
146
|
-
action: board.action,
|
|
147
|
-
paths: board.paths,
|
|
148
|
-
manifest: board.manifest,
|
|
149
|
-
},
|
|
150
136
|
gitignore: {
|
|
151
137
|
action: gitignoreAction,
|
|
152
138
|
path: resolve(database.paths.storageDir, ".gitignore"),
|
|
@@ -170,21 +156,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
170
156
|
}
|
|
171
157
|
}
|
|
172
158
|
|
|
173
|
-
if (error instanceof BoardInstallError) {
|
|
174
|
-
return failResult({
|
|
175
|
-
command: "init",
|
|
176
|
-
human: error.message,
|
|
177
|
-
data: {
|
|
178
|
-
code: error.code,
|
|
179
|
-
...error.details,
|
|
180
|
-
},
|
|
181
|
-
error: {
|
|
182
|
-
code: error.code,
|
|
183
|
-
message: error.message,
|
|
184
|
-
},
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
159
|
return unexpectedFailureResult(error, {
|
|
189
160
|
command: "init",
|
|
190
161
|
human: "Unexpected init command failure",
|
|
@@ -32,7 +32,7 @@ const QUICKSTART_TEXT = [
|
|
|
32
32
|
" stop and fix the setup before continuing.",
|
|
33
33
|
"",
|
|
34
34
|
" Manual bootstrap (step by step):",
|
|
35
|
-
" trekoon --toon init",
|
|
35
|
+
" trekoon --toon init # creates .trekoon/ shared storage (no board assets copied)",
|
|
36
36
|
" trekoon --toon sync status",
|
|
37
37
|
" trekoon --toon task next",
|
|
38
38
|
"",
|
package/src/runtime/version.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
3
|
interface PackageManifest {
|
|
4
4
|
readonly version?: string;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
function readCliVersion(): string {
|
|
8
|
-
const packageJsonPath =
|
|
8
|
+
const packageJsonPath = [
|
|
9
|
+
new URL("../../package.json", import.meta.url),
|
|
10
|
+
new URL("./package.json", import.meta.url),
|
|
11
|
+
].find((candidate: URL): boolean => existsSync(candidate));
|
|
12
|
+
|
|
13
|
+
if (packageJsonPath === undefined) {
|
|
14
|
+
throw new Error("package.json is missing from the Trekoon runtime bundle.");
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
const packageJsonContent: string = readFileSync(packageJsonPath, "utf8");
|
|
10
18
|
const packageManifest: PackageManifest = JSON.parse(packageJsonContent) as PackageManifest;
|
|
11
19
|
const version: string | undefined = packageManifest.version;
|
package/src/storage/path.ts
CHANGED
|
@@ -4,9 +4,6 @@ import { resolve } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
export const TREKOON_STORAGE_DIRNAME = ".trekoon";
|
|
6
6
|
export const TREKOON_DATABASE_FILENAME = "trekoon.db";
|
|
7
|
-
export const TREKOON_BOARD_DIRNAME = "board";
|
|
8
|
-
export const TREKOON_BOARD_ENTRY_FILENAME = "index.html";
|
|
9
|
-
export const TREKOON_BOARD_MANIFEST_FILENAME = "manifest.json";
|
|
10
7
|
|
|
11
8
|
export function resolveLegacyWorktreeStorageDir(worktreeRoot: string): string {
|
|
12
9
|
return resolve(worktreeRoot, TREKOON_STORAGE_DIRNAME);
|
|
@@ -18,18 +15,6 @@ export function resolveLegacyWorktreeDatabaseFile(worktreeRoot: string): string
|
|
|
18
15
|
|
|
19
16
|
export type StorageMode = "cwd" | "git_common_dir";
|
|
20
17
|
|
|
21
|
-
export function resolveBoardStorageDir(storageDir: string): string {
|
|
22
|
-
return resolve(storageDir, TREKOON_BOARD_DIRNAME);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function resolveBoardEntryFile(storageDir: string): string {
|
|
26
|
-
return resolve(resolveBoardStorageDir(storageDir), TREKOON_BOARD_ENTRY_FILENAME);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function resolveBoardManifestFile(storageDir: string): string {
|
|
30
|
-
return resolve(resolveBoardStorageDir(storageDir), TREKOON_BOARD_MANIFEST_FILENAME);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
18
|
export interface StoragePaths {
|
|
34
19
|
readonly invocationCwd: string;
|
|
35
20
|
readonly storageMode: StorageMode;
|
|
@@ -38,9 +23,6 @@ export interface StoragePaths {
|
|
|
38
23
|
readonly sharedStorageRoot: string;
|
|
39
24
|
readonly storageDir: string;
|
|
40
25
|
readonly databaseFile: string;
|
|
41
|
-
readonly boardDir: string;
|
|
42
|
-
readonly boardEntryFile: string;
|
|
43
|
-
readonly boardManifestFile: string;
|
|
44
26
|
readonly diagnostics: StoragePathDiagnostics;
|
|
45
27
|
}
|
|
46
28
|
|
|
@@ -53,9 +35,6 @@ export interface StoragePathIssue {
|
|
|
53
35
|
readonly worktreeRoot: string;
|
|
54
36
|
readonly sharedStorageRoot: string;
|
|
55
37
|
readonly databaseFile: string;
|
|
56
|
-
readonly boardDir: string;
|
|
57
|
-
readonly boardEntryFile: string;
|
|
58
|
-
readonly boardManifestFile: string;
|
|
59
38
|
}
|
|
60
39
|
|
|
61
40
|
export interface StoragePathDiagnostics {
|
|
@@ -65,9 +44,6 @@ export interface StoragePathDiagnostics {
|
|
|
65
44
|
readonly worktreeRoot: string;
|
|
66
45
|
readonly sharedStorageRoot: string;
|
|
67
46
|
readonly databaseFile: string;
|
|
68
|
-
readonly boardDir: string;
|
|
69
|
-
readonly boardEntryFile: string;
|
|
70
|
-
readonly boardManifestFile: string;
|
|
71
47
|
readonly warnings: readonly StoragePathIssue[];
|
|
72
48
|
readonly errors: readonly StoragePathIssue[];
|
|
73
49
|
}
|
|
@@ -115,9 +91,6 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
|
|
|
115
91
|
const sharedStorageRoot: string = repoCommonDir ? realpathSync(resolve(repoCommonDir, "..")) : invocationCwd;
|
|
116
92
|
const storageDir: string = resolve(sharedStorageRoot, TREKOON_STORAGE_DIRNAME);
|
|
117
93
|
const databaseFile: string = resolve(storageDir, TREKOON_DATABASE_FILENAME);
|
|
118
|
-
const boardDir: string = resolveBoardStorageDir(storageDir);
|
|
119
|
-
const boardEntryFile: string = resolveBoardEntryFile(storageDir);
|
|
120
|
-
const boardManifestFile: string = resolveBoardManifestFile(storageDir);
|
|
121
94
|
const warnings: StoragePathIssue[] = [];
|
|
122
95
|
|
|
123
96
|
const createIssue = (code: string, message: string): StoragePathIssue => ({
|
|
@@ -129,9 +102,6 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
|
|
|
129
102
|
worktreeRoot,
|
|
130
103
|
sharedStorageRoot,
|
|
131
104
|
databaseFile,
|
|
132
|
-
boardDir,
|
|
133
|
-
boardEntryFile,
|
|
134
|
-
boardManifestFile,
|
|
135
105
|
});
|
|
136
106
|
|
|
137
107
|
if (invocationCwd !== worktreeRoot) {
|
|
@@ -156,9 +126,6 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
|
|
|
156
126
|
worktreeRoot,
|
|
157
127
|
sharedStorageRoot,
|
|
158
128
|
databaseFile,
|
|
159
|
-
boardDir,
|
|
160
|
-
boardEntryFile,
|
|
161
|
-
boardManifestFile,
|
|
162
129
|
warnings,
|
|
163
130
|
errors: [],
|
|
164
131
|
};
|
|
@@ -171,9 +138,6 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
|
|
|
171
138
|
sharedStorageRoot,
|
|
172
139
|
storageDir,
|
|
173
140
|
databaseFile,
|
|
174
|
-
boardDir,
|
|
175
|
-
boardEntryFile,
|
|
176
|
-
boardManifestFile,
|
|
177
141
|
diagnostics,
|
|
178
142
|
};
|
|
179
143
|
|
package/src/board/install.ts
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
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
|
-
}
|