start-command 0.20.4 → 0.22.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.
@@ -0,0 +1,147 @@
1
+ /** Log utility functions for isolation runners */
2
+
3
+ const { spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ /**
9
+ * Generate timestamp for logging
10
+ * @returns {string} ISO timestamp without 'T' and 'Z'
11
+ */
12
+ function getTimestamp() {
13
+ return new Date().toISOString().replace('T', ' ').replace('Z', '');
14
+ }
15
+
16
+ /**
17
+ * Generate unique log filename
18
+ * @param {string} environment - The isolation environment name
19
+ * @returns {string} Log filename
20
+ */
21
+ function generateLogFilename(environment) {
22
+ const timestamp = Date.now();
23
+ const random = Math.random().toString(36).substring(2, 8);
24
+ return `start-command-${environment}-${timestamp}-${random}.log`;
25
+ }
26
+
27
+ /**
28
+ * Create log content header
29
+ * @param {object} params - Log parameters
30
+ * @param {string} params.command - The command being executed
31
+ * @param {string} params.environment - The isolation environment
32
+ * @param {string} params.mode - attached or detached
33
+ * @param {string} params.sessionName - Session/container name
34
+ * @param {string} [params.image] - Docker image (for docker environment)
35
+ * @param {string} [params.user] - User to run command as (optional)
36
+ * @param {string} params.startTime - Start timestamp
37
+ * @returns {string} Log header content
38
+ */
39
+ function createLogHeader(params) {
40
+ let content = `=== Start Command Log ===\n`;
41
+ content += `Timestamp: ${params.startTime}\n`;
42
+ content += `Command: ${params.command}\n`;
43
+ content += `Environment: ${params.environment}\n`;
44
+ content += `Mode: ${params.mode}\n`;
45
+ content += `Session: ${params.sessionName}\n`;
46
+ if (params.image) {
47
+ content += `Image: ${params.image}\n`;
48
+ }
49
+ if (params.user) {
50
+ content += `User: ${params.user}\n`;
51
+ }
52
+ content += `Platform: ${process.platform}\n`;
53
+ content += `Node Version: ${process.version}\n`;
54
+ content += `Working Directory: ${process.cwd()}\n`;
55
+ content += `${'='.repeat(50)}\n\n`;
56
+ return content;
57
+ }
58
+
59
+ /**
60
+ * Create log content footer
61
+ * @param {string} endTime - End timestamp
62
+ * @param {number} exitCode - Exit code
63
+ * @returns {string} Log footer content
64
+ */
65
+ function createLogFooter(endTime, exitCode) {
66
+ let content = `\n${'='.repeat(50)}\n`;
67
+ content += `Finished: ${endTime}\n`;
68
+ content += `Exit Code: ${exitCode}\n`;
69
+ return content;
70
+ }
71
+
72
+ /**
73
+ * Write log file
74
+ * @param {string} logPath - Path to log file
75
+ * @param {string} content - Log content
76
+ * @returns {boolean} Success status
77
+ */
78
+ function writeLogFile(logPath, content) {
79
+ try {
80
+ fs.writeFileSync(logPath, content, 'utf8');
81
+ return true;
82
+ } catch (err) {
83
+ console.error(`\nWarning: Could not save log file: ${err.message}`);
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get log directory from environment or use system temp
90
+ * @returns {string} Log directory path
91
+ */
92
+ function getLogDir() {
93
+ return process.env.START_LOG_DIR || os.tmpdir();
94
+ }
95
+
96
+ /**
97
+ * Create log file path
98
+ * @param {string} environment - The isolation environment
99
+ * @returns {string} Full path to log file
100
+ */
101
+ function createLogPath(environment) {
102
+ const logDir = getLogDir();
103
+ const logFilename = generateLogFilename(environment);
104
+ return path.join(logDir, logFilename);
105
+ }
106
+
107
+ /**
108
+ * Run command as an isolated user (without isolation environment)
109
+ * Uses sudo -u to switch users
110
+ * @param {string} cmd - Command to execute
111
+ * @param {string} username - User to run as
112
+ * @returns {Promise<{success: boolean, message: string, exitCode: number}>}
113
+ */
114
+ function runAsIsolatedUser(cmd, username) {
115
+ return new Promise((resolve) => {
116
+ const child = spawn('sudo', ['-n', '-u', username, 'sh', '-c', cmd], {
117
+ stdio: 'inherit',
118
+ });
119
+
120
+ child.on('exit', (code) => {
121
+ resolve({
122
+ success: code === 0,
123
+ message: `Command completed as user "${username}" with exit code ${code}`,
124
+ exitCode: code || 0,
125
+ });
126
+ });
127
+
128
+ child.on('error', (err) => {
129
+ resolve({
130
+ success: false,
131
+ message: `Failed to run as user "${username}": ${err.message}`,
132
+ exitCode: 1,
133
+ });
134
+ });
135
+ });
136
+ }
137
+
138
+ module.exports = {
139
+ getTimestamp,
140
+ generateLogFilename,
141
+ createLogHeader,
142
+ createLogFooter,
143
+ writeLogFile,
144
+ getLogDir,
145
+ createLogPath,
146
+ runAsIsolatedUser,
147
+ };
@@ -116,6 +116,101 @@ function getShell() {
116
116
  return { shell, shellArg };
117
117
  }
118
118
 
119
+ /**
120
+ * Detect the best available shell in an isolation environment (docker/ssh)
121
+ * Tries shells in order: bash, zsh, sh
122
+ * @param {'docker'|'ssh'} environment - Isolation environment type
123
+ * @param {object} options - Options for the isolation environment
124
+ * @param {string} [shellPreference='auto'] - Shell preference: 'auto', 'bash', 'zsh', 'sh'
125
+ * @returns {string} Shell path to use
126
+ */
127
+ function detectShellInEnvironment(
128
+ environment,
129
+ options,
130
+ shellPreference = 'auto'
131
+ ) {
132
+ // If a specific shell is requested (not auto), use it directly
133
+ if (shellPreference && shellPreference !== 'auto') {
134
+ if (DEBUG) {
135
+ console.log(`[DEBUG] Using forced shell: ${shellPreference}`);
136
+ }
137
+ return shellPreference;
138
+ }
139
+
140
+ // In auto mode, try shells in order of preference
141
+ const shellsToTry = ['bash', 'zsh', 'sh'];
142
+
143
+ if (environment === 'docker') {
144
+ const image = options.image;
145
+ if (!image) {
146
+ return 'sh';
147
+ }
148
+
149
+ for (const shell of shellsToTry) {
150
+ try {
151
+ const result = spawnSync(
152
+ 'docker',
153
+ ['run', '--rm', image, 'sh', '-c', `command -v ${shell}`],
154
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
155
+ );
156
+ if (result.status === 0 && result.stdout.trim()) {
157
+ if (DEBUG) {
158
+ console.log(
159
+ `[DEBUG] Detected shell in docker image ${image}: ${result.stdout.trim()}`
160
+ );
161
+ }
162
+ return result.stdout.trim();
163
+ }
164
+ } catch {
165
+ // Continue to next shell
166
+ }
167
+ }
168
+
169
+ if (DEBUG) {
170
+ console.log(
171
+ `[DEBUG] Could not detect shell in docker image ${image}, falling back to sh`
172
+ );
173
+ }
174
+ return 'sh';
175
+ }
176
+
177
+ if (environment === 'ssh') {
178
+ const endpoint = options.endpoint;
179
+ if (!endpoint) {
180
+ return 'sh';
181
+ }
182
+
183
+ try {
184
+ // Run a single SSH command to check for available shells in order
185
+ const checkCmd = shellsToTry.map((s) => `command -v ${s}`).join(' || ');
186
+ const result = spawnSync('ssh', [endpoint, checkCmd], {
187
+ encoding: 'utf8',
188
+ stdio: ['pipe', 'pipe', 'pipe'],
189
+ });
190
+ if (result.status === 0 && result.stdout.trim()) {
191
+ const detected = result.stdout.trim();
192
+ if (DEBUG) {
193
+ console.log(
194
+ `[DEBUG] Detected shell on SSH host ${endpoint}: ${detected}`
195
+ );
196
+ }
197
+ return detected;
198
+ }
199
+ } catch {
200
+ // Fall through to default
201
+ }
202
+
203
+ if (DEBUG) {
204
+ console.log(
205
+ `[DEBUG] Could not detect shell on SSH host ${endpoint}, falling back to sh`
206
+ );
207
+ }
208
+ return 'sh';
209
+ }
210
+
211
+ return 'sh';
212
+ }
213
+
119
214
  /**
120
215
  * Check if the current process has a TTY attached
121
216
  * @returns {boolean} True if TTY is available
@@ -553,15 +648,25 @@ function runInSsh(command, options = {}) {
553
648
  const sessionName = options.session || generateSessionName('ssh');
554
649
  const sshTarget = options.endpoint;
555
650
 
651
+ // Detect the shell to use on the remote host
652
+ // In auto mode, detection may fall back to passing command directly to leverage
653
+ // the remote user's default login shell (which may already be bash)
654
+ const shellToUse = detectShellInEnvironment('ssh', options, options.shell);
655
+ // Whether to wrap command with a shell (only when explicit shell is specified)
656
+ const useExplicitShell =
657
+ options.shell && options.shell !== 'auto' ? shellToUse : null;
658
+
556
659
  try {
557
660
  if (options.detached) {
558
661
  // Detached mode: Run command in background on remote server using nohup
559
662
  // The command will continue running even after SSH connection closes
560
- const remoteCommand = `nohup ${command} > /tmp/${sessionName}.log 2>&1 &`;
663
+ const remoteShell = useExplicitShell || shellToUse;
664
+ const remoteCommand = `nohup ${remoteShell} -c ${JSON.stringify(command)} > /tmp/${sessionName}.log 2>&1 &`;
561
665
  const sshArgs = [sshTarget, remoteCommand];
562
666
 
563
667
  if (DEBUG) {
564
668
  console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
669
+ console.log(`[DEBUG] shell: ${remoteShell}`);
565
670
  }
566
671
 
567
672
  const result = spawnSync('ssh', sshArgs, {
@@ -579,11 +684,15 @@ function runInSsh(command, options = {}) {
579
684
  });
580
685
  } else {
581
686
  // Attached mode: Run command interactively over SSH
582
- // This creates a direct SSH connection and runs the command
583
- const sshArgs = [sshTarget, command];
687
+ // When a specific shell is requested, wrap the command with that shell.
688
+ // In auto mode, pass the command directly and let the remote's default shell handle it.
689
+ const sshArgs = useExplicitShell
690
+ ? [sshTarget, useExplicitShell, '-c', command]
691
+ : [sshTarget, command];
584
692
 
585
693
  if (DEBUG) {
586
694
  console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
695
+ console.log(`[DEBUG] shell: ${shellToUse}`);
587
696
  }
588
697
 
589
698
  return new Promise((resolve) => {
@@ -660,6 +769,9 @@ function runInDocker(command, options = {}) {
660
769
  }
661
770
  }
662
771
 
772
+ // Detect the shell to use in the container
773
+ const shellToUse = detectShellInEnvironment('docker', options, options.shell);
774
+
663
775
  // Print the user command (this appears after any virtual commands like docker pull)
664
776
  const { createCommandLine } = require('./output-blocks');
665
777
  console.log(createCommandLine(command));
@@ -674,7 +786,7 @@ function runInDocker(command, options = {}) {
674
786
 
675
787
  if (options.keepAlive) {
676
788
  // With keep-alive: run command, then keep shell alive
677
- effectiveCommand = `${command}; exec /bin/sh`;
789
+ effectiveCommand = `${command}; exec ${shellToUse}`;
678
790
  }
679
791
  // Without keep-alive: container exits naturally when command completes
680
792
 
@@ -691,10 +803,11 @@ function runInDocker(command, options = {}) {
691
803
  dockerArgs.push('--user', options.user);
692
804
  }
693
805
 
694
- dockerArgs.push(options.image, '/bin/sh', '-c', effectiveCommand);
806
+ dockerArgs.push(options.image, shellToUse, '-c', effectiveCommand);
695
807
 
696
808
  if (DEBUG) {
697
809
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
810
+ console.log(`[DEBUG] shell: ${shellToUse}`);
698
811
  console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
699
812
  console.log(
700
813
  `[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}`
@@ -735,7 +848,11 @@ function runInDocker(command, options = {}) {
735
848
  dockerArgs.push('--user', options.user);
736
849
  }
737
850
 
738
- dockerArgs.push(options.image, '/bin/sh', '-c', command);
851
+ if (DEBUG) {
852
+ console.log(`[DEBUG] shell: ${shellToUse}`);
853
+ }
854
+
855
+ dockerArgs.push(options.image, shellToUse, '-c', command);
739
856
 
740
857
  if (DEBUG) {
741
858
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
@@ -775,21 +892,48 @@ function runInDocker(command, options = {}) {
775
892
 
776
893
  /**
777
894
  * Run command in the specified isolation environment
895
+ * Supports stacked isolation where each level calls $ with remaining levels
778
896
  * @param {string} backend - Isolation environment (screen, tmux, docker, ssh)
779
897
  * @param {string} command - Command to execute
780
898
  * @param {object} options - Options
781
899
  * @returns {Promise<{success: boolean, message: string}>}
782
900
  */
783
901
  function runIsolated(backend, command, options = {}) {
902
+ // If stacked isolation, build the command for next level
903
+ let effectiveCommand = command;
904
+
905
+ if (options.isolatedStack && options.isolatedStack.length > 1) {
906
+ // Lazy load to avoid circular dependency
907
+ const { buildNextLevelCommand } = require('./command-builder');
908
+ effectiveCommand = buildNextLevelCommand(options, command);
909
+
910
+ if (DEBUG) {
911
+ console.log(
912
+ `[DEBUG] Stacked isolation - level command: ${effectiveCommand}`
913
+ );
914
+ }
915
+ }
916
+
917
+ // Get current level option values
918
+ const currentOptions = {
919
+ ...options,
920
+ // Use current level values from stacks
921
+ image: options.imageStack ? options.imageStack[0] : options.image,
922
+ endpoint: options.endpointStack
923
+ ? options.endpointStack[0]
924
+ : options.endpoint,
925
+ session: options.sessionStack ? options.sessionStack[0] : options.session,
926
+ };
927
+
784
928
  switch (backend) {
785
929
  case 'screen':
786
- return runInScreen(command, options);
930
+ return runInScreen(effectiveCommand, currentOptions);
787
931
  case 'tmux':
788
- return runInTmux(command, options);
932
+ return runInTmux(effectiveCommand, currentOptions);
789
933
  case 'docker':
790
- return runInDocker(command, options);
934
+ return runInDocker(effectiveCommand, currentOptions);
791
935
  case 'ssh':
792
- return runInSsh(command, options);
936
+ return runInSsh(effectiveCommand, currentOptions);
793
937
  default:
794
938
  return Promise.resolve({
795
939
  success: false,
@@ -798,105 +942,6 @@ function runIsolated(backend, command, options = {}) {
798
942
  }
799
943
  }
800
944
 
801
- /**
802
- * Generate timestamp for logging
803
- * @returns {string} ISO timestamp without 'T' and 'Z'
804
- */
805
- function getTimestamp() {
806
- return new Date().toISOString().replace('T', ' ').replace('Z', '');
807
- }
808
-
809
- /**
810
- * Generate unique log filename
811
- * @param {string} environment - The isolation environment name
812
- * @returns {string} Log filename
813
- */
814
- function generateLogFilename(environment) {
815
- const timestamp = Date.now();
816
- const random = Math.random().toString(36).substring(2, 8);
817
- return `start-command-${environment}-${timestamp}-${random}.log`;
818
- }
819
-
820
- /**
821
- * Create log content header
822
- * @param {object} params - Log parameters
823
- * @param {string} params.command - The command being executed
824
- * @param {string} params.environment - The isolation environment
825
- * @param {string} params.mode - attached or detached
826
- * @param {string} params.sessionName - Session/container name
827
- * @param {string} [params.image] - Docker image (for docker environment)
828
- * @param {string} [params.user] - User to run command as (optional)
829
- * @param {string} params.startTime - Start timestamp
830
- * @returns {string} Log header content
831
- */
832
- function createLogHeader(params) {
833
- let content = `=== Start Command Log ===\n`;
834
- content += `Timestamp: ${params.startTime}\n`;
835
- content += `Command: ${params.command}\n`;
836
- content += `Environment: ${params.environment}\n`;
837
- content += `Mode: ${params.mode}\n`;
838
- content += `Session: ${params.sessionName}\n`;
839
- if (params.image) {
840
- content += `Image: ${params.image}\n`;
841
- }
842
- if (params.user) {
843
- content += `User: ${params.user}\n`;
844
- }
845
- content += `Platform: ${process.platform}\n`;
846
- content += `Node Version: ${process.version}\n`;
847
- content += `Working Directory: ${process.cwd()}\n`;
848
- content += `${'='.repeat(50)}\n\n`;
849
- return content;
850
- }
851
-
852
- /**
853
- * Create log content footer
854
- * @param {string} endTime - End timestamp
855
- * @param {number} exitCode - Exit code
856
- * @returns {string} Log footer content
857
- */
858
- function createLogFooter(endTime, exitCode) {
859
- let content = `\n${'='.repeat(50)}\n`;
860
- content += `Finished: ${endTime}\n`;
861
- content += `Exit Code: ${exitCode}\n`;
862
- return content;
863
- }
864
-
865
- /**
866
- * Write log file
867
- * @param {string} logPath - Path to log file
868
- * @param {string} content - Log content
869
- * @returns {boolean} Success status
870
- */
871
- function writeLogFile(logPath, content) {
872
- try {
873
- fs.writeFileSync(logPath, content, 'utf8');
874
- return true;
875
- } catch (err) {
876
- console.error(`\nWarning: Could not save log file: ${err.message}`);
877
- return false;
878
- }
879
- }
880
-
881
- /**
882
- * Get log directory from environment or use system temp
883
- * @returns {string} Log directory path
884
- */
885
- function getLogDir() {
886
- return process.env.START_LOG_DIR || os.tmpdir();
887
- }
888
-
889
- /**
890
- * Create log file path
891
- * @param {string} environment - The isolation environment
892
- * @returns {string} Full path to log file
893
- */
894
- function createLogPath(environment) {
895
- const logDir = getLogDir();
896
- const logFilename = generateLogFilename(environment);
897
- return path.join(logDir, logFilename);
898
- }
899
-
900
945
  /**
901
946
  * Reset screen version cache (useful for testing)
902
947
  */
@@ -905,36 +950,17 @@ function resetScreenVersionCache() {
905
950
  screenVersionChecked = false;
906
951
  }
907
952
 
908
- /**
909
- * Run command as an isolated user (without isolation environment)
910
- * Uses sudo -u to switch users
911
- * @param {string} cmd - Command to execute
912
- * @param {string} username - User to run as
913
- * @returns {Promise<{success: boolean, message: string, exitCode: number}>}
914
- */
915
- function runAsIsolatedUser(cmd, username) {
916
- return new Promise((resolve) => {
917
- const child = spawn('sudo', ['-n', '-u', username, 'sh', '-c', cmd], {
918
- stdio: 'inherit',
919
- });
920
-
921
- child.on('exit', (code) => {
922
- resolve({
923
- success: code === 0,
924
- message: `Command completed as user "${username}" with exit code ${code}`,
925
- exitCode: code || 0,
926
- });
927
- });
928
-
929
- child.on('error', (err) => {
930
- resolve({
931
- success: false,
932
- message: `Failed to run as user "${username}": ${err.message}`,
933
- exitCode: 1,
934
- });
935
- });
936
- });
937
- }
953
+ // Log utilities and runAsIsolatedUser extracted to isolation-log-utils.js
954
+ const {
955
+ getTimestamp,
956
+ generateLogFilename,
957
+ createLogHeader,
958
+ createLogFooter,
959
+ writeLogFile,
960
+ getLogDir,
961
+ createLogPath,
962
+ runAsIsolatedUser,
963
+ } = require('./isolation-log-utils');
938
964
 
939
965
  // Re-export docker utilities from docker-utils for backwards compatibility
940
966
  const {
@@ -945,6 +971,7 @@ const {
945
971
  module.exports = {
946
972
  isCommandAvailable,
947
973
  hasTTY,
974
+ detectShellInEnvironment,
948
975
  runInScreen,
949
976
  runInTmux,
950
977
  runInDocker,