termdeck-cli 2.0.4 → 2.0.6
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 +108 -163
- package/package.json +1 -1
- package/src/agentManager.js +6 -0
- package/src/dashboard.js +479 -135
- package/src/logView.js +3 -0
- package/src/projectManager.js +331 -12
package/src/logView.js
CHANGED
|
@@ -26,6 +26,7 @@ class LogView {
|
|
|
26
26
|
* @param {number} [options.flushInterval] ms between repaints
|
|
27
27
|
* @param {function} [options.viewportHeight] visible row count (used to clamp scrolling)
|
|
28
28
|
* @param {function} [options.onChange] called after the widget changed
|
|
29
|
+
* @param {function} [options.onLabelChange] called with the new text whenever the pane label is rewritten
|
|
29
30
|
* @param {string} [options.label] base label
|
|
30
31
|
*/
|
|
31
32
|
constructor(widget, options = {}) {
|
|
@@ -34,6 +35,7 @@ class LogView {
|
|
|
34
35
|
this.flushInterval = options.flushInterval || 100;
|
|
35
36
|
this.viewportHeight = options.viewportHeight || (() => (typeof widget.height === 'number' ? widget.height - 2 : 10));
|
|
36
37
|
this.onChange = options.onChange || (() => {});
|
|
38
|
+
this.onLabelChange = options.onLabelChange || (() => {});
|
|
37
39
|
this.baseLabel = options.label || ' logs ';
|
|
38
40
|
|
|
39
41
|
this.lines = [];
|
|
@@ -183,6 +185,7 @@ class LogView {
|
|
|
183
185
|
if (label !== this.lastLabel) {
|
|
184
186
|
this.lastLabel = label;
|
|
185
187
|
this.widget.setLabel(label);
|
|
188
|
+
this.onLabelChange(label);
|
|
186
189
|
}
|
|
187
190
|
}
|
|
188
191
|
|
package/src/projectManager.js
CHANGED
|
@@ -14,26 +14,60 @@
|
|
|
14
14
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
const crossSpawn = require('cross-spawn');
|
|
19
18
|
const { timeAgo } = require('./util');
|
|
20
19
|
|
|
21
20
|
const GIT_CACHE_TTL_MS = 30000;
|
|
22
21
|
const GIT_TIMEOUT_MS = 4000;
|
|
22
|
+
const STACK_CACHE_TTL_MS = 30000;
|
|
23
23
|
|
|
24
24
|
/** projectPath -> { fetchedAt, info }. Private; use clearGitCache() in tests. */
|
|
25
25
|
const cache = new Map();
|
|
26
|
+
const stackCache = new Map();
|
|
27
|
+
|
|
28
|
+
const STACK_PACKAGE_NAMES = {
|
|
29
|
+
next: 'Next.js',
|
|
30
|
+
react: 'React',
|
|
31
|
+
typescript: 'TypeScript',
|
|
32
|
+
tailwindcss: 'Tailwind',
|
|
33
|
+
express: 'Express',
|
|
34
|
+
vite: 'Vite',
|
|
35
|
+
prisma: 'Prisma',
|
|
36
|
+
};
|
|
26
37
|
|
|
27
38
|
/** Default git runner: `git <args>` in `cwd`, trimmed stdout or null. */
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
function runGitResult(args, cwd, { spawn = crossSpawn.sync } = {}) {
|
|
40
|
+
let result;
|
|
41
|
+
try {
|
|
42
|
+
result = spawn('git', args, {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
timeout: GIT_TIMEOUT_MS,
|
|
46
|
+
windowsHide: true,
|
|
47
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
result = { error, status: null, signal: null, stdout: '', stderr: '' };
|
|
51
|
+
}
|
|
52
|
+
if (typeof result === 'string') {
|
|
53
|
+
return { ok: true, status: 0, signal: null, error: null, stdout: result, stderr: '' };
|
|
54
|
+
}
|
|
55
|
+
const stdout = result && result.stdout != null ? String(result.stdout) : '';
|
|
56
|
+
const stderr = result && result.stderr != null ? String(result.stderr) : '';
|
|
57
|
+
const ok = Boolean(result) && (result.ok === true || (result.ok !== false && !result.error && !result.signal && result.status === 0));
|
|
58
|
+
return {
|
|
59
|
+
ok,
|
|
60
|
+
status: result && result.status != null ? result.status : null,
|
|
61
|
+
signal: result && result.signal ? result.signal : null,
|
|
62
|
+
error: result && result.error ? result.error : null,
|
|
63
|
+
stdout,
|
|
64
|
+
stderr,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function runGit(args, cwd, options) {
|
|
69
|
+
const result = runGitResult(args, cwd, options);
|
|
70
|
+
return result.ok ? result.stdout.trim() : null;
|
|
37
71
|
}
|
|
38
72
|
|
|
39
73
|
/** Test hook: drop every cached git snapshot. */
|
|
@@ -41,6 +75,68 @@ function clearGitCache() {
|
|
|
41
75
|
cache.clear();
|
|
42
76
|
}
|
|
43
77
|
|
|
78
|
+
function clearStackCache() {
|
|
79
|
+
stackCache.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasProjectFile(projectPath, name) {
|
|
83
|
+
try {
|
|
84
|
+
return fs.statSync(path.join(projectPath, name)).isFile();
|
|
85
|
+
} catch (_) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readPackageStack(projectPath) {
|
|
91
|
+
let manifest;
|
|
92
|
+
try {
|
|
93
|
+
manifest = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf8'));
|
|
94
|
+
} catch (_) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!manifest || typeof manifest !== 'object') return null;
|
|
99
|
+
const dependencies = { ...(manifest.dependencies || {}), ...(manifest.devDependencies || {}) };
|
|
100
|
+
const stack = [];
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
for (const packageName of Object.keys(dependencies)) {
|
|
103
|
+
const cleanName = STACK_PACKAGE_NAMES[packageName];
|
|
104
|
+
if (cleanName && !seen.has(cleanName)) {
|
|
105
|
+
seen.add(cleanName);
|
|
106
|
+
stack.push(cleanName);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return stack.length ? stack.join(', ') : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function detectStack(projectPath, { force = false } = {}) {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const hit = force ? null : stackCache.get(projectPath);
|
|
115
|
+
if (hit && now - hit.fetchedAt < STACK_CACHE_TTL_MS) return hit.value;
|
|
116
|
+
|
|
117
|
+
let value = 'Unknown';
|
|
118
|
+
if (hasProjectFile(projectPath, 'package.json')) {
|
|
119
|
+
const packageStack = readPackageStack(projectPath);
|
|
120
|
+
value = packageStack || 'Unknown';
|
|
121
|
+
} else {
|
|
122
|
+
const fallbacks = [
|
|
123
|
+
[['requirements.txt', 'Pipfile'], 'Python'],
|
|
124
|
+
[['Cargo.toml'], 'Rust'],
|
|
125
|
+
[['go.mod'], 'Go'],
|
|
126
|
+
[['pom.xml'], 'Java'],
|
|
127
|
+
];
|
|
128
|
+
for (const [names, stack] of fallbacks) {
|
|
129
|
+
if (names.some((name) => hasProjectFile(projectPath, name))) {
|
|
130
|
+
value = stack;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
stackCache.set(projectPath, { fetchedAt: now, value });
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
44
140
|
/** True when `cwd` sits inside a git working tree. */
|
|
45
141
|
function isGitRepo(projectPath, { git = runGit } = {}) {
|
|
46
142
|
return git(['rev-parse', '--is-inside-work-tree'], projectPath) === 'true';
|
|
@@ -115,6 +211,221 @@ function getLastActivity(projectPath, { git = null } = {}) {
|
|
|
115
211
|
return timeAgo(info.lastCommitAt) || null;
|
|
116
212
|
}
|
|
117
213
|
|
|
214
|
+
function parseDiffStat(stat) {
|
|
215
|
+
if (!stat || typeof stat !== 'string') return [];
|
|
216
|
+
const files = [];
|
|
217
|
+
for (const raw of stat.split('\n')) {
|
|
218
|
+
const match = raw.match(/^\s*(.*?)\s*\|\s*(?:\d+|Bin\b)/);
|
|
219
|
+
if (!match) continue;
|
|
220
|
+
let file = match[1].trim();
|
|
221
|
+
if (!file) continue;
|
|
222
|
+
if (file.includes(' => ')) file = file.split(' => ').pop().trim();
|
|
223
|
+
if (file.startsWith('"') && file.endsWith('"')) {
|
|
224
|
+
try {
|
|
225
|
+
file = JSON.parse(file);
|
|
226
|
+
} catch (_) {
|
|
227
|
+
file = file.slice(1, -1);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
files.push(file);
|
|
231
|
+
}
|
|
232
|
+
return [...new Set(files)];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function uniquePatterns(files) {
|
|
236
|
+
const patterns = [];
|
|
237
|
+
const add = (pattern) => {
|
|
238
|
+
if (!patterns.includes(pattern)) patterns.push(pattern);
|
|
239
|
+
};
|
|
240
|
+
for (const file of files) {
|
|
241
|
+
const normalized = file.replace(/\\/g, '/');
|
|
242
|
+
const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase();
|
|
243
|
+
if (basename === 'package.json') add('dependencies');
|
|
244
|
+
if (basename === 'readme.md') add('docs');
|
|
245
|
+
if (/(^|\/)(src|lib)\//.test(normalized)) add('source code');
|
|
246
|
+
}
|
|
247
|
+
return patterns;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function changedDirectories(files) {
|
|
251
|
+
const directories = [];
|
|
252
|
+
for (const file of files) {
|
|
253
|
+
const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
254
|
+
const slash = normalized.lastIndexOf('/');
|
|
255
|
+
if (slash <= 0) continue;
|
|
256
|
+
const directory = normalized.slice(0, slash);
|
|
257
|
+
if (!directories.includes(directory)) directories.push(directory);
|
|
258
|
+
}
|
|
259
|
+
return directories.length ? directories : ['root'];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildCommitMessage(files) {
|
|
263
|
+
const changedFiles = [...new Set((files || []).filter(Boolean).map((file) => String(file).trim()).filter(Boolean))];
|
|
264
|
+
if (!changedFiles.length) return null;
|
|
265
|
+
|
|
266
|
+
const patterns = uniquePatterns(changedFiles);
|
|
267
|
+
const names = changedFiles.join(', ');
|
|
268
|
+
let message;
|
|
269
|
+
if (changedFiles.length === 1) {
|
|
270
|
+
message = `Update ${changedFiles[0]}`;
|
|
271
|
+
} else if (changedFiles.length <= 5) {
|
|
272
|
+
message = `Modified ${changedFiles.length} files: ${names}`;
|
|
273
|
+
} else {
|
|
274
|
+
message = `Updated ${changedFiles.length} files across ${changedDirectories(changedFiles).join(', ')}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (
|
|
278
|
+
patterns.includes('dependencies') &&
|
|
279
|
+
patterns.includes('docs') &&
|
|
280
|
+
changedFiles.length === 2 &&
|
|
281
|
+
changedFiles.some((file) => {
|
|
282
|
+
const normalized = file.replace(/\\/g, '/').toLowerCase();
|
|
283
|
+
return normalized.endsWith('/package.json') || normalized === 'package.json';
|
|
284
|
+
}) &&
|
|
285
|
+
changedFiles.some((file) => {
|
|
286
|
+
const normalized = file.replace(/\\/g, '/').toLowerCase();
|
|
287
|
+
return normalized.endsWith('/readme.md') || normalized === 'readme.md';
|
|
288
|
+
})
|
|
289
|
+
) {
|
|
290
|
+
return `chore: update dependencies and docs (${names})`;
|
|
291
|
+
}
|
|
292
|
+
return patterns.length ? `${message} (${patterns.join(', ')})` : message;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function gitResultFailed(value) {
|
|
296
|
+
return Boolean(value && typeof value === 'object' && (value.__failed === true || value.ok === false || (value.ok !== true && value.status !== 0) || value.error));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function generateCommitMessage(projectPath, { git = runGit } = {}) {
|
|
300
|
+
const stagedStat = safeGitOutput(git, ['diff', '--cached', '--stat'], projectPath);
|
|
301
|
+
let files = parseDiffStat(stagedStat);
|
|
302
|
+
let allStat = null;
|
|
303
|
+
if (!files.length) {
|
|
304
|
+
allStat = safeGitOutput(git, ['diff', 'HEAD', '--stat'], projectPath);
|
|
305
|
+
files = parseDiffStat(allStat);
|
|
306
|
+
}
|
|
307
|
+
let untrackedResult = null;
|
|
308
|
+
if (!files.length) {
|
|
309
|
+
untrackedResult = safeGitOutput(git, ['ls-files', '--others', '--exclude-standard'], projectPath);
|
|
310
|
+
if (typeof untrackedResult === 'string') files = untrackedResult.split(/\r?\n|\r/).map((line) => line.trim()).filter(Boolean);
|
|
311
|
+
}
|
|
312
|
+
if (
|
|
313
|
+
!files.length &&
|
|
314
|
+
((stagedStat === null && allStat === null) ||
|
|
315
|
+
gitResultFailed(stagedStat) ||
|
|
316
|
+
gitResultFailed(allStat) ||
|
|
317
|
+
gitResultFailed(untrackedResult))
|
|
318
|
+
) return 'Update project files';
|
|
319
|
+
return files.length ? buildCommitMessage(files) : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function safeGitOutput(git, args, cwd) {
|
|
323
|
+
try {
|
|
324
|
+
return git(args, cwd);
|
|
325
|
+
} catch (_) {
|
|
326
|
+
return { __failed: true };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function runInjectedGitResult(args, cwd, git) {
|
|
331
|
+
if (git === runGit) return runGitResult(args, cwd);
|
|
332
|
+
let result;
|
|
333
|
+
try {
|
|
334
|
+
result = git(args, cwd);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
result = { error, status: null, signal: null, stdout: '', stderr: '' };
|
|
337
|
+
}
|
|
338
|
+
if (typeof result === 'string') {
|
|
339
|
+
return { ok: true, status: 0, signal: null, error: null, stdout: result, stderr: '' };
|
|
340
|
+
}
|
|
341
|
+
const stdout = result && result.stdout != null ? String(result.stdout) : '';
|
|
342
|
+
const stderr = result && result.stderr != null ? String(result.stderr) : '';
|
|
343
|
+
return {
|
|
344
|
+
ok: Boolean(result) && (result.ok === true || (result.ok !== false && !result.error && !result.signal && result.status === 0)),
|
|
345
|
+
status: result && result.status != null ? result.status : null,
|
|
346
|
+
signal: result && result.signal ? result.signal : null,
|
|
347
|
+
error: result && result.error ? result.error : null,
|
|
348
|
+
stdout,
|
|
349
|
+
stderr,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function outputLines(result) {
|
|
354
|
+
const lines = [];
|
|
355
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
356
|
+
const text = result && result[stream] ? String(result[stream]) : '';
|
|
357
|
+
for (const line of text.split(/\r?\n|\r/)) {
|
|
358
|
+
if (line.trim()) lines.push({ line, stream });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return lines;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function summarizeGitFailure(result) {
|
|
365
|
+
const text = [result && result.stderr, result && result.stdout]
|
|
366
|
+
.filter(Boolean)
|
|
367
|
+
.join('\n')
|
|
368
|
+
.trim();
|
|
369
|
+
const lines = text.split(/\r?\n|\r/).map((line) => line.trim()).filter(Boolean);
|
|
370
|
+
const spawnError = result && result.error && (result.error.message || String(result.error));
|
|
371
|
+
if (!lines.length && spawnError) return spawnError;
|
|
372
|
+
if (!lines.length) return 'git command failed';
|
|
373
|
+
if (/rejected/i.test(text) && /fetch first|upstream/i.test(text)) return 'rejected (fetch first)';
|
|
374
|
+
return lines.find((line) => /rejected|failed|error|fatal|upstream/i.test(line)) || lines[0];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function countChangedFiles(projectPath, { git = runGit } = {}) {
|
|
378
|
+
const tracked = safeGitOutput(git, ['diff', '--name-only', 'HEAD'], projectPath);
|
|
379
|
+
if (!tracked) return 0;
|
|
380
|
+
return [...new Set(tracked.split(/\r?\n|\r/).map((line) => line.trim()).filter(Boolean))].length;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function commitAndPush(projectPath, commitMessage, { git = runGit, onOutput = null } = {}) {
|
|
384
|
+
const message = String(commitMessage == null ? '' : commitMessage).trim();
|
|
385
|
+
if (!message) return { ok: false, error: 'Commit message cannot be empty.' };
|
|
386
|
+
|
|
387
|
+
const status = safeGitOutput(git, ['status', '--porcelain'], projectPath);
|
|
388
|
+
if (typeof status === 'string' && !status.trim()) {
|
|
389
|
+
return { ok: false, warning: 'No changes to commit.' };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const addResult = runInjectedGitResult(['add', '.'], projectPath, git);
|
|
393
|
+
for (const item of outputLines(addResult)) {
|
|
394
|
+
if (onOutput) onOutput(item.line, item.stream);
|
|
395
|
+
}
|
|
396
|
+
if (!addResult.ok) {
|
|
397
|
+
return { ok: false, error: `Add failed: ${summarizeGitFailure(addResult)}`, output: addResult };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const commitResult = runInjectedGitResult(['commit', '-m', message], projectPath, git);
|
|
401
|
+
for (const item of outputLines(commitResult)) {
|
|
402
|
+
if (onOutput) onOutput(item.line, item.stream);
|
|
403
|
+
}
|
|
404
|
+
if (!commitResult.ok) {
|
|
405
|
+
const failure = summarizeGitFailure(commitResult);
|
|
406
|
+
if (/nothing added|no changes|nothing to commit/i.test(`${commitResult.stdout}\n${commitResult.stderr}`)) {
|
|
407
|
+
return { ok: false, warning: 'No changes to commit.', output: commitResult };
|
|
408
|
+
}
|
|
409
|
+
return { ok: false, error: `Commit failed: ${failure}`, output: commitResult };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const pushResult = runInjectedGitResult(['push'], projectPath, git);
|
|
413
|
+
for (const item of outputLines(pushResult)) {
|
|
414
|
+
if (onOutput) onOutput(item.line, item.stream === 'stderr' ? 'stderr' : 'stdout');
|
|
415
|
+
}
|
|
416
|
+
if (!pushResult.ok) {
|
|
417
|
+
return { ok: false, error: `Push failed: ${summarizeGitFailure(pushResult)}`, output: pushResult };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let fileCount = countChangedFiles(projectPath, { git });
|
|
421
|
+
if (!fileCount) {
|
|
422
|
+
const commitText = `${commitResult.stdout}\n${commitResult.stderr}`;
|
|
423
|
+
const match = commitText.match(/(\d+)\s+files?\s+changed/i);
|
|
424
|
+
fileCount = match ? Number(match[1]) : 0;
|
|
425
|
+
}
|
|
426
|
+
return { ok: true, message, fileCount };
|
|
427
|
+
}
|
|
428
|
+
|
|
118
429
|
/**
|
|
119
430
|
* Immediate sub-folders of `root` that look like projects: directories that
|
|
120
431
|
* are not hidden and not on the ignore list. Returns sorted {name, path}.
|
|
@@ -148,12 +459,20 @@ function scanProjects(root, { ignored = new Set() } = {}) {
|
|
|
148
459
|
module.exports = {
|
|
149
460
|
GIT_CACHE_TTL_MS,
|
|
150
461
|
GIT_TIMEOUT_MS,
|
|
462
|
+
STACK_CACHE_TTL_MS,
|
|
151
463
|
runGit,
|
|
464
|
+
runGitResult,
|
|
152
465
|
clearGitCache,
|
|
466
|
+
clearStackCache,
|
|
153
467
|
isGitRepo,
|
|
154
468
|
parseDirtyState,
|
|
155
469
|
readGitInfo,
|
|
156
470
|
getGitInfo,
|
|
157
471
|
getLastActivity,
|
|
472
|
+
detectStack,
|
|
473
|
+
parseDiffStat,
|
|
474
|
+
buildCommitMessage,
|
|
475
|
+
generateCommitMessage,
|
|
476
|
+
commitAndPush,
|
|
158
477
|
scanProjects,
|
|
159
|
-
};
|
|
478
|
+
};
|