start-command 0.9.0 → 0.11.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.
@@ -6,11 +6,14 @@
6
6
  * 2. $ [wrapper-options] command [command-options]
7
7
  *
8
8
  * Wrapper Options:
9
- * --isolated, -i <backend> Run in isolated environment (screen, tmux, docker)
9
+ * --isolated, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
10
10
  * --attached, -a Run in attached mode (foreground)
11
11
  * --detached, -d Run in detached mode (background)
12
12
  * --session, -s <name> Session name for isolation
13
13
  * --image <image> Docker image (required for docker isolation)
14
+ * --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
15
+ * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
16
+ * --keep-user Keep isolated user after command completes (don't delete)
14
17
  * --keep-alive, -k Keep isolation environment alive after command exits
15
18
  * --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
16
19
  */
@@ -22,7 +25,7 @@ const DEBUG =
22
25
  /**
23
26
  * Valid isolation backends
24
27
  */
25
- const VALID_BACKENDS = ['screen', 'tmux', 'docker'];
28
+ const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'ssh'];
26
29
 
27
30
  /**
28
31
  * Parse command line arguments into wrapper options and command
@@ -31,11 +34,15 @@ const VALID_BACKENDS = ['screen', 'tmux', 'docker'];
31
34
  */
32
35
  function parseArgs(args) {
33
36
  const wrapperOptions = {
34
- isolated: null, // Isolation backend: screen, tmux, docker
37
+ isolated: null, // Isolation backend: screen, tmux, docker, ssh
35
38
  attached: false, // Run in attached mode
36
39
  detached: false, // Run in detached mode
37
40
  session: null, // Session name
38
41
  image: null, // Docker image
42
+ endpoint: null, // SSH endpoint (e.g., user@host)
43
+ user: false, // Create isolated user
44
+ userName: null, // Optional custom username for isolated user
45
+ keepUser: false, // Keep isolated user after command completes (don't delete)
39
46
  keepAlive: false, // Keep environment alive after command exits
40
47
  autoRemoveDockerContainer: false, // Auto-remove docker container after exit
41
48
  };
@@ -175,6 +182,51 @@ function parseOption(args, index, options) {
175
182
  return 1;
176
183
  }
177
184
 
185
+ // --endpoint (for ssh)
186
+ if (arg === '--endpoint') {
187
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
188
+ options.endpoint = args[index + 1];
189
+ return 2;
190
+ } else {
191
+ throw new Error(`Option ${arg} requires an endpoint argument`);
192
+ }
193
+ }
194
+
195
+ // --endpoint=<value>
196
+ if (arg.startsWith('--endpoint=')) {
197
+ options.endpoint = arg.split('=')[1];
198
+ return 1;
199
+ }
200
+
201
+ // --isolated-user or -u [optional-username] - creates isolated user with same permissions
202
+ if (arg === '--isolated-user' || arg === '-u') {
203
+ options.user = true;
204
+ // Check if next arg is an optional username (not starting with -)
205
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
206
+ // Check if next arg looks like a username (not a command)
207
+ const nextArg = args[index + 1];
208
+ // If next arg matches username format, consume it
209
+ if (/^[a-zA-Z0-9_-]+$/.test(nextArg) && nextArg.length <= 32) {
210
+ options.userName = nextArg;
211
+ return 2;
212
+ }
213
+ }
214
+ return 1;
215
+ }
216
+
217
+ // --isolated-user=<value>
218
+ if (arg.startsWith('--isolated-user=')) {
219
+ options.user = true;
220
+ options.userName = arg.split('=')[1];
221
+ return 1;
222
+ }
223
+
224
+ // --keep-user - keep isolated user after command completes
225
+ if (arg === '--keep-user') {
226
+ options.keepUser = true;
227
+ return 1;
228
+ }
229
+
178
230
  // --keep-alive or -k
179
231
  if (arg === '--keep-alive' || arg === '-k') {
180
232
  options.keepAlive = true;
@@ -218,6 +270,13 @@ function validateOptions(options) {
218
270
  'Docker isolation requires --image option to specify the container image'
219
271
  );
220
272
  }
273
+
274
+ // SSH requires --endpoint
275
+ if (options.isolated === 'ssh' && !options.endpoint) {
276
+ throw new Error(
277
+ 'SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)'
278
+ );
279
+ }
221
280
  }
222
281
 
223
282
  // Session name is only valid with isolation
@@ -230,6 +289,11 @@ function validateOptions(options) {
230
289
  throw new Error('--image option is only valid with --isolated docker');
231
290
  }
232
291
 
292
+ // Endpoint is only valid with ssh
293
+ if (options.endpoint && options.isolated !== 'ssh') {
294
+ throw new Error('--endpoint option is only valid with --isolated ssh');
295
+ }
296
+
233
297
  // Keep-alive is only valid with isolation
234
298
  if (options.keepAlive && !options.isolated) {
235
299
  throw new Error('--keep-alive option is only valid with --isolated');
@@ -241,6 +305,34 @@ function validateOptions(options) {
241
305
  '--auto-remove-docker-container option is only valid with --isolated docker'
242
306
  );
243
307
  }
308
+
309
+ // User isolation validation
310
+ if (options.user) {
311
+ // User isolation is not supported with Docker (Docker has its own user mechanism)
312
+ if (options.isolated === 'docker') {
313
+ throw new Error(
314
+ '--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation.'
315
+ );
316
+ }
317
+ // Validate custom username if provided
318
+ if (options.userName) {
319
+ if (!/^[a-zA-Z0-9_-]+$/.test(options.userName)) {
320
+ throw new Error(
321
+ `Invalid username format for --isolated-user: "${options.userName}". Username should contain only letters, numbers, hyphens, and underscores.`
322
+ );
323
+ }
324
+ if (options.userName.length > 32) {
325
+ throw new Error(
326
+ `Username too long for --isolated-user: "${options.userName}". Maximum length is 32 characters.`
327
+ );
328
+ }
329
+ }
330
+ }
331
+
332
+ // Keep-user validation
333
+ if (options.keepUser && !options.user) {
334
+ throw new Error('--keep-user option is only valid with --isolated-user');
335
+ }
244
336
  }
245
337
 
246
338
  /**
@@ -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, keepAlive)
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,16 +335,17 @@ 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
343
  // By default (keepAlive=false), the session will exit after command completes
323
344
  // With keepAlive=true, we start a shell that runs the command but stays alive
324
- let effectiveCommand = command;
325
345
 
326
346
  if (options.keepAlive) {
327
347
  // With keep-alive: run command, then keep shell open
328
- // Use exec to replace the shell, but first run command
329
- effectiveCommand = `${command}; exec ${shell}`;
348
+ effectiveCommand = `${effectiveCommand}; exec ${shell}`;
330
349
  }
331
350
  // Without keep-alive: command runs and session exits naturally when done
332
351
 
@@ -383,7 +402,12 @@ function runInScreen(command, options = {}) {
383
402
  );
384
403
  }
385
404
 
386
- return runScreenWithLogCapture(command, sessionName, shellInfo);
405
+ return runScreenWithLogCapture(
406
+ command,
407
+ sessionName,
408
+ shellInfo,
409
+ options.user
410
+ );
387
411
  }
388
412
  } catch (err) {
389
413
  return Promise.resolve({
@@ -397,7 +421,7 @@ function runInScreen(command, options = {}) {
397
421
  /**
398
422
  * Run command in tmux
399
423
  * @param {string} command - Command to execute
400
- * @param {object} options - Options (session, detached, keepAlive)
424
+ * @param {object} options - Options (session, detached, user, keepAlive)
401
425
  * @returns {Promise<{success: boolean, sessionName: string, message: string}>}
402
426
  */
403
427
  function runInTmux(command, options = {}) {
@@ -414,16 +438,18 @@ function runInTmux(command, options = {}) {
414
438
  const shellInfo = getShell();
415
439
  const { shell } = shellInfo;
416
440
 
441
+ // Wrap command with user switch if specified
442
+ let effectiveCommand = wrapCommandWithUser(command, options.user);
443
+
417
444
  try {
418
445
  if (options.detached) {
419
446
  // Detached mode: tmux new-session -d -s <session> '<command>'
420
447
  // By default (keepAlive=false), the session will exit after command completes
421
448
  // With keepAlive=true, we keep the shell alive after the command
422
- let effectiveCommand = command;
423
449
 
424
450
  if (options.keepAlive) {
425
451
  // With keep-alive: run command, then keep shell open
426
- effectiveCommand = `${command}; exec ${shell}`;
452
+ effectiveCommand = `${effectiveCommand}; exec ${shell}`;
427
453
  }
428
454
  // Without keep-alive: command runs and session exits naturally when done
429
455
 
@@ -458,14 +484,14 @@ function runInTmux(command, options = {}) {
458
484
  // Attached mode: tmux new-session -s <session> '<command>'
459
485
  if (DEBUG) {
460
486
  console.log(
461
- `[DEBUG] Running: tmux new-session -s "${sessionName}" "${command}"`
487
+ `[DEBUG] Running: tmux new-session -s "${sessionName}" "${effectiveCommand}"`
462
488
  );
463
489
  }
464
490
 
465
491
  return new Promise((resolve) => {
466
492
  const child = spawn(
467
493
  'tmux',
468
- ['new-session', '-s', sessionName, command],
494
+ ['new-session', '-s', sessionName, effectiveCommand],
469
495
  {
470
496
  stdio: 'inherit',
471
497
  }
@@ -498,10 +524,103 @@ function runInTmux(command, options = {}) {
498
524
  }
499
525
  }
500
526
 
527
+ /**
528
+ * Run command over SSH on a remote server
529
+ * @param {string} command - Command to execute
530
+ * @param {object} options - Options (endpoint, session, detached)
531
+ * @returns {Promise<{success: boolean, sessionName: string, message: string}>}
532
+ */
533
+ function runInSsh(command, options = {}) {
534
+ if (!isCommandAvailable('ssh')) {
535
+ return Promise.resolve({
536
+ success: false,
537
+ sessionName: null,
538
+ message:
539
+ 'ssh is not installed. Install it with: sudo apt-get install openssh-client (Debian/Ubuntu) or brew install openssh (macOS)',
540
+ });
541
+ }
542
+
543
+ if (!options.endpoint) {
544
+ return Promise.resolve({
545
+ success: false,
546
+ sessionName: null,
547
+ message:
548
+ 'SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)',
549
+ });
550
+ }
551
+
552
+ const sessionName = options.session || generateSessionName('ssh');
553
+ const sshTarget = options.endpoint;
554
+
555
+ try {
556
+ if (options.detached) {
557
+ // Detached mode: Run command in background on remote server using nohup
558
+ // The command will continue running even after SSH connection closes
559
+ const remoteCommand = `nohup ${command} > /tmp/${sessionName}.log 2>&1 &`;
560
+ const sshArgs = [sshTarget, remoteCommand];
561
+
562
+ if (DEBUG) {
563
+ console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
564
+ }
565
+
566
+ const result = spawnSync('ssh', sshArgs, {
567
+ stdio: 'inherit',
568
+ });
569
+
570
+ if (result.error) {
571
+ throw result.error;
572
+ }
573
+
574
+ return Promise.resolve({
575
+ success: true,
576
+ sessionName,
577
+ message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/${sessionName}.log"`,
578
+ });
579
+ } else {
580
+ // Attached mode: Run command interactively over SSH
581
+ // This creates a direct SSH connection and runs the command
582
+ const sshArgs = [sshTarget, command];
583
+
584
+ if (DEBUG) {
585
+ console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
586
+ }
587
+
588
+ return new Promise((resolve) => {
589
+ const child = spawn('ssh', sshArgs, {
590
+ stdio: 'inherit',
591
+ });
592
+
593
+ child.on('exit', (code) => {
594
+ resolve({
595
+ success: code === 0,
596
+ sessionName,
597
+ message: `SSH session "${sessionName}" on ${sshTarget} exited with code ${code}`,
598
+ exitCode: code,
599
+ });
600
+ });
601
+
602
+ child.on('error', (err) => {
603
+ resolve({
604
+ success: false,
605
+ sessionName,
606
+ message: `Failed to start SSH: ${err.message}`,
607
+ });
608
+ });
609
+ });
610
+ }
611
+ } catch (err) {
612
+ return Promise.resolve({
613
+ success: false,
614
+ sessionName,
615
+ message: `Failed to run over SSH: ${err.message}`,
616
+ });
617
+ }
618
+ }
619
+
501
620
  /**
502
621
  * Run command in Docker container
503
622
  * @param {string} command - Command to execute
504
- * @param {object} options - Options (image, session/name, detached, keepAlive, autoRemoveDockerContainer)
623
+ * @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer)
505
624
  * @returns {Promise<{success: boolean, containerName: string, message: string}>}
506
625
  */
507
626
  function runInDocker(command, options = {}) {
@@ -526,7 +645,7 @@ function runInDocker(command, options = {}) {
526
645
 
527
646
  try {
528
647
  if (options.detached) {
529
- // Detached mode: docker run -d --name <name> <image> <shell> -c '<command>'
648
+ // Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
530
649
  // By default (keepAlive=false), the container exits after command completes
531
650
  // With keepAlive=true, we keep the container running with a shell
532
651
  let effectiveCommand = command;
@@ -537,16 +656,7 @@ function runInDocker(command, options = {}) {
537
656
  }
538
657
  // Without keep-alive: container exits naturally when command completes
539
658
 
540
- const dockerArgs = [
541
- 'run',
542
- '-d',
543
- '--name',
544
- containerName,
545
- options.image,
546
- '/bin/sh',
547
- '-c',
548
- effectiveCommand,
549
- ];
659
+ const dockerArgs = ['run', '-d', '--name', containerName];
550
660
 
551
661
  // Add --rm flag if autoRemoveDockerContainer is true
552
662
  // Note: --rm must come before the image name
@@ -554,6 +664,13 @@ function runInDocker(command, options = {}) {
554
664
  dockerArgs.splice(2, 0, '--rm');
555
665
  }
556
666
 
667
+ // Add --user flag if specified
668
+ if (options.user) {
669
+ dockerArgs.push('--user', options.user);
670
+ }
671
+
672
+ dockerArgs.push(options.image, '/bin/sh', '-c', effectiveCommand);
673
+
557
674
  if (DEBUG) {
558
675
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
559
676
  console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
@@ -588,18 +705,15 @@ function runInDocker(command, options = {}) {
588
705
  message,
589
706
  });
590
707
  } else {
591
- // Attached mode: docker run -it --name <name> <image> <shell> -c '<command>'
592
- const dockerArgs = [
593
- 'run',
594
- '-it',
595
- '--rm',
596
- '--name',
597
- containerName,
598
- options.image,
599
- '/bin/sh',
600
- '-c',
601
- command,
602
- ];
708
+ // Attached mode: docker run -it --name <name> [--user <user>] <image> <shell> -c '<command>'
709
+ const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
710
+
711
+ // Add --user flag if specified
712
+ if (options.user) {
713
+ dockerArgs.push('--user', options.user);
714
+ }
715
+
716
+ dockerArgs.push(options.image, '/bin/sh', '-c', command);
603
717
 
604
718
  if (DEBUG) {
605
719
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -639,7 +753,7 @@ function runInDocker(command, options = {}) {
639
753
 
640
754
  /**
641
755
  * Run command in the specified isolation backend
642
- * @param {string} backend - Isolation backend (screen, tmux, docker)
756
+ * @param {string} backend - Isolation backend (screen, tmux, docker, ssh)
643
757
  * @param {string} command - Command to execute
644
758
  * @param {object} options - Options
645
759
  * @returns {Promise<{success: boolean, message: string}>}
@@ -652,6 +766,8 @@ function runIsolated(backend, command, options = {}) {
652
766
  return runInTmux(command, options);
653
767
  case 'docker':
654
768
  return runInDocker(command, options);
769
+ case 'ssh':
770
+ return runInSsh(command, options);
655
771
  default:
656
772
  return Promise.resolve({
657
773
  success: false,
@@ -687,6 +803,7 @@ function generateLogFilename(environment) {
687
803
  * @param {string} params.mode - attached or detached
688
804
  * @param {string} params.sessionName - Session/container name
689
805
  * @param {string} [params.image] - Docker image (for docker environment)
806
+ * @param {string} [params.user] - User to run command as (optional)
690
807
  * @param {string} params.startTime - Start timestamp
691
808
  * @returns {string} Log header content
692
809
  */
@@ -700,6 +817,9 @@ function createLogHeader(params) {
700
817
  if (params.image) {
701
818
  content += `Image: ${params.image}\n`;
702
819
  }
820
+ if (params.user) {
821
+ content += `User: ${params.user}\n`;
822
+ }
703
823
  content += `Platform: ${process.platform}\n`;
704
824
  content += `Node Version: ${process.version}\n`;
705
825
  content += `Working Directory: ${process.cwd()}\n`;
@@ -763,14 +883,47 @@ function resetScreenVersionCache() {
763
883
  screenVersionChecked = false;
764
884
  }
765
885
 
886
+ /**
887
+ * Run command as an isolated user (without isolation backend)
888
+ * Uses sudo -u to switch users
889
+ * @param {string} cmd - Command to execute
890
+ * @param {string} username - User to run as
891
+ * @returns {Promise<{success: boolean, message: string, exitCode: number}>}
892
+ */
893
+ function runAsIsolatedUser(cmd, username) {
894
+ return new Promise((resolve) => {
895
+ const child = spawn('sudo', ['-n', '-u', username, 'sh', '-c', cmd], {
896
+ stdio: 'inherit',
897
+ });
898
+
899
+ child.on('exit', (code) => {
900
+ resolve({
901
+ success: code === 0,
902
+ message: `Command completed as user "${username}" with exit code ${code}`,
903
+ exitCode: code || 0,
904
+ });
905
+ });
906
+
907
+ child.on('error', (err) => {
908
+ resolve({
909
+ success: false,
910
+ message: `Failed to run as user "${username}": ${err.message}`,
911
+ exitCode: 1,
912
+ });
913
+ });
914
+ });
915
+ }
916
+
766
917
  module.exports = {
767
918
  isCommandAvailable,
768
919
  hasTTY,
769
920
  runInScreen,
770
921
  runInTmux,
771
922
  runInDocker,
923
+ runInSsh,
772
924
  runIsolated,
773
- // Export logging utilities for unified experience
925
+ runAsIsolatedUser,
926
+ wrapCommandWithUser,
774
927
  getTimestamp,
775
928
  generateLogFilename,
776
929
  createLogHeader,
@@ -778,7 +931,6 @@ module.exports = {
778
931
  writeLogFile,
779
932
  getLogDir,
780
933
  createLogPath,
781
- // Export screen version utilities for testing and debugging
782
934
  getScreenVersion,
783
935
  supportsLogfileOption,
784
936
  resetScreenVersionCache,