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.
Files changed (43) 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 +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/docker-network-integration.js +22 -14
  30. package/test/echo-integration.js +20 -13
  31. package/test/execution-attach.js +221 -0
  32. package/test/execution-resume.js +433 -0
  33. package/test/execution-store.js +52 -0
  34. package/test/exit-reason.js +209 -0
  35. package/test/failure-handler.js +107 -0
  36. package/test/load-command-stream.mjs +83 -0
  37. package/test/regression-162.js +320 -0
  38. package/test/regression-164.js +308 -0
  39. package/test/regression-165.js +310 -0
  40. package/test/regression-84.js +8 -3
  41. package/test/release-name.mjs +57 -0
  42. package/test/session-probe.js +150 -0
  43. 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.trim().split(/\s+/),
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
+ };
@@ -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.trim().split(/\s+/);
10
- return SHELL_NAMES.includes(path.basename(parts[0])) && !parts.includes('-c');
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.trim().split(/\s+/);
16
- return SHELL_NAMES.includes(path.basename(parts[0])) && parts.includes('-c');
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 (reverses commandArgs.join(' ')). */
144
+ /** Build argv for shell-with-c command; everything after -c is one script argument. */
20
145
  function buildShellWithArgsCmdArgs(command) {
21
- const parts = command.trim().split(/\s+/);
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
- /** Build a display string for a command, quoting arguments that contain spaces (issue #91). */
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 (!isShellInvocationWithArgs(command)) {
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
- const argv = buildShellWithArgsCmdArgs(command);
38
- return argv.map((arg) => (arg.includes(' ') ? `"${arg}"` : arg)).join(' ');
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
  };