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,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve an abbreviated sandbox ID to a full ID
|
|
9
|
+
* @param {string} sandboxId - The sandbox ID (can be abbreviated or full)
|
|
10
|
+
* @param {Object} options - Options
|
|
11
|
+
* @param {string} options.baseDir - Base directory for history (default: ~/.rock)
|
|
12
|
+
* @returns {Promise<string>} The resolved full sandbox ID
|
|
13
|
+
*/
|
|
14
|
+
async function resolveSandboxId(sandboxId, options = {}) {
|
|
15
|
+
const baseDir = options.baseDir || path.join(os.homedir(), '.rock');
|
|
16
|
+
const historyDir = path.join(baseDir, 'history');
|
|
17
|
+
|
|
18
|
+
// If the ID looks like a complete ID (length >= 32, typical MD5 hash length),
|
|
19
|
+
// allow it even if not in history (new sandbox case)
|
|
20
|
+
if (sandboxId.length >= 32) {
|
|
21
|
+
return sandboxId;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check if history directory exists
|
|
25
|
+
if (!fs.existsSync(historyDir)) {
|
|
26
|
+
throw new Error(`No sandbox found matching: ${sandboxId}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Get all sandbox directories
|
|
30
|
+
const sandboxDirs = fs.readdirSync(historyDir, { withFileTypes: true })
|
|
31
|
+
.filter(dirent => dirent.isDirectory())
|
|
32
|
+
.map(dirent => dirent.name);
|
|
33
|
+
|
|
34
|
+
// If the provided ID is already a full ID and exists, return it
|
|
35
|
+
if (sandboxDirs.includes(sandboxId)) {
|
|
36
|
+
return sandboxId;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Try to find a match for abbreviated ID
|
|
40
|
+
const matches = sandboxDirs.filter(id => id.startsWith(sandboxId));
|
|
41
|
+
|
|
42
|
+
if (matches.length === 0) {
|
|
43
|
+
throw new Error(`No sandbox found matching: ${sandboxId}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (matches.length > 1) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Ambiguous sandbox ID: ${sandboxId} matches multiple sandboxes:\n` +
|
|
49
|
+
matches.map(m => ` - ${m}`).join('\n')
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return matches[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { resolveSandboxId };
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const logger = require('../../utils/logger');
|
|
5
|
+
|
|
6
|
+
class SessionManager {
|
|
7
|
+
constructor(client, sandboxId) {
|
|
8
|
+
this.client = client;
|
|
9
|
+
this.sandboxId = sandboxId;
|
|
10
|
+
this.sessionId = null;
|
|
11
|
+
this.heartbeatInterval = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wait for sandbox to be ready (alive)
|
|
16
|
+
* @param {Object} options - Wait options
|
|
17
|
+
* @param {number} options.maxAttempts - Maximum number of attempts (default: 30)
|
|
18
|
+
* @param {number} options.intervalMs - Interval between attempts in ms (default: 1000)
|
|
19
|
+
* @param {Function} options.onProgress - Optional callback for progress updates
|
|
20
|
+
* @param {AbortSignal} options.signal - Optional AbortSignal to cancel waiting
|
|
21
|
+
* @returns {Promise<boolean>} True if sandbox is ready, false if timeout or aborted
|
|
22
|
+
*/
|
|
23
|
+
async waitForReady({ maxAttempts = 30, intervalMs = 1000, onProgress = null, signal = null } = {}) {
|
|
24
|
+
// Set sandboxId on client (required by SandboxClient)
|
|
25
|
+
this.client._sandboxId = this.sandboxId;
|
|
26
|
+
let lastError = null;
|
|
27
|
+
|
|
28
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
29
|
+
// Check if aborted
|
|
30
|
+
if (signal?.aborted) {
|
|
31
|
+
logger.debug('Wait aborted by signal');
|
|
32
|
+
return { ready: false, error: lastError };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const status = await this.client.getStatus();
|
|
37
|
+
logger.debug(`[waitForReady] Status response: ${JSON.stringify(status)}`);
|
|
38
|
+
|
|
39
|
+
if (status.isAlive) {
|
|
40
|
+
return { ready: true, error: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check if sandbox is in Failed state (destroyed or not exist)
|
|
44
|
+
// The 'status' field in response is an object with stage details
|
|
45
|
+
// Check for any indication of failure in the status object
|
|
46
|
+
const statusObj = status.status;
|
|
47
|
+
logger.debug(`[waitForReady] Sandbox isAlive: ${status.isAlive}, status: ${JSON.stringify(statusObj)}`);
|
|
48
|
+
|
|
49
|
+
if (statusObj && typeof statusObj === 'object') {
|
|
50
|
+
// Check if any stage indicates failure
|
|
51
|
+
const stages = Object.values(statusObj);
|
|
52
|
+
const hasFailedStage = stages.some(stage =>
|
|
53
|
+
stage && (stage.status === 'failed' || stage.status === 'error')
|
|
54
|
+
);
|
|
55
|
+
if (hasFailedStage) {
|
|
56
|
+
logger.debug(`[waitForReady] Sandbox has failed stage (attempt ${attempt}/${maxAttempts})`);
|
|
57
|
+
return { ready: false, error: new Error('Sandbox has been destroyed or does not exist'), status: 'Failed' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.debug(`[waitForReady] Sandbox not ready (attempt ${attempt}/${maxAttempts})`);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
lastError = error; // Store the last error
|
|
64
|
+
logger.debug(`[waitForReady] Status check failed (attempt ${attempt}/${maxAttempts}): ${error.message}`);
|
|
65
|
+
// Check if error indicates sandbox not found
|
|
66
|
+
const errorMsg = error.message || '';
|
|
67
|
+
if (errorMsg.toLowerCase().includes('not found') ||
|
|
68
|
+
errorMsg.toLowerCase().includes('does not exist') ||
|
|
69
|
+
errorMsg.toLowerCase().includes('sandbox') && errorMsg.toLowerCase().includes('not')) {
|
|
70
|
+
return { ready: false, error: error, status: 'Failed' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (onProgress) {
|
|
75
|
+
onProgress(attempt, maxAttempts);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (attempt < maxAttempts) {
|
|
79
|
+
await this._sleepWithSignal(intervalMs, signal);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { ready: false, error: lastError };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_sleepWithSignal(ms, signal) {
|
|
87
|
+
return new Promise(resolve => {
|
|
88
|
+
const timeout = setTimeout(resolve, ms);
|
|
89
|
+
if (signal) {
|
|
90
|
+
signal.addEventListener('abort', () => {
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
resolve();
|
|
93
|
+
}, { once: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async connect(options = {}) {
|
|
99
|
+
// Set sandboxId on client (required by SandboxClient)
|
|
100
|
+
this.client._sandboxId = this.sandboxId;
|
|
101
|
+
|
|
102
|
+
const { reuseSession } = options;
|
|
103
|
+
|
|
104
|
+
// Try to reuse existing session if specified
|
|
105
|
+
if (reuseSession) {
|
|
106
|
+
logger.debug(`Attempting to reuse session: ${reuseSession}`);
|
|
107
|
+
try {
|
|
108
|
+
// Try running a simple command to verify session is alive
|
|
109
|
+
const testResult = await this.client.runInSession({
|
|
110
|
+
command: 'echo SESSION_ALIVE',
|
|
111
|
+
session: reuseSession,
|
|
112
|
+
check: 'silent',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (testResult.output && testResult.output.includes('SESSION_ALIVE')) {
|
|
116
|
+
this.sessionId = reuseSession;
|
|
117
|
+
// Get current user and pwd
|
|
118
|
+
const [whoamiResult, pwdResult] = await Promise.all([
|
|
119
|
+
this.client.runInSession({
|
|
120
|
+
command: 'whoami',
|
|
121
|
+
session: reuseSession,
|
|
122
|
+
check: 'silent',
|
|
123
|
+
}),
|
|
124
|
+
this.client.runInSession({
|
|
125
|
+
command: 'pwd',
|
|
126
|
+
session: reuseSession,
|
|
127
|
+
check: 'silent',
|
|
128
|
+
}),
|
|
129
|
+
]);
|
|
130
|
+
const user = whoamiResult.output?.trim() || 'root';
|
|
131
|
+
const cwd = pwdResult.output?.trim() || '/';
|
|
132
|
+
const hostname = this.sandboxId;
|
|
133
|
+
this.initialPrompt = `${user}@${hostname}:${cwd}# `;
|
|
134
|
+
logger.debug(`Reused existing session: ${this.sessionId}`);
|
|
135
|
+
return {
|
|
136
|
+
sessionId: this.sessionId,
|
|
137
|
+
initialPrompt: this.initialPrompt,
|
|
138
|
+
user,
|
|
139
|
+
reused: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
logger.debug(`Failed to reuse session ${reuseSession}: ${error.message}`);
|
|
144
|
+
// Fall through to create new session
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Create new session
|
|
149
|
+
logger.debug(`Creating session for sandbox ${this.sandboxId}`);
|
|
150
|
+
|
|
151
|
+
// Use UUID to ensure unique session name and avoid collision
|
|
152
|
+
// If session already exists, API returns empty output
|
|
153
|
+
const sessionName = `repl-${crypto.randomUUID()}`;
|
|
154
|
+
|
|
155
|
+
// Create session - API returns { session_type, output }
|
|
156
|
+
// The output contains the initial shell prompt (e.g., "root@host:/# ")
|
|
157
|
+
const result = await this.client.createSession({
|
|
158
|
+
session: sessionName,
|
|
159
|
+
sessionType: 'bash',
|
|
160
|
+
envEnable: true,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Use the session name we passed as our sessionId
|
|
164
|
+
this.sessionId = sessionName;
|
|
165
|
+
this.initialPrompt = result.output || '';
|
|
166
|
+
logger.debug(`Session created: ${this.sessionId}`);
|
|
167
|
+
logger.debug(`Initial prompt: ${this.initialPrompt}`);
|
|
168
|
+
|
|
169
|
+
// Get current user
|
|
170
|
+
let user = 'root';
|
|
171
|
+
try {
|
|
172
|
+
const whoamiResult = await this.client.runInSession({
|
|
173
|
+
command: 'whoami',
|
|
174
|
+
session: sessionName,
|
|
175
|
+
check: 'silent',
|
|
176
|
+
});
|
|
177
|
+
user = whoamiResult.output?.trim() || 'root';
|
|
178
|
+
} catch (e) {
|
|
179
|
+
logger.debug('Failed to get user:', e.message);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
sessionId: this.sessionId,
|
|
184
|
+
initialPrompt: this.initialPrompt,
|
|
185
|
+
user,
|
|
186
|
+
reused: false,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async execute(command, options = {}) {
|
|
191
|
+
if (!this.sessionId) {
|
|
192
|
+
throw new Error('Not connected. Call connect() first.');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// SandboxClient.runInSession expects { command, session }
|
|
196
|
+
const runOpts = {
|
|
197
|
+
command: command,
|
|
198
|
+
session: this.sessionId,
|
|
199
|
+
check: 'silent',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Forward AbortSignal for cancellation support
|
|
203
|
+
if (options.signal) {
|
|
204
|
+
runOpts.signal = options.signal;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const result = await this.client.runInSession(runOpts);
|
|
208
|
+
|
|
209
|
+
// Mark as background command if specified
|
|
210
|
+
if (options.background) {
|
|
211
|
+
result._background = true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async executeWithRetry(command, maxRetries = 3, delayMs = 2000) {
|
|
218
|
+
let lastError;
|
|
219
|
+
|
|
220
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
221
|
+
try {
|
|
222
|
+
return await this.execute(command);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
lastError = error;
|
|
225
|
+
logger.warn(`Command failed (attempt ${attempt}/${maxRetries}): ${error.message}`);
|
|
226
|
+
|
|
227
|
+
if (attempt < maxRetries) {
|
|
228
|
+
await this._sleep(delayMs);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
throw lastError;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_sleep(ms) {
|
|
237
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async disconnect() {
|
|
241
|
+
this.stopHeartbeat();
|
|
242
|
+
// Don't close session - keep it alive for resume
|
|
243
|
+
// Session will be auto-cleared by sandbox timeout
|
|
244
|
+
logger.debug(`Disconnected from session ${this.sessionId} (session kept alive for resume)`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Interrupt the current session and create a new one.
|
|
249
|
+
* Used when a running command is cancelled (ESC / Ctrl+C) to unblock
|
|
250
|
+
* the session, since the server-side process may still be running.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} cwd - Working directory to restore in the new session
|
|
253
|
+
*/
|
|
254
|
+
async interruptSession(cwd) {
|
|
255
|
+
const oldSessionId = this.sessionId;
|
|
256
|
+
|
|
257
|
+
// Create a new session immediately (don't wait for old one to close)
|
|
258
|
+
const sessionName = `repl-${crypto.randomUUID()}`;
|
|
259
|
+
await this.client.createSession({
|
|
260
|
+
session: sessionName,
|
|
261
|
+
sessionType: 'bash',
|
|
262
|
+
envEnable: true,
|
|
263
|
+
});
|
|
264
|
+
this.sessionId = sessionName;
|
|
265
|
+
logger.debug(`Interrupted session ${oldSessionId}, new session: ${sessionName}`);
|
|
266
|
+
|
|
267
|
+
// Restore working directory (skip for root /)
|
|
268
|
+
if (cwd && cwd !== '/') {
|
|
269
|
+
try {
|
|
270
|
+
await this.client.runInSession({
|
|
271
|
+
command: `cd ${cwd}`,
|
|
272
|
+
session: this.sessionId,
|
|
273
|
+
check: 'silent',
|
|
274
|
+
});
|
|
275
|
+
} catch (e) {
|
|
276
|
+
logger.debug(`Failed to restore cwd after interrupt: ${e.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Best-effort close of old session in background
|
|
281
|
+
if (oldSessionId) {
|
|
282
|
+
this.client.closeSession(oldSessionId).catch((e) => {
|
|
283
|
+
logger.debug(`Failed to close interrupted session ${oldSessionId}: ${e.message}`);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
startHeartbeat(intervalMs = 150000) {
|
|
289
|
+
this.heartbeatInterval = setInterval(async () => {
|
|
290
|
+
try {
|
|
291
|
+
await this.client.getStatus();
|
|
292
|
+
logger.debug('Heartbeat sent');
|
|
293
|
+
} catch (error) {
|
|
294
|
+
logger.warn('Heartbeat failed:', error.message);
|
|
295
|
+
}
|
|
296
|
+
}, intervalMs);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
stopHeartbeat() {
|
|
300
|
+
if (this.heartbeatInterval) {
|
|
301
|
+
clearInterval(this.heartbeatInterval);
|
|
302
|
+
this.heartbeatInterval = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = SessionManager;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const INK_MIN_NODE_MAJOR = 20;
|
|
4
|
+
|
|
5
|
+
function parseNodeMajor(nodeVersion) {
|
|
6
|
+
if (!nodeVersion || typeof nodeVersion !== 'string') return null;
|
|
7
|
+
const majorStr = nodeVersion.split('.')[0];
|
|
8
|
+
const major = Number.parseInt(majorStr, 10);
|
|
9
|
+
return Number.isFinite(major) ? major : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeMode(value) {
|
|
13
|
+
if (!value || typeof value !== 'string') return null;
|
|
14
|
+
const mode = value.trim().toLowerCase();
|
|
15
|
+
if (mode === 'ink' || mode === 'basic' || mode === 'copy' || mode === 'opentui') return mode;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if the current process is running under Bun runtime.
|
|
21
|
+
*/
|
|
22
|
+
function isBunRuntime() {
|
|
23
|
+
return typeof globalThis.Bun !== 'undefined';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find the bun binary path.
|
|
28
|
+
* Checks (in order):
|
|
29
|
+
* 1. node_modules/.bin/bun (installed via npm)
|
|
30
|
+
* 2. ~/.bun/bin/bun (curl installed)
|
|
31
|
+
* 3. System-level bun (via `which bun`)
|
|
32
|
+
* Returns the resolved path, or null if not found.
|
|
33
|
+
*/
|
|
34
|
+
function findBunBinary() {
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const os = require('os');
|
|
38
|
+
const { execSync } = require('child_process');
|
|
39
|
+
|
|
40
|
+
// 1. Check node_modules/.bin/bun relative to this project
|
|
41
|
+
const candidates = [
|
|
42
|
+
// From project root (two levels up from commands/attach/)
|
|
43
|
+
path.resolve(__dirname, '..', '..', 'node_modules', '.bin', 'bun'),
|
|
44
|
+
// From cwd
|
|
45
|
+
path.resolve(process.cwd(), 'node_modules', '.bin', 'bun'),
|
|
46
|
+
// ~/.bun/bin/bun (curl 安装的默认路径)
|
|
47
|
+
path.join(os.homedir(), '.bun', 'bin', 'bun'),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(candidate)) {
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Check system bun via PATH
|
|
61
|
+
try {
|
|
62
|
+
const systemBun = execSync('which bun', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
|
|
63
|
+
if (systemBun && fs.existsSync(systemBun)) {
|
|
64
|
+
return systemBun;
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// not found
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get Bun version from binary path.
|
|
75
|
+
* @param {string} bunPath - Path to bun binary
|
|
76
|
+
* @returns {{major: number, version: string, valid: boolean}|null} Version info or null
|
|
77
|
+
*/
|
|
78
|
+
function getBunVersion(bunPath) {
|
|
79
|
+
try {
|
|
80
|
+
const { execSync } = require('child_process');
|
|
81
|
+
const versionStr = execSync(`"${bunPath}" --version`, {
|
|
82
|
+
encoding: 'utf8',
|
|
83
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
84
|
+
}).trim();
|
|
85
|
+
|
|
86
|
+
const match = versionStr.match(/(\d+\.\d+\.\d+)/);
|
|
87
|
+
if (match) {
|
|
88
|
+
const major = parseInt(match[1].split('.')[0], 10);
|
|
89
|
+
return { major, version: match[1], valid: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { major: 0, version: versionStr, valid: false };
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decide which UI to use for `attach`.
|
|
100
|
+
*
|
|
101
|
+
* Ink UI (React/Ink) requires a modern Node runtime; on older Node versions it can
|
|
102
|
+
* fail with a SyntaxError (e.g. `Unexpected token '.'`) while importing `ink`.
|
|
103
|
+
*/
|
|
104
|
+
function selectAttachUiMode(options) {
|
|
105
|
+
const argv = options && options.argv ? options.argv : {};
|
|
106
|
+
const env = options && options.env ? options.env : {};
|
|
107
|
+
const nodeVersion = options && options.nodeVersion ? options.nodeVersion : process.versions.node;
|
|
108
|
+
|
|
109
|
+
const forcedByArgv = normalizeMode(argv.ui);
|
|
110
|
+
if (forcedByArgv) {
|
|
111
|
+
return { mode: forcedByArgv, reason: 'forced by argv.ui', nodeVersion, nodeMajor: parseNodeMajor(nodeVersion) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const forcedByEnv = normalizeMode(env.ROCK_ATTACH_UI);
|
|
115
|
+
if (forcedByEnv) {
|
|
116
|
+
return { mode: forcedByEnv, reason: 'forced by env ROCK_ATTACH_UI', nodeVersion, nodeMajor: parseNodeMajor(nodeVersion) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const nodeMajor = parseNodeMajor(nodeVersion);
|
|
120
|
+
|
|
121
|
+
// Bun runtime (>= 1.0 guaranteed by all.js bootstrap) reports a low
|
|
122
|
+
// process.versions.node for compat, but fully supports modern JS features.
|
|
123
|
+
if (isBunRuntime()) {
|
|
124
|
+
return { mode: 'opentui', reason: 'default (bun)', nodeVersion, nodeMajor };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (nodeMajor !== null && nodeMajor < INK_MIN_NODE_MAJOR) {
|
|
128
|
+
return {
|
|
129
|
+
mode: 'basic',
|
|
130
|
+
reason: `Ink UI requires Node >= ${INK_MIN_NODE_MAJOR} (current ${nodeVersion})`,
|
|
131
|
+
nodeVersion,
|
|
132
|
+
nodeMajor,
|
|
133
|
+
minNodeMajor: INK_MIN_NODE_MAJOR,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { mode: 'opentui', reason: 'default', nodeVersion, nodeMajor: nodeMajor };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
INK_MIN_NODE_MAJOR,
|
|
142
|
+
selectAttachUiMode,
|
|
143
|
+
isBunRuntime,
|
|
144
|
+
findBunBinary,
|
|
145
|
+
getBunVersion,
|
|
146
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('../utils/logger');
|
|
4
|
+
const { resolveSandboxId } = require('./attach/sandbox-id-resolver');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prompt user for sandbox ID using terminal-kit interactive UI
|
|
8
|
+
* @returns {Promise<string|null>} The sandbox ID entered by user, or null if cancelled
|
|
9
|
+
*/
|
|
10
|
+
async function promptForSandboxId() {
|
|
11
|
+
const term = require('terminal-kit').terminal;
|
|
12
|
+
|
|
13
|
+
// Clear screen and show welcome
|
|
14
|
+
term.clear();
|
|
15
|
+
term.cyan('┌─────────────────────────────────────────────────────────────┐\n');
|
|
16
|
+
term.cyan('│ │\n');
|
|
17
|
+
term.cyan('│ ');
|
|
18
|
+
term.bold.white('🪨 ROCK CLI - Sandbox Attach');
|
|
19
|
+
term.cyan(' │\n');
|
|
20
|
+
term.cyan('│ │\n');
|
|
21
|
+
term.cyan('└─────────────────────────────────────────────────────────────┘\n');
|
|
22
|
+
|
|
23
|
+
// Check for recent sandboxes from history
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const historyDir = path.join(os.homedir(), '.rock', 'history');
|
|
28
|
+
let recentSandboxes = [];
|
|
29
|
+
|
|
30
|
+
if (fs.existsSync(historyDir)) {
|
|
31
|
+
try {
|
|
32
|
+
const dirs = fs.readdirSync(historyDir)
|
|
33
|
+
.filter(d => fs.statSync(path.join(historyDir, d)).isDirectory() && d !== 'current')
|
|
34
|
+
.map(d => ({
|
|
35
|
+
name: d,
|
|
36
|
+
mtime: fs.statSync(path.join(historyDir, d)).mtimeMs,
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => b.mtime - a.mtime) // Sort by most recent first
|
|
39
|
+
.slice(0, 5)
|
|
40
|
+
.map(d => d.name);
|
|
41
|
+
recentSandboxes = dirs;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// Ignore errors reading history
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Show recent sandboxes if any
|
|
48
|
+
if (recentSandboxes.length > 0) {
|
|
49
|
+
term.gray(' 最近使用的 Sandbox:\n');
|
|
50
|
+
recentSandboxes.forEach((id, index) => {
|
|
51
|
+
term.gray(` ${index + 1}. `);
|
|
52
|
+
term.white(id);
|
|
53
|
+
term('\n');
|
|
54
|
+
});
|
|
55
|
+
term.gray(' 输入序号选择,或直接输入新的 Sandbox ID\n\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Prompt for input
|
|
59
|
+
term.bold(' Sandbox ID: ');
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
let resolved = false;
|
|
63
|
+
function finish(val) {
|
|
64
|
+
if (resolved) return;
|
|
65
|
+
resolved = true;
|
|
66
|
+
if (typeof term.removeListener === 'function') {
|
|
67
|
+
term.removeListener('key', onKey);
|
|
68
|
+
} else if (typeof term.off === 'function') {
|
|
69
|
+
term.off('key', onKey);
|
|
70
|
+
}
|
|
71
|
+
resolve(val);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function onKey(name) {
|
|
75
|
+
if (name === 'CTRL_C') {
|
|
76
|
+
term('\n\n');
|
|
77
|
+
term.gray(' 已取消\n');
|
|
78
|
+
finish(null);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
term.on('key', onKey);
|
|
83
|
+
|
|
84
|
+
term.inputField({
|
|
85
|
+
cancelable: true,
|
|
86
|
+
history: recentSandboxes,
|
|
87
|
+
autoComplete: recentSandboxes,
|
|
88
|
+
autoCompleteHint: true,
|
|
89
|
+
autoCompleteMenu: recentSandboxes.length > 0,
|
|
90
|
+
}, (error, input) => {
|
|
91
|
+
term('\n');
|
|
92
|
+
|
|
93
|
+
if (error || input === undefined) {
|
|
94
|
+
term.gray(' 已取消\n');
|
|
95
|
+
finish(null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const trimmed = input.trim();
|
|
100
|
+
|
|
101
|
+
// Check if user entered a number to select from recent
|
|
102
|
+
if (recentSandboxes.length > 0 && /^[1-5]$/.test(trimmed)) {
|
|
103
|
+
const index = parseInt(trimmed) - 1;
|
|
104
|
+
if (index < recentSandboxes.length) {
|
|
105
|
+
finish(recentSandboxes[index]);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
finish(trimmed);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
command: 'attach [sandbox-id]',
|
|
117
|
+
describe: 'Connect to a sandbox in interactive REPL mode',
|
|
118
|
+
builder: (yargs) => {
|
|
119
|
+
return yargs
|
|
120
|
+
.positional('sandbox-id', {
|
|
121
|
+
describe: 'The sandbox ID to connect to (will prompt if not provided)',
|
|
122
|
+
type: 'string',
|
|
123
|
+
})
|
|
124
|
+
.option('verbose', {
|
|
125
|
+
alias: 'v',
|
|
126
|
+
type: 'count',
|
|
127
|
+
description: 'Verbosity level',
|
|
128
|
+
})
|
|
129
|
+
.option('api-key', {
|
|
130
|
+
describe: 'API key for authentication',
|
|
131
|
+
type: 'string',
|
|
132
|
+
})
|
|
133
|
+
.option('cluster', {
|
|
134
|
+
describe: 'Target cluster',
|
|
135
|
+
type: 'string',
|
|
136
|
+
})
|
|
137
|
+
.option('base-url', {
|
|
138
|
+
describe: 'Base URL for the API',
|
|
139
|
+
type: 'string',
|
|
140
|
+
})
|
|
141
|
+
.option('session', {
|
|
142
|
+
alias: 's',
|
|
143
|
+
describe: 'Resume a specific session by ID (preserves env vars, cwd, etc.)',
|
|
144
|
+
type: 'string',
|
|
145
|
+
})
|
|
146
|
+
.option('ui', {
|
|
147
|
+
describe: 'Attach UI mode: ink|basic|opentui (default: opentui; env: ROCK_ATTACH_UI)',
|
|
148
|
+
type: 'string',
|
|
149
|
+
choices: ['ink', 'basic', 'opentui'],
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
handler: async (argv) => {
|
|
153
|
+
const logger = require('../utils/logger');
|
|
154
|
+
logger.debug(`attach handler received argv.session=${argv.session}, argv.s=${argv.s}`);
|
|
155
|
+
logger.debug(`Full argv keys: ${Object.keys(argv).join(', ')}`);
|
|
156
|
+
|
|
157
|
+
let sandboxId = argv.sandboxId;
|
|
158
|
+
|
|
159
|
+
// If sandbox-id not provided, prompt interactively with terminal-kit UI
|
|
160
|
+
if (!sandboxId) {
|
|
161
|
+
sandboxId = await promptForSandboxId();
|
|
162
|
+
if (!sandboxId) {
|
|
163
|
+
process.exitCode = 0;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Resolve abbreviated sandbox ID to full ID
|
|
169
|
+
try {
|
|
170
|
+
sandboxId = await resolveSandboxId(sandboxId);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
logger.error(error.message);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const { SandboxREPL } = require('./attach/repl');
|
|
178
|
+
const repl = new SandboxREPL(sandboxId, argv);
|
|
179
|
+
try {
|
|
180
|
+
await repl.start();
|
|
181
|
+
} catch (e) {
|
|
182
|
+
logger.error(e && e.message ? e.message : String(e));
|
|
183
|
+
process.exitCode = e && typeof e.exitCode === 'number' ? e.exitCode : 1;
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
};
|