@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.
Files changed (86) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +34 -0
  3. package/README.md +216 -0
  4. package/SKILL-ORBIT.md +94 -0
  5. package/build.mjs +204 -0
  6. package/dist/full/apple-touch-icon.png +0 -0
  7. package/dist/full/cursors/live_arrow.cur +0 -0
  8. package/dist/full/cursors/live_link.cur +0 -0
  9. package/dist/full/cursors/live_move.cur +0 -0
  10. package/dist/full/cursors/live_select.cur +0 -0
  11. package/dist/full/cursors/live_unavail.cur +0 -0
  12. package/dist/full/favicon-32x32.png +0 -0
  13. package/dist/full/index.html +314 -0
  14. package/dist/full/js/app.70be7d06.js +554 -0
  15. package/dist/full/js/app.70be7d06.js.map +7 -0
  16. package/dist/full/orbits-favicon.png +0 -0
  17. package/dist/full/orbits-logo-bg.png +0 -0
  18. package/dist/full/orbits-logo.png +0 -0
  19. package/dist/full/styles.c2e3d20a.css +4892 -0
  20. package/dist/full/version.json +6 -0
  21. package/docs/AGENT_PROTOCOL.md +379 -0
  22. package/docs/MCP_SETUP.md +49 -0
  23. package/docs/mcp-client.example.json +13 -0
  24. package/package.json +62 -0
  25. package/pnpm-workspace.yaml +2 -0
  26. package/public/apple-touch-icon.png +0 -0
  27. package/public/cursors/live_arrow.cur +0 -0
  28. package/public/cursors/live_link.cur +0 -0
  29. package/public/cursors/live_move.cur +0 -0
  30. package/public/cursors/live_select.cur +0 -0
  31. package/public/cursors/live_unavail.cur +0 -0
  32. package/public/favicon-32x32.png +0 -0
  33. package/public/index.html +314 -0
  34. package/public/js/api.js +35 -0
  35. package/public/js/app.js +35 -0
  36. package/public/js/board-menu.js +145 -0
  37. package/public/js/config.js +19 -0
  38. package/public/js/create-card.js +153 -0
  39. package/public/js/dom.js +19 -0
  40. package/public/js/drawer.js +137 -0
  41. package/public/js/format.js +108 -0
  42. package/public/js/kanban.js +475 -0
  43. package/public/js/local-backend.js +934 -0
  44. package/public/js/main.js +142 -0
  45. package/public/js/modal.js +31 -0
  46. package/public/js/router.js +114 -0
  47. package/public/js/search.js +53 -0
  48. package/public/js/settings-tabs.js +7 -0
  49. package/public/js/settings.js +931 -0
  50. package/public/js/sse.js +156 -0
  51. package/public/js/starscape.js +315 -0
  52. package/public/js/state.js +54 -0
  53. package/public/js/ticket-detail.js +766 -0
  54. package/public/js/toast.js +74 -0
  55. package/public/js/transport.js +22 -0
  56. package/public/js/unread.js +121 -0
  57. package/public/orbits-favicon.png +0 -0
  58. package/public/orbits-logo-bg.png +0 -0
  59. package/public/orbits-logo.png +0 -0
  60. package/public/styles.css +4892 -0
  61. package/src/cli/orbit.js +438 -0
  62. package/src/core/agent.js +262 -0
  63. package/src/core/auth.js +49 -0
  64. package/src/core/backups.js +136 -0
  65. package/src/core/board.js +112 -0
  66. package/src/core/boards.js +310 -0
  67. package/src/core/bootstrap.js +162 -0
  68. package/src/core/db.js +237 -0
  69. package/src/core/delete-board.js +53 -0
  70. package/src/core/events.js +314 -0
  71. package/src/core/http.js +81 -0
  72. package/src/core/paths.js +25 -0
  73. package/src/core/provision-repo-board.js +113 -0
  74. package/src/core/queries.js +246 -0
  75. package/src/core/registry.js +207 -0
  76. package/src/core/relations.js +163 -0
  77. package/src/core/router.js +414 -0
  78. package/src/core/search.js +70 -0
  79. package/src/core/seed.js +226 -0
  80. package/src/core/snapshots.js +255 -0
  81. package/src/core/states.js +167 -0
  82. package/src/core/system-picker.js +93 -0
  83. package/src/core/tickets.js +344 -0
  84. package/src/core/util.js +105 -0
  85. package/src/mcp-server.js +773 -0
  86. package/src/server.js +48 -0
@@ -0,0 +1,162 @@
1
+ import { compactTicket, ticketById } from "./queries.js";
2
+ import { canAccessBoard, publicActor } from "./auth.js";
3
+ import { EXPORT_DIR } from "./paths.js";
4
+ import {
5
+ getBoardByRegistryId,
6
+ listBoards,
7
+ openBoardDb,
8
+ pickDefaultBoard,
9
+ touchBoardActive
10
+ } from "./registry.js";
11
+
12
+ function boardSummaryFromRegistry(r) {
13
+ return {
14
+ id: r.id,
15
+ slug: r.slug,
16
+ name: r.name,
17
+ repo_url: r.repo_url,
18
+ default_branch: r.default_branch,
19
+ repo_path: r.repo_path,
20
+ system_path: r.repo_path,
21
+ project_notes: "",
22
+ agent_instructions: "",
23
+ ai_enabled: 0,
24
+ created_at: r.created_at,
25
+ updated_at: r.updated_at
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Build the bootstrap payload for one board (or just registry metadata if the
31
+ * actor has no accessible boards). No global state is mutated; the chosen
32
+ * board's last_active_at is bumped so subsequent default-pick prefers it.
33
+ *
34
+ * @param actor authenticated actor
35
+ * @param requestedBoardId optional board id from `?board_id=` (or null)
36
+ */
37
+ export function getBootstrap(actor, requestedBoardId = null) {
38
+ const registryRows = listBoards();
39
+ const accessible = registryRows.filter((row) => canAccessBoard(actor, { slug: row.slug }));
40
+
41
+ if (accessible.length === 0) {
42
+ return {
43
+ actor: publicActor(actor),
44
+ meta: {
45
+ db_path: null,
46
+ export_dir: EXPORT_DIR
47
+ },
48
+ boards: [],
49
+ board_entries: [],
50
+ states: [],
51
+ labels: [],
52
+ tickets: []
53
+ };
54
+ }
55
+
56
+ let chosen = null;
57
+ if (requestedBoardId) {
58
+ chosen = accessible.find((row) => row.id === requestedBoardId) || null;
59
+ }
60
+ if (!chosen) {
61
+ const defaultRow = pickDefaultBoard();
62
+ if (defaultRow && accessible.some((row) => row.id === defaultRow.id)) {
63
+ chosen = defaultRow;
64
+ }
65
+ }
66
+ if (!chosen) {
67
+ const sorted = [...accessible].sort(
68
+ (a, b) =>
69
+ String(b.last_active_at || "").localeCompare(String(a.last_active_at || "")) ||
70
+ String(a.name).localeCompare(String(b.name))
71
+ );
72
+ chosen = sorted[0];
73
+ }
74
+
75
+ // Re-fetch in case the chosen row came from pickDefaultBoard before the
76
+ // accessible filter. Either way, this is the row we open.
77
+ const chosenRow = getBoardByRegistryId(chosen.id);
78
+ touchBoardActive(chosenRow.id);
79
+ const db = openBoardDb(chosenRow);
80
+ const innerBoard = db.prepare("SELECT * FROM boards LIMIT 1").get();
81
+
82
+ const boards = accessible.map((r) => {
83
+ if (innerBoard && r.id === innerBoard.id) {
84
+ return {
85
+ ...innerBoard,
86
+ repo_path: r.repo_path,
87
+ system_path: innerBoard.system_path || r.repo_path
88
+ };
89
+ }
90
+ return boardSummaryFromRegistry(r);
91
+ });
92
+
93
+ const boardIds = new Set(innerBoard ? [innerBoard.id] : []);
94
+
95
+ const states = db
96
+ .prepare("SELECT * FROM states ORDER BY position")
97
+ .all()
98
+ .filter((state) => boardIds.has(state.board_id));
99
+
100
+ const tickets = db
101
+ .prepare(
102
+ `SELECT t.*, s.name AS state_name, s.role AS state_role,
103
+ b.slug AS board_slug, b.name AS board_name
104
+ FROM tickets t
105
+ JOIN states s ON s.id = t.state_id
106
+ JOIN boards b ON b.id = t.board_id
107
+ WHERE t.archived_at IS NULL
108
+ ORDER BY t.updated_at DESC`
109
+ )
110
+ .all()
111
+ .filter((ticket) => boardIds.has(ticket.board_id));
112
+
113
+ const labels = db
114
+ .prepare("SELECT * FROM labels ORDER BY name")
115
+ .all()
116
+ .filter((label) => boardIds.has(label.board_id));
117
+
118
+ const ticketLabels = db
119
+ .prepare(
120
+ `SELECT tl.ticket_id, l.id, l.name, l.color
121
+ FROM ticket_labels tl
122
+ JOIN labels l ON l.id = tl.label_id`
123
+ )
124
+ .all()
125
+ .filter((label) => tickets.some((ticket) => ticket.id === label.ticket_id));
126
+
127
+ const commentCounts = db
128
+ .prepare("SELECT ticket_id, COUNT(*) AS count FROM comments GROUP BY ticket_id")
129
+ .all()
130
+ .filter((row) => tickets.some((ticket) => ticket.id === row.ticket_id));
131
+
132
+ const childCounts = db
133
+ .prepare(
134
+ "SELECT parent_ticket_id AS ticket_id, COUNT(*) AS count FROM tickets WHERE parent_ticket_id IS NOT NULL AND archived_at IS NULL GROUP BY parent_ticket_id"
135
+ )
136
+ .all()
137
+ .filter((row) => tickets.some((ticket) => ticket.id === row.ticket_id));
138
+
139
+ const boardEntries = db
140
+ .prepare("SELECT * FROM board_entries ORDER BY created_at DESC")
141
+ .all()
142
+ .filter((entry) => boardIds.has(entry.board_id));
143
+
144
+ return {
145
+ actor: publicActor(actor),
146
+ meta: {
147
+ db_path: chosenRow.db_path,
148
+ export_dir: EXPORT_DIR
149
+ },
150
+ boards,
151
+ board_entries: boardEntries,
152
+ states,
153
+ labels,
154
+ tickets: tickets.map((ticket) => ({
155
+ ...ticket,
156
+ labels: ticketLabels.filter((label) => label.ticket_id === ticket.id),
157
+ comment_count: commentCounts.find((row) => row.ticket_id === ticket.id)?.count || 0,
158
+ child_count: childCounts.find((row) => row.ticket_id === ticket.id)?.count || 0,
159
+ parent_ticket: ticket.parent_ticket_id ? compactTicket(ticketById(db, ticket.parent_ticket_id)) : null
160
+ }))
161
+ };
162
+ }
package/src/core/db.js ADDED
@@ -0,0 +1,237 @@
1
+ // Connection management. There is intentionally no module-level "active board"
2
+ // singleton: every caller resolves a per-board DB connection via openConnection
3
+ // and threads it through. This is what makes the server safe under concurrent
4
+ // HTTP requests for different boards — a global active-pointer would race
5
+ // across requests that interleave around `await readJson`.
6
+
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import { REGISTRY_DB_PATH } from "./paths.js";
9
+ import { __enterEventTx, __settleEventTx } from "./events.js";
10
+
11
+ const connections = new Map();
12
+
13
+ /** Open (or return cached) per-database connection. Safe to share within a process. */
14
+ export function openConnection(dbPath) {
15
+ if (connections.has(dbPath)) return connections.get(dbPath);
16
+ const conn = new DatabaseSync(dbPath);
17
+ conn.exec("PRAGMA foreign_keys = ON;");
18
+ conn.exec("PRAGMA busy_timeout = 5000;");
19
+ conn.exec("PRAGMA journal_mode = WAL;");
20
+ // busy_timeout (set above) makes a second writer from another process (MCP
21
+ // server, CLI) wait instead of failing immediately with SQLITE_BUSY when an
22
+ // agent commit and a UI drag overlap. NORMAL is the recommended
23
+ // durability/throughput tradeoff under WAL (a power-loss can lose the last
24
+ // commit but never corrupts the DB).
25
+ conn.exec("PRAGMA synchronous = NORMAL;");
26
+ connections.set(dbPath, conn);
27
+ return conn;
28
+ }
29
+
30
+ export function closeConnection(dbPath) {
31
+ const conn = connections.get(dbPath);
32
+ if (!conn) return;
33
+ conn.close();
34
+ connections.delete(dbPath);
35
+ }
36
+
37
+ // Reentrancy depth per connection. Only the outermost tx() issues
38
+ // BEGIN/COMMIT/ROLLBACK; inner calls (a tx-wrapped function calling another)
39
+ // fold into the enclosing transaction. Execution is synchronous and a request
40
+ // never awaits between BEGIN and COMMIT, so per-connection depth is race-free.
41
+ const txDepth = new WeakMap();
42
+
43
+ /**
44
+ * Run `fn` inside an IMMEDIATE transaction on `db` so a multi-statement
45
+ * mutation is atomic: it either fully lands or fully rolls back. BEGIN
46
+ * IMMEDIATE takes the write lock up front, which also makes read-then-write
47
+ * sequences (e.g. `MAX(number)+1` ticket numbering) safe against concurrent
48
+ * writer processes. SSE broadcasts for events recorded inside the transaction
49
+ * are buffered and only fan out after COMMIT (see events.js).
50
+ */
51
+ export function tx(db, fn) {
52
+ const depth = txDepth.get(db) || 0;
53
+ if (depth === 0) db.exec("BEGIN IMMEDIATE;");
54
+ txDepth.set(db, depth + 1);
55
+ __enterEventTx();
56
+ try {
57
+ const result = fn();
58
+ const next = txDepth.get(db) - 1;
59
+ txDepth.set(db, next);
60
+ if (next === 0) db.exec("COMMIT;");
61
+ __settleEventTx(true);
62
+ return result;
63
+ } catch (error) {
64
+ const next = txDepth.get(db) - 1;
65
+ txDepth.set(db, next);
66
+ if (next === 0) {
67
+ try {
68
+ db.exec("ROLLBACK;");
69
+ } catch {
70
+ // A failed BEGIN leaves no transaction to roll back; ignore.
71
+ }
72
+ }
73
+ __settleEventTx(false);
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ export function getRegistry() {
79
+ return openConnection(REGISTRY_DB_PATH);
80
+ }
81
+
82
+ /** Registry metadata table (not the per-board `boards` content row). */
83
+ export function createRegistrySchema(reg = getRegistry()) {
84
+ reg.exec(`
85
+ CREATE TABLE IF NOT EXISTS boards (
86
+ id TEXT PRIMARY KEY,
87
+ slug TEXT NOT NULL UNIQUE,
88
+ name TEXT NOT NULL,
89
+ repo_path TEXT NOT NULL DEFAULT '',
90
+ db_path TEXT NOT NULL,
91
+ repo_url TEXT NOT NULL DEFAULT '',
92
+ default_branch TEXT NOT NULL DEFAULT 'main',
93
+ last_active_at TEXT,
94
+ created_at TEXT NOT NULL,
95
+ updated_at TEXT NOT NULL
96
+ );
97
+ CREATE INDEX IF NOT EXISTS idx_registry_boards_repo_path ON boards(repo_path);
98
+ `);
99
+ }
100
+
101
+ /** Full Kanban schema on a per-board database connection. */
102
+ export function createBoardSchema(db) {
103
+ db.exec(`
104
+ CREATE TABLE IF NOT EXISTS boards (
105
+ id TEXT PRIMARY KEY,
106
+ slug TEXT NOT NULL UNIQUE,
107
+ name TEXT NOT NULL,
108
+ repo_url TEXT NOT NULL DEFAULT '',
109
+ system_path TEXT NOT NULL DEFAULT '',
110
+ default_branch TEXT NOT NULL DEFAULT 'main',
111
+ project_notes TEXT NOT NULL DEFAULT '',
112
+ agent_instructions TEXT NOT NULL DEFAULT '',
113
+ ai_enabled INTEGER NOT NULL DEFAULT 0,
114
+ created_at TEXT NOT NULL,
115
+ updated_at TEXT NOT NULL
116
+ );
117
+
118
+ CREATE TABLE IF NOT EXISTS states (
119
+ id TEXT PRIMARY KEY,
120
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
121
+ name TEXT NOT NULL,
122
+ position INTEGER NOT NULL,
123
+ is_default INTEGER NOT NULL DEFAULT 0,
124
+ role TEXT,
125
+ created_at TEXT NOT NULL,
126
+ UNIQUE(board_id, name)
127
+ );
128
+
129
+ CREATE TABLE IF NOT EXISTS tickets (
130
+ id TEXT PRIMARY KEY,
131
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
132
+ number INTEGER NOT NULL,
133
+ title TEXT NOT NULL,
134
+ description TEXT NOT NULL DEFAULT '',
135
+ type TEXT NOT NULL DEFAULT 'task',
136
+ parent_ticket_id TEXT,
137
+ ai_plan TEXT NOT NULL DEFAULT '',
138
+ implementation_summary TEXT NOT NULL DEFAULT '',
139
+ implementation_updates TEXT NOT NULL DEFAULT '',
140
+ state_id TEXT NOT NULL REFERENCES states(id),
141
+ priority INTEGER NOT NULL DEFAULT 2,
142
+ created_by TEXT NOT NULL DEFAULT 'human',
143
+ created_at TEXT NOT NULL,
144
+ updated_at TEXT NOT NULL,
145
+ archived_at TEXT,
146
+ UNIQUE(board_id, number)
147
+ );
148
+
149
+ CREATE TABLE IF NOT EXISTS comments (
150
+ id TEXT PRIMARY KEY,
151
+ ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
152
+ author TEXT NOT NULL,
153
+ kind TEXT NOT NULL DEFAULT 'comment',
154
+ body TEXT NOT NULL,
155
+ created_at TEXT NOT NULL
156
+ );
157
+
158
+ CREATE TABLE IF NOT EXISTS board_entries (
159
+ id TEXT PRIMARY KEY,
160
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
161
+ type TEXT NOT NULL,
162
+ title TEXT NOT NULL,
163
+ body TEXT NOT NULL DEFAULT '',
164
+ ticket_id TEXT REFERENCES tickets(id) ON DELETE SET NULL,
165
+ created_by TEXT NOT NULL DEFAULT 'human',
166
+ created_at TEXT NOT NULL,
167
+ updated_at TEXT NOT NULL,
168
+ struck_at TEXT
169
+ );
170
+
171
+ CREATE TABLE IF NOT EXISTS labels (
172
+ id TEXT PRIMARY KEY,
173
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
174
+ name TEXT NOT NULL,
175
+ color TEXT NOT NULL DEFAULT '#64748b',
176
+ created_at TEXT NOT NULL,
177
+ UNIQUE(board_id, name)
178
+ );
179
+
180
+ CREATE TABLE IF NOT EXISTS ticket_labels (
181
+ ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
182
+ label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
183
+ PRIMARY KEY(ticket_id, label_id)
184
+ );
185
+
186
+ CREATE TABLE IF NOT EXISTS relations (
187
+ id TEXT PRIMARY KEY,
188
+ source_ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
189
+ target_ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
190
+ type TEXT NOT NULL,
191
+ created_at TEXT NOT NULL,
192
+ UNIQUE(source_ticket_id, target_ticket_id, type)
193
+ );
194
+
195
+ CREATE TABLE IF NOT EXISTS events (
196
+ id TEXT PRIMARY KEY,
197
+ ticket_id TEXT REFERENCES tickets(id) ON DELETE SET NULL,
198
+ actor TEXT NOT NULL,
199
+ type TEXT NOT NULL,
200
+ body_json TEXT NOT NULL DEFAULT '{}',
201
+ created_at TEXT NOT NULL
202
+ );
203
+
204
+ CREATE VIRTUAL TABLE IF NOT EXISTS ticket_fts USING fts5(
205
+ ticket_id UNINDEXED,
206
+ title,
207
+ description,
208
+ comments
209
+ );
210
+
211
+ CREATE INDEX IF NOT EXISTS idx_states_board ON states(board_id, position);
212
+ CREATE INDEX IF NOT EXISTS idx_states_role ON states(board_id, role);
213
+ CREATE INDEX IF NOT EXISTS idx_tickets_board_state ON tickets(board_id, state_id);
214
+ CREATE INDEX IF NOT EXISTS idx_tickets_archived ON tickets(board_id, archived_at);
215
+ CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_ticket_id);
216
+ CREATE INDEX IF NOT EXISTS idx_comments_ticket ON comments(ticket_id, created_at);
217
+ CREATE INDEX IF NOT EXISTS idx_board_entries_board ON board_entries(board_id, type, created_at);
218
+ CREATE INDEX IF NOT EXISTS idx_relations_source ON relations(source_ticket_id);
219
+ CREATE INDEX IF NOT EXISTS idx_relations_target ON relations(target_ticket_id);
220
+ CREATE INDEX IF NOT EXISTS idx_events_ticket ON events(ticket_id, created_at);
221
+ `);
222
+ }
223
+
224
+ export function resetBoard(db) {
225
+ db.exec(`
226
+ DELETE FROM ticket_fts;
227
+ DELETE FROM ticket_labels;
228
+ DELETE FROM relations;
229
+ DELETE FROM comments;
230
+ DELETE FROM board_entries;
231
+ DELETE FROM events;
232
+ DELETE FROM tickets;
233
+ DELETE FROM labels;
234
+ DELETE FROM states;
235
+ DELETE FROM boards;
236
+ `);
237
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { basename, dirname, join, relative } from "node:path";
3
+ import { requireBoardAccess } from "./auth.js";
4
+ import { backupBoardDatabase, backupRegistry, cancelAutomaticBoardBackup } from "./backups.js";
5
+ import { closeConnection } from "./db.js";
6
+ import { deleteBoard, openBoardDb } from "./registry.js";
7
+ import { httpError, normalizePath } from "./util.js";
8
+
9
+ function isInside(parent, child) {
10
+ const rel = relative(parent, child);
11
+ return rel && !rel.startsWith("..") && !rel.startsWith("/") && rel !== "..";
12
+ }
13
+
14
+ function boardDbDeleteTargets(boardRow) {
15
+ const repoRoot = normalizePath(boardRow.repo_path || "");
16
+ const dbPath = normalizePath(boardRow.db_path || "");
17
+ const orbitRoot = normalizePath(join(repoRoot, ".orbit"));
18
+ if (!repoRoot || !dbPath || !isInside(orbitRoot, dbPath) || basename(dbPath) !== "board.db") {
19
+ throw httpError(409, "unsafe_board_db_path");
20
+ }
21
+
22
+ const boardDir = dirname(dbPath);
23
+ const boardsRoot = normalizePath(join(orbitRoot, "boards"));
24
+ const removeBoardDir = isInside(boardsRoot, boardDir);
25
+ if (removeBoardDir) return [boardDir];
26
+ return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`, `${dbPath}-journal`];
27
+ }
28
+
29
+ export function deleteRegisteredBoard(boardRow, body, actor) {
30
+ requireBoardAccess(actor, { slug: boardRow.slug });
31
+
32
+ const confirmation = String(body?.confirm_slug || "").trim();
33
+ if (confirmation !== boardRow.slug) throw httpError(400, "board_slug_confirmation_required");
34
+
35
+ const deleteFiles = body?.delete_files !== false;
36
+ const targets = deleteFiles ? boardDbDeleteTargets(boardRow) : [];
37
+ cancelAutomaticBoardBackup(boardRow.id);
38
+ if (deleteFiles) backupBoardDatabase(boardRow, openBoardDb(boardRow), "pre-board-delete");
39
+ backupRegistry("pre-board-delete");
40
+ closeConnection(boardRow.db_path);
41
+
42
+ for (const target of targets) {
43
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
44
+ }
45
+
46
+ deleteBoard(boardRow.id);
47
+ return {
48
+ ok: true,
49
+ deleted_board_id: boardRow.id,
50
+ deleted_board_slug: boardRow.slug,
51
+ deleted_files: deleteFiles
52
+ };
53
+ }