start-command 0.32.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +32 -137
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/detached-finalize.js +281 -0
- package/src/lib/docker-cleanup.js +240 -39
- package/src/lib/docker-post-mortem.js +289 -0
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +211 -0
- package/src/lib/execution-resume.js +455 -0
- package/src/lib/execution-store.js +93 -6
- package/src/lib/exit-reason.js +264 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +66 -1
- package/src/lib/isolation.js +28 -15
- 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 +233 -69
- 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/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +57 -2
- 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-170.js +321 -0
- package/test/regression-171.js +562 -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,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #162.
|
|
3
|
+
*
|
|
4
|
+
* A detached docker session used to be a dead end: once it stopped there was
|
|
5
|
+
* no way to re-enter it, continue it, or run a different command inside the
|
|
6
|
+
* same container, and a supervisor restart left records stuck in "executing"
|
|
7
|
+
* forever. `exitCode 139` with `oomKilled false` also hid the real cause.
|
|
8
|
+
*
|
|
9
|
+
* These tests drive the CLI end to end, so they cover the wiring between the
|
|
10
|
+
* argument parser, the execution store and the new attach/resume verbs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
ExecutionRecord,
|
|
21
|
+
ExecutionStore,
|
|
22
|
+
} = require('../src/lib/execution-store');
|
|
23
|
+
|
|
24
|
+
const TEST_APP_FOLDER = path.join(
|
|
25
|
+
os.tmpdir(),
|
|
26
|
+
`regression-162-${process.pid}-${Date.now()}`
|
|
27
|
+
);
|
|
28
|
+
const CLI_PATH = path.join(__dirname, '../src/bin/cli.js');
|
|
29
|
+
const LINO_DB_PATH = path.join(TEST_APP_FOLDER, 'executions.lino');
|
|
30
|
+
|
|
31
|
+
function cleanupTestDir() {
|
|
32
|
+
fs.rmSync(TEST_APP_FOLDER, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runCli(args) {
|
|
36
|
+
// Reading the store back first is both a precondition check (the record
|
|
37
|
+
// really is persisted) and a guarantee that the CLI child sees it.
|
|
38
|
+
const persisted = fs.existsSync(LINO_DB_PATH)
|
|
39
|
+
? fs.readFileSync(LINO_DB_PATH, 'utf8')
|
|
40
|
+
: '';
|
|
41
|
+
const result = spawnSync(process.execPath, [CLI_PATH, ...args], {
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
env: { ...process.env, START_APP_FOLDER: TEST_APP_FOLDER },
|
|
44
|
+
timeout: 20000,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
persisted,
|
|
48
|
+
stdout: result.stdout || '',
|
|
49
|
+
stderr: result.stderr || '',
|
|
50
|
+
exitCode: result.status,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function saveRecord(store, overrides = {}) {
|
|
55
|
+
const record = new ExecutionRecord({
|
|
56
|
+
command: overrides.command || 'echo hi',
|
|
57
|
+
pid: overrides.pid || 4242,
|
|
58
|
+
logPath: overrides.logPath || null,
|
|
59
|
+
workingDirectory: '/tmp',
|
|
60
|
+
shell: '/bin/bash',
|
|
61
|
+
options: overrides.options || {},
|
|
62
|
+
});
|
|
63
|
+
if (overrides.exitCode !== undefined) {
|
|
64
|
+
record.complete(overrides.exitCode);
|
|
65
|
+
}
|
|
66
|
+
store.save(record);
|
|
67
|
+
return record;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('Issue #162: attach, resume and exit reasons', () => {
|
|
71
|
+
let store;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
cleanupTestDir();
|
|
75
|
+
fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
|
|
76
|
+
store = new ExecutionStore({
|
|
77
|
+
appFolder: TEST_APP_FOLDER,
|
|
78
|
+
useLinks: false,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
cleanupTestDir();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('--list --running', () => {
|
|
87
|
+
it('reports only executions that are still running', () => {
|
|
88
|
+
const finished = saveRecord(store, {
|
|
89
|
+
command: 'echo finished',
|
|
90
|
+
exitCode: 0,
|
|
91
|
+
});
|
|
92
|
+
const running = saveRecord(store, { command: 'echo running' });
|
|
93
|
+
|
|
94
|
+
const result = runCli(['--list', '--running', '--output-format', 'json']);
|
|
95
|
+
|
|
96
|
+
expect(result.exitCode).toBe(0);
|
|
97
|
+
const parsed = JSON.parse(result.stdout);
|
|
98
|
+
const uuids = parsed.executions.map((record) => record.uuid);
|
|
99
|
+
expect(uuids).toContain(running.uuid);
|
|
100
|
+
expect(uuids).not.toContain(finished.uuid);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('is rejected without --list', () => {
|
|
104
|
+
const result = runCli(['--running', '--', 'echo hi']);
|
|
105
|
+
|
|
106
|
+
expect(result.exitCode).not.toBe(0);
|
|
107
|
+
expect(result.stderr).toContain(
|
|
108
|
+
'--running option is only valid with --list'
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('--attach', () => {
|
|
114
|
+
it('fails with a clear error for an unknown identifier', () => {
|
|
115
|
+
const result = runCli(['--attach', 'does-not-exist']);
|
|
116
|
+
|
|
117
|
+
expect(result.exitCode).toBe(1);
|
|
118
|
+
expect(result.stderr).toContain(
|
|
119
|
+
'No execution found with UUID or session name: does-not-exist'
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('refuses to attach to a non-isolated execution', () => {
|
|
124
|
+
const record = saveRecord(store, { command: 'echo local' });
|
|
125
|
+
|
|
126
|
+
const result = runCli(['--attach', record.uuid]);
|
|
127
|
+
|
|
128
|
+
expect(result.exitCode).toBe(1);
|
|
129
|
+
expect(result.stderr).toContain(
|
|
130
|
+
'Execution record does not contain an isolation session name'
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('points at --resume when the session is already gone', () => {
|
|
135
|
+
const record = saveRecord(store, {
|
|
136
|
+
command: 'echo gone',
|
|
137
|
+
options: {
|
|
138
|
+
isolated: 'docker',
|
|
139
|
+
isolationMode: 'detached',
|
|
140
|
+
sessionName: 'start-command-162-missing',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = runCli(['--attach', record.uuid]);
|
|
145
|
+
|
|
146
|
+
expect(result.exitCode).toBe(1);
|
|
147
|
+
expect(result.stderr).toContain('--resume');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('--resume', () => {
|
|
152
|
+
it('fails with a clear error for an unknown identifier', () => {
|
|
153
|
+
const result = runCli(['--resume', 'does-not-exist']);
|
|
154
|
+
|
|
155
|
+
expect(result.exitCode).toBe(1);
|
|
156
|
+
expect(result.stderr).toContain(
|
|
157
|
+
'No execution found with UUID or session name: does-not-exist'
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('refuses to resume a non-isolated execution', () => {
|
|
162
|
+
const record = saveRecord(store, { command: 'echo local' });
|
|
163
|
+
|
|
164
|
+
const result = runCli(['--resume', record.uuid]);
|
|
165
|
+
|
|
166
|
+
expect(result.exitCode).toBe(1);
|
|
167
|
+
expect(result.stderr).toContain(
|
|
168
|
+
'Execution record does not contain an isolation session name'
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('accepts a replacement command after --', () => {
|
|
173
|
+
const result = runCli(['--resume', 'does-not-exist', '--', 'echo', 'hi']);
|
|
174
|
+
|
|
175
|
+
// The identifier is still resolved first, so this reports the missing
|
|
176
|
+
// record rather than treating "echo hi" as a fresh command.
|
|
177
|
+
expect(result.exitCode).toBe(1);
|
|
178
|
+
expect(result.stderr).toContain(
|
|
179
|
+
'No execution found with UUID or session name: does-not-exist'
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('--resume-all', () => {
|
|
185
|
+
it('succeeds with an empty report when nothing is running', () => {
|
|
186
|
+
saveRecord(store, { command: 'echo finished', exitCode: 0 });
|
|
187
|
+
|
|
188
|
+
const result = runCli(['--resume-all', '--output-format', 'json']);
|
|
189
|
+
|
|
190
|
+
expect(result.exitCode).toBe(0);
|
|
191
|
+
const parsed = JSON.parse(result.stdout);
|
|
192
|
+
expect(parsed.count).toBe(0);
|
|
193
|
+
expect(parsed.executions).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('reconciles executions whose session no longer exists', () => {
|
|
197
|
+
const logPath = path.join(TEST_APP_FOLDER, 'orphan.log');
|
|
198
|
+
fs.writeFileSync(
|
|
199
|
+
logPath,
|
|
200
|
+
[
|
|
201
|
+
'working...',
|
|
202
|
+
'==================================================',
|
|
203
|
+
'Finished: 2026-09-03 10:00:00.000',
|
|
204
|
+
'Exit Code: 0',
|
|
205
|
+
'',
|
|
206
|
+
].join('\n'),
|
|
207
|
+
'utf8'
|
|
208
|
+
);
|
|
209
|
+
const record = saveRecord(store, {
|
|
210
|
+
command: 'echo orphan',
|
|
211
|
+
logPath,
|
|
212
|
+
options: {
|
|
213
|
+
isolated: 'docker',
|
|
214
|
+
isolationMode: 'detached',
|
|
215
|
+
sessionName: 'start-command-162-orphan',
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const result = runCli(['--resume-all', '--output-format', 'json']);
|
|
220
|
+
|
|
221
|
+
expect(result.exitCode).toBe(0);
|
|
222
|
+
const parsed = JSON.parse(result.stdout);
|
|
223
|
+
expect(parsed.count).toBe(1);
|
|
224
|
+
expect(parsed.executions[0].uuid).toBe(record.uuid);
|
|
225
|
+
expect(parsed.executions[0].action).toBe('reconciled');
|
|
226
|
+
|
|
227
|
+
// The stuck "executing" record is now finalized in the store.
|
|
228
|
+
const reloaded = new ExecutionStore({
|
|
229
|
+
appFolder: TEST_APP_FOLDER,
|
|
230
|
+
useLinks: false,
|
|
231
|
+
}).get(record.uuid);
|
|
232
|
+
expect(reloaded.status).toBe('executed');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('exit reason hint', () => {
|
|
237
|
+
it('explains a 139 exit caused by heap exhaustion', () => {
|
|
238
|
+
const logPath = path.join(TEST_APP_FOLDER, 'oom.log');
|
|
239
|
+
fs.writeFileSync(
|
|
240
|
+
logPath,
|
|
241
|
+
[
|
|
242
|
+
'building...',
|
|
243
|
+
'',
|
|
244
|
+
'<--- Last few GCs --->',
|
|
245
|
+
'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory',
|
|
246
|
+
'',
|
|
247
|
+
'==================================================',
|
|
248
|
+
'Finished: 2026-09-03 10:00:00.000',
|
|
249
|
+
'Exit Code: 139',
|
|
250
|
+
'',
|
|
251
|
+
].join('\n'),
|
|
252
|
+
'utf8'
|
|
253
|
+
);
|
|
254
|
+
const record = saveRecord(store, {
|
|
255
|
+
command: 'bun run build',
|
|
256
|
+
logPath,
|
|
257
|
+
exitCode: 139,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const result = runCli([
|
|
261
|
+
'--status',
|
|
262
|
+
record.uuid,
|
|
263
|
+
'--output-format',
|
|
264
|
+
'json',
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
expect(result.exitCode).toBe(0);
|
|
268
|
+
const parsed = JSON.parse(result.stdout);
|
|
269
|
+
expect(parsed.exitCode).toBe(139);
|
|
270
|
+
expect(parsed.oomKilled).toBeUndefined();
|
|
271
|
+
expect(parsed.exitReason).toBe('memory-exhaustion (v8-heap-limit)');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('does not invent a reason for a clean exit', () => {
|
|
275
|
+
const logPath = path.join(TEST_APP_FOLDER, 'clean.log');
|
|
276
|
+
fs.writeFileSync(logPath, 'all good\n', 'utf8');
|
|
277
|
+
const record = saveRecord(store, {
|
|
278
|
+
command: 'echo ok',
|
|
279
|
+
logPath,
|
|
280
|
+
exitCode: 0,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = runCli([
|
|
284
|
+
'--status',
|
|
285
|
+
record.uuid,
|
|
286
|
+
'--output-format',
|
|
287
|
+
'json',
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
expect(result.exitCode).toBe(0);
|
|
291
|
+
const parsed = JSON.parse(result.stdout);
|
|
292
|
+
expect(parsed.exitReason).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('session identity across resumes', () => {
|
|
297
|
+
it('keeps addressing one logical session by its previous name', () => {
|
|
298
|
+
const record = saveRecord(store, {
|
|
299
|
+
command: 'echo resumed',
|
|
300
|
+
options: {
|
|
301
|
+
isolated: 'docker',
|
|
302
|
+
isolationMode: 'detached',
|
|
303
|
+
sessionName: 'start-command-162-b',
|
|
304
|
+
sessionNameHistory: ['start-command-162-a'],
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const result = runCli([
|
|
309
|
+
'--status',
|
|
310
|
+
'start-command-162-a',
|
|
311
|
+
'--output-format',
|
|
312
|
+
'json',
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
expect(result.exitCode).toBe(0);
|
|
316
|
+
const parsed = JSON.parse(result.stdout);
|
|
317
|
+
expect(parsed.uuid).toBe(record.uuid);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #164:
|
|
3
|
+
* "Command argv is flattened with join(' '), so quoted arguments are re-parsed
|
|
4
|
+
* by the inner shell (`$ node -e \"console.log('hi')\"` fails)"
|
|
5
|
+
*
|
|
6
|
+
* Root cause:
|
|
7
|
+
* parseArgs() returned `commandArgs.join(' ')`, so every argument boundary and
|
|
8
|
+
* every quote the user typed was destroyed before the command reached
|
|
9
|
+
* `sh -c`. `node -e "console.log('hi')"` became `node -e console.log('hi')`
|
|
10
|
+
* (a syntax error), and `echo "a b"` became `echo a b` (re-split into two
|
|
11
|
+
* words). `buildShellWithArgsCmdArgs()` repaired only the `<shell> -c …`
|
|
12
|
+
* shape (issue #91) and only on the isolation code paths.
|
|
13
|
+
*
|
|
14
|
+
* Fix:
|
|
15
|
+
* parseArgs() rebuilds the command with buildCommandString(): a single
|
|
16
|
+
* argument is a shell script the user quoted as a whole and stays verbatim,
|
|
17
|
+
* while multiple arguments are each shell-quoted so the inner shell sees the
|
|
18
|
+
* argv the user typed.
|
|
19
|
+
*
|
|
20
|
+
* Reference: https://github.com/link-foundation/start/issues/164
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { describe, it, expect } = require('bun:test');
|
|
24
|
+
const { spawnSync } = require('child_process');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
|
|
28
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
29
|
+
const {
|
|
30
|
+
buildCommandString,
|
|
31
|
+
defaultShellQuotingStyle,
|
|
32
|
+
buildDisplayCommand,
|
|
33
|
+
buildShellWithArgsCmdArgs,
|
|
34
|
+
getCommandName,
|
|
35
|
+
isInteractiveShellCommand,
|
|
36
|
+
isShellInvocationWithArgs,
|
|
37
|
+
quoteShellArg,
|
|
38
|
+
splitShellWords,
|
|
39
|
+
toShellWords,
|
|
40
|
+
} = require('../src/lib/shell-utils');
|
|
41
|
+
|
|
42
|
+
const CLI_PATH = path.join(__dirname, '../src/bin/cli.js');
|
|
43
|
+
// PowerShell (the host shell on Windows) escapes a quote by doubling it, so the
|
|
44
|
+
// rebuilt command line differs from the POSIX one (issue #164).
|
|
45
|
+
const IS_POWERSHELL = defaultShellQuotingStyle() === 'powershell';
|
|
46
|
+
const TEST_APP_FOLDER = path.join(
|
|
47
|
+
os.tmpdir(),
|
|
48
|
+
`regression-164-${process.pid}-${Date.now()}`
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/** Run the CLI the way a shell would: one process argument per argv element. */
|
|
52
|
+
function runCli(argv) {
|
|
53
|
+
const result = spawnSync(process.execPath, [CLI_PATH, ...argv], {
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
env: {
|
|
56
|
+
...process.env,
|
|
57
|
+
START_APP_FOLDER: TEST_APP_FOLDER,
|
|
58
|
+
START_DISABLE_AUTO_ISSUE: '1',
|
|
59
|
+
},
|
|
60
|
+
timeout: 20000,
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
stdout: result.stdout || '',
|
|
64
|
+
stderr: result.stderr || '',
|
|
65
|
+
exitCode: result.status,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The command output sits between the start block and the finish block. */
|
|
70
|
+
function commandOutput(stdout) {
|
|
71
|
+
// PowerShell prefixes its output with a UTF-8 BOM and ends its lines with a
|
|
72
|
+
// carriage return on Windows, and the finish block is preceded by one more
|
|
73
|
+
// blank line there, so trailing blank lines are trimmed (issue #164).
|
|
74
|
+
const lines = stdout
|
|
75
|
+
.replace(/\uFEFF/g, '')
|
|
76
|
+
.replace(/\r/g, '')
|
|
77
|
+
.split('\n');
|
|
78
|
+
const start = lines.findIndex((line) => line.startsWith('$ '));
|
|
79
|
+
const finish = lines.findIndex((line) => line === '✓' || line === '✗');
|
|
80
|
+
return lines
|
|
81
|
+
.slice(start + 2, finish - 1)
|
|
82
|
+
.join('\n')
|
|
83
|
+
.replace(/\n+$/, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function displayedCommand(stdout) {
|
|
87
|
+
return (stdout.split('\n').find((line) => line.startsWith('$ ')) || '').slice(
|
|
88
|
+
2
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('parseArgs keeps argv boundaries (issue #164)', () => {
|
|
93
|
+
it('quotes an argument that contains a shell metacharacter', () => {
|
|
94
|
+
const { command, rawCommand } = parseArgs([
|
|
95
|
+
'node',
|
|
96
|
+
'-e',
|
|
97
|
+
"console.log('hi')",
|
|
98
|
+
]);
|
|
99
|
+
expect(command).toBe(
|
|
100
|
+
IS_POWERSHELL
|
|
101
|
+
? "node -e 'console.log(''hi'')'"
|
|
102
|
+
: "node -e 'console.log('\\''hi'\\'')'"
|
|
103
|
+
);
|
|
104
|
+
expect(rawCommand).toEqual(['node', '-e', "console.log('hi')"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('quotes an argument that contains repeated spaces', () => {
|
|
108
|
+
expect(parseArgs(['echo', 'a b']).command).toBe("echo 'a b'");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('keeps a single argument verbatim so shell scripts still work', () => {
|
|
112
|
+
expect(parseArgs(['ls | wc -l']).command).toBe('ls | wc -l');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('leaves ordinary arguments unquoted', () => {
|
|
116
|
+
// `%` is an alias for ForEach-Object in PowerShell, so it has to be quoted
|
|
117
|
+
// there while a POSIX shell leaves it alone.
|
|
118
|
+
expect(parseArgs(['git', 'log', '-1', '--pretty=%s']).command).toBe(
|
|
119
|
+
IS_POWERSHELL ? "git log -1 '--pretty=%s'" : 'git log -1 --pretty=%s'
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('keeps boundaries after the -- separator', () => {
|
|
124
|
+
const { command } = parseArgs([
|
|
125
|
+
'--isolated',
|
|
126
|
+
'docker',
|
|
127
|
+
'--',
|
|
128
|
+
'git',
|
|
129
|
+
'commit',
|
|
130
|
+
'-m',
|
|
131
|
+
'msg with spaces',
|
|
132
|
+
]);
|
|
133
|
+
expect(command).toBe("git commit -m 'msg with spaces'");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns an empty command when no command was given', () => {
|
|
137
|
+
expect(parseArgs(['--list']).command).toBe('');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('buildCommandString and quoteShellArg (issue #164)', () => {
|
|
142
|
+
it('returns an empty string for empty argv', () => {
|
|
143
|
+
expect(buildCommandString([])).toBe('');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('escapes embedded single quotes for a POSIX shell', () => {
|
|
147
|
+
expect(quoteShellArg("it's", 'posix')).toBe("'it'\\''s'");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('escapes embedded single quotes for PowerShell by doubling them', () => {
|
|
151
|
+
expect(quoteShellArg("it's", 'powershell')).toBe("'it''s'");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('quotes characters PowerShell treats specially but a POSIX shell does not', () => {
|
|
155
|
+
expect(quoteShellArg('a,b', 'posix')).toBe('a,b');
|
|
156
|
+
expect(quoteShellArg('a,b', 'powershell')).toBe("'a,b'");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('uses the host shell dialect by default', () => {
|
|
160
|
+
expect(quoteShellArg("it's")).toBe(
|
|
161
|
+
quoteShellArg("it's", defaultShellQuotingStyle())
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('quotes an empty argument so it survives as an argument', () => {
|
|
166
|
+
expect(buildCommandString(['echo', ''])).toBe("echo ''");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('round-trips through splitShellWords in both dialects', () => {
|
|
170
|
+
const argv = ['node', '-e', "console.log('a b')", 'x$y', ''];
|
|
171
|
+
for (const style of ['posix', 'powershell']) {
|
|
172
|
+
expect(splitShellWords(buildCommandString(argv, style), style)).toEqual(
|
|
173
|
+
argv
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('splitShellWords (issue #164)', () => {
|
|
180
|
+
it('splits on unquoted whitespace', () => {
|
|
181
|
+
expect(splitShellWords('echo a b')).toEqual(['echo', 'a', 'b']);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('keeps double-quoted words together', () => {
|
|
185
|
+
expect(splitShellWords('echo "a b"')).toEqual(['echo', 'a b']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('keeps single-quoted words together', () => {
|
|
189
|
+
expect(splitShellWords("echo 'a b'")).toEqual(['echo', 'a b']);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('honours backslash escapes in a POSIX shell', () => {
|
|
193
|
+
expect(splitShellWords('echo a\\ b', 'posix')).toEqual(['echo', 'a b']);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('keeps backslashes literal for PowerShell, where they are separators', () => {
|
|
197
|
+
expect(splitShellWords('echo C:\\tmp', 'powershell')).toEqual([
|
|
198
|
+
'echo',
|
|
199
|
+
'C:\\tmp',
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('reads a doubled quote inside a PowerShell string as one quote', () => {
|
|
204
|
+
expect(splitShellWords("echo 'it''s'", 'powershell')).toEqual([
|
|
205
|
+
'echo',
|
|
206
|
+
"it's",
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('returns null when quoting is unbalanced', () => {
|
|
211
|
+
expect(splitShellWords('echo "a')).toBe(null);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('toShellWords (issue #164)', () => {
|
|
216
|
+
it('keeps a quoted bare-shell argument in one argv element', () => {
|
|
217
|
+
expect(toShellWords("bash --rcfile 'my file'")).toEqual([
|
|
218
|
+
'bash',
|
|
219
|
+
'--rcfile',
|
|
220
|
+
'my file',
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('falls back to whitespace splitting when quoting is unbalanced', () => {
|
|
225
|
+
expect(toShellWords('echo "a b')).toEqual(['echo', '"a', 'b']);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('shell detection stays correct for quoted commands (issue #164)', () => {
|
|
230
|
+
it('still detects a bare shell invocation', () => {
|
|
231
|
+
expect(isInteractiveShellCommand('bash')).toBe(true);
|
|
232
|
+
expect(isInteractiveShellCommand("bash -c 'echo hi'")).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('detects a shell invocation whose script argument is quoted', () => {
|
|
236
|
+
expect(isShellInvocationWithArgs("bash -c 'echo hi'")).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('passes the quoted script through as a single argv element', () => {
|
|
240
|
+
expect(buildShellWithArgsCmdArgs("bash -i -c 'nvm --version'")).toEqual([
|
|
241
|
+
'bash',
|
|
242
|
+
'-i',
|
|
243
|
+
'-c',
|
|
244
|
+
'nvm --version',
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('reads the command name from the first shell word', () => {
|
|
249
|
+
expect(getCommandName("'my command' --flag")).toBe('my command');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('buildDisplayCommand shows the argv the user typed (issue #164)', () => {
|
|
254
|
+
it('re-quotes an argument that was quoted by the parser', () => {
|
|
255
|
+
expect(buildDisplayCommand(parseArgs(['echo', 'a b']).command)).toBe(
|
|
256
|
+
'echo "a b"'
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('re-quotes an argument containing shell metacharacters', () => {
|
|
261
|
+
expect(
|
|
262
|
+
buildDisplayCommand(
|
|
263
|
+
parseArgs(['node', '-e', "console.log('hi')"]).command
|
|
264
|
+
)
|
|
265
|
+
).toBe('node -e "console.log(\'hi\')"');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('shows a single-argument shell script verbatim', () => {
|
|
269
|
+
expect(buildDisplayCommand('ls | wc -l')).toBe('ls | wc -l');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('CLI end to end (issue #164)', () => {
|
|
274
|
+
it('runs node -e with a quoted script', () => {
|
|
275
|
+
const result = runCli(['node', '-e', "console.log('hi')"]);
|
|
276
|
+
expect(result.exitCode).toBe(0);
|
|
277
|
+
expect(commandOutput(result.stdout)).toBe('hi');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('preserves repeated spaces inside a quoted argument', () => {
|
|
281
|
+
const result = runCli(['echo', 'a b']);
|
|
282
|
+
expect(result.exitCode).toBe(0);
|
|
283
|
+
expect(commandOutput(result.stdout)).toBe('a b');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('keeps `bash -c` scripts intact on the direct path (issue #91 shape)', () => {
|
|
287
|
+
const result = runCli(['bash', '-c', 'echo hello world']);
|
|
288
|
+
expect(result.exitCode).toBe(0);
|
|
289
|
+
expect(commandOutput(result.stdout)).toBe('hello world');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('keeps arithmetic expansion inside `bash -c` intact', () => {
|
|
293
|
+
const result = runCli(['bash', '-c', 'echo $((1+1))']);
|
|
294
|
+
expect(result.exitCode).toBe(0);
|
|
295
|
+
expect(commandOutput(result.stdout)).toBe('2');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('still runs a single quoted argument as a shell script', () => {
|
|
299
|
+
const result = runCli(['echo one | tr a-z A-Z']);
|
|
300
|
+
expect(result.exitCode).toBe(0);
|
|
301
|
+
expect(commandOutput(result.stdout)).toBe('ONE');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('displays the command with the quoting the user typed', () => {
|
|
305
|
+
const result = runCli(['echo', 'a b']);
|
|
306
|
+
expect(displayedCommand(result.stdout)).toBe('echo "a b"');
|
|
307
|
+
});
|
|
308
|
+
});
|