termbeam 1.5.0 → 1.8.0
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 +7 -32
- package/bin/termbeam.js +27 -12
- package/package.json +1 -1
- package/public/css/themes.css +217 -0
- package/public/index.html +42 -242
- package/public/js/keybar.js +180 -0
- package/public/js/search.js +95 -0
- package/public/js/shared.js +39 -0
- package/public/js/terminal-themes.js +291 -0
- package/public/js/themes.js +54 -0
- package/public/terminal.html +74 -873
- package/src/cli.js +6 -0
- package/src/git.js +125 -0
- package/src/interactive.js +269 -0
- package/src/prompts.js +146 -0
- package/src/service.js +13 -129
- package/src/sessions.js +86 -1
package/src/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ Options:
|
|
|
31
31
|
--host <addr> Bind address (default: 127.0.0.1)
|
|
32
32
|
--lan Bind to 0.0.0.0 (allow LAN access, default: localhost only)
|
|
33
33
|
--log-level <level> Set log verbosity: error, warn, info, debug (default: info)
|
|
34
|
+
-i, --interactive Interactive setup wizard (guided configuration)
|
|
34
35
|
-h, --help Show this help
|
|
35
36
|
-v, --version Show version
|
|
36
37
|
|
|
@@ -47,6 +48,7 @@ Examples:
|
|
|
47
48
|
termbeam --password secret Start with specific password
|
|
48
49
|
termbeam --persisted-tunnel Stable tunnel URL across restarts
|
|
49
50
|
termbeam /bin/bash Use bash instead of default shell
|
|
51
|
+
termbeam --interactive Guided setup wizard
|
|
50
52
|
termbeam service install Set up as background service (PM2)
|
|
51
53
|
|
|
52
54
|
Environment:
|
|
@@ -241,6 +243,7 @@ function parseArgs() {
|
|
|
241
243
|
let noTunnel = false;
|
|
242
244
|
let persistedTunnel = false;
|
|
243
245
|
let publicTunnel = false;
|
|
246
|
+
let interactive = false;
|
|
244
247
|
let explicitPassword = !!password;
|
|
245
248
|
|
|
246
249
|
const args = process.argv.slice(2);
|
|
@@ -281,6 +284,8 @@ function parseArgs() {
|
|
|
281
284
|
host = '0.0.0.0';
|
|
282
285
|
} else if (args[i] === '--host' && args[i + 1]) {
|
|
283
286
|
host = args[++i];
|
|
287
|
+
} else if (args[i] === '--interactive' || (args[i] === '-i' && filteredArgs.length === 0)) {
|
|
288
|
+
interactive = true;
|
|
284
289
|
} else if (args[i] === '--log-level' && args[i + 1]) {
|
|
285
290
|
logLevel = args[++i];
|
|
286
291
|
} else {
|
|
@@ -335,6 +340,7 @@ function parseArgs() {
|
|
|
335
340
|
defaultShell,
|
|
336
341
|
version,
|
|
337
342
|
logLevel,
|
|
343
|
+
interactive,
|
|
338
344
|
};
|
|
339
345
|
}
|
|
340
346
|
|
package/src/git.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function git(cmd, cwd) {
|
|
5
|
+
return execSync(`git ${cmd}`, { cwd, stdio: 'pipe', timeout: 3000 }).toString().trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getGitInfo(cwd) {
|
|
9
|
+
try {
|
|
10
|
+
git('rev-parse --is-inside-work-tree', cwd);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const result = { branch: null, repoName: null, provider: null, status: null };
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const branch = git('branch --show-current', cwd);
|
|
19
|
+
if (branch) {
|
|
20
|
+
result.branch = branch;
|
|
21
|
+
} else {
|
|
22
|
+
// Detached HEAD — use short SHA
|
|
23
|
+
result.branch = `(${git('rev-parse --short HEAD', cwd)})`;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
/* empty repo */
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const remoteUrl = git('remote get-url origin', cwd);
|
|
31
|
+
const parsed = parseRemoteUrl(remoteUrl);
|
|
32
|
+
if (parsed) {
|
|
33
|
+
result.repoName = parsed.repoName;
|
|
34
|
+
result.provider = parsed.provider;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// No remote — use directory name
|
|
38
|
+
try {
|
|
39
|
+
const root = git('rev-parse --show-toplevel', cwd);
|
|
40
|
+
result.repoName = path.basename(root);
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let ahead = 0,
|
|
47
|
+
behind = 0;
|
|
48
|
+
try {
|
|
49
|
+
const counts = git('rev-list --left-right --count HEAD...@{upstream}', cwd);
|
|
50
|
+
[ahead, behind] = counts.split(/\s+/).map(Number);
|
|
51
|
+
} catch {
|
|
52
|
+
/* no upstream configured */
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const raw = git('status --porcelain', cwd);
|
|
57
|
+
result.status = parseStatus(raw, ahead, behind);
|
|
58
|
+
} catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseRemoteUrl(url) {
|
|
66
|
+
// Azure DevOps: https://dev.azure.com/org/project/_git/repo
|
|
67
|
+
const azureMatch = url.match(/dev\.azure\.com\/([^/]+\/[^/]+)\/_git\/([^/]+?)(?:\.git)?$/);
|
|
68
|
+
if (azureMatch) {
|
|
69
|
+
return { repoName: `${azureMatch[1]}/${azureMatch[2]}`, provider: 'Azure DevOps' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Azure DevOps (legacy): https://org.visualstudio.com/project/_git/repo
|
|
73
|
+
const vsMatch = url.match(/([^/.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/);
|
|
74
|
+
if (vsMatch) {
|
|
75
|
+
return { repoName: `${vsMatch[1]}/${vsMatch[2]}/${vsMatch[3]}`, provider: 'Azure DevOps' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// SSH: git@github.com:owner/repo.git
|
|
79
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
80
|
+
const match = url.match(/[@/]([^/:]+)[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
81
|
+
if (!match) return null;
|
|
82
|
+
|
|
83
|
+
const host = match[1];
|
|
84
|
+
const fullName = match[2];
|
|
85
|
+
|
|
86
|
+
let provider = host;
|
|
87
|
+
if (host.includes('github')) provider = 'GitHub';
|
|
88
|
+
else if (host.includes('gitlab')) provider = 'GitLab';
|
|
89
|
+
else if (host.includes('bitbucket')) provider = 'Bitbucket';
|
|
90
|
+
|
|
91
|
+
return { repoName: fullName, provider };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseStatus(output, ahead, behind) {
|
|
95
|
+
let modified = 0,
|
|
96
|
+
staged = 0,
|
|
97
|
+
untracked = 0;
|
|
98
|
+
|
|
99
|
+
if (output) {
|
|
100
|
+
const lines = output.split('\n').filter(Boolean);
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
const index = line[0];
|
|
103
|
+
const working = line[1];
|
|
104
|
+
if (index === '?' && working === '?') {
|
|
105
|
+
untracked++;
|
|
106
|
+
} else {
|
|
107
|
+
if (index !== ' ' && index !== '?') staged++;
|
|
108
|
+
if (working !== ' ' && working !== '?') modified++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const parts = [];
|
|
114
|
+
if (staged) parts.push(`${staged} staged`);
|
|
115
|
+
if (modified) parts.push(`${modified} modified`);
|
|
116
|
+
if (untracked) parts.push(`${untracked} untracked`);
|
|
117
|
+
if (ahead) parts.push(`${ahead}↑`);
|
|
118
|
+
if (behind) parts.push(`${behind}↓`);
|
|
119
|
+
const clean = !staged && !modified && !untracked && !ahead && !behind;
|
|
120
|
+
const summary = parts.length ? parts.join(', ') : 'clean';
|
|
121
|
+
|
|
122
|
+
return { clean, modified, staged, untracked, ahead: ahead || 0, behind: behind || 0, summary };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { getGitInfo, parseRemoteUrl, parseStatus };
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const {
|
|
3
|
+
green,
|
|
4
|
+
yellow,
|
|
5
|
+
red,
|
|
6
|
+
cyan,
|
|
7
|
+
bold,
|
|
8
|
+
dim,
|
|
9
|
+
ask,
|
|
10
|
+
choose,
|
|
11
|
+
confirm,
|
|
12
|
+
createRL,
|
|
13
|
+
} = require('./prompts');
|
|
14
|
+
|
|
15
|
+
// ── Interactive Setup Wizard ─────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
async function runInteractiveSetup(baseConfig) {
|
|
18
|
+
// Enter alternate screen buffer for a clean wizard (like vim/htop)
|
|
19
|
+
process.stdout.write('\x1b[?1049h');
|
|
20
|
+
const exitAltScreen = () => process.stdout.write('\x1b[?1049l');
|
|
21
|
+
process.on('exit', exitAltScreen);
|
|
22
|
+
|
|
23
|
+
const rl = createRL();
|
|
24
|
+
|
|
25
|
+
const steps = ['Password', 'Port', 'Access', 'Log level', 'Confirm'];
|
|
26
|
+
const decisions = [];
|
|
27
|
+
|
|
28
|
+
function showProgress(stepIndex) {
|
|
29
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
30
|
+
|
|
31
|
+
console.log(bold('🚀 TermBeam Interactive Setup'));
|
|
32
|
+
console.log('');
|
|
33
|
+
const total = steps.length;
|
|
34
|
+
const filled = stepIndex + 1;
|
|
35
|
+
const bar = steps
|
|
36
|
+
.map((s, i) => {
|
|
37
|
+
if (i < stepIndex) return green('●');
|
|
38
|
+
if (i === stepIndex) return cyan('●');
|
|
39
|
+
return dim('○');
|
|
40
|
+
})
|
|
41
|
+
.join(dim(' ─ '));
|
|
42
|
+
console.log(`${dim(`Step ${filled}/${total}`)} ${bar} ${cyan(steps[stepIndex])}`);
|
|
43
|
+
|
|
44
|
+
if (decisions.length > 0) {
|
|
45
|
+
console.log('');
|
|
46
|
+
for (const { label, value } of decisions) {
|
|
47
|
+
console.log(` ${dim(label + ':')} ${value}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build config from base
|
|
53
|
+
const config = {
|
|
54
|
+
port: baseConfig.port,
|
|
55
|
+
host: baseConfig.host,
|
|
56
|
+
password: baseConfig.password,
|
|
57
|
+
useTunnel: baseConfig.useTunnel,
|
|
58
|
+
persistedTunnel: baseConfig.persistedTunnel,
|
|
59
|
+
publicTunnel: baseConfig.publicTunnel,
|
|
60
|
+
shell: baseConfig.shell,
|
|
61
|
+
shellArgs: baseConfig.shellArgs,
|
|
62
|
+
cwd: baseConfig.cwd,
|
|
63
|
+
defaultShell: baseConfig.defaultShell,
|
|
64
|
+
version: baseConfig.version,
|
|
65
|
+
logLevel: baseConfig.logLevel,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Step 1: Password
|
|
69
|
+
showProgress(0);
|
|
70
|
+
const pwChoice = await choose(rl, 'Password authentication:', [
|
|
71
|
+
{
|
|
72
|
+
label: 'Auto-generate',
|
|
73
|
+
hint: 'Random password, shown on screen and embedded in the QR code',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
label: 'Custom password',
|
|
77
|
+
hint: 'You type a password to use for this session',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
label: 'No password',
|
|
81
|
+
hint: '⚠ No authentication — anyone who can reach the server gets shell access',
|
|
82
|
+
warn: true,
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
let passwordMode = 'auto';
|
|
86
|
+
if (pwChoice.index === 0) {
|
|
87
|
+
config.password = crypto.randomBytes(16).toString('base64url');
|
|
88
|
+
console.log(dim(` Generated password: ${config.password}`));
|
|
89
|
+
} else if (pwChoice.index === 1) {
|
|
90
|
+
passwordMode = 'custom';
|
|
91
|
+
config.password = await ask(rl, 'Enter password:');
|
|
92
|
+
while (!config.password) {
|
|
93
|
+
console.log(red(' Password cannot be empty.'));
|
|
94
|
+
config.password = await ask(rl, 'Enter password:');
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
passwordMode = 'none';
|
|
98
|
+
config.password = null;
|
|
99
|
+
}
|
|
100
|
+
decisions.push({
|
|
101
|
+
label: 'Password',
|
|
102
|
+
value: config.password == null ? yellow('disabled') : '••••••••',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Step 2: Port
|
|
106
|
+
showProgress(1);
|
|
107
|
+
const portStr = await ask(rl, 'Port:', String(config.port));
|
|
108
|
+
const portNum = parseInt(portStr, 10);
|
|
109
|
+
config.port = portNum >= 1 && portNum <= 65535 ? portNum : 3456;
|
|
110
|
+
decisions.push({ label: 'Port', value: String(config.port) });
|
|
111
|
+
|
|
112
|
+
// Step 3: Access mode
|
|
113
|
+
showProgress(2);
|
|
114
|
+
const accessChoice = await choose(rl, 'How will you connect to TermBeam?', [
|
|
115
|
+
{
|
|
116
|
+
label: 'DevTunnel (internet)',
|
|
117
|
+
hint: 'HTTPS tunnel — accessible from any network, secured with your Microsoft account',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
label: 'LAN',
|
|
121
|
+
hint: 'Binds to 0.0.0.0 — accessible from devices on the same network',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
label: 'Localhost only',
|
|
125
|
+
hint: 'Binds to 127.0.0.1 — only this machine can connect',
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
if (accessChoice.index === 0) {
|
|
130
|
+
// DevTunnel mode
|
|
131
|
+
config.host = '127.0.0.1';
|
|
132
|
+
config.useTunnel = true;
|
|
133
|
+
|
|
134
|
+
// Sub-question: tunnel persistence
|
|
135
|
+
showProgress(2);
|
|
136
|
+
const persistChoice = await choose(rl, 'Tunnel persistence:', [
|
|
137
|
+
{
|
|
138
|
+
label: 'Ephemeral',
|
|
139
|
+
hint: 'New URL each run, automatically deleted when TermBeam exits',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: 'Persisted',
|
|
143
|
+
hint: 'Stable URL that survives restarts (expires after 30 days idle)',
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
config.persistedTunnel = persistChoice.index === 1;
|
|
147
|
+
|
|
148
|
+
// Sub-question: access level
|
|
149
|
+
showProgress(2);
|
|
150
|
+
const publicChoice = await choose(rl, 'Tunnel access:', [
|
|
151
|
+
{
|
|
152
|
+
label: 'Private (owner-only)',
|
|
153
|
+
hint: 'Only the Microsoft account that created the tunnel can access it',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
label: 'Public',
|
|
157
|
+
hint: '🚨 No Microsoft login — anyone with the URL can reach your terminal',
|
|
158
|
+
danger: true,
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
config.publicTunnel = publicChoice.index === 1;
|
|
162
|
+
|
|
163
|
+
// Auto-generate password if public tunnel with no password
|
|
164
|
+
if (config.publicTunnel && !config.password) {
|
|
165
|
+
console.log(yellow(' ⚠ Public tunnels require password authentication.'));
|
|
166
|
+
config.password = crypto.randomBytes(16).toString('base64url');
|
|
167
|
+
console.log(dim(` Auto-generated password: ${config.password}`));
|
|
168
|
+
passwordMode = 'auto';
|
|
169
|
+
// Update the password decision
|
|
170
|
+
decisions[0] = { label: 'Password', value: '••••••••' };
|
|
171
|
+
}
|
|
172
|
+
} else if (accessChoice.index === 1) {
|
|
173
|
+
// LAN mode
|
|
174
|
+
config.host = '0.0.0.0';
|
|
175
|
+
config.useTunnel = false;
|
|
176
|
+
config.persistedTunnel = false;
|
|
177
|
+
config.publicTunnel = false;
|
|
178
|
+
} else {
|
|
179
|
+
// Localhost only
|
|
180
|
+
config.host = '127.0.0.1';
|
|
181
|
+
config.useTunnel = false;
|
|
182
|
+
config.persistedTunnel = false;
|
|
183
|
+
config.publicTunnel = false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const accessLabel = !config.useTunnel
|
|
187
|
+
? config.host === '0.0.0.0'
|
|
188
|
+
? 'LAN (0.0.0.0)'
|
|
189
|
+
: 'Localhost only'
|
|
190
|
+
: config.publicTunnel
|
|
191
|
+
? 'DevTunnel (public)'
|
|
192
|
+
: 'DevTunnel (private)';
|
|
193
|
+
decisions.push({ label: 'Access', value: accessLabel });
|
|
194
|
+
|
|
195
|
+
// Step 4: Log level
|
|
196
|
+
showProgress(3);
|
|
197
|
+
const logChoice = await choose(
|
|
198
|
+
rl,
|
|
199
|
+
'Log level:',
|
|
200
|
+
[
|
|
201
|
+
{ label: 'info', hint: 'Logs startup, connections, sessions, and errors (default)' },
|
|
202
|
+
{ label: 'debug', hint: 'Includes all info logs plus WebSocket frames and internal state' },
|
|
203
|
+
{ label: 'warn', hint: 'Only logs warnings and errors' },
|
|
204
|
+
{ label: 'error', hint: 'Only logs critical errors' },
|
|
205
|
+
],
|
|
206
|
+
0,
|
|
207
|
+
);
|
|
208
|
+
config.logLevel = logChoice.value;
|
|
209
|
+
decisions.push({ label: 'Log level', value: config.logLevel });
|
|
210
|
+
|
|
211
|
+
// Step 5: Confirmation
|
|
212
|
+
showProgress(4);
|
|
213
|
+
console.log(bold('\n── Configuration Summary ──────────────────'));
|
|
214
|
+
console.log(
|
|
215
|
+
` Password: ${config.password == null ? yellow('disabled') : cyan('••••••••')}`,
|
|
216
|
+
);
|
|
217
|
+
console.log(` Port: ${cyan(String(config.port))}`);
|
|
218
|
+
console.log(
|
|
219
|
+
` Host: ${cyan(config.host === '0.0.0.0' ? '0.0.0.0 (LAN)' : config.host)}`,
|
|
220
|
+
);
|
|
221
|
+
console.log(` Tunnel: ${config.useTunnel ? cyan('enabled') : yellow('disabled')}`);
|
|
222
|
+
if (config.useTunnel) {
|
|
223
|
+
console.log(` Persisted: ${config.persistedTunnel ? cyan('yes') : dim('no')}`);
|
|
224
|
+
console.log(` Public: ${config.publicTunnel ? yellow('yes') : dim('no')}`);
|
|
225
|
+
}
|
|
226
|
+
console.log(` Shell: ${cyan(config.shell || 'default')}`);
|
|
227
|
+
console.log(` Directory: ${cyan(config.cwd)}`);
|
|
228
|
+
console.log(` Log level: ${cyan(config.logLevel)}`);
|
|
229
|
+
console.log(dim('─'.repeat(44)));
|
|
230
|
+
|
|
231
|
+
// Build the equivalent CLI command
|
|
232
|
+
const cmdParts = ['termbeam'];
|
|
233
|
+
if (passwordMode === 'none') {
|
|
234
|
+
cmdParts.push('--no-password');
|
|
235
|
+
} else if (passwordMode === 'custom') {
|
|
236
|
+
cmdParts.push('--password', '"<your-password>"');
|
|
237
|
+
}
|
|
238
|
+
// auto-generate is the default — no flag needed
|
|
239
|
+
if (config.port !== 3456) cmdParts.push('--port', String(config.port));
|
|
240
|
+
if (!config.useTunnel) {
|
|
241
|
+
cmdParts.push('--no-tunnel');
|
|
242
|
+
if (config.host === '0.0.0.0') cmdParts.push('--lan');
|
|
243
|
+
} else {
|
|
244
|
+
if (config.persistedTunnel) cmdParts.push('--persisted-tunnel');
|
|
245
|
+
if (config.publicTunnel) cmdParts.push('--public');
|
|
246
|
+
}
|
|
247
|
+
if (config.logLevel !== 'info') cmdParts.push('--log-level', config.logLevel);
|
|
248
|
+
const cliCommand = cmdParts.join(' ');
|
|
249
|
+
|
|
250
|
+
console.log('');
|
|
251
|
+
console.log(dim(' To reuse this configuration without the wizard:'));
|
|
252
|
+
console.log(` ${cyan(cliCommand)}`);
|
|
253
|
+
|
|
254
|
+
const proceed = await confirm(rl, '\nStart TermBeam with this configuration?', true);
|
|
255
|
+
rl.close();
|
|
256
|
+
|
|
257
|
+
// Exit alternate screen — return to normal terminal
|
|
258
|
+
exitAltScreen();
|
|
259
|
+
process.removeListener('exit', exitAltScreen);
|
|
260
|
+
|
|
261
|
+
if (!proceed) {
|
|
262
|
+
console.log(dim('Cancelled.'));
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return config;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = { runInteractiveSetup };
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
|
|
3
|
+
// ── Color helpers ────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
function color(code, text) {
|
|
6
|
+
return `\x1b[${code}m${text}\x1b[0m`;
|
|
7
|
+
}
|
|
8
|
+
const green = (t) => color('32', t);
|
|
9
|
+
const yellow = (t) => color('33', t);
|
|
10
|
+
const red = (t) => color('31', t);
|
|
11
|
+
const cyan = (t) => color('36', t);
|
|
12
|
+
const bold = (t) => color('1', t);
|
|
13
|
+
const dim = (t) => color('2', t);
|
|
14
|
+
|
|
15
|
+
// ── Interactive prompts ──────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Prompt the user with a question. Returns the trimmed answer.
|
|
19
|
+
* If `defaultValue` is provided, it's shown in brackets and used when the user presses Enter.
|
|
20
|
+
*/
|
|
21
|
+
function ask(rl, question, defaultValue) {
|
|
22
|
+
const suffix = defaultValue != null ? ` ${dim(`[${defaultValue}]`)} ` : ' ';
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
rl.question(`${question}${suffix}`, (answer) => {
|
|
25
|
+
const trimmed = answer.trim();
|
|
26
|
+
resolve(trimmed || (defaultValue != null ? String(defaultValue) : ''));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Prompt the user with a list of choices using arrow keys.
|
|
33
|
+
* Each choice can be a string or { label, hint } object.
|
|
34
|
+
* Up/Down to move, Enter to select. Returns the chosen value.
|
|
35
|
+
*/
|
|
36
|
+
function choose(rl, question, choices, defaultIndex = 0) {
|
|
37
|
+
// Normalize choices to { label, hint } objects
|
|
38
|
+
const items = choices.map((c) => (typeof c === 'string' ? { label: c, hint: '' } : c));
|
|
39
|
+
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
let selected = defaultIndex;
|
|
42
|
+
|
|
43
|
+
function lineCount() {
|
|
44
|
+
return items.reduce((n, item) => n + 1 + (item.hint ? 1 : 0), 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function render(clear) {
|
|
48
|
+
if (clear) {
|
|
49
|
+
process.stdout.write(`\x1b[${lineCount()}A\r\x1b[J`);
|
|
50
|
+
}
|
|
51
|
+
items.forEach((item, i) => {
|
|
52
|
+
const marker = i === selected ? cyan('→') : ' ';
|
|
53
|
+
const label = i === selected ? bold(item.label) : item.label;
|
|
54
|
+
process.stdout.write(` ${marker} ${label}\n`);
|
|
55
|
+
if (item.hint) {
|
|
56
|
+
const hintText = item.danger
|
|
57
|
+
? red(item.hint)
|
|
58
|
+
: item.warn
|
|
59
|
+
? yellow(item.hint)
|
|
60
|
+
: dim(item.hint);
|
|
61
|
+
process.stdout.write(` ${hintText}\n`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
process.stdout.write(dim(' ↑/↓ to move, Enter to select'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
rl.pause();
|
|
68
|
+
console.log(`\n${question}`);
|
|
69
|
+
render(false);
|
|
70
|
+
|
|
71
|
+
const wasRaw = process.stdin.isRaw;
|
|
72
|
+
if (process.stdin.isTTY) {
|
|
73
|
+
process.stdin.setRawMode(true);
|
|
74
|
+
}
|
|
75
|
+
process.stdin.resume();
|
|
76
|
+
|
|
77
|
+
function onKey(buf) {
|
|
78
|
+
const key = buf.toString();
|
|
79
|
+
|
|
80
|
+
if (key === '\x1b[A' || key === 'k') {
|
|
81
|
+
selected = (selected - 1 + items.length) % items.length;
|
|
82
|
+
render(true);
|
|
83
|
+
} else if (key === '\x1b[B' || key === 'j') {
|
|
84
|
+
selected = (selected + 1) % items.length;
|
|
85
|
+
render(true);
|
|
86
|
+
} else if (key === '\r' || key === '\n') {
|
|
87
|
+
cleanup();
|
|
88
|
+
process.stdout.write('\r\x1b[K\n');
|
|
89
|
+
console.log(dim(` Selected: ${items[selected].label}`));
|
|
90
|
+
resolve({ index: selected, value: items[selected].label });
|
|
91
|
+
} else if (key === '\x03') {
|
|
92
|
+
cleanup();
|
|
93
|
+
process.stdout.write('\x1b[?1049l');
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cleanup() {
|
|
99
|
+
process.stdin.removeListener('data', onKey);
|
|
100
|
+
if (process.stdin.isTTY) {
|
|
101
|
+
process.stdin.setRawMode(wasRaw || false);
|
|
102
|
+
}
|
|
103
|
+
process.stdin.pause();
|
|
104
|
+
rl.resume();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.stdin.on('data', onKey);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Ask a yes/no question. Returns boolean.
|
|
113
|
+
*/
|
|
114
|
+
function confirm(rl, question, defaultYes = true) {
|
|
115
|
+
const hint = defaultYes ? 'Y/n' : 'y/N';
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
rl.question(`${question} ${dim(`[${hint}]`)} `, (answer) => {
|
|
118
|
+
const a = answer.trim().toLowerCase();
|
|
119
|
+
if (a === '') resolve(defaultYes);
|
|
120
|
+
else resolve(a === 'y' || a === 'yes');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── readline factory ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function createRL() {
|
|
128
|
+
return readline.createInterface({
|
|
129
|
+
input: process.stdin,
|
|
130
|
+
output: process.stdout,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
color,
|
|
136
|
+
green,
|
|
137
|
+
yellow,
|
|
138
|
+
red,
|
|
139
|
+
cyan,
|
|
140
|
+
bold,
|
|
141
|
+
dim,
|
|
142
|
+
ask,
|
|
143
|
+
choose,
|
|
144
|
+
confirm,
|
|
145
|
+
createRL,
|
|
146
|
+
};
|