termdeck-cli 1.0.2 → 2.0.2

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/devServer.js CHANGED
@@ -33,8 +33,10 @@ class DevServerManager {
33
33
  * @param {object} options
34
34
  * @param {function} options.onLog (project, line, stream) => void
35
35
  * @param {function} options.onState (project, state) => void
36
- * @param {function} options.onExit (project, {code, signal, stoppedByUs}) => void
36
+ * @param {function} options.onExit (project, {code, signal, stoppedByUs, restart, attempt, max}) => void
37
37
  * @param {function} options.openBrowser (url, project) => Promise<void>
38
+ * @param {number} [options.maxRestarts=3] auto-restart attempts after a crash
39
+ * @param {number} [options.restartDelayMs] pause before an auto-restart
38
40
  */
39
41
  constructor(options = {}) {
40
42
  this.onLog = options.onLog || (() => {});
@@ -50,6 +52,10 @@ class DevServerManager {
50
52
  this.servers = new Map();
51
53
  this.lastExit = new Map();
52
54
  this.autoOpenBrowser = options.autoOpenBrowser !== false;
55
+ this.autoRestart = options.autoRestart !== false;
56
+ this.maxRestarts = options.maxRestarts == null ? 3 : options.maxRestarts;
57
+ this.restartDelayMs = options.restartDelayMs || 1200;
58
+ this.restartCounts = new Map();
53
59
  }
54
60
 
55
61
  /** @returns {object|undefined} running entry for a project path */
@@ -138,7 +144,29 @@ class DevServerManager {
138
144
  entry.status = 'stopped';
139
145
  this.lastExit.set(project.path, { code, signal, at: Date.now(), stoppedByUs });
140
146
  this.state(project, { status: 'stopped', pid: entry.pid, url: entry.url, code, signal });
141
- this.onExit(project, { code, signal, stoppedByUs });
147
+
148
+ // The process died on its own (a crash), not because we stopped it:
149
+ // bring it straight back up, up to maxRestarts times, as long as neither
150
+ // the global setting nor the project says otherwise.
151
+ if (!stoppedByUs && this.shouldRestart(project)) {
152
+ const attempt = this.restartCounts.get(project.path);
153
+ const detail = signal ? `signal ${signal}` : `exit code ${code}`;
154
+ this.log(
155
+ project,
156
+ `{yellow-fg}dev server exited (${detail}) — auto-restarting ({bold}${attempt}/${this.maxRestarts}{/bold})\u2026{/yellow-fg}`,
157
+ 'system'
158
+ );
159
+ this.onExit(project, { code, signal, stoppedByUs, restart: true, attempt, max: this.maxRestarts });
160
+ const timer = setTimeout(() => {
161
+ if (this.servers.has(project.path)) return;
162
+ this.log(project, `{cyan-fg}[termdeck]{/cyan-fg} auto-restarting dev server`, 'system');
163
+ this.start(project);
164
+ }, this.restartDelayMs);
165
+ if (timer.unref) timer.unref();
166
+ entry.timers.push(timer);
167
+ } else {
168
+ this.onExit(project, { code, signal, stoppedByUs });
169
+ }
142
170
  });
143
171
 
144
172
  this.state(project, { status: 'starting', pid: entry.pid, url: null });
@@ -161,6 +189,25 @@ class DevServerManager {
161
189
  return { ok: true, entry };
162
190
  }
163
191
 
192
+ /**
193
+ * True when a crashed dev server should be restarted: auto-restart is
194
+ * enabled (globally and for this project) and the restart budget remains.
195
+ */
196
+ restartAvailable(project) {
197
+ if (!this.autoRestart) return false;
198
+ if (project && project.autoRestart === false) return false;
199
+ if (this.maxRestarts <= 0) return false;
200
+ return true;
201
+ }
202
+
203
+ shouldRestart(project) {
204
+ if (!this.restartAvailable(project)) return false;
205
+ const attempts = (this.restartCounts.get(project.path) || 0) + 1;
206
+ if (attempts > this.maxRestarts) return false;
207
+ this.restartCounts.set(project.path, attempts);
208
+ return true;
209
+ }
210
+
164
211
  /** Consume raw stream data: split lines, look for the first local URL. */
165
212
  handleChunk(entry, chunk, carry, stream) {
166
213
  const lines = splitLines(chunk.toString('utf8'), carry);
@@ -180,6 +227,8 @@ class DevServerManager {
180
227
  entry.url = url;
181
228
  entry.urlGuess = guessed;
182
229
  entry.status = 'running';
230
+ // The server proved healthy — restore the crash-restart budget.
231
+ this.restartCounts.delete(entry.project.path);
183
232
  this.state(entry.project, { status: 'running', pid: entry.pid, url, guessed });
184
233
 
185
234
  if (!this.autoOpenBrowser || entry.browserOpened) return;
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  'use strict';
2
3
 
3
4
  /**
@@ -5,14 +6,25 @@
5
6
  *
6
7
  * `termdeck` launch the dashboard (runs setup on first use)
7
8
  * `termdeck --setup` re-run the setup wizard
9
+ * `termdeck --scan` scan for projects and merge them into the config
8
10
  * `termdeck --reset` delete the config file and run setup again
9
11
  * `termdeck --list` print the configured projects and exit (no TUI)
12
+ * `termdeck --demo` launch with the shipped 14-project sample dataset
10
13
  * `termdeck --no-open` do not auto-open the browser for dev servers
14
+ * `termdeck --no-update` skip the automatic background update check
15
+ * `termdeck --no-auto-restart` disable dev-server crash recovery for this session
16
+ *
17
+ * Every interactive launch silently re-scans the configured root directory and
18
+ * adds any new project folders it finds (status `pend`, auto-detected port and
19
+ * package manager) before the dashboard opens. See `autoDiscoverProjects`.
11
20
  */
12
21
 
13
22
  const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const { getConfigPath, configExists, loadConfig, loadConfigFromPath, runSetupWizard, autoDiscoverProjects, saveConfig } = require('./config');
14
26
 
15
- const { getConfigPath, configExists, loadConfig, runSetupWizard } = require('./config');
27
+ const DEMO_CONFIG_PATH = path.join(__dirname, '..', 'sample-config.json');
16
28
 
17
29
  const HELP = `
18
30
  termdeck - a terminal dashboard for your local dev projects
@@ -24,27 +36,38 @@ const HELP = `
24
36
  -h, --help Show this help
25
37
  -V, --version Show the version
26
38
  -s, --setup Re-run the interactive setup wizard
39
+ --scan Scan for projects and merge them into the config
27
40
  -r, --reset Delete the config file, then run setup again
28
41
  -l, --list Print the configured projects and exit
42
+ --demo Launch with the shipped 14-project sample dataset
29
43
  --no-open Do not open a browser when a dev server starts
44
+ --no-update Skip the automatic background update check
45
+ --no-auto-restart Disable dev-server crash recovery for this session
30
46
 
31
47
  Keys (inside the dashboard)
32
48
  up/down, j/k Select a project
33
- d Start \`npm run dev\` and stream its logs into the pane
49
+ r Run \`npm run dev\` for the selected project
34
50
  e Open the project in your editor in a NEW terminal window
35
- a Open your coding agent in a NEW terminal window
36
- x Stop the selected project's dev server
37
- r Reload the config file
51
+ c/x/o/f/k, a Open claude / codex / opencode / freebuff / kilocode
52
+ s Change the selected project's status
53
+ tab / S-tab Cycle focus: project list -> actions -> output
54
+ shift+x Stop the selected project's dev server
38
55
  PgUp/PgDn, wheel Scroll the dev server logs (G to follow the tail again)
56
+ / Search filter (type to filter, enter to commit)
57
+ 1-5 Filter by status: all / live / exp / pend / scrap
39
58
  q Quit (stops every dev server it started)
40
59
 
60
+ Discovery
61
+ On every launch termdeck quietly re-scans the configured root and adds any
62
+ new project folders (with .git or package.json) as pending projects.
63
+
41
64
  Config
42
65
  ${getConfigPath()}
43
66
  Override the location with the TERMDECK_CONFIG environment variable.
44
67
  `;
45
68
 
46
69
  function parseArgs(argv = []) {
47
- const args = { help: false, version: false, setup: false, reset: false, list: false, noOpen: false };
70
+ const args = { help: false, version: false, setup: false, reset: false, scan: false, list: false, demo: false, noOpen: false, noUpdate: false, noAutoRestart: false };
48
71
  for (const raw of argv) {
49
72
  const arg = String(raw);
50
73
  switch (arg) {
@@ -64,13 +87,25 @@ function parseArgs(argv = []) {
64
87
  case '--reset':
65
88
  args.reset = true;
66
89
  break;
90
+ case '--scan':
91
+ args.scan = true;
92
+ break;
67
93
  case '-l':
68
94
  case '--list':
69
95
  args.list = true;
70
96
  break;
97
+ case '--demo':
98
+ args.demo = true;
99
+ break;
71
100
  case '--no-open':
72
101
  args.noOpen = true;
73
102
  break;
103
+ case '--no-update':
104
+ args.noUpdate = true;
105
+ break;
106
+ case '--no-auto-restart':
107
+ args.noAutoRestart = true;
108
+ break;
74
109
  default:
75
110
  args.unknown = arg;
76
111
  }
@@ -128,14 +163,21 @@ async function main(argv = process.argv.slice(2)) {
128
163
  }
129
164
 
130
165
  const existing = args.reset ? null : loadConfig({ onWarn: (message) => process.stderr.write(`${message}\n`) });
131
- let config = existing;
166
+ let config = args.demo
167
+ ? loadConfigFromPath(DEMO_CONFIG_PATH, { onWarn: (message) => process.stderr.write(`${message}\n`) })
168
+ : existing;
169
+
170
+ if (args.demo && !config) {
171
+ process.stderr.write(`Could not load the sample dataset at ${DEMO_CONFIG_PATH}.\n`);
172
+ return 1;
173
+ }
132
174
 
133
175
  if (args.list && !config) {
134
176
  process.stderr.write(`No config found at ${getConfigPath()}. Run \`termdeck --setup\` in a real terminal first.\n`);
135
177
  return 1;
136
178
  }
137
179
 
138
- if (args.setup || args.reset || !config) {
180
+ if (args.setup || args.scan || args.reset || !config) {
139
181
  if (needsTTY('run the setup wizard')) return 1;
140
182
  config = await runSetupWizard({ existing });
141
183
  }
@@ -152,13 +194,65 @@ async function main(argv = process.argv.slice(2)) {
152
194
 
153
195
  if (needsTTY('render the dashboard')) return 1;
154
196
 
197
+ // Silent auto-discovery: shallow-scan the configured root on every launch
198
+ // and fold any new project folders into the config. This is sync and fast
199
+ // (one readdir + a couple of stats per folder, no git parsing), never
200
+ // prompts, and never removes anything. The only feedback is a transient
201
+ // footer toast when the dashboard comes up; failures are swallowed so a
202
+ // bad root can never block the launch.
203
+ let bootStatus = null;
204
+ if (config.root && !config.demoMode) {
205
+ try {
206
+ const discovered = autoDiscoverProjects(config);
207
+ if (discovered.added.length) {
208
+ config.projects = discovered.projects;
209
+ const names = discovered.added.map((p) => p.name).join(', ');
210
+ const count = discovered.added.length;
211
+ bootStatus = `\u2728 Discovered and added ${count} new project${count === 1 ? '' : 's'}: ${names}`;
212
+ try {
213
+ saveConfig(config);
214
+ } catch (_) {
215
+ /* the dashboard still runs with the in-memory config */
216
+ }
217
+ }
218
+ } catch (_) {
219
+ /* discovery must never block the launch */
220
+ }
221
+ }
222
+
155
223
  // Loaded lazily so `--help`, `--version` and `--list` stay fast and work
156
224
  // even when blessed has no usable terminal.
157
225
  // eslint-disable-next-line global-require
158
226
  const { launchDashboard } = require('./dashboard');
159
- launchDashboard(config, { autoOpen: !args.noOpen });
227
+ const controller = launchDashboard(config, { autoOpen: !args.noOpen, autoRestart: !args.noAutoRestart, bootStatus });
228
+
229
+ // Fire-and-forget auto-update: the registry check is capped at 2s and runs
230
+ // in the background, and any banner is routed through the TUI footer so the
231
+ // blessed screen is never corrupted by stray terminal output.
232
+ // eslint-disable-next-line global-require
233
+ const { runAutoUpdate } = require('./updater');
234
+ runAutoUpdate({
235
+ stdout: process.stdout,
236
+ enabled: !args.noUpdate,
237
+ onUpdating: (version) => controller.updateStatus(`Found v${version} — updating in the background…`),
238
+ });
160
239
 
161
240
  return 0;
162
241
  }
163
242
 
164
243
  module.exports = { main, parseArgs, printProjects, HELP, getConfigPath };
244
+
245
+ // termdeck's bin entry point maps straight to this file, so running it directly
246
+ // means "run the CLI". When required as a module (tests, embedding), do nothing.
247
+ if (require.main === module) {
248
+ main(process.argv.slice(2))
249
+ .then((code) => {
250
+ // The dashboard keeps the process alive; only surface real exit codes.
251
+ if (typeof code === 'number' && code !== 0) process.exitCode = code;
252
+ })
253
+ .catch((err) => {
254
+ const message = err && err.message ? err.message : String(err);
255
+ process.stderr.write(`termdeck: ${message}\n`);
256
+ process.exitCode = 1;
257
+ });
258
+ }
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * processMonitor - real-time per-project process stats.
5
+ *
6
+ * Two cheap primitives (find the PID behind a port, then read CPU/memory for
7
+ * that PID) feed a per-project polling interval. The port lookup shells out to
8
+ * `lsof` (macOS/Linux) or `netstat` (Windows); stats come from the `pidusage`
9
+ * package. Every failure path is silent: a missing process, a dead PID or a
10
+ * blocked command yields `null` / a `running: false` report, never a throw, so
11
+ * the TUI can call this on every render tick without fear.
12
+ *
13
+ * All command runners are injectable (`{ spawn, usage }`) so the unit tests
14
+ * cover every branch without a real `lsof`/`netstat` or a live process.
15
+ */
16
+
17
+ const crossSpawn = require('cross-spawn');
18
+ const pidusage = require('pidusage');
19
+
20
+ const MONITOR_INTERVAL_MS = 2000;
21
+
22
+ /** per-key interval handles + in-flight guard so ticks never overlap. */
23
+ const intervals = new Map();
24
+ const inFlight = new Set();
25
+
26
+ function formatMemory(bytes) {
27
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return null;
28
+ return `${(bytes / 1048576).toFixed(1)} MB`;
29
+ }
30
+
31
+ function formatCpu(percent) {
32
+ if (typeof percent !== 'number' || !Number.isFinite(percent)) return null;
33
+ return `${percent.toFixed(1)}%`;
34
+ }
35
+
36
+ /**
37
+ * Find the PID listening on `port`, or null when none / the command fails.
38
+ *
39
+ * macOS/Linux: `lsof -ti :<port>`
40
+ * Windows: parse the rows of `netstat -ano` ourselves (avoids a locale-
41
+ * dependent `findstr` pipe); the PID is the last token of rows
42
+ * whose local address carries `:<port>`.
43
+ */
44
+ function getPIDByPort(port, { spawn = crossSpawn } = {}) {
45
+ if (!port || !Number.isFinite(Number(port))) return null;
46
+ try {
47
+ if (process.platform === 'win32') {
48
+ const result = spawn.sync('netstat', ['-ano'], {
49
+ encoding: 'utf8',
50
+ windowsHide: true,
51
+ timeout: 4000,
52
+ });
53
+ if (!result || result.error || result.status !== 0) return null;
54
+ let firstPid = null;
55
+ for (const rawLine of String(result.stdout).split(/\r?\n/)) {
56
+ const tokens = rawLine.trim().split(/\s+/);
57
+ if (tokens.length < 2) continue;
58
+ const pidToken = tokens[tokens.length - 1];
59
+ if (!/^\d+$/.test(pidToken)) continue;
60
+ if (!tokens.slice(0, -1).some((token) => token.endsWith(`:${port}`) || token === `${port}`)) continue;
61
+ if (firstPid == null) firstPid = Number(pidToken);
62
+ }
63
+ return firstPid;
64
+ }
65
+
66
+ const result = spawn.sync('lsof', ['-ti', `:${port}`], {
67
+ encoding: 'utf8',
68
+ windowsHide: true,
69
+ timeout: 4000,
70
+ });
71
+ if (!result || result.error || result.status !== 0) return null;
72
+ const first = String(result.stdout).trim().split(/\r?\n/)[0];
73
+ const pid = Number(first);
74
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
75
+ } catch (_) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * CPU + memory for a PID, formatted for the UI, or null when the PID is
82
+ * invalid/dead/blocked. Never rejects (the caller in the TUI awaits this).
83
+ */
84
+ async function getProcessStats(pid, { usage = pidusage } = {}) {
85
+ if (!pid || !Number.isInteger(Number(pid)) || Number(pid) <= 0) return null;
86
+ try {
87
+ const stats = await usage(pid);
88
+ if (!stats || typeof stats.memory !== 'number') return null;
89
+ const cpu = formatCpu(stats.cpu);
90
+ const memory = formatMemory(stats.memory);
91
+ if (!cpu || !memory) return null;
92
+ return { pid: Number(pid), memory, cpu };
93
+ } catch (_) {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function monitorKey(project) {
99
+ return project && (project.path || project.id || project.name);
100
+ }
101
+
102
+ /**
103
+ * Start polling `project` every `intervalMs` for its PID/CPU/memory, passing
104
+ * the report to `callback`. Returns a stop function. Silently degrades to
105
+ * `{ running:false }` when the port has no process.
106
+ */
107
+ function startMonitoring(project, callback, options = {}) {
108
+ const key = monitorKey(project);
109
+ if (!key || typeof callback !== 'function') return null;
110
+ const getPID = options.getPID || getPIDByPort;
111
+ const getStats = options.getStats || getProcessStats;
112
+ const intervalMs = options.intervalMs != null ? options.intervalMs : MONITOR_INTERVAL_MS;
113
+ const onError = options.onError || (() => {});
114
+
115
+ stopMonitoring(key);
116
+
117
+ const tick = async () => {
118
+ if (inFlight.has(key)) return;
119
+ inFlight.add(key);
120
+ try {
121
+ const pid = await getPID(project.port);
122
+ if (pid == null) {
123
+ callback({ running: false, pid: null, memory: null, cpu: null });
124
+ return;
125
+ }
126
+ const stats = (await getStats(pid)) || {};
127
+ callback({ running: true, pid, memory: stats.memory || null, cpu: stats.cpu || null });
128
+ } catch (err) {
129
+ onError(err);
130
+ } finally {
131
+ inFlight.delete(key);
132
+ }
133
+ };
134
+
135
+ const timer = setInterval(tick, Math.max(50, intervalMs));
136
+ if (timer.unref) timer.unref();
137
+ intervals.set(key, timer);
138
+ tick();
139
+ return () => stopMonitoring(key);
140
+ }
141
+
142
+ /** Stop monitoring a project (pass a project object or its key). */
143
+ function stopMonitoring(project) {
144
+ const key = monitorKey(project) || project;
145
+ const timer = intervals.get(key);
146
+ if (timer) {
147
+ clearInterval(timer);
148
+ intervals.delete(key);
149
+ }
150
+ inFlight.delete(key);
151
+ }
152
+
153
+ function stopAllMonitoring() {
154
+ for (const key of [...intervals.keys()]) stopMonitoring(key);
155
+ intervals.clear();
156
+ inFlight.clear();
157
+ }
158
+
159
+ module.exports = {
160
+ MONITOR_INTERVAL_MS,
161
+ formatMemory,
162
+ formatCpu,
163
+ getPIDByPort,
164
+ getProcessStats,
165
+ startMonitoring,
166
+ stopMonitoring,
167
+ stopAllMonitoring,
168
+ };
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * projectManager - scans project folders and reads git metadata.
5
+ *
6
+ * Git reads shell out to `git` through cross-spawn and are cached per project
7
+ * for GIT_CACHE_TTL_MS, so a 14-project dashboard does not hammer the disk on
8
+ * every 2s render tick. Nothing here ever throws: every failure degrades to
9
+ * `null` / empty values and the UI renders an em-dash for the field.
10
+ *
11
+ * The git runner is injectable (`git` option) so unit tests can feed fake
12
+ * output without needing a real repository or the git binary.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { spawnSync } = require('child_process');
18
+
19
+ const { timeAgo } = require('./util');
20
+
21
+ const GIT_CACHE_TTL_MS = 30000;
22
+ const GIT_TIMEOUT_MS = 4000;
23
+
24
+ /** projectPath -> { fetchedAt, info }. Private; use clearGitCache() in tests. */
25
+ const cache = new Map();
26
+
27
+ /** 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();
37
+ }
38
+
39
+ /** Test hook: drop every cached git snapshot. */
40
+ function clearGitCache() {
41
+ cache.clear();
42
+ }
43
+
44
+ /** True when `cwd` sits inside a git working tree. */
45
+ function isGitRepo(projectPath, { git = runGit } = {}) {
46
+ return git(['rev-parse', '--is-inside-work-tree'], projectPath) === 'true';
47
+ }
48
+
49
+ /**
50
+ * Turn `git status --porcelain` into a (+added, -removed) pair. Counts files,
51
+ * not diff lines: `??`/`A`/`M` lines are additions, `D`/`R` are removals, and
52
+ * a differently-staged file (e.g. `AM typo.js`) still counts once.
53
+ */
54
+ function parseDirtyState(porcelain) {
55
+ if (!porcelain || typeof porcelain !== 'string') return { added: 0, removed: 0 };
56
+
57
+ let added = 0;
58
+ let removed = 0;
59
+ for (const raw of porcelain.split('\n')) {
60
+ const line = raw.trim();
61
+ if (!line) continue;
62
+ if (line.startsWith('??') || /^[AM]/.test(line)) added += 1;
63
+ else if (/^[DR]/.test(line)) removed += 1;
64
+ // Anything else (e.g. `U`) counts as neither side.
65
+ }
66
+ return { added, removed };
67
+ }
68
+
69
+ /**
70
+ * Read branch + last commit + dirty state from a real repo.
71
+ * Returns zero-ish values (nulls / empty dirty) when git is unavailable or the
72
+ * directory is not a repository, so callers never have to handle failure.
73
+ */
74
+ function readGitInfo(projectPath, { git = runGit } = {}) {
75
+ if (!isGitRepo(projectPath, { git })) {
76
+ return { branch: null, commitHash: null, commitMsg: null, lastCommitAt: null, dirty: { added: 0, removed: 0 } };
77
+ }
78
+
79
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], projectPath);
80
+ const commitHash = git(['rev-parse', '--short', 'HEAD'], projectPath);
81
+ const commitMsg = git(['log', '-1', '--pretty=%s'], projectPath);
82
+ const lastCommitAt = git(['log', '-1', '--format=%cI'], projectPath) || null;
83
+ const porcelain = git(['status', '--porcelain'], projectPath);
84
+
85
+ return {
86
+ branch,
87
+ commitHash,
88
+ commitMsg,
89
+ lastCommitAt,
90
+ dirty: parseDirtyState(porcelain),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Cached git info for a project. Re-reads only when the cache entry is older
96
+ * than GIT_CACHE_TTL_MS or `force` is set. Returns the same shape as
97
+ * readGitInfo.
98
+ */
99
+ function getGitInfo(projectPath, { force = false, git = null } = {}) {
100
+ const now = Date.now();
101
+ const hit = force ? null : cache.get(projectPath);
102
+ if (hit && now - hit.fetchedAt < GIT_CACHE_TTL_MS) return hit.info;
103
+
104
+ const info = readGitInfo(projectPath, git ? { git } : {});
105
+ cache.set(projectPath, { fetchedAt: now, info });
106
+ return info;
107
+ }
108
+
109
+ /**
110
+ * "12m ago" style relative activity time for a project, derived from its last
111
+ * commit. Falls back to `null` (dashboard shows an em-dash) outside a repo.
112
+ */
113
+ function getLastActivity(projectPath, { git = null } = {}) {
114
+ const info = getGitInfo(projectPath, git ? { git } : {});
115
+ return timeAgo(info.lastCommitAt) || null;
116
+ }
117
+
118
+ /**
119
+ * Immediate sub-folders of `root` that look like projects: directories that
120
+ * are not hidden and not on the ignore list. Returns sorted {name, path}.
121
+ */
122
+ function scanProjects(root, { ignored = new Set() } = {}) {
123
+ let entries;
124
+ try {
125
+ entries = fs.readdirSync(root, { withFileTypes: true });
126
+ } catch (_) {
127
+ return [];
128
+ }
129
+
130
+ return entries
131
+ .filter((entry) => {
132
+ if (entry.name.startsWith('.')) return false;
133
+ if (ignored.has(entry.name)) return false;
134
+ if (entry.isDirectory()) return true;
135
+ if (entry.isSymbolicLink()) {
136
+ try {
137
+ return fs.statSync(path.join(root, entry.name)).isDirectory();
138
+ } catch (_) {
139
+ return false;
140
+ }
141
+ }
142
+ return false;
143
+ })
144
+ .map((entry) => ({ name: entry.name, path: path.join(root, entry.name) }))
145
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
146
+ }
147
+
148
+ module.exports = {
149
+ GIT_CACHE_TTL_MS,
150
+ GIT_TIMEOUT_MS,
151
+ runGit,
152
+ clearGitCache,
153
+ isGitRepo,
154
+ parseDirtyState,
155
+ readGitInfo,
156
+ getGitInfo,
157
+ getLastActivity,
158
+ scanProjects,
159
+ };