start-command 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # start-command
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1da275c: feat: Improve output block uniformity and add OS-based Docker image detection
8
+ - Move isolation info lines into start block instead of printing them separately
9
+ - Move exit/result messages into finish block instead of printing them separately
10
+ - Add getDefaultDockerImage() to detect host OS and select matching Docker image
11
+ - Default Docker images: ubuntu, debian, archlinux, fedora, centos based on host OS
12
+
3
13
  ## 0.16.0
4
14
 
5
15
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -22,6 +22,7 @@ const {
22
22
  createLogFooter,
23
23
  writeLogFile,
24
24
  createLogPath,
25
+ getDefaultDockerImage,
25
26
  } = require('../lib/isolation');
26
27
  const {
27
28
  createIsolatedUser,
@@ -286,6 +287,12 @@ async function runWithIsolation(
286
287
  const startTime = getTimestamp();
287
288
  const startTimeMs = Date.now();
288
289
 
290
+ // Use default Docker image if docker isolation is selected but no image specified
291
+ let effectiveImage = options.image;
292
+ if (environment === 'docker' && !effectiveImage) {
293
+ effectiveImage = getDefaultDockerImage();
294
+ }
295
+
289
296
  // Create log file path
290
297
  const logFilePath = createLogPath(environment || 'direct');
291
298
 
@@ -298,38 +305,8 @@ async function runWithIsolation(
298
305
  const isWindows = process.platform === 'win32';
299
306
  const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
300
307
 
301
- // Create execution record for tracking with provided session ID
302
- let executionRecord = null;
303
- const store = getExecutionStore();
304
- if (store) {
305
- executionRecord = new ExecutionRecord({
306
- uuid: sessionId, // Use the provided session ID
307
- command: cmd,
308
- logPath: logFilePath,
309
- shell,
310
- workingDirectory: process.cwd(),
311
- options: {
312
- isolated: environment,
313
- isolationMode: mode,
314
- sessionName,
315
- image: options.image,
316
- endpoint: options.endpoint,
317
- user: options.user,
318
- keepAlive: options.keepAlive,
319
- useCommandStream,
320
- },
321
- });
322
- }
323
-
324
- // Print start block with session ID
325
- console.log(
326
- createStartBlock({
327
- sessionId,
328
- timestamp: startTime,
329
- command: cmd,
330
- })
331
- );
332
- console.log('');
308
+ // Collect isolation info lines for start block
309
+ const extraLines = [];
333
310
 
334
311
  // Handle --isolated-user option: create a new user with same permissions
335
312
  let createdUser = null;
@@ -352,9 +329,9 @@ async function runWithIsolation(
352
329
  currentGroups.includes(g)
353
330
  );
354
331
 
355
- console.log(`[User Isolation] Creating new user with same permissions...`);
332
+ extraLines.push('[User Isolation] Creating new user...');
356
333
  if (importantGroups.length > 0) {
357
- console.log(
334
+ extraLines.push(
358
335
  `[User Isolation] Inheriting groups: ${importantGroups.join(', ')}`
359
336
  );
360
337
  }
@@ -369,37 +346,69 @@ async function runWithIsolation(
369
346
  }
370
347
 
371
348
  createdUser = userResult.username;
372
- console.log(`[User Isolation] Created user: ${createdUser}`);
349
+ extraLines.push(`[User Isolation] Created user: ${createdUser}`);
373
350
  if (userResult.groups && userResult.groups.length > 0) {
374
- console.log(
351
+ extraLines.push(
375
352
  `[User Isolation] User groups: ${userResult.groups.join(', ')}`
376
353
  );
377
354
  }
378
355
  if (options.keepUser) {
379
- console.log(`[User Isolation] User will be kept after command completes`);
356
+ extraLines.push('[User Isolation] User will be kept after completion');
380
357
  }
381
- console.log('');
382
358
  }
383
359
 
384
- // Log isolation info
360
+ // Add isolation info to extra lines
385
361
  if (environment) {
386
- console.log(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
362
+ extraLines.push(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
387
363
  }
388
364
  if (options.session) {
389
- console.log(`[Isolation] Session: ${options.session}`);
365
+ extraLines.push(`[Isolation] Session: ${options.session}`);
390
366
  }
391
- if (options.image) {
392
- console.log(`[Isolation] Image: ${options.image}`);
367
+ if (effectiveImage) {
368
+ extraLines.push(`[Isolation] Image: ${effectiveImage}`);
393
369
  }
394
370
  if (options.endpoint) {
395
- console.log(`[Isolation] Endpoint: ${options.endpoint}`);
371
+ extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`);
396
372
  }
397
373
  if (createdUser) {
398
- console.log(`[Isolation] User: ${createdUser} (isolated)`);
374
+ extraLines.push(`[Isolation] User: ${createdUser} (isolated)`);
399
375
  }
400
376
  if (useCommandStream) {
401
- console.log(`[Isolation] Using command-stream library`);
377
+ extraLines.push('[Isolation] Using command-stream library');
378
+ }
379
+
380
+ // Create execution record for tracking with provided session ID
381
+ let executionRecord = null;
382
+ const store = getExecutionStore();
383
+ if (store) {
384
+ executionRecord = new ExecutionRecord({
385
+ uuid: sessionId, // Use the provided session ID
386
+ command: cmd,
387
+ logPath: logFilePath,
388
+ shell,
389
+ workingDirectory: process.cwd(),
390
+ options: {
391
+ isolated: environment,
392
+ isolationMode: mode,
393
+ sessionName,
394
+ image: effectiveImage,
395
+ endpoint: options.endpoint,
396
+ user: options.user,
397
+ keepAlive: options.keepAlive,
398
+ useCommandStream,
399
+ },
400
+ });
402
401
  }
402
+
403
+ // Print start block with session ID and isolation info
404
+ console.log(
405
+ createStartBlock({
406
+ sessionId,
407
+ timestamp: startTime,
408
+ command: cmd,
409
+ extraLines,
410
+ })
411
+ );
403
412
  console.log('');
404
413
 
405
414
  // Save initial execution record
@@ -421,7 +430,7 @@ async function runWithIsolation(
421
430
  environment: environment || 'direct',
422
431
  mode,
423
432
  sessionName,
424
- image: options.image,
433
+ image: effectiveImage,
425
434
  user: createdUser,
426
435
  startTime,
427
436
  });
@@ -442,7 +451,7 @@ async function runWithIsolation(
442
451
  // Future: Add command-stream support with raw() function for multiplexers
443
452
  result = await runIsolated(environment, cmd, {
444
453
  session: options.session,
445
- image: options.image,
454
+ image: effectiveImage,
446
455
  endpoint: options.endpoint,
447
456
  detached: mode === 'detached',
448
457
  user: createdUser,
@@ -483,30 +492,26 @@ async function runWithIsolation(
483
492
  }
484
493
  }
485
494
 
486
- // Print result
487
- console.log('');
488
- console.log(result.message);
489
-
490
495
  // Cleanup: delete the created user if we created one (unless --keep-user)
496
+ // This output goes to stdout but NOT inside the boxes - it's operational info
491
497
  if (createdUser && !options.keepUser) {
492
- console.log('');
493
498
  console.log(`[User Isolation] Cleaning up user: ${createdUser}`);
494
499
  const deleteResult = deleteUser(createdUser, { removeHome: true });
495
500
  if (deleteResult.success) {
496
- console.log(`[User Isolation] User deleted successfully`);
501
+ console.log('[User Isolation] User deleted successfully');
497
502
  } else {
498
503
  console.log(`[User Isolation] Warning: ${deleteResult.message}`);
499
504
  }
500
- } else if (createdUser && options.keepUser) {
501
505
  console.log('');
506
+ } else if (createdUser && options.keepUser) {
502
507
  console.log(
503
508
  `[User Isolation] Keeping user: ${createdUser} (use 'sudo userdel -r ${createdUser}' to delete)`
504
509
  );
510
+ console.log('');
505
511
  }
506
512
 
507
- // Print finish block
513
+ // Print finish block with result message inside
508
514
  const durationMs = Date.now() - startTimeMs;
509
- console.log('');
510
515
  console.log(
511
516
  createFinishBlock({
512
517
  sessionId,
@@ -514,6 +519,7 @@ async function runWithIsolation(
514
519
  exitCode,
515
520
  logPath: logFilePath,
516
521
  durationMs,
522
+ resultMessage: result.message,
517
523
  })
518
524
  );
519
525
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Docker utilities for the start-command CLI
3
+ *
4
+ * Provides Docker-related helper functions like detecting the default
5
+ * Docker image based on the host operating system.
6
+ */
7
+
8
+ const fs = require('fs');
9
+
10
+ /**
11
+ * Get the default Docker image based on the host operating system
12
+ * Returns an image that matches the current OS as closely as possible:
13
+ * - macOS: Uses alpine (since macOS cannot run in Docker)
14
+ * - Ubuntu/Debian: Uses ubuntu:latest
15
+ * - Arch Linux: Uses archlinux:latest
16
+ * - Other Linux: Uses the detected distro or alpine as fallback
17
+ * - Windows: Uses alpine (Windows containers have limited support)
18
+ * @returns {string} Docker image name
19
+ */
20
+ function getDefaultDockerImage() {
21
+ const platform = process.platform;
22
+
23
+ if (platform === 'darwin') {
24
+ // macOS cannot run in Docker containers, use alpine as lightweight alternative
25
+ return 'alpine:latest';
26
+ }
27
+
28
+ if (platform === 'win32') {
29
+ // Windows containers have limited support, use alpine for Linux containers
30
+ return 'alpine:latest';
31
+ }
32
+
33
+ if (platform === 'linux') {
34
+ // Try to detect the Linux distribution
35
+ try {
36
+ const osRelease = fs.readFileSync('/etc/os-release', 'utf8');
37
+
38
+ // Check for Ubuntu
39
+ if (
40
+ osRelease.includes('ID=ubuntu') ||
41
+ osRelease.includes('ID_LIKE=ubuntu') ||
42
+ osRelease.includes('ID_LIKE=debian ubuntu')
43
+ ) {
44
+ return 'ubuntu:latest';
45
+ }
46
+
47
+ // Check for Debian
48
+ if (
49
+ osRelease.includes('ID=debian') ||
50
+ osRelease.includes('ID_LIKE=debian')
51
+ ) {
52
+ return 'debian:latest';
53
+ }
54
+
55
+ // Check for Arch Linux
56
+ if (osRelease.includes('ID=arch') || osRelease.includes('ID_LIKE=arch')) {
57
+ return 'archlinux:latest';
58
+ }
59
+
60
+ // Check for Fedora
61
+ if (osRelease.includes('ID=fedora')) {
62
+ return 'fedora:latest';
63
+ }
64
+
65
+ // Check for CentOS/RHEL
66
+ if (
67
+ osRelease.includes('ID=centos') ||
68
+ osRelease.includes('ID=rhel') ||
69
+ osRelease.includes('ID_LIKE=rhel')
70
+ ) {
71
+ return 'centos:latest';
72
+ }
73
+
74
+ // Check for Alpine
75
+ if (osRelease.includes('ID=alpine')) {
76
+ return 'alpine:latest';
77
+ }
78
+ } catch {
79
+ // Cannot read /etc/os-release, fall through to default
80
+ }
81
+ }
82
+
83
+ // Default fallback: use alpine as a lightweight, universal option
84
+ return 'alpine:latest';
85
+ }
86
+
87
+ module.exports = {
88
+ getDefaultDockerImage,
89
+ };
@@ -964,6 +964,9 @@ function canRunLinuxDockerImages() {
964
964
  }
965
965
  }
966
966
 
967
+ // Re-export getDefaultDockerImage from docker-utils for backwards compatibility
968
+ const { getDefaultDockerImage } = require('./docker-utils');
969
+
967
970
  module.exports = {
968
971
  isCommandAvailable,
969
972
  hasTTY,
@@ -985,4 +988,5 @@ module.exports = {
985
988
  supportsLogfileOption,
986
989
  resetScreenVersionCache,
987
990
  canRunLinuxDockerImages,
991
+ getDefaultDockerImage,
988
992
  };
@@ -144,6 +144,7 @@ function createBottomBorder(width, style) {
144
144
  * @param {string} options.sessionId - Session UUID
145
145
  * @param {string} options.timestamp - Timestamp string
146
146
  * @param {string} options.command - Command being executed
147
+ * @param {string[]} [options.extraLines] - Additional lines to show after the command line
147
148
  * @param {string} [options.style] - Box style name
148
149
  * @param {number} [options.width] - Box width
149
150
  * @returns {string} Formatted start block
@@ -153,6 +154,7 @@ function createStartBlock(options) {
153
154
  sessionId,
154
155
  timestamp,
155
156
  command,
157
+ extraLines = [],
156
158
  style: styleName = DEFAULT_STYLE,
157
159
  width = DEFAULT_WIDTH,
158
160
  } = options;
@@ -165,6 +167,12 @@ function createStartBlock(options) {
165
167
  lines.push(
166
168
  createBorderedLine(`Starting at ${timestamp}: ${command}`, width, style)
167
169
  );
170
+
171
+ // Add extra lines (e.g., isolation info, docker image, etc.)
172
+ for (const line of extraLines) {
173
+ lines.push(createBorderedLine(line, width, style));
174
+ }
175
+
168
176
  lines.push(createBottomBorder(width, style));
169
177
 
170
178
  return lines.join('\n');
@@ -197,6 +205,7 @@ function formatDuration(durationMs) {
197
205
  * @param {number} options.exitCode - Exit code
198
206
  * @param {string} options.logPath - Path to log file
199
207
  * @param {number} [options.durationMs] - Duration in milliseconds
208
+ * @param {string} [options.resultMessage] - Result message (e.g., "Screen session exited...")
200
209
  * @param {string} [options.style] - Box style name
201
210
  * @param {number} [options.width] - Box width
202
211
  * @returns {string} Formatted finish block
@@ -208,6 +217,7 @@ function createFinishBlock(options) {
208
217
  exitCode,
209
218
  logPath,
210
219
  durationMs,
220
+ resultMessage,
211
221
  style: styleName = DEFAULT_STYLE,
212
222
  width = DEFAULT_WIDTH,
213
223
  } = options;
@@ -222,6 +232,12 @@ function createFinishBlock(options) {
222
232
  }
223
233
 
224
234
  lines.push(createTopBorder(width, style));
235
+
236
+ // Add result message first if provided (e.g., "Docker container exited...")
237
+ if (resultMessage) {
238
+ lines.push(createBorderedLine(resultMessage, width, style));
239
+ }
240
+
225
241
  lines.push(createBorderedLine(finishedMsg, width, style));
226
242
  lines.push(createBorderedLine(`Exit code: ${exitCode}`, width, style));
227
243
  lines.push(createBorderedLine(`Log: ${logPath}`, width, style));
@@ -13,6 +13,7 @@ const {
13
13
  getScreenVersion,
14
14
  supportsLogfileOption,
15
15
  resetScreenVersionCache,
16
+ getDefaultDockerImage,
16
17
  } = require('../src/lib/isolation');
17
18
 
18
19
  describe('Isolation Module', () => {
@@ -723,3 +724,34 @@ describe('Isolation Runner with Available Backends', () => {
723
724
  });
724
725
  });
725
726
  });
727
+
728
+ describe('Default Docker Image Detection', () => {
729
+ describe('getDefaultDockerImage', () => {
730
+ it('should return a valid Docker image string', () => {
731
+ const image = getDefaultDockerImage();
732
+ assert.ok(image, 'Should return a non-empty string');
733
+ assert.ok(typeof image === 'string', 'Should return a string');
734
+ });
735
+
736
+ it('should return an image with a tag', () => {
737
+ const image = getDefaultDockerImage();
738
+ assert.ok(image.includes(':'), 'Image should have a tag (e.g., :latest)');
739
+ });
740
+
741
+ it('should return a known base image', () => {
742
+ const image = getDefaultDockerImage();
743
+ const knownImages = [
744
+ 'alpine:latest',
745
+ 'ubuntu:latest',
746
+ 'debian:latest',
747
+ 'archlinux:latest',
748
+ 'fedora:latest',
749
+ 'centos:latest',
750
+ ];
751
+ assert.ok(
752
+ knownImages.includes(image),
753
+ `Image '${image}' should be one of the known base images: ${knownImages.join(', ')}`
754
+ );
755
+ });
756
+ });
757
+ });
@@ -77,6 +77,26 @@ describe('output-blocks module', () => {
77
77
  expect(block).toContain('+');
78
78
  expect(block).toContain('-');
79
79
  });
80
+
81
+ it('should include extra lines when provided', () => {
82
+ const block = createStartBlock({
83
+ sessionId: 'test-uuid',
84
+ timestamp: '2025-01-01 00:00:00',
85
+ command: 'echo hello',
86
+ extraLines: [
87
+ '[Isolation] Environment: screen, Mode: attached',
88
+ '[Isolation] Session: my-session',
89
+ ],
90
+ });
91
+
92
+ expect(block).toContain('╭');
93
+ expect(block).toContain('╰');
94
+ expect(block).toContain('Session ID: test-uuid');
95
+ expect(block).toContain(
96
+ '[Isolation] Environment: screen, Mode: attached'
97
+ );
98
+ expect(block).toContain('[Isolation] Session: my-session');
99
+ });
80
100
  });
81
101
 
82
102
  describe('createFinishBlock', () => {
@@ -110,6 +130,26 @@ describe('output-blocks module', () => {
110
130
  expect(block).toContain('Finished at 2025-01-01 00:00:01');
111
131
  expect(block).not.toContain('seconds');
112
132
  });
133
+
134
+ it('should include result message when provided', () => {
135
+ const block = createFinishBlock({
136
+ sessionId: 'test-uuid-1234',
137
+ timestamp: '2025-01-01 00:00:01',
138
+ exitCode: 0,
139
+ logPath: '/tmp/test.log',
140
+ durationMs: 17,
141
+ resultMessage: 'Screen session "my-session" exited with code 0',
142
+ });
143
+
144
+ expect(block).toContain('╭');
145
+ expect(block).toContain('╰');
146
+ expect(block).toContain('Screen session');
147
+ expect(block).toContain('exited with code 0');
148
+ expect(block).toContain('Session ID: test-uuid-1234');
149
+ expect(block).toContain(
150
+ 'Finished at 2025-01-01 00:00:01 in 0.017 seconds'
151
+ );
152
+ });
113
153
  });
114
154
 
115
155
  describe('formatDuration', () => {