buddy-workbench 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/package.json +25 -0
- package/plugins/example/plugin.json +5 -0
- package/plugins/example/view.html +5 -0
- package/server/config.js +14 -0
- package/server/lib/free-port.js +27 -0
- package/server/repositories/group-tasks.js +6 -0
- package/server/repositories/launchers.js +19 -0
- package/server/repositories/port-history.js +16 -0
- package/server/repositories/settings.js +44 -0
- package/server/routes/clipboard.js +8 -0
- package/server/routes/group-tasks.js +17 -0
- package/server/routes/launchers.js +47 -0
- package/server/routes/plugins.js +6 -0
- package/server/routes/port-diagnostics.js +25 -0
- package/server/routes/pr-review.js +378 -0
- package/server/routes/settings.js +18 -0
- package/server/services/clipboard-history.js +85 -0
- package/server/services/git.js +14 -0
- package/server/services/package-scripts.js +11 -0
- package/server/services/plugins.js +15 -0
- package/server/services/process-manager.js +195 -0
- package/server/services/script-supervisor.js +28 -0
- package/server.js +55 -0
- package/ui/dist/assets/index-CCCOP2nr.js +496 -0
- package/ui/dist/assets/index-Dty-56mC.js +3 -0
- package/ui/dist/assets/index-VPzoCQox.css +1 -0
- package/ui/dist/assets/mozjpeg_dec-muSO2n8T.wasm +0 -0
- package/ui/dist/assets/mozjpeg_enc-DO-zoExo.wasm +0 -0
- package/ui/dist/devbuddy.svg +29 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/manifest.webmanifest +17 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { readSettings } from '../repositories/settings.js';
|
|
3
|
+
|
|
4
|
+
const router = Router();
|
|
5
|
+
const previewLimit = 2000;
|
|
6
|
+
let lastUsedHost = '';
|
|
7
|
+
|
|
8
|
+
function getBitbucketHost() {
|
|
9
|
+
if (lastUsedHost) return lastUsedHost;
|
|
10
|
+
const settings = readSettings();
|
|
11
|
+
const domain = settings.domain || '';
|
|
12
|
+
if (!domain) return '';
|
|
13
|
+
if (domain.includes('bitbucket')) return domain;
|
|
14
|
+
return `bitbucket.${domain}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseBitbucketPrUrl(url) {
|
|
18
|
+
const match = url.match(/https?:\/\/([^/]+)\/projects\/([^/]+)\/repos\/([^/]+)\/pull-requests\/(\d+)/i);
|
|
19
|
+
if (match) {
|
|
20
|
+
return {
|
|
21
|
+
host: match[1],
|
|
22
|
+
projectKey: match[2],
|
|
23
|
+
repositorySlug: match[3],
|
|
24
|
+
pullRequestId: match[4]
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const userMatch = url.match(/https?:\/\/([^/]+)\/users\/([^/]+)\/repos\/([^/]+)\/pull-requests\/(\d+)/i);
|
|
28
|
+
if (userMatch) {
|
|
29
|
+
return {
|
|
30
|
+
host: userMatch[1],
|
|
31
|
+
projectKey: `~${userMatch[2]}`,
|
|
32
|
+
repositorySlug: userMatch[3],
|
|
33
|
+
pullRequestId: userMatch[4]
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isFiltered = (filePath) => {
|
|
40
|
+
const name = filePath.split('/').pop();
|
|
41
|
+
if (name === 'bd.json' || name === 'package-lock.json') return true;
|
|
42
|
+
if (/\.spec\.[^.]+$/.test(name) || /\.test\.[^.]+$/.test(name)) return true;
|
|
43
|
+
return false;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const analyzeDiff = (filePath, hunks) => {
|
|
47
|
+
const issues = [];
|
|
48
|
+
for (const hunk of hunks || []) {
|
|
49
|
+
for (const segment of hunk.segments || []) {
|
|
50
|
+
if (segment.type !== 'ADDED') continue;
|
|
51
|
+
for (const line of segment.lines || []) {
|
|
52
|
+
const text = line.text || '';
|
|
53
|
+
const lineNum = line.destinationLine;
|
|
54
|
+
|
|
55
|
+
// Rule 1: No debugger
|
|
56
|
+
if (/\bdebugger\b/.test(text)) {
|
|
57
|
+
issues.push({
|
|
58
|
+
severity: 'critical',
|
|
59
|
+
rule: 'No Debugger',
|
|
60
|
+
message: 'Avoid leaving `debugger;` statements in production code.',
|
|
61
|
+
line: lineNum,
|
|
62
|
+
code: text
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Rule 2: Conflict Markers
|
|
67
|
+
if (/^<<<<<<<|^=======|^>>>>>>>/.test(text)) {
|
|
68
|
+
issues.push({
|
|
69
|
+
severity: 'critical',
|
|
70
|
+
rule: 'Conflict Markers',
|
|
71
|
+
message: 'Unresolved git conflict markers found.',
|
|
72
|
+
line: lineNum,
|
|
73
|
+
code: text
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Rule 3: Focused tests
|
|
78
|
+
if (/\.(only|skip)\(/.test(text) && /\.(test|spec)\./i.test(filePath)) {
|
|
79
|
+
issues.push({
|
|
80
|
+
severity: 'critical',
|
|
81
|
+
rule: 'Focused/Skipped Test',
|
|
82
|
+
message: 'Do not commit focused (`.only`) or skipped (`.skip`) tests.',
|
|
83
|
+
line: lineNum,
|
|
84
|
+
code: text
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Rule 4: Secrets
|
|
89
|
+
if (/(password|passwd|secret|token|api_key|apikey|private_key|auth_token)\s*[:=]\s*['"`][a-zA-Z0-9_\-+=/]{16,}['"`]/i.test(text)) {
|
|
90
|
+
if (!text.trim().startsWith('//') && !text.includes('placeholder') && !text.includes('dummy')) {
|
|
91
|
+
issues.push({
|
|
92
|
+
severity: 'critical',
|
|
93
|
+
rule: 'Hardcoded Secret',
|
|
94
|
+
message: 'Potential hardcoded token, password, or key detected.',
|
|
95
|
+
line: lineNum,
|
|
96
|
+
code: text
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Rule 5: Console log
|
|
102
|
+
if (/\bconsole\.log\(/.test(text)) {
|
|
103
|
+
issues.push({
|
|
104
|
+
severity: 'warning',
|
|
105
|
+
rule: 'No Console Log',
|
|
106
|
+
message: 'Consider removing `console.log` statements before merging.',
|
|
107
|
+
line: lineNum,
|
|
108
|
+
code: text
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Rule 6: TODO/FIXME
|
|
113
|
+
if (/\b(TODO|FIXME)\b/i.test(text)) {
|
|
114
|
+
issues.push({
|
|
115
|
+
severity: 'info',
|
|
116
|
+
rule: 'Pending Task',
|
|
117
|
+
message: 'Track open TODO or FIXME items.',
|
|
118
|
+
line: lineNum,
|
|
119
|
+
code: text
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Rule 7: IP Addresses
|
|
124
|
+
const ipMatch = text.match(/\b(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3})\b/);
|
|
125
|
+
if (ipMatch) {
|
|
126
|
+
issues.push({
|
|
127
|
+
severity: 'warning',
|
|
128
|
+
rule: 'Hardcoded IP Address',
|
|
129
|
+
message: `Avoid hardcoding internal/private IP addresses (${ipMatch[0]}).`,
|
|
130
|
+
line: lineNum,
|
|
131
|
+
code: text
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Rule 8: Hardcoded user-facing text (Chinese / Non-ascii characters)
|
|
136
|
+
if (/[\u4e00-\u9fa5]+/.test(text)) {
|
|
137
|
+
if (!text.trim().startsWith('//') && !text.trim().startsWith('*')) {
|
|
138
|
+
issues.push({
|
|
139
|
+
severity: 'warning',
|
|
140
|
+
rule: 'Hardcoded Text',
|
|
141
|
+
message: 'Avoid hardcoding user-facing text strings directly; consider using internationalization (i18n).',
|
|
142
|
+
line: lineNum,
|
|
143
|
+
code: text
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Rule 9: Deep object access without optional chaining (a.b.c)
|
|
149
|
+
const deepAccessRegex = /\b(?!(?:process\.env|e\.target|event\.target|res\.data|response\.data)\b)[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*\b/;
|
|
150
|
+
if (deepAccessRegex.test(text)) {
|
|
151
|
+
issues.push({
|
|
152
|
+
severity: 'warning',
|
|
153
|
+
rule: 'Deep Object Access',
|
|
154
|
+
message: 'Deep object property access (depth > 1) can cause runtime crashes if intermediate properties are nullish. Consider using optional chaining (?.).',
|
|
155
|
+
line: lineNum,
|
|
156
|
+
code: text
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Rule 10: useMemo/useCallback with empty dependency array []
|
|
161
|
+
if (/\buse(Memo|Callback)\b.*,\s*\[\s*\]/.test(text)) {
|
|
162
|
+
issues.push({
|
|
163
|
+
severity: 'warning',
|
|
164
|
+
rule: 'Static Hook Dependency',
|
|
165
|
+
message: 'useMemo/useCallback has an empty dependency array []. Consider declaring the function or value outside the component if it has no dynamic scope dependencies.',
|
|
166
|
+
line: lineNum,
|
|
167
|
+
code: text
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Rule 11: variant="primary" (often a Bootstrap/MUI style leak instead of Button type="primary")
|
|
172
|
+
if (/\bvariant\s*=\s*['"`]primary['"`]/i.test(text)) {
|
|
173
|
+
issues.push({
|
|
174
|
+
severity: 'warning',
|
|
175
|
+
rule: 'Incorrect Button Variant',
|
|
176
|
+
message: '`variant="primary"` is the default variant.',
|
|
177
|
+
line: lineNum,
|
|
178
|
+
code: text
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return issues;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
router.post('/check', async (req, res) => {
|
|
188
|
+
const { prLink } = req.body || {};
|
|
189
|
+
if (!prLink) return res.status(400).json({ error: 'PR Link is required.' });
|
|
190
|
+
|
|
191
|
+
const parsed = parseBitbucketPrUrl(prLink);
|
|
192
|
+
if (!parsed) return res.status(400).json({ error: 'Invalid Bitbucket Pull Request URL.' });
|
|
193
|
+
|
|
194
|
+
const { host, projectKey, repositorySlug, pullRequestId } = parsed;
|
|
195
|
+
lastUsedHost = host;
|
|
196
|
+
const settings = readSettings();
|
|
197
|
+
const token = settings.bitbucketAccessToken;
|
|
198
|
+
|
|
199
|
+
const headers = { 'Accept': 'application/json' };
|
|
200
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
// 1. Fetch Pull Request details to show basic info
|
|
204
|
+
const prUrl = `https://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${pullRequestId}`;
|
|
205
|
+
const prRes = await fetch(prUrl, { headers });
|
|
206
|
+
if (!prRes.ok) {
|
|
207
|
+
if (prRes.status === 401) {
|
|
208
|
+
return res.status(401).json({ error: 'Unauthorized. Please check your Bitbucket Access Token in Settings.' });
|
|
209
|
+
}
|
|
210
|
+
return res.status(prRes.status).json({ error: `Failed to fetch PR info: ${prRes.statusText}` });
|
|
211
|
+
}
|
|
212
|
+
const prInfo = await prRes.json();
|
|
213
|
+
|
|
214
|
+
// 2. Fetch changes
|
|
215
|
+
const changesUrl = `https://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${pullRequestId}/changes?limit=1000`;
|
|
216
|
+
const changesRes = await fetch(changesUrl, { headers });
|
|
217
|
+
if (!changesRes.ok) {
|
|
218
|
+
return res.status(changesRes.status).json({ error: `Failed to fetch PR changes: ${changesRes.statusText}` });
|
|
219
|
+
}
|
|
220
|
+
const changesData = await changesRes.json();
|
|
221
|
+
|
|
222
|
+
const allChanges = changesData.values || [];
|
|
223
|
+
const filteredOut = [];
|
|
224
|
+
const targetChanges = [];
|
|
225
|
+
|
|
226
|
+
for (const change of allChanges) {
|
|
227
|
+
const filePath = change.path.toString;
|
|
228
|
+
if (isFiltered(filePath)) {
|
|
229
|
+
filteredOut.push(filePath);
|
|
230
|
+
} else {
|
|
231
|
+
targetChanges.push(change);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 3. Fetch diffs and run rule-based checks
|
|
236
|
+
const reviewFiles = [];
|
|
237
|
+
let totalIssues = 0;
|
|
238
|
+
let criticalCount = 0;
|
|
239
|
+
let warningCount = 0;
|
|
240
|
+
let infoCount = 0;
|
|
241
|
+
|
|
242
|
+
for (const change of targetChanges) {
|
|
243
|
+
const filePath = change.path.toString;
|
|
244
|
+
const diffUrl = `https://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${pullRequestId}/diff/${encodeURIComponent(filePath)}?context=0`;
|
|
245
|
+
const diffRes = await fetch(diffUrl, { headers });
|
|
246
|
+
if (!diffRes.ok) continue; // skip file if diff cannot be retrieved
|
|
247
|
+
|
|
248
|
+
const diffData = await diffRes.json();
|
|
249
|
+
const fileIssues = analyzeDiff(filePath, diffData.hunks);
|
|
250
|
+
|
|
251
|
+
if (fileIssues.length > 0) {
|
|
252
|
+
reviewFiles.push({
|
|
253
|
+
filePath,
|
|
254
|
+
type: change.type,
|
|
255
|
+
issues: fileIssues
|
|
256
|
+
});
|
|
257
|
+
totalIssues += fileIssues.length;
|
|
258
|
+
criticalCount += fileIssues.filter(i => i.severity === 'critical').length;
|
|
259
|
+
warningCount += fileIssues.filter(i => i.severity === 'warning').length;
|
|
260
|
+
infoCount += fileIssues.filter(i => i.severity === 'info').length;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
res.json({
|
|
265
|
+
pr: {
|
|
266
|
+
title: prInfo.title,
|
|
267
|
+
description: prInfo.description,
|
|
268
|
+
author: prInfo.author?.user?.displayName || 'Unknown',
|
|
269
|
+
sourceBranch: prInfo.fromRef?.displayId || 'Unknown',
|
|
270
|
+
targetBranch: prInfo.toRef?.displayId || 'Unknown',
|
|
271
|
+
projectKey,
|
|
272
|
+
repositorySlug,
|
|
273
|
+
pullRequestId
|
|
274
|
+
},
|
|
275
|
+
stats: {
|
|
276
|
+
totalFilesChanged: allChanges.length,
|
|
277
|
+
filteredCount: filteredOut.length,
|
|
278
|
+
reviewedCount: targetChanges.length,
|
|
279
|
+
totalIssues,
|
|
280
|
+
criticalCount,
|
|
281
|
+
warningCount,
|
|
282
|
+
infoCount
|
|
283
|
+
},
|
|
284
|
+
filteredOut,
|
|
285
|
+
files: reviewFiles
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
} catch (error) {
|
|
289
|
+
res.status(500).json({ error: `Internal error: ${error.message}` });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
function enrichPrList(values, host) {
|
|
294
|
+
return values.map(pr => {
|
|
295
|
+
let href = '';
|
|
296
|
+
if (Array.isArray(pr.links?.self)) {
|
|
297
|
+
href = pr.links.self[0]?.href || '';
|
|
298
|
+
} else if (pr.links?.self?.href) {
|
|
299
|
+
href = pr.links.self.href;
|
|
300
|
+
}
|
|
301
|
+
if (!href && pr.toRef?.repository?.project?.key && pr.toRef?.repository?.slug && pr.id) {
|
|
302
|
+
const proj = pr.toRef.repository.project.key;
|
|
303
|
+
const repo = pr.toRef.repository.slug;
|
|
304
|
+
const isPersonal = proj.startsWith('~');
|
|
305
|
+
const projectPath = isPersonal ? `users/${proj.slice(1)}` : `projects/${proj}`;
|
|
306
|
+
href = `https://${host}/${projectPath}/repos/${repo}/pull-requests/${pr.id}`;
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
...pr,
|
|
310
|
+
reviewUrl: href
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
router.get('/my-prs', async (req, res) => {
|
|
316
|
+
const host = getBitbucketHost();
|
|
317
|
+
if (!host) return res.json({ values: [] });
|
|
318
|
+
|
|
319
|
+
const settings = readSettings();
|
|
320
|
+
const token = settings.bitbucketAccessToken;
|
|
321
|
+
const headers = { 'Accept': 'application/json' };
|
|
322
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const url = `https://${host}/rest/api/1.0/dashboard/pull-requests?role=author&state=OPEN&limit=100`;
|
|
326
|
+
const response = await fetch(url, { headers });
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
if (response.status === 401) {
|
|
329
|
+
return res.status(401).json({ error: 'Unauthorized. Please check your Bitbucket Token.' });
|
|
330
|
+
}
|
|
331
|
+
return res.status(response.status).json({ error: `Bitbucket API error: ${response.statusText}` });
|
|
332
|
+
}
|
|
333
|
+
const data = await response.json();
|
|
334
|
+
const values = data.values || [];
|
|
335
|
+
|
|
336
|
+
const sorted = values.sort((a, b) => {
|
|
337
|
+
const aDraft = a.draft === true;
|
|
338
|
+
const bDraft = b.draft === true;
|
|
339
|
+
if (aDraft !== bDraft) return aDraft ? 1 : -1;
|
|
340
|
+
return (b.updatedDate || 0) - (a.updatedDate || 0);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
res.json({ values: enrichPrList(sorted, host) });
|
|
344
|
+
} catch (error) {
|
|
345
|
+
res.status(500).json({ error: error.message });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
router.get('/review-prs', async (req, res) => {
|
|
350
|
+
const host = getBitbucketHost();
|
|
351
|
+
if (!host) return res.json({ values: [] });
|
|
352
|
+
|
|
353
|
+
const settings = readSettings();
|
|
354
|
+
const token = settings.bitbucketAccessToken;
|
|
355
|
+
const headers = { 'Accept': 'application/json' };
|
|
356
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const url = `https://${host}/rest/api/1.0/dashboard/pull-requests?role=reviewer&state=OPEN&limit=100`;
|
|
360
|
+
const response = await fetch(url, { headers });
|
|
361
|
+
if (!response.ok) {
|
|
362
|
+
if (response.status === 401) {
|
|
363
|
+
return res.status(401).json({ error: 'Unauthorized. Please check your Bitbucket Token.' });
|
|
364
|
+
}
|
|
365
|
+
return res.status(response.status).json({ error: `Bitbucket API error: ${response.statusText}` });
|
|
366
|
+
}
|
|
367
|
+
const data = await response.json();
|
|
368
|
+
const values = data.values || [];
|
|
369
|
+
|
|
370
|
+
const sorted = values.sort((a, b) => (b.updatedDate || 0) - (a.updatedDate || 0));
|
|
371
|
+
|
|
372
|
+
res.json({ values: enrichPrList(sorted, host) });
|
|
373
|
+
} catch (error) {
|
|
374
|
+
res.status(500).json({ error: error.message });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
export default router;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { saveAccessToken, saveDomain, settingsStatus } from '../repositories/settings.js';
|
|
3
|
+
|
|
4
|
+
const router = Router();
|
|
5
|
+
|
|
6
|
+
router.get('/', (_req, res) => res.json(settingsStatus()));
|
|
7
|
+
router.put('/domain', (req, res) => {
|
|
8
|
+
const { domain } = req.body || {};
|
|
9
|
+
if (typeof domain !== 'string') return res.status(400).json({ error: 'Domain must be a string.' });
|
|
10
|
+
res.json(saveDomain(domain.trim()));
|
|
11
|
+
});
|
|
12
|
+
router.put('/:service-access-token', (req, res) => {
|
|
13
|
+
const { token } = req.body || {};
|
|
14
|
+
if (typeof token !== 'string') return res.status(400).json({ error: 'Token must be a string.' });
|
|
15
|
+
try { res.json(saveAccessToken(req.params.service, token.trim())); } catch (error) { res.status(400).json({ error: error.message }); }
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default router;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { paths } from '../config.js';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const previewLimit = 2000;
|
|
9
|
+
let lastValue = '';
|
|
10
|
+
|
|
11
|
+
function today() { return new Intl.DateTimeFormat('en-CA').format(new Date()); }
|
|
12
|
+
function dayFile(date) { const [year, month, day] = date.split('-'); return join(paths.clipboardDir, year, month, `${day}.json`); }
|
|
13
|
+
function readJson(file, fallback) { try { return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback; } catch { return fallback; } }
|
|
14
|
+
function writeJson(file, data) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(data, null, 2)); }
|
|
15
|
+
|
|
16
|
+
export function clipboardDates() {
|
|
17
|
+
if (!existsSync(paths.clipboardDir)) return [];
|
|
18
|
+
return readdirSync(paths.clipboardDir, { withFileTypes: true }).filter((year) => year.isDirectory() && /^\d{4}$/.test(year.name)).flatMap((year) => readdirSync(join(paths.clipboardDir, year.name), { withFileTypes: true }).filter((month) => month.isDirectory() && /^\d{2}$/.test(month.name)).flatMap((month) => readdirSync(join(paths.clipboardDir, year.name, month.name), { withFileTypes: true }).filter((day) => day.isFile() && /^\d{2}\.json$/.test(day.name)).map((day) => `${year.name}-${month.name}-${day.name.slice(0, 2)}`))).sort().reverse();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function dayItems(date) { return readJson(dayFile(date), []); }
|
|
22
|
+
export function clipboardItems(date = today()) { return dayItems(date).map((item) => item.contentFile ? { ...item, editorUrl: `vscode://file${encodeURI(join(paths.clipboardDir, 'content', item.contentFile))}` } : item); }
|
|
23
|
+
export function clipboardOriginal(date, id) { const item = dayItems(date).find((entry) => entry.id === id); if (!item?.contentFile) return item?.text || null; try { return readFileSync(join(paths.clipboardDir, 'content', item.contentFile), 'utf8'); } catch { return null; } }
|
|
24
|
+
export function deleteClipboardItem(date, id) {
|
|
25
|
+
const items = dayItems(date); const item = items.find((entry) => entry.id === id);
|
|
26
|
+
if (!item) return false;
|
|
27
|
+
writeJson(dayFile(date), items.filter((entry) => entry.id !== id));
|
|
28
|
+
if (item.contentFile) {
|
|
29
|
+
const contentPath = join(paths.clipboardDir, 'content', item.contentFile);
|
|
30
|
+
if (existsSync(contentPath)) unlinkSync(contentPath);
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function captureClipboard() {
|
|
36
|
+
if (process.platform !== 'darwin') return;
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await execFileAsync('pbpaste'); const text = stdout.trim();
|
|
39
|
+
if (!text || text === lastValue) return;
|
|
40
|
+
lastValue = text;
|
|
41
|
+
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const limit = now - 60 * 60 * 1000;
|
|
44
|
+
const date = today();
|
|
45
|
+
const items = dayItems(date);
|
|
46
|
+
|
|
47
|
+
let recentItems = [...items];
|
|
48
|
+
const yesterdayDate = new Intl.DateTimeFormat('en-CA').format(new Date(now - 24 * 60 * 60 * 1000));
|
|
49
|
+
if (date !== yesterdayDate) {
|
|
50
|
+
recentItems = [...recentItems, ...dayItems(yesterdayDate)];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const oneHourItems = recentItems.filter(item => {
|
|
54
|
+
const itemTime = new Date(item.createdAt).getTime();
|
|
55
|
+
return itemTime >= limit;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const alreadyExists = oneHourItems.some(item => {
|
|
59
|
+
if (item.text !== undefined) {
|
|
60
|
+
return item.text === text;
|
|
61
|
+
}
|
|
62
|
+
if (item.contentFile) {
|
|
63
|
+
const prefix = item.preview.slice(0, -1);
|
|
64
|
+
if (text.startsWith(prefix)) {
|
|
65
|
+
try {
|
|
66
|
+
const fullText = readFileSync(join(paths.clipboardDir, 'content', item.contentFile), 'utf8');
|
|
67
|
+
return fullText === text;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (alreadyExists) return;
|
|
77
|
+
|
|
78
|
+
const id = crypto.randomUUID(); const isLong = text.length > previewLimit;
|
|
79
|
+
if (isLong) { mkdirSync(join(paths.clipboardDir, 'content'), { recursive: true }); writeFileSync(join(paths.clipboardDir, 'content', `${id}.txt`), text, 'utf8'); }
|
|
80
|
+
const item = isLong ? { id, preview: `${text.slice(0, previewLimit)}…`, contentFile: `${id}.txt`, createdAt: new Date().toISOString() } : { id, text, createdAt: new Date().toISOString() };
|
|
81
|
+
writeJson(dayFile(date), [item, ...items].slice(0, 200));
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function startClipboardCapture() { captureClipboard(); const timer = setInterval(captureClipboard, 1000); timer.unref(); }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function currentGitBranch(folder) {
|
|
7
|
+
if (!folder) return null;
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: folder, timeout: 1500 });
|
|
10
|
+
return stdout.trim() || null;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function readPackageScripts(folder) {
|
|
5
|
+
const packageFile = join(folder, 'package.json');
|
|
6
|
+
if (!existsSync(packageFile)) return [];
|
|
7
|
+
try {
|
|
8
|
+
const { scripts = {} } = JSON.parse(readFileSync(packageFile, 'utf8'));
|
|
9
|
+
return Object.entries(scripts).map(([name, command]) => ({ id: `package:${name}`, name, command: String(command) }));
|
|
10
|
+
} catch { return []; }
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { paths } from '../config.js';
|
|
4
|
+
|
|
5
|
+
export function listPlugins() {
|
|
6
|
+
if (!existsSync(paths.plugins)) return [];
|
|
7
|
+
return readdirSync(paths.plugins, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => {
|
|
8
|
+
const manifest = join(paths.plugins, entry.name, 'plugin.json');
|
|
9
|
+
if (!existsSync(manifest)) return [];
|
|
10
|
+
try {
|
|
11
|
+
const plugin = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
12
|
+
return [{ id: entry.name, name: plugin.name || entry.name, icon: plugin.icon || '▦', view: plugin.view ? `/plugins/${encodeURIComponent(entry.name)}/${plugin.view}` : '' }];
|
|
13
|
+
} catch { return []; }
|
|
14
|
+
});
|
|
15
|
+
}
|