termdeck-cli 1.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.
@@ -0,0 +1,246 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Dev server manager.
5
+ *
6
+ * Each project gets at most one running `npm run dev` child process. stdout and
7
+ * stderr are piped back to the caller (the TUI log pane) instead of being
8
+ * attached to a terminal, and the first localhost URL found in the output is
9
+ * opened in the default browser.
10
+ */
11
+
12
+ const open = require('open');
13
+ const crossSpawn = require('cross-spawn');
14
+ const { extractLocalUrl, splitLines, stripAnsi, killTree } = require('./util');
15
+
16
+ const DEFAULT_DEV_COMMAND = 'npm run dev';
17
+
18
+ /** Fallback browser launcher (injectable so tests never open a real browser). */
19
+ async function defaultOpenBrowser(url) {
20
+ await open(url);
21
+ }
22
+
23
+ function parseCommand(command) {
24
+ const parts = String(command || '')
25
+ .trim()
26
+ .split(/\s+/)
27
+ .filter(Boolean);
28
+ return { bin: parts[0], args: parts.slice(1) };
29
+ }
30
+
31
+ class DevServerManager {
32
+ /**
33
+ * @param {object} options
34
+ * @param {function} options.onLog (project, line, stream) => void
35
+ * @param {function} options.onState (project, state) => void
36
+ * @param {function} options.onExit (project, {code, signal, stoppedByUs}) => void
37
+ * @param {function} options.openBrowser (url, project) => Promise<void>
38
+ */
39
+ constructor(options = {}) {
40
+ this.onLog = options.onLog || (() => {});
41
+ this.onState = options.onState || (() => {});
42
+ this.onExit = options.onExit || (() => {});
43
+ this.openBrowser = options.openBrowser || defaultOpenBrowser;
44
+ this.devCommand = options.devCommand || DEFAULT_DEV_COMMAND;
45
+ this.fallbackPort = options.fallbackPort || 3000;
46
+ // How long to wait for the process to print a URL before guessing one.
47
+ this.urlFallbackDelayMs = options.urlFallbackDelayMs || 9000;
48
+ // Give the HTTP listener a moment to bind before the browser hits it.
49
+ this.browserDelayMs = options.browserDelayMs || 1200;
50
+ this.servers = new Map();
51
+ this.lastExit = new Map();
52
+ this.autoOpenBrowser = options.autoOpenBrowser !== false;
53
+ }
54
+
55
+ /** @returns {object|undefined} running entry for a project path */
56
+ get(projectPath) {
57
+ return this.servers.get(projectPath);
58
+ }
59
+
60
+ isRunning(projectPath) {
61
+ return this.servers.has(projectPath);
62
+ }
63
+
64
+ get runningCount() {
65
+ return this.servers.size;
66
+ }
67
+
68
+ /**
69
+ * Start a project's dev server.
70
+ * @returns {{ok: boolean, entry?: object, error?: string, alreadyRunning?: boolean}}
71
+ */
72
+ start(project, options = {}) {
73
+ if (this.servers.has(project.path)) {
74
+ return { ok: false, alreadyRunning: true, entry: this.servers.get(project.path), error: 'already running' };
75
+ }
76
+
77
+ const command = options.command || project.devCommand || this.devCommand;
78
+ const { bin, args } = parseCommand(command);
79
+ if (!bin) return { ok: false, error: `No dev command configured for ${project.name}.` };
80
+
81
+ const entry = {
82
+ project,
83
+ command,
84
+ child: null,
85
+ url: null,
86
+ urlGuess: false,
87
+ status: 'starting',
88
+ startedAt: Date.now(),
89
+ browserOpened: false,
90
+ stopping: false,
91
+ timers: [],
92
+ };
93
+
94
+ let child;
95
+ try {
96
+ child = crossSpawn(bin, args, {
97
+ cwd: project.path,
98
+ env: {
99
+ ...process.env,
100
+ // Keep output plain so it renders cleanly inside the TUI log pane,
101
+ // and stop frameworks from opening their own browser tab.
102
+ FORCE_COLOR: '0',
103
+ BROWSER: 'none',
104
+ },
105
+ stdio: ['ignore', 'pipe', 'pipe'],
106
+ windowsHide: true,
107
+ // Own process group on POSIX so we can kill the whole tree.
108
+ detached: process.platform !== 'win32',
109
+ });
110
+ } catch (err) {
111
+ return { ok: false, error: err.message };
112
+ }
113
+
114
+ entry.child = child;
115
+ entry.pid = child.pid;
116
+ this.servers.set(project.path, entry);
117
+ this.log(project, `{bold}$${escapeTags(command)}{/bold} (cwd: ${project.path})`, 'system');
118
+
119
+ const carryOut = { rest: '' };
120
+ const carryErr = { rest: '' };
121
+
122
+ child.stdout.on('data', (chunk) => this.handleChunk(entry, chunk, carryOut, 'stdout'));
123
+ child.stderr.on('data', (chunk) => this.handleChunk(entry, chunk, carryErr, 'stderr'));
124
+
125
+ child.on('error', (err) => {
126
+ if (this.servers.get(project.path) === entry) this.servers.delete(project.path);
127
+ entry.status = 'error';
128
+ entry.error = err.message;
129
+ this.clearTimers(entry);
130
+ this.state(project, { status: 'error', pid: entry.pid, url: entry.url, error: err.message });
131
+ this.log(project, `{red-fg}cannot start "${command}": ${escapeTags(err.message)}{/red-fg}`, 'system');
132
+ });
133
+
134
+ child.on('exit', (code, signal) => {
135
+ const stoppedByUs = entry.stopping;
136
+ if (this.servers.get(project.path) === entry) this.servers.delete(project.path);
137
+ this.clearTimers(entry);
138
+ entry.status = 'stopped';
139
+ this.lastExit.set(project.path, { code, signal, at: Date.now(), stoppedByUs });
140
+ this.state(project, { status: 'stopped', pid: entry.pid, url: entry.url, code, signal });
141
+ this.onExit(project, { code, signal, stoppedByUs });
142
+ });
143
+
144
+ this.state(project, { status: 'starting', pid: entry.pid, url: null });
145
+
146
+ // Some dev servers are silent: fall back to an assumed port.
147
+ entry.timers.push(
148
+ setTimeout(() => {
149
+ if (!entry.url && this.servers.get(project.path) === entry) {
150
+ const port = project.port || this.fallbackPort;
151
+ this.log(
152
+ project,
153
+ `{yellow-fg}no localhost URL in the logs after ${Math.round(this.urlFallbackDelayMs / 1000)}s \u2014 assuming http://localhost:${port}{/yellow-fg}`,
154
+ 'system'
155
+ );
156
+ this.handleUrl(entry, `http://localhost:${port}`, { guessed: true });
157
+ }
158
+ }, this.urlFallbackDelayMs)
159
+ );
160
+
161
+ return { ok: true, entry };
162
+ }
163
+
164
+ /** Consume raw stream data: split lines, look for the first local URL. */
165
+ handleChunk(entry, chunk, carry, stream) {
166
+ const lines = splitLines(chunk.toString('utf8'), carry);
167
+ for (const raw of lines) {
168
+ const line = stripAnsi(raw).replace(/\s+$/, '');
169
+ if (!line.trim()) continue;
170
+ if (!entry.url) {
171
+ const found = extractLocalUrl(line);
172
+ if (found) this.handleUrl(entry, found);
173
+ }
174
+ this.log(entry.project, line, stream);
175
+ }
176
+ }
177
+
178
+ handleUrl(entry, url, { guessed = false } = {}) {
179
+ if (entry.url) return;
180
+ entry.url = url;
181
+ entry.urlGuess = guessed;
182
+ entry.status = 'running';
183
+ this.state(entry.project, { status: 'running', pid: entry.pid, url, guessed });
184
+
185
+ if (!this.autoOpenBrowser || entry.browserOpened) return;
186
+ entry.browserOpened = true;
187
+
188
+ const timer = setTimeout(async () => {
189
+ if (this.servers.get(entry.project.path) !== entry) return;
190
+ try {
191
+ await this.openBrowser(url, entry.project);
192
+ this.log(entry.project, `{green-fg}opened ${url} in your browser{/green-fg}`, 'system');
193
+ } catch (err) {
194
+ this.log(entry.project, `{yellow-fg}could not open a browser (${escapeTags(err.message)}) \u2014 visit ${url}{/yellow-fg}`, 'system');
195
+ }
196
+ }, guessed ? 0 : this.browserDelayMs);
197
+ entry.timers.push(timer);
198
+ }
199
+
200
+ /** Stop one dev server (and its children). */
201
+ stop(projectPath) {
202
+ const entry = this.servers.get(projectPath);
203
+ if (!entry) return false;
204
+ entry.stopping = true;
205
+ this.clearTimers(entry);
206
+ this.servers.delete(projectPath);
207
+ this.state(entry.project, { status: 'stopped', pid: entry.pid, url: entry.url });
208
+ killTree(entry.child);
209
+ return true;
210
+ }
211
+
212
+ /** Stop everything \u2014 called when the TUI quits. */
213
+ stopAll() {
214
+ const paths = [...this.servers.keys()];
215
+ paths.forEach((projectPath) => this.stop(projectPath));
216
+ return paths.length;
217
+ }
218
+
219
+ clearTimers(entry) {
220
+ entry.timers.forEach((timer) => clearTimeout(timer));
221
+ entry.timers = [];
222
+ }
223
+
224
+ state(project, state) {
225
+ try {
226
+ this.onState(project, state);
227
+ } catch (_) {
228
+ /* never let a render error kill a child process */
229
+ }
230
+ }
231
+
232
+ log(project, line, stream) {
233
+ try {
234
+ this.onLog(project, line, stream);
235
+ } catch (_) {
236
+ /* ignore */
237
+ }
238
+ }
239
+ }
240
+
241
+ /** blessed treats `{`/`}` as markup \u2014 escape command strings for the log pane. */
242
+ function escapeTags(text) {
243
+ return String(text == null ? '' : text).replace(/\{/g, '{open}').replace(/\}/g, '{close}');
244
+ }
245
+
246
+ module.exports = { DevServerManager, DEFAULT_DEV_COMMAND, defaultOpenBrowser, parseCommand };
package/src/index.js ADDED
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * termdeck CLI.
5
+ *
6
+ * `termdeck` launch the dashboard (runs setup on first use)
7
+ * `termdeck --setup` re-run the setup wizard
8
+ * `termdeck --reset` delete the config file and run setup again
9
+ * `termdeck --list` print the configured projects and exit (no TUI)
10
+ * `termdeck --no-open` do not auto-open the browser for dev servers
11
+ */
12
+
13
+ const fs = require('fs');
14
+
15
+ const { getConfigPath, configExists, loadConfig, runSetupWizard } = require('./config');
16
+
17
+ const HELP = `
18
+ termdeck - a terminal dashboard for your local dev projects
19
+
20
+ Usage
21
+ $ termdeck [options]
22
+
23
+ Options
24
+ -h, --help Show this help
25
+ -V, --version Show the version
26
+ -s, --setup Re-run the interactive setup wizard
27
+ -r, --reset Delete the config file, then run setup again
28
+ -l, --list Print the configured projects and exit
29
+ --no-open Do not open a browser when a dev server starts
30
+
31
+ Keys (inside the dashboard)
32
+ up/down, j/k Select a project
33
+ d Start \`npm run dev\` and stream its logs into the pane
34
+ 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
38
+ PgUp/PgDn, wheel Scroll the dev server logs (G to follow the tail again)
39
+ q Quit (stops every dev server it started)
40
+
41
+ Config
42
+ ${getConfigPath()}
43
+ Override the location with the TERMDECK_CONFIG environment variable.
44
+ `;
45
+
46
+ function parseArgs(argv = []) {
47
+ const args = { help: false, version: false, setup: false, reset: false, list: false, noOpen: false };
48
+ for (const raw of argv) {
49
+ const arg = String(raw);
50
+ switch (arg) {
51
+ case '-h':
52
+ case '--help':
53
+ args.help = true;
54
+ break;
55
+ case '-V':
56
+ case '--version':
57
+ args.version = true;
58
+ break;
59
+ case '-s':
60
+ case '--setup':
61
+ args.setup = true;
62
+ break;
63
+ case '-r':
64
+ case '--reset':
65
+ args.reset = true;
66
+ break;
67
+ case '-l':
68
+ case '--list':
69
+ args.list = true;
70
+ break;
71
+ case '--no-open':
72
+ args.noOpen = true;
73
+ break;
74
+ default:
75
+ args.unknown = arg;
76
+ }
77
+ }
78
+ return args;
79
+ }
80
+
81
+ function printProjects(config) {
82
+ const lines = [`Config: ${getConfigPath()}`, `Root: ${config.root}`, ''];
83
+ const nameWidth = Math.max(...config.projects.map((p) => p.name.length), 4) + 2;
84
+ lines.push(` ${'NAME'.padEnd(nameWidth)}${'STATUS'.padEnd(14)}DESCRIPTION / PATH`);
85
+ for (const project of config.projects) {
86
+ lines.push(` ${project.name.padEnd(nameWidth)}${project.status.padEnd(14)}${project.info}`);
87
+ lines.push(` ${' '.repeat(nameWidth)}${' '.repeat(14)}${project.path}`);
88
+ }
89
+ process.stdout.write(`${lines.join('\n')}\n`);
90
+ }
91
+
92
+ function needsTTY(what) {
93
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
94
+ if (interactive) return false;
95
+ process.stderr.write(
96
+ `termdeck needs an interactive terminal to ${what}.\n` +
97
+ `Open a real terminal and run it again, or use \`termdeck --list\` to inspect the saved config.\n`
98
+ );
99
+ return true;
100
+ }
101
+
102
+ /**
103
+ * @param {string[]} argv
104
+ * @returns {Promise<number>} exit code (the dashboard keeps the event loop alive)
105
+ */
106
+ async function main(argv = process.argv.slice(2)) {
107
+ const args = parseArgs(argv);
108
+
109
+ if (args.help) {
110
+ process.stdout.write(HELP);
111
+ return 0;
112
+ }
113
+
114
+ if (args.version) {
115
+ // eslint-disable-next-line global-require
116
+ process.stdout.write(`${require('../package.json').version}\n`);
117
+ return 0;
118
+ }
119
+
120
+ if (args.reset) {
121
+ const file = getConfigPath();
122
+ if (configExists()) {
123
+ fs.unlinkSync(file);
124
+ process.stdout.write(`Removed ${file}\n`);
125
+ } else {
126
+ process.stdout.write(`No config file at ${file}\n`);
127
+ }
128
+ }
129
+
130
+ const existing = args.reset ? null : loadConfig({ onWarn: (message) => process.stderr.write(`${message}\n`) });
131
+ let config = existing;
132
+
133
+ if (args.list && !config) {
134
+ process.stderr.write(`No config found at ${getConfigPath()}. Run \`termdeck --setup\` in a real terminal first.\n`);
135
+ return 1;
136
+ }
137
+
138
+ if (args.setup || args.reset || !config) {
139
+ if (needsTTY('run the setup wizard')) return 1;
140
+ config = await runSetupWizard({ existing });
141
+ }
142
+
143
+ if (!config.projects.length) {
144
+ process.stderr.write('No projects configured. Run `termdeck --setup`.\n');
145
+ return 1;
146
+ }
147
+
148
+ if (args.list) {
149
+ printProjects(config);
150
+ return 0;
151
+ }
152
+
153
+ if (needsTTY('render the dashboard')) return 1;
154
+
155
+ // Loaded lazily so `--help`, `--version` and `--list` stay fast and work
156
+ // even when blessed has no usable terminal.
157
+ // eslint-disable-next-line global-require
158
+ const { launchDashboard } = require('./dashboard');
159
+ launchDashboard(config, { autoOpen: !args.noOpen });
160
+
161
+ return 0;
162
+ }
163
+
164
+ module.exports = { main, parseArgs, printProjects, HELP, getConfigPath };
package/src/logView.js ADDED
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Scrollable log pane.
5
+ *
6
+ * Wraps a blessed-contrib `log` widget (which is a blessed `List`) and adds:
7
+ * - batched writes (dev servers can emit hundreds of lines per second and
8
+ * rebuilding the widget on every single line is far too slow),
9
+ * - follow-the-tail behaviour,
10
+ * - scroll back / pause with a "N new lines" indicator,
11
+ * - a bounded history so long running servers cannot eat all memory.
12
+ *
13
+ * Scrolling is implemented by trimming the tail of the visible slice rather
14
+ * than by touching blessed's internal childBase/childOffset, so it behaves
15
+ * identically on every blessed version. The widget is only ever asked to
16
+ * `setItems()` and `setLabel()`.
17
+ */
18
+
19
+ const DEFAULT_MAX_LINES = 500;
20
+
21
+ class LogView {
22
+ /**
23
+ * @param {object} widget blessed-contrib log widget
24
+ * @param {object} [options]
25
+ * @param {number} [options.maxLines] history size
26
+ * @param {number} [options.flushInterval] ms between repaints
27
+ * @param {function} [options.viewportHeight] visible row count (used to clamp scrolling)
28
+ * @param {function} [options.onChange] called after the widget changed
29
+ * @param {string} [options.label] base label
30
+ */
31
+ constructor(widget, options = {}) {
32
+ this.widget = widget;
33
+ this.maxLines = options.maxLines || DEFAULT_MAX_LINES;
34
+ this.flushInterval = options.flushInterval || 100;
35
+ this.viewportHeight = options.viewportHeight || (() => (typeof widget.height === 'number' ? widget.height - 2 : 10));
36
+ this.onChange = options.onChange || (() => {});
37
+ this.baseLabel = options.label || ' logs ';
38
+
39
+ this.lines = [];
40
+ this.follow = true;
41
+ this.viewOffset = 0;
42
+ // While paused the visible history is frozen at this length, so incoming
43
+ // lines cannot shift what the user is reading underneath them.
44
+ this.frozenLength = null;
45
+ this.pendingNew = 0;
46
+ this.dirty = true;
47
+ this.lastLabel = null;
48
+
49
+ this.timer = setInterval(() => this.flush(), this.flushInterval);
50
+ if (this.timer.unref) this.timer.unref();
51
+
52
+ this.flush();
53
+ }
54
+
55
+ get paused() {
56
+ return !this.follow;
57
+ }
58
+
59
+ /** Append one already formatted (tagged) line. */
60
+ push(line) {
61
+ this.lines.push(String(line));
62
+ if (this.lines.length > this.maxLines) {
63
+ this.lines.splice(0, this.lines.length - this.maxLines);
64
+ if (this.viewOffset > 0) this.viewOffset = Math.min(this.viewOffset, this.lines.length);
65
+ }
66
+ if (!this.follow) this.pendingNew += 1;
67
+ this.dirty = true;
68
+ }
69
+
70
+ /** Append many lines at once. */
71
+ pushAll(lines) {
72
+ lines.forEach((line) => this.push(line));
73
+ }
74
+
75
+ /** Replace the whole history. */
76
+ clear() {
77
+ this.lines = [];
78
+ this.viewOffset = 0;
79
+ this.pendingNew = 0;
80
+ this.follow = true;
81
+ this.frozenLength = null;
82
+ this.dirty = true;
83
+ this.flush();
84
+ }
85
+
86
+ /** Repaint if anything changed. Safe to call on a timer. */
87
+ flush() {
88
+ if (!this.dirty) return;
89
+ this.dirty = false;
90
+
91
+ if (this.follow) {
92
+ this.viewOffset = 0;
93
+ this.frozenLength = null;
94
+ this.pendingNew = 0;
95
+ this.render();
96
+ }
97
+ this.updateLabel();
98
+ this.onChange();
99
+ }
100
+
101
+ /** Number of lines that are part of the current (possibly frozen) view. */
102
+ visibleLength() {
103
+ if (this.follow) return this.lines.length;
104
+ const frozen = this.frozenLength == null ? this.lines.length : this.frozenLength;
105
+ return Math.min(frozen, this.lines.length);
106
+ }
107
+
108
+ render() {
109
+ const end = this.visibleLength();
110
+ const visible = this.viewOffset > 0 ? this.lines.slice(0, Math.max(0, end - this.viewOffset)) : this.lines.slice(0, end);
111
+ this.widget.setItems(visible);
112
+ }
113
+
114
+ clampOffset(offset) {
115
+ const viewport = Math.max(1, Number(this.viewportHeight()) || 1);
116
+ const maxOffset = Math.max(0, this.visibleLength() - viewport);
117
+ return Math.max(0, Math.min(maxOffset, offset));
118
+ }
119
+
120
+ /** Scroll back `amount` lines (pauses following). */
121
+ scrollUp(amount = 3) {
122
+ if (!this.lines.length) return;
123
+ if (this.follow) this.frozenLength = this.lines.length;
124
+ this.follow = false;
125
+ this.viewOffset = this.clampOffset(this.viewOffset + amount);
126
+ this.repaintNow();
127
+ }
128
+
129
+ /** Scroll towards the tail; reaches the tail -> resume following. */
130
+ scrollDown(amount = 3) {
131
+ if (this.follow) return;
132
+ this.viewOffset = Math.max(0, this.viewOffset - amount);
133
+ if (this.viewOffset === 0) {
134
+ this.follow = true;
135
+ this.frozenLength = null;
136
+ this.pendingNew = 0;
137
+ }
138
+ this.repaintNow();
139
+ }
140
+
141
+ /** Jump to the very top of the history (pauses following). */
142
+ scrollTop() {
143
+ if (!this.lines.length) return;
144
+ if (this.follow) this.frozenLength = this.lines.length;
145
+ this.follow = false;
146
+ this.viewOffset = this.clampOffset(this.visibleLength());
147
+ this.repaintNow();
148
+ }
149
+
150
+ /** Resume following the tail. */
151
+ followTail() {
152
+ this.follow = true;
153
+ this.frozenLength = null;
154
+ this.viewOffset = 0;
155
+ this.pendingNew = 0;
156
+ this.repaintNow();
157
+ }
158
+
159
+ /** Page up/down by roughly one screen. */
160
+ page(direction) {
161
+ const viewport = Math.max(1, Number(this.viewportHeight()) || 1);
162
+ if (direction < 0) this.scrollUp(viewport - 1);
163
+ else this.scrollDown(viewport - 1);
164
+ }
165
+
166
+ repaintNow() {
167
+ this.render();
168
+ this.updateLabel();
169
+ this.onChange();
170
+ }
171
+
172
+ label() {
173
+ if (this.paused) {
174
+ const newLines = this.pendingNew > 0 ? `, ${this.pendingNew} new` : '';
175
+ return `${this.baseLabel}paused${newLines} \u2014 G to follow`;
176
+ }
177
+ return this.baseLabel;
178
+ }
179
+
180
+ updateLabel() {
181
+ if (!this.widget.setLabel) return;
182
+ const label = this.label();
183
+ if (label !== this.lastLabel) {
184
+ this.lastLabel = label;
185
+ this.widget.setLabel(label);
186
+ }
187
+ }
188
+
189
+ destroy() {
190
+ clearInterval(this.timer);
191
+ }
192
+ }
193
+
194
+ module.exports = { LogView, DEFAULT_MAX_LINES };