rl-rockcli 0.0.9 → 0.0.11
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/commands/attach/basic-repl.js +212 -0
- package/commands/attach/cleanup-history.js +189 -0
- package/commands/attach/cleanup-manager.js +163 -0
- package/commands/attach/copy-ui/copyRepl.js +195 -0
- package/commands/attach/copy-ui/index.js +7 -0
- package/commands/attach/copy-ui/render/outputBlock.js +25 -0
- package/commands/attach/copy-ui/viewport/viewport.js +23 -0
- package/commands/attach/copy-ui/viewport/wheel.js +14 -0
- package/commands/attach/history-manager.js +507 -0
- package/commands/attach/history-session.js +48 -0
- package/commands/attach/ink-repl/InkREPL.js +1507 -0
- package/commands/attach/ink-repl/builtinCommands.js +1253 -0
- package/commands/attach/ink-repl/components/ConnectingScreen.js +76 -0
- package/commands/attach/ink-repl/components/Console.js +191 -0
- package/commands/attach/ink-repl/components/DetailView.js +148 -0
- package/commands/attach/ink-repl/components/DropdownMenu.js +86 -0
- package/commands/attach/ink-repl/components/InputArea.js +125 -0
- package/commands/attach/ink-repl/components/InputLine.js +18 -0
- package/commands/attach/ink-repl/components/OutputArea.js +22 -0
- package/commands/attach/ink-repl/components/OutputItem.js +96 -0
- package/commands/attach/ink-repl/components/ShellLayout.js +61 -0
- package/commands/attach/ink-repl/components/Spinner.js +79 -0
- package/commands/attach/ink-repl/components/StatusBar.js +106 -0
- package/commands/attach/ink-repl/components/WelcomeBanner.js +48 -0
- package/commands/attach/ink-repl/contexts/LayoutContext.js +12 -0
- package/commands/attach/ink-repl/contexts/ThemeContext.js +43 -0
- package/commands/attach/ink-repl/hooks/useFunctionKeys.js +70 -0
- package/commands/attach/ink-repl/hooks/useMouse.js +162 -0
- package/commands/attach/ink-repl/hooks/useResources.js +132 -0
- package/commands/attach/ink-repl/hooks/useSpinner.js +49 -0
- package/commands/attach/ink-repl/index.js +112 -0
- package/commands/attach/ink-repl/package.json +3 -0
- package/commands/attach/ink-repl/replState.js +947 -0
- package/commands/attach/ink-repl/shortcuts/defaultKeybindings.js +138 -0
- package/commands/attach/ink-repl/shortcuts/index.js +332 -0
- package/commands/attach/ink-repl/themes/defaultDark.js +18 -0
- package/commands/attach/ink-repl/themes/defaultLight.js +18 -0
- package/commands/attach/ink-repl/themes/index.js +4 -0
- package/commands/attach/ink-repl/themes/themeManager.js +45 -0
- package/commands/attach/ink-repl/themes/themeTokens.js +15 -0
- package/commands/attach/ink-repl/utils/atCompletion.js +346 -0
- package/commands/attach/ink-repl/utils/clipboard.js +50 -0
- package/commands/attach/ink-repl/utils/consoleLogger.js +81 -0
- package/commands/attach/ink-repl/utils/exitCodeHandler.js +49 -0
- package/commands/attach/ink-repl/utils/exitCodeTips.js +56 -0
- package/commands/attach/ink-repl/utils/formatTime.js +12 -0
- package/commands/attach/ink-repl/utils/outputSelection.js +120 -0
- package/commands/attach/ink-repl/utils/outputViewport.js +77 -0
- package/commands/attach/ink-repl/utils/paginatedFileLoading.js +76 -0
- package/commands/attach/ink-repl/utils/paramHint.js +60 -0
- package/commands/attach/ink-repl/utils/parseError.js +174 -0
- package/commands/attach/ink-repl/utils/pathCompletion.js +167 -0
- package/commands/attach/ink-repl/utils/remotePathSafety.js +56 -0
- package/commands/attach/ink-repl/utils/replSelection.js +205 -0
- package/commands/attach/ink-repl/utils/responseFormatter.js +127 -0
- package/commands/attach/ink-repl/utils/textWrap.js +117 -0
- package/commands/attach/ink-repl/utils/truncate.js +115 -0
- package/commands/attach/opentui-repl/App.tsx +891 -0
- package/commands/attach/opentui-repl/builtinCommands.ts +80 -0
- package/commands/attach/opentui-repl/components/ConfirmDialog.tsx +116 -0
- package/commands/attach/opentui-repl/components/ConnectingScreen.tsx +131 -0
- package/commands/attach/opentui-repl/components/Console.tsx +73 -0
- package/commands/attach/opentui-repl/components/DetailView.tsx +45 -0
- package/commands/attach/opentui-repl/components/DropdownMenu.tsx +130 -0
- package/commands/attach/opentui-repl/components/ExecutionStatus.tsx +66 -0
- package/commands/attach/opentui-repl/components/Header.tsx +24 -0
- package/commands/attach/opentui-repl/components/OutputArea.tsx +25 -0
- package/commands/attach/opentui-repl/components/OutputBlock.tsx +108 -0
- package/commands/attach/opentui-repl/components/PromptInput.tsx +109 -0
- package/commands/attach/opentui-repl/components/StatusBar.tsx +63 -0
- package/commands/attach/opentui-repl/components/Toast.tsx +65 -0
- package/commands/attach/opentui-repl/components/WelcomeBanner.tsx +41 -0
- package/commands/attach/opentui-repl/contexts/ReplContext.tsx +137 -0
- package/commands/attach/opentui-repl/contexts/SessionContext.tsx +32 -0
- package/commands/attach/opentui-repl/contexts/ThemeContext.tsx +70 -0
- package/commands/attach/opentui-repl/contexts/ToastContext.tsx +69 -0
- package/commands/attach/opentui-repl/contexts/toast-logic.js +71 -0
- package/commands/attach/opentui-repl/hooks/useResources.ts +102 -0
- package/commands/attach/opentui-repl/hooks/useSpinner.ts +46 -0
- package/commands/attach/opentui-repl/index.js +99 -0
- package/commands/attach/opentui-repl/keybindings.ts +39 -0
- package/commands/attach/opentui-repl/package.json +3 -0
- package/commands/attach/opentui-repl/render.tsx +72 -0
- package/commands/attach/opentui-repl/tsconfig.json +12 -0
- package/commands/attach/repl.js +791 -0
- package/commands/attach/sandbox-id-resolver.js +56 -0
- package/commands/attach/session-manager.js +307 -0
- package/commands/attach/ui-mode.js +146 -0
- package/commands/attach.js +186 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
const logger = require('../../../utils/logger');
|
|
5
|
+
import { hasDangerousShellChars, shellEscapePosix } from './utils/remotePathSafety.js';
|
|
6
|
+
import { formatUploadResult } from './utils/responseFormatter.js';
|
|
7
|
+
import { friendlyErrorMessage } from './utils/parseError.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Command help descriptions
|
|
11
|
+
*/
|
|
12
|
+
export const COMMAND_HELP = {
|
|
13
|
+
// Primary commands
|
|
14
|
+
status: 'Show sandbox status',
|
|
15
|
+
sessions: 'List attach sessions',
|
|
16
|
+
log: 'View sandbox logs. Usage: /log [-f file] [-k keyword] [-n lines]',
|
|
17
|
+
tail: 'View last lines of a file. Usage: /tail [-n lines] [-k keyword] [-f] <file>',
|
|
18
|
+
upload: 'Upload file/directory to sandbox. Usage: /upload @<local> @<remote>',
|
|
19
|
+
download: 'Download file from sandbox. Usage: /download @<remote> @<local>',
|
|
20
|
+
// Utility commands
|
|
21
|
+
stop: 'Stop the sandbox',
|
|
22
|
+
// resume: 'Resume a previous session. Usage: /resume [session-id]', // DISABLED
|
|
23
|
+
clear: 'Clear terminal screen',
|
|
24
|
+
stats: 'Show session statistics',
|
|
25
|
+
copy: 'Copy output of last command to clipboard',
|
|
26
|
+
about: 'Show about information',
|
|
27
|
+
retry: 'Retry last failed command',
|
|
28
|
+
theme: 'Switch UI theme. Usage: /theme <name> | /theme list',
|
|
29
|
+
'cleanup-history': 'Clean up history sessions. Usage: /cleanup-history [--sandbox <id>] [--all]',
|
|
30
|
+
// Meta commands
|
|
31
|
+
help: 'Show help message',
|
|
32
|
+
docs: 'Open documentation',
|
|
33
|
+
bug: 'Report a bug',
|
|
34
|
+
close: 'Close the session permanently and exit',
|
|
35
|
+
exit: 'Exit the REPL (session kept alive for resume)',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Command parameter templates for inline hints
|
|
40
|
+
* Used by paramHint.js to show placeholder text when user is typing commands
|
|
41
|
+
*/
|
|
42
|
+
export const COMMAND_PARAM_TEMPLATES = {
|
|
43
|
+
upload: {
|
|
44
|
+
template: '@<local> @<remote>',
|
|
45
|
+
params: [
|
|
46
|
+
{ placeholder: '<local>', prefix: '@' },
|
|
47
|
+
{ placeholder: '<remote>', prefix: '@' }
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
download: {
|
|
51
|
+
template: '@<remote> @<local>',
|
|
52
|
+
params: [
|
|
53
|
+
{ placeholder: '<remote>', prefix: '@' },
|
|
54
|
+
{ placeholder: '<local>', prefix: '@' }
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function clampNumber(value, { min, max, fallback }) {
|
|
60
|
+
const num = Number(value);
|
|
61
|
+
if (!Number.isFinite(num)) return fallback;
|
|
62
|
+
return Math.max(min, Math.min(max, Math.floor(num)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isSafeLogFileName(name) {
|
|
66
|
+
if (!name) return false;
|
|
67
|
+
// Only allow basenames for Loghouse log file selection.
|
|
68
|
+
// For remote tail fallback, absolute paths are handled separately.
|
|
69
|
+
if (name.includes('/')) return false;
|
|
70
|
+
return /^[A-Za-z0-9._-]{1,128}$/.test(name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveRemoteLogPath(file) {
|
|
74
|
+
if (!file) return '/var/log/sandbox.log';
|
|
75
|
+
if (file.startsWith('/')) return file;
|
|
76
|
+
if (file === 'sandbox.log') return '/var/log/sandbox.log';
|
|
77
|
+
if (file === 'command.log') return '/data/logs/command.log';
|
|
78
|
+
return `/data/logs/${file}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Interactive commands that should be blocked
|
|
83
|
+
*/
|
|
84
|
+
export const INTERACTIVE_COMMANDS = {
|
|
85
|
+
vim: 'Use: cat <file> to view, or echo "content" > file to write',
|
|
86
|
+
vi: 'Use: cat <file> to view, or echo "content" > file to write',
|
|
87
|
+
nano: 'Use: cat <file> to view, or echo "content" > file to write',
|
|
88
|
+
emacs: 'Use: cat <file> to view, or echo "content" > file to write',
|
|
89
|
+
less: 'Use: cat <file> or head/tail <file>',
|
|
90
|
+
more: 'Use: cat <file> or head/tail <file>',
|
|
91
|
+
top: 'Use: ps aux | head -20, or top -b -n 1',
|
|
92
|
+
htop: 'Use: ps aux | head -20, or top -b -n 1',
|
|
93
|
+
tail: 'Use: tail -n 50 <file> (without -f)',
|
|
94
|
+
python: 'Try: python -c "print(1+1)" or python script.py',
|
|
95
|
+
python3: 'Try: python3 -c "print(1+1)" or python3 script.py',
|
|
96
|
+
node: 'Use: node -e "code" or node script.js',
|
|
97
|
+
mysql: 'Use: mysql -e "query" dbname',
|
|
98
|
+
psql: 'Use: psql -c "query" dbname',
|
|
99
|
+
ssh: 'SSH not supported in attach mode',
|
|
100
|
+
man: 'Use: command --help',
|
|
101
|
+
watch: 'Use command directly or in a loop',
|
|
102
|
+
irb: 'Use: ruby -e "code"',
|
|
103
|
+
bash: 'Already in bash shell',
|
|
104
|
+
sh: 'Already in shell',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Upload a directory recursively to sandbox
|
|
109
|
+
* @param {Object} ctx - Execution context
|
|
110
|
+
* @param {string} localDir - Local directory path (resolved)
|
|
111
|
+
* @param {string} remoteDir - Remote directory path
|
|
112
|
+
* @param {string} displayPath - Original path for display
|
|
113
|
+
* @returns {Promise<{ output: string, exitCode: number }>}
|
|
114
|
+
*/
|
|
115
|
+
async function uploadDirectory(ctx, localDir, remoteDir, displayPath) {
|
|
116
|
+
const fs = require('fs');
|
|
117
|
+
const path = require('path');
|
|
118
|
+
|
|
119
|
+
const uploadedFiles = [];
|
|
120
|
+
const errors = [];
|
|
121
|
+
|
|
122
|
+
// Ensure remote directory ends with /
|
|
123
|
+
const normalizedRemoteDir = remoteDir.endsWith('/') ? remoteDir : `${remoteDir}/`;
|
|
124
|
+
|
|
125
|
+
// Recursive function to collect all files
|
|
126
|
+
function collectFiles(dir, relativePath = '') {
|
|
127
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
128
|
+
const files = [];
|
|
129
|
+
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const localPath = path.join(dir, entry.name);
|
|
132
|
+
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
133
|
+
|
|
134
|
+
if (entry.isDirectory()) {
|
|
135
|
+
files.push(...collectFiles(localPath, relPath));
|
|
136
|
+
} else if (entry.isFile()) {
|
|
137
|
+
files.push({
|
|
138
|
+
localPath,
|
|
139
|
+
relativePath: relPath,
|
|
140
|
+
remotePath: `${normalizedRemoteDir}${relPath}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return files;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const files = collectFiles(localDir);
|
|
150
|
+
|
|
151
|
+
if (files.length === 0) {
|
|
152
|
+
return { output: `Directory is empty: ${displayPath}`, exitCode: 0 };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Upload each file
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
try {
|
|
158
|
+
const result = await ctx.client.uploadFile(file.localPath, file.remotePath);
|
|
159
|
+
if (result.success) {
|
|
160
|
+
uploadedFiles.push(file.relativePath);
|
|
161
|
+
} else {
|
|
162
|
+
errors.push(`${file.relativePath}: ${result.message}`);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const errMessage = err?.message || (typeof err === 'string' ? err : 'Unknown error');
|
|
166
|
+
errors.push(`${file.relativePath}: ${errMessage}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Build result message
|
|
171
|
+
const lines = [];
|
|
172
|
+
if (uploadedFiles.length > 0) {
|
|
173
|
+
lines.push(`✓ Uploaded ${uploadedFiles.length} file(s): ${displayPath} → ${remoteDir}`);
|
|
174
|
+
if (uploadedFiles.length <= 10) {
|
|
175
|
+
for (const f of uploadedFiles) {
|
|
176
|
+
lines.push(` ✓ ${f}`);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
for (const f of uploadedFiles.slice(0, 5)) {
|
|
180
|
+
lines.push(` ✓ ${f}`);
|
|
181
|
+
}
|
|
182
|
+
lines.push(` ... and ${uploadedFiles.length - 5} more`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (errors.length > 0) {
|
|
187
|
+
lines.push('');
|
|
188
|
+
lines.push(`✗ Failed to upload ${errors.length} file(s):`);
|
|
189
|
+
for (const e of errors.slice(0, 5)) {
|
|
190
|
+
lines.push(` ✗ ${e}`);
|
|
191
|
+
}
|
|
192
|
+
if (errors.length > 5) {
|
|
193
|
+
lines.push(` ... and ${errors.length - 5} more errors`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
output: lines.join('\n'),
|
|
199
|
+
exitCode: errors.length > 0 ? 1 : 0,
|
|
200
|
+
};
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return { output: `Failed to read directory: ${error.message}`, exitCode: 1 };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Builtin command handlers
|
|
208
|
+
* Each handler receives (context, args) and returns { output, exitCode }
|
|
209
|
+
*/
|
|
210
|
+
export const commandHandlers = {
|
|
211
|
+
/**
|
|
212
|
+
* /exit - Exit REPL
|
|
213
|
+
*/
|
|
214
|
+
exit: async (ctx, args) => {
|
|
215
|
+
return { action: 'exit' };
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* /quit - Alias for exit
|
|
220
|
+
*/
|
|
221
|
+
quit: async (ctx, args) => {
|
|
222
|
+
return { action: 'exit' };
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* /clear - Clear screen
|
|
227
|
+
*/
|
|
228
|
+
clear: async (ctx, args) => {
|
|
229
|
+
return { action: 'clear' };
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* /help - Show help
|
|
234
|
+
*/
|
|
235
|
+
help: async (ctx, args) => {
|
|
236
|
+
if (args.length > 0) {
|
|
237
|
+
const cmd = args[0].replace(/^\//, '');
|
|
238
|
+
if (COMMAND_HELP[cmd]) {
|
|
239
|
+
return { output: `/${cmd}: ${COMMAND_HELP[cmd]}`, exitCode: 0 };
|
|
240
|
+
}
|
|
241
|
+
return { output: `Unknown command: ${cmd}`, exitCode: 1 };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const lines = [
|
|
245
|
+
'Available commands:',
|
|
246
|
+
'',
|
|
247
|
+
'Builtin commands (start with /):',
|
|
248
|
+
...Object.entries(COMMAND_HELP).map(([cmd, desc]) =>
|
|
249
|
+
` /${cmd.padEnd(12)} ${desc}`
|
|
250
|
+
),
|
|
251
|
+
'',
|
|
252
|
+
'Shell commands:',
|
|
253
|
+
' Any command not starting with / is executed as a shell command.',
|
|
254
|
+
' Examples: ls -la, cd /app, python script.py',
|
|
255
|
+
'',
|
|
256
|
+
'Tips:',
|
|
257
|
+
' - Use Up/Down arrows to navigate history',
|
|
258
|
+
' - Use Tab for auto-completion',
|
|
259
|
+
' - Double Ctrl+C to exit',
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
return { output: lines.join('\n'), exitCode: 0 };
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* /status - Show sandbox status
|
|
267
|
+
*/
|
|
268
|
+
status: async (ctx, args) => {
|
|
269
|
+
try {
|
|
270
|
+
const status = await ctx.client.getStatus();
|
|
271
|
+
|
|
272
|
+
// Status indicator
|
|
273
|
+
const aliveIcon = status.isAlive ? '●' : '○';
|
|
274
|
+
const aliveText = status.isAlive ? 'Running' : 'Stopped';
|
|
275
|
+
|
|
276
|
+
// Stage status with icons
|
|
277
|
+
let stagesLine = '';
|
|
278
|
+
if (status.status) {
|
|
279
|
+
const stages = Object.entries(status.status);
|
|
280
|
+
const stageDisplay = stages.map(([stage, details]) => {
|
|
281
|
+
const s = details.status || 'unknown';
|
|
282
|
+
let icon = '?';
|
|
283
|
+
if (s === 'success') icon = '✓';
|
|
284
|
+
else if (s === 'failed' || s === 'error') icon = '✗';
|
|
285
|
+
else if (s === 'running' || s === 'pending') icon = '◐';
|
|
286
|
+
return `${icon} ${stage.replace(/_/g, ' ')}`;
|
|
287
|
+
});
|
|
288
|
+
stagesLine = stageDisplay.join(' ');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Build compact status display
|
|
292
|
+
const lines = [
|
|
293
|
+
'',
|
|
294
|
+
` ${aliveIcon} Sandbox ${aliveText}`,
|
|
295
|
+
'',
|
|
296
|
+
` ID ${ctx.sandboxId}`,
|
|
297
|
+
` Cluster ${status.cluster || 'N/A'}`,
|
|
298
|
+
` Host ${status.hostName || 'N/A'}`,
|
|
299
|
+
` IP ${status.hostIp || 'N/A'}`,
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
if (stagesLine) {
|
|
303
|
+
lines.push(` Stages ${stagesLine}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
lines.push('');
|
|
307
|
+
|
|
308
|
+
return { output: lines.join('\n'), exitCode: 0 };
|
|
309
|
+
} catch (error) {
|
|
310
|
+
// Error message is already user-friendly from getStatus()
|
|
311
|
+
return { output: error.message, exitCode: 1 };
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* /tail - View last lines of a file
|
|
317
|
+
*/
|
|
318
|
+
tail: async (ctx, args) => {
|
|
319
|
+
let lines = 10;
|
|
320
|
+
let keyword = null;
|
|
321
|
+
let follow = false;
|
|
322
|
+
let filePath = null;
|
|
323
|
+
|
|
324
|
+
for (let i = 0; i < args.length; i++) {
|
|
325
|
+
if (args[i] === '-n' && args[i + 1]) {
|
|
326
|
+
lines = clampNumber(args[++i], { min: 1, max: 10000, fallback: 10 });
|
|
327
|
+
} else if (args[i] === '-k' && args[i + 1]) {
|
|
328
|
+
keyword = args[++i];
|
|
329
|
+
} else if (args[i] === '-f' || args[i] === '--follow') {
|
|
330
|
+
follow = true;
|
|
331
|
+
} else if (!args[i].startsWith('-')) {
|
|
332
|
+
filePath = args[i];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!filePath) {
|
|
337
|
+
return {
|
|
338
|
+
output: 'Usage: /tail [-n lines] [-k keyword] [-f] <file>\nExample: /tail /var/log/sandbox.log\n /tail -n 50 -k error /var/log/sandbox.log',
|
|
339
|
+
exitCode: 1,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (hasDangerousShellChars(filePath)) {
|
|
344
|
+
return { output: 'Invalid file path: dangerous characters detected', exitCode: 1 };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (follow) {
|
|
348
|
+
// Follow mode requires interactive terminal, not supported in tests
|
|
349
|
+
return {
|
|
350
|
+
output: 'Follow mode (-f) is not supported in this environment. Use tail without -f.',
|
|
351
|
+
exitCode: 1,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
let command = `tail -n ${lines} -- ${shellEscapePosix(filePath)}`;
|
|
357
|
+
if (keyword) {
|
|
358
|
+
command = `${command} | grep -i -- ${shellEscapePosix(keyword)}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const result = await ctx.sessionManager.execute(command);
|
|
362
|
+
|
|
363
|
+
if (result.exit_code !== 0) {
|
|
364
|
+
const errorMsg = result.stderr || result.output || '';
|
|
365
|
+
if (errorMsg.includes('No such file') || errorMsg.includes('cannot access')) {
|
|
366
|
+
return { output: 'Failed to read file: file not found', exitCode: 1 };
|
|
367
|
+
}
|
|
368
|
+
return { output: `Failed to read file: ${errorMsg}`, exitCode: 1 };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const output = result.output || '';
|
|
372
|
+
if (!output.trim()) {
|
|
373
|
+
return { output: '(empty file)', exitCode: 0 };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { output, exitCode: 0 };
|
|
377
|
+
} catch (error) {
|
|
378
|
+
return { output: `Failed to read file: ${error.message}`, exitCode: 1 };
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* /log - View sandbox logs
|
|
384
|
+
*/
|
|
385
|
+
log: async (ctx, args) => {
|
|
386
|
+
let keyword = null;
|
|
387
|
+
let lines = 100;
|
|
388
|
+
let logFile = 'command.log';
|
|
389
|
+
|
|
390
|
+
for (let i = 0; i < args.length; i++) {
|
|
391
|
+
if (args[i] === '-k' && args[i + 1]) {
|
|
392
|
+
keyword = args[++i];
|
|
393
|
+
} else if (args[i] === '-n' && args[i + 1]) {
|
|
394
|
+
lines = clampNumber(args[++i], { min: 1, max: 5000, fallback: 100 });
|
|
395
|
+
} else if ((args[i] === '-f' || args[i] === '--file') && args[i + 1]) {
|
|
396
|
+
logFile = args[++i];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Reject obvious shell injection for user-provided values. This is not an auto-triggered path,
|
|
401
|
+
// but we still keep it safe since it runs in the sandbox shell.
|
|
402
|
+
if (hasDangerousShellChars(logFile) || (keyword && hasDangerousShellChars(keyword))) {
|
|
403
|
+
return { output: 'Invalid log parameters.', exitCode: 1 };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
// Try Loghouse first (only supports safe basenames for logFile)
|
|
408
|
+
const safeLoghouseFile = isSafeLogFileName(logFile) ? logFile : null;
|
|
409
|
+
const handleLogSearch =
|
|
410
|
+
typeof ctx.handleLogSearch === 'function'
|
|
411
|
+
? ctx.handleLogSearch
|
|
412
|
+
: require('../../log/search').handleLogSearch;
|
|
413
|
+
|
|
414
|
+
const searchArgv = {
|
|
415
|
+
sandboxId: ctx.sandboxId,
|
|
416
|
+
logFile: safeLoghouseFile || 'command.log',
|
|
417
|
+
minutes: 60,
|
|
418
|
+
limit: lines,
|
|
419
|
+
keyword: keyword || undefined,
|
|
420
|
+
raw: false,
|
|
421
|
+
highlight: true,
|
|
422
|
+
highlightSandboxId: false,
|
|
423
|
+
truncate: 200,
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// Capture console output
|
|
427
|
+
const output = [];
|
|
428
|
+
const originalLog = console.log;
|
|
429
|
+
const originalError = console.error;
|
|
430
|
+
|
|
431
|
+
console.log = (...args) => output.push(args.join(' '));
|
|
432
|
+
console.error = (...args) => output.push(args.join(' '));
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
await handleLogSearch(searchArgv);
|
|
436
|
+
} finally {
|
|
437
|
+
console.log = originalLog;
|
|
438
|
+
console.error = originalError;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (output.length > 0) {
|
|
442
|
+
return { output: output.join('\n'), exitCode: 0 };
|
|
443
|
+
}
|
|
444
|
+
return { output: 'No logs found.', exitCode: 0 };
|
|
445
|
+
} catch (error) {
|
|
446
|
+
// Fallback to local log
|
|
447
|
+
try {
|
|
448
|
+
const remotePath = resolveRemoteLogPath(logFile);
|
|
449
|
+
if (hasDangerousShellChars(remotePath)) {
|
|
450
|
+
return { output: 'Invalid log file path.', exitCode: 1 };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let command = `tail -n ${lines} -- ${shellEscapePosix(remotePath)} 2>/dev/null`;
|
|
454
|
+
if (keyword) {
|
|
455
|
+
command = `${command} | grep -i -- ${shellEscapePosix(keyword)}`;
|
|
456
|
+
}
|
|
457
|
+
command = `${command} || echo ${shellEscapePosix(keyword ? 'No matching logs' : 'No logs available')}`;
|
|
458
|
+
|
|
459
|
+
const result = await ctx.sessionManager.execute(command);
|
|
460
|
+
return { output: result.output || 'No logs available', exitCode: 0 };
|
|
461
|
+
} catch (e) {
|
|
462
|
+
return { output: `Failed to get logs: ${e.message}`, exitCode: 1 };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* /upload - Upload file or directory to sandbox
|
|
469
|
+
* Supports @ prefix for paths (e.g., /upload @local.txt @/remote.txt)
|
|
470
|
+
*/
|
|
471
|
+
upload: async (ctx, args) => {
|
|
472
|
+
if (args.length < 2) {
|
|
473
|
+
return {
|
|
474
|
+
output: 'Usage: /upload @<local-path> @<remote-path>\nExample: /upload @./script.py @/app/script.py\n /upload @./src/ @/app/src/',
|
|
475
|
+
exitCode: 1,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const fs = require('fs');
|
|
480
|
+
const path = require('path');
|
|
481
|
+
|
|
482
|
+
// Strip @ prefix from paths
|
|
483
|
+
const localPath = args[0].startsWith('@') ? args[0].slice(1) : args[0];
|
|
484
|
+
let remotePath = args[1].startsWith('@') ? args[1].slice(1) : args[1];
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
const resolvedPath = path.resolve(localPath);
|
|
488
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
489
|
+
return { output: `File not found: ${localPath}`, exitCode: 1 };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const stats = fs.statSync(resolvedPath);
|
|
493
|
+
|
|
494
|
+
if (stats.isDirectory()) {
|
|
495
|
+
// Upload directory recursively
|
|
496
|
+
const result = await uploadDirectory(ctx, resolvedPath, remotePath, localPath);
|
|
497
|
+
return result;
|
|
498
|
+
} else {
|
|
499
|
+
// If remote path ends with /, append the local filename
|
|
500
|
+
if (remotePath.endsWith('/')) {
|
|
501
|
+
remotePath = remotePath + path.basename(resolvedPath);
|
|
502
|
+
}
|
|
503
|
+
// Upload single file
|
|
504
|
+
const result = await ctx.client.uploadFile(resolvedPath, remotePath);
|
|
505
|
+
return formatUploadResult(result, { localPath, remotePath });
|
|
506
|
+
}
|
|
507
|
+
} catch (error) {
|
|
508
|
+
const errorMessage = error?.message || (typeof error === 'string' ? error : 'Unknown error');
|
|
509
|
+
return { output: `Upload failed: ${errorMessage}`, exitCode: 1 };
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* /download - Download file from sandbox
|
|
515
|
+
*/
|
|
516
|
+
download: async (ctx, args) => {
|
|
517
|
+
if (args.length < 2) {
|
|
518
|
+
return {
|
|
519
|
+
output: 'Usage: /download @<remote-path> @<local-path>\nExample: /download @/app/output.txt @./output.txt',
|
|
520
|
+
exitCode: 1,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Remove @ prefix from paths if present
|
|
525
|
+
let remotePath = args[0];
|
|
526
|
+
let localPath = args[1];
|
|
527
|
+
|
|
528
|
+
if (remotePath.startsWith('@')) {
|
|
529
|
+
remotePath = remotePath.substring(1);
|
|
530
|
+
}
|
|
531
|
+
if (localPath.startsWith('@')) {
|
|
532
|
+
localPath = localPath.substring(1);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Check for dangerous characters in remote path
|
|
536
|
+
if (hasDangerousShellChars(remotePath)) {
|
|
537
|
+
return { output: '✗ Download failed: Invalid characters in remote path', exitCode: 1 };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const fs = require('fs');
|
|
541
|
+
const path = require('path');
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
// If localPath is a directory, append the remote filename
|
|
545
|
+
if (localPath.endsWith('/') || localPath === '.') {
|
|
546
|
+
const remoteFileName = path.basename(remotePath);
|
|
547
|
+
localPath = path.join(localPath, remoteFileName);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Use cat command to read the entire file content (with escaped path)
|
|
551
|
+
const result = await ctx.sessionManager.execute(`cat -- ${shellEscapePosix(remotePath)}`);
|
|
552
|
+
|
|
553
|
+
// Check exit_code (note: underscore format)
|
|
554
|
+
if (result.exit_code !== 0) {
|
|
555
|
+
const errorMsg = result.stderr || result.output || 'Failed to read file';
|
|
556
|
+
return { output: `✗ Download failed\n\nerror: ${errorMsg}`, exitCode: 1 };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const resolvedPath = path.resolve(localPath);
|
|
560
|
+
fs.writeFileSync(resolvedPath, result.output);
|
|
561
|
+
|
|
562
|
+
return { output: `✓ ${remotePath} → ${resolvedPath}`, exitCode: 0 };
|
|
563
|
+
} catch (error) {
|
|
564
|
+
return { output: `✗ Download failed\n\nerror: ${error.message}`, exitCode: 1 };
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* /stop - Stop the sandbox
|
|
570
|
+
*/
|
|
571
|
+
stop: async (ctx, args) => {
|
|
572
|
+
try {
|
|
573
|
+
await ctx.client.stop();
|
|
574
|
+
return { output: 'Sandbox stopped.', exitCode: 0, action: 'exit' };
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return { output: `Failed to stop sandbox: ${error.message}`, exitCode: 1 };
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* /sessions - List attach sessions
|
|
582
|
+
*/
|
|
583
|
+
sessions: async (ctx, args) => {
|
|
584
|
+
if (!ctx.historyManager || typeof ctx.historyManager.listSessions !== 'function') {
|
|
585
|
+
return { output: 'Session history not available.', exitCode: 0 };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
const sessions = await ctx.historyManager.listSessions();
|
|
590
|
+
|
|
591
|
+
if (!sessions || !Array.isArray(sessions) || sessions.length === 0) {
|
|
592
|
+
return { output: 'No previous sessions found.', exitCode: 0 };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Get terminal width from context (REPL environment) or fallback to process.stdout
|
|
596
|
+
const terminalWidth = ctx.terminalWidth || process.stdout.columns || 120;
|
|
597
|
+
|
|
598
|
+
// opentui OutputBlock: marginLeft/Right=2 each (4 chars) + border=2 + paddingLeft/Right=1 each (2 chars) = 8 chars total
|
|
599
|
+
// ink OutputItem: border=2 + paddingLeft/Right=1 each (2 chars) = 4 chars total
|
|
600
|
+
// Use 8 chars to be safe for both modes (extra padding won't hurt)
|
|
601
|
+
const outputBoxPadding = 8;
|
|
602
|
+
const availableWidth = terminalWidth - outputBoxPadding;
|
|
603
|
+
|
|
604
|
+
// Separator and content use the same available width
|
|
605
|
+
// All lines have ' ' prefix (2 chars)
|
|
606
|
+
const separatorWidth = availableWidth - 2;
|
|
607
|
+
|
|
608
|
+
// Data rows format: ' ' + mark(' ★' or ' ') + ' ' + columns
|
|
609
|
+
// Total prefix width: 2 + 2 + 1 = 5 chars
|
|
610
|
+
const dataRowPrefixWidth = 5;
|
|
611
|
+
|
|
612
|
+
// Available width for content columns
|
|
613
|
+
const contentWidth = availableWidth - dataRowPrefixWidth;
|
|
614
|
+
|
|
615
|
+
// Format date/time
|
|
616
|
+
const formatTime = (ts) => {
|
|
617
|
+
if (!ts) return ' -- ';
|
|
618
|
+
const d = new Date(ts);
|
|
619
|
+
return d.toLocaleString('zh-CN', {
|
|
620
|
+
month: '2-digit',
|
|
621
|
+
day: '2-digit',
|
|
622
|
+
hour: '2-digit',
|
|
623
|
+
minute: '2-digit',
|
|
624
|
+
});
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// Calculate dynamic column widths
|
|
628
|
+
const timeWidth = 13; // "02/06 15:37" format
|
|
629
|
+
const spacing = 4; // 4 spaces between each column
|
|
630
|
+
|
|
631
|
+
// Minimum widths for column headers
|
|
632
|
+
const minSessionIdWidth = 'SESSION'.length; // 7
|
|
633
|
+
const minPathWidth = 'PATH'.length; // 4
|
|
634
|
+
|
|
635
|
+
// Fixed width: time*2 + spacing*3 (between sessionId, created, updated, path)
|
|
636
|
+
const fixedWidth = timeWidth + timeWidth + spacing * 3;
|
|
637
|
+
const flexibleWidth = contentWidth - fixedWidth;
|
|
638
|
+
|
|
639
|
+
// Split flexible width between session ID and path (70% session, 30% path)
|
|
640
|
+
// But ensure minimum widths for headers
|
|
641
|
+
const sessionIdWidth = Math.max(minSessionIdWidth, Math.floor(flexibleWidth * 0.7));
|
|
642
|
+
const pathWidth = Math.max(minPathWidth, flexibleWidth - sessionIdWidth);
|
|
643
|
+
|
|
644
|
+
const formatCwd = (cwd) => {
|
|
645
|
+
const safe = String(cwd || '/');
|
|
646
|
+
if (safe.length <= pathWidth) return safe.padEnd(pathWidth);
|
|
647
|
+
return `…${safe.slice(-(pathWidth - 1))}`;
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// Get current session ID
|
|
651
|
+
const currentHistorySessionId = ctx.historyManager.sessionId || '';
|
|
652
|
+
const currentShellSessionName = ctx.sessionManager?.sessionId || '';
|
|
653
|
+
|
|
654
|
+
const separator = '─'.repeat(separatorWidth);
|
|
655
|
+
const lines = [
|
|
656
|
+
'',
|
|
657
|
+
' Recent Sessions',
|
|
658
|
+
' ' + separator,
|
|
659
|
+
// Header: 5 chars prefix + columns (PATH is padded to pathWidth)
|
|
660
|
+
' ' + `${'SESSION'.padEnd(sessionIdWidth)} ${'CREATED'.padEnd(timeWidth)} ${'UPDATED'.padEnd(timeWidth)} ${'PATH'.padEnd(pathWidth)}`,
|
|
661
|
+
' ' + separator,
|
|
662
|
+
];
|
|
663
|
+
|
|
664
|
+
for (const session of sessions.slice(0, 10)) {
|
|
665
|
+
const historySessionId = session.session_id || session.id || '-';
|
|
666
|
+
const shellSessionName = session.shell_session_name || '-';
|
|
667
|
+
const isCurrent = historySessionId === currentHistorySessionId ||
|
|
668
|
+
(currentShellSessionName && shellSessionName === currentShellSessionName);
|
|
669
|
+
const created = formatTime(session.created_at || session.start_time);
|
|
670
|
+
const updated = formatTime(session.updated_at || session.ended_at || session.created_at || session.start_time);
|
|
671
|
+
const cwd = formatCwd(session.work_dir || session.cwd || '/');
|
|
672
|
+
|
|
673
|
+
const sessionIdForAttach = shellSessionName && shellSessionName !== '-' ? shellSessionName : '-';
|
|
674
|
+
const currentMark = isCurrent ? ' ★' : ' ';
|
|
675
|
+
|
|
676
|
+
// Format: ' ' + mark + ' ' + content
|
|
677
|
+
const sessionIdPadded = sessionIdForAttach.padEnd(sessionIdWidth);
|
|
678
|
+
lines.push(` ${currentMark} ${sessionIdPadded} ${created} ${updated} ${cwd}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
lines.push(' ' + separator);
|
|
682
|
+
lines.push('');
|
|
683
|
+
|
|
684
|
+
// Debug: Add width info (remove this after debugging)
|
|
685
|
+
if (process.env.DEBUG_SESSIONS_WIDTH) {
|
|
686
|
+
lines.push(` [Debug] Terminal width: ${terminalWidth}`);
|
|
687
|
+
lines.push(` [Debug] Separator line length: ${(' ' + separator).length}`);
|
|
688
|
+
lines.push(` [Debug] Expected: ${terminalWidth}, Actual: ${(' ' + separator).length}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return { output: lines.join('\n'), exitCode: 0 };
|
|
692
|
+
} catch (error) {
|
|
693
|
+
return { output: `Failed to list sessions: ${error.message}`, exitCode: 1 };
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* /cleanup-history - Clean up history sessions (keep current)
|
|
699
|
+
* Usage: /cleanup-history [--sandbox <id>] [--all]
|
|
700
|
+
*/
|
|
701
|
+
'cleanup-history': async (ctx, args) => {
|
|
702
|
+
if (!ctx.historyManager) {
|
|
703
|
+
return { output: 'Session history not available.', exitCode: 1 };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const fs = require('fs');
|
|
707
|
+
const path = require('path');
|
|
708
|
+
const os = require('os');
|
|
709
|
+
const readline = require('readline');
|
|
710
|
+
|
|
711
|
+
let targetSandboxId = null;
|
|
712
|
+
let cleanupAll = false;
|
|
713
|
+
let skipConfirm = false;
|
|
714
|
+
|
|
715
|
+
for (let i = 0; i < args.length; i++) {
|
|
716
|
+
if (args[i] === '--sandbox' && args[i + 1]) {
|
|
717
|
+
targetSandboxId = args[++i];
|
|
718
|
+
} else if (args[i] === '--all') {
|
|
719
|
+
cleanupAll = true;
|
|
720
|
+
} else if (args[i] === '--confirm') {
|
|
721
|
+
skipConfirm = true;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
try {
|
|
726
|
+
// Get sessions list
|
|
727
|
+
if (typeof ctx.historyManager.listSessions !== 'function') {
|
|
728
|
+
return { output: 'Session listing not available.', exitCode: 1 };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const allSessions = await ctx.historyManager.listSessions();
|
|
732
|
+
if (!allSessions || !Array.isArray(allSessions)) {
|
|
733
|
+
return { output: 'No sessions found.', exitCode: 0 };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Get current session IDs
|
|
737
|
+
const currentHistorySessionId = ctx.historyManager.sessionId || '';
|
|
738
|
+
const currentShellSessionName = ctx.sessionManager?.sessionId || '';
|
|
739
|
+
|
|
740
|
+
// Filter sessions to clean (exclude current unless --all)
|
|
741
|
+
let sessionsToClean;
|
|
742
|
+
if (cleanupAll) {
|
|
743
|
+
sessionsToClean = allSessions;
|
|
744
|
+
} else {
|
|
745
|
+
sessionsToClean = allSessions.filter(session => {
|
|
746
|
+
const historyId = session.session_id || session.id || '';
|
|
747
|
+
const shellName = session.shell_session_name || '';
|
|
748
|
+
return historyId !== currentHistorySessionId && shellName !== currentShellSessionName;
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (sessionsToClean.length === 0) {
|
|
753
|
+
return { output: 'No sessions to clean up.', exitCode: 0 };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Format session items for display (max 10)
|
|
757
|
+
const maxDisplay = 10;
|
|
758
|
+
const displaySessions = sessionsToClean.slice(0, maxDisplay);
|
|
759
|
+
const moreCount = sessionsToClean.length > maxDisplay ? sessionsToClean.length - maxDisplay : 0;
|
|
760
|
+
|
|
761
|
+
const formatTime = (ts) => {
|
|
762
|
+
if (!ts) return ' -- ';
|
|
763
|
+
const d = new Date(ts);
|
|
764
|
+
return d.toLocaleString('zh-CN', {
|
|
765
|
+
month: '2-digit',
|
|
766
|
+
day: '2-digit',
|
|
767
|
+
hour: '2-digit',
|
|
768
|
+
minute: '2-digit',
|
|
769
|
+
});
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
// Build output with session list
|
|
773
|
+
const lines = [
|
|
774
|
+
'',
|
|
775
|
+
`Found ${sessionsToClean.length} session${sessionsToClean.length !== 1 ? 's' : ''} to clean up:`,
|
|
776
|
+
'',
|
|
777
|
+
' SESSION CREATED UPDATED PATH',
|
|
778
|
+
' ' + '─'.repeat(50),
|
|
779
|
+
];
|
|
780
|
+
|
|
781
|
+
displaySessions.forEach(session => {
|
|
782
|
+
const sessionId = (session.session_id || session.id || '').slice(0, 8);
|
|
783
|
+
const created = formatTime(session.created_at || session.start_time);
|
|
784
|
+
const updated = formatTime(session.updated_at || session.ended_at);
|
|
785
|
+
const cwd = session.work_dir || session.cwd || '/';
|
|
786
|
+
lines.push(` ${sessionId.padEnd(10)} ${created} ${updated} ${cwd}`);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
if (moreCount > 0) {
|
|
790
|
+
lines.push(` ... and ${moreCount} more session${moreCount !== 1 ? 's' : ''}`);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
lines.push('');
|
|
794
|
+
|
|
795
|
+
// Get user confirmation if not skipped
|
|
796
|
+
if (!skipConfirm) {
|
|
797
|
+
const rl = readline.createInterface({
|
|
798
|
+
input: process.stdin,
|
|
799
|
+
output: process.stdout
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const answer = await new Promise((resolve) => {
|
|
803
|
+
rl.question(`Clean up ${sessionsToClean.length} session${sessionsToClean.length !== 1 ? 's' : ''}? (y/N): `, (input) => {
|
|
804
|
+
rl.close();
|
|
805
|
+
resolve(input.trim().toLowerCase());
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
810
|
+
return { output: 'Cleanup cancelled.', exitCode: 0 };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Get sandbox directory
|
|
815
|
+
const sandboxId = targetSandboxId || ctx.historyManager.sandboxId;
|
|
816
|
+
const sandboxDir = targetSandboxId
|
|
817
|
+
? path.join(os.homedir(), '.rock', 'history', targetSandboxId)
|
|
818
|
+
: ctx.historyManager._getSandboxDir();
|
|
819
|
+
const indexFile = path.join(sandboxDir, 'index.json');
|
|
820
|
+
|
|
821
|
+
// Read index
|
|
822
|
+
let index = { current_session: null, sessions: [] };
|
|
823
|
+
if (fs.existsSync(indexFile)) {
|
|
824
|
+
try {
|
|
825
|
+
index = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
826
|
+
} catch (e) {
|
|
827
|
+
// Ignore parse errors
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const currentSessionId = index.current_session || ctx.historyManager.sessionId;
|
|
832
|
+
const sessions = index.sessions || [];
|
|
833
|
+
|
|
834
|
+
// Determine which sessions to clean
|
|
835
|
+
let sessionsToCleanIds;
|
|
836
|
+
if (cleanupAll) {
|
|
837
|
+
sessionsToCleanIds = sessions.map(s => s.id);
|
|
838
|
+
} else {
|
|
839
|
+
sessionsToCleanIds = sessions.filter(s => s.id !== currentSessionId).map(s => s.id);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Delete sessions
|
|
843
|
+
let success = 0;
|
|
844
|
+
let failed = 0;
|
|
845
|
+
for (const sessionId of sessionsToCleanIds) {
|
|
846
|
+
const sessionDir = path.join(sandboxDir, sessionId);
|
|
847
|
+
try {
|
|
848
|
+
if (fs.existsSync(sessionDir)) {
|
|
849
|
+
fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
850
|
+
success++;
|
|
851
|
+
}
|
|
852
|
+
} catch (e) {
|
|
853
|
+
failed++;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Update index
|
|
858
|
+
const newSessions = cleanupAll
|
|
859
|
+
? []
|
|
860
|
+
: sessions.filter(s => s.id === currentSessionId);
|
|
861
|
+
fs.writeFileSync(indexFile, JSON.stringify({
|
|
862
|
+
sandbox_id: sandboxId,
|
|
863
|
+
current_session: cleanupAll ? null : currentSessionId,
|
|
864
|
+
sessions: newSessions
|
|
865
|
+
}, null, 2));
|
|
866
|
+
|
|
867
|
+
// Build output message
|
|
868
|
+
lines.push(`Cleaned up ${success} session${success !== 1 ? 's' : ''}${failed > 0 ? ` (${failed} failed)` : ''}`);
|
|
869
|
+
lines.push('');
|
|
870
|
+
|
|
871
|
+
return { output: lines.join('\n'), exitCode: 0 };
|
|
872
|
+
} catch (error) {
|
|
873
|
+
return { output: `Failed to cleanup history: ${error.message}`, exitCode: 1 };
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* /resume - Resume a previous session
|
|
879
|
+
* DISABLED: Use /sessions to view session history
|
|
880
|
+
*/
|
|
881
|
+
/*
|
|
882
|
+
resume: async (ctx, args) => {
|
|
883
|
+
if (!ctx.historyManager) {
|
|
884
|
+
return { output: 'Session history not available.', exitCode: 0 };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
let sessionId = args[0];
|
|
889
|
+
|
|
890
|
+
if (!sessionId) {
|
|
891
|
+
// Get last session
|
|
892
|
+
if (typeof ctx.historyManager.listSessions !== 'function') {
|
|
893
|
+
return { output: 'Session listing not available.', exitCode: 0 };
|
|
894
|
+
}
|
|
895
|
+
const sessions = await ctx.historyManager.listSessions();
|
|
896
|
+
if (!sessions || !Array.isArray(sessions) || sessions.length < 2) {
|
|
897
|
+
return { output: 'No previous session to resume.', exitCode: 0 };
|
|
898
|
+
}
|
|
899
|
+
sessionId = sessions[1].session_id || sessions[1].id;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (typeof ctx.historyManager.getSessionHistory !== 'function') {
|
|
903
|
+
return { output: 'Session history retrieval not available.', exitCode: 0 };
|
|
904
|
+
}
|
|
905
|
+
const history = await ctx.historyManager.getSessionHistory(sessionId);
|
|
906
|
+
if (!history || history.length === 0) {
|
|
907
|
+
return { output: `No commands found in session ${sessionId}`, exitCode: 0 };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const lines = [`Session ${sessionId.slice(0, 8)} history:`, ''];
|
|
911
|
+
for (const entry of history) {
|
|
912
|
+
lines.push(` ${entry.command}`);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return { output: lines.join('\n'), exitCode: 0 };
|
|
916
|
+
} catch (error) {
|
|
917
|
+
return { output: `Failed to resume session: ${error.message}`, exitCode: 1 };
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
*/
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* /stats - Show session statistics
|
|
924
|
+
*/
|
|
925
|
+
stats: async (ctx, args) => {
|
|
926
|
+
const stats = ctx.stats || {};
|
|
927
|
+
const uptime = stats.startTime
|
|
928
|
+
? Math.round((Date.now() - stats.startTime) / 1000)
|
|
929
|
+
: 0;
|
|
930
|
+
|
|
931
|
+
const lines = [
|
|
932
|
+
'Session Statistics:',
|
|
933
|
+
` Uptime: ${uptime}s`,
|
|
934
|
+
` Shell commands: ${stats.shellCommands || 0}`,
|
|
935
|
+
` Builtin commands: ${stats.builtinCommands || 0}`,
|
|
936
|
+
];
|
|
937
|
+
|
|
938
|
+
return { output: lines.join('\n'), exitCode: 0 };
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
theme: async (ctx, args) => {
|
|
942
|
+
const themeManager = ctx.themeManager;
|
|
943
|
+
const setTheme = ctx.setTheme;
|
|
944
|
+
if (!themeManager || !setTheme) {
|
|
945
|
+
return { output: 'Theme switching not available in this mode.', exitCode: 1 };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (!args || args.length === 0 || args[0] === 'list') {
|
|
949
|
+
const current = ctx.themeName;
|
|
950
|
+
const available = themeManager.listThemes().map(t => ` - ${t.name}${t.name === current ? ' (current)' : ''}`);
|
|
951
|
+
return {
|
|
952
|
+
output: ['Available themes:', ...available, '', 'Usage: /theme <name>'].join('\n'),
|
|
953
|
+
exitCode: 0,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const target = args[0];
|
|
958
|
+
if (setTheme(target)) {
|
|
959
|
+
if (ctx.setUIConfig) {
|
|
960
|
+
await ctx.setUIConfig('theme', target);
|
|
961
|
+
}
|
|
962
|
+
return { output: `Theme switched to ${target}`, exitCode: 0 };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return { output: `Unknown theme: ${target}`, exitCode: 1 };
|
|
966
|
+
},
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* /copy - Copy last output to clipboard
|
|
970
|
+
*/
|
|
971
|
+
copy: async (ctx, args) => {
|
|
972
|
+
if (!ctx.lastOutput) {
|
|
973
|
+
return { output: 'No output to copy.', exitCode: 0 };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
try {
|
|
977
|
+
const { exec } = require('child_process');
|
|
978
|
+
const { promisify } = require('util');
|
|
979
|
+
const execAsync = promisify(exec);
|
|
980
|
+
|
|
981
|
+
// Detect OS and use appropriate clipboard command
|
|
982
|
+
const platform = process.platform;
|
|
983
|
+
let cmd;
|
|
984
|
+
|
|
985
|
+
if (platform === 'darwin') {
|
|
986
|
+
cmd = 'pbcopy';
|
|
987
|
+
} else if (platform === 'linux') {
|
|
988
|
+
cmd = 'xclip -selection clipboard';
|
|
989
|
+
} else {
|
|
990
|
+
return { output: 'Clipboard not supported on this platform.', exitCode: 1 };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
await execAsync(cmd, { input: ctx.lastOutput });
|
|
994
|
+
return { output: 'Output copied to clipboard.', exitCode: 0 };
|
|
995
|
+
} catch (error) {
|
|
996
|
+
return { output: `Failed to copy: ${error.message}`, exitCode: 1 };
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* /about - Show about information
|
|
1002
|
+
*/
|
|
1003
|
+
about: async (ctx, args) => {
|
|
1004
|
+
const version = ctx.version || 'unknown';
|
|
1005
|
+
|
|
1006
|
+
// OSC 8 终端超链接格式: \e]8;;URL\a文本\e]8;;\a
|
|
1007
|
+
// \e = ESC (\u001B), \a = BEL (\u0007)
|
|
1008
|
+
const createHyperlink = (text, url) => {
|
|
1009
|
+
return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`;
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
const docLink = createHyperlink('查看文档', 'https://rock-cli.io.example.com/docs/rock_cli_introduction');
|
|
1013
|
+
|
|
1014
|
+
// 格式化信息
|
|
1015
|
+
const formatRow = (label, value) => {
|
|
1016
|
+
return ` ${label.padEnd(6)} ${value}`;
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
const lines = [
|
|
1020
|
+
'',
|
|
1021
|
+
' 关于 ROCK CLI',
|
|
1022
|
+
'',
|
|
1023
|
+
formatRow('版本', version === 'unknown' ? '未知' : version),
|
|
1024
|
+
formatRow('沙箱', ctx.sandboxId || '未知'),
|
|
1025
|
+
formatRow('目录', process.cwd()),
|
|
1026
|
+
'',
|
|
1027
|
+
` ${docLink}`,
|
|
1028
|
+
'',
|
|
1029
|
+
];
|
|
1030
|
+
|
|
1031
|
+
return { output: lines.join('\n'), exitCode: 0 };
|
|
1032
|
+
},
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* /docs - Open documentation
|
|
1036
|
+
*/
|
|
1037
|
+
docs: async (ctx, args) => {
|
|
1038
|
+
const url = 'https://rock-cli.io.example.com/docs/rock_cli_introduction';
|
|
1039
|
+
const { exec } = require('child_process');
|
|
1040
|
+
|
|
1041
|
+
try {
|
|
1042
|
+
const platform = process.platform;
|
|
1043
|
+
if (platform === 'darwin') {
|
|
1044
|
+
exec(`open ${url}`);
|
|
1045
|
+
} else if (platform === 'linux') {
|
|
1046
|
+
exec(`xdg-open ${url}`);
|
|
1047
|
+
}
|
|
1048
|
+
return { output: `Opening ${url}...`, exitCode: 0 };
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
return { output: `Visit: ${url}`, exitCode: 0 };
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* /bug - Report a bug
|
|
1056
|
+
*/
|
|
1057
|
+
bug: async (ctx, args) => {
|
|
1058
|
+
const url = 'https://project.aone.example.com/v2/project/2125004/req#viewIdentifier=d7f112f9d023e2108fa1b0d8';
|
|
1059
|
+
const { exec } = require('child_process');
|
|
1060
|
+
|
|
1061
|
+
try {
|
|
1062
|
+
const platform = process.platform;
|
|
1063
|
+
if (platform === 'darwin') {
|
|
1064
|
+
exec(`open ${url}`);
|
|
1065
|
+
} else if (platform === 'linux') {
|
|
1066
|
+
exec(`xdg-open ${url}`);
|
|
1067
|
+
}
|
|
1068
|
+
return { output: `Opening ${url}...`, exitCode: 0 };
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
return { output: `Visit: ${url}`, exitCode: 0 };
|
|
1071
|
+
}
|
|
1072
|
+
},
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* /retry - Retry last failed command
|
|
1076
|
+
*/
|
|
1077
|
+
retry: async (ctx, args) => {
|
|
1078
|
+
if (!ctx.lastCommand) {
|
|
1079
|
+
return { output: 'No command to retry.', exitCode: 0 };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return { action: 'retry', command: ctx.lastCommand };
|
|
1083
|
+
},
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* /close - Close session permanently and exit
|
|
1087
|
+
*/
|
|
1088
|
+
close: async (ctx, args) => {
|
|
1089
|
+
try {
|
|
1090
|
+
const sessionId = ctx.sessionManager?.sessionId;
|
|
1091
|
+
if (sessionId && ctx.client?.closeSession) {
|
|
1092
|
+
await ctx.client.closeSession(sessionId);
|
|
1093
|
+
return { output: 'Session closed.', exitCode: 0, action: 'exit' };
|
|
1094
|
+
}
|
|
1095
|
+
return { output: 'No active session to close.', exitCode: 0, action: 'exit' };
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
logger.debug(`Failed to close session: ${error.message}`);
|
|
1098
|
+
return { output: `Warning: ${error.message}`, exitCode: 0, action: 'exit' };
|
|
1099
|
+
}
|
|
1100
|
+
},
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Shell-like commands that work without / prefix
|
|
1105
|
+
*/
|
|
1106
|
+
const SHELL_BUILTINS = ['quit', 'exit', 'clear'];
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Check if input is a valid builtin command
|
|
1110
|
+
* @param {string} input - User input
|
|
1111
|
+
* @returns {boolean}
|
|
1112
|
+
*/
|
|
1113
|
+
export function isBuiltinCommand(input) {
|
|
1114
|
+
if (!input) return false;
|
|
1115
|
+
|
|
1116
|
+
// Check /command format
|
|
1117
|
+
if (input.startsWith('/')) {
|
|
1118
|
+
const cmd = input.slice(1).split(/\s+/)[0].toLowerCase();
|
|
1119
|
+
return cmd in commandHandlers;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Check shell-like builtins (quit, exit, clear without /)
|
|
1123
|
+
const cmd = input.trim().split(/\s+/)[0].toLowerCase();
|
|
1124
|
+
return SHELL_BUILTINS.includes(cmd) && cmd in commandHandlers;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Parse builtin command
|
|
1129
|
+
* @param {string} input - User input like "/log -n 50" or "quit"
|
|
1130
|
+
* @returns {{ command: string, args: string[] }}
|
|
1131
|
+
*/
|
|
1132
|
+
export function parseBuiltinCommand(input) {
|
|
1133
|
+
// Handle /command format
|
|
1134
|
+
if (input.startsWith('/')) {
|
|
1135
|
+
const parts = input.slice(1).split(/\s+/);
|
|
1136
|
+
const command = parts[0].toLowerCase();
|
|
1137
|
+
const args = parts.slice(1);
|
|
1138
|
+
return { command, args };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Handle shell-like builtins (quit, exit, clear)
|
|
1142
|
+
const parts = input.trim().split(/\s+/);
|
|
1143
|
+
const command = parts[0].toLowerCase();
|
|
1144
|
+
const args = parts.slice(1);
|
|
1145
|
+
return { command, args };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Execute a builtin command
|
|
1150
|
+
* @param {string} input - User input like "/log -n 50"
|
|
1151
|
+
* @param {Object} context - Execution context { client, sessionManager, historyManager, sandboxId, stats, lastCommand, lastOutput }
|
|
1152
|
+
* @returns {Promise<{ output: string, exitCode: number, action?: string }>}
|
|
1153
|
+
*/
|
|
1154
|
+
export async function executeBuiltinCommand(input, context) {
|
|
1155
|
+
const { command, args } = parseBuiltinCommand(input);
|
|
1156
|
+
|
|
1157
|
+
const handler = commandHandlers[command];
|
|
1158
|
+
if (!handler) {
|
|
1159
|
+
return {
|
|
1160
|
+
output: `Unknown command: /${command}. Type / for available commands.`,
|
|
1161
|
+
exitCode: 1,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
try {
|
|
1166
|
+
return await handler(context, args);
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
logger.debug(`Builtin command error: ${error.stack}`);
|
|
1169
|
+
return {
|
|
1170
|
+
output: `Error executing /${command}: ${friendlyErrorMessage(error.message)}`,
|
|
1171
|
+
exitCode: 1,
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Check if command is interactive (requires PTY)
|
|
1178
|
+
* @param {string} command - Shell command
|
|
1179
|
+
* @returns {{ isInteractive: boolean, cmdName: string, alternative: string|null }}
|
|
1180
|
+
*/
|
|
1181
|
+
export function checkInteractiveCommand(command) {
|
|
1182
|
+
const parts = command.trim().split(/\s+/);
|
|
1183
|
+
const fullCmd = parts[0] || '';
|
|
1184
|
+
const cmdName = fullCmd.split('/').pop();
|
|
1185
|
+
|
|
1186
|
+
if (!(cmdName in INTERACTIVE_COMMANDS)) {
|
|
1187
|
+
return { isInteractive: false, cmdName, alternative: null };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Check for non-interactive flags
|
|
1191
|
+
const nonInteractiveFlags = {
|
|
1192
|
+
python: ['-c', '-m', '-V', '--version', '-h', '--help'],
|
|
1193
|
+
python3: ['-c', '-m', '-V', '--version', '-h', '--help'],
|
|
1194
|
+
node: ['-e', '-p', '-c', '-v', '--version', '-h', '--help', '--check'],
|
|
1195
|
+
top: ['-b'],
|
|
1196
|
+
mysql: ['-e', '--execute', '-V', '--version', '-h', '--help'],
|
|
1197
|
+
psql: ['-c', '--command', '-V', '--version', '-h', '--help'],
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
if (nonInteractiveFlags[cmdName]) {
|
|
1201
|
+
for (const flag of nonInteractiveFlags[cmdName]) {
|
|
1202
|
+
if (parts.includes(flag)) {
|
|
1203
|
+
return { isInteractive: false, cmdName, alternative: null };
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
// Check for --execute= or --command= format
|
|
1207
|
+
if (cmdName === 'mysql' || cmdName === 'psql') {
|
|
1208
|
+
for (const part of parts) {
|
|
1209
|
+
if (part.startsWith('--execute=') || part.startsWith('--command=')) {
|
|
1210
|
+
return { isInteractive: false, cmdName, alternative: null };
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// tail -f is interactive
|
|
1217
|
+
if (cmdName === 'tail') {
|
|
1218
|
+
if (command.includes('-f') || command.includes('--follow')) {
|
|
1219
|
+
return { isInteractive: true, cmdName, alternative: 'tail -n 50 <file>' };
|
|
1220
|
+
}
|
|
1221
|
+
return { isInteractive: false, cmdName, alternative: null };
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// python/node/irb: only interactive when called with no arguments
|
|
1225
|
+
if (['python', 'python3', 'node', 'irb'].includes(cmdName)) {
|
|
1226
|
+
if (parts.length === 1) {
|
|
1227
|
+
return { isInteractive: true, cmdName, alternative: INTERACTIVE_COMMANDS[cmdName] };
|
|
1228
|
+
}
|
|
1229
|
+
// Has arguments (script file or -c/-e), not interactive
|
|
1230
|
+
return { isInteractive: false, cmdName, alternative: null };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// mysql/psql: interactive when called with no arguments or only database name
|
|
1234
|
+
// Non-interactive when using -e/--execute (mysql) or -c/--command (psql)
|
|
1235
|
+
if (['mysql', 'psql'].includes(cmdName)) {
|
|
1236
|
+
if (parts.length === 1) {
|
|
1237
|
+
return { isInteractive: true, cmdName, alternative: INTERACTIVE_COMMANDS[cmdName] };
|
|
1238
|
+
}
|
|
1239
|
+
// Already checked non-interactive flags above, so if we get here with args, check again
|
|
1240
|
+
// If no non-interactive flag found, it's still interactive (e.g., mysql dbname)
|
|
1241
|
+
return { isInteractive: true, cmdName, alternative: INTERACTIVE_COMMANDS[cmdName] };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Always interactive commands (no non-interactive mode)
|
|
1245
|
+
// Note: top is here because -b check happens before this
|
|
1246
|
+
// Note: mysql and psql removed - they have -e/--execute and -c/--command non-interactive modes
|
|
1247
|
+
const alwaysInteractive = ['vim', 'vi', 'nano', 'emacs', 'less', 'more', 'top', 'htop', 'ssh', 'man', 'watch', 'bash', 'sh'];
|
|
1248
|
+
if (alwaysInteractive.includes(cmdName)) {
|
|
1249
|
+
return { isInteractive: true, cmdName, alternative: INTERACTIVE_COMMANDS[cmdName] };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
return { isInteractive: false, cmdName, alternative: null };
|
|
1253
|
+
}
|