start-command 0.24.6 → 0.24.8

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,31 @@
1
1
  # start-command
2
2
 
3
+ ## 0.24.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 1195fc1: Add CI/CD coverage enforcement and Rust/JS test parity checks (issue #93)
8
+ - Add `scripts/check-test-parity.mjs` script to enforce Rust/JS test count within 10%
9
+ - Add coverage job to JavaScript CI/CD workflow (80% minimum threshold)
10
+ - Update `ARCHITECTURE.md` to document dual-language sync requirements
11
+ - Update `REQUIREMENTS.md` to document test coverage requirements and parity rules
12
+
13
+ ## 0.24.7
14
+
15
+ ### Patch Changes
16
+
17
+ - 1eef620: fix: display `bash -c "..."` commands with quotes in command line output (issue #91)
18
+
19
+ When a command like `bash -i -c nvm --version` was passed to Docker isolation,
20
+ the displayed command line was missing quotes around the `-c` script argument,
21
+ making the output misleading (showing `bash -i -c nvm --version` instead of
22
+ `bash -i -c "nvm --version"`).
23
+
24
+ A new `buildDisplayCommand()` helper is added in `shell-utils.js` that quotes
25
+ any space-containing `-c` script arguments so the displayed command accurately
26
+ reflects how it was interpreted. Shell command helpers are extracted from
27
+ `isolation.js` into a new `shell-utils.js` module to keep file sizes within limits.
28
+
3
29
  ## 0.24.6
4
30
 
5
31
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.24.6",
3
+ "version": "0.24.8",
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": {
@@ -214,29 +214,12 @@ function getShellInteractiveFlag(shellPath) {
214
214
  const shellName = shellPath.split('/').pop();
215
215
  return shellName === 'bash' || shellName === 'zsh' ? '-i' : null;
216
216
  }
217
- const SHELL_NAMES = ['bash', 'zsh', 'sh', 'fish', 'ksh', 'csh', 'tcsh', 'dash'];
218
- /** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
219
- function isInteractiveShellCommand(command) {
220
- const parts = command.trim().split(/\s+/);
221
- return SHELL_NAMES.includes(path.basename(parts[0])) && !parts.includes('-c');
222
- }
223
- /** True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91). */
224
- function isShellInvocationWithArgs(command) {
225
- const parts = command.trim().split(/\s+/);
226
- return SHELL_NAMES.includes(path.basename(parts[0])) && parts.includes('-c');
227
- }
228
- /** Build argv for shell-with-c command; everything after -c is one argument (reverses commandArgs.join(' ')). */
229
- function buildShellWithArgsCmdArgs(command) {
230
- const parts = command.trim().split(/\s+/);
231
- const cIdx = parts.indexOf('-c');
232
- if (cIdx === -1) {
233
- return parts;
234
- }
235
- const scriptArg = parts.slice(cIdx + 1).join(' ');
236
- return scriptArg.length > 0
237
- ? [...parts.slice(0, cIdx + 1), scriptArg]
238
- : parts.slice(0, cIdx + 1);
239
- }
217
+ const {
218
+ isInteractiveShellCommand,
219
+ isShellInvocationWithArgs,
220
+ buildShellWithArgsCmdArgs,
221
+ buildDisplayCommand,
222
+ } = require('./shell-utils');
240
223
 
241
224
  /** Returns true if the current process has a TTY attached. */
242
225
  function hasTTY() {
@@ -764,7 +747,7 @@ function runInDocker(command, options = {}) {
764
747
  : detectShellInEnvironment('docker', options, options.shell);
765
748
  const shellInteractiveFlag = getShellInteractiveFlag(shellToUse);
766
749
 
767
- console.log(outputBlocks.createCommandLine(command));
750
+ console.log(outputBlocks.createCommandLine(buildDisplayCommand(command)));
768
751
  console.log();
769
752
 
770
753
  try {
@@ -974,6 +957,7 @@ module.exports = {
974
957
  isInteractiveShellCommand,
975
958
  isShellInvocationWithArgs,
976
959
  buildShellWithArgsCmdArgs,
960
+ buildDisplayCommand,
977
961
  detectShellInEnvironment,
978
962
  runInScreen,
979
963
  runInTmux,
@@ -0,0 +1,47 @@
1
+ /** Shell command detection and argument-building utilities for start-command */
2
+
3
+ const path = require('path');
4
+
5
+ const SHELL_NAMES = ['bash', 'zsh', 'sh', 'fish', 'ksh', 'csh', 'tcsh', 'dash'];
6
+
7
+ /** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
8
+ function isInteractiveShellCommand(command) {
9
+ const parts = command.trim().split(/\s+/);
10
+ return SHELL_NAMES.includes(path.basename(parts[0])) && !parts.includes('-c');
11
+ }
12
+
13
+ /** True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91). */
14
+ function isShellInvocationWithArgs(command) {
15
+ const parts = command.trim().split(/\s+/);
16
+ return SHELL_NAMES.includes(path.basename(parts[0])) && parts.includes('-c');
17
+ }
18
+
19
+ /** Build argv for shell-with-c command; everything after -c is one argument (reverses commandArgs.join(' ')). */
20
+ function buildShellWithArgsCmdArgs(command) {
21
+ const parts = command.trim().split(/\s+/);
22
+ const cIdx = parts.indexOf('-c');
23
+ if (cIdx === -1) {
24
+ return parts;
25
+ }
26
+ const scriptArg = parts.slice(cIdx + 1).join(' ');
27
+ return scriptArg.length > 0
28
+ ? [...parts.slice(0, cIdx + 1), scriptArg]
29
+ : parts.slice(0, cIdx + 1);
30
+ }
31
+
32
+ /** Build a display string for a command, quoting arguments that contain spaces (issue #91). */
33
+ function buildDisplayCommand(command) {
34
+ if (!isShellInvocationWithArgs(command)) {
35
+ return command;
36
+ }
37
+ const argv = buildShellWithArgsCmdArgs(command);
38
+ return argv.map((arg) => (arg.includes(' ') ? `"${arg}"` : arg)).join(' ');
39
+ }
40
+
41
+ module.exports = {
42
+ SHELL_NAMES,
43
+ isInteractiveShellCommand,
44
+ isShellInvocationWithArgs,
45
+ buildShellWithArgsCmdArgs,
46
+ buildDisplayCommand,
47
+ };
@@ -24,6 +24,7 @@ const path = require('path');
24
24
  const {
25
25
  isCommandAvailable,
26
26
  canRunLinuxDockerImages,
27
+ hasTTY,
27
28
  } = require('../src/lib/isolation');
28
29
 
29
30
  // Path to the CLI
@@ -537,6 +538,14 @@ describe('Echo Integration Tests - Issue #55', () => {
537
538
  }
538
539
 
539
540
  describe('Attached Mode', () => {
541
+ if (!hasTTY()) {
542
+ it('should skip attached docker tests when no TTY is available', () => {
543
+ console.log(' ⚠ no TTY available, skipping attached docker tests');
544
+ assert.ok(true);
545
+ });
546
+ return;
547
+ }
548
+
540
549
  it('should execute echo hi in attached docker mode with proper formatting', () => {
541
550
  const containerName = `test-docker-attached-${Date.now()}`;
542
551
  const result = runCli(
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Unit tests for failure-handler module
4
+ * Tests pure functions: parseGitUrl and handleFailure early-exit behavior
5
+ */
6
+
7
+ const { describe, it } = require('node:test');
8
+ const assert = require('assert');
9
+ const { parseGitUrl, handleFailure } = require('../src/lib/failure-handler');
10
+
11
+ describe('failure-handler', () => {
12
+ describe('parseGitUrl', () => {
13
+ it('should parse HTTPS GitHub URL', () => {
14
+ const result = parseGitUrl('https://github.com/owner/my-repo');
15
+ assert.ok(result !== null);
16
+ assert.strictEqual(result.owner, 'owner');
17
+ assert.strictEqual(result.repo, 'my-repo');
18
+ assert.strictEqual(result.url, 'https://github.com/owner/my-repo');
19
+ });
20
+
21
+ it('should parse HTTPS URL with .git suffix', () => {
22
+ const result = parseGitUrl('https://github.com/owner/my-repo.git');
23
+ assert.ok(result !== null);
24
+ assert.strictEqual(result.owner, 'owner');
25
+ assert.strictEqual(result.repo, 'my-repo');
26
+ assert.strictEqual(result.url, 'https://github.com/owner/my-repo');
27
+ });
28
+
29
+ it('should parse SSH git@ URL', () => {
30
+ const result = parseGitUrl('git@github.com:owner/my-repo.git');
31
+ assert.ok(result !== null);
32
+ assert.strictEqual(result.owner, 'owner');
33
+ assert.strictEqual(result.repo, 'my-repo');
34
+ });
35
+
36
+ it('should parse git+https URL format', () => {
37
+ const result = parseGitUrl('git+https://github.com/owner/repo.git');
38
+ assert.ok(result !== null);
39
+ assert.strictEqual(result.owner, 'owner');
40
+ assert.strictEqual(result.repo, 'repo');
41
+ });
42
+
43
+ it('should return null for empty string', () => {
44
+ const result = parseGitUrl('');
45
+ assert.strictEqual(result, null);
46
+ });
47
+
48
+ it('should return null for null/undefined input', () => {
49
+ assert.strictEqual(parseGitUrl(null), null);
50
+ assert.strictEqual(parseGitUrl(undefined), null);
51
+ });
52
+
53
+ it('should return null for non-github URL', () => {
54
+ const result = parseGitUrl('https://gitlab.com/owner/repo');
55
+ assert.strictEqual(result, null);
56
+ });
57
+
58
+ it('should return null for invalid/random string', () => {
59
+ const result = parseGitUrl('not-a-url-at-all');
60
+ assert.strictEqual(result, null);
61
+ });
62
+
63
+ it('should normalize URL to https://github.com format', () => {
64
+ const result = parseGitUrl('git@github.com:myorg/myrepo');
65
+ assert.ok(result !== null);
66
+ assert.ok(result.url.startsWith('https://github.com/'));
67
+ });
68
+
69
+ it('should handle URL with subdirectory (only owner/repo captured)', () => {
70
+ const result = parseGitUrl('https://github.com/myorg/myrepo/issues');
71
+ assert.ok(result !== null);
72
+ assert.strictEqual(result.owner, 'myorg');
73
+ assert.strictEqual(result.repo, 'myrepo');
74
+ });
75
+
76
+ it('should return object with owner, repo, url keys', () => {
77
+ const result = parseGitUrl('https://github.com/test/project');
78
+ assert.ok(result !== null);
79
+ assert.ok(Object.prototype.hasOwnProperty.call(result, 'owner'));
80
+ assert.ok(Object.prototype.hasOwnProperty.call(result, 'repo'));
81
+ assert.ok(Object.prototype.hasOwnProperty.call(result, 'url'));
82
+ });
83
+ });
84
+
85
+ describe('handleFailure', () => {
86
+ it('should return early when disableAutoIssue is true', () => {
87
+ // This should not throw and should return without calling external processes
88
+ const config = { disableAutoIssue: true };
89
+ // If it tries to call external tools, it would either throw or hang;
90
+ // returning cleanly means the early-exit path was taken.
91
+ assert.doesNotThrow(() => {
92
+ handleFailure(config, 'someCmd', 'someCmd --flag', 1, '/tmp/fake.log');
93
+ });
94
+ });
95
+
96
+ it('should return early when disableAutoIssue is true (verbose mode)', () => {
97
+ const config = { disableAutoIssue: true, verbose: true };
98
+ assert.doesNotThrow(() => {
99
+ handleFailure(config, 'cmd', 'cmd arg', 2, '/tmp/fake.log');
100
+ });
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Unit tests for isolation-log-utils module
4
+ * Tests pure utility functions for log file management
5
+ */
6
+
7
+ const { describe, it } = require('node:test');
8
+ const assert = require('assert');
9
+ const path = require('path');
10
+ const {
11
+ getTimestamp,
12
+ generateLogFilename,
13
+ createLogHeader,
14
+ createLogFooter,
15
+ getLogDir,
16
+ createLogPath,
17
+ } = require('../src/lib/isolation-log-utils');
18
+
19
+ describe('isolation-log-utils', () => {
20
+ describe('getTimestamp', () => {
21
+ it('should return a non-empty string', () => {
22
+ const ts = getTimestamp();
23
+ assert.strictEqual(typeof ts, 'string');
24
+ assert.ok(ts.length > 0);
25
+ });
26
+
27
+ it('should return a timestamp without T or Z (ISO-like but space-separated)', () => {
28
+ const ts = getTimestamp();
29
+ assert.ok(!ts.includes('T'), 'Should not contain ISO T separator');
30
+ assert.ok(!ts.endsWith('Z'), 'Should not end with Z');
31
+ });
32
+
33
+ it('should contain date-like content (numbers and dashes)', () => {
34
+ const ts = getTimestamp();
35
+ // Expect format like "2024-01-15 10:30:45.123"
36
+ assert.match(ts, /\d{4}-\d{2}-\d{2}/);
37
+ });
38
+
39
+ it('should return different values on successive calls (or same within same ms)', () => {
40
+ const ts1 = getTimestamp();
41
+ assert.strictEqual(typeof ts1, 'string');
42
+ // Just verify it's callable multiple times without error
43
+ const ts2 = getTimestamp();
44
+ assert.strictEqual(typeof ts2, 'string');
45
+ });
46
+ });
47
+
48
+ describe('generateLogFilename', () => {
49
+ it('should return a string ending with .log', () => {
50
+ const filename = generateLogFilename('screen');
51
+ assert.ok(filename.endsWith('.log'));
52
+ });
53
+
54
+ it('should include the environment name in the filename', () => {
55
+ const filename = generateLogFilename('docker');
56
+ assert.ok(filename.includes('docker'));
57
+ });
58
+
59
+ it('should start with "start-command-"', () => {
60
+ const filename = generateLogFilename('tmux');
61
+ assert.ok(filename.startsWith('start-command-'));
62
+ });
63
+
64
+ it('should generate unique filenames on successive calls', () => {
65
+ const f1 = generateLogFilename('screen');
66
+ const f2 = generateLogFilename('screen');
67
+ // Due to random component, should be different
68
+ assert.notStrictEqual(f1, f2);
69
+ });
70
+
71
+ it('should handle different environment names', () => {
72
+ const environments = ['screen', 'tmux', 'docker', 'user', 'none'];
73
+ for (const env of environments) {
74
+ const filename = generateLogFilename(env);
75
+ assert.ok(
76
+ filename.includes(env),
77
+ `Filename should include environment "${env}"`
78
+ );
79
+ assert.ok(filename.endsWith('.log'));
80
+ }
81
+ });
82
+ });
83
+
84
+ describe('createLogHeader', () => {
85
+ const baseParams = {
86
+ command: 'npm test',
87
+ environment: 'screen',
88
+ mode: 'attached',
89
+ sessionName: 'test-session-123',
90
+ startTime: '2024-01-15 10:30:00.000',
91
+ };
92
+
93
+ it('should return a non-empty string', () => {
94
+ const header = createLogHeader(baseParams);
95
+ assert.strictEqual(typeof header, 'string');
96
+ assert.ok(header.length > 0);
97
+ });
98
+
99
+ it('should include the command in the header', () => {
100
+ const header = createLogHeader(baseParams);
101
+ assert.ok(header.includes('npm test'));
102
+ });
103
+
104
+ it('should include the environment in the header', () => {
105
+ const header = createLogHeader(baseParams);
106
+ assert.ok(header.includes('screen'));
107
+ });
108
+
109
+ it('should include the session name in the header', () => {
110
+ const header = createLogHeader(baseParams);
111
+ assert.ok(header.includes('test-session-123'));
112
+ });
113
+
114
+ it('should include the mode in the header', () => {
115
+ const header = createLogHeader(baseParams);
116
+ assert.ok(header.includes('attached'));
117
+ });
118
+
119
+ it('should include image field when provided', () => {
120
+ const params = { ...baseParams, image: 'node:20-alpine' };
121
+ const header = createLogHeader(params);
122
+ assert.ok(header.includes('node:20-alpine'));
123
+ });
124
+
125
+ it('should include user field when provided', () => {
126
+ const params = { ...baseParams, user: 'isolateduser' };
127
+ const header = createLogHeader(params);
128
+ assert.ok(header.includes('isolateduser'));
129
+ });
130
+
131
+ it('should NOT include Image line when image is not provided', () => {
132
+ const header = createLogHeader(baseParams);
133
+ assert.ok(!header.includes('Image:'));
134
+ });
135
+
136
+ it('should contain separator line', () => {
137
+ const header = createLogHeader(baseParams);
138
+ assert.ok(header.includes('==='));
139
+ });
140
+ });
141
+
142
+ describe('createLogFooter', () => {
143
+ it('should return a non-empty string', () => {
144
+ const footer = createLogFooter('2024-01-15 10:35:00.000', 0);
145
+ assert.strictEqual(typeof footer, 'string');
146
+ assert.ok(footer.length > 0);
147
+ });
148
+
149
+ it('should include the exit code', () => {
150
+ const footer = createLogFooter('2024-01-15 10:35:00.000', 42);
151
+ assert.ok(footer.includes('42'));
152
+ });
153
+
154
+ it('should include exit code 0', () => {
155
+ const footer = createLogFooter('2024-01-15 10:35:00.000', 0);
156
+ assert.ok(footer.includes('0'));
157
+ });
158
+
159
+ it('should include the end time', () => {
160
+ const endTime = '2024-01-15 10:35:00.000';
161
+ const footer = createLogFooter(endTime, 1);
162
+ assert.ok(footer.includes(endTime));
163
+ });
164
+
165
+ it('should contain separator line', () => {
166
+ const footer = createLogFooter('2024-01-15 10:35:00.000', 0);
167
+ assert.ok(footer.includes('='));
168
+ });
169
+ });
170
+
171
+ describe('getLogDir', () => {
172
+ it('should return a string', () => {
173
+ const dir = getLogDir();
174
+ assert.strictEqual(typeof dir, 'string');
175
+ });
176
+
177
+ it('should return a non-empty path', () => {
178
+ const dir = getLogDir();
179
+ assert.ok(dir.length > 0);
180
+ });
181
+
182
+ it('should use START_LOG_DIR env var when set', () => {
183
+ const original = process.env.START_LOG_DIR;
184
+ process.env.START_LOG_DIR = '/tmp/custom-log-dir';
185
+ try {
186
+ const dir = getLogDir();
187
+ assert.strictEqual(dir, '/tmp/custom-log-dir');
188
+ } finally {
189
+ if (original === undefined) {
190
+ delete process.env.START_LOG_DIR;
191
+ } else {
192
+ process.env.START_LOG_DIR = original;
193
+ }
194
+ }
195
+ });
196
+
197
+ it('should fall back to os.tmpdir() when START_LOG_DIR is not set', () => {
198
+ const os = require('os');
199
+ const original = process.env.START_LOG_DIR;
200
+ delete process.env.START_LOG_DIR;
201
+ try {
202
+ const dir = getLogDir();
203
+ assert.strictEqual(dir, os.tmpdir());
204
+ } finally {
205
+ if (original !== undefined) {
206
+ process.env.START_LOG_DIR = original;
207
+ }
208
+ }
209
+ });
210
+ });
211
+
212
+ describe('createLogPath', () => {
213
+ it('should return a string ending with .log', () => {
214
+ const logPath = createLogPath('screen');
215
+ assert.ok(logPath.endsWith('.log'));
216
+ });
217
+
218
+ it('should return an absolute path', () => {
219
+ const logPath = createLogPath('tmux');
220
+ assert.ok(path.isAbsolute(logPath));
221
+ });
222
+
223
+ it('should include the environment name', () => {
224
+ const logPath = createLogPath('docker');
225
+ assert.ok(logPath.includes('docker'));
226
+ });
227
+
228
+ it('should be under the log directory', () => {
229
+ const logDir = getLogDir();
230
+ const logPath = createLogPath('screen');
231
+ assert.ok(logPath.startsWith(logDir));
232
+ });
233
+ });
234
+ });
@@ -34,6 +34,7 @@ const {
34
34
  isInteractiveShellCommand,
35
35
  isShellInvocationWithArgs,
36
36
  buildShellWithArgsCmdArgs,
37
+ buildDisplayCommand,
37
38
  } = require('../src/lib/isolation');
38
39
 
39
40
  // Helper: mirrors the attached-mode command-args construction logic in runInDocker.
@@ -258,3 +259,38 @@ describe('isShellInvocationWithArgs is mutually exclusive with isInteractiveShel
258
259
  });
259
260
  }
260
261
  });
262
+
263
+ describe('buildDisplayCommand (issue #91 display fix)', () => {
264
+ it('should quote the -c script argument when it contains spaces', () => {
265
+ assert.strictEqual(
266
+ buildDisplayCommand('bash -i -c nvm --version'),
267
+ 'bash -i -c "nvm --version"'
268
+ );
269
+ });
270
+
271
+ it('should quote the -c script argument for plain "bash -c echo hello"', () => {
272
+ assert.strictEqual(
273
+ buildDisplayCommand('bash -c echo hello'),
274
+ 'bash -c "echo hello"'
275
+ );
276
+ });
277
+
278
+ it('should not double-quote if script has no spaces', () => {
279
+ assert.strictEqual(buildDisplayCommand('bash -c ls'), 'bash -c ls');
280
+ });
281
+
282
+ it('should return command unchanged for non-shell-with-args commands', () => {
283
+ assert.strictEqual(buildDisplayCommand('nvm --version'), 'nvm --version');
284
+ });
285
+
286
+ it('should return command unchanged for bare shell invocations', () => {
287
+ assert.strictEqual(buildDisplayCommand('bash -i'), 'bash -i');
288
+ });
289
+
290
+ it('should handle zsh -c with spaces', () => {
291
+ assert.strictEqual(
292
+ buildDisplayCommand('zsh -c echo hello world'),
293
+ 'zsh -c "echo hello world"'
294
+ );
295
+ });
296
+ });