kanbanqube 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/server.js ADDED
@@ -0,0 +1,1347 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const http = require("node:http");
5
+ const fs = require("node:fs/promises");
6
+ const fsSync = require("node:fs");
7
+ const path = require("node:path");
8
+ const { spawn, execFile } = require("node:child_process");
9
+ const crypto = require("node:crypto");
10
+ const { version: PACKAGE_VERSION } = require("./package.json");
11
+
12
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
13
+ console.log("KanbanQube");
14
+ console.log("");
15
+ console.log("Usage:");
16
+ console.log(" kanbanqube [vault-directory]");
17
+ console.log("");
18
+ console.log("Environment:");
19
+ console.log(" PORT=3000 HTTP port to listen on");
20
+ process.exit(0);
21
+ }
22
+
23
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
24
+ console.log(PACKAGE_VERSION);
25
+ process.exit(0);
26
+ }
27
+
28
+ const APP_DIR = __dirname;
29
+ const WORKSPACE_DIR = resolveWorkspaceDirectory(process.argv[2]);
30
+ const PUBLIC_DIR = path.join(APP_DIR, "public");
31
+ const BOARD_FILE_NAME = "board.json";
32
+ const DEMO_BOARD_FILE_NAME = "demo_board.json";
33
+ const BOARD_FILE_PATH = path.join(WORKSPACE_DIR, BOARD_FILE_NAME);
34
+ const DEMO_BOARD_FILE_PATH = path.join(APP_DIR, DEMO_BOARD_FILE_NAME);
35
+ const BOARD_DIR_NAME = "board";
36
+ const BOARD_DIR = path.join(WORKSPACE_DIR, BOARD_DIR_NAME);
37
+ const BOARD_META_FILE_PATH = path.join(BOARD_DIR, "meta.json");
38
+ const UPLOADS_DIR_NAME = "uploads";
39
+ const UPLOADS_DIR = path.join(WORKSPACE_DIR, UPLOADS_DIR_NAME);
40
+ const SAMPLE_EXPORT_DIR = path.join(WORKSPACE_DIR, "trello_export");
41
+ const PORT = Number(process.env.PORT || 3000);
42
+ const gitExecutableCandidates = [
43
+ "/usr/bin/git",
44
+ "/bin/git",
45
+ "/usr/local/bin/git",
46
+ "/opt/homebrew/bin/git"
47
+ ];
48
+ const sshExecutableCandidates = [
49
+ "/usr/bin/ssh",
50
+ "/bin/ssh",
51
+ "/usr/local/bin/ssh",
52
+ "/opt/homebrew/bin/ssh"
53
+ ];
54
+ const gitSafePath = "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin";
55
+ const syncStatus = {
56
+ running: false,
57
+ startedAt: "",
58
+ finishedAt: "",
59
+ ok: null,
60
+ output: ""
61
+ };
62
+ const mimeTypes = {
63
+ ".css": "text/css; charset=utf-8",
64
+ ".html": "text/html; charset=utf-8",
65
+ ".js": "application/javascript; charset=utf-8",
66
+ ".json": "application/json; charset=utf-8",
67
+ ".svg": "image/svg+xml",
68
+ ".png": "image/png",
69
+ ".jpg": "image/jpeg",
70
+ ".jpeg": "image/jpeg",
71
+ ".gif": "image/gif",
72
+ ".webp": "image/webp",
73
+ ".pdf": "application/pdf",
74
+ ".txt": "text/plain; charset=utf-8"
75
+ };
76
+
77
+ const server = http.createServer((request, response) => {
78
+ void handleRequest(request, response);
79
+ });
80
+
81
+ async function handleRequest(request, response) {
82
+ try {
83
+ const url = new URL(request.url, `http://${request.headers.host || "localhost"}`);
84
+
85
+ if (await handleApiRequest(request, response, url)) return;
86
+ if (await handleAssetRequest(request, response, url)) return;
87
+ sendJson(response, 405, { error: "Method not allowed." });
88
+ } catch (error) {
89
+ sendJson(response, error.statusCode || 500, { error: error.message || "Unexpected server error." });
90
+ }
91
+ }
92
+
93
+ async function handleApiRequest(request, response, url) {
94
+ if (request.method === "GET") return handleGetApiRequest(response, url);
95
+ if (request.method === "POST") return handlePostApiRequest(request, response, url);
96
+ if (request.method === "PUT" && url.pathname === "/api/board") return saveBoardApiRequest(request, response);
97
+ if (request.method === "DELETE" && url.pathname.startsWith(`/api/${UPLOADS_DIR_NAME}/`)) {
98
+ const result = await deleteUploadedFile(url.pathname);
99
+ sendJson(response, 200, result);
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ async function handleGetApiRequest(response, url) {
106
+ if (url.pathname === "/api/board") {
107
+ sendJson(response, 200, await loadBoard());
108
+ return true;
109
+ }
110
+ if (url.pathname === "/api/config") {
111
+ sendJson(response, 200, await loadConfig());
112
+ return true;
113
+ }
114
+ if (url.pathname === "/api/sync-status") {
115
+ sendJson(response, 200, syncStatus);
116
+ return true;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ async function handlePostApiRequest(request, response, url) {
122
+ if (url.pathname === "/api/uploads") {
123
+ const files = await saveUploadedFiles(request);
124
+ sendJson(response, 200, { files });
125
+ return true;
126
+ }
127
+ if (url.pathname === "/api/import") {
128
+ await importBoardApiRequest(request, response);
129
+ return true;
130
+ }
131
+ if (url.pathname === "/api/sync") {
132
+ await syncBoardApiRequest(response);
133
+ return true;
134
+ }
135
+ return false;
136
+ }
137
+
138
+ async function handleAssetRequest(request, response, url) {
139
+ if (request.method === "GET" && url.pathname === `/${DEMO_BOARD_FILE_NAME}`) {
140
+ await serveDemoBoard(response);
141
+ return true;
142
+ }
143
+ if (request.method === "GET" && url.pathname.startsWith(`/${UPLOADS_DIR_NAME}/`)) {
144
+ await serveUpload(url.pathname, response);
145
+ return true;
146
+ }
147
+ if (request.method === "GET") {
148
+ await serveStatic(url.pathname, response);
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ async function serveDemoBoard(response) {
155
+ try {
156
+ const body = await fs.readFile(DEMO_BOARD_FILE_PATH);
157
+ response.writeHead(200, {
158
+ "Content-Type": "application/json; charset=utf-8",
159
+ "Cache-Control": "no-store"
160
+ });
161
+ response.end(body);
162
+ } catch (error) {
163
+ if (error.code === "ENOENT") return sendText(response, 404, "Not found.");
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ async function loadConfig() {
169
+ return {
170
+ boardFile: BOARD_FILE_NAME,
171
+ storagePath: BOARD_DIR_NAME,
172
+ workspacePath: WORKSPACE_DIR,
173
+ hasGitRepo: await hasGitRepository(WORKSPACE_DIR),
174
+ gitRemote: await gitRemoteOrigin(WORKSPACE_DIR),
175
+ gitUserName: await gitUserName(WORKSPACE_DIR),
176
+ gitUserEmail: await gitUserEmail(WORKSPACE_DIR)
177
+ };
178
+ }
179
+
180
+ async function saveBoardApiRequest(request, response) {
181
+ const payload = await readJsonBody(request);
182
+ const board = await saveBoard(payload);
183
+ sendJson(response, 200, board);
184
+ return true;
185
+ }
186
+
187
+ async function importBoardApiRequest(request, response) {
188
+ const currentBoard = await loadBoard();
189
+ if ((currentBoard.cards || []).length > 0) {
190
+ sendJson(response, 409, { error: "Import is only available when the board has no cards." });
191
+ return;
192
+ }
193
+ const importedBoard = await readImportedBoard(request);
194
+ const board = await saveBoard(importedBoard);
195
+ sendJson(response, 200, board);
196
+ }
197
+
198
+ async function syncBoardApiRequest(response) {
199
+ if (syncStatus.running) {
200
+ sendJson(response, 409, {
201
+ ok: false,
202
+ output: syncStatus.output || "A git sync is already in progress.",
203
+ startedAt: syncStatus.startedAt,
204
+ finishedAt: syncStatus.finishedAt
205
+ });
206
+ return;
207
+ }
208
+ const result = await syncBoardRepository();
209
+ sendJson(response, result.ok ? 200 : 500, result);
210
+ }
211
+
212
+ server.listen(PORT, async () => {
213
+ await ensureBoardStorage();
214
+ console.log(`KanbanQube running on http://localhost:${PORT} (workspace: ${WORKSPACE_DIR})`);
215
+ });
216
+
217
+ async function serveStatic(requestPath, response) {
218
+ let safePath = requestPath === "/" ? "/index.html" : requestPath;
219
+ if (safePath.includes("\0")) {
220
+ return sendText(response, 400, "Invalid path.");
221
+ }
222
+
223
+ safePath = path.posix.normalize(safePath).replace(/^(\.\.(\/|\\|$))+/, "");
224
+ const filePath = path.join(PUBLIC_DIR, safePath);
225
+
226
+ if (!filePath.startsWith(PUBLIC_DIR)) {
227
+ return sendText(response, 403, "Forbidden.");
228
+ }
229
+
230
+ try {
231
+ const stat = await fs.stat(filePath);
232
+ if (!stat.isFile()) return sendText(response, 404, "Not found.");
233
+ const ext = path.extname(filePath).toLowerCase();
234
+ const body = await fs.readFile(filePath);
235
+ response.writeHead(200, {
236
+ "Content-Type": mimeTypes[ext] || "application/octet-stream",
237
+ "Cache-Control": "no-store"
238
+ });
239
+ response.end(body);
240
+ } catch (error) {
241
+ if (error.code === "ENOENT") return sendText(response, 404, "Not found.");
242
+ throw error;
243
+ }
244
+ }
245
+
246
+ async function serveUpload(requestPath, response) {
247
+ let relativePath = decodeURIComponent(requestPath.slice(`/${UPLOADS_DIR_NAME}/`.length));
248
+ if (!relativePath || relativePath.includes("\0") || relativePath.includes("/") || relativePath.includes("\\")) {
249
+ return sendText(response, 400, "Invalid upload path.");
250
+ }
251
+
252
+ relativePath = path.basename(relativePath);
253
+ const filePath = path.join(UPLOADS_DIR, relativePath);
254
+ if (!filePath.startsWith(UPLOADS_DIR)) {
255
+ return sendText(response, 403, "Forbidden.");
256
+ }
257
+
258
+ try {
259
+ const stat = await fs.stat(filePath);
260
+ if (!stat.isFile()) return sendText(response, 404, "Not found.");
261
+ const ext = path.extname(filePath).toLowerCase();
262
+ const body = await fs.readFile(filePath);
263
+ response.writeHead(200, {
264
+ "Content-Type": mimeTypes[ext] || "application/octet-stream",
265
+ "Cache-Control": "no-store"
266
+ });
267
+ response.end(body);
268
+ } catch (error) {
269
+ if (error.code === "ENOENT") return sendText(response, 404, "Not found.");
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ async function deleteUploadedFile(requestPath) {
275
+ let relativePath = decodeURIComponent(requestPath.slice(`/api/${UPLOADS_DIR_NAME}/`.length));
276
+ if (!relativePath || relativePath.includes("\0") || relativePath.includes("/") || relativePath.includes("\\")) {
277
+ throw new Error("Invalid upload path.");
278
+ }
279
+
280
+ relativePath = path.basename(relativePath);
281
+ const board = await loadBoard();
282
+ if (isUploadReferenced(board, relativePath)) {
283
+ return { deleted: false, referenced: true };
284
+ }
285
+
286
+ const filePath = path.join(UPLOADS_DIR, relativePath);
287
+ if (!filePath.startsWith(UPLOADS_DIR)) {
288
+ throw new Error("Invalid upload path.");
289
+ }
290
+
291
+ try {
292
+ await fs.unlink(filePath);
293
+ return { deleted: true };
294
+ } catch (error) {
295
+ if (error.code === "ENOENT") return { deleted: false };
296
+ throw error;
297
+ }
298
+ }
299
+
300
+ function isUploadReferenced(board, storedName) {
301
+ return (board.cards || []).some((card) => {
302
+ return (card.attachments || []).some((attachment) => uploadStoredName(attachment) === storedName);
303
+ });
304
+ }
305
+
306
+ function uploadStoredName(attachment) {
307
+ if (!attachment || typeof attachment !== "object") return "";
308
+ if (nonEmptyString(attachment.fileName)) return path.basename(attachment.fileName);
309
+ if (nonEmptyString(attachment.url)) {
310
+ try {
311
+ return path.basename(decodeURIComponent(new URL(attachment.url, "http://localhost").pathname));
312
+ } catch {
313
+ return "";
314
+ }
315
+ }
316
+ return "";
317
+ }
318
+
319
+ async function readJsonBody(request) {
320
+ const chunks = [];
321
+ let size = 0;
322
+ for await (const chunk of request) {
323
+ size += chunk.length;
324
+ if (size > 5 * 1024 * 1024) {
325
+ throw new Error("Request body too large.");
326
+ }
327
+ chunks.push(chunk);
328
+ }
329
+
330
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
331
+ return raw ? JSON.parse(raw) : {};
332
+ }
333
+
334
+ async function readBody(request, maxSize = 25 * 1024 * 1024) {
335
+ const chunks = [];
336
+ let size = 0;
337
+ for await (const chunk of request) {
338
+ size += chunk.length;
339
+ if (size > maxSize) {
340
+ throw new Error("Request body too large.");
341
+ }
342
+ chunks.push(chunk);
343
+ }
344
+ return Buffer.concat(chunks);
345
+ }
346
+
347
+ async function readImportedBoard(request) {
348
+ const contentType = request.headers["content-type"] || "";
349
+ if (contentType.includes("multipart/form-data")) {
350
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
351
+ if (!boundaryMatch) throw new Error("Import request must include a file.");
352
+ const boundary = boundaryMatch[1] || boundaryMatch[2];
353
+ const body = await readBody(request, 50 * 1024 * 1024);
354
+ const filePart = parseMultipartBody(body, boundary).find((part) => part.filename && part.data.length > 0);
355
+ if (!filePart) throw new Error("Import request must include a JSON file.");
356
+ return JSON.parse(filePart.data.toString("utf8"));
357
+ }
358
+
359
+ return readJsonBody(request);
360
+ }
361
+
362
+ async function saveUploadedFiles(request) {
363
+ const contentType = request.headers["content-type"] || "";
364
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
365
+ if (!boundaryMatch) {
366
+ throw new Error("Upload request must use multipart/form-data.");
367
+ }
368
+
369
+ const boundary = boundaryMatch[1] || boundaryMatch[2];
370
+ const body = await readBody(request);
371
+ const parts = parseMultipartBody(body, boundary);
372
+ await fs.mkdir(UPLOADS_DIR, { recursive: true });
373
+
374
+ const files = [];
375
+ for (const part of parts) {
376
+ if (!part.filename || part.data.length === 0) continue;
377
+ const originalName = displayOriginalFileName(part.filename);
378
+ const storedName = createStoredFileName(originalName);
379
+ const filePath = path.join(UPLOADS_DIR, storedName);
380
+ await fs.writeFile(filePath, part.data);
381
+ files.push({
382
+ id: createHexId(),
383
+ name: originalName,
384
+ fileName: storedName,
385
+ url: `/${UPLOADS_DIR_NAME}/${encodeURIComponent(storedName)}`,
386
+ mimeType: part.contentType || mimeTypeForFileName(originalName),
387
+ bytes: part.data.length,
388
+ date: new Date().toISOString(),
389
+ isUpload: true
390
+ });
391
+ }
392
+
393
+ return files;
394
+ }
395
+
396
+ function parseMultipartBody(body, boundary) {
397
+ const delimiter = Buffer.from(`--${boundary}`);
398
+ const parts = [];
399
+ let start = body.indexOf(delimiter);
400
+
401
+ while (start !== -1) {
402
+ start += delimiter.length;
403
+ if (body[start] === 45 && body[start + 1] === 45) break;
404
+ if (body[start] === 13 && body[start + 1] === 10) start += 2;
405
+
406
+ const next = body.indexOf(delimiter, start);
407
+ if (next === -1) break;
408
+
409
+ let part = body.subarray(start, next);
410
+ if (part.length >= 2 && part[part.length - 2] === 13 && part[part.length - 1] === 10) {
411
+ part = part.subarray(0, -2);
412
+ }
413
+
414
+ const headerEnd = part.indexOf(Buffer.from("\r\n\r\n"));
415
+ if (headerEnd !== -1) {
416
+ const rawHeaders = part.subarray(0, headerEnd).toString("utf8");
417
+ const data = part.subarray(headerEnd + 4);
418
+ const disposition = rawHeaders.match(/content-disposition:\s*([^\r\n]+)/i)?.[1] || "";
419
+ const contentType = rawHeaders.match(/content-type:\s*([^\r\n]+)/i)?.[1]?.trim() || "";
420
+ const filename = disposition.match(/filename="([^"]*)"/i)?.[1] || "";
421
+ parts.push({ filename, contentType, data });
422
+ }
423
+
424
+ start = next;
425
+ }
426
+
427
+ return parts;
428
+ }
429
+
430
+ function sendJson(response, statusCode, payload) {
431
+ const body = JSON.stringify(payload, null, 2);
432
+ response.writeHead(statusCode, {
433
+ "Content-Type": "application/json; charset=utf-8",
434
+ "Cache-Control": "no-store"
435
+ });
436
+ response.end(body);
437
+ }
438
+
439
+ function sendText(response, statusCode, body) {
440
+ response.writeHead(statusCode, {
441
+ "Content-Type": "text/plain; charset=utf-8",
442
+ "Cache-Control": "no-store"
443
+ });
444
+ response.end(body);
445
+ }
446
+
447
+ function displayOriginalFileName(value) {
448
+ const baseName = path.basename(String(value || "attachment"));
449
+ return baseName.replace(/[\0\r\n]/g, "").trim() || "attachment";
450
+ }
451
+
452
+ function createStoredFileName(originalName) {
453
+ const ext = path.extname(originalName);
454
+ const base = sanitizeStoredFileName(path.basename(originalName, ext) || "attachment");
455
+ const stamp = new Date().toISOString().replace(/[^0-9A-Za-z]/g, "");
456
+ const suffix = crypto.randomBytes(3).toString("hex");
457
+ return `${base}.kbq_${stamp}${suffix}${ext}`;
458
+ }
459
+
460
+ function sanitizeStoredFileName(value) {
461
+ let result = "";
462
+ let previousWasUnderscore = true;
463
+ for (const character of String(value)) {
464
+ const isAlphaNumeric = (character >= "a" && character <= "z")
465
+ || (character >= "A" && character <= "Z")
466
+ || (character >= "0" && character <= "9");
467
+ const isAllowedSymbol = character === "(" || character === ")" || character === "[" || character === "]" || character === "-";
468
+ if (isAlphaNumeric || character === "_" || isAllowedSymbol) {
469
+ result += character;
470
+ previousWasUnderscore = false;
471
+ } else if (!previousWasUnderscore) {
472
+ result += "_";
473
+ previousWasUnderscore = true;
474
+ }
475
+ }
476
+ const trimmed = previousWasUnderscore ? result.slice(0, -1) : result;
477
+ return trimmed || "attachment";
478
+ }
479
+
480
+ function mimeTypeForFileName(fileName) {
481
+ const ext = path.extname(fileName).toLowerCase();
482
+ return mimeTypes[ext] || "application/octet-stream";
483
+ }
484
+
485
+ async function ensureBoardStorage() {
486
+ if (await exists(BOARD_META_FILE_PATH)) return;
487
+ const board = await seedBoard();
488
+ await writeSplitBoard(board);
489
+ }
490
+
491
+ async function seedBoard() {
492
+ try {
493
+ if (await exists(BOARD_FILE_PATH)) {
494
+ const raw = await fs.readFile(BOARD_FILE_PATH, "utf8");
495
+ return normalizeBoard(JSON.parse(raw));
496
+ }
497
+ } catch {
498
+ // Fall back to bundled sample or a clean starter board.
499
+ }
500
+
501
+ try {
502
+ const entries = await fs.readdir(SAMPLE_EXPORT_DIR, { withFileTypes: true });
503
+ const sample = entries
504
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".json"))
505
+ .sort((left, right) => left.name.localeCompare(right.name))[0];
506
+
507
+ if (sample) {
508
+ const raw = await fs.readFile(path.join(SAMPLE_EXPORT_DIR, sample.name), "utf8");
509
+ return normalizeBoard(JSON.parse(raw));
510
+ }
511
+ } catch {
512
+ // Fall back to a clean starter board.
513
+ }
514
+
515
+ return normalizeBoard(defaultBoardSkeleton());
516
+ }
517
+
518
+ async function loadBoard() {
519
+ await ensureBoardStorage();
520
+ const board = await readSplitBoard();
521
+ return normalizeBoard(board);
522
+ }
523
+
524
+ async function saveBoard(candidateBoard) {
525
+ const board = normalizeBoard(candidateBoard);
526
+ await writeSplitBoard(board);
527
+ return board;
528
+ }
529
+
530
+ async function writeJsonIfChanged(filePath, value) {
531
+ const next = `${JSON.stringify(value, null, 2)}\n`;
532
+ try {
533
+ const current = await fs.readFile(filePath, "utf8");
534
+ if (current === next) return;
535
+ } catch (error) {
536
+ if (error.code !== "ENOENT") throw error;
537
+ }
538
+ await writeTextAtomically(filePath, next);
539
+ }
540
+
541
+ async function writeTextAtomically(filePath, text) {
542
+ const directory = path.dirname(filePath);
543
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
544
+ await fs.mkdir(directory, { recursive: true });
545
+ await fs.writeFile(tmpPath, text, "utf8");
546
+ await fs.rename(tmpPath, filePath);
547
+ }
548
+
549
+ async function readSplitBoard() {
550
+ const meta = await readJsonFile(BOARD_META_FILE_PATH, {});
551
+ return {
552
+ ...meta,
553
+ lists: await readJsonCollection("lists"),
554
+ labels: await readJsonCollection("labels"),
555
+ members: await readJsonCollection("members"),
556
+ cards: await readJsonCollection("cards"),
557
+ checklists: await readJsonCollection("checklists"),
558
+ actions: await readJsonCollection("actions")
559
+ };
560
+ }
561
+
562
+ async function writeSplitBoard(board) {
563
+ await fs.mkdir(BOARD_DIR, { recursive: true });
564
+ const {
565
+ lists,
566
+ labels,
567
+ members,
568
+ cards,
569
+ checklists,
570
+ actions,
571
+ ...meta
572
+ } = board;
573
+
574
+ await writeJsonIfChanged(BOARD_META_FILE_PATH, meta);
575
+ await writeJsonCollection("lists", lists || []);
576
+ await writeJsonCollection("labels", labels || []);
577
+ await writeJsonCollection("members", members || []);
578
+ await writeJsonCollection("cards", cards || []);
579
+ await writeJsonCollection("checklists", checklists || []);
580
+ await writeJsonCollection("actions", actions || []);
581
+ }
582
+
583
+ async function readJsonCollection(name) {
584
+ const directory = path.join(BOARD_DIR, name);
585
+ let entries = [];
586
+ try {
587
+ entries = await fs.readdir(directory, { withFileTypes: true });
588
+ } catch (error) {
589
+ if (error.code === "ENOENT") return [];
590
+ throw error;
591
+ }
592
+
593
+ const items = [];
594
+ for (const entry of entries) {
595
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
596
+ items.push(await readJsonFile(path.join(directory, entry.name), null));
597
+ }
598
+ return items.filter(Boolean);
599
+ }
600
+
601
+ async function writeJsonCollection(name, items) {
602
+ const directory = path.join(BOARD_DIR, name);
603
+ await fs.mkdir(directory, { recursive: true });
604
+ const desiredFiles = new Set();
605
+
606
+ for (const item of items) {
607
+ if (!item || typeof item !== "object") continue;
608
+ const id = nonEmptyString(item.id) || createHexId();
609
+ item.id = id;
610
+ const fileName = `${encodeURIComponent(id)}.json`;
611
+ desiredFiles.add(fileName);
612
+ await writeJsonIfChanged(path.join(directory, fileName), item);
613
+ }
614
+
615
+ let entries = [];
616
+ try {
617
+ entries = await fs.readdir(directory, { withFileTypes: true });
618
+ } catch (error) {
619
+ if (error.code === "ENOENT") return;
620
+ throw error;
621
+ }
622
+
623
+ for (const entry of entries) {
624
+ if (entry.isFile() && entry.name.endsWith(".json") && !desiredFiles.has(entry.name)) {
625
+ await fs.unlink(path.join(directory, entry.name));
626
+ }
627
+ }
628
+ }
629
+
630
+ async function readJsonFile(filePath, fallback) {
631
+ try {
632
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
633
+ } catch (error) {
634
+ if (error.code === "ENOENT") return fallback;
635
+ throw error;
636
+ }
637
+ }
638
+
639
+ function normalizeBoard(candidate) {
640
+ const seededDefaults = defaultBoardSkeleton();
641
+ const source = candidate && typeof candidate === "object" ? candidate : {};
642
+ const board = { ...seededDefaults, ...source };
643
+ const boardId = nonEmptyString(board.id) || seededDefaults.id;
644
+ const baseShortLink = nonEmptyString(source.shortLink) || boardId.slice(-8);
645
+ const now = newestTimestamp([source.dateLastActivity]);
646
+
647
+ const lists = normalizeLists(source.lists, boardId);
648
+ const labels = normalizeLabels(source.labels, boardId);
649
+ const members = normalizeMembers(source.members);
650
+ const checklists = normalizeChecklists(source.checklists, boardId);
651
+ const cards = normalizeCards(source.cards, boardId, lists, checklists);
652
+ const actions = normalizeActions(source.actions, boardId, cards, lists, members);
653
+ const badgeMap = buildBadgeMap(cards, checklists, actions);
654
+
655
+ const normalizedCards = cards
656
+ .map((card, index) => ({
657
+ ...card,
658
+ idShort: Number.isFinite(card.idShort) ? card.idShort : index + 1,
659
+ badges: badgeMap.get(card.id)
660
+ }))
661
+ .sort((left, right) => left.pos - right.pos);
662
+ lists.sort((left, right) => left.pos - right.pos);
663
+
664
+ return {
665
+ ...board,
666
+ id: boardId,
667
+ nodeId: nonEmptyString(source.nodeId) || `kanbanqube:board:${boardId}`,
668
+ name: nonEmptyString(board.name) || "KanbanQube Board",
669
+ desc: stringOrDefault(board.desc, ""),
670
+ descData: board.descData ?? null,
671
+ closed: Boolean(board.closed),
672
+ creationMethod: board.creationMethod ?? null,
673
+ creationMethodError: board.creationMethodError ?? null,
674
+ creationMethodLoadingStartedAt: board.creationMethodLoadingStartedAt ?? null,
675
+ creationMethodLoadingPhase: board.creationMethodLoadingPhase ?? null,
676
+ dateClosed: board.dateClosed ?? null,
677
+ dateLastActivity: source.dateLastActivity || now,
678
+ dateLastView: source.dateLastView || now,
679
+ datePluginDisable: source.datePluginDisable ?? null,
680
+ enterpriseOwned: Boolean(source.enterpriseOwned),
681
+ idBoardSource: source.idBoardSource ?? null,
682
+ idEnterprise: source.idEnterprise ?? null,
683
+ idMemberCreator: source.idMemberCreator ?? (members[0]?.id || null),
684
+ idOrganization: source.idOrganization ?? null,
685
+ idTags: Array.isArray(source.idTags) ? source.idTags : [],
686
+ ixUpdate: Number.isFinite(source.ixUpdate) ? source.ixUpdate : Date.now(),
687
+ labelNames: source.labelNames ?? buildLabelNames(labels),
688
+ labels,
689
+ limits: source.limits ?? {},
690
+ lists,
691
+ members,
692
+ memberships: Array.isArray(source.memberships) ? source.memberships : [],
693
+ cards: normalizedCards,
694
+ actions,
695
+ checklists,
696
+ customFields: Array.isArray(source.customFields) ? source.customFields : [],
697
+ pinned: Boolean(source.pinned),
698
+ pluginData: Array.isArray(source.pluginData) ? source.pluginData : [],
699
+ powerUps: Array.isArray(source.powerUps) ? source.powerUps : [],
700
+ prefs: normalizePrefs(source.prefs),
701
+ premiumFeatures: Array.isArray(source.premiumFeatures) ? source.premiumFeatures : [],
702
+ shortLink: baseShortLink,
703
+ shortUrl: nonEmptyString(source.shortUrl) || `https://kanbanqube.local/b/${baseShortLink}`,
704
+ starred: Boolean(source.starred),
705
+ subscribed: Boolean(source.subscribed),
706
+ templateGallery: source.templateGallery ?? null,
707
+ type: source.type ?? "board",
708
+ url: nonEmptyString(source.url) || `https://kanbanqube.local/b/${baseShortLink}`,
709
+ kanbanQubeMeta: {
710
+ ...(source.kanbanQubeMeta && typeof source.kanbanQubeMeta === "object" ? source.kanbanQubeMeta : {}),
711
+ version: 1,
712
+ savedAt: source.kanbanQubeMeta?.savedAt || source.dateLastActivity || now,
713
+ boardFile: BOARD_FILE_NAME
714
+ }
715
+ };
716
+ }
717
+
718
+ function normalizeLists(candidateLists, boardId) {
719
+ const defaults = defaultBoardSkeleton().lists;
720
+ const source = Array.isArray(candidateLists) && candidateLists.length > 0 ? candidateLists : defaults;
721
+ return source.map((list, index) => ({
722
+ ...list,
723
+ id: nonEmptyString(list.id) || createHexId(),
724
+ name: nonEmptyString(list.name) || `Lane ${index + 1}`,
725
+ closed: Boolean(list.closed),
726
+ color: list.color ?? null,
727
+ idBoard: nonEmptyString(list.idBoard) || boardId,
728
+ pos: normalizePos(list.pos, index)
729
+ }));
730
+ }
731
+
732
+ function normalizeCards(candidateCards, boardId, lists, checklists) {
733
+ const listIds = new Set(lists.map((list) => list.id));
734
+ const checklistIds = new Set(checklists.map((checklist) => checklist.id));
735
+ return (Array.isArray(candidateCards) ? candidateCards : []).map((card, index) => {
736
+ const cardId = nonEmptyString(card.id) || createHexId();
737
+ const targetListId = listIds.has(card.idList) ? card.idList : lists[0]?.id || null;
738
+ const shortLink = nonEmptyString(card.shortLink) || cardId.slice(-8);
739
+ const attachments = Array.isArray(card.attachments) ? card.attachments : [];
740
+ return {
741
+ ...card,
742
+ id: cardId,
743
+ idBoard: nonEmptyString(card.idBoard) || boardId,
744
+ idList: targetListId,
745
+ name: stringOrDefault(card.name, ""),
746
+ desc: stringOrDefault(card.desc, ""),
747
+ closed: Boolean(card.closed),
748
+ pos: normalizePos(card.pos, index),
749
+ idLabels: Array.isArray(card.idLabels) ? card.idLabels.filter(Boolean) : [],
750
+ idMembers: Array.isArray(card.idMembers) ? card.idMembers.filter(Boolean) : [],
751
+ idChecklists: Array.isArray(card.idChecklists)
752
+ ? card.idChecklists.filter((checklistId) => checklistIds.has(checklistId))
753
+ : [],
754
+ attachments,
755
+ cover: normalizeCover(card.cover),
756
+ due: card.due ?? null,
757
+ dueComplete: Boolean(card.dueComplete),
758
+ start: card.start ?? null,
759
+ subscribed: Boolean(card.subscribed),
760
+ shortLink,
761
+ shortUrl: nonEmptyString(card.shortUrl) || `https://kanbanqube.local/c/${shortLink}`,
762
+ url: nonEmptyString(card.url) || `https://kanbanqube.local/c/${shortLink}`,
763
+ dateLastActivity: newestTimestamp([
764
+ card.dateLastActivity,
765
+ ...collectDates(card.actions)
766
+ ]),
767
+ labels: Array.isArray(card.labels) ? card.labels : []
768
+ };
769
+ });
770
+ }
771
+
772
+ function normalizeChecklists(candidateChecklists, boardId) {
773
+ return (Array.isArray(candidateChecklists) ? candidateChecklists : []).map((checklist, checklistIndex) => ({
774
+ ...checklist,
775
+ id: nonEmptyString(checklist.id) || createHexId(),
776
+ idBoard: nonEmptyString(checklist.idBoard) || boardId,
777
+ idCard: nonEmptyString(checklist.idCard) || null,
778
+ name: nonEmptyString(checklist.name) || `Checklist ${checklistIndex + 1}`,
779
+ checkItems: (Array.isArray(checklist.checkItems) ? checklist.checkItems : []).map((item, itemIndex) => ({
780
+ ...item,
781
+ id: nonEmptyString(item.id) || createHexId(),
782
+ name: nonEmptyString(item.name) || `Item ${itemIndex + 1}`,
783
+ pos: normalizePos(item.pos, itemIndex),
784
+ state: item.state === "complete" ? "complete" : "incomplete",
785
+ due: item.due ?? null,
786
+ dueReminder: Number.isFinite(item.dueReminder) ? item.dueReminder : -1,
787
+ idMember: item.idMember ?? null,
788
+ idChecklist: nonEmptyString(item.idChecklist) || nonEmptyString(checklist.id) || null,
789
+ nameData: item.nameData ?? { emoji: {} }
790
+ }))
791
+ }));
792
+ }
793
+
794
+ function normalizeLabels(candidateLabels, boardId) {
795
+ return (Array.isArray(candidateLabels) ? candidateLabels : []).map((label) => ({
796
+ ...label,
797
+ id: nonEmptyString(label.id) || createHexId(),
798
+ idBoard: nonEmptyString(label.idBoard) || boardId,
799
+ name: stringOrDefault(label.name, ""),
800
+ color: nonEmptyString(label.color) || "blue",
801
+ uses: Number.isFinite(label.uses) ? label.uses : 0
802
+ }));
803
+ }
804
+
805
+ function normalizeMembers(candidateMembers) {
806
+ return (Array.isArray(candidateMembers) ? candidateMembers : []).map((member) => ({
807
+ ...member,
808
+ id: nonEmptyString(member.id) || createHexId(),
809
+ fullName: nonEmptyString(member.fullName) || nonEmptyString(member.username) || "Unknown User",
810
+ username: nonEmptyString(member.username) || slugify(nonEmptyString(member.fullName) || "user"),
811
+ initials: nonEmptyString(member.initials) || initialsFor(member.fullName || member.username || "U"),
812
+ nonPublic: member.nonPublic ?? {
813
+ fullName: nonEmptyString(member.fullName) || nonEmptyString(member.username) || "Unknown User",
814
+ initials: nonEmptyString(member.initials) || initialsFor(member.fullName || member.username || "U")
815
+ }
816
+ }));
817
+ }
818
+
819
+ function normalizeActions(candidateActions, boardId, cards, lists, members) {
820
+ const cardMap = new Map(cards.map((card) => [card.id, card]));
821
+ const listMap = new Map(lists.map((list) => [list.id, list]));
822
+ const memberMap = new Map(members.map((member) => [member.id, member]));
823
+
824
+ return (Array.isArray(candidateActions) ? candidateActions : []).map((action) => {
825
+ const cardId = action?.data?.idCard || action?.data?.card?.id || null;
826
+ const listId = action?.data?.list?.id || action?.data?.listAfter?.id || action?.data?.listBefore?.id || null;
827
+ return {
828
+ ...action,
829
+ id: nonEmptyString(action.id) || createHexId(),
830
+ idMemberCreator: action.idMemberCreator ?? action.memberCreator?.id ?? null,
831
+ type: nonEmptyString(action.type) || "updateCard",
832
+ date: newestTimestamp([action.date]),
833
+ data: normalizeActionData(action.data, boardId, cardId ? cardMap.get(cardId) : null, listId ? listMap.get(listId) : null),
834
+ memberCreator: action.memberCreator?.id && memberMap.has(action.memberCreator.id)
835
+ ? memberMap.get(action.memberCreator.id)
836
+ : action.memberCreator ?? null
837
+ };
838
+ }).sort((left, right) => new Date(right.date).getTime() - new Date(left.date).getTime());
839
+ }
840
+
841
+ function normalizeActionData(data, boardId, card, list) {
842
+ const source = data && typeof data === "object" ? data : {};
843
+ return {
844
+ ...source,
845
+ board: source.board ?? {
846
+ id: boardId
847
+ },
848
+ card: source.card ?? (card ? {
849
+ id: card.id,
850
+ name: card.name,
851
+ idShort: card.idShort ?? null,
852
+ shortLink: card.shortLink
853
+ } : undefined),
854
+ list: source.list ?? (list ? {
855
+ id: list.id,
856
+ name: list.name
857
+ } : undefined)
858
+ };
859
+ }
860
+
861
+ function normalizeCover(cover) {
862
+ return {
863
+ idAttachment: cover?.idAttachment ?? null,
864
+ color: cover?.color ?? null,
865
+ idUploadedBackground: cover?.idUploadedBackground ?? null,
866
+ size: cover?.size ?? "normal",
867
+ brightness: cover?.brightness ?? "dark",
868
+ yPosition: Number.isFinite(cover?.yPosition) ? cover.yPosition : 0.5,
869
+ idPlugin: cover?.idPlugin ?? null
870
+ };
871
+ }
872
+
873
+ function normalizePrefs(prefs) {
874
+ return {
875
+ background: prefs?.background ?? "blue",
876
+ backgroundColor: prefs?.backgroundColor ?? "#123c84",
877
+ backgroundDarkColor: prefs?.backgroundDarkColor ?? "#0a214a",
878
+ backgroundTopColor: prefs?.backgroundTopColor ?? "#174a98",
879
+ backgroundBottomColor: prefs?.backgroundBottomColor ?? "#0a214a",
880
+ canBePublic: prefs?.canBePublic ?? true,
881
+ canBeEnterprise: prefs?.canBeEnterprise ?? true,
882
+ canBeOrg: prefs?.canBeOrg ?? true,
883
+ canInvite: prefs?.canInvite ?? true,
884
+ cardAging: prefs?.cardAging ?? "regular",
885
+ comments: prefs?.comments ?? "members",
886
+ hideVotes: Boolean(prefs?.hideVotes),
887
+ invitations: prefs?.invitations ?? "members",
888
+ permissionLevel: prefs?.permissionLevel ?? "private",
889
+ selfJoin: Boolean(prefs?.selfJoin),
890
+ voting: prefs?.voting ?? "disabled"
891
+ };
892
+ }
893
+
894
+ function buildBadgeMap(cards, checklists, actions) {
895
+ const checklistsByCard = new Map();
896
+ for (const checklist of checklists) {
897
+ if (!checklist.idCard) continue;
898
+ const current = checklistsByCard.get(checklist.idCard) || [];
899
+ current.push(checklist);
900
+ checklistsByCard.set(checklist.idCard, current);
901
+ }
902
+
903
+ const commentsByCard = new Map();
904
+ for (const action of actions) {
905
+ const cardId = action?.data?.idCard || action?.data?.card?.id;
906
+ if (!cardId) continue;
907
+ if (action.type === "commentCard") {
908
+ commentsByCard.set(cardId, (commentsByCard.get(cardId) || 0) + 1);
909
+ }
910
+ }
911
+
912
+ const badgeMap = new Map();
913
+ for (const card of cards) {
914
+ const cardChecklists = checklistsByCard.get(card.id) || [];
915
+ const checklistItems = cardChecklists.flatMap((checklist) => checklist.checkItems);
916
+ const checkedItems = checklistItems.filter((item) => item.state === "complete");
917
+
918
+ badgeMap.set(card.id, {
919
+ attachments: Array.isArray(card.attachments) ? card.attachments.length : 0,
920
+ attachmentsByType: {
921
+ trello: {
922
+ board: 0,
923
+ card: Array.isArray(card.attachments) ? card.attachments.length : 0
924
+ }
925
+ },
926
+ checkItems: checklistItems.length,
927
+ checkItemsChecked: checkedItems.length,
928
+ checkItemsEarliestDue: null,
929
+ comments: commentsByCard.get(card.id) || 0,
930
+ description: Boolean(card.desc?.trim()),
931
+ due: card.due ?? null,
932
+ dueComplete: Boolean(card.dueComplete),
933
+ externalSource: null,
934
+ fogbugz: "",
935
+ lastUpdatedByAi: false,
936
+ location: false,
937
+ maliciousAttachments: 0,
938
+ start: card.start ?? null,
939
+ subscribed: Boolean(card.subscribed),
940
+ viewingMemberVoted: false,
941
+ votes: 0
942
+ });
943
+ }
944
+ return badgeMap;
945
+ }
946
+
947
+ function buildLabelNames(labels) {
948
+ const labelNames = {
949
+ green: "",
950
+ yellow: "",
951
+ orange: "",
952
+ red: "",
953
+ purple: "",
954
+ blue: "",
955
+ sky: "",
956
+ lime: "",
957
+ pink: "",
958
+ black: "",
959
+ green_dark: "",
960
+ yellow_dark: "",
961
+ orange_dark: "",
962
+ red_dark: "",
963
+ purple_dark: "",
964
+ blue_dark: "",
965
+ sky_dark: "",
966
+ lime_dark: "",
967
+ pink_dark: "",
968
+ black_dark: ""
969
+ };
970
+
971
+ for (const label of labels) {
972
+ if (label.color in labelNames && label.name && !labelNames[label.color]) {
973
+ labelNames[label.color] = label.name;
974
+ }
975
+ }
976
+
977
+ return labelNames;
978
+ }
979
+
980
+ function defaultBoardSkeleton() {
981
+ const boardId = createHexId();
982
+ const names = ["Backlog", "To Do", "In Progress", "In Review", "Done"];
983
+ const lists = names.map((name, index) => ({
984
+ id: createHexId(),
985
+ idBoard: boardId,
986
+ name,
987
+ closed: false,
988
+ pos: (index + 1) * 16384
989
+ }));
990
+
991
+ return {
992
+ id: boardId,
993
+ nodeId: `kanbanqube:board:${boardId}`,
994
+ name: "KanbanQube Board",
995
+ desc: "",
996
+ descData: null,
997
+ closed: false,
998
+ creationMethod: null,
999
+ creationMethodError: null,
1000
+ creationMethodLoadingStartedAt: null,
1001
+ creationMethodLoadingPhase: null,
1002
+ dateClosed: null,
1003
+ dateLastActivity: new Date().toISOString(),
1004
+ enterpriseOwned: false,
1005
+ idBoardSource: null,
1006
+ idEnterprise: null,
1007
+ idMemberCreator: null,
1008
+ idOrganization: null,
1009
+ idTags: [],
1010
+ ixUpdate: Date.now(),
1011
+ labelNames: {},
1012
+ labels: [],
1013
+ limits: {},
1014
+ lists,
1015
+ members: [],
1016
+ memberships: [],
1017
+ cards: [],
1018
+ actions: [],
1019
+ checklists: [],
1020
+ customFields: [],
1021
+ pinned: false,
1022
+ pluginData: [],
1023
+ powerUps: [],
1024
+ prefs: normalizePrefs({}),
1025
+ premiumFeatures: [],
1026
+ shortLink: boardId.slice(-8),
1027
+ shortUrl: `https://kanbanqube.local/b/${boardId.slice(-8)}`,
1028
+ starred: false,
1029
+ subscribed: false,
1030
+ templateGallery: null,
1031
+ type: "board",
1032
+ url: `https://kanbanqube.local/b/${boardId.slice(-8)}`
1033
+ };
1034
+ }
1035
+
1036
+ async function syncBoardRepository() {
1037
+ syncStatus.running = true;
1038
+ syncStatus.startedAt = new Date().toISOString();
1039
+ syncStatus.finishedAt = "";
1040
+ syncStatus.ok = null;
1041
+ syncStatus.output = "Syncing with git…";
1042
+
1043
+ if (!(await hasGitRepository(WORKSPACE_DIR))) {
1044
+ const result = {
1045
+ ok: false,
1046
+ output: `This folder does not contain a .git directory.\nWorkspace: ${WORKSPACE_DIR}`,
1047
+ startedAt: syncStatus.startedAt,
1048
+ finishedAt: new Date().toISOString()
1049
+ };
1050
+ Object.assign(syncStatus, { running: false, ok: false, output: result.output, finishedAt: result.finishedAt });
1051
+ return result;
1052
+ }
1053
+
1054
+ const remote = await gitRemoteOrigin(WORKSPACE_DIR);
1055
+ const output = [];
1056
+ const updateOutput = () => {
1057
+ syncStatus.output = output.join("\n\n") || "Syncing with git…";
1058
+ };
1059
+ const startCommand = (label) => {
1060
+ output.push(`${label}\n\nRunning…`);
1061
+ updateOutput();
1062
+ return output.length - 1;
1063
+ };
1064
+ const finishCommand = (index, text) => {
1065
+ output[index] = text;
1066
+ updateOutput();
1067
+ };
1068
+ await checkSshAuth(WORKSPACE_DIR, remote, output);
1069
+ updateOutput();
1070
+
1071
+ let commandIndex = startCommand("git status --porcelain");
1072
+ const status = await runGit(WORKSPACE_DIR, ["status", "--porcelain"]);
1073
+ finishCommand(commandIndex, formatGitCommandOutput("git status --porcelain", status, status.stdout.trim() ? "" : "Repository is clean."));
1074
+ if (status.code !== 0) {
1075
+ const result = { ok: false, output: output.join("\n\n"), startedAt: syncStatus.startedAt, finishedAt: new Date().toISOString() };
1076
+ Object.assign(syncStatus, { running: false, ok: false, output: result.output, finishedAt: result.finishedAt });
1077
+ return result;
1078
+ }
1079
+
1080
+ if (status.stdout.trim()) {
1081
+ commandIndex = startCommand("git add --all");
1082
+ const add = await runGit(WORKSPACE_DIR, ["add", "--all"]);
1083
+ finishCommand(commandIndex, formatGitCommandOutput("git add --all", add));
1084
+ if (add.code !== 0) {
1085
+ const result = { ok: false, output: output.join("\n\n"), startedAt: syncStatus.startedAt, finishedAt: new Date().toISOString() };
1086
+ Object.assign(syncStatus, { running: false, ok: false, output: result.output, finishedAt: result.finishedAt });
1087
+ return result;
1088
+ }
1089
+
1090
+ commandIndex = startCommand("git commit -m \"Update KanbanQube vault\"");
1091
+ const commit = await runGit(WORKSPACE_DIR, ["commit", "-m", "Update KanbanQube vault"]);
1092
+ finishCommand(commandIndex, formatGitCommandOutput("git commit -m \"Update KanbanQube vault\"", commit));
1093
+ if (commit.code !== 0) {
1094
+ const result = { ok: false, output: output.join("\n\n"), startedAt: syncStatus.startedAt, finishedAt: new Date().toISOString() };
1095
+ Object.assign(syncStatus, { running: false, ok: false, output: result.output, finishedAt: result.finishedAt });
1096
+ return result;
1097
+ }
1098
+ }
1099
+
1100
+ commandIndex = startCommand("git pull --rebase");
1101
+ const pull = await runGit(WORKSPACE_DIR, ["pull", "--rebase"]);
1102
+ finishCommand(commandIndex, formatGitCommandOutput("git pull --rebase", pull));
1103
+ if (pull.code !== 0) {
1104
+ const result = { ok: false, output: output.join("\n\n"), startedAt: syncStatus.startedAt, finishedAt: new Date().toISOString() };
1105
+ Object.assign(syncStatus, { running: false, ok: false, output: result.output, finishedAt: result.finishedAt });
1106
+ return result;
1107
+ }
1108
+
1109
+ commandIndex = startCommand("git push");
1110
+ const push = await runGit(WORKSPACE_DIR, ["push"]);
1111
+ finishCommand(commandIndex, formatGitCommandOutput("git push", push));
1112
+ const result = {
1113
+ ok: push.code === 0,
1114
+ output: output.join("\n\n"),
1115
+ startedAt: syncStatus.startedAt,
1116
+ finishedAt: new Date().toISOString()
1117
+ };
1118
+ Object.assign(syncStatus, { running: false, ok: result.ok, output: result.output, finishedAt: result.finishedAt });
1119
+ return result;
1120
+ }
1121
+
1122
+ function resolveWorkspaceDirectory(argument) {
1123
+ if (typeof argument === "string" && argument.trim()) {
1124
+ return path.resolve(argument);
1125
+ }
1126
+ return process.cwd();
1127
+ }
1128
+
1129
+ async function hasGitRepository(rootPath) {
1130
+ try {
1131
+ const stat = await fs.stat(path.join(rootPath, ".git"));
1132
+ return stat.isDirectory() || stat.isFile();
1133
+ } catch {
1134
+ return false;
1135
+ }
1136
+ }
1137
+
1138
+ async function gitRemoteOrigin(rootPath) {
1139
+ const result = await runGit(rootPath, ["remote", "get-url", "origin"]);
1140
+ return result.code === 0 ? result.stdout.trim() : null;
1141
+ }
1142
+
1143
+ async function gitUserName(rootPath) {
1144
+ const hasRepo = await hasGitRepository(rootPath);
1145
+ if (hasRepo) {
1146
+ const localResult = await runGit(rootPath, ["config", "--local", "--get", "user.name"]);
1147
+ const localName = localResult.code === 0 ? localResult.stdout.trim() : "";
1148
+ if (localName) return localName;
1149
+ }
1150
+
1151
+ const globalResult = await runGit(rootPath, ["config", "--global", "--get", "user.name"]);
1152
+ const globalName = globalResult.code === 0 ? globalResult.stdout.trim() : "";
1153
+ return globalName || null;
1154
+ }
1155
+
1156
+ async function gitUserEmail(rootPath) {
1157
+ const hasRepo = await hasGitRepository(rootPath);
1158
+ if (hasRepo) {
1159
+ const localResult = await runGit(rootPath, ["config", "--local", "--get", "user.email"]);
1160
+ const localEmail = localResult.code === 0 ? localResult.stdout.trim() : "";
1161
+ if (localEmail) return localEmail;
1162
+ }
1163
+
1164
+ const globalResult = await runGit(rootPath, ["config", "--global", "--get", "user.email"]);
1165
+ const globalEmail = globalResult.code === 0 ? globalResult.stdout.trim() : "";
1166
+ return globalEmail || null;
1167
+ }
1168
+
1169
+ async function checkSshAuth(rootPath, remoteUrl, output) {
1170
+ if (!remoteUrl || remoteUrl.startsWith("http://") || remoteUrl.startsWith("https://")) return;
1171
+ const hostMatch = remoteUrl.match(/[@/]([a-zA-Z0-9._-]+)[:/]/);
1172
+ if (!hostMatch) return;
1173
+
1174
+ const host = hostMatch[1];
1175
+ const sshExecutable = await resolveSshExecutable();
1176
+ await new Promise((resolve) => {
1177
+ execFile(
1178
+ sshExecutable,
1179
+ ["-T", `git@${host}`, "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new"],
1180
+ { env: { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK || "" } },
1181
+ (error, _stdout, stderr) => {
1182
+ const text = (stderr || "").toLowerCase();
1183
+ const authenticated = !error || text.includes("successfully authenticated") || text.includes("welcome to");
1184
+ if (!authenticated) {
1185
+ output.push(`Warning: SSH authentication to ${host} failed.\n${stderr.trim()}`.trim());
1186
+ }
1187
+ resolve();
1188
+ }
1189
+ );
1190
+ });
1191
+ }
1192
+
1193
+ async function runGit(rootPath, args) {
1194
+ const gitExecutable = await resolveGitExecutable();
1195
+ return new Promise((resolve) => {
1196
+ const child = spawn(gitExecutable, args, {
1197
+ cwd: rootPath,
1198
+ env: {
1199
+ HOME: process.env.HOME || "",
1200
+ LANG: process.env.LANG || "en_US.UTF-8",
1201
+ LC_ALL: process.env.LC_ALL || "",
1202
+ SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK || "",
1203
+ GIT_TERMINAL_PROMPT: "0",
1204
+ PATH: gitSafePath
1205
+ }
1206
+ });
1207
+
1208
+ let stdout = "";
1209
+ let stderr = "";
1210
+ let timedOut = false;
1211
+ const timeout = setTimeout(() => {
1212
+ timedOut = true;
1213
+ child.kill();
1214
+ }, 120000);
1215
+
1216
+ child.stdout.on("data", (chunk) => {
1217
+ stdout += chunk.toString();
1218
+ });
1219
+ child.stderr.on("data", (chunk) => {
1220
+ stderr += chunk.toString();
1221
+ });
1222
+ child.on("error", (error) => {
1223
+ clearTimeout(timeout);
1224
+ resolve({
1225
+ code: 1,
1226
+ signal: null,
1227
+ stdout,
1228
+ stderr,
1229
+ error: error.message
1230
+ });
1231
+ });
1232
+ child.on("close", (code, signal) => {
1233
+ clearTimeout(timeout);
1234
+ resolve({
1235
+ code: timedOut ? 1 : (code || 0),
1236
+ signal: timedOut ? "timeout" : signal,
1237
+ stdout,
1238
+ stderr,
1239
+ error: timedOut ? "Command timed out." : ""
1240
+ });
1241
+ });
1242
+ });
1243
+ }
1244
+
1245
+ async function resolveGitExecutable() {
1246
+ for (const candidate of gitExecutableCandidates) {
1247
+ if (await isSafeExecutable(candidate)) return candidate;
1248
+ }
1249
+ throw new Error("Git executable was not found in a trusted system location.");
1250
+ }
1251
+
1252
+ async function resolveSshExecutable() {
1253
+ for (const candidate of sshExecutableCandidates) {
1254
+ if (await isSafeExecutable(candidate)) return candidate;
1255
+ }
1256
+ throw new Error("SSH executable was not found in a trusted system location.");
1257
+ }
1258
+
1259
+ async function isSafeExecutable(candidate) {
1260
+ try {
1261
+ const stat = await fs.stat(candidate);
1262
+ if (!stat.isFile()) return false;
1263
+ await fs.access(candidate, fsSync.constants.X_OK);
1264
+ const parent = await fs.stat(path.dirname(candidate));
1265
+ return (parent.mode & 0o002) === 0;
1266
+ } catch {
1267
+ return false;
1268
+ }
1269
+ }
1270
+
1271
+ function formatGitCommandOutput(command, result, emptyOutput = "") {
1272
+ const status = result.code === 0 ? "OK" : `FAILED (${result.signal || result.code})`;
1273
+ const parts = [`$ ${command}`, status];
1274
+ if (result.stdout.trim()) parts.push("", result.stdout.trim());
1275
+ else if (emptyOutput) parts.push("", emptyOutput);
1276
+ if (result.stderr.trim()) parts.push("", result.stderr.trim());
1277
+ if (result.error && result.code !== 0 && !result.stderr.trim()) parts.push("", result.error);
1278
+ return parts.join("\n");
1279
+ }
1280
+
1281
+ function collectDates(actions) {
1282
+ if (!Array.isArray(actions)) return [];
1283
+ return actions.map((action) => action?.date).filter(Boolean);
1284
+ }
1285
+
1286
+ function newestTimestamp(values) {
1287
+ const dates = values
1288
+ .map((value) => {
1289
+ const timestamp = new Date(value || 0).getTime();
1290
+ return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : null;
1291
+ })
1292
+ .filter((value) => value !== null);
1293
+
1294
+ return new Date((dates.length ? Math.max(...dates) : Date.now())).toISOString();
1295
+ }
1296
+
1297
+ function normalizePos(value, index) {
1298
+ if (Number.isFinite(value)) return value;
1299
+ return (index + 1) * 16384;
1300
+ }
1301
+
1302
+ function stringOrDefault(value, fallback) {
1303
+ return typeof value === "string" ? value : fallback;
1304
+ }
1305
+
1306
+ function nonEmptyString(value) {
1307
+ return typeof value === "string" && value.trim() ? value.trim() : "";
1308
+ }
1309
+
1310
+ function initialsFor(name) {
1311
+ return String(name)
1312
+ .trim()
1313
+ .split(/\s+/)
1314
+ .slice(0, 2)
1315
+ .map((part) => part[0]?.toUpperCase() || "")
1316
+ .join("") || "U";
1317
+ }
1318
+
1319
+ function slugify(value) {
1320
+ let result = "";
1321
+ let previousWasDash = true;
1322
+ for (const character of String(value).toLowerCase()) {
1323
+ const isAlphaNumeric = (character >= "a" && character <= "z") || (character >= "0" && character <= "9");
1324
+ if (isAlphaNumeric) {
1325
+ result += character;
1326
+ previousWasDash = false;
1327
+ } else if (!previousWasDash) {
1328
+ result += "-";
1329
+ previousWasDash = true;
1330
+ }
1331
+ }
1332
+ const trimmed = previousWasDash ? result.slice(0, -1) : result;
1333
+ return trimmed || "user";
1334
+ }
1335
+
1336
+ function createHexId() {
1337
+ return crypto.randomBytes(12).toString("hex");
1338
+ }
1339
+
1340
+ async function exists(filePath) {
1341
+ try {
1342
+ await fs.access(filePath);
1343
+ return true;
1344
+ } catch {
1345
+ return false;
1346
+ }
1347
+ }