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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query and control command handlers.
|
|
3
|
+
*
|
|
4
|
+
* Every option in this module addresses an execution that already exists in
|
|
5
|
+
* the tracking store instead of launching a new one: --status, --list,
|
|
6
|
+
* --upload-log, --stop, --terminate, --attach, --resume, --resume-all and
|
|
7
|
+
* --cleanup. `dispatchQueryCommand` is the single entry point the CLI calls
|
|
8
|
+
* before it starts interpreting the command line as a command to run.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { queryStatus, listExecutions } = require('./status-formatter');
|
|
12
|
+
const { ControlAction, controlExecution } = require('./execution-control');
|
|
13
|
+
const { attachExecution } = require('./execution-attach');
|
|
14
|
+
const { resumeAllExecutions, resumeExecution } = require('./execution-resume');
|
|
15
|
+
const { uploadExecutionLog } = require('./log-uploader');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Print a handler result, returning the process exit code.
|
|
19
|
+
* @param {{success: boolean, output?: string, error?: string, exitCode?: number}} result
|
|
20
|
+
* @returns {number} Exit code
|
|
21
|
+
*/
|
|
22
|
+
function reportResult(result) {
|
|
23
|
+
if (result.success) {
|
|
24
|
+
if (result.output) {
|
|
25
|
+
console.log(result.output);
|
|
26
|
+
}
|
|
27
|
+
return result.exitCode || 0;
|
|
28
|
+
}
|
|
29
|
+
console.error(`Error: ${result.error}`);
|
|
30
|
+
return result.exitCode || 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function handleStatusQuery(store, identifier, outputFormat) {
|
|
34
|
+
return reportResult(queryStatus(store, identifier, outputFormat));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handleListQuery(store, outputFormat, options = {}) {
|
|
38
|
+
return reportResult(listExecutions(store, outputFormat, options));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function handleUploadLogQuery(store, identifier) {
|
|
42
|
+
const result = uploadExecutionLog(store, identifier);
|
|
43
|
+
if (result.success) {
|
|
44
|
+
return result.exitCode || 0;
|
|
45
|
+
}
|
|
46
|
+
console.error(`Error: ${result.error}`);
|
|
47
|
+
return result.exitCode || 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleControlQuery(store, identifier, action) {
|
|
51
|
+
return reportResult(controlExecution(store, identifier, action));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleAttachQuery(store, identifier, readOnly) {
|
|
55
|
+
return reportResult(attachExecution(store, identifier, { readOnly }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function handleResumeQuery(store, identifier, command, outputFormat) {
|
|
59
|
+
return reportResult(
|
|
60
|
+
await resumeExecution(store, identifier, { command, outputFormat })
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function handleResumeAllQuery(store, outputFormat) {
|
|
65
|
+
return reportResult(resumeAllExecutions(store, { outputFormat }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Handle --cleanup / --cleanup-dry-run.
|
|
70
|
+
* Cleans up stale "executing" records (processes that crashed or were killed).
|
|
71
|
+
* @param {?object} store - Execution store
|
|
72
|
+
* @param {boolean} dryRun - If true, just report what would be cleaned
|
|
73
|
+
* @returns {number} Exit code
|
|
74
|
+
*/
|
|
75
|
+
function handleCleanup(store, dryRun) {
|
|
76
|
+
if (!store) {
|
|
77
|
+
console.error('Error: Execution tracking is disabled.');
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = store.cleanupStale({ dryRun });
|
|
82
|
+
|
|
83
|
+
if (result.errors.length > 0) {
|
|
84
|
+
for (const error of result.errors) {
|
|
85
|
+
console.error(`Error: ${error}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (result.records.length === 0) {
|
|
90
|
+
console.log('No stale records found.');
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (dryRun) {
|
|
95
|
+
console.log(
|
|
96
|
+
`Found ${result.records.length} stale record(s) that would be cleaned up:\n`
|
|
97
|
+
);
|
|
98
|
+
} else {
|
|
99
|
+
console.log(`Cleaned up ${result.cleaned} stale record(s):\n`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const record of result.records) {
|
|
103
|
+
const startTime = new Date(record.startTime).toLocaleString();
|
|
104
|
+
console.log(` UUID: ${record.uuid}`);
|
|
105
|
+
console.log(` Command: ${record.command}`);
|
|
106
|
+
console.log(` Started: ${startTime}`);
|
|
107
|
+
console.log(` PID: ${record.pid || 'N/A'}`);
|
|
108
|
+
console.log('');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
console.log('Run with --cleanup to actually clean up these records.');
|
|
113
|
+
}
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hasValue(value) {
|
|
118
|
+
return value !== null && value !== undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Report whether any query/control mode is active.
|
|
123
|
+
* @param {object} options - Parsed wrapper options
|
|
124
|
+
* @returns {boolean} True when the invocation addresses a stored execution
|
|
125
|
+
*/
|
|
126
|
+
function hasQueryMode(options) {
|
|
127
|
+
return (
|
|
128
|
+
hasValue(options.status) ||
|
|
129
|
+
options.list === true ||
|
|
130
|
+
hasValue(options.uploadLog) ||
|
|
131
|
+
hasValue(options.stop) ||
|
|
132
|
+
hasValue(options.terminate) ||
|
|
133
|
+
hasValue(options.attach) ||
|
|
134
|
+
hasValue(options.resume) ||
|
|
135
|
+
options.resumeAll === true ||
|
|
136
|
+
options.cleanup === true
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run the query/control mode selected by the wrapper options, if any.
|
|
142
|
+
* @param {object} options - Parsed wrapper options
|
|
143
|
+
* @param {object} context - {store, command}
|
|
144
|
+
* @returns {Promise<?number>} Exit code, or null when no query mode is active
|
|
145
|
+
*/
|
|
146
|
+
async function dispatchQueryCommand(options, context = {}) {
|
|
147
|
+
const store = context.store || null;
|
|
148
|
+
const outputFormat = options.outputFormat;
|
|
149
|
+
|
|
150
|
+
if (hasValue(options.status)) {
|
|
151
|
+
return handleStatusQuery(store, options.status, outputFormat);
|
|
152
|
+
}
|
|
153
|
+
if (options.list) {
|
|
154
|
+
return handleListQuery(store, outputFormat, { running: options.running });
|
|
155
|
+
}
|
|
156
|
+
if (hasValue(options.uploadLog)) {
|
|
157
|
+
return handleUploadLogQuery(store, options.uploadLog);
|
|
158
|
+
}
|
|
159
|
+
if (hasValue(options.stop)) {
|
|
160
|
+
return handleControlQuery(store, options.stop, ControlAction.STOP);
|
|
161
|
+
}
|
|
162
|
+
if (hasValue(options.terminate)) {
|
|
163
|
+
return handleControlQuery(
|
|
164
|
+
store,
|
|
165
|
+
options.terminate,
|
|
166
|
+
ControlAction.TERMINATE
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (hasValue(options.attach)) {
|
|
170
|
+
return handleAttachQuery(store, options.attach, options.readOnly === true);
|
|
171
|
+
}
|
|
172
|
+
if (hasValue(options.resume)) {
|
|
173
|
+
return await handleResumeQuery(
|
|
174
|
+
store,
|
|
175
|
+
options.resume,
|
|
176
|
+
context.command || null,
|
|
177
|
+
outputFormat
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (options.resumeAll) {
|
|
181
|
+
return handleResumeAllQuery(store, outputFormat);
|
|
182
|
+
}
|
|
183
|
+
if (options.cleanup) {
|
|
184
|
+
return handleCleanup(store, options.cleanupDryRun);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
dispatchQueryCommand,
|
|
192
|
+
hasQueryMode,
|
|
193
|
+
handleAttachQuery,
|
|
194
|
+
handleCleanup,
|
|
195
|
+
handleControlQuery,
|
|
196
|
+
handleListQuery,
|
|
197
|
+
handleResumeAllQuery,
|
|
198
|
+
handleResumeQuery,
|
|
199
|
+
handleStatusQuery,
|
|
200
|
+
handleUploadLogQuery,
|
|
201
|
+
reportResult,
|
|
202
|
+
};
|
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
getTempDir,
|
|
9
9
|
wrapCommandWithLogFooter,
|
|
10
10
|
} = require('./isolation-log-utils');
|
|
11
|
+
const { toShellWords } = require('./shell-utils');
|
|
11
12
|
|
|
12
13
|
const setTimeout = globalThis.setTimeout;
|
|
13
14
|
|
|
@@ -213,7 +214,7 @@ function runScreenWithLogCapture(
|
|
|
213
214
|
'-L',
|
|
214
215
|
'-c',
|
|
215
216
|
screenrcFile,
|
|
216
|
-
...command
|
|
217
|
+
...toShellWords(command),
|
|
217
218
|
]
|
|
218
219
|
: [
|
|
219
220
|
'-dmS',
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session liveness probing for tracked executions.
|
|
3
|
+
*
|
|
4
|
+
* `--attach`, `--resume` and `--resume-all` all need the same question
|
|
5
|
+
* answered first: is the isolation session behind this execution record still
|
|
6
|
+
* alive, stopped but recoverable, or gone entirely? The probe never mutates
|
|
7
|
+
* anything, so it is safe to run against every tracked record.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { runCommand } = require('./execution-control');
|
|
11
|
+
const { getDockerCommand } = require('./docker-cleanup');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* States a tracked isolation session can be observed in.
|
|
15
|
+
* - RUNNING: the session/container is alive and can be attached to
|
|
16
|
+
* - STOPPED: the container still exists but is not running (resumable)
|
|
17
|
+
* - MISSING: no trace of the session/container remains
|
|
18
|
+
* - UNKNOWN: the backend cannot be probed locally (e.g. ssh)
|
|
19
|
+
*/
|
|
20
|
+
const SessionState = {
|
|
21
|
+
RUNNING: 'running',
|
|
22
|
+
STOPPED: 'stopped',
|
|
23
|
+
MISSING: 'missing',
|
|
24
|
+
UNKNOWN: 'unknown',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map a `docker inspect -f {{.State.Status}}` value to a session state.
|
|
29
|
+
* @param {?string} status - Docker container status
|
|
30
|
+
* @returns {string} SessionState value
|
|
31
|
+
*/
|
|
32
|
+
function mapDockerStatusToState(status) {
|
|
33
|
+
const normalized = String(status || '')
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase();
|
|
36
|
+
if (!normalized) {
|
|
37
|
+
return SessionState.UNKNOWN;
|
|
38
|
+
}
|
|
39
|
+
if (normalized === 'running' || normalized === 'restarting') {
|
|
40
|
+
return SessionState.RUNNING;
|
|
41
|
+
}
|
|
42
|
+
return SessionState.STOPPED;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find a session in `screen -ls` output.
|
|
47
|
+
* Session names are matched exactly against the `<pid>.<name>` column so a
|
|
48
|
+
* prefix like "session" never matches "my-session".
|
|
49
|
+
* @param {string} output - Combined stdout/stderr of `screen -ls`
|
|
50
|
+
* @param {string} sessionName - Session name to look for
|
|
51
|
+
* @returns {string} SessionState.RUNNING or SessionState.MISSING
|
|
52
|
+
*/
|
|
53
|
+
function parseScreenSessionState(output, sessionName) {
|
|
54
|
+
for (const line of String(output || '').split('\n')) {
|
|
55
|
+
const firstColumn = line.trim().split(/\s+/)[0] || '';
|
|
56
|
+
const match = firstColumn.match(/^(\d+)\.(.+)$/);
|
|
57
|
+
if (match && match[2] === sessionName) {
|
|
58
|
+
return SessionState.RUNNING;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return SessionState.MISSING;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Probe the isolation session behind an execution record.
|
|
66
|
+
* @param {object} record - Execution record
|
|
67
|
+
* @param {function} runner - Command runner (injectable for tests)
|
|
68
|
+
* @returns {{backend: ?string, sessionName: ?string, state: string, alive: boolean, containerStatus: ?string}}
|
|
69
|
+
*/
|
|
70
|
+
function probeSession(record, runner = runCommand) {
|
|
71
|
+
const opts = (record && record.options) || {};
|
|
72
|
+
const backend = opts.isolated || null;
|
|
73
|
+
const sessionName = opts.sessionName || null;
|
|
74
|
+
const probe = {
|
|
75
|
+
backend,
|
|
76
|
+
sessionName,
|
|
77
|
+
state: SessionState.UNKNOWN,
|
|
78
|
+
alive: false,
|
|
79
|
+
containerStatus: null,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (!sessionName || !backend) {
|
|
83
|
+
return probe;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (backend === 'docker') {
|
|
87
|
+
const result = runner(getDockerCommand(), [
|
|
88
|
+
'inspect',
|
|
89
|
+
'-f',
|
|
90
|
+
'{{.State.Status}}',
|
|
91
|
+
sessionName,
|
|
92
|
+
]);
|
|
93
|
+
if (!result.success) {
|
|
94
|
+
probe.state = SessionState.MISSING;
|
|
95
|
+
return probe;
|
|
96
|
+
}
|
|
97
|
+
probe.containerStatus = String(result.stdout || '').trim() || null;
|
|
98
|
+
probe.state = mapDockerStatusToState(probe.containerStatus);
|
|
99
|
+
} else if (backend === 'screen') {
|
|
100
|
+
const result = runner('screen', ['-ls']);
|
|
101
|
+
// `screen -ls` exits non-zero even when it lists sessions, so the output is
|
|
102
|
+
// authoritative, not the exit status.
|
|
103
|
+
probe.state = parseScreenSessionState(
|
|
104
|
+
`${result.stdout || ''}${result.stderr || ''}`,
|
|
105
|
+
sessionName
|
|
106
|
+
);
|
|
107
|
+
} else if (backend === 'tmux') {
|
|
108
|
+
const result = runner('tmux', ['has-session', '-t', sessionName]);
|
|
109
|
+
probe.state = result.success ? SessionState.RUNNING : SessionState.MISSING;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
probe.alive = probe.state === SessionState.RUNNING;
|
|
113
|
+
return probe;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
SessionState,
|
|
118
|
+
mapDockerStatusToState,
|
|
119
|
+
parseScreenSessionState,
|
|
120
|
+
probeSession,
|
|
121
|
+
};
|
package/src/lib/shell-utils.js
CHANGED
|
@@ -4,21 +4,146 @@ const path = require('path');
|
|
|
4
4
|
|
|
5
5
|
const SHELL_NAMES = ['bash', 'zsh', 'sh', 'fish', 'ksh', 'csh', 'tcsh', 'dash'];
|
|
6
6
|
|
|
7
|
+
/** Argument characters that a POSIX shell reads literally, so they need no quoting. */
|
|
8
|
+
const SAFE_ARG_PATTERN = /^[A-Za-z0-9_@%+=:,./^-]+$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Argument characters PowerShell reads literally. Narrower than the POSIX set:
|
|
12
|
+
* `,` builds an array, `@` splats and `%` is an alias for ForEach-Object.
|
|
13
|
+
*/
|
|
14
|
+
const POWERSHELL_SAFE_ARG_PATTERN = /^[A-Za-z0-9_=:.\\/-]+$/;
|
|
15
|
+
|
|
16
|
+
/** The quoting dialect of the shell that will run the rebuilt command line. */
|
|
17
|
+
function defaultShellQuotingStyle() {
|
|
18
|
+
return process.platform === 'win32' ? 'powershell' : 'posix';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Quote one argv element so the target shell parses it back as exactly that
|
|
23
|
+
* element (issue #164). PowerShell escapes a single quote by doubling it;
|
|
24
|
+
* a POSIX shell closes the quote, escapes, and reopens.
|
|
25
|
+
* @param {string} arg - Argument to quote
|
|
26
|
+
* @param {'posix'|'powershell'} [style] - Quoting dialect; defaults to the host shell
|
|
27
|
+
* @returns {string} Quoted argument
|
|
28
|
+
*/
|
|
29
|
+
function quoteShellArg(arg, style = defaultShellQuotingStyle()) {
|
|
30
|
+
const value = String(arg);
|
|
31
|
+
if (style === 'powershell') {
|
|
32
|
+
return POWERSHELL_SAFE_ARG_PATTERN.test(value)
|
|
33
|
+
? value
|
|
34
|
+
: `'${value.replace(/'/g, "''")}'`;
|
|
35
|
+
}
|
|
36
|
+
return SAFE_ARG_PATTERN.test(value)
|
|
37
|
+
? value
|
|
38
|
+
: `'${value.replace(/'/g, "'\\''")}'`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Rebuild a shell command line from the argv the user typed (issue #164).
|
|
43
|
+
* A single element is a shell script the user quoted as a whole (`$ "ls | wc -l"`)
|
|
44
|
+
* and is kept verbatim; multiple elements were split by the outer shell, so each
|
|
45
|
+
* one is quoted to survive the inner shell unchanged.
|
|
46
|
+
* @param {string[]} argv - Command arguments
|
|
47
|
+
* @param {'posix'|'powershell'} [style] - Quoting dialect; defaults to the host shell
|
|
48
|
+
* @returns {string} Command line for `sh -c` (or `powershell -Command` on Windows)
|
|
49
|
+
*/
|
|
50
|
+
function buildCommandString(argv, style = defaultShellQuotingStyle()) {
|
|
51
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
return argv.length === 1
|
|
55
|
+
? argv[0]
|
|
56
|
+
: argv.map((arg) => quoteShellArg(arg, style)).join(' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Split a command line into shell words, reversing quoteShellArg for the same
|
|
61
|
+
* dialect: a POSIX shell escapes with a backslash, PowerShell has no backslash
|
|
62
|
+
* escape (it is a path separator) and writes a literal quote as a doubled one.
|
|
63
|
+
* @param {string} command - Command line
|
|
64
|
+
* @param {'posix'|'powershell'} [style] - Quoting dialect; defaults to the host shell
|
|
65
|
+
* @returns {string[]|null} Words, or null when quoting is unbalanced
|
|
66
|
+
*/
|
|
67
|
+
function splitShellWords(command, style = defaultShellQuotingStyle()) {
|
|
68
|
+
const isPowerShell = style === 'powershell';
|
|
69
|
+
const words = [];
|
|
70
|
+
let current = '';
|
|
71
|
+
let started = false;
|
|
72
|
+
let quote = null;
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < command.length; i++) {
|
|
75
|
+
const char = command[i];
|
|
76
|
+
if (quote === null && /\s/.test(char)) {
|
|
77
|
+
if (started) {
|
|
78
|
+
words.push(current);
|
|
79
|
+
current = '';
|
|
80
|
+
started = false;
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
started = true;
|
|
85
|
+
if (!isPowerShell && char === '\\' && quote !== "'") {
|
|
86
|
+
if (i + 1 >= command.length) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
current += command[++i];
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (quote === null && (char === "'" || char === '"')) {
|
|
93
|
+
quote = char;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (char === quote) {
|
|
97
|
+
if (isPowerShell && command[i + 1] === quote) {
|
|
98
|
+
current += quote;
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
quote = null;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
current += char;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (quote !== null) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (started) {
|
|
112
|
+
words.push(current);
|
|
113
|
+
}
|
|
114
|
+
return words;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Split a command into words, falling back to whitespace splitting when quoting is unbalanced. */
|
|
118
|
+
function toShellWords(command) {
|
|
119
|
+
return (
|
|
120
|
+
splitShellWords(command) ?? command.trim().split(/\s+/).filter(Boolean)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
7
124
|
/** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
|
|
8
125
|
function isInteractiveShellCommand(command) {
|
|
9
|
-
const parts = command
|
|
10
|
-
return
|
|
126
|
+
const parts = toShellWords(command);
|
|
127
|
+
return (
|
|
128
|
+
parts.length > 0 &&
|
|
129
|
+
SHELL_NAMES.includes(path.basename(parts[0])) &&
|
|
130
|
+
!parts.includes('-c')
|
|
131
|
+
);
|
|
11
132
|
}
|
|
12
133
|
|
|
13
134
|
/** True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91). */
|
|
14
135
|
function isShellInvocationWithArgs(command) {
|
|
15
|
-
const parts = command
|
|
16
|
-
return
|
|
136
|
+
const parts = toShellWords(command);
|
|
137
|
+
return (
|
|
138
|
+
parts.length > 0 &&
|
|
139
|
+
SHELL_NAMES.includes(path.basename(parts[0])) &&
|
|
140
|
+
parts.includes('-c')
|
|
141
|
+
);
|
|
17
142
|
}
|
|
18
143
|
|
|
19
|
-
/** Build argv for shell-with-c command; everything after -c is one argument
|
|
144
|
+
/** Build argv for shell-with-c command; everything after -c is one script argument. */
|
|
20
145
|
function buildShellWithArgsCmdArgs(command) {
|
|
21
|
-
const parts = command
|
|
146
|
+
const parts = toShellWords(command);
|
|
22
147
|
const cIdx = parts.indexOf('-c');
|
|
23
148
|
if (cIdx === -1) {
|
|
24
149
|
return parts;
|
|
@@ -29,19 +154,48 @@ function buildShellWithArgsCmdArgs(command) {
|
|
|
29
154
|
: parts.slice(0, cIdx + 1);
|
|
30
155
|
}
|
|
31
156
|
|
|
32
|
-
/**
|
|
157
|
+
/** Quote an argument for display only, keeping the user-facing double-quoted form (issue #91). */
|
|
158
|
+
function quoteForDisplay(arg) {
|
|
159
|
+
if (SAFE_ARG_PATTERN.test(arg)) {
|
|
160
|
+
return arg;
|
|
161
|
+
}
|
|
162
|
+
return arg.includes('"') ? `'${arg}'` : `"${arg}"`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build a display string that shows the argument boundaries the user typed
|
|
167
|
+
* (issues #91, #164). A command the user quoted as one shell script is shown
|
|
168
|
+
* verbatim, because its metacharacters are meant for the shell, not for display.
|
|
169
|
+
* @param {string} command - Command line
|
|
170
|
+
* @returns {string} Display string
|
|
171
|
+
*/
|
|
33
172
|
function buildDisplayCommand(command) {
|
|
34
|
-
if (
|
|
173
|
+
if (isShellInvocationWithArgs(command)) {
|
|
174
|
+
return buildShellWithArgsCmdArgs(command).map(quoteForDisplay).join(' ');
|
|
175
|
+
}
|
|
176
|
+
const words = splitShellWords(command);
|
|
177
|
+
if (!words || buildCommandString(words) !== command) {
|
|
35
178
|
return command;
|
|
36
179
|
}
|
|
37
|
-
|
|
38
|
-
|
|
180
|
+
return words.map(quoteForDisplay).join(' ');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** First word of a command line, used for failure reports and log headers. */
|
|
184
|
+
function getCommandName(command) {
|
|
185
|
+
const parts = toShellWords(command);
|
|
186
|
+
return parts.length > 0 ? parts[0] : '';
|
|
39
187
|
}
|
|
40
188
|
|
|
41
189
|
module.exports = {
|
|
42
190
|
SHELL_NAMES,
|
|
191
|
+
defaultShellQuotingStyle,
|
|
192
|
+
quoteShellArg,
|
|
193
|
+
buildCommandString,
|
|
194
|
+
splitShellWords,
|
|
195
|
+
toShellWords,
|
|
43
196
|
isInteractiveShellCommand,
|
|
44
197
|
isShellInvocationWithArgs,
|
|
45
198
|
buildShellWithArgsCmdArgs,
|
|
46
199
|
buildDisplayCommand,
|
|
200
|
+
getCommandName,
|
|
47
201
|
};
|