start-command 0.32.0 → 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.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -0
- package/eslint.config.mjs +8 -0
- package/package.json +7 -3
- package/src/bin/cli.js +23 -136
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/docker-cleanup.js +80 -5
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +210 -0
- package/src/lib/execution-resume.js +454 -0
- package/src/lib/execution-store.js +47 -4
- package/src/lib/exit-reason.js +234 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +58 -0
- package/src/lib/isolation.js +13 -10
- package/src/lib/query-commands.js +202 -0
- package/src/lib/screen-isolation.js +2 -1
- package/src/lib/session-probe.js +121 -0
- package/src/lib/shell-utils.js +164 -10
- package/src/lib/status-formatter.js +130 -57
- package/src/lib/usage.js +13 -1
- package/src/lib/user-manager.js +18 -1
- package/test/args-parser-attach-resume.js +159 -0
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/check-file-size.js +91 -0
- package/test/ci-workflow-invariants.js +191 -0
- package/test/docker-network-integration.js +22 -14
- package/test/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +52 -0
- package/test/exit-reason.js +209 -0
- package/test/failure-handler.js +107 -0
- package/test/load-command-stream.mjs +83 -0
- package/test/regression-162.js +320 -0
- package/test/regression-164.js +308 -0
- package/test/regression-165.js +310 -0
- package/test/regression-84.js +8 -3
- package/test/release-name.mjs +57 -0
- package/test/session-probe.js +150 -0
- 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
|
|
248
|
-
if (
|
|
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
|
-
*
|
|
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
|
|
271
|
-
const footerExit =
|
|
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
|
|
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 $:');
|
package/src/lib/user-manager.js
CHANGED
|
@@ -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
|
-
|
|
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 --
|
|
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', () => {
|
package/test/args-parser.js
CHANGED
|
@@ -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 --
|
|
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
|
+
});
|