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,175 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Opening a *brand new* OS terminal window in a project folder.
5
+ *
6
+ * Termdeck never opens a terminal for the dev server (those logs are piped into
7
+ * the TUI) \u2014 this module is only used for the "Open Editor" / "Open Agent"
8
+ * CTAs, where a real interactive shell is what the user wants.
9
+ */
10
+
11
+ const crossSpawn = require('cross-spawn');
12
+ const { which, shellQuote, appleScriptString } = require('./util');
13
+
14
+ /** Commands that are shell built-ins / launch a terminal window. */
15
+ function buildTerminalCandidates({ cwd, command, platform = process.platform }) {
16
+ const candidates = [];
17
+ // Keeps the window open after the command exits, so errors stay readable.
18
+ const keepOpen = `${command}; exec bash`;
19
+
20
+ if (platform === 'darwin') {
21
+ const shellCommand = `cd ${shellQuote(cwd)} && ${command}`;
22
+ const script = [
23
+ 'tell application "Terminal"',
24
+ ' activate',
25
+ ` do script ${appleScriptString(shellCommand)}`,
26
+ 'end tell',
27
+ ];
28
+ const args = [];
29
+ script.forEach((line) => args.push('-e', line));
30
+
31
+ candidates.push({
32
+ id: 'macos-terminal',
33
+ bin: 'osascript',
34
+ args,
35
+ available: true,
36
+ });
37
+ return candidates;
38
+ }
39
+
40
+ if (platform === 'win32') {
41
+ // Windows Terminal first (prettier + tab support), then a plain console.
42
+ candidates.push({
43
+ id: 'windows-terminal',
44
+ bin: 'wt.exe',
45
+ args: ['-d', cwd, 'cmd', '/k', command],
46
+ available: Boolean(which('wt.exe')),
47
+ });
48
+
49
+ candidates.push({
50
+ id: 'cmd-start',
51
+ bin: process.env.ComSpec || 'cmd.exe',
52
+ // NOTE: windowsVerbatimArguments means we do our own quoting here. `/D`
53
+ // sets the working directory of the new window, and `start ""` supplies
54
+ // the required (empty) window title.
55
+ args: ['/c', 'start', '""', '/D', `"${cwd}"`, 'cmd.exe', '/k', `"${command}"`],
56
+ verbatim: true,
57
+ available: true,
58
+ });
59
+ return candidates;
60
+ }
61
+
62
+ // Linux / BSD: try every common emulator, in order of popularity.
63
+ const emulators = [
64
+ { id: 'gnome-terminal', bin: 'gnome-terminal', args: ['--working-directory', cwd, '--', 'bash', '-lc', keepOpen] },
65
+ { id: 'konsole', bin: 'konsole', args: ['--workdir', cwd, '-e', 'bash', '-lc', keepOpen] },
66
+ { id: 'xfce4-terminal', bin: 'xfce4-terminal', args: ['--working-directory', cwd, '-x', 'bash', '-lc', keepOpen] },
67
+ { id: 'kitty', bin: 'kitty', args: ['--directory', cwd, 'bash', '-lc', keepOpen] },
68
+ { id: 'alacritty', bin: 'alacritty', args: ['--working-directory', cwd, '-e', 'bash', '-lc', keepOpen] },
69
+ { id: 'wezterm', bin: 'wezterm', args: ['start', '--cwd', cwd, '--', 'bash', '-lc', keepOpen] },
70
+ {
71
+ id: 'x-terminal-emulator',
72
+ bin: 'x-terminal-emulator',
73
+ args: ['-e', 'bash', '-lc', `cd ${shellQuote(cwd)} && ${keepOpen}`],
74
+ },
75
+ {
76
+ id: 'xterm',
77
+ bin: 'xterm',
78
+ args: ['-e', 'bash', '-lc', `cd ${shellQuote(cwd)} && ${keepOpen}`],
79
+ },
80
+ ];
81
+
82
+ emulators.forEach((emulator) => {
83
+ candidates.push({ ...emulator, available: Boolean(which(emulator.bin)) });
84
+ });
85
+
86
+ return candidates;
87
+ }
88
+
89
+ /** Human readable form of a candidate, for logs and `--dry-run`. */
90
+ function formatTerminalCommand(candidate) {
91
+ if (!candidate) return '';
92
+ const args = candidate.args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg));
93
+ return `${candidate.bin} ${args.join(' ')}`.trim();
94
+ }
95
+
96
+ /** Put a user-preferred emulator (TERMDECK_TERMINAL) at the front of the list. */
97
+ function applyPreference(candidates, prefer) {
98
+ if (!prefer) return candidates;
99
+ const wanted = String(prefer).toLowerCase();
100
+ const index = candidates.findIndex(
101
+ (c) => c.id.toLowerCase() === wanted || c.bin.toLowerCase() === wanted || c.bin.toLowerCase().startsWith(wanted)
102
+ );
103
+ if (index <= 0) return candidates;
104
+ const preferred = candidates[index];
105
+ return [preferred, ...candidates.filter((_, i) => i !== index)];
106
+ }
107
+
108
+ /** Resolve when the child actually started; reject on ENOENT/EPERM. */
109
+ function waitForSpawn(child, timeoutMs = 5000) {
110
+ return new Promise((resolve, reject) => {
111
+ let done = false;
112
+ const finish = (fn, value) => {
113
+ if (done) return;
114
+ done = true;
115
+ clearTimeout(timer);
116
+ child.removeListener('spawn', onSpawn);
117
+ child.removeListener('error', onError);
118
+ fn(value);
119
+ };
120
+ const onSpawn = () => finish(resolve);
121
+ const onError = (err) => finish(reject, err);
122
+ const timer = setTimeout(() => finish(reject, new Error('timed out starting terminal')), timeoutMs);
123
+ if (timer.unref) timer.unref();
124
+
125
+ child.once('spawn', onSpawn);
126
+ child.once('error', onError);
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Spawn a new OS terminal window, cd into `cwd` and run `command` there.
132
+ *
133
+ * @returns {Promise<{ok: boolean, terminal?: string, command?: string, error?: string}>}
134
+ */
135
+ async function openInNewTerminal({ cwd, command, platform = process.platform, prefer = process.env.TERMDECK_TERMINAL, dryRun = false }) {
136
+ const candidates = applyPreference(buildTerminalCandidates({ cwd, command, platform }), prefer);
137
+ const usable = candidates.filter((c) => c.available);
138
+
139
+ if (usable.length === 0) {
140
+ return {
141
+ ok: false,
142
+ error: `No terminal emulator found. Tried: ${candidates.map((c) => c.id).join(', ')}. Set TERMDECK_TERMINAL to your emulator.`,
143
+ };
144
+ }
145
+
146
+ if (dryRun) {
147
+ return { ok: true, terminal: usable[0].id, command: formatTerminalCommand(usable[0]), dryRun: true };
148
+ }
149
+
150
+ const errors = [];
151
+ for (const candidate of usable) {
152
+ try {
153
+ const child = crossSpawn(candidate.bin, candidate.args, {
154
+ detached: true,
155
+ stdio: 'ignore',
156
+ windowsVerbatimArguments: candidate.verbatim === true,
157
+ });
158
+ await waitForSpawn(child);
159
+ // A late failure must never crash the TUI.
160
+ child.on('error', () => {});
161
+ child.unref();
162
+ return { ok: true, terminal: candidate.id, command: formatTerminalCommand(candidate) };
163
+ } catch (err) {
164
+ errors.push(`${candidate.id}: ${err.message}`);
165
+ }
166
+ }
167
+
168
+ return { ok: false, error: errors.join(' | ') };
169
+ }
170
+
171
+ module.exports = {
172
+ buildTerminalCandidates,
173
+ formatTerminalCommand,
174
+ openInNewTerminal,
175
+ };
package/src/util.js ADDED
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Small shared helpers used across termdeck.
5
+ *
6
+ * Everything in here is intentionally dependency free and side-effect free so it
7
+ * can be unit tested without a terminal (see test/run.js).
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { spawnSync } = require('child_process');
13
+
14
+ /* ------------------------------------------------------------------ *
15
+ * Terminal output helpers
16
+ * ------------------------------------------------------------------ */
17
+
18
+ // Covers CSI sequences (\x1b[...m), OSC sequences (\x1b]...\x07) and the
19
+ // `ESC ( B` style charset selects that npm/vite sometimes emit.
20
+ const ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z0-9]*(?:;[-a-zA-Z0-9\/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
21
+
22
+ /** Remove ANSI colour/control codes from a chunk of terminal output. */
23
+ function stripAnsi(input) {
24
+ return String(input == null ? '' : input).replace(ANSI_RE, '');
25
+ }
26
+
27
+ /**
28
+ * Escape `{`/`}` for blessed's tag parser. Blessed treats `{red-fg}` as markup,
29
+ * so raw log output (JSON, JSX, template literals...) has to be escaped or it
30
+ * renders as garbage. `{open}`/`{close}` are blessed's literal brace tags.
31
+ */
32
+ function escapeBraces(text) {
33
+ // Single pass: replacing `{` first would then rewrite the `}` of the
34
+ // `{open}` tag we just inserted.
35
+ return String(text == null ? '' : text).replace(/[{}]/g, (ch) => (ch === '{' ? '{open}' : '{close}'));
36
+ }
37
+
38
+ /** Truncate plain text to `width` characters, adding an ellipsis when cut. */
39
+ function truncate(text, width) {
40
+ const str = String(text == null ? '' : text);
41
+ if (!Number.isFinite(width) || width <= 1) return str;
42
+ if (str.length <= width) return str;
43
+ return str.slice(0, Math.max(0, width - 1)) + '\u2026';
44
+ }
45
+
46
+ /** Right-pad/left-pad a label to a fixed width. */
47
+ function padEnd(text, width) {
48
+ const str = String(text == null ? '' : text);
49
+ return str.length >= width ? str : str + ' '.repeat(width - str.length);
50
+ }
51
+
52
+ /** `HH:MM:SS` timestamp for log lines. */
53
+ function timestamp(date = new Date()) {
54
+ const p = (n) => String(n).padStart(2, '0');
55
+ return `${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
56
+ }
57
+
58
+ /* ------------------------------------------------------------------ *
59
+ * Dev-server output parsing
60
+ * ------------------------------------------------------------------ */
61
+
62
+ const URL_RE = /https?:\/\/[^\s"'`<>()[\]{}|\\^]+/gi;
63
+ const HOST_PORT_RE = /\b(localhost|127\.0\.0\.1|0\.0\.0\.0)\s*:\s*(\d{2,5})\b/i;
64
+ // Last-resort: "Port 3000", "port: 5173", "using port 8080".
65
+ const PORT_RE = /\bport\b[^\d\n]{0,24}?(\d{2,5})\b/i;
66
+ const LOCAL_HOSTS = /(^|\/\/)(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:|\/|$)/i;
67
+
68
+ /** `http://0.0.0.0:3000/` -> `http://localhost:3000`. */
69
+ function normalizeLocalUrl(url) {
70
+ return String(url == null ? '' : url)
71
+ .trim()
72
+ .replace(/\/\/(0\.0\.0\.0)/, '//localhost')
73
+ .replace(/\/\/(\[::1\]|::1)/, '//localhost')
74
+ .replace(/[.,;:)\]}'"`]+$/, '')
75
+ .replace(/\/+$/, '');
76
+ }
77
+
78
+ /**
79
+ * Pull the first "local" URL out of a line of dev-server output.
80
+ * Returns null when the line does not advertise a local address.
81
+ */
82
+ function extractLocalUrl(text) {
83
+ const clean = stripAnsi(text);
84
+
85
+ const urls = clean.match(URL_RE);
86
+ if (urls) {
87
+ for (const raw of urls) {
88
+ const candidate = raw.replace(/[.,;:)\]}'"`]+$/, '');
89
+ if (LOCAL_HOSTS.test(candidate)) return normalizeLocalUrl(candidate);
90
+ }
91
+ }
92
+
93
+ const hostPort = clean.match(HOST_PORT_RE);
94
+ if (hostPort) return normalizeLocalUrl(`http://${hostPort[1]}:${hostPort[2]}`);
95
+
96
+ const port = clean.match(PORT_RE);
97
+ if (port) return `http://localhost:${port[1]}`;
98
+
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Split a stream chunk into complete lines, keeping the unfinished tail in
104
+ * `carry` (mutated in place so callers can pipe chunks straight through).
105
+ */
106
+ function splitLines(chunk, carry = { rest: '' }) {
107
+ carry.rest += String(chunk == null ? '' : chunk);
108
+ const parts = carry.rest.split(/\r\n|\n|\r/);
109
+ carry.rest = parts.pop();
110
+ return parts.filter((line) => line.trim() !== '');
111
+ }
112
+
113
+ /* ------------------------------------------------------------------ *
114
+ * OS helpers
115
+ * ------------------------------------------------------------------ */
116
+
117
+ /**
118
+ * Minimal `which(1)`: look a binary up on PATH.
119
+ * Avoids spawning a child process just to test for an executable.
120
+ */
121
+ function which(bin, env = process.env) {
122
+ if (!bin) return null;
123
+ const exts = process.platform === 'win32'
124
+ ? String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
125
+ : [''];
126
+ const dirs = String(env.PATH || env.Path || '').split(path.delimiter).filter(Boolean);
127
+ const hasExt = path.extname(bin) !== '';
128
+
129
+ for (const dir of dirs) {
130
+ for (const ext of hasExt ? [''] : exts) {
131
+ const candidate = path.join(dir, bin + ext);
132
+ try {
133
+ if (fs.statSync(candidate).isFile()) return candidate;
134
+ } catch (_) {
135
+ /* keep looking */
136
+ }
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /** POSIX single-quote a value so it survives a shell round trip. */
143
+ function shellQuote(value) {
144
+ return `'${String(value == null ? '' : value).replace(/'/g, `'\\''`)}'`;
145
+ }
146
+
147
+ /** Quote a value for embedding inside an AppleScript string literal. */
148
+ function appleScriptString(value) {
149
+ return `"${String(value == null ? '' : value)
150
+ .replace(/\\/g, '\\\\')
151
+ .replace(/"/g, '\\"')
152
+ .replace(/\r?\n/g, ' ')}"`;
153
+ }
154
+
155
+ /**
156
+ * Kill a child process *and* its children (npm -> node -> vite, etc).
157
+ * Windows needs `taskkill /T`; on POSIX we spawn the tree detached and kill the
158
+ * whole process group.
159
+ */
160
+ function killTree(child, { signal = 'SIGTERM' } = {}) {
161
+ if (!child || child.pid == null) return false;
162
+ if (child.exitCode !== null || child.signalCode) return false;
163
+
164
+ const pid = child.pid;
165
+
166
+ if (process.platform === 'win32') {
167
+ try {
168
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
169
+ return true;
170
+ } catch (_) {
171
+ try {
172
+ child.kill();
173
+ return true;
174
+ } catch (_) {
175
+ return false;
176
+ }
177
+ }
178
+ }
179
+
180
+ try {
181
+ process.kill(-pid, signal);
182
+ } catch (_) {
183
+ try {
184
+ child.kill(signal);
185
+ } catch (_) {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ // Escalate if the dev server ignores SIGTERM. `unref` keeps this timer from
191
+ // holding the process open.
192
+ const timer = setTimeout(() => {
193
+ if (child.exitCode !== null || child.signalCode) return;
194
+ try {
195
+ process.kill(-pid, 'SIGKILL');
196
+ } catch (_) {
197
+ /* already gone */
198
+ }
199
+ }, 3000);
200
+ if (timer.unref) timer.unref();
201
+
202
+ return true;
203
+ }
204
+
205
+ module.exports = {
206
+ stripAnsi,
207
+ escapeBraces,
208
+ truncate,
209
+ padEnd,
210
+ timestamp,
211
+ normalizeLocalUrl,
212
+ extractLocalUrl,
213
+ splitLines,
214
+ which,
215
+ shellQuote,
216
+ appleScriptString,
217
+ killTree,
218
+ };