buddy-workbench 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,8 @@
18
18
  "dev": "node --watch server.js",
19
19
  "build": "npm --prefix ui run build",
20
20
  "dev:ui": "npm --prefix ui run dev",
21
- "prepublishOnly": "npm run build"
21
+ "prepublishOnly": "npm run build",
22
+ "version": "node -e \"const v=process.env.npm_package_version; const fs=require('fs'); ['ui/package.json', 'ui/package-lock.json'].forEach(p=>{if(fs.existsSync(p)){const j=JSON.parse(fs.readFileSync(p)); j.version=v; if(j.packages&&j.packages['']){j.packages[''].version=v;} fs.writeFileSync(p, JSON.stringify(j,null,2)+'\\n');}});\" && git add ui/package.json ui/package-lock.json"
22
23
  },
23
24
  "dependencies": {
24
25
  "axios": "^1.7.9",
@@ -0,0 +1,42 @@
1
+ import { Router } from 'express';
2
+ import {
3
+ getBookmarkSyncStatus,
4
+ previewSync,
5
+ performSync
6
+ } from '../services/bookmark-sync.js';
7
+
8
+ const router = Router();
9
+
10
+ // Get status of Chrome & Edge bookmark files & processes
11
+ router.get('/status', (req, res, next) => {
12
+ try {
13
+ const status = getBookmarkSyncStatus();
14
+ res.json(status);
15
+ } catch (error) {
16
+ next(error);
17
+ }
18
+ });
19
+
20
+ // Preview sync changes
21
+ router.get('/preview', (req, res, next) => {
22
+ try {
23
+ const mode = req.query.mode || 'two-way';
24
+ const preview = previewSync(mode);
25
+ res.json(preview);
26
+ } catch (error) {
27
+ next(error);
28
+ }
29
+ });
30
+
31
+ // Perform actual sync
32
+ router.post('/sync', (req, res, next) => {
33
+ try {
34
+ const mode = req.body?.mode || 'two-way';
35
+ const result = performSync(mode);
36
+ res.json(result);
37
+ } catch (error) {
38
+ next(error);
39
+ }
40
+ });
41
+
42
+ export default router;
@@ -1,8 +1,8 @@
1
1
  import { Router } from 'express';
2
2
  import { listLaunchers, saveLaunchers } from '../repositories/launchers.js';
3
- import { runScript, runningErrorCounts, runningScripts, scriptErrorLogs, scriptLogs, stopScript } from '../services/process-manager.js';
3
+ import { clearScriptLogs, runScript, runningErrorCounts, runningScripts, scriptErrorLogs, scriptLogs, stopScript } from '../services/process-manager.js';
4
4
  import { readPackageScripts } from '../services/package-scripts.js';
5
- import { currentGitBranch } from '../services/git.js';
5
+ import { currentGitBranch, getGitRemoteUrl, toWebRepoUrl } from '../services/git.js';
6
6
 
7
7
  const router = Router();
8
8
  function validLauncher(body) {
@@ -17,7 +17,18 @@ const invalid = (res) => res.status(400).json({ error: 'Name, project folder, an
17
17
 
18
18
  router.get('/', async (_req, res) => {
19
19
  const launchers = listLaunchers();
20
- const items = await Promise.all(launchers.map(async (launcher) => ({ ...launcher, packageScriptCount: readPackageScripts(launcher.folder).length, branch: await currentGitBranch(launcher.folder) })));
20
+ const items = await Promise.all(launchers.map(async (launcher) => {
21
+ const [branch, remoteUrl] = await Promise.all([
22
+ currentGitBranch(launcher.folder),
23
+ getGitRemoteUrl(launcher.folder)
24
+ ]);
25
+ return {
26
+ ...launcher,
27
+ packageScriptCount: readPackageScripts(launcher.folder).length,
28
+ branch,
29
+ repoUrl: toWebRepoUrl(remoteUrl)
30
+ };
31
+ }));
21
32
  res.json(items);
22
33
  });
23
34
  router.post('/', (req, res) => { const config = validLauncher(req.body); if (!config) return invalid(res); const launcher = { id: crypto.randomUUID(), ...config }; const launchers = listLaunchers(); saveLaunchers([...launchers, launcher]); res.status(201).json(launcher); });
@@ -39,8 +50,10 @@ router.post('/:id/install/run', (req, res) => { const launcher = listLaunchers()
39
50
  router.post('/:id/package-scripts/:scriptName/run', (req, res) => { const launcher = listLaunchers().find((item) => item.id === req.params.id); if (!launcher) return res.status(404).json({ error: 'Configuration not found.' }); const script = readPackageScripts(launcher.folder).find((item) => item.name === req.params.scriptName); if (!script) return res.status(404).json({ error: 'Package script not found.' }); try { runScript(launcher, { ...script, command: `${launcher.executor} run ${script.name}` }); res.json({ ok: true }); } catch (error) { res.status(error.message.includes('already') ? 409 : 400).json({ error: error.message }); } });
40
51
  router.get('/:id/package-scripts/:scriptName/logs', (req, res) => { const script = readPackageScripts(listLaunchers().find((item) => item.id === req.params.id)?.folder || '').find((item) => item.name === req.params.scriptName); if (!script) return res.status(404).json({ error: 'Package script not found.' }); res.json({ log: scriptLogs(req.params.id, script.id) }); });
41
52
  router.get('/:id/package-scripts/:scriptName/logs/error', (req, res) => { const script = readPackageScripts(listLaunchers().find((item) => item.id === req.params.id)?.folder || '').find((item) => item.name === req.params.scriptName); if (!script) return res.status(404).json({ error: 'Package script not found.' }); res.json({ log: scriptErrorLogs(req.params.id, script.id) }); });
53
+ router.delete('/:id/package-scripts/:scriptName/logs', (req, res) => { const script = readPackageScripts(listLaunchers().find((item) => item.id === req.params.id)?.folder || '').find((item) => item.name === req.params.scriptName); if (!script) return res.status(404).json({ error: 'Package script not found.' }); clearScriptLogs(req.params.id, script.id); res.json({ ok: true }); });
42
54
  router.get('/:id/scripts/:scriptId/logs', (req, res) => res.json({ log: scriptLogs(req.params.id, req.params.scriptId) }));
43
55
  router.get('/:id/scripts/:scriptId/logs/error', (req, res) => res.json({ log: scriptErrorLogs(req.params.id, req.params.scriptId) }));
56
+ router.delete('/:id/scripts/:scriptId/logs', (req, res) => { clearScriptLogs(req.params.id, req.params.scriptId); res.json({ ok: true }); });
44
57
  router.post('/:id/scripts/:scriptId/run', (req, res) => { const launcher = listLaunchers().find((item) => item.id === req.params.id); if (!launcher) return res.status(404).json({ error: 'Configuration not found.' }); const script = launcher.scripts.find((item) => item.id === req.params.scriptId); if (!script) return res.status(404).json({ error: 'Script not found.' }); try { runScript(launcher, script); res.json({ ok: true }); } catch (error) { res.status(error.message.includes('already') ? 409 : 400).json({ error: error.message }); } });
45
58
  router.post('/:id/scripts/:scriptId/stop', (req, res) => { try { stopScript(req.params.id, req.params.scriptId); res.json({ ok: true }); } catch (error) { res.status(404).json({ error: error.message }); } });
46
59
 
@@ -0,0 +1,435 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import { execSync } from 'node:child_process';
6
+
7
+ /**
8
+ * Resolves standard bookmark file paths for Chrome and Edge based on OS.
9
+ */
10
+ export function getBrowserBookmarkPaths() {
11
+ const home = os.homedir();
12
+ const platform = os.platform();
13
+
14
+ let chromePath = '';
15
+ let edgePath = '';
16
+
17
+ if (platform === 'darwin') {
18
+ chromePath = path.join(home, 'Library', 'Application Support', 'Google', 'Chrome', 'Default', 'Bookmarks');
19
+ edgePath = path.join(home, 'Library', 'Application Support', 'Microsoft Edge', 'Default', 'Bookmarks');
20
+ } else if (platform === 'win32') {
21
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
22
+ chromePath = path.join(localAppData, 'Google', 'Chrome', 'User Data', 'Default', 'Bookmarks');
23
+ edgePath = path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'Default', 'Bookmarks');
24
+ } else {
25
+ chromePath = path.join(home, '.config', 'google-chrome', 'Default', 'Bookmarks');
26
+ edgePath = path.join(home, '.config', 'microsoft-edge', 'Default', 'Bookmarks');
27
+ }
28
+
29
+ return { chromePath, edgePath };
30
+ }
31
+
32
+ /**
33
+ * Checks if a browser process is currently running.
34
+ */
35
+ export function checkBrowserRunning(browserName) {
36
+ try {
37
+ const processPattern = browserName === 'chrome' ? 'Google Chrome' : 'Microsoft Edge';
38
+ const output = execSync(`pgrep -f "${processPattern}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
39
+ return output.trim().length > 0;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Reads and parses a Chromium bookmark file.
47
+ */
48
+ export function readBookmarkFile(filePath) {
49
+ if (!fs.existsSync(filePath)) {
50
+ return null;
51
+ }
52
+ try {
53
+ const content = fs.readFileSync(filePath, 'utf8');
54
+ return JSON.parse(content);
55
+ } catch (error) {
56
+ throw new Error(`Failed to parse bookmark file at ${filePath}: ${error.message}`);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Extracts all URLs from a bookmark tree or roots object into a Map keyed by normalized URL.
62
+ */
63
+ export function getAllUrls(node, urlMap = new Map()) {
64
+ if (!node) return urlMap;
65
+ if (node.type === 'url' && node.url) {
66
+ const normalizedUrl = normalizeUrl(node.url);
67
+ if (!urlMap.has(normalizedUrl)) {
68
+ urlMap.set(normalizedUrl, node);
69
+ }
70
+ }
71
+ if (Array.isArray(node.children)) {
72
+ for (const child of node.children) {
73
+ getAllUrls(child, urlMap);
74
+ }
75
+ } else if (typeof node === 'object') {
76
+ for (const key of Object.keys(node)) {
77
+ if (typeof node[key] === 'object' && node[key] !== null) {
78
+ getAllUrls(node[key], urlMap);
79
+ }
80
+ }
81
+ }
82
+ return urlMap;
83
+ }
84
+
85
+ /**
86
+ * Standardizes URL for comparison (removes trailing slash, case-insensitive protocol/domain).
87
+ */
88
+ function normalizeUrl(rawUrl) {
89
+ try {
90
+ const parsed = new URL(rawUrl);
91
+ parsed.hash = '';
92
+ let href = parsed.href;
93
+ if (href.endsWith('/')) href = href.slice(0, -1);
94
+ return href.toLowerCase();
95
+ } catch {
96
+ return (rawUrl || '').trim().toLowerCase().replace(/\/$/, '');
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Finds maximum numeric ID used in a bookmark tree.
102
+ */
103
+ function getMaxId(node) {
104
+ let max = 0;
105
+ if (!node) return max;
106
+ const numericId = parseInt(node.id, 10);
107
+ if (!isNaN(numericId) && numericId > max) {
108
+ max = numericId;
109
+ }
110
+ if (Array.isArray(node.children)) {
111
+ for (const child of node.children) {
112
+ const childMax = getMaxId(child);
113
+ if (childMax > max) max = childMax;
114
+ }
115
+ } else if (typeof node === 'object') {
116
+ for (const key of Object.keys(node)) {
117
+ if (typeof node[key] === 'object' && node[key] !== null) {
118
+ const keyMax = getMaxId(node[key]);
119
+ if (keyMax > max) max = keyMax;
120
+ }
121
+ }
122
+ }
123
+ return max;
124
+ }
125
+
126
+ /**
127
+ * Collects list of all bookmark items from a node or roots object with their folder hierarchy path.
128
+ */
129
+ export function extractBookmarksList(rootContainer, folderPath = []) {
130
+ let list = [];
131
+ if (!rootContainer) return list;
132
+
133
+ if (Array.isArray(rootContainer.children)) {
134
+ for (const child of rootContainer.children) {
135
+ if (child.type === 'folder') {
136
+ const currentPath = [...folderPath, child.name];
137
+ list = list.concat(extractBookmarksList(child, currentPath));
138
+ } else if (child.type === 'url' && child.url) {
139
+ list.push({
140
+ name: child.name || 'Untitled',
141
+ url: child.url,
142
+ folderPath: [...folderPath]
143
+ });
144
+ }
145
+ }
146
+ } else if (typeof rootContainer === 'object') {
147
+ for (const key of Object.keys(rootContainer)) {
148
+ if (typeof rootContainer[key] === 'object' && rootContainer[key] !== null) {
149
+ const rootFolder = rootContainer[key];
150
+ const initialPath = rootFolder.name ? [...folderPath] : folderPath;
151
+ list = list.concat(extractBookmarksList(rootFolder, initialPath));
152
+ }
153
+ }
154
+ }
155
+
156
+ return list;
157
+ }
158
+
159
+ /**
160
+ * Ensures target folder hierarchy exists in rootNode, returning the destination folder node.
161
+ */
162
+ function ensureFolderPath(rootNode, folderPath, maxIdRef) {
163
+ let current = rootNode;
164
+ if (!current.children) current.children = [];
165
+
166
+ for (const folderName of folderPath) {
167
+ let childFolder = current.children.find(c => c.type === 'folder' && c.name === folderName);
168
+ if (!childFolder) {
169
+ maxIdRef.current += 1;
170
+ childFolder = {
171
+ children: [],
172
+ date_added: String(Date.now() * 1000),
173
+ date_modified: String(Date.now() * 1000),
174
+ guid: crypto.randomUUID(),
175
+ id: String(maxIdRef.current),
176
+ name: folderName,
177
+ type: 'folder'
178
+ };
179
+ current.children.push(childFolder);
180
+ }
181
+ current = childFolder;
182
+ if (!current.children) current.children = [];
183
+ }
184
+ return current;
185
+ }
186
+
187
+ /**
188
+ * Merges source items into target Chromium bookmark JSON structure.
189
+ * Returns { updatedJson, addedItems }
190
+ */
191
+ export function mergeBookmarksIntoTree(targetJson, sourceItems) {
192
+ if (!targetJson || !targetJson.roots) {
193
+ // If target JSON doesn't exist or is invalid, build a fresh Chromium roots structure
194
+ targetJson = {
195
+ checksum: '',
196
+ roots: {
197
+ bookmark_bar: {
198
+ children: [],
199
+ date_added: String(Date.now() * 1000),
200
+ date_modified: String(Date.now() * 1000),
201
+ guid: crypto.randomUUID(),
202
+ id: '1',
203
+ name: '书签栏',
204
+ type: 'folder'
205
+ },
206
+ other: {
207
+ children: [],
208
+ date_added: String(Date.now() * 1000),
209
+ date_modified: String(Date.now() * 1000),
210
+ guid: crypto.randomUUID(),
211
+ id: '2',
212
+ name: '其他书签',
213
+ type: 'folder'
214
+ },
215
+ synced: {
216
+ children: [],
217
+ date_added: String(Date.now() * 1000),
218
+ date_modified: String(Date.now() * 1000),
219
+ guid: crypto.randomUUID(),
220
+ id: '3',
221
+ name: '移动设备书签',
222
+ type: 'folder'
223
+ }
224
+ },
225
+ version: 1
226
+ };
227
+ }
228
+
229
+ if (!targetJson.roots.bookmark_bar) {
230
+ targetJson.roots.bookmark_bar = {
231
+ children: [],
232
+ date_added: String(Date.now() * 1000),
233
+ date_modified: String(Date.now() * 1000),
234
+ guid: crypto.randomUUID(),
235
+ id: '1',
236
+ name: '书签栏',
237
+ type: 'folder'
238
+ };
239
+ }
240
+
241
+ // Get existing URLs across the entire target tree
242
+ const existingUrlMap = getAllUrls(targetJson.roots);
243
+
244
+ const maxIdRef = { current: getMaxId(targetJson.roots) };
245
+ const addedItems = [];
246
+
247
+ for (const item of sourceItems) {
248
+ const normUrl = normalizeUrl(item.url);
249
+ if (existingUrlMap.has(normUrl)) {
250
+ continue; // Skip existing bookmark
251
+ }
252
+
253
+ // Destination folder under bookmark_bar
254
+ const destFolder = ensureFolderPath(targetJson.roots.bookmark_bar, item.folderPath, maxIdRef);
255
+
256
+ maxIdRef.current += 1;
257
+ const newBookmarkNode = {
258
+ date_added: String(Date.now() * 1000),
259
+ guid: crypto.randomUUID(),
260
+ id: String(maxIdRef.current),
261
+ name: item.name,
262
+ type: 'url',
263
+ url: item.url
264
+ };
265
+
266
+ destFolder.children.push(newBookmarkNode);
267
+ existingUrlMap.set(normUrl, newBookmarkNode);
268
+ addedItems.push({
269
+ name: item.name,
270
+ url: item.url,
271
+ folderPath: item.folderPath.join(' / ') || '书签栏'
272
+ });
273
+ }
274
+
275
+ return { updatedJson: targetJson, addedItems };
276
+ }
277
+
278
+ /**
279
+ * Gets overview status of Chrome & Edge bookmarks.
280
+ */
281
+ export function getBookmarkSyncStatus() {
282
+ const { chromePath, edgePath } = getBrowserBookmarkPaths();
283
+
284
+ const chromeExists = fs.existsSync(chromePath);
285
+ const edgeExists = fs.existsSync(edgePath);
286
+
287
+ const chromeRunning = checkBrowserRunning('chrome');
288
+ const edgeRunning = checkBrowserRunning('edge');
289
+
290
+ let chromeCount = 0;
291
+ let edgeCount = 0;
292
+
293
+ if (chromeExists) {
294
+ const chromeJson = readBookmarkFile(chromePath);
295
+ if (chromeJson && chromeJson.roots) {
296
+ const urls = getAllUrls(chromeJson.roots);
297
+ chromeCount = urls.size;
298
+ }
299
+ }
300
+
301
+ if (edgeExists) {
302
+ const edgeJson = readBookmarkFile(edgePath);
303
+ if (edgeJson && edgeJson.roots) {
304
+ const urls = getAllUrls(edgeJson.roots);
305
+ edgeCount = urls.size;
306
+ }
307
+ }
308
+
309
+ return {
310
+ chrome: {
311
+ path: chromePath,
312
+ exists: chromeExists,
313
+ running: chromeRunning,
314
+ count: chromeCount
315
+ },
316
+ edge: {
317
+ path: edgePath,
318
+ exists: edgeExists,
319
+ running: edgeRunning,
320
+ count: edgeCount
321
+ }
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Previews what items would be added in sync.
327
+ */
328
+ export function previewSync(mode = 'two-way') {
329
+ const { chromePath, edgePath } = getBrowserBookmarkPaths();
330
+ const chromeJson = readBookmarkFile(chromePath);
331
+ const edgeJson = readBookmarkFile(edgePath);
332
+
333
+ if (!chromeJson && !edgeJson) {
334
+ throw new Error('Neither Chrome nor Edge bookmark files were found.');
335
+ }
336
+
337
+ const chromeItems = chromeJson && chromeJson.roots ? extractBookmarksList(chromeJson.roots) : [];
338
+ const edgeItems = edgeJson && edgeJson.roots ? extractBookmarksList(edgeJson.roots) : [];
339
+
340
+ let toAddEdge = [];
341
+ let toAddChrome = [];
342
+
343
+ if (mode === 'two-way' || mode === 'chrome-to-edge') {
344
+ const baseEdgeJson = edgeJson || null;
345
+ const { addedItems } = mergeBookmarksIntoTree(baseEdgeJson ? structuredClone(baseEdgeJson) : null, chromeItems);
346
+ toAddEdge = addedItems;
347
+ }
348
+
349
+ if (mode === 'two-way' || mode === 'edge-to-chrome') {
350
+ const baseChromeJson = chromeJson || null;
351
+ const { addedItems } = mergeBookmarksIntoTree(baseChromeJson ? structuredClone(baseChromeJson) : null, edgeItems);
352
+ toAddChrome = addedItems;
353
+ }
354
+
355
+ return {
356
+ mode,
357
+ toAddEdge,
358
+ toAddChrome
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Performs actual sync and creates backup files.
364
+ */
365
+ export function performSync(mode = 'two-way') {
366
+ const { chromePath, edgePath } = getBrowserBookmarkPaths();
367
+ const chromeJson = readBookmarkFile(chromePath);
368
+ const edgeJson = readBookmarkFile(edgePath);
369
+
370
+ if (!chromeJson && !edgeJson) {
371
+ throw new Error('Neither Chrome nor Edge bookmark files were found.');
372
+ }
373
+
374
+ const chromeItems = chromeJson && chromeJson.roots ? extractBookmarksList(chromeJson.roots) : [];
375
+ const edgeItems = edgeJson && edgeJson.roots ? extractBookmarksList(edgeJson.roots) : [];
376
+
377
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
378
+ const backupsCreated = [];
379
+
380
+ let addedToEdge = [];
381
+ let addedToChrome = [];
382
+
383
+ // Sync Chrome -> Edge
384
+ if (mode === 'two-way' || mode === 'chrome-to-edge') {
385
+ const { updatedJson, addedItems } = mergeBookmarksIntoTree(edgeJson ? structuredClone(edgeJson) : null, chromeItems);
386
+ addedToEdge = addedItems;
387
+
388
+ if (addedItems.length > 0) {
389
+ if (fs.existsSync(edgePath)) {
390
+ const edgeBak = `${edgePath}.bak_${timestamp}`;
391
+ fs.copyFileSync(edgePath, edgeBak);
392
+ backupsCreated.push(edgeBak);
393
+ } else {
394
+ // Ensure directory exists if creating new edge file
395
+ const edgeDir = path.dirname(edgePath);
396
+ if (!fs.existsSync(edgeDir)) {
397
+ fs.mkdirSync(edgeDir, { recursive: true });
398
+ }
399
+ }
400
+
401
+ fs.writeFileSync(edgePath, JSON.stringify(updatedJson, null, 2), 'utf8');
402
+ }
403
+ }
404
+
405
+ // Sync Edge -> Chrome
406
+ if (mode === 'two-way' || mode === 'edge-to-chrome') {
407
+ const { updatedJson, addedItems } = mergeBookmarksIntoTree(chromeJson ? structuredClone(chromeJson) : null, edgeItems);
408
+ addedToChrome = addedItems;
409
+
410
+ if (addedItems.length > 0) {
411
+ if (fs.existsSync(chromePath)) {
412
+ const chromeBak = `${chromePath}.bak_${timestamp}`;
413
+ fs.copyFileSync(chromePath, chromeBak);
414
+ backupsCreated.push(chromeBak);
415
+ } else {
416
+ const chromeDir = path.dirname(chromePath);
417
+ if (!fs.existsSync(chromeDir)) {
418
+ fs.mkdirSync(chromeDir, { recursive: true });
419
+ }
420
+ }
421
+
422
+ fs.writeFileSync(chromePath, JSON.stringify(updatedJson, null, 2), 'utf8');
423
+ }
424
+ }
425
+
426
+ return {
427
+ success: true,
428
+ timestamp,
429
+ backupsCreated,
430
+ addedToEdgeCount: addedToEdge.length,
431
+ addedToChromeCount: addedToChrome.length,
432
+ addedToEdge,
433
+ addedToChrome
434
+ };
435
+ }
@@ -12,3 +12,79 @@ export async function currentGitBranch(folder) {
12
12
  return null;
13
13
  }
14
14
  }
15
+
16
+ export async function getGitRemoteUrl(folder) {
17
+ if (!folder) return null;
18
+ try {
19
+ const { stdout } = await execFileAsync('git', ['config', '--get', 'remote.origin.url'], { cwd: folder, timeout: 1500 });
20
+ const url = stdout.trim();
21
+ if (url) return url;
22
+ } catch {}
23
+ try {
24
+ const { stdout } = await execFileAsync('git', ['remote', '-v'], { cwd: folder, timeout: 1500 });
25
+ const match = stdout.match(/\S+\s+(\S+)\s+\(fetch\)/);
26
+ if (match) return match[1].trim();
27
+ } catch {}
28
+ return null;
29
+ }
30
+
31
+ export function toWebRepoUrl(remoteUrl) {
32
+ if (!remoteUrl || typeof remoteUrl !== 'string') return null;
33
+ let raw = remoteUrl.trim();
34
+ if (!raw) return null;
35
+
36
+ // Remove trailing .git
37
+ raw = raw.replace(/\.git$/i, '');
38
+
39
+ // Handle SCP-like SSH syntax: git@host:owner/repo
40
+ const scpMatch = raw.match(/^([a-zA-Z0-9_.-]+@)?([^:]+):(.+)$/);
41
+ if (!raw.includes('://') && scpMatch) {
42
+ const host = scpMatch[2];
43
+ let path = scpMatch[3].replace(/^\/+/, '');
44
+
45
+ if (host.includes('bitbucket') && !host.includes('bitbucket.org')) {
46
+ const parts = path.split('/');
47
+ if (parts.length === 2 && parts[0] !== 'projects' && parts[0] !== 'users') {
48
+ const projKey = parts[0].startsWith('~') ? `users/${parts[0].slice(1)}` : `projects/${parts[0]}`;
49
+ path = `${projKey}/repos/${parts[1]}`;
50
+ } else if (parts.length === 3 && parts[0] === 'scm') {
51
+ const projKey = parts[1].startsWith('~') ? `users/${parts[1].slice(1)}` : `projects/${parts[1]}`;
52
+ path = `${projKey}/repos/${parts[2]}`;
53
+ }
54
+ }
55
+ return `https://${host}/${path}`;
56
+ }
57
+
58
+ // Handle URLs with protocols (ssh://, http://, https://)
59
+ try {
60
+ let urlStr = raw;
61
+ const isSshProto = urlStr.startsWith('ssh://');
62
+ if (isSshProto) {
63
+ urlStr = urlStr.replace(/^ssh:\/\//i, 'https://');
64
+ }
65
+ // Strip userinfo (e.g. git@ or user:pass@)
66
+ urlStr = urlStr.replace(/^(https?:\/\/)(([^/@]+)@)/i, '$1');
67
+
68
+ const urlObj = new URL(urlStr);
69
+ const host = urlObj.hostname;
70
+ let pathname = urlObj.pathname.replace(/^\/+/, '');
71
+
72
+ if (host.includes('bitbucket') && !host.includes('bitbucket.org')) {
73
+ const parts = pathname.split('/');
74
+ if (parts.length === 2 && parts[0] !== 'projects' && parts[0] !== 'users') {
75
+ const projKey = parts[0].startsWith('~') ? `users/${parts[0].slice(1)}` : `projects/${parts[0]}`;
76
+ pathname = `${projKey}/repos/${parts[1]}`;
77
+ } else if (parts.length === 3 && parts[0] === 'scm') {
78
+ const projKey = parts[1].startsWith('~') ? `users/${parts[1].slice(1)}` : `projects/${parts[1]}`;
79
+ pathname = `${projKey}/repos/${parts[2]}`;
80
+ }
81
+ }
82
+
83
+ const protocol = raw.startsWith('http://') ? 'http:' : 'https:';
84
+ const portStr = (urlObj.port && !isSshProto) ? `:${urlObj.port}` : '';
85
+ return `${protocol}//${host}${portStr}/${pathname}`;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
@@ -60,7 +60,6 @@ export function countErrorBlocks(text, isStderr = false) {
60
60
  }
61
61
 
62
62
  const isErrorLine =
63
- isStderr ||
64
63
  isNewHeader ||
65
64
  /\b(error|failed|fatal|exception)\b/i.test(cleanLine) ||
66
65
  /^npm ERR!/i.test(cleanLine) ||
@@ -83,8 +82,11 @@ export function countErrorBlocks(text, isStderr = false) {
83
82
  const appendLog = (key, type, chunk) => {
84
83
  const current = logs.get(key) || { output: '', error: '', errorCount: 0 };
85
84
  const str = String(chunk || '');
86
- current[type] = `${current[type]}${str}`.split(/\r?\n/).slice(-1000).join('\n');
87
- current.errorCount = countErrorBlocks(current.error, true) + countErrorBlocks(current.output, false);
85
+ if (type === 'error' || type === 'stderr') {
86
+ current.error = `${current.error}${str}`.split(/\r?\n/).slice(-1000).join('\n');
87
+ }
88
+ current.output = `${current.output}${str}`.split(/\r?\n/).slice(-1000).join('\n');
89
+ current.errorCount = countErrorBlocks(current.output, false);
88
90
  logs.set(key, current);
89
91
  };
90
92
 
@@ -98,6 +100,7 @@ export function runningErrorCounts() {
98
100
  }
99
101
  export function scriptLogs(launcherId, scriptId) { return logs.get(keyFor(launcherId, scriptId))?.output || ''; }
100
102
  export function scriptErrorLogs(launcherId, scriptId) { return logs.get(keyFor(launcherId, scriptId))?.error || ''; }
103
+ export function clearScriptLogs(launcherId, scriptId) { logs.set(keyFor(launcherId, scriptId), { output: '', error: '', errorCount: 0 }); }
101
104
 
102
105
  async function listeningProcesses() {
103
106
  if (process.platform === 'win32') return [];
package/server.js CHANGED
@@ -18,6 +18,7 @@ import todoRoutes from './server/routes/todos.js';
18
18
  import staticPagesRoutes from './server/routes/static-pages.js';
19
19
  import errorRoutes from './server/routes/errors.js';
20
20
  import postmanRoutes from './server/routes/postman.js';
21
+ import bookmarkSyncRoutes from './server/routes/bookmark-sync.js';
21
22
  import { addErrorRecord } from './server/repositories/errors.js';
22
23
  import { startClipboardCapture } from './server/services/clipboard-history.js';
23
24
 
@@ -71,6 +72,7 @@ app.use('/api/todos', todoRoutes);
71
72
  app.use('/api/static-pages', staticPagesRoutes);
72
73
  app.use('/api/errors', errorRoutes);
73
74
  app.use('/api/postman', postmanRoutes);
75
+ app.use('/api/bookmark-sync', bookmarkSyncRoutes);
74
76
 
75
77
  app.use((err, req, res, _next) => {
76
78
  addErrorRecord({