start-command 0.13.0 → 0.15.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +28 -231
  2. package/bun.lock +5 -0
  3. package/eslint.config.mjs +1 -1
  4. package/package.json +11 -6
  5. package/src/bin/cli.js +275 -137
  6. package/src/lib/args-parser.js +118 -0
  7. package/src/lib/execution-store.js +722 -0
  8. package/src/lib/isolation.js +51 -0
  9. package/src/lib/status-formatter.js +121 -0
  10. package/src/lib/version.js +143 -0
  11. package/test/args-parser.test.js +107 -0
  12. package/test/cli.test.js +11 -1
  13. package/test/docker-autoremove.test.js +11 -16
  14. package/test/execution-store.test.js +483 -0
  15. package/test/isolation-cleanup.test.js +11 -16
  16. package/test/isolation.test.js +11 -17
  17. package/test/public-exports.test.js +105 -0
  18. package/test/status-query.test.js +195 -0
  19. package/.github/workflows/release.yml +0 -352
  20. package/.husky/pre-commit +0 -1
  21. package/ARCHITECTURE.md +0 -297
  22. package/LICENSE +0 -24
  23. package/README.md +0 -339
  24. package/REQUIREMENTS.md +0 -299
  25. package/docs/PIPES.md +0 -243
  26. package/docs/USAGE.md +0 -194
  27. package/docs/case-studies/issue-15/README.md +0 -208
  28. package/docs/case-studies/issue-18/README.md +0 -343
  29. package/docs/case-studies/issue-18/issue-comments.json +0 -1
  30. package/docs/case-studies/issue-18/issue-data.json +0 -7
  31. package/docs/case-studies/issue-22/analysis.md +0 -547
  32. package/docs/case-studies/issue-22/issue-data.json +0 -12
  33. package/docs/case-studies/issue-25/README.md +0 -232
  34. package/docs/case-studies/issue-25/issue-data.json +0 -21
  35. package/docs/case-studies/issue-28/README.md +0 -405
  36. package/docs/case-studies/issue-28/issue-data.json +0 -105
  37. package/docs/case-studies/issue-28/raw-issue-data.md +0 -92
  38. package/experiments/debug-regex.js +0 -49
  39. package/experiments/isolation-design.md +0 -131
  40. package/experiments/screen-output-test.js +0 -265
  41. package/experiments/test-cli.sh +0 -42
  42. package/experiments/test-command-stream-cjs.cjs +0 -30
  43. package/experiments/test-command-stream-wrapper.js +0 -54
  44. package/experiments/test-command-stream.mjs +0 -56
  45. package/experiments/test-screen-attached.js +0 -126
  46. package/experiments/test-screen-logfile.js +0 -286
  47. package/experiments/test-screen-modes.js +0 -128
  48. package/experiments/test-screen-output.sh +0 -27
  49. package/experiments/test-screen-tee-debug.js +0 -237
  50. package/experiments/test-screen-tee-fallback.js +0 -230
  51. package/experiments/test-substitution.js +0 -143
  52. package/experiments/user-isolation-research.md +0 -83
  53. package/scripts/changeset-version.mjs +0 -38
  54. package/scripts/check-file-size.mjs +0 -103
  55. package/scripts/create-github-release.mjs +0 -93
  56. package/scripts/create-manual-changeset.mjs +0 -89
  57. package/scripts/format-github-release.mjs +0 -83
  58. package/scripts/format-release-notes.mjs +0 -219
  59. package/scripts/instant-version-bump.mjs +0 -121
  60. package/scripts/publish-to-npm.mjs +0 -129
  61. package/scripts/setup-npm.mjs +0 -37
  62. package/scripts/validate-changeset.mjs +0 -107
  63. package/scripts/version-and-commit.mjs +0 -237
@@ -914,6 +914,56 @@ function runAsIsolatedUser(cmd, username) {
914
914
  });
915
915
  }
916
916
 
917
+ /**
918
+ * Check if the Docker daemon can run Linux container images
919
+ * On Windows with Docker Desktop in Windows containers mode,
920
+ * Linux images like alpine:latest cannot be pulled or run.
921
+ * @returns {boolean} True if Linux Docker images can be run
922
+ */
923
+ function canRunLinuxDockerImages() {
924
+ if (!isCommandAvailable('docker')) {
925
+ return false;
926
+ }
927
+
928
+ try {
929
+ // First check if Docker daemon is running
930
+ execSync('docker info', { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 });
931
+
932
+ // On Windows, check if Docker is configured for Linux containers
933
+ if (process.platform === 'win32') {
934
+ try {
935
+ const osType = execSync('docker info --format "{{.OSType}}"', {
936
+ encoding: 'utf8',
937
+ stdio: ['pipe', 'pipe', 'pipe'],
938
+ timeout: 5000,
939
+ }).trim();
940
+
941
+ // Docker must be using Linux containers to run Linux images
942
+ if (osType !== 'linux') {
943
+ if (DEBUG) {
944
+ console.log(
945
+ `[DEBUG] Docker is running in ${osType} containers mode, cannot run Linux images`
946
+ );
947
+ }
948
+ return false;
949
+ }
950
+ } catch {
951
+ // If we can't determine the OS type, assume Linux images won't work on Windows
952
+ if (DEBUG) {
953
+ console.log(
954
+ '[DEBUG] Could not determine Docker OS type, assuming Linux images unavailable'
955
+ );
956
+ }
957
+ return false;
958
+ }
959
+ }
960
+
961
+ return true;
962
+ } catch {
963
+ return false;
964
+ }
965
+ }
966
+
917
967
  module.exports = {
918
968
  isCommandAvailable,
919
969
  hasTTY,
@@ -934,4 +984,5 @@ module.exports = {
934
984
  getScreenVersion,
935
985
  supportsLogfileOption,
936
986
  resetScreenVersionCache,
987
+ canRunLinuxDockerImages,
937
988
  };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Status formatter module for execution records
3
+ *
4
+ * Provides formatting functions for execution status output in various formats:
5
+ * - Links Notation (links-notation): Structured link doublet format
6
+ * - JSON: Standard JSON output
7
+ * - Text: Human-readable text format
8
+ */
9
+
10
+ /**
11
+ * Format execution record as Links Notation (indented style)
12
+ * @param {Object} record - The execution record with toObject() method
13
+ * @returns {string} Links Notation formatted string in indented style
14
+ *
15
+ * Output format:
16
+ * <uuid>
17
+ * <key> "<value>"
18
+ * ...
19
+ */
20
+ function formatRecordAsLinksNotation(record) {
21
+ const obj = record.toObject();
22
+ const lines = [record.uuid];
23
+
24
+ for (const [key, value] of Object.entries(obj)) {
25
+ if (value !== null && value !== undefined) {
26
+ // Format value based on type
27
+ let formattedValue;
28
+ if (typeof value === 'object') {
29
+ formattedValue = JSON.stringify(value);
30
+ } else {
31
+ formattedValue = String(value);
32
+ }
33
+ // Escape quotes in the value
34
+ const escapedValue = formattedValue.replace(/"/g, '\\"');
35
+ lines.push(` ${key} "${escapedValue}"`);
36
+ }
37
+ }
38
+
39
+ return lines.join('\n');
40
+ }
41
+
42
+ /**
43
+ * Format execution record as human-readable text
44
+ * @param {Object} record - The execution record with toObject() method
45
+ * @returns {string} Human-readable text
46
+ */
47
+ function formatRecordAsText(record) {
48
+ const obj = record.toObject();
49
+ const lines = [
50
+ `Execution Status`,
51
+ `${'='.repeat(50)}`,
52
+ `UUID: ${obj.uuid}`,
53
+ `Status: ${obj.status}`,
54
+ `Command: ${obj.command}`,
55
+ `Exit Code: ${obj.exitCode !== null ? obj.exitCode : 'N/A'}`,
56
+ `PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
57
+ `Working Directory: ${obj.workingDirectory}`,
58
+ `Shell: ${obj.shell}`,
59
+ `Platform: ${obj.platform}`,
60
+ `Start Time: ${obj.startTime}`,
61
+ `End Time: ${obj.endTime || 'N/A'}`,
62
+ `Log Path: ${obj.logPath}`,
63
+ ];
64
+
65
+ if (Object.keys(obj.options).length > 0) {
66
+ lines.push(`Options: ${JSON.stringify(obj.options)}`);
67
+ }
68
+
69
+ return lines.join('\n');
70
+ }
71
+
72
+ /**
73
+ * Format execution record based on format type
74
+ * @param {Object} record - The execution record
75
+ * @param {string} format - Output format (links-notation, json, text)
76
+ * @returns {string} Formatted output string
77
+ */
78
+ function formatRecord(record, format) {
79
+ switch (format) {
80
+ case 'links-notation':
81
+ return formatRecordAsLinksNotation(record);
82
+ case 'json':
83
+ return JSON.stringify(record.toObject(), null, 2);
84
+ case 'text':
85
+ return formatRecordAsText(record);
86
+ default:
87
+ throw new Error(`Unknown output format: ${format}`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Handle status query and output the result
93
+ * @param {Object} store - ExecutionStore instance
94
+ * @param {string} uuid - UUID of the execution to query
95
+ * @param {string|null} outputFormat - Output format (links-notation, json, text)
96
+ * @returns {{success: boolean, output?: string, error?: string}}
97
+ */
98
+ function queryStatus(store, uuid, outputFormat) {
99
+ if (!store) {
100
+ return { success: false, error: 'Execution tracking is disabled.' };
101
+ }
102
+ const record = store.get(uuid);
103
+ if (!record) {
104
+ return { success: false, error: `No execution found with UUID: ${uuid}` };
105
+ }
106
+ try {
107
+ return {
108
+ success: true,
109
+ output: formatRecord(record, outputFormat || 'links-notation'),
110
+ };
111
+ } catch (err) {
112
+ return { success: false, error: err.message };
113
+ }
114
+ }
115
+
116
+ module.exports = {
117
+ formatRecordAsLinksNotation,
118
+ formatRecordAsText,
119
+ formatRecord,
120
+ queryStatus,
121
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Version and tool information utilities
3
+ */
4
+
5
+ const os = require('os');
6
+ const { execSync, spawnSync } = require('child_process');
7
+
8
+ /**
9
+ * Print version information
10
+ * @param {boolean} verbose - Whether to show verbose debugging info
11
+ */
12
+ function printVersion(verbose = false) {
13
+ // Get package version
14
+ const packageJson = require('../../package.json');
15
+ const startCommandVersion = packageJson.version;
16
+
17
+ console.log(`start-command version: ${startCommandVersion}`);
18
+ console.log('');
19
+
20
+ // Get runtime information (Bun or Node.js)
21
+ const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js';
22
+ const runtimeVersion =
23
+ typeof Bun !== 'undefined' ? Bun.version : process.version;
24
+
25
+ // Get OS information
26
+ console.log(`OS: ${process.platform}`);
27
+
28
+ // Get OS version (use sw_vers on macOS for user-friendly version)
29
+ let osVersion = os.release();
30
+ if (process.platform === 'darwin') {
31
+ try {
32
+ osVersion = execSync('sw_vers -productVersion', {
33
+ encoding: 'utf8',
34
+ timeout: 5000,
35
+ }).trim();
36
+ if (verbose) {
37
+ console.log(`[verbose] macOS version from sw_vers: ${osVersion}`);
38
+ }
39
+ } catch {
40
+ // Fallback to kernel version if sw_vers fails
41
+ osVersion = os.release();
42
+ if (verbose) {
43
+ console.log(
44
+ `[verbose] sw_vers failed, using kernel version: ${osVersion}`
45
+ );
46
+ }
47
+ }
48
+ }
49
+
50
+ console.log(`OS Version: ${osVersion}`);
51
+ console.log(`${runtime} Version: ${runtimeVersion}`);
52
+ console.log(`Architecture: ${process.arch}`);
53
+ console.log('');
54
+
55
+ // Check for installed isolation tools
56
+ console.log('Isolation tools:');
57
+
58
+ if (verbose) {
59
+ console.log('[verbose] Checking isolation tools...');
60
+ }
61
+
62
+ // Check screen (use -v flag for compatibility with older versions)
63
+ const screenVersion = getToolVersion('screen', '-v', verbose);
64
+ if (screenVersion) {
65
+ console.log(` screen: ${screenVersion}`);
66
+ } else {
67
+ console.log(' screen: not installed');
68
+ }
69
+
70
+ // Check tmux
71
+ const tmuxVersion = getToolVersion('tmux', '-V', verbose);
72
+ if (tmuxVersion) {
73
+ console.log(` tmux: ${tmuxVersion}`);
74
+ } else {
75
+ console.log(' tmux: not installed');
76
+ }
77
+
78
+ // Check docker
79
+ const dockerVersion = getToolVersion('docker', '--version', verbose);
80
+ if (dockerVersion) {
81
+ console.log(` docker: ${dockerVersion}`);
82
+ } else {
83
+ console.log(' docker: not installed');
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Get version of an installed tool
89
+ * @param {string} toolName - Name of the tool
90
+ * @param {string} versionFlag - Flag to get version (e.g., '--version', '-V')
91
+ * @param {boolean} verbose - Whether to log verbose information
92
+ * @returns {string|null} Version string or null if not installed
93
+ */
94
+ function getToolVersion(toolName, versionFlag, verbose = false) {
95
+ const isWindows = process.platform === 'win32';
96
+ const whichCmd = isWindows ? 'where' : 'which';
97
+
98
+ // First, check if the tool exists in PATH
99
+ try {
100
+ execSync(`${whichCmd} ${toolName}`, {
101
+ encoding: 'utf8',
102
+ timeout: 5000,
103
+ stdio: ['pipe', 'pipe', 'pipe'],
104
+ });
105
+ } catch {
106
+ // Tool not found in PATH
107
+ if (verbose) {
108
+ console.log(`[verbose] ${toolName}: not found in PATH`);
109
+ }
110
+ return null;
111
+ }
112
+
113
+ // Tool exists, try to get version using spawnSync
114
+ // This captures output regardless of exit code (some tools like older screen
115
+ // versions return non-zero exit code even when showing version successfully)
116
+ const result = spawnSync(toolName, [versionFlag], {
117
+ encoding: 'utf8',
118
+ timeout: 5000,
119
+ shell: false,
120
+ });
121
+
122
+ // Combine stdout and stderr (some tools output version to stderr)
123
+ const output = ((result.stdout || '') + (result.stderr || '')).trim();
124
+
125
+ if (verbose) {
126
+ console.log(
127
+ `[verbose] ${toolName} ${versionFlag}: exit=${result.status}, output="${output.substring(0, 100)}"`
128
+ );
129
+ }
130
+
131
+ if (!output) {
132
+ return null;
133
+ }
134
+
135
+ // Return the first line of output
136
+ const firstLine = output.split('\n')[0];
137
+ return firstLine || null;
138
+ }
139
+
140
+ module.exports = {
141
+ printVersion,
142
+ getToolVersion,
143
+ };
@@ -13,6 +13,7 @@ const {
13
13
  hasIsolation,
14
14
  getEffectiveMode,
15
15
  VALID_BACKENDS,
16
+ VALID_OUTPUT_FORMATS,
16
17
  } = require('../src/lib/args-parser');
17
18
 
18
19
  describe('parseArgs', () => {
@@ -782,3 +783,109 @@ describe('keep-user option', () => {
782
783
  assert.strictEqual(result.wrapperOptions.keepUser, true);
783
784
  });
784
785
  });
786
+
787
+ describe('status option', () => {
788
+ it('should parse --status with UUID', () => {
789
+ const uuid = '12345678-1234-1234-1234-123456789abc';
790
+ const result = parseArgs(['--status', uuid]);
791
+ assert.strictEqual(result.wrapperOptions.status, uuid);
792
+ assert.strictEqual(result.command, '');
793
+ });
794
+
795
+ it('should parse --status=value format', () => {
796
+ const uuid = '12345678-1234-1234-1234-123456789abc';
797
+ const result = parseArgs([`--status=${uuid}`]);
798
+ assert.strictEqual(result.wrapperOptions.status, uuid);
799
+ });
800
+
801
+ it('should throw error for missing UUID argument', () => {
802
+ assert.throws(() => {
803
+ parseArgs(['--status']);
804
+ }, /requires a UUID argument/);
805
+ });
806
+
807
+ it('should throw error for --status with -flag as next argument', () => {
808
+ assert.throws(() => {
809
+ parseArgs(['--status', '--output-format']);
810
+ }, /requires a UUID argument/);
811
+ });
812
+
813
+ it('should default status to null', () => {
814
+ const result = parseArgs(['echo', 'hello']);
815
+ assert.strictEqual(result.wrapperOptions.status, null);
816
+ });
817
+ });
818
+
819
+ describe('output-format option', () => {
820
+ it('should parse --output-format with --status', () => {
821
+ const uuid = '12345678-1234-1234-1234-123456789abc';
822
+ const result = parseArgs([
823
+ '--status',
824
+ uuid,
825
+ '--output-format',
826
+ 'links-notation',
827
+ ]);
828
+ assert.strictEqual(result.wrapperOptions.status, uuid);
829
+ assert.strictEqual(result.wrapperOptions.outputFormat, 'links-notation');
830
+ });
831
+
832
+ it('should parse --output-format=value format', () => {
833
+ const uuid = '12345678-1234-1234-1234-123456789abc';
834
+ const result = parseArgs(['--status', uuid, '--output-format=json']);
835
+ assert.strictEqual(result.wrapperOptions.outputFormat, 'json');
836
+ });
837
+
838
+ it('should normalize format to lowercase', () => {
839
+ const uuid = '12345678-1234-1234-1234-123456789abc';
840
+ const result = parseArgs(['--status', uuid, '--output-format', 'JSON']);
841
+ assert.strictEqual(result.wrapperOptions.outputFormat, 'json');
842
+ });
843
+
844
+ it('should throw error for missing format argument', () => {
845
+ const uuid = '12345678-1234-1234-1234-123456789abc';
846
+ assert.throws(() => {
847
+ parseArgs(['--status', uuid, '--output-format']);
848
+ }, /requires a format argument/);
849
+ });
850
+
851
+ it('should throw error for invalid format', () => {
852
+ const uuid = '12345678-1234-1234-1234-123456789abc';
853
+ assert.throws(() => {
854
+ parseArgs(['--status', uuid, '--output-format', 'invalid']);
855
+ }, /Invalid output format/);
856
+ });
857
+
858
+ it('should throw error for output-format without status', () => {
859
+ assert.throws(() => {
860
+ parseArgs(['--output-format', 'json', '--', 'npm', 'test']);
861
+ }, /--output-format option is only valid with --status/);
862
+ });
863
+
864
+ it('should accept all valid output formats', () => {
865
+ const uuid = '12345678-1234-1234-1234-123456789abc';
866
+ for (const format of VALID_OUTPUT_FORMATS) {
867
+ assert.doesNotThrow(() => {
868
+ parseArgs(['--status', uuid, '--output-format', format]);
869
+ });
870
+ }
871
+ });
872
+
873
+ it('should default outputFormat to null', () => {
874
+ const result = parseArgs(['echo', 'hello']);
875
+ assert.strictEqual(result.wrapperOptions.outputFormat, null);
876
+ });
877
+ });
878
+
879
+ describe('VALID_OUTPUT_FORMATS', () => {
880
+ it('should include links-notation', () => {
881
+ assert.ok(VALID_OUTPUT_FORMATS.includes('links-notation'));
882
+ });
883
+
884
+ it('should include json', () => {
885
+ assert.ok(VALID_OUTPUT_FORMATS.includes('json'));
886
+ });
887
+
888
+ it('should include text', () => {
889
+ assert.ok(VALID_OUTPUT_FORMATS.includes('text'));
890
+ });
891
+ });
package/test/cli.test.js CHANGED
@@ -13,10 +13,14 @@ const fs = require('fs');
13
13
  // Path to the CLI script
14
14
  const CLI_PATH = path.join(__dirname, '../src/bin/cli.js');
15
15
 
16
- // Helper to run CLI
16
+ // Timeout for CLI operations - longer on Windows due to cold-start latency
17
+ const CLI_TIMEOUT = process.platform === 'win32' ? 30000 : 10000;
18
+
19
+ // Helper to run CLI with timeout
17
20
  function runCLI(args = []) {
18
21
  return spawnSync('bun', [CLI_PATH, ...args], {
19
22
  encoding: 'utf8',
23
+ timeout: CLI_TIMEOUT,
20
24
  env: {
21
25
  ...process.env,
22
26
  START_DISABLE_AUTO_ISSUE: '1',
@@ -29,6 +33,12 @@ describe('CLI version flag', () => {
29
33
  it('should display version with --version', () => {
30
34
  const result = runCLI(['--version']);
31
35
 
36
+ // Check if process was killed (e.g., due to timeout)
37
+ assert.notStrictEqual(
38
+ result.status,
39
+ null,
40
+ `Process should complete (was killed with signal: ${result.signal})`
41
+ );
32
42
  assert.strictEqual(result.status, 0, 'Exit code should be 0');
33
43
 
34
44
  // Check for key elements in version output
@@ -21,18 +21,9 @@ async function waitFor(conditionFn, timeout = 5000, interval = 100) {
21
21
  return false;
22
22
  }
23
23
 
24
- // Helper function to check if docker daemon is running
25
- function isDockerRunning() {
26
- if (!isCommandAvailable('docker')) {
27
- return false;
28
- }
29
- try {
30
- execSync('docker info', { stdio: 'ignore', timeout: 5000 });
31
- return true;
32
- } catch {
33
- return false;
34
- }
35
- }
24
+ // Use the canRunLinuxDockerImages function from isolation module
25
+ // to properly detect if Linux containers can run (handles Windows containers mode)
26
+ const { canRunLinuxDockerImages } = require('../src/lib/isolation');
36
27
 
37
28
  describe('Docker Auto-Remove Container Feature', () => {
38
29
  // These tests verify the --auto-remove-docker-container option
@@ -40,8 +31,10 @@ describe('Docker Auto-Remove Container Feature', () => {
40
31
 
41
32
  describe('auto-remove enabled', () => {
42
33
  it('should automatically remove container when autoRemoveDockerContainer is true', async () => {
43
- if (!isDockerRunning()) {
44
- console.log(' Skipping: docker not available or daemon not running');
34
+ if (!canRunLinuxDockerImages()) {
35
+ console.log(
36
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
37
+ );
45
38
  return;
46
39
  }
47
40
 
@@ -103,8 +96,10 @@ describe('Docker Auto-Remove Container Feature', () => {
103
96
 
104
97
  describe('auto-remove disabled (default)', () => {
105
98
  it('should preserve container filesystem by default (without autoRemoveDockerContainer)', async () => {
106
- if (!isDockerRunning()) {
107
- console.log(' Skipping: docker not available or daemon not running');
99
+ if (!canRunLinuxDockerImages()) {
100
+ console.log(
101
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
102
+ );
108
103
  return;
109
104
  }
110
105