start-command 0.7.6 → 0.10.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.
@@ -131,6 +131,22 @@ function hasTTY() {
131
131
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
132
132
  }
133
133
 
134
+ /**
135
+ * Wrap command with sudo -u if user option is specified
136
+ * @param {string} command - Original command
137
+ * @param {string|null} user - Username to run as (or null)
138
+ * @returns {string} Wrapped command
139
+ */
140
+ function wrapCommandWithUser(command, user) {
141
+ if (!user) {
142
+ return command;
143
+ }
144
+ // Use sudo -u to run command as specified user
145
+ // -E preserves environment variables
146
+ // -n ensures non-interactive (fails if password required)
147
+ return `sudo -n -u ${user} sh -c '${command.replace(/'/g, "'\\''")}'`;
148
+ }
149
+
134
150
  /**
135
151
  * Run command in GNU Screen using detached mode with log capture
136
152
  * This is a workaround for environments without TTY
@@ -142,9 +158,10 @@ function hasTTY() {
142
158
  * @param {string} command - Command to execute
143
159
  * @param {string} sessionName - Session name
144
160
  * @param {object} shellInfo - Shell info from getShell()
161
+ * @param {string|null} user - Username to run command as (optional)
145
162
  * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
146
163
  */
147
- function runScreenWithLogCapture(command, sessionName, shellInfo) {
164
+ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
148
165
  const { shell, shellArg } = shellInfo;
149
166
  const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
150
167
 
@@ -154,7 +171,8 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) {
154
171
  return new Promise((resolve) => {
155
172
  try {
156
173
  let screenArgs;
157
- let effectiveCommand = command;
174
+ // Wrap command with user switch if specified
175
+ let effectiveCommand = wrapCommandWithUser(command, user);
158
176
 
159
177
  if (useNativeLogging) {
160
178
  // Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
@@ -167,7 +185,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) {
167
185
  logFile,
168
186
  shell,
169
187
  shellArg,
170
- command,
188
+ effectiveCommand,
171
189
  ];
172
190
 
173
191
  if (DEBUG) {
@@ -179,7 +197,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) {
179
197
  // Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
180
198
  // Wrap the command to capture output using tee
181
199
  // The parentheses ensure proper grouping of the command and its stderr
182
- effectiveCommand = `(${command}) 2>&1 | tee "${logFile}"`;
200
+ effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
183
201
  screenArgs = ['-dmS', sessionName, shell, shellArg, effectiveCommand];
184
202
 
185
203
  if (DEBUG) {
@@ -299,7 +317,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) {
299
317
  /**
300
318
  * Run command in GNU Screen
301
319
  * @param {string} command - Command to execute
302
- * @param {object} options - Options (session, detached)
320
+ * @param {object} options - Options (session, detached, user, keepAlive)
303
321
  * @returns {Promise<{success: boolean, sessionName: string, message: string}>}
304
322
  */
305
323
  function runInScreen(command, options = {}) {
@@ -317,12 +335,31 @@ function runInScreen(command, options = {}) {
317
335
  const { shell, shellArg } = shellInfo;
318
336
 
319
337
  try {
338
+ // Wrap command with user switch if specified
339
+ let effectiveCommand = wrapCommandWithUser(command, options.user);
340
+
320
341
  if (options.detached) {
321
342
  // Detached mode: screen -dmS <session> <shell> -c '<command>'
322
- const screenArgs = ['-dmS', sessionName, shell, shellArg, command];
343
+ // By default (keepAlive=false), the session will exit after command completes
344
+ // With keepAlive=true, we start a shell that runs the command but stays alive
345
+
346
+ if (options.keepAlive) {
347
+ // With keep-alive: run command, then keep shell open
348
+ effectiveCommand = `${effectiveCommand}; exec ${shell}`;
349
+ }
350
+ // Without keep-alive: command runs and session exits naturally when done
351
+
352
+ const screenArgs = [
353
+ '-dmS',
354
+ sessionName,
355
+ shell,
356
+ shellArg,
357
+ effectiveCommand,
358
+ ];
323
359
 
324
360
  if (DEBUG) {
325
361
  console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
362
+ console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
326
363
  }
327
364
 
328
365
  // Use spawnSync with array arguments to avoid shell quoting issues
@@ -336,10 +373,18 @@ function runInScreen(command, options = {}) {
336
373
  throw result.error;
337
374
  }
338
375
 
376
+ let message = `Command started in detached screen session: ${sessionName}`;
377
+ if (options.keepAlive) {
378
+ message += `\nSession will stay alive after command completes.`;
379
+ } else {
380
+ message += `\nSession will exit automatically after command completes.`;
381
+ }
382
+ message += `\nReattach with: screen -r ${sessionName}`;
383
+
339
384
  return Promise.resolve({
340
385
  success: true,
341
386
  sessionName,
342
- message: `Command started in detached screen session: ${sessionName}\nReattach with: screen -r ${sessionName}`,
387
+ message,
343
388
  });
344
389
  } else {
345
390
  // Attached mode: always use detached mode with log capture
@@ -357,7 +402,12 @@ function runInScreen(command, options = {}) {
357
402
  );
358
403
  }
359
404
 
360
- return runScreenWithLogCapture(command, sessionName, shellInfo);
405
+ return runScreenWithLogCapture(
406
+ command,
407
+ sessionName,
408
+ shellInfo,
409
+ options.user
410
+ );
361
411
  }
362
412
  } catch (err) {
363
413
  return Promise.resolve({
@@ -371,7 +421,7 @@ function runInScreen(command, options = {}) {
371
421
  /**
372
422
  * Run command in tmux
373
423
  * @param {string} command - Command to execute
374
- * @param {object} options - Options (session, detached)
424
+ * @param {object} options - Options (session, detached, user, keepAlive)
375
425
  * @returns {Promise<{success: boolean, sessionName: string, message: string}>}
376
426
  */
377
427
  function runInTmux(command, options = {}) {
@@ -385,37 +435,63 @@ function runInTmux(command, options = {}) {
385
435
  }
386
436
 
387
437
  const sessionName = options.session || generateSessionName('tmux');
438
+ const shellInfo = getShell();
439
+ const { shell } = shellInfo;
440
+
441
+ // Wrap command with user switch if specified
442
+ let effectiveCommand = wrapCommandWithUser(command, options.user);
388
443
 
389
444
  try {
390
445
  if (options.detached) {
391
446
  // Detached mode: tmux new-session -d -s <session> '<command>'
447
+ // By default (keepAlive=false), the session will exit after command completes
448
+ // With keepAlive=true, we keep the shell alive after the command
449
+
450
+ if (options.keepAlive) {
451
+ // With keep-alive: run command, then keep shell open
452
+ effectiveCommand = `${effectiveCommand}; exec ${shell}`;
453
+ }
454
+ // Without keep-alive: command runs and session exits naturally when done
455
+
392
456
  if (DEBUG) {
393
457
  console.log(
394
- `[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${command}"`
458
+ `[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${effectiveCommand}"`
395
459
  );
460
+ console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
396
461
  }
397
462
 
398
- execSync(`tmux new-session -d -s "${sessionName}" "${command}"`, {
399
- stdio: 'inherit',
400
- });
463
+ execSync(
464
+ `tmux new-session -d -s "${sessionName}" "${effectiveCommand}"`,
465
+ {
466
+ stdio: 'inherit',
467
+ }
468
+ );
469
+
470
+ let message = `Command started in detached tmux session: ${sessionName}`;
471
+ if (options.keepAlive) {
472
+ message += `\nSession will stay alive after command completes.`;
473
+ } else {
474
+ message += `\nSession will exit automatically after command completes.`;
475
+ }
476
+ message += `\nReattach with: tmux attach -t ${sessionName}`;
401
477
 
402
478
  return Promise.resolve({
403
479
  success: true,
404
480
  sessionName,
405
- message: `Command started in detached tmux session: ${sessionName}\nReattach with: tmux attach -t ${sessionName}`,
481
+ message,
406
482
  });
407
483
  } else {
408
484
  // Attached mode: tmux new-session -s <session> '<command>'
409
485
  if (DEBUG) {
410
486
  console.log(
411
- `[DEBUG] Running: tmux new-session -s "${sessionName}" "${command}"`
487
+ `[DEBUG] Running: tmux new-session -s "${sessionName}" "${effectiveCommand}"`
412
488
  );
413
489
  }
414
490
 
415
491
  return new Promise((resolve) => {
416
492
  const child = spawn(
417
493
  'tmux',
418
- ['new-session', '-s', sessionName, command],
494
+ ['new-session', '-s', sessionName, effectiveCommand],
419
495
  {
420
496
  stdio: 'inherit',
421
497
  }
@@ -451,7 +527,7 @@ function runInTmux(command, options = {}) {
451
527
  /**
452
528
  * Run command in Docker container
453
529
  * @param {string} command - Command to execute
454
- * @param {object} options - Options (image, session/name, detached)
530
+ * @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer)
455
531
  * @returns {Promise<{success: boolean, containerName: string, message: string}>}
456
532
  */
457
533
  function runInDocker(command, options = {}) {
@@ -476,45 +552,75 @@ function runInDocker(command, options = {}) {
476
552
 
477
553
  try {
478
554
  if (options.detached) {
479
- // Detached mode: docker run -d --name <name> <image> <shell> -c '<command>'
480
- const dockerArgs = [
481
- 'run',
482
- '-d',
483
- '--name',
484
- containerName,
485
- options.image,
486
- '/bin/sh',
487
- '-c',
488
- command,
489
- ];
555
+ // Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
556
+ // By default (keepAlive=false), the container exits after command completes
557
+ // With keepAlive=true, we keep the container running with a shell
558
+ let effectiveCommand = command;
559
+
560
+ if (options.keepAlive) {
561
+ // With keep-alive: run command, then keep shell alive
562
+ effectiveCommand = `${command}; exec /bin/sh`;
563
+ }
564
+ // Without keep-alive: container exits naturally when command completes
565
+
566
+ const dockerArgs = ['run', '-d', '--name', containerName];
567
+
568
+ // Add --rm flag if autoRemoveDockerContainer is true
569
+ // Note: --rm must come before the image name
570
+ if (options.autoRemoveDockerContainer) {
571
+ dockerArgs.splice(2, 0, '--rm');
572
+ }
573
+
574
+ // Add --user flag if specified
575
+ if (options.user) {
576
+ dockerArgs.push('--user', options.user);
577
+ }
578
+
579
+ dockerArgs.push(options.image, '/bin/sh', '-c', effectiveCommand);
490
580
 
491
581
  if (DEBUG) {
492
582
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
583
+ console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
584
+ console.log(
585
+ `[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}`
586
+ );
493
587
  }
494
588
 
495
589
  const containerId = execSync(`docker ${dockerArgs.join(' ')}`, {
496
590
  encoding: 'utf8',
497
591
  }).trim();
498
592
 
593
+ let message = `Command started in detached docker container: ${containerName}`;
594
+ message += `\nContainer ID: ${containerId.substring(0, 12)}`;
595
+ if (options.keepAlive) {
596
+ message += `\nContainer will stay alive after command completes.`;
597
+ } else {
598
+ message += `\nContainer will exit automatically after command completes.`;
599
+ }
600
+ if (options.autoRemoveDockerContainer) {
601
+ message += `\nContainer will be automatically removed after exit.`;
602
+ } else {
603
+ message += `\nContainer filesystem will be preserved after exit.`;
604
+ }
605
+ message += `\nAttach with: docker attach ${containerName}`;
606
+ message += `\nView logs: docker logs ${containerName}`;
607
+
499
608
  return Promise.resolve({
500
609
  success: true,
501
610
  containerName,
502
611
  containerId,
503
- message: `Command started in detached docker container: ${containerName}\nContainer ID: ${containerId.substring(0, 12)}\nAttach with: docker attach ${containerName}\nView logs: docker logs ${containerName}`,
612
+ message,
504
613
  });
505
614
  } else {
506
- // Attached mode: docker run -it --name <name> <image> <shell> -c '<command>'
507
- const dockerArgs = [
508
- 'run',
509
- '-it',
510
- '--rm',
511
- '--name',
512
- containerName,
513
- options.image,
514
- '/bin/sh',
515
- '-c',
516
- command,
517
- ];
615
+ // Attached mode: docker run -it --name <name> [--user <user>] <image> <shell> -c '<command>'
616
+ const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
617
+
618
+ // Add --user flag if specified
619
+ if (options.user) {
620
+ dockerArgs.push('--user', options.user);
621
+ }
622
+
623
+ dockerArgs.push(options.image, '/bin/sh', '-c', command);
518
624
 
519
625
  if (DEBUG) {
520
626
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -602,6 +708,7 @@ function generateLogFilename(environment) {
602
708
  * @param {string} params.mode - attached or detached
603
709
  * @param {string} params.sessionName - Session/container name
604
710
  * @param {string} [params.image] - Docker image (for docker environment)
711
+ * @param {string} [params.user] - User to run command as (optional)
605
712
  * @param {string} params.startTime - Start timestamp
606
713
  * @returns {string} Log header content
607
714
  */
@@ -615,6 +722,9 @@ function createLogHeader(params) {
615
722
  if (params.image) {
616
723
  content += `Image: ${params.image}\n`;
617
724
  }
725
+ if (params.user) {
726
+ content += `User: ${params.user}\n`;
727
+ }
618
728
  content += `Platform: ${process.platform}\n`;
619
729
  content += `Node Version: ${process.version}\n`;
620
730
  content += `Working Directory: ${process.cwd()}\n`;
@@ -678,6 +788,37 @@ function resetScreenVersionCache() {
678
788
  screenVersionChecked = false;
679
789
  }
680
790
 
791
+ /**
792
+ * Run command as an isolated user (without isolation backend)
793
+ * Uses sudo -u to switch users
794
+ * @param {string} cmd - Command to execute
795
+ * @param {string} username - User to run as
796
+ * @returns {Promise<{success: boolean, message: string, exitCode: number}>}
797
+ */
798
+ function runAsIsolatedUser(cmd, username) {
799
+ return new Promise((resolve) => {
800
+ const child = spawn('sudo', ['-n', '-u', username, 'sh', '-c', cmd], {
801
+ stdio: 'inherit',
802
+ });
803
+
804
+ child.on('exit', (code) => {
805
+ resolve({
806
+ success: code === 0,
807
+ message: `Command completed as user "${username}" with exit code ${code}`,
808
+ exitCode: code || 0,
809
+ });
810
+ });
811
+
812
+ child.on('error', (err) => {
813
+ resolve({
814
+ success: false,
815
+ message: `Failed to run as user "${username}": ${err.message}`,
816
+ exitCode: 1,
817
+ });
818
+ });
819
+ });
820
+ }
821
+
681
822
  module.exports = {
682
823
  isCommandAvailable,
683
824
  hasTTY,
@@ -685,7 +826,8 @@ module.exports = {
685
826
  runInTmux,
686
827
  runInDocker,
687
828
  runIsolated,
688
- // Export logging utilities for unified experience
829
+ runAsIsolatedUser,
830
+ wrapCommandWithUser,
689
831
  getTimestamp,
690
832
  generateLogFilename,
691
833
  createLogHeader,
@@ -693,7 +835,6 @@ module.exports = {
693
835
  writeLogFile,
694
836
  getLogDir,
695
837
  createLogPath,
696
- // Export screen version utilities for testing and debugging
697
838
  getScreenVersion,
698
839
  supportsLogfileOption,
699
840
  resetScreenVersionCache,