start-command 0.24.7 → 0.24.9

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,45 @@
1
1
  # start-command
2
2
 
3
+ ## 0.24.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 48515a1: fix: capture output from quick-completing commands in screen isolation (issue #96)
8
+
9
+ When running a short-lived command like `agent --version` through screen isolation:
10
+
11
+ ```
12
+ $ --isolated screen -- agent --version
13
+ ```
14
+
15
+ the version output was silently lost — the command exited cleanly (exit code 0)
16
+ but no output was displayed.
17
+
18
+ **Root cause:** GNU Screen's internal log buffer flushes every 10 seconds by default
19
+ (`log_flush = 10`). For commands that complete faster than this, the buffer may not
20
+ be flushed to the log file before the screen session terminates.
21
+
22
+ **Fix:** A temporary screenrc file with `logfile flush 0` is passed to screen via
23
+ the `-c` option. This forces screen to flush the log buffer after every write,
24
+ eliminating the 10-second flush delay for quick-completing commands.
25
+
26
+ A retry mechanism is also added for the tee fallback path (older screen < 4.5.1)
27
+ to handle the TOCTOU race where the log file appears empty when first read
28
+ immediately after session completion.
29
+
30
+ Both JavaScript (`isolation.js`) and Rust (`isolation.rs`) implementations are fixed
31
+ with equivalent test coverage added.
32
+
33
+ ## 0.24.8
34
+
35
+ ### Patch Changes
36
+
37
+ - 1195fc1: Add CI/CD coverage enforcement and Rust/JS test parity checks (issue #93)
38
+ - Add `scripts/check-test-parity.mjs` script to enforce Rust/JS test count within 10%
39
+ - Add coverage job to JavaScript CI/CD workflow (80% minimum threshold)
40
+ - Update `ARCHITECTURE.md` to document dual-language sync requirements
41
+ - Update `REQUIREMENTS.md` to document test coverage requirements and parity rules
42
+
3
43
  ## 0.24.7
4
44
 
5
45
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.24.7",
3
+ "version": "0.24.9",
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": {
@@ -1,94 +1,20 @@
1
1
  /** Isolation Runners for start-command (screen, tmux, docker, ssh) */
2
2
 
3
3
  const { execSync, spawn, spawnSync } = require('child_process');
4
- const fs = require('fs');
5
- const os = require('os');
6
4
  const path = require('path');
7
5
  const { generateSessionName } = require('./args-parser');
8
6
  const outputBlocks = require('./output-blocks');
9
7
 
10
- const setTimeout = globalThis.setTimeout;
11
-
12
8
  // Debug mode from environment
13
9
  const DEBUG =
14
10
  process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
15
11
 
16
- // Cache for screen version detection
17
- let cachedScreenVersion = null;
18
- let screenVersionChecked = false;
19
-
20
- /**
21
- * Get the installed screen version
22
- * @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
23
- */
24
- function getScreenVersion() {
25
- if (screenVersionChecked) {
26
- return cachedScreenVersion;
27
- }
28
-
29
- screenVersionChecked = true;
30
-
31
- try {
32
- const output = execSync('screen --version', {
33
- encoding: 'utf8',
34
- stdio: ['pipe', 'pipe', 'pipe'],
35
- });
36
- // Match patterns like "4.09.01", "4.00.03", "4.5.1"
37
- const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
38
- if (match) {
39
- cachedScreenVersion = {
40
- major: parseInt(match[1], 10),
41
- minor: parseInt(match[2], 10),
42
- patch: parseInt(match[3], 10),
43
- };
44
-
45
- if (DEBUG) {
46
- console.log(
47
- `[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
48
- );
49
- }
50
-
51
- return cachedScreenVersion;
52
- }
53
- } catch {
54
- if (DEBUG) {
55
- console.log('[DEBUG] Could not detect screen version');
56
- }
57
- }
58
-
59
- return null;
60
- }
61
-
62
- /**
63
- * Check if screen supports the -Logfile option
64
- * The -Logfile option was introduced in GNU Screen 4.5.1
65
- * @returns {boolean} True if -Logfile is supported
66
- */
67
- function supportsLogfileOption() {
68
- const version = getScreenVersion();
69
- if (!version) {
70
- // If we can't detect version, assume older version and use fallback
71
- return false;
72
- }
73
-
74
- // -Logfile was added in 4.5.1
75
- // Compare: version >= 4.5.1
76
- if (version.major > 4) {
77
- return true;
78
- }
79
- if (version.major < 4) {
80
- return false;
81
- }
82
- // major === 4
83
- if (version.minor > 5) {
84
- return true;
85
- }
86
- if (version.minor < 5) {
87
- return false;
88
- }
89
- // minor === 5
90
- return version.patch >= 1;
91
- }
12
+ const {
13
+ getScreenVersion,
14
+ supportsLogfileOption,
15
+ runScreenWithLogCapture: _runScreenWithLogCapture,
16
+ resetScreenVersionCache,
17
+ } = require('./screen-isolation');
92
18
 
93
19
  /**
94
20
  * Check if a command is available on the system
@@ -245,159 +171,14 @@ function wrapCommandWithUser(command, user) {
245
171
  * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
246
172
  */
247
173
  function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
248
- const { shell, shellArg } = shellInfo;
249
- const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
250
-
251
- // Check if screen supports -Logfile option (added in 4.5.1)
252
- const useNativeLogging = supportsLogfileOption();
253
-
254
- return new Promise((resolve) => {
255
- try {
256
- let screenArgs;
257
- // Wrap command with user switch if specified
258
- let effectiveCommand = wrapCommandWithUser(command, user);
259
-
260
- if (useNativeLogging) {
261
- // Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
262
- // screen -dmS <session> -L -Logfile <logfile> <shell> -c '<command>'
263
- const logArgs = ['-dmS', sessionName, '-L', '-Logfile', logFile];
264
- screenArgs = isInteractiveShellCommand(command)
265
- ? [...logArgs, ...command.trim().split(/\s+/)]
266
- : [...logArgs, shell, shellArg, effectiveCommand];
267
-
268
- if (DEBUG) {
269
- console.log(
270
- `[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
271
- );
272
- }
273
- } else {
274
- // Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
275
- // The parentheses ensure proper grouping of the command and its stderr
276
- const isBareShell = isInteractiveShellCommand(command);
277
- if (!isBareShell) {
278
- effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
279
- }
280
- screenArgs = isBareShell
281
- ? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
282
- : ['-dmS', sessionName, shell, shellArg, effectiveCommand];
283
-
284
- if (DEBUG) {
285
- console.log(
286
- `[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
287
- );
288
- }
289
- }
290
-
291
- // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
292
- const result = spawnSync('screen', screenArgs, {
293
- stdio: 'inherit',
294
- });
295
-
296
- if (result.error) {
297
- throw result.error;
298
- }
299
-
300
- // Poll for session completion
301
- const checkInterval = 100; // ms
302
- const maxWait = 300000; // 5 minutes max
303
- let waited = 0;
304
-
305
- const checkCompletion = () => {
306
- try {
307
- // Check if session still exists
308
- const sessions = execSync('screen -ls', {
309
- encoding: 'utf8',
310
- stdio: ['pipe', 'pipe', 'pipe'],
311
- });
312
-
313
- if (!sessions.includes(sessionName)) {
314
- // Session ended, read output
315
- let output = '';
316
- try {
317
- output = fs.readFileSync(logFile, 'utf8');
318
- // Display the output with surrounding empty lines for consistency
319
- if (output.trim()) {
320
- process.stdout.write(output);
321
- // Add trailing newline if output doesn't end with one
322
- if (!output.endsWith('\n')) {
323
- process.stdout.write('\n');
324
- }
325
- }
326
- } catch {
327
- // Log file might not exist if command was very quick
328
- }
329
-
330
- // Clean up log file
331
- try {
332
- fs.unlinkSync(logFile);
333
- } catch {
334
- // Ignore cleanup errors
335
- }
336
-
337
- resolve({
338
- success: true,
339
- sessionName,
340
- message: `Screen session "${sessionName}" exited with code 0`,
341
- exitCode: 0,
342
- output,
343
- });
344
- return;
345
- }
346
-
347
- waited += checkInterval;
348
- if (waited >= maxWait) {
349
- resolve({
350
- success: false,
351
- sessionName,
352
- message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
353
- exitCode: 1,
354
- });
355
- return;
356
- }
357
-
358
- setTimeout(checkCompletion, checkInterval);
359
- } catch {
360
- // screen -ls failed, session probably ended
361
- let output = '';
362
- try {
363
- output = fs.readFileSync(logFile, 'utf8');
364
- if (output.trim()) {
365
- process.stdout.write(output);
366
- // Add trailing newline if output doesn't end with one
367
- if (!output.endsWith('\n')) {
368
- process.stdout.write('\n');
369
- }
370
- }
371
- } catch {
372
- // Ignore
373
- }
374
-
375
- try {
376
- fs.unlinkSync(logFile);
377
- } catch {
378
- // Ignore
379
- }
380
-
381
- resolve({
382
- success: true,
383
- sessionName,
384
- message: `Screen session "${sessionName}" exited with code 0`,
385
- exitCode: 0,
386
- output,
387
- });
388
- }
389
- };
390
-
391
- // Start checking after a brief delay
392
- setTimeout(checkCompletion, checkInterval);
393
- } catch (err) {
394
- resolve({
395
- success: false,
396
- sessionName,
397
- message: `Failed to run in screen: ${err.message}`,
398
- });
399
- }
400
- });
174
+ return _runScreenWithLogCapture(
175
+ command,
176
+ sessionName,
177
+ shellInfo,
178
+ user,
179
+ wrapCommandWithUser,
180
+ isInteractiveShellCommand
181
+ );
401
182
  }
402
183
 
403
184
  /**
@@ -934,12 +715,6 @@ function runIsolated(backend, command, options = {}) {
934
715
  }
935
716
  }
936
717
 
937
- /** Reset screen version cache (useful for testing) */
938
- function resetScreenVersionCache() {
939
- cachedScreenVersion = null;
940
- screenVersionChecked = false;
941
- }
942
-
943
718
  const {
944
719
  getTimestamp,
945
720
  generateLogFilename,
@@ -0,0 +1,309 @@
1
+ /** Screen-specific isolation helpers extracted from isolation.js */
2
+
3
+ const { execSync, spawnSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const setTimeout = globalThis.setTimeout;
9
+
10
+ // Debug mode from environment
11
+ const DEBUG =
12
+ process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
13
+
14
+ // Cache for screen version detection
15
+ let cachedScreenVersion = null;
16
+ let screenVersionChecked = false;
17
+
18
+ /**
19
+ * Get the installed screen version
20
+ * @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
21
+ */
22
+ function getScreenVersion() {
23
+ if (screenVersionChecked) {
24
+ return cachedScreenVersion;
25
+ }
26
+
27
+ screenVersionChecked = true;
28
+
29
+ try {
30
+ const output = execSync('screen --version', {
31
+ encoding: 'utf8',
32
+ stdio: ['pipe', 'pipe', 'pipe'],
33
+ });
34
+ // Match patterns like "4.09.01", "4.00.03", "4.5.1"
35
+ const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
36
+ if (match) {
37
+ cachedScreenVersion = {
38
+ major: parseInt(match[1], 10),
39
+ minor: parseInt(match[2], 10),
40
+ patch: parseInt(match[3], 10),
41
+ };
42
+
43
+ if (DEBUG) {
44
+ console.log(
45
+ `[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
46
+ );
47
+ }
48
+
49
+ return cachedScreenVersion;
50
+ }
51
+ } catch {
52
+ if (DEBUG) {
53
+ console.log('[DEBUG] Could not detect screen version');
54
+ }
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Check if screen supports the -Logfile option
62
+ * The -Logfile option was introduced in GNU Screen 4.5.1
63
+ * @returns {boolean} True if -Logfile is supported
64
+ */
65
+ function supportsLogfileOption() {
66
+ const version = getScreenVersion();
67
+ if (!version) {
68
+ // If we can't detect version, assume older version and use fallback
69
+ return false;
70
+ }
71
+
72
+ // -Logfile was added in 4.5.1
73
+ // Compare: version >= 4.5.1
74
+ if (version.major > 4) {
75
+ return true;
76
+ }
77
+ if (version.major < 4) {
78
+ return false;
79
+ }
80
+ // major === 4
81
+ if (version.minor > 5) {
82
+ return true;
83
+ }
84
+ if (version.minor < 5) {
85
+ return false;
86
+ }
87
+ // minor === 5
88
+ return version.patch >= 1;
89
+ }
90
+
91
+ /**
92
+ * Run command in GNU Screen using detached mode with log capture.
93
+ * Supports screen >= 4.5.1 (native -Logfile) and older versions (tee fallback).
94
+ * @param {string} command - Command to execute
95
+ * @param {string} sessionName - Session name
96
+ * @param {object} shellInfo - Shell info from getShell()
97
+ * @param {string|null} user - Username to run command as (optional)
98
+ * @param {Function} wrapCommandWithUser - Function to wrap command with user
99
+ * @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
100
+ * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
101
+ */
102
+ function runScreenWithLogCapture(
103
+ command,
104
+ sessionName,
105
+ shellInfo,
106
+ user = null,
107
+ wrapCommandWithUser,
108
+ isInteractiveShellCommand
109
+ ) {
110
+ const { shell, shellArg } = shellInfo;
111
+ const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
112
+
113
+ // Check if screen supports -Logfile option (added in 4.5.1)
114
+ const useNativeLogging = supportsLogfileOption();
115
+
116
+ return new Promise((resolve) => {
117
+ try {
118
+ let screenArgs;
119
+ // Wrap command with user switch if specified
120
+ let effectiveCommand = wrapCommandWithUser(command, user);
121
+
122
+ // Temporary screenrc file for native logging path (issue #96)
123
+ // Setting logfile flush 0 forces screen to flush its log buffer after every write,
124
+ // preventing output loss for quick-completing commands like `agent --version`.
125
+ // Without this, screen buffers log writes and flushes every 10 seconds by default.
126
+ let screenrcFile = null;
127
+
128
+ if (useNativeLogging) {
129
+ // Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
130
+ // Use a temporary screenrc with `logfile flush 0` to force immediate log flushing
131
+ // (issue #96: quick commands like `agent --version` lose output without this)
132
+ screenrcFile = path.join(os.tmpdir(), `screenrc-${sessionName}`);
133
+ try {
134
+ fs.writeFileSync(screenrcFile, 'logfile flush 0\n');
135
+ } catch {
136
+ // If we can't create the screenrc, proceed without it (best effort)
137
+ screenrcFile = null;
138
+ }
139
+
140
+ // screen -dmS <session> -c <screenrc> -L -Logfile <logfile> <shell> -c '<command>'
141
+ const logArgs = screenrcFile
142
+ ? ['-dmS', sessionName, '-c', screenrcFile, '-L', '-Logfile', logFile]
143
+ : ['-dmS', sessionName, '-L', '-Logfile', logFile];
144
+ screenArgs = isInteractiveShellCommand(command)
145
+ ? [...logArgs, ...command.trim().split(/\s+/)]
146
+ : [...logArgs, shell, shellArg, effectiveCommand];
147
+
148
+ if (DEBUG) {
149
+ console.log(
150
+ `[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
151
+ );
152
+ }
153
+ } else {
154
+ // Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
155
+ // The parentheses ensure proper grouping of the command and its stderr
156
+ const isBareShell = isInteractiveShellCommand(command);
157
+ if (!isBareShell) {
158
+ effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
159
+ }
160
+ screenArgs = isBareShell
161
+ ? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
162
+ : ['-dmS', sessionName, shell, shellArg, effectiveCommand];
163
+
164
+ if (DEBUG) {
165
+ console.log(
166
+ `[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
167
+ );
168
+ }
169
+ }
170
+
171
+ // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
172
+ const result = spawnSync('screen', screenArgs, {
173
+ stdio: 'inherit',
174
+ });
175
+
176
+ if (result.error) {
177
+ throw result.error;
178
+ }
179
+
180
+ // Helper to read log file output and write to stdout
181
+ // Includes a short retry for the tee fallback path to handle the TOCTOU race
182
+ // condition where the session appears gone but the log file isn't fully written yet
183
+ // (issue #96)
184
+ const readAndDisplayOutput = (retryCount = 0) => {
185
+ let output = '';
186
+ try {
187
+ output = fs.readFileSync(logFile, 'utf8');
188
+ } catch {
189
+ // Log file might not exist if command produced no output
190
+ }
191
+
192
+ // If output is empty and we haven't retried yet, wait briefly and retry once.
193
+ // This handles the race where tee's write hasn't been flushed to disk yet
194
+ // when the screen session appears done in `screen -ls` (issue #96).
195
+ if (!output.trim() && retryCount === 0) {
196
+ return new Promise((resolveRetry) => {
197
+ setTimeout(() => {
198
+ resolveRetry(readAndDisplayOutput(1));
199
+ }, 50);
200
+ });
201
+ }
202
+
203
+ // Display the output
204
+ if (output.trim()) {
205
+ process.stdout.write(output);
206
+ // Add trailing newline if output doesn't end with one
207
+ if (!output.endsWith('\n')) {
208
+ process.stdout.write('\n');
209
+ }
210
+ }
211
+ return Promise.resolve(output);
212
+ };
213
+
214
+ // Clean up temp files
215
+ const cleanupTempFiles = () => {
216
+ try {
217
+ fs.unlinkSync(logFile);
218
+ } catch {
219
+ // Ignore cleanup errors
220
+ }
221
+ if (screenrcFile) {
222
+ try {
223
+ fs.unlinkSync(screenrcFile);
224
+ } catch {
225
+ // Ignore cleanup errors
226
+ }
227
+ }
228
+ };
229
+
230
+ // Poll for session completion
231
+ const checkInterval = 100; // ms
232
+ const maxWait = 300000; // 5 minutes max
233
+ let waited = 0;
234
+
235
+ const checkCompletion = () => {
236
+ try {
237
+ // Check if session still exists
238
+ const sessions = execSync('screen -ls', {
239
+ encoding: 'utf8',
240
+ stdio: ['pipe', 'pipe', 'pipe'],
241
+ });
242
+
243
+ if (!sessions.includes(sessionName)) {
244
+ // Session ended, read output (with retry for tee path race condition)
245
+ readAndDisplayOutput().then((output) => {
246
+ cleanupTempFiles();
247
+ resolve({
248
+ success: true,
249
+ sessionName,
250
+ message: `Screen session "${sessionName}" exited with code 0`,
251
+ exitCode: 0,
252
+ output,
253
+ });
254
+ });
255
+ return;
256
+ }
257
+
258
+ waited += checkInterval;
259
+ if (waited >= maxWait) {
260
+ cleanupTempFiles();
261
+ resolve({
262
+ success: false,
263
+ sessionName,
264
+ message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
265
+ exitCode: 1,
266
+ });
267
+ return;
268
+ }
269
+
270
+ setTimeout(checkCompletion, checkInterval);
271
+ } catch {
272
+ // screen -ls failed, session probably ended
273
+ readAndDisplayOutput().then((output) => {
274
+ cleanupTempFiles();
275
+ resolve({
276
+ success: true,
277
+ sessionName,
278
+ message: `Screen session "${sessionName}" exited with code 0`,
279
+ exitCode: 0,
280
+ output,
281
+ });
282
+ });
283
+ }
284
+ };
285
+
286
+ // Start checking after a brief delay
287
+ setTimeout(checkCompletion, checkInterval);
288
+ } catch (err) {
289
+ resolve({
290
+ success: false,
291
+ sessionName,
292
+ message: `Failed to run in screen: ${err.message}`,
293
+ });
294
+ }
295
+ });
296
+ }
297
+
298
+ /** Reset screen version cache (useful for testing) */
299
+ function resetScreenVersionCache() {
300
+ cachedScreenVersion = null;
301
+ screenVersionChecked = false;
302
+ }
303
+
304
+ module.exports = {
305
+ getScreenVersion,
306
+ supportsLogfileOption,
307
+ runScreenWithLogCapture,
308
+ resetScreenVersionCache,
309
+ };
@@ -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
+ });
@@ -678,6 +678,39 @@ describe('Isolation Runner with Available Backends', () => {
678
678
  );
679
679
  console.log(` Verified output capture: "${result.output.trim()}"`);
680
680
  });
681
+
682
+ it('should capture output from version-flag commands (issue #96)', async () => {
683
+ if (!isCommandAvailable('screen')) {
684
+ console.log(' Skipping: screen not installed');
685
+ return;
686
+ }
687
+
688
+ // This test verifies that screen isolation captures output from quick-completing
689
+ // commands like `agent --version` or `node --version`.
690
+ // Issue #96: output was silently lost because screen's internal log buffer was
691
+ // not flushed before the session terminated (default 10s flush interval).
692
+ // Fix: use a temporary screenrc with `logfile flush 0` to force immediate flushing.
693
+ const result = await runInScreen('node --version', {
694
+ session: `test-version-flag-${Date.now()}`,
695
+ detached: false,
696
+ });
697
+
698
+ assert.strictEqual(result.success, true, 'Command should succeed');
699
+ assert.ok(
700
+ result.output !== undefined,
701
+ 'Attached mode should always return output property'
702
+ );
703
+ assert.ok(
704
+ result.output.trim().length > 0,
705
+ 'Output should not be empty (issue #96: version output was silently lost)'
706
+ );
707
+ // node --version outputs something like "v20.0.0"
708
+ assert.ok(
709
+ result.output.includes('v') || /\d+\.\d+/.test(result.output),
710
+ 'Output should contain version string'
711
+ );
712
+ console.log(` Captured version output: "${result.output.trim()}"`);
713
+ });
681
714
  });
682
715
 
683
716
  describe('runInTmux (if available)', () => {