termdeck-cli 2.0.4 → 2.0.5

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/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
 
@@ -14,26 +14,60 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
- const { spawnSync } = require('child_process');
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 runGit(args, cwd) {
29
- const result = spawnSync('git', args, {
30
- cwd,
31
- encoding: 'utf8',
32
- timeout: GIT_TIMEOUT_MS,
33
- windowsHide: true,
34
- });
35
- if (result.error || result.signal || result.status !== 0) return null;
36
- return result.stdout.trim();
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
+ };