start-command 0.32.1 → 0.34.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 (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +32 -137
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/detached-finalize.js +281 -0
  9. package/src/lib/docker-cleanup.js +240 -39
  10. package/src/lib/docker-post-mortem.js +289 -0
  11. package/src/lib/execution-attach.js +245 -0
  12. package/src/lib/execution-resume-all.js +211 -0
  13. package/src/lib/execution-resume.js +455 -0
  14. package/src/lib/execution-store.js +93 -6
  15. package/src/lib/exit-reason.js +264 -0
  16. package/src/lib/failure-handler.js +43 -41
  17. package/src/lib/isolation-log-utils.js +66 -1
  18. package/src/lib/isolation.js +28 -15
  19. package/src/lib/query-commands.js +202 -0
  20. package/src/lib/screen-isolation.js +2 -1
  21. package/src/lib/session-probe.js +121 -0
  22. package/src/lib/shell-utils.js +164 -10
  23. package/src/lib/status-formatter.js +233 -69
  24. package/src/lib/usage.js +13 -1
  25. package/src/lib/user-manager.js +18 -1
  26. package/test/args-parser-attach-resume.js +159 -0
  27. package/test/args-parser-control.js +3 -3
  28. package/test/args-parser.js +1 -1
  29. package/test/check-file-size.js +91 -0
  30. package/test/ci-workflow-invariants.js +191 -0
  31. package/test/echo-integration.js +20 -13
  32. package/test/execution-attach.js +221 -0
  33. package/test/execution-resume.js +433 -0
  34. package/test/execution-store.js +57 -2
  35. package/test/exit-reason.js +209 -0
  36. package/test/failure-handler.js +107 -0
  37. package/test/load-command-stream.mjs +83 -0
  38. package/test/regression-162.js +320 -0
  39. package/test/regression-164.js +308 -0
  40. package/test/regression-165.js +310 -0
  41. package/test/regression-170.js +321 -0
  42. package/test/regression-171.js +562 -0
  43. package/test/regression-84.js +8 -3
  44. package/test/release-name.mjs +57 -0
  45. package/test/session-probe.js +150 -0
  46. package/test/user-manager.js +23 -0
@@ -8,13 +8,23 @@
8
8
  */
9
9
 
10
10
  const { execSync, spawnSync } = require('child_process');
11
- const fs = require('fs');
12
11
  const {
13
12
  escapeForLinksNotation,
14
13
  formatAsNestedLinksNotation,
15
14
  } = require('./output-blocks');
16
15
  const { collectProcessIds } = require('./execution-control');
17
16
  const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
17
+ const {
18
+ describeExitCode,
19
+ resolveExitReason,
20
+ resolveMemoryExhaustion,
21
+ } = require('./exit-reason');
22
+ const {
23
+ FATAL_MARKER_TAIL_BYTES,
24
+ readLogTail,
25
+ } = require('./isolation-log-utils');
26
+ const { normalizeDockerTimestamp } = require('./docker-post-mortem');
27
+ const { END_TIME_SOURCE } = require('./detached-finalize');
18
28
 
19
29
  /**
20
30
  * Inspect the live state of a detached docker container by name.
@@ -40,7 +50,8 @@ function inspectDockerState(sessionName) {
40
50
  [
41
51
  'inspect',
42
52
  '-f',
43
- '{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}',
53
+ '{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
54
+ '{{.State.StartedAt}} {{.State.FinishedAt}}',
44
55
  sessionName,
45
56
  ],
46
57
  getDockerSpawnOptions({
@@ -52,13 +63,18 @@ function inspectDockerState(sessionName) {
52
63
  if (result.error || result.status !== 0 || !result.stdout) {
53
64
  return null;
54
65
  }
55
- const [runningRaw, exitRaw, oomKilledRaw] = result.stdout.trim().split(/\s+/);
66
+ // Parsed positionally and defensively: an older docker (or a stubbed one)
67
+ // may answer with fewer fields than the template asks for.
68
+ const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] =
69
+ result.stdout.trim().split(/\s+/);
56
70
  const exitCode = Number.parseInt(exitRaw, 10);
57
71
  return {
58
72
  running: runningRaw === 'true',
59
73
  exitCode: Number.isFinite(exitCode) ? exitCode : null,
60
74
  oomKilled:
61
75
  oomKilledRaw === 'true' ? true : oomKilledRaw === 'false' ? false : null,
76
+ startedAt: normalizeDockerTimestamp(startedRaw),
77
+ finishedAt: normalizeDockerTimestamp(finishedRaw),
62
78
  };
63
79
  }
64
80
 
@@ -175,13 +191,6 @@ function isDetachedSessionAlive(record) {
175
191
  }
176
192
  }
177
193
 
178
- /**
179
- * Number of trailing bytes scanned for the terminal log footer. The footer is
180
- * always appended at the very end of the log, so there is no reason to read
181
- * (potentially megabytes of) command output on every `--status` call.
182
- */
183
- const LOG_TAIL_BYTES = 16 * 1024;
184
-
185
194
  /**
186
195
  * The three-line terminal footer that `start` itself writes (see
187
196
  * `createLogFooter()` and `createShellLogFooterSnippet()`):
@@ -196,44 +205,49 @@ const LOG_TAIL_BYTES = 16 * 1024;
196
205
  * `start` appends itself (issue #150).
197
206
  */
198
207
  const LOG_FOOTER_PATTERN =
199
- /^={10,}[ \t]*\r?\n^Finished:[^\r\n]*\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;
208
+ /^={10,}[ \t]*\r?\n^Finished:[ \t]*([^\r\n]*)\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;
200
209
 
201
210
  /**
202
- * Read the last `bytes` bytes of a file as UTF-8 text.
211
+ * Parse the `Finished:` line of the footer into an ISO timestamp.
203
212
  *
204
- * A partial first line is dropped: the slice can start in the middle of a line,
205
- * and that fragment must not be treated as the beginning of a line by the
206
- * anchored footer pattern.
213
+ * `start` writes it as `YYYY-MM-DD HH:MM:SS[.mmm]` in UTC (both from JS and
214
+ * from the shell snippet). Anything else — a truncated log, a test fixture, a
215
+ * future format — yields null so the caller falls back instead of inventing a
216
+ * timestamp out of an unparseable string.
207
217
  *
208
- * @param {string} logPath - Path to the log file
209
- * @param {number} [bytes] - Maximum number of trailing bytes to read
210
- * @returns {string|null} Tail content, or null when the file cannot be read
218
+ * @param {string|null|undefined} value - Raw text after `Finished:`
219
+ * @returns {string|null} ISO timestamp, or null
211
220
  */
212
- function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
213
- let fd;
214
- try {
215
- fd = fs.openSync(logPath, 'r');
216
- const size = fs.fstatSync(fd).size;
217
- const length = Math.min(size, bytes);
218
- const buffer = Buffer.alloc(length);
219
- fs.readSync(fd, buffer, 0, length, size - length);
220
- const tail = buffer.toString('utf8');
221
- if (size <= bytes) {
222
- return tail;
223
- }
224
- const firstNewline = tail.indexOf('\n');
225
- return firstNewline === -1 ? '' : tail.slice(firstNewline + 1);
226
- } catch {
221
+ function parseFooterTimestamp(value) {
222
+ if (typeof value !== 'string') {
227
223
  return null;
228
- } finally {
229
- if (fd !== undefined) {
230
- try {
231
- fs.closeSync(fd);
232
- } catch {
233
- // ignore
234
- }
235
- }
236
224
  }
225
+ const trimmed = value.trim();
226
+ if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?Z?$/.test(trimmed)) {
227
+ return null;
228
+ }
229
+ const parsed = Date.parse(`${trimmed.replace(' ', 'T').replace(/Z$/, '')}Z`);
230
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
231
+ }
232
+
233
+ /**
234
+ * Read the terminal footer written at the very end of a log.
235
+ * @param {string|null} tail - Tail of the execution log
236
+ * @returns {{exitCode: number|null, finishedAt: string|null}} Footer facts
237
+ */
238
+ function parseFooterFromTail(tail) {
239
+ if (tail === null || tail === undefined) {
240
+ return { exitCode: null, finishedAt: null };
241
+ }
242
+ const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
243
+ if (matches.length === 0) {
244
+ return { exitCode: null, finishedAt: null };
245
+ }
246
+ const last = matches[matches.length - 1];
247
+ return {
248
+ exitCode: parseInt(last[2], 10),
249
+ finishedAt: parseFooterTimestamp(last[1]),
250
+ };
237
251
  }
238
252
 
239
253
  /**
@@ -244,31 +258,66 @@ function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
244
258
  * @param {string} logPath - Path to the log file
245
259
  * @returns {number|null}
246
260
  */
247
- function readExitCodeFromLog(logPath) {
248
- if (!logPath) {
249
- return null;
250
- }
251
- const tail = readLogTail(logPath);
252
- if (tail === null) {
261
+ function parseExitCodeFromTail(tail) {
262
+ if (tail === null || tail === undefined) {
253
263
  return null;
254
264
  }
255
- const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
256
- if (matches.length === 0) {
265
+ return parseFooterFromTail(tail).exitCode;
266
+ }
267
+
268
+ function readExitCodeFromLog(logPath) {
269
+ if (!logPath) {
257
270
  return null;
258
271
  }
259
- return parseInt(matches[matches.length - 1][1], 10);
272
+ return parseExitCodeFromTail(readLogTail(logPath));
260
273
  }
261
274
 
262
275
  /**
263
- * Enrich execution record with live session status for detached executions.
276
+ * Resolve the live status of a detached execution.
264
277
  * If a record shows "executing" but the detached session has actually ended,
265
278
  * returns an updated copy with status "executed". If it shows "executed" but
266
279
  * the session is still running, returns a copy with status "executing".
267
280
  * @param {Object} record - Execution record
281
+ * @param {string|null} logTail - Tail of the execution log (already read once)
268
282
  * @returns {Object} Possibly updated execution record
269
283
  */
270
- function enrichDetachedStatus(record) {
271
- const footerExit = readExitCodeFromLog(record.logPath);
284
+ /**
285
+ * Decide the moment a finished execution ended, and say where that moment came
286
+ * from (issue #170.2).
287
+ *
288
+ * Precedence: the container's own `State.FinishedAt`, then the `Finished:` line
289
+ * of the footer `start` wrote itself, and only then the time the end was
290
+ * observed — which is explicitly marked as such, so a consumer can tell a real
291
+ * finish time from a query-time observation.
292
+ *
293
+ * @param {Object} enriched - Record copy being enriched (mutated)
294
+ * @param {Object|null} dockerState - Inspected docker state, when available
295
+ * @param {string|null} footerFinishedAt - Footer timestamp, when parseable
296
+ * @returns {void}
297
+ */
298
+ function applyEndTime(enriched, dockerState, footerFinishedAt) {
299
+ if (enriched.endTime) {
300
+ return;
301
+ }
302
+ const dockerFinishedAt = dockerState ? dockerState.finishedAt : null;
303
+ if (dockerFinishedAt) {
304
+ enriched.endTime = new Date(dockerFinishedAt).toISOString();
305
+ enriched.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
306
+ return;
307
+ }
308
+ if (footerFinishedAt) {
309
+ enriched.endTime = footerFinishedAt;
310
+ enriched.endTimeSource = END_TIME_SOURCE.LOG_FOOTER;
311
+ return;
312
+ }
313
+ enriched.endTime = new Date().toISOString();
314
+ enriched.endTimeSource = END_TIME_SOURCE.OBSERVED_AT;
315
+ enriched.observedAt = enriched.endTime;
316
+ }
317
+
318
+ function resolveDetachedStatus(record, logTail) {
319
+ const footer = parseFooterFromTail(logTail);
320
+ const footerExit = footer.exitCode;
272
321
  const dockerState = isDetachedDockerRecord(record)
273
322
  ? readDockerState(record)
274
323
  : null;
@@ -305,9 +354,7 @@ function enrichDetachedStatus(record) {
305
354
  const enriched = cloneRecord();
306
355
  enriched.status = 'executed';
307
356
  enriched.exitCode = footerExit;
308
- if (!enriched.endTime) {
309
- enriched.endTime = new Date().toISOString();
310
- }
357
+ applyEndTime(enriched, dockerState, footer.finishedAt);
311
358
  return enriched;
312
359
  }
313
360
  if (oomKilled === true) {
@@ -319,9 +366,7 @@ function enrichDetachedStatus(record) {
319
366
  const enriched = cloneRecord();
320
367
  enriched.status = 'executed';
321
368
  enriched.exitCode = 137;
322
- if (!enriched.endTime) {
323
- enriched.endTime = new Date().toISOString();
324
- }
369
+ applyEndTime(enriched, dockerState, footer.finishedAt);
325
370
  return enriched;
326
371
  }
327
372
  }
@@ -361,14 +406,102 @@ function enrichDetachedStatus(record) {
361
406
  footerExit ??
362
407
  (oomKilled === true ? 137 : -1);
363
408
  }
364
- if (!enriched.endTime) {
365
- enriched.endTime = new Date().toISOString();
366
- }
409
+ applyEndTime(enriched, dockerState, footer.finishedAt);
367
410
  }
368
411
 
369
412
  return enriched;
370
413
  }
371
414
 
415
+ /**
416
+ * Attach an `exitReason` hint to a finished record.
417
+ *
418
+ * The hint explains an otherwise opaque exit code (`139` with `oomKilled
419
+ * false` is the motivating case from issue #162) and is derived from the same
420
+ * log tail the footer scan already reads. It is purely additive: `status`,
421
+ * `exitCode` and `oomKilled` are never changed by it.
422
+ *
423
+ * @param {Object} record - Execution record
424
+ * @param {string|null} logTail - Tail of the execution log
425
+ * @returns {Object} Record, or a copy carrying `exitReason`
426
+ */
427
+ function attachExitReason(record, logTail) {
428
+ if (!record || record.status !== 'executed') {
429
+ return record;
430
+ }
431
+
432
+ const exitReason = resolveExitReason({
433
+ exitCode: record.exitCode,
434
+ logTail,
435
+ oomKilled: record.oomKilled,
436
+ });
437
+
438
+ if (!exitReason) {
439
+ return record;
440
+ }
441
+
442
+ const enriched = Object.create(Object.getPrototypeOf(record));
443
+ Object.assign(enriched, record);
444
+ enriched.exitReason = exitReason;
445
+ return enriched;
446
+ }
447
+
448
+ /**
449
+ * Attach the memory-exhaustion observation to a finished record (issue #165).
450
+ *
451
+ * A runtime that aborts on its own heap limit (`FATAL ERROR: Reached heap limit
452
+ * ...`) never trips a container signal, so `oomKilled false` next to `exitCode
453
+ * 139` is a false negative for "out of memory" every single time. The evidence
454
+ * is in the log tail this scan already holds, so it is surfaced explicitly
455
+ * instead of leaving every consumer to re-derive it.
456
+ *
457
+ * Like `exitReason`, this is purely additive: `status`, `exitCode` and
458
+ * `oomKilled` are never changed by it.
459
+ *
460
+ * @param {Object} record - Execution record
461
+ * @param {string|null} logTail - Tail of the execution log
462
+ * @returns {Object} Record, or a copy carrying the memory fields
463
+ */
464
+ function attachMemoryExhaustion(record, logTail) {
465
+ if (!record || record.status !== 'executed') {
466
+ return record;
467
+ }
468
+
469
+ const memory = resolveMemoryExhaustion({
470
+ exitCode: record.exitCode,
471
+ logTail,
472
+ oomKilled: record.oomKilled,
473
+ });
474
+
475
+ if (!memory) {
476
+ return record;
477
+ }
478
+
479
+ const enriched = Object.create(Object.getPrototypeOf(record));
480
+ Object.assign(enriched, record);
481
+ enriched.memoryExhausted = memory.memoryExhausted;
482
+ enriched.memoryExhaustedReason = memory.memoryExhaustedReason;
483
+ return enriched;
484
+ }
485
+
486
+ /**
487
+ * Enrich execution record with live session status and exit diagnostics.
488
+ *
489
+ * The tail is read once, with the wider fatal-marker window: a dying runtime
490
+ * prints a native stack trace *after* its `FATAL ERROR` line, which can push
491
+ * the marker out of the narrower footer window (issue #165).
492
+ *
493
+ * @param {Object} record - Execution record
494
+ * @returns {Object} Possibly updated execution record
495
+ */
496
+ function enrichDetachedStatus(record) {
497
+ const logTail =
498
+ record && record.logPath
499
+ ? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
500
+ : null;
501
+ const resolved = resolveDetachedStatus(record, logTail);
502
+ return attachMemoryExhaustion(attachExitReason(resolved, logTail), logTail);
503
+ }
504
+
372
505
  /**
373
506
  * Wrap a record so its serialized form includes a `currentTime` field when
374
507
  * the status is "executing". This reflects the moment `--status` was invoked,
@@ -509,10 +642,19 @@ function formatRecordAsText(record) {
509
642
  `UUID: ${obj.uuid}`,
510
643
  `Status: ${obj.status}`,
511
644
  `Command: ${obj.command}`,
512
- `Exit Code: ${obj.exitCode !== null ? obj.exitCode : 'N/A'}`,
645
+ `Exit Code: ${obj.exitCode !== null ? describeExitCode(obj.exitCode).text : 'N/A'}`,
513
646
  ...(obj.oomKilled !== undefined
514
647
  ? [`OOM Killed: ${obj.oomKilled}`]
515
648
  : []),
649
+ ...(obj.exitReason !== undefined
650
+ ? [`Exit Reason: ${obj.exitReason}`]
651
+ : []),
652
+ ...(obj.memoryExhausted !== undefined
653
+ ? [`Memory Exhausted: ${obj.memoryExhausted}`]
654
+ : []),
655
+ ...(obj.memoryExhaustedReason !== undefined
656
+ ? [`Memory Evidence: ${obj.memoryExhaustedReason}`]
657
+ : []),
516
658
  `PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
517
659
  `Working Directory: ${obj.workingDirectory}`,
518
660
  `Shell: ${obj.shell}`,
@@ -522,10 +664,19 @@ function formatRecordAsText(record) {
522
664
  if (obj.currentTime) {
523
665
  lines.push(`Current Time: ${obj.currentTime}`);
524
666
  }
525
- lines.push(
526
- `End Time: ${obj.endTime || 'N/A'}`,
527
- `Log Path: ${obj.logPath}`
528
- );
667
+ lines.push(`End Time: ${obj.endTime || 'N/A'}`);
668
+ if (obj.endTimeSource) {
669
+ // Where `End Time` came from: a real finish time, or merely when `start`
670
+ // noticed the execution was over (issue #170.2).
671
+ lines.push(`End Time Source: ${obj.endTimeSource}`);
672
+ }
673
+ if (obj.containerStartedAt) {
674
+ lines.push(`Container Started: ${obj.containerStartedAt}`);
675
+ }
676
+ if (obj.staleDetectedAt) {
677
+ lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
678
+ }
679
+ lines.push(`Log Path: ${obj.logPath}`);
529
680
 
530
681
  // Format options as nested list instead of JSON
531
682
  const optionEntries = Object.entries(obj.options || {}).filter(
@@ -677,17 +828,25 @@ function queryStatus(store, identifier, outputFormat) {
677
828
  * Handle execution list query and output the result
678
829
  * @param {Object} store - ExecutionStore instance
679
830
  * @param {string|null} outputFormat - Output format (links-notation, json, text)
831
+ * @param {Object} [options] - List options
832
+ * @param {boolean} [options.running] - Only list executions that are still running
680
833
  * @returns {{success: boolean, output?: string, error?: string}}
681
834
  */
682
- function listExecutions(store, outputFormat) {
835
+ function listExecutions(store, outputFormat, options = {}) {
683
836
  if (!store) {
684
837
  return { success: false, error: 'Execution tracking is disabled.' };
685
838
  }
686
839
 
687
840
  try {
688
- const records = sortRecordsByStartTimeDesc(
841
+ const enriched = sortRecordsByStartTimeDesc(
689
842
  store.getAll().map(prepareRecordForList)
690
843
  );
844
+ // --running reports the reconciled status, not the stored one: a record
845
+ // whose session already died is listed as executed even if the store still
846
+ // says "executing" (issue #162).
847
+ const records = options.running
848
+ ? enriched.filter((record) => record.status === 'executing')
849
+ : enriched;
691
850
  return {
692
851
  success: true,
693
852
  output: formatRecordList(records, outputFormat || 'links-notation'),
@@ -708,8 +867,13 @@ module.exports = {
708
867
  listExecutions,
709
868
  isDetachedSessionAlive,
710
869
  enrichDetachedStatus,
870
+ parseFooterFromTail,
871
+ parseFooterTimestamp,
711
872
  readExitCodeFromLog,
712
873
  readLogTail,
874
+ attachExitReason,
875
+ attachMemoryExhaustion,
876
+ resolveDetachedStatus,
713
877
  attachCurrentTime,
714
878
  attachProcessIds,
715
879
  };
package/src/lib/usage.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Print usage information */
2
2
  function printUsage() {
3
- console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id>
3
+ console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--running] [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id> | $ --attach <id> [--read-only] | $ --resume <id> [-- <command>] | $ --resume-all
4
4
 
5
5
  Options:
6
6
  --isolated, --isolation, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
@@ -28,9 +28,15 @@ Options:
28
28
  --use-command-stream Use command-stream library for execution (experimental)
29
29
  --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
30
30
  --list List all tracked executions (--output-format: links-notation|json|text)
31
+ --running With --list, only report executions that are still running
31
32
  --upload-log <id> Upload the stored log for an execution UUID or session name
32
33
  --stop <id> Ask a detached isolated execution to stop gracefully
33
34
  --terminate <id> Terminate a detached isolated execution immediately
35
+ --attach <id> Attach to a running detached isolated execution
36
+ --read-only With --attach, follow output without sending input
37
+ --resume <id> Restart a stopped detached execution in the same environment
38
+ (append -- <command> to run a different command there)
39
+ --resume-all Re-attach or reconcile every execution still marked running
34
40
  --cleanup Clean up stale "executing" records (crashed/killed processes)
35
41
  --cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
36
42
  --version, -v Show version information
@@ -54,6 +60,12 @@ Examples:
54
60
  $ --upload-log my-screen-session # Upload stored execution log
55
61
  $ --stop my-screen-session # Ask detached execution to stop gracefully
56
62
  $ --terminate my-screen-session # Terminate detached execution immediately
63
+ $ --list --running # Only executions that are still running
64
+ $ --attach my-docker-session # Re-enter a running detached session
65
+ $ --attach my-docker-session --read-only # Follow its output without stdin
66
+ $ --resume my-docker-session # Restart the stored command in the same container
67
+ $ --resume my-docker-session -- bash # Run a different command in the same container
68
+ $ --resume-all # Re-attach every still-running execution
57
69
  $ --use-command-stream echo "Hello" # Use command-stream library`);
58
70
  console.log('');
59
71
  console.log('Piping with $:');
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  const { execSync, spawnSync } = require('child_process');
11
+ const { randomInt } = require('crypto');
11
12
 
12
13
  // Debug mode from environment
13
14
  const DEBUG =
@@ -73,6 +74,19 @@ function groupExists(groupname) {
73
74
  }
74
75
  }
75
76
 
77
+ /**
78
+ * Generate `length` cryptographically random base36 characters.
79
+ * @param {number} length
80
+ * @returns {string}
81
+ */
82
+ function randomBase36(length) {
83
+ let value = '';
84
+ while (value.length < length) {
85
+ value += randomInt(36).toString(36);
86
+ }
87
+ return value;
88
+ }
89
+
76
90
  /**
77
91
  * Generate a unique username for isolation
78
92
  * @param {string} [prefix='start'] - Prefix for the username
@@ -80,7 +94,10 @@ function groupExists(groupname) {
80
94
  */
81
95
  function generateIsolatedUsername(prefix = 'start') {
82
96
  const timestamp = Date.now().toString(36);
83
- const random = Math.random().toString(36).substring(2, 6);
97
+ // A CSPRNG, not Math.random: this suffix is the only unguessable part of the
98
+ // name of a user account that other processes may race to create
99
+ // (CodeQL js/insecure-randomness).
100
+ const random = randomBase36(4);
84
101
  // Keep username short (max 32 chars on most systems)
85
102
  // and valid (only alphanumeric, hyphen, underscore)
86
103
  return `${prefix}-${timestamp}${random}`.substring(0, 31);
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Tests for the --attach/--resume/--resume-all/--running options (issue #162)
3
+ */
4
+
5
+ const { test, describe } = require('node:test');
6
+ const assert = require('node:assert');
7
+ const { parseArgs, validateOptions } = require('../src/lib/args-parser');
8
+
9
+ function parse(args) {
10
+ const result = parseArgs(args);
11
+ validateOptions(result.wrapperOptions);
12
+ return result;
13
+ }
14
+
15
+ describe('--attach', () => {
16
+ test('accepts a session identifier as a separate argument', () => {
17
+ const { wrapperOptions } = parse(['--attach', 'my-session']);
18
+ assert.strictEqual(wrapperOptions.attach, 'my-session');
19
+ assert.strictEqual(wrapperOptions.readOnly, false);
20
+ });
21
+
22
+ test('accepts the --attach=<id> form', () => {
23
+ const { wrapperOptions } = parse([
24
+ '--attach=dd1acfbe-3c01-4ffa-8c78-f825457f5813',
25
+ ]);
26
+ assert.strictEqual(
27
+ wrapperOptions.attach,
28
+ 'dd1acfbe-3c01-4ffa-8c78-f825457f5813'
29
+ );
30
+ });
31
+
32
+ test('supports --read-only', () => {
33
+ const { wrapperOptions } = parse(['--attach', 'my-session', '--read-only']);
34
+ assert.strictEqual(wrapperOptions.attach, 'my-session');
35
+ assert.strictEqual(wrapperOptions.readOnly, true);
36
+ });
37
+
38
+ test('rejects --read-only without --attach', () => {
39
+ assert.throws(() => {
40
+ parse(['--read-only', '--status', 'my-session']);
41
+ }, /--read-only option is only valid with --attach/);
42
+ });
43
+
44
+ test('requires an identifier argument', () => {
45
+ assert.throws(() => {
46
+ parseArgs(['--attach']);
47
+ }, /Option --attach requires a UUID or session name argument/);
48
+ });
49
+
50
+ test('rejects an empty --attach= value', () => {
51
+ assert.throws(() => {
52
+ parseArgs(['--attach=']);
53
+ }, /Option --attach requires a UUID or session name argument/);
54
+ });
55
+ });
56
+
57
+ describe('--resume', () => {
58
+ test('accepts a session identifier', () => {
59
+ const { wrapperOptions, command } = parse(['--resume', 'my-session']);
60
+ assert.strictEqual(wrapperOptions.resume, 'my-session');
61
+ assert.strictEqual(command, '');
62
+ });
63
+
64
+ test('accepts a replacement command after the -- separator', () => {
65
+ const { wrapperOptions, command, rawCommand } = parse([
66
+ '--resume',
67
+ 'my-session',
68
+ '--',
69
+ 'echo',
70
+ 'hello world',
71
+ ]);
72
+ assert.strictEqual(wrapperOptions.resume, 'my-session');
73
+ // Quoted argv elements stay quoted so the inner shell keeps the
74
+ // boundaries the user typed (issue #164).
75
+ assert.strictEqual(command, "echo 'hello world'");
76
+ assert.deepStrictEqual(rawCommand, ['echo', 'hello world']);
77
+ });
78
+
79
+ test('accepts the --resume=<id> form with a replacement command', () => {
80
+ const { wrapperOptions, rawCommand } = parse([
81
+ '--resume=my-session',
82
+ '--',
83
+ 'npm',
84
+ 'test',
85
+ ]);
86
+ assert.strictEqual(wrapperOptions.resume, 'my-session');
87
+ assert.deepStrictEqual(rawCommand, ['npm', 'test']);
88
+ });
89
+
90
+ test('supports --output-format', () => {
91
+ const { wrapperOptions } = parse([
92
+ '--resume',
93
+ 'my-session',
94
+ '--output-format',
95
+ 'json',
96
+ ]);
97
+ assert.strictEqual(wrapperOptions.outputFormat, 'json');
98
+ });
99
+ });
100
+
101
+ describe('--resume-all', () => {
102
+ test('is a boolean flag', () => {
103
+ const { wrapperOptions } = parse(['--resume-all']);
104
+ assert.strictEqual(wrapperOptions.resumeAll, true);
105
+ });
106
+
107
+ test('supports --output-format', () => {
108
+ const { wrapperOptions } = parse([
109
+ '--resume-all',
110
+ '--output-format',
111
+ 'json',
112
+ ]);
113
+ assert.strictEqual(wrapperOptions.resumeAll, true);
114
+ assert.strictEqual(wrapperOptions.outputFormat, 'json');
115
+ });
116
+ });
117
+
118
+ describe('--list --running', () => {
119
+ test('sets the running filter', () => {
120
+ const { wrapperOptions } = parse(['--list', '--running']);
121
+ assert.strictEqual(wrapperOptions.list, true);
122
+ assert.strictEqual(wrapperOptions.running, true);
123
+ });
124
+
125
+ test('rejects --running without --list', () => {
126
+ assert.throws(() => {
127
+ parse(['--running']);
128
+ }, /--running option is only valid with --list/);
129
+ });
130
+ });
131
+
132
+ describe('query mode exclusivity', () => {
133
+ const combinations = [
134
+ ['--attach', 'a', '--resume', 'b'],
135
+ ['--attach', 'a', '--status', 'b'],
136
+ ['--resume', 'a', '--terminate', 'b'],
137
+ ['--resume-all', '--list'],
138
+ ['--resume-all', '--cleanup'],
139
+ ];
140
+
141
+ for (const args of combinations) {
142
+ test(`rejects ${args.join(' ')}`, () => {
143
+ assert.throws(() => {
144
+ parse(args);
145
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup/);
146
+ });
147
+ }
148
+ });
149
+
150
+ describe('defaults', () => {
151
+ test('are all inactive without query options', () => {
152
+ const { wrapperOptions } = parse(['echo', 'hi']);
153
+ assert.strictEqual(wrapperOptions.attach, null);
154
+ assert.strictEqual(wrapperOptions.readOnly, false);
155
+ assert.strictEqual(wrapperOptions.resume, null);
156
+ assert.strictEqual(wrapperOptions.resumeAll, false);
157
+ assert.strictEqual(wrapperOptions.running, false);
158
+ });
159
+ });