rl-rockcli 0.0.9 → 0.0.10

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.
Files changed (89) hide show
  1. package/commands/attach/basic-repl.js +212 -0
  2. package/commands/attach/cleanup-history.js +189 -0
  3. package/commands/attach/cleanup-manager.js +163 -0
  4. package/commands/attach/copy-ui/copyRepl.js +195 -0
  5. package/commands/attach/copy-ui/index.js +7 -0
  6. package/commands/attach/copy-ui/render/outputBlock.js +25 -0
  7. package/commands/attach/copy-ui/viewport/viewport.js +23 -0
  8. package/commands/attach/copy-ui/viewport/wheel.js +14 -0
  9. package/commands/attach/history-manager.js +507 -0
  10. package/commands/attach/history-session.js +48 -0
  11. package/commands/attach/ink-repl/InkREPL.js +1507 -0
  12. package/commands/attach/ink-repl/builtinCommands.js +1253 -0
  13. package/commands/attach/ink-repl/components/ConnectingScreen.js +76 -0
  14. package/commands/attach/ink-repl/components/Console.js +191 -0
  15. package/commands/attach/ink-repl/components/DetailView.js +148 -0
  16. package/commands/attach/ink-repl/components/DropdownMenu.js +86 -0
  17. package/commands/attach/ink-repl/components/InputArea.js +125 -0
  18. package/commands/attach/ink-repl/components/InputLine.js +18 -0
  19. package/commands/attach/ink-repl/components/OutputArea.js +22 -0
  20. package/commands/attach/ink-repl/components/OutputItem.js +96 -0
  21. package/commands/attach/ink-repl/components/ShellLayout.js +61 -0
  22. package/commands/attach/ink-repl/components/Spinner.js +79 -0
  23. package/commands/attach/ink-repl/components/StatusBar.js +106 -0
  24. package/commands/attach/ink-repl/components/WelcomeBanner.js +48 -0
  25. package/commands/attach/ink-repl/contexts/LayoutContext.js +12 -0
  26. package/commands/attach/ink-repl/contexts/ThemeContext.js +43 -0
  27. package/commands/attach/ink-repl/hooks/useFunctionKeys.js +70 -0
  28. package/commands/attach/ink-repl/hooks/useMouse.js +162 -0
  29. package/commands/attach/ink-repl/hooks/useResources.js +132 -0
  30. package/commands/attach/ink-repl/hooks/useSpinner.js +49 -0
  31. package/commands/attach/ink-repl/index.js +112 -0
  32. package/commands/attach/ink-repl/package.json +3 -0
  33. package/commands/attach/ink-repl/replState.js +947 -0
  34. package/commands/attach/ink-repl/shortcuts/defaultKeybindings.js +138 -0
  35. package/commands/attach/ink-repl/shortcuts/index.js +332 -0
  36. package/commands/attach/ink-repl/themes/defaultDark.js +18 -0
  37. package/commands/attach/ink-repl/themes/defaultLight.js +18 -0
  38. package/commands/attach/ink-repl/themes/index.js +4 -0
  39. package/commands/attach/ink-repl/themes/themeManager.js +45 -0
  40. package/commands/attach/ink-repl/themes/themeTokens.js +15 -0
  41. package/commands/attach/ink-repl/utils/atCompletion.js +346 -0
  42. package/commands/attach/ink-repl/utils/clipboard.js +50 -0
  43. package/commands/attach/ink-repl/utils/consoleLogger.js +81 -0
  44. package/commands/attach/ink-repl/utils/exitCodeHandler.js +49 -0
  45. package/commands/attach/ink-repl/utils/exitCodeTips.js +56 -0
  46. package/commands/attach/ink-repl/utils/formatTime.js +12 -0
  47. package/commands/attach/ink-repl/utils/outputSelection.js +120 -0
  48. package/commands/attach/ink-repl/utils/outputViewport.js +77 -0
  49. package/commands/attach/ink-repl/utils/paginatedFileLoading.js +76 -0
  50. package/commands/attach/ink-repl/utils/paramHint.js +60 -0
  51. package/commands/attach/ink-repl/utils/parseError.js +174 -0
  52. package/commands/attach/ink-repl/utils/pathCompletion.js +167 -0
  53. package/commands/attach/ink-repl/utils/remotePathSafety.js +56 -0
  54. package/commands/attach/ink-repl/utils/replSelection.js +205 -0
  55. package/commands/attach/ink-repl/utils/responseFormatter.js +127 -0
  56. package/commands/attach/ink-repl/utils/textWrap.js +117 -0
  57. package/commands/attach/ink-repl/utils/truncate.js +115 -0
  58. package/commands/attach/opentui-repl/App.tsx +891 -0
  59. package/commands/attach/opentui-repl/builtinCommands.ts +80 -0
  60. package/commands/attach/opentui-repl/components/ConfirmDialog.tsx +116 -0
  61. package/commands/attach/opentui-repl/components/ConnectingScreen.tsx +131 -0
  62. package/commands/attach/opentui-repl/components/Console.tsx +73 -0
  63. package/commands/attach/opentui-repl/components/DetailView.tsx +45 -0
  64. package/commands/attach/opentui-repl/components/DropdownMenu.tsx +130 -0
  65. package/commands/attach/opentui-repl/components/ExecutionStatus.tsx +66 -0
  66. package/commands/attach/opentui-repl/components/Header.tsx +24 -0
  67. package/commands/attach/opentui-repl/components/OutputArea.tsx +25 -0
  68. package/commands/attach/opentui-repl/components/OutputBlock.tsx +108 -0
  69. package/commands/attach/opentui-repl/components/PromptInput.tsx +109 -0
  70. package/commands/attach/opentui-repl/components/StatusBar.tsx +63 -0
  71. package/commands/attach/opentui-repl/components/Toast.tsx +65 -0
  72. package/commands/attach/opentui-repl/components/WelcomeBanner.tsx +41 -0
  73. package/commands/attach/opentui-repl/contexts/ReplContext.tsx +137 -0
  74. package/commands/attach/opentui-repl/contexts/SessionContext.tsx +32 -0
  75. package/commands/attach/opentui-repl/contexts/ThemeContext.tsx +70 -0
  76. package/commands/attach/opentui-repl/contexts/ToastContext.tsx +69 -0
  77. package/commands/attach/opentui-repl/contexts/toast-logic.js +71 -0
  78. package/commands/attach/opentui-repl/hooks/useResources.ts +102 -0
  79. package/commands/attach/opentui-repl/hooks/useSpinner.ts +46 -0
  80. package/commands/attach/opentui-repl/index.js +99 -0
  81. package/commands/attach/opentui-repl/keybindings.ts +39 -0
  82. package/commands/attach/opentui-repl/package.json +3 -0
  83. package/commands/attach/opentui-repl/render.tsx +72 -0
  84. package/commands/attach/opentui-repl/tsconfig.json +12 -0
  85. package/commands/attach/repl.js +791 -0
  86. package/commands/attach/sandbox-id-resolver.js +56 -0
  87. package/commands/attach/session-manager.js +307 -0
  88. package/commands/attach/ui-mode.js +146 -0
  89. 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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-rockcli",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "Open-source ROCK CLI - Sandbox and Log management tool",
5
5
  "bin": {
6
6
  "rockcli": "./index.js"