termdeck-cli 1.0.3 → 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/README.md +197 -177
- package/package.json +9 -7
- package/sample-config.json +176 -0
- package/src/agentManager.js +217 -0
- package/src/config.js +636 -427
- package/src/dashboard.js +496 -213
- package/src/devServer.js +51 -2
- package/src/index.js +103 -10
- package/src/processMonitor.js +168 -0
- package/src/projectManager.js +159 -0
- package/src/updater.js +172 -0
- package/src/util.js +23 -0
- package/bin/termdeck.js +0 -22
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
|
-
|
|
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
|
|
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,28 +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
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
39
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
|
|
40
58
|
q Quit (stops every dev server it started)
|
|
41
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
|
+
|
|
42
64
|
Config
|
|
43
65
|
${getConfigPath()}
|
|
44
66
|
Override the location with the TERMDECK_CONFIG environment variable.
|
|
45
67
|
`;
|
|
46
68
|
|
|
47
69
|
function parseArgs(argv = []) {
|
|
48
|
-
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 };
|
|
49
71
|
for (const raw of argv) {
|
|
50
72
|
const arg = String(raw);
|
|
51
73
|
switch (arg) {
|
|
@@ -65,13 +87,25 @@ function parseArgs(argv = []) {
|
|
|
65
87
|
case '--reset':
|
|
66
88
|
args.reset = true;
|
|
67
89
|
break;
|
|
90
|
+
case '--scan':
|
|
91
|
+
args.scan = true;
|
|
92
|
+
break;
|
|
68
93
|
case '-l':
|
|
69
94
|
case '--list':
|
|
70
95
|
args.list = true;
|
|
71
96
|
break;
|
|
97
|
+
case '--demo':
|
|
98
|
+
args.demo = true;
|
|
99
|
+
break;
|
|
72
100
|
case '--no-open':
|
|
73
101
|
args.noOpen = true;
|
|
74
102
|
break;
|
|
103
|
+
case '--no-update':
|
|
104
|
+
args.noUpdate = true;
|
|
105
|
+
break;
|
|
106
|
+
case '--no-auto-restart':
|
|
107
|
+
args.noAutoRestart = true;
|
|
108
|
+
break;
|
|
75
109
|
default:
|
|
76
110
|
args.unknown = arg;
|
|
77
111
|
}
|
|
@@ -129,14 +163,21 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
129
163
|
}
|
|
130
164
|
|
|
131
165
|
const existing = args.reset ? null : loadConfig({ onWarn: (message) => process.stderr.write(`${message}\n`) });
|
|
132
|
-
let config =
|
|
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
|
+
}
|
|
133
174
|
|
|
134
175
|
if (args.list && !config) {
|
|
135
176
|
process.stderr.write(`No config found at ${getConfigPath()}. Run \`termdeck --setup\` in a real terminal first.\n`);
|
|
136
177
|
return 1;
|
|
137
178
|
}
|
|
138
179
|
|
|
139
|
-
if (args.setup || args.reset || !config) {
|
|
180
|
+
if (args.setup || args.scan || args.reset || !config) {
|
|
140
181
|
if (needsTTY('run the setup wizard')) return 1;
|
|
141
182
|
config = await runSetupWizard({ existing });
|
|
142
183
|
}
|
|
@@ -153,13 +194,65 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
153
194
|
|
|
154
195
|
if (needsTTY('render the dashboard')) return 1;
|
|
155
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
|
+
|
|
156
223
|
// Loaded lazily so `--help`, `--version` and `--list` stay fast and work
|
|
157
224
|
// even when blessed has no usable terminal.
|
|
158
225
|
// eslint-disable-next-line global-require
|
|
159
226
|
const { launchDashboard } = require('./dashboard');
|
|
160
|
-
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
|
+
});
|
|
161
239
|
|
|
162
240
|
return 0;
|
|
163
241
|
}
|
|
164
242
|
|
|
165
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
|
+
};
|