@starlove/orbit 1.0.1
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/.dockerignore +13 -0
- package/Dockerfile +34 -0
- package/README.md +216 -0
- package/SKILL-ORBIT.md +94 -0
- package/build.mjs +204 -0
- package/dist/full/apple-touch-icon.png +0 -0
- package/dist/full/cursors/live_arrow.cur +0 -0
- package/dist/full/cursors/live_link.cur +0 -0
- package/dist/full/cursors/live_move.cur +0 -0
- package/dist/full/cursors/live_select.cur +0 -0
- package/dist/full/cursors/live_unavail.cur +0 -0
- package/dist/full/favicon-32x32.png +0 -0
- package/dist/full/index.html +314 -0
- package/dist/full/js/app.70be7d06.js +554 -0
- package/dist/full/js/app.70be7d06.js.map +7 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.c2e3d20a.css +4892 -0
- package/dist/full/version.json +6 -0
- package/docs/AGENT_PROTOCOL.md +379 -0
- package/docs/MCP_SETUP.md +49 -0
- package/docs/mcp-client.example.json +13 -0
- package/package.json +62 -0
- package/pnpm-workspace.yaml +2 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/index.html +314 -0
- package/public/js/api.js +35 -0
- package/public/js/app.js +35 -0
- package/public/js/board-menu.js +145 -0
- package/public/js/config.js +19 -0
- package/public/js/create-card.js +153 -0
- package/public/js/dom.js +19 -0
- package/public/js/drawer.js +137 -0
- package/public/js/format.js +108 -0
- package/public/js/kanban.js +475 -0
- package/public/js/local-backend.js +934 -0
- package/public/js/main.js +142 -0
- package/public/js/modal.js +31 -0
- package/public/js/router.js +114 -0
- package/public/js/search.js +53 -0
- package/public/js/settings-tabs.js +7 -0
- package/public/js/settings.js +931 -0
- package/public/js/sse.js +156 -0
- package/public/js/starscape.js +315 -0
- package/public/js/state.js +54 -0
- package/public/js/ticket-detail.js +766 -0
- package/public/js/toast.js +74 -0
- package/public/js/transport.js +22 -0
- package/public/js/unread.js +121 -0
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +4892 -0
- package/src/cli/orbit.js +438 -0
- package/src/core/agent.js +262 -0
- package/src/core/auth.js +49 -0
- package/src/core/backups.js +136 -0
- package/src/core/board.js +112 -0
- package/src/core/boards.js +310 -0
- package/src/core/bootstrap.js +162 -0
- package/src/core/db.js +237 -0
- package/src/core/delete-board.js +53 -0
- package/src/core/events.js +314 -0
- package/src/core/http.js +81 -0
- package/src/core/paths.js +25 -0
- package/src/core/provision-repo-board.js +113 -0
- package/src/core/queries.js +246 -0
- package/src/core/registry.js +207 -0
- package/src/core/relations.js +163 -0
- package/src/core/router.js +414 -0
- package/src/core/search.js +70 -0
- package/src/core/seed.js +226 -0
- package/src/core/snapshots.js +255 -0
- package/src/core/states.js +167 -0
- package/src/core/system-picker.js +93 -0
- package/src/core/tickets.js +344 -0
- package/src/core/util.js +105 -0
- package/src/mcp-server.js +773 -0
- package/src/server.js +48 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Event recording and SSE fan-out. `recordEvent` is always called with the
|
|
2
|
+
// per-board `db` and the owning `boardId`, so events get written to the
|
|
3
|
+
// right per-board DB and SSE listeners only see events for the board they
|
|
4
|
+
// subscribed to.
|
|
5
|
+
//
|
|
6
|
+
// Same-process HTTP mutations can broadcast immediately. MCP/CLI tools mutate
|
|
7
|
+
// the same per-board SQLite DB from separate Node processes, so the web server
|
|
8
|
+
// also keeps a lightweight per-board watcher while SSE clients are connected.
|
|
9
|
+
// Watchers use SQLite `PRAGMA data_version` as the cheap "did another
|
|
10
|
+
// connection commit?" check, then drain semantic rows from the `events` table.
|
|
11
|
+
|
|
12
|
+
import { localOwnerActor } from "./auth.js";
|
|
13
|
+
import { getBoardByRegistryId, openBoardDb } from "./registry.js";
|
|
14
|
+
import { sendJson, setCors } from "./http.js";
|
|
15
|
+
import { id, now } from "./util.js";
|
|
16
|
+
|
|
17
|
+
const sseClients = new Set();
|
|
18
|
+
const boardWatchers = new Map();
|
|
19
|
+
const DEFAULT_SSE_POLL_MS = 500;
|
|
20
|
+
const MIN_SSE_POLL_MS = 25;
|
|
21
|
+
const MAX_SSE_POLL_MS = 30_000;
|
|
22
|
+
const EVENT_BATCH_LIMIT = 200;
|
|
23
|
+
// If a client's socket buffers more than this, it's not keeping up. We end
|
|
24
|
+
// the stream so the browser reconnects and replays from Last-Event-ID rather
|
|
25
|
+
// than letting Node's write buffer grow without bound.
|
|
26
|
+
const SSE_MAX_BUFFERED_BYTES = 1_000_000;
|
|
27
|
+
|
|
28
|
+
// Events written inside a tx() are not visible to other connections until
|
|
29
|
+
// COMMIT, so broadcasting them immediately would tell SSE listeners about a
|
|
30
|
+
// mutation that might still roll back. db.tx() brackets each transaction with
|
|
31
|
+
// __enterEventTx/__settleEventTx; while depth > 0 we queue and only fan out
|
|
32
|
+
// (or drop, on rollback) once the outermost transaction settles.
|
|
33
|
+
let eventTxDepth = 0;
|
|
34
|
+
const bufferedBroadcasts = [];
|
|
35
|
+
|
|
36
|
+
export function __enterEventTx() {
|
|
37
|
+
eventTxDepth += 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function __settleEventTx(committed) {
|
|
41
|
+
eventTxDepth -= 1;
|
|
42
|
+
if (eventTxDepth > 0) return;
|
|
43
|
+
const queued = bufferedBroadcasts.splice(0, bufferedBroadcasts.length);
|
|
44
|
+
if (!committed) return;
|
|
45
|
+
for (const item of queued) {
|
|
46
|
+
broadcastSSE(item.payload);
|
|
47
|
+
advanceWatcherCursorByRowid(item.boardId, item.rowid);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function recordEvent(db, boardId, type, ticketId, actor, body) {
|
|
52
|
+
const eventId = id();
|
|
53
|
+
const timestamp = now();
|
|
54
|
+
const result = db.prepare(
|
|
55
|
+
"INSERT INTO events (id, ticket_id, actor, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
56
|
+
).run(eventId, ticketId, actor, type, JSON.stringify(body || {}), timestamp);
|
|
57
|
+
|
|
58
|
+
const payload = {
|
|
59
|
+
id: eventId,
|
|
60
|
+
type,
|
|
61
|
+
ticket_id: ticketId,
|
|
62
|
+
board_id: boardId,
|
|
63
|
+
actor,
|
|
64
|
+
data: body || {},
|
|
65
|
+
timestamp
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (eventTxDepth > 0) {
|
|
69
|
+
bufferedBroadcasts.push({ payload, boardId, rowid: result.lastInsertRowid });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
broadcastSSE(payload);
|
|
73
|
+
advanceWatcherCursorByRowid(boardId, result.lastInsertRowid);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function startSSEStream(req, res, url) {
|
|
77
|
+
const actor = localOwnerActor();
|
|
78
|
+
|
|
79
|
+
const boardId = url.searchParams.get("board_id") || null;
|
|
80
|
+
let board = null;
|
|
81
|
+
if (boardId) {
|
|
82
|
+
board = getBoardByRegistryId(boardId);
|
|
83
|
+
if (!board) {
|
|
84
|
+
sendJson(res, 404, { error: "board_not_found" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const lastEventId = req.headers["last-event-id"] || url.searchParams.get("last_event_id") || null;
|
|
90
|
+
const watcher = board ? getOrCreateBoardWatcher(board) : null;
|
|
91
|
+
|
|
92
|
+
setCors(res);
|
|
93
|
+
res.writeHead(200, {
|
|
94
|
+
"Content-Type": "text/event-stream",
|
|
95
|
+
"Cache-Control": "no-cache",
|
|
96
|
+
Connection: "keep-alive",
|
|
97
|
+
"X-Accel-Buffering": "no"
|
|
98
|
+
});
|
|
99
|
+
res.write(":ok\n\n");
|
|
100
|
+
|
|
101
|
+
if (lastEventId && board) {
|
|
102
|
+
replayEventsForBoard(res, board, lastEventId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const heartbeat = setInterval(() => {
|
|
106
|
+
if (!res.writableEnded) res.write(":heartbeat\n\n");
|
|
107
|
+
}, 30_000);
|
|
108
|
+
|
|
109
|
+
const client = { res, boardId, boardRow: board, actor, heartbeat, watcher };
|
|
110
|
+
sseClients.add(client);
|
|
111
|
+
if (watcher) watcher.clients.add(client);
|
|
112
|
+
|
|
113
|
+
req.on("close", () => {
|
|
114
|
+
clearInterval(heartbeat);
|
|
115
|
+
sseClients.delete(client);
|
|
116
|
+
if (watcher) {
|
|
117
|
+
watcher.clients.delete(client);
|
|
118
|
+
stopBoardWatcherIfIdle(watcher);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getOrCreateBoardWatcher(boardRow) {
|
|
124
|
+
const existing = boardWatchers.get(boardRow.id);
|
|
125
|
+
if (existing) {
|
|
126
|
+
existing.boardRow = boardRow;
|
|
127
|
+
return existing;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const watcher = {
|
|
131
|
+
boardId: boardRow.id,
|
|
132
|
+
boardRow,
|
|
133
|
+
clients: new Set(),
|
|
134
|
+
dataVersion: dataVersionForBoard(boardRow),
|
|
135
|
+
cursor: latestEventCursor(boardRow),
|
|
136
|
+
timer: null
|
|
137
|
+
};
|
|
138
|
+
watcher.timer = setInterval(() => pollBoardWatcher(watcher), ssePollIntervalMs());
|
|
139
|
+
watcher.timer.unref?.();
|
|
140
|
+
boardWatchers.set(boardRow.id, watcher);
|
|
141
|
+
return watcher;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function stopBoardWatcherIfIdle(watcher) {
|
|
145
|
+
if (watcher.clients.size > 0) return;
|
|
146
|
+
clearInterval(watcher.timer);
|
|
147
|
+
boardWatchers.delete(watcher.boardId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pollBoardWatcher(watcher) {
|
|
151
|
+
if (watcher.clients.size === 0) {
|
|
152
|
+
stopBoardWatcherIfIdle(watcher);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let nextDataVersion;
|
|
157
|
+
try {
|
|
158
|
+
nextDataVersion = dataVersionForBoard(watcher.boardRow);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
console.warn(`[orbit] Failed to read data_version for board ${watcher.boardId}: ${error.message}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (nextDataVersion === watcher.dataVersion) return;
|
|
165
|
+
watcher.dataVersion = nextDataVersion;
|
|
166
|
+
drainBoardWatcherEvents(watcher);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function drainBoardWatcherEvents(watcher) {
|
|
170
|
+
try {
|
|
171
|
+
while (true) {
|
|
172
|
+
const rows = eventsAfterCursor(watcher.boardRow, watcher.cursor);
|
|
173
|
+
if (rows.length === 0) return;
|
|
174
|
+
|
|
175
|
+
for (const row of rows) {
|
|
176
|
+
const event = eventFromRow(watcher.boardRow, row);
|
|
177
|
+
for (const client of watcher.clients) {
|
|
178
|
+
writeSSEFrame(client.res, event);
|
|
179
|
+
}
|
|
180
|
+
watcher.cursor = eventCursor(row);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (rows.length < EVENT_BATCH_LIMIT) return;
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.warn(`[orbit] Failed to poll events for board ${watcher.boardId}: ${error.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ssePollIntervalMs() {
|
|
191
|
+
const raw = process.env.ORBIT_SSE_POLL_MS;
|
|
192
|
+
const value = raw === undefined ? DEFAULT_SSE_POLL_MS : Number(raw);
|
|
193
|
+
if (!Number.isFinite(value) || value <= 0) return DEFAULT_SSE_POLL_MS;
|
|
194
|
+
return Math.min(MAX_SSE_POLL_MS, Math.max(MIN_SSE_POLL_MS, Math.trunc(value)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function dataVersionForBoard(boardRow) {
|
|
198
|
+
const row = openBoardDb(boardRow).prepare("PRAGMA data_version").get();
|
|
199
|
+
return Number(row?.data_version || 0);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function replayEventsForBoard(res, boardRow, afterId) {
|
|
203
|
+
const db = openBoardDb(boardRow);
|
|
204
|
+
const anchor = db.prepare("SELECT rowid AS event_rowid FROM events WHERE id = ?").get(afterId);
|
|
205
|
+
if (!anchor) return latestEventCursor(boardRow);
|
|
206
|
+
|
|
207
|
+
let cursor = eventCursor(anchor);
|
|
208
|
+
const rows = db
|
|
209
|
+
.prepare(
|
|
210
|
+
`SELECT e.rowid AS event_rowid, e.id, e.type, e.ticket_id, e.actor, e.body_json, e.created_at
|
|
211
|
+
FROM events e
|
|
212
|
+
WHERE e.rowid > ?
|
|
213
|
+
ORDER BY e.rowid LIMIT ${EVENT_BATCH_LIMIT}`
|
|
214
|
+
)
|
|
215
|
+
.all(anchor.event_rowid);
|
|
216
|
+
|
|
217
|
+
for (const row of rows) {
|
|
218
|
+
const event = eventFromRow(boardRow, row);
|
|
219
|
+
writeSSEFrame(res, event);
|
|
220
|
+
cursor = eventCursor(row);
|
|
221
|
+
}
|
|
222
|
+
return cursor;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function latestEventCursor(boardRow) {
|
|
226
|
+
const db = openBoardDb(boardRow);
|
|
227
|
+
const row = db
|
|
228
|
+
.prepare("SELECT rowid AS event_rowid FROM events ORDER BY rowid DESC LIMIT 1")
|
|
229
|
+
.get();
|
|
230
|
+
return eventCursor(row);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function eventCursor(row) {
|
|
234
|
+
if (!row) return null;
|
|
235
|
+
return { event_rowid: Number(row.event_rowid) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function compareCursors(left, right) {
|
|
239
|
+
if (!left && !right) return 0;
|
|
240
|
+
if (!left) return -1;
|
|
241
|
+
if (!right) return 1;
|
|
242
|
+
if (left.event_rowid > right.event_rowid) return 1;
|
|
243
|
+
if (left.event_rowid < right.event_rowid) return -1;
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function eventsAfterCursor(boardRow, cursor) {
|
|
248
|
+
const db = openBoardDb(boardRow);
|
|
249
|
+
if (!cursor) {
|
|
250
|
+
return db
|
|
251
|
+
.prepare(
|
|
252
|
+
`SELECT e.rowid AS event_rowid, e.id, e.type, e.ticket_id, e.actor, e.body_json, e.created_at
|
|
253
|
+
FROM events e
|
|
254
|
+
ORDER BY e.rowid LIMIT ${EVENT_BATCH_LIMIT}`
|
|
255
|
+
)
|
|
256
|
+
.all();
|
|
257
|
+
}
|
|
258
|
+
return db
|
|
259
|
+
.prepare(
|
|
260
|
+
`SELECT e.rowid AS event_rowid, e.id, e.type, e.ticket_id, e.actor, e.body_json, e.created_at
|
|
261
|
+
FROM events e
|
|
262
|
+
WHERE e.rowid > ?
|
|
263
|
+
ORDER BY e.rowid LIMIT ${EVENT_BATCH_LIMIT}`
|
|
264
|
+
)
|
|
265
|
+
.all(cursor.event_rowid);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function eventFromRow(boardRow, row) {
|
|
269
|
+
return {
|
|
270
|
+
id: row.id,
|
|
271
|
+
type: row.type,
|
|
272
|
+
ticket_id: row.ticket_id,
|
|
273
|
+
board_id: boardRow.id,
|
|
274
|
+
actor: row.actor,
|
|
275
|
+
data: parseEventBody(row.body_json),
|
|
276
|
+
timestamp: row.created_at
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseEventBody(bodyJson) {
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(bodyJson || "{}");
|
|
283
|
+
} catch {
|
|
284
|
+
return {};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function advanceWatcherCursorByRowid(boardId, rowid) {
|
|
289
|
+
const watcher = boardWatchers.get(boardId);
|
|
290
|
+
if (!watcher) return;
|
|
291
|
+
const cursor = { event_rowid: Number(rowid) };
|
|
292
|
+
if (cursor && compareCursors(cursor, watcher.cursor) > 0) {
|
|
293
|
+
watcher.cursor = cursor;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function broadcastSSE(event) {
|
|
298
|
+
for (const client of sseClients) {
|
|
299
|
+
if (client.boardId && event.board_id !== client.boardId) continue;
|
|
300
|
+
writeSSEFrame(client.res, event);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function writeSSEFrame(res, event) {
|
|
305
|
+
if (res.writableEnded) return;
|
|
306
|
+
// A backpressured client (closed laptop, dead tab) would otherwise make
|
|
307
|
+
// Node buffer every event in memory forever. Cut it loose; the EventSource
|
|
308
|
+
// client reconnects and replays missed events via Last-Event-ID.
|
|
309
|
+
if (res.writableLength > SSE_MAX_BUFFERED_BYTES) {
|
|
310
|
+
res.end();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
res.write(`id: ${event.id}\nevent: board\ndata: ${JSON.stringify(event)}\n\n`);
|
|
314
|
+
}
|
package/src/core/http.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { extname, resolve as resolvePath } from "node:path";
|
|
3
|
+
import { PUBLIC_DIR } from "./paths.js";
|
|
4
|
+
import { httpError } from "./util.js";
|
|
5
|
+
|
|
6
|
+
export function setCors(res) {
|
|
7
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
8
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
|
9
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sendJson(res, status, body) {
|
|
13
|
+
setCors(res);
|
|
14
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
15
|
+
res.end(JSON.stringify(body, null, 2));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sendEmpty(res, status) {
|
|
19
|
+
setCors(res);
|
|
20
|
+
res.writeHead(status);
|
|
21
|
+
res.end();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readJson(req) {
|
|
25
|
+
const chunks = [];
|
|
26
|
+
let size = 0;
|
|
27
|
+
for await (const chunk of req) {
|
|
28
|
+
size += chunk.length;
|
|
29
|
+
if (size > 1024 * 1024) throw httpError(413, "payload_too_large");
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
}
|
|
32
|
+
if (chunks.length === 0) return {};
|
|
33
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
34
|
+
if (!text.trim()) return {};
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(text);
|
|
37
|
+
} catch {
|
|
38
|
+
throw httpError(400, "invalid_json");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function contentType(filePath) {
|
|
43
|
+
const types = {
|
|
44
|
+
".html": "text/html; charset=utf-8",
|
|
45
|
+
".css": "text/css; charset=utf-8",
|
|
46
|
+
".js": "text/javascript; charset=utf-8",
|
|
47
|
+
".json": "application/json; charset=utf-8",
|
|
48
|
+
".svg": "image/svg+xml"
|
|
49
|
+
};
|
|
50
|
+
return types[extname(filePath)] || "application/octet-stream";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function serveStatic(req, res, pathname) {
|
|
54
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
55
|
+
sendEmpty(res, 405);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const requested = pathname === "/" ? "/index.html" : pathname;
|
|
60
|
+
const filePath = resolvePath(PUBLIC_DIR, `.${decodeURIComponent(requested)}`);
|
|
61
|
+
if (!filePath.startsWith(PUBLIC_DIR)) {
|
|
62
|
+
sendEmpty(res, 403);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const data = await readFile(filePath);
|
|
68
|
+
res.writeHead(200, {
|
|
69
|
+
"Content-Type": contentType(filePath),
|
|
70
|
+
"Cache-Control": "no-store"
|
|
71
|
+
});
|
|
72
|
+
if (req.method !== "HEAD") res.end(data);
|
|
73
|
+
else res.end();
|
|
74
|
+
} catch {
|
|
75
|
+
if (pathname !== "/") {
|
|
76
|
+
await serveStatic(req, res, "/");
|
|
77
|
+
} else {
|
|
78
|
+
sendEmpty(res, 404);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
export const ROOT_DIR = resolve(__dirname, "..", "..");
|
|
9
|
+
// Prefer the pre-built bundle (present in published package); fall back to raw source for dev.
|
|
10
|
+
const _distFull = join(ROOT_DIR, "dist", "full");
|
|
11
|
+
export const PUBLIC_DIR = existsSync(_distFull) ? _distFull : join(ROOT_DIR, "public");
|
|
12
|
+
export const MCP_SERVER_PATH = join(ROOT_DIR, "src", "mcp-server.js");
|
|
13
|
+
/** Repo root for default board placement; cwd unless PROJECT_ROOT is set. */
|
|
14
|
+
export const PROJECT_ROOT = process.env.PROJECT_ROOT ? resolve(process.env.PROJECT_ROOT) : resolve(process.cwd());
|
|
15
|
+
// Store registry + exports in ~/.orbit so global installs don't write into the package dir.
|
|
16
|
+
export const DATA_DIR = process.env.DATA_DIR || join(homedir(), ".orbit");
|
|
17
|
+
/** Central registry of all boards (metadata + paths to per-board SQLite files). */
|
|
18
|
+
export const REGISTRY_DB_PATH = join(DATA_DIR, "registry.db");
|
|
19
|
+
export const EXPORT_DIR = join(DATA_DIR, "exports");
|
|
20
|
+
export const BACKUP_DIR = join(DATA_DIR, "backups");
|
|
21
|
+
export const PORT = Number(process.env.PORT || 3337);
|
|
22
|
+
|
|
23
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
24
|
+
mkdirSync(EXPORT_DIR, { recursive: true });
|
|
25
|
+
mkdirSync(BACKUP_DIR, { recursive: true });
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Side-effect-free workspace provisioning for CLI and server startup.
|
|
2
|
+
// Do not import board.js from here (that module runs full startup at import time).
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { basename, dirname } from "node:path";
|
|
6
|
+
import { createBoardSchema, openConnection } from "./db.js";
|
|
7
|
+
import {
|
|
8
|
+
computeNewBoardDbPath,
|
|
9
|
+
getBoardByRepoPath,
|
|
10
|
+
insertBoard,
|
|
11
|
+
syncRegistryFromBoardDb
|
|
12
|
+
} from "./registry.js";
|
|
13
|
+
import { seedIfEmpty } from "./seed.js";
|
|
14
|
+
import { normalizePath, now, slugify } from "./util.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create or repair the **first** Orbit board for a repo root: `.orbit/board.db`,
|
|
18
|
+
* full seed when empty, and a matching registry row.
|
|
19
|
+
*
|
|
20
|
+
* Idempotent when a registry row already exists for `repo_path`.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} repoPathRaw Absolute or normalized repo directory
|
|
23
|
+
* @returns {{ ok: true, status: "skipped"|"created"|"repaired", registryRow: object|null, message?: string }}
|
|
24
|
+
*/
|
|
25
|
+
export function provisionRepoBoard(repoPathRaw, options = {}) {
|
|
26
|
+
const repoRoot = normalizePath(repoPathRaw);
|
|
27
|
+
const existingReg = getBoardByRepoPath(repoRoot);
|
|
28
|
+
if (existingReg) {
|
|
29
|
+
return {
|
|
30
|
+
ok: true,
|
|
31
|
+
status: "skipped",
|
|
32
|
+
registryRow: existingReg,
|
|
33
|
+
message: "A board is already registered for this repo path."
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const slugHint = slugify(basename(repoRoot) || "board");
|
|
38
|
+
const dbPath = computeNewBoardDbPath(repoRoot, slugHint);
|
|
39
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
40
|
+
|
|
41
|
+
const hadExistingFile = existsSync(dbPath);
|
|
42
|
+
if (hadExistingFile) {
|
|
43
|
+
return provisionExistingDbFile(repoRoot, dbPath, options);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const db = openConnection(dbPath);
|
|
47
|
+
createBoardSchema(db);
|
|
48
|
+
const seeded = seedIfEmpty(db, repoRoot, { includeExamples: options.includeExamples });
|
|
49
|
+
if (!seeded) {
|
|
50
|
+
const boardMeta = readBoardMetaFromSqlite(db);
|
|
51
|
+
if (!boardMeta) {
|
|
52
|
+
throw new Error("provisionRepoBoard: database file exists but contains no board row.");
|
|
53
|
+
}
|
|
54
|
+
return registerOrphanBoardDb(repoRoot, dbPath, db, boardMeta);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return insertAfterSeed(repoRoot, dbPath, db, seeded, "created");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function provisionExistingDbFile(repoRoot, dbPath, options = {}) {
|
|
61
|
+
const db = openConnection(dbPath);
|
|
62
|
+
createBoardSchema(db);
|
|
63
|
+
const seeded = seedIfEmpty(db, repoRoot, { includeExamples: options.includeExamples });
|
|
64
|
+
if (seeded) {
|
|
65
|
+
return insertAfterSeed(repoRoot, dbPath, db, seeded, "repaired");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const boardMeta = readBoardMetaFromSqlite(db);
|
|
69
|
+
if (!boardMeta) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"provisionRepoBoard: .orbit/board.db exists but has no board row — remove or repair the file manually."
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return registerOrphanBoardDb(repoRoot, dbPath, db, boardMeta);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readBoardMetaFromSqlite(db) {
|
|
78
|
+
return db.prepare("SELECT * FROM boards LIMIT 1").get() || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {"created"|"repaired"} outcome
|
|
83
|
+
*/
|
|
84
|
+
function insertAfterSeed(repoRoot, dbPath, db, seeded, outcome) {
|
|
85
|
+
const t = now();
|
|
86
|
+
insertBoard({
|
|
87
|
+
id: seeded.id,
|
|
88
|
+
slug: seeded.slug,
|
|
89
|
+
name: seeded.name,
|
|
90
|
+
repo_path: repoRoot,
|
|
91
|
+
db_path: dbPath,
|
|
92
|
+
repo_url: seeded.repo_url || "",
|
|
93
|
+
default_branch: seeded.default_branch || "main",
|
|
94
|
+
last_active_at: t,
|
|
95
|
+
created_at: t,
|
|
96
|
+
updated_at: t
|
|
97
|
+
});
|
|
98
|
+
const fresh = getBoardByRepoPath(repoRoot);
|
|
99
|
+
if (fresh) syncRegistryFromBoardDb(fresh, db);
|
|
100
|
+
return { ok: true, status: outcome, registryRow: fresh, message: undefined };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function registerOrphanBoardDb(repoRoot, dbPath, db, b) {
|
|
104
|
+
const boardRow = { id: b.id, repo_path: repoRoot, db_path: dbPath };
|
|
105
|
+
syncRegistryFromBoardDb(boardRow, db);
|
|
106
|
+
const fresh = getBoardByRepoPath(repoRoot);
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
status: "repaired",
|
|
110
|
+
registryRow: fresh,
|
|
111
|
+
message: "Registered existing .orbit/board.db with the registry."
|
|
112
|
+
};
|
|
113
|
+
}
|