start-command 0.32.1 → 0.33.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 (42) hide show
  1. package/CHANGELOG.md +21 -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 +23 -136
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/docker-cleanup.js +80 -5
  9. package/src/lib/execution-attach.js +245 -0
  10. package/src/lib/execution-resume-all.js +210 -0
  11. package/src/lib/execution-resume.js +454 -0
  12. package/src/lib/execution-store.js +47 -4
  13. package/src/lib/exit-reason.js +234 -0
  14. package/src/lib/failure-handler.js +43 -41
  15. package/src/lib/isolation-log-utils.js +58 -0
  16. package/src/lib/isolation.js +13 -10
  17. package/src/lib/query-commands.js +202 -0
  18. package/src/lib/screen-isolation.js +2 -1
  19. package/src/lib/session-probe.js +121 -0
  20. package/src/lib/shell-utils.js +164 -10
  21. package/src/lib/status-formatter.js +130 -57
  22. package/src/lib/usage.js +13 -1
  23. package/src/lib/user-manager.js +18 -1
  24. package/test/args-parser-attach-resume.js +159 -0
  25. package/test/args-parser-control.js +3 -3
  26. package/test/args-parser.js +1 -1
  27. package/test/check-file-size.js +91 -0
  28. package/test/ci-workflow-invariants.js +191 -0
  29. package/test/echo-integration.js +20 -13
  30. package/test/execution-attach.js +221 -0
  31. package/test/execution-resume.js +433 -0
  32. package/test/execution-store.js +52 -0
  33. package/test/exit-reason.js +209 -0
  34. package/test/failure-handler.js +107 -0
  35. package/test/load-command-stream.mjs +83 -0
  36. package/test/regression-162.js +320 -0
  37. package/test/regression-164.js +308 -0
  38. package/test/regression-165.js +310 -0
  39. package/test/regression-84.js +8 -3
  40. package/test/release-name.mjs +57 -0
  41. package/test/session-probe.js +150 -0
  42. package/test/user-manager.js +23 -0
@@ -8,13 +8,17 @@
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 { resolveExitReason, resolveMemoryExhaustion } = require('./exit-reason');
18
+ const {
19
+ FATAL_MARKER_TAIL_BYTES,
20
+ readLogTail,
21
+ } = require('./isolation-log-utils');
18
22
 
19
23
  /**
20
24
  * Inspect the live state of a detached docker container by name.
@@ -175,13 +179,6 @@ function isDetachedSessionAlive(record) {
175
179
  }
176
180
  }
177
181
 
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
182
  /**
186
183
  * The three-line terminal footer that `start` itself writes (see
187
184
  * `createLogFooter()` and `createShellLogFooterSnippet()`):
@@ -198,44 +195,6 @@ const LOG_TAIL_BYTES = 16 * 1024;
198
195
  const LOG_FOOTER_PATTERN =
199
196
  /^={10,}[ \t]*\r?\n^Finished:[^\r\n]*\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;
200
197
 
201
- /**
202
- * Read the last `bytes` bytes of a file as UTF-8 text.
203
- *
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.
207
- *
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
211
- */
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 {
227
- return null;
228
- } finally {
229
- if (fd !== undefined) {
230
- try {
231
- fs.closeSync(fd);
232
- } catch {
233
- // ignore
234
- }
235
- }
236
- }
237
- }
238
-
239
198
  /**
240
199
  * Read the terminal exit code from the anchored footer at the end of a log.
241
200
  * Returns null when the log has no footer yet (command still running, or the
@@ -244,12 +203,8 @@ function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
244
203
  * @param {string} logPath - Path to the log file
245
204
  * @returns {number|null}
246
205
  */
247
- function readExitCodeFromLog(logPath) {
248
- if (!logPath) {
249
- return null;
250
- }
251
- const tail = readLogTail(logPath);
252
- if (tail === null) {
206
+ function parseExitCodeFromTail(tail) {
207
+ if (tail === null || tail === undefined) {
253
208
  return null;
254
209
  }
255
210
  const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
@@ -259,16 +214,24 @@ function readExitCodeFromLog(logPath) {
259
214
  return parseInt(matches[matches.length - 1][1], 10);
260
215
  }
261
216
 
217
+ function readExitCodeFromLog(logPath) {
218
+ if (!logPath) {
219
+ return null;
220
+ }
221
+ return parseExitCodeFromTail(readLogTail(logPath));
222
+ }
223
+
262
224
  /**
263
- * Enrich execution record with live session status for detached executions.
225
+ * Resolve the live status of a detached execution.
264
226
  * If a record shows "executing" but the detached session has actually ended,
265
227
  * returns an updated copy with status "executed". If it shows "executed" but
266
228
  * the session is still running, returns a copy with status "executing".
267
229
  * @param {Object} record - Execution record
230
+ * @param {string|null} logTail - Tail of the execution log (already read once)
268
231
  * @returns {Object} Possibly updated execution record
269
232
  */
270
- function enrichDetachedStatus(record) {
271
- const footerExit = readExitCodeFromLog(record.logPath);
233
+ function resolveDetachedStatus(record, logTail) {
234
+ const footerExit = parseExitCodeFromTail(logTail);
272
235
  const dockerState = isDetachedDockerRecord(record)
273
236
  ? readDockerState(record)
274
237
  : null;
@@ -369,6 +332,96 @@ function enrichDetachedStatus(record) {
369
332
  return enriched;
370
333
  }
371
334
 
335
+ /**
336
+ * Attach an `exitReason` hint to a finished record.
337
+ *
338
+ * The hint explains an otherwise opaque exit code (`139` with `oomKilled
339
+ * false` is the motivating case from issue #162) and is derived from the same
340
+ * log tail the footer scan already reads. It is purely additive: `status`,
341
+ * `exitCode` and `oomKilled` are never changed by it.
342
+ *
343
+ * @param {Object} record - Execution record
344
+ * @param {string|null} logTail - Tail of the execution log
345
+ * @returns {Object} Record, or a copy carrying `exitReason`
346
+ */
347
+ function attachExitReason(record, logTail) {
348
+ if (!record || record.status !== 'executed') {
349
+ return record;
350
+ }
351
+
352
+ const exitReason = resolveExitReason({
353
+ exitCode: record.exitCode,
354
+ logTail,
355
+ oomKilled: record.oomKilled,
356
+ });
357
+
358
+ if (!exitReason) {
359
+ return record;
360
+ }
361
+
362
+ const enriched = Object.create(Object.getPrototypeOf(record));
363
+ Object.assign(enriched, record);
364
+ enriched.exitReason = exitReason;
365
+ return enriched;
366
+ }
367
+
368
+ /**
369
+ * Attach the memory-exhaustion observation to a finished record (issue #165).
370
+ *
371
+ * A runtime that aborts on its own heap limit (`FATAL ERROR: Reached heap limit
372
+ * ...`) never trips a container signal, so `oomKilled false` next to `exitCode
373
+ * 139` is a false negative for "out of memory" every single time. The evidence
374
+ * is in the log tail this scan already holds, so it is surfaced explicitly
375
+ * instead of leaving every consumer to re-derive it.
376
+ *
377
+ * Like `exitReason`, this is purely additive: `status`, `exitCode` and
378
+ * `oomKilled` are never changed by it.
379
+ *
380
+ * @param {Object} record - Execution record
381
+ * @param {string|null} logTail - Tail of the execution log
382
+ * @returns {Object} Record, or a copy carrying the memory fields
383
+ */
384
+ function attachMemoryExhaustion(record, logTail) {
385
+ if (!record || record.status !== 'executed') {
386
+ return record;
387
+ }
388
+
389
+ const memory = resolveMemoryExhaustion({
390
+ exitCode: record.exitCode,
391
+ logTail,
392
+ oomKilled: record.oomKilled,
393
+ });
394
+
395
+ if (!memory) {
396
+ return record;
397
+ }
398
+
399
+ const enriched = Object.create(Object.getPrototypeOf(record));
400
+ Object.assign(enriched, record);
401
+ enriched.memoryExhausted = memory.memoryExhausted;
402
+ enriched.memoryExhaustedReason = memory.memoryExhaustedReason;
403
+ return enriched;
404
+ }
405
+
406
+ /**
407
+ * Enrich execution record with live session status and exit diagnostics.
408
+ *
409
+ * The tail is read once, with the wider fatal-marker window: a dying runtime
410
+ * prints a native stack trace *after* its `FATAL ERROR` line, which can push
411
+ * the marker out of the narrower footer window (issue #165).
412
+ *
413
+ * @param {Object} record - Execution record
414
+ * @returns {Object} Possibly updated execution record
415
+ */
416
+ function enrichDetachedStatus(record) {
417
+ const logTail =
418
+ record && record.logPath
419
+ ? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
420
+ : null;
421
+ const resolved = resolveDetachedStatus(record, logTail);
422
+ return attachMemoryExhaustion(attachExitReason(resolved, logTail), logTail);
423
+ }
424
+
372
425
  /**
373
426
  * Wrap a record so its serialized form includes a `currentTime` field when
374
427
  * the status is "executing". This reflects the moment `--status` was invoked,
@@ -513,6 +566,15 @@ function formatRecordAsText(record) {
513
566
  ...(obj.oomKilled !== undefined
514
567
  ? [`OOM Killed: ${obj.oomKilled}`]
515
568
  : []),
569
+ ...(obj.exitReason !== undefined
570
+ ? [`Exit Reason: ${obj.exitReason}`]
571
+ : []),
572
+ ...(obj.memoryExhausted !== undefined
573
+ ? [`Memory Exhausted: ${obj.memoryExhausted}`]
574
+ : []),
575
+ ...(obj.memoryExhaustedReason !== undefined
576
+ ? [`Memory Evidence: ${obj.memoryExhaustedReason}`]
577
+ : []),
516
578
  `PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
517
579
  `Working Directory: ${obj.workingDirectory}`,
518
580
  `Shell: ${obj.shell}`,
@@ -677,17 +739,25 @@ function queryStatus(store, identifier, outputFormat) {
677
739
  * Handle execution list query and output the result
678
740
  * @param {Object} store - ExecutionStore instance
679
741
  * @param {string|null} outputFormat - Output format (links-notation, json, text)
742
+ * @param {Object} [options] - List options
743
+ * @param {boolean} [options.running] - Only list executions that are still running
680
744
  * @returns {{success: boolean, output?: string, error?: string}}
681
745
  */
682
- function listExecutions(store, outputFormat) {
746
+ function listExecutions(store, outputFormat, options = {}) {
683
747
  if (!store) {
684
748
  return { success: false, error: 'Execution tracking is disabled.' };
685
749
  }
686
750
 
687
751
  try {
688
- const records = sortRecordsByStartTimeDesc(
752
+ const enriched = sortRecordsByStartTimeDesc(
689
753
  store.getAll().map(prepareRecordForList)
690
754
  );
755
+ // --running reports the reconciled status, not the stored one: a record
756
+ // whose session already died is listed as executed even if the store still
757
+ // says "executing" (issue #162).
758
+ const records = options.running
759
+ ? enriched.filter((record) => record.status === 'executing')
760
+ : enriched;
691
761
  return {
692
762
  success: true,
693
763
  output: formatRecordList(records, outputFormat || 'links-notation'),
@@ -710,6 +780,9 @@ module.exports = {
710
780
  enrichDetachedStatus,
711
781
  readExitCodeFromLog,
712
782
  readLogTail,
783
+ attachExitReason,
784
+ attachMemoryExhaustion,
785
+ resolveDetachedStatus,
713
786
  attachCurrentTime,
714
787
  attachProcessIds,
715
788
  };
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
+ });
@@ -54,19 +54,19 @@ describe('control options', () => {
54
54
  it('should reject combining query and control modes', () => {
55
55
  assert.throws(() => {
56
56
  parseArgs(['--status', 'uuid-here', '--stop', 'my-session']);
57
- }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup/);
57
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup/);
58
58
  });
59
59
 
60
60
  it('should reject combining upload-log with control modes', () => {
61
61
  assert.throws(() => {
62
62
  parseArgs(['--upload-log', 'uuid-here', '--terminate', 'my-session']);
63
- }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup/);
63
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup/);
64
64
  });
65
65
 
66
66
  it('should reject output-format with control modes', () => {
67
67
  assert.throws(() => {
68
68
  parseArgs(['--stop', 'my-session', '--output-format', 'json']);
69
- }, /--output-format option is only valid with --status or --list/);
69
+ }, /--output-format option is only valid with --status, --list, --resume, or --resume-all/);
70
70
  });
71
71
 
72
72
  it('should default control options to null', () => {
@@ -859,7 +859,7 @@ describe('output-format option', () => {
859
859
  it('should throw error for output-format without status', () => {
860
860
  assert.throws(() => {
861
861
  parseArgs(['--output-format', 'json', '--', 'npm', 'test']);
862
- }, /--output-format option is only valid with --status or --list/);
862
+ }, /--output-format option is only valid with --status, --list, --resume, or --resume-all/);
863
863
  });
864
864
 
865
865
  it('should accept all valid output formats', () => {
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Behaviour of `scripts/check-file-size.mjs` (issue #168).
4
+ *
5
+ * The 1000-line limit is a refactoring rule for this repository's own source.
6
+ * It fired on `dev/log/issues/168/pulls/169/upstream/use-m-8.15.1-use.js` - a
7
+ * third-party file archived verbatim as investigation evidence - and failed
8
+ * both the JavaScript and the Rust pipeline. That is a false positive: the
9
+ * archive cannot be refactored without destroying the evidence it preserves.
10
+ *
11
+ * The paths are compared with forward slashes: the checker normalises them so
12
+ * the exclusion list matches on Windows too, where `relative()` would otherwise
13
+ * yield `dev\\log`.
14
+ *
15
+ * The Rust suite mirrors these checks in `rust/tests/check_file_size.rs`.
16
+ */
17
+
18
+ const { describe, it } = require('node:test');
19
+ const assert = require('assert');
20
+ const { execFileSync } = require('child_process');
21
+ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require('fs');
22
+ const { tmpdir } = require('os');
23
+ const { join, resolve } = require('path');
24
+
25
+ const script = resolve(__dirname, '..', '..', 'scripts', 'check-file-size.mjs');
26
+
27
+ /**
28
+ * Run the checker over a throwaway tree.
29
+ * @param {Record<string, number>} files - relative path -> line count
30
+ * @returns {{code: number, output: string}}
31
+ */
32
+ function runChecker(files) {
33
+ const dir = mkdtempSync(join(tmpdir(), 'check-file-size-'));
34
+ try {
35
+ for (const [relativePath, lines] of Object.entries(files)) {
36
+ const full = join(dir, relativePath);
37
+ mkdirSync(resolve(full, '..'), { recursive: true });
38
+ writeFileSync(full, 'const x = 1;\n'.repeat(lines));
39
+ }
40
+ try {
41
+ const output = execFileSync('node', [script], {
42
+ cwd: dir,
43
+ encoding: 'utf8',
44
+ });
45
+ return { code: 0, output };
46
+ } catch (error) {
47
+ return {
48
+ code: error.status,
49
+ output: `${error.stdout || ''}${error.stderr || ''}`,
50
+ };
51
+ }
52
+ } finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ }
56
+
57
+ describe('check-file-size', () => {
58
+ it('fails on an oversized file in the repository source', () => {
59
+ const result = runChecker({ 'scripts/huge.mjs': 1500 });
60
+ assert.strictEqual(result.code, 1);
61
+ assert.ok(result.output.includes('scripts/huge.mjs'));
62
+ });
63
+
64
+ it('passes when every file is within the limit', () => {
65
+ const result = runChecker({ 'scripts/small.mjs': 10 });
66
+ assert.strictEqual(result.code, 0);
67
+ assert.ok(result.output.includes('within the line limit'));
68
+ });
69
+
70
+ it('ignores archived evidence under dev/log', () => {
71
+ const result = runChecker({
72
+ 'dev/log/issues/168/pulls/169/upstream/vendored.js': 1500,
73
+ 'scripts/small.mjs': 10,
74
+ });
75
+ assert.strictEqual(result.code, 0);
76
+ assert.ok(!result.output.includes('vendored.js'));
77
+ });
78
+
79
+ it('reports paths with forward slashes on every platform', () => {
80
+ const result = runChecker({ 'scripts/nested/huge.mjs': 1500 });
81
+ assert.strictEqual(result.code, 1);
82
+ assert.ok(result.output.includes('scripts/nested/huge.mjs'));
83
+ assert.ok(!result.output.includes('scripts\\nested'));
84
+ });
85
+
86
+ it('still checks Rust sources outside the archive', () => {
87
+ const result = runChecker({ 'rust/src/huge.rs': 1500 });
88
+ assert.strictEqual(result.code, 1);
89
+ assert.ok(result.output.includes('rust/src/huge.rs'));
90
+ });
91
+ });