start-command 0.25.5 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +2 -1
  3. package/bunfig.toml +2 -2
  4. package/eslint.config.mjs +1 -1
  5. package/package.json +2 -2
  6. package/src/bin/cli.js +48 -67
  7. package/src/lib/args-parser.js +88 -7
  8. package/src/lib/execution-control.js +317 -0
  9. package/src/lib/isolation.js +22 -4
  10. package/src/lib/status-formatter.js +153 -1
  11. package/src/lib/usage.js +74 -0
  12. package/test/args-parser-control.js +71 -0
  13. package/test/{args-parser.test.js → args-parser.js} +28 -4
  14. package/test/cli.js +260 -0
  15. package/test/docker-autoremove.js +175 -0
  16. package/test/execution-control.js +253 -0
  17. package/test/{isolation.test.js → isolation.js} +4 -2
  18. package/test/merge-changesets.mjs +154 -0
  19. package/test/release-name.mjs +117 -0
  20. package/test/{screen-integration.test.js → screen-integration.js} +1 -1
  21. package/test/{ssh-integration.test.js → ssh-integration.js} +1 -1
  22. package/test/{status-query.test.js → status-query.js} +73 -0
  23. package/test/{substitution.test.js → substitution.js} +1 -2
  24. package/test/{user-manager.test.js → user-manager.js} +17 -0
  25. package/test/cli.test.js +0 -218
  26. package/test/docker-autoremove.test.js +0 -164
  27. package/test/release-name.test.mjs +0 -34
  28. /package/test/{args-parser-shell.test.js → args-parser-shell.js} +0 -0
  29. /package/test/{echo-integration.test.js → echo-integration.js} +0 -0
  30. /package/test/{execution-store.test.js → execution-store.js} +0 -0
  31. /package/test/{failure-handler.test.js → failure-handler.js} +0 -0
  32. /package/test/{isolation-cleanup.test.js → isolation-cleanup.js} +0 -0
  33. /package/test/{isolation-log-utils.test.js → isolation-log-utils.js} +0 -0
  34. /package/test/{isolation-stacking.test.js → isolation-stacking.js} +0 -0
  35. /package/test/{output-blocks.test.js → output-blocks.js} +0 -0
  36. /package/test/{public-exports.test.js → public-exports.js} +0 -0
  37. /package/test/{regression-84.test.js → regression-84.js} +0 -0
  38. /package/test/{regression-89.test.js → regression-89.js} +0 -0
  39. /package/test/{regression-91.test.js → regression-91.js} +0 -0
  40. /package/test/{sequence-parser.test.js → sequence-parser.js} +0 -0
  41. /package/test/{session-name-status.test.js → session-name-status.js} +0 -0
  42. /package/test/{version.test.js → version.js} +0 -0
@@ -545,9 +545,13 @@ function runInDocker(command, options = {}) {
545
545
 
546
546
  try {
547
547
  if (options.detached) {
548
- const dockerArgs = ['run', '-d', '--name', containerName];
548
+ const dockerArgs = ['run', '-d'];
549
+ if (options.keepAlive) {
550
+ dockerArgs.push('-i', '-t');
551
+ }
552
+ dockerArgs.push('--name', containerName);
549
553
  if (options.autoRemoveDockerContainer) {
550
- dockerArgs.splice(2, 0, '--rm');
554
+ dockerArgs.push('--rm');
551
555
  }
552
556
 
553
557
  if (options.user) {
@@ -576,9 +580,23 @@ function runInDocker(command, options = {}) {
576
580
  );
577
581
  }
578
582
 
579
- const containerId = execSync(`docker ${dockerArgs.join(' ')}`, {
583
+ const dockerResult = spawnSync('docker', dockerArgs, {
580
584
  encoding: 'utf8',
581
- }).trim();
585
+ stdio: ['pipe', 'pipe', 'pipe'],
586
+ });
587
+
588
+ if (dockerResult.error) {
589
+ throw dockerResult.error;
590
+ }
591
+ if (dockerResult.status !== 0) {
592
+ const dockerError =
593
+ dockerResult.stderr.trim() ||
594
+ dockerResult.stdout.trim() ||
595
+ `docker exited with code ${dockerResult.status}`;
596
+ throw new Error(dockerError);
597
+ }
598
+
599
+ const containerId = dockerResult.stdout.trim();
582
600
 
583
601
  if (options.logPath) {
584
602
  const loggerScript = [
@@ -13,6 +13,7 @@ const {
13
13
  escapeForLinksNotation,
14
14
  formatAsNestedLinksNotation,
15
15
  } = require('./output-blocks');
16
+ const { collectProcessIds } = require('./execution-control');
16
17
 
17
18
  /**
18
19
  * Check if a detached isolation session is still running
@@ -165,6 +166,47 @@ function attachCurrentTime(record) {
165
166
  return wrapped;
166
167
  }
167
168
 
169
+ /**
170
+ * Wrap a record so its serialized form includes best-effort process IDs.
171
+ * The original record is not mutated.
172
+ * @param {Object} record - Execution record
173
+ * @returns {Object} Record-like object with processIds in toObject()
174
+ */
175
+ function attachProcessIds(record) {
176
+ const processIds = collectProcessIds(record);
177
+ if (!processIds) {
178
+ return record;
179
+ }
180
+
181
+ const wrapped = Object.create(Object.getPrototypeOf(record));
182
+ Object.assign(wrapped, record);
183
+ wrapped.processIds = processIds;
184
+
185
+ const sourceToObject =
186
+ typeof record.toObject === 'function'
187
+ ? () => record.toObject()
188
+ : () => ({ ...record });
189
+
190
+ wrapped.toObject = function () {
191
+ const base = sourceToObject();
192
+ const ordered = {};
193
+ let inserted = false;
194
+ for (const [key, value] of Object.entries(base)) {
195
+ ordered[key] = value;
196
+ if (key === 'pid') {
197
+ ordered.processIds = processIds;
198
+ inserted = true;
199
+ }
200
+ }
201
+ if (!inserted) {
202
+ ordered.processIds = processIds;
203
+ }
204
+ return ordered;
205
+ };
206
+
207
+ return wrapped;
208
+ }
209
+
168
210
  /**
169
211
  * Format execution record as Links Notation (indented style)
170
212
  * Uses nested Links notation for object values (like options) instead of JSON
@@ -273,6 +315,86 @@ function formatRecord(record, format) {
273
315
  }
274
316
  }
275
317
 
318
+ function sortRecordsByStartTimeDesc(records) {
319
+ return [...records].sort(
320
+ (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
321
+ );
322
+ }
323
+
324
+ function indentBlock(block, spaces) {
325
+ const prefix = ' '.repeat(spaces);
326
+ return block
327
+ .split('\n')
328
+ .map((line) => `${prefix}${line}`)
329
+ .join('\n');
330
+ }
331
+
332
+ function prepareRecordForList(record) {
333
+ return attachCurrentTime(attachProcessIds(enrichDetachedStatus(record)));
334
+ }
335
+
336
+ /**
337
+ * Format execution records as a Links Notation list.
338
+ * @param {Object[]} records - Execution records
339
+ * @returns {string} Links Notation formatted list
340
+ */
341
+ function formatRecordListAsLinksNotation(records) {
342
+ const lines = ['executions', ` count ${records.length}`];
343
+
344
+ if (records.length === 0) {
345
+ lines.push(' records ()');
346
+ return lines.join('\n');
347
+ }
348
+
349
+ lines.push(' records');
350
+ for (const record of records) {
351
+ lines.push(indentBlock(formatRecordAsLinksNotation(record), 4));
352
+ }
353
+
354
+ return lines.join('\n');
355
+ }
356
+
357
+ /**
358
+ * Format execution records as human-readable text.
359
+ * @param {Object[]} records - Execution records
360
+ * @returns {string} Human-readable list
361
+ */
362
+ function formatRecordListAsText(records) {
363
+ const lines = ['Executions', `${'='.repeat(50)}`, `Count: ${records.length}`];
364
+
365
+ for (const record of records) {
366
+ lines.push('', formatRecordAsText(record));
367
+ }
368
+
369
+ return lines.join('\n');
370
+ }
371
+
372
+ /**
373
+ * Format execution records based on format type.
374
+ * @param {Object[]} records - Execution records
375
+ * @param {string} format - Output format (links-notation, json, text)
376
+ * @returns {string} Formatted output string
377
+ */
378
+ function formatRecordList(records, format) {
379
+ switch (format) {
380
+ case 'links-notation':
381
+ return formatRecordListAsLinksNotation(records);
382
+ case 'json':
383
+ return JSON.stringify(
384
+ {
385
+ count: records.length,
386
+ executions: records.map((record) => record.toObject()),
387
+ },
388
+ null,
389
+ 2
390
+ );
391
+ case 'text':
392
+ return formatRecordListAsText(records);
393
+ default:
394
+ throw new Error(`Unknown output format: ${format}`);
395
+ }
396
+ }
397
+
276
398
  /**
277
399
  * Handle status query and output the result
278
400
  * @param {Object} store - ExecutionStore instance
@@ -294,8 +416,9 @@ function queryStatus(store, identifier, outputFormat) {
294
416
  try {
295
417
  // Enrich detached execution status with live session check
296
418
  const enrichedRecord = enrichDetachedStatus(record);
419
+ const withProcessIds = attachProcessIds(enrichedRecord);
297
420
  // Attach currentTime so callers can see how long an executing command has been running
298
- const withCurrentTime = attachCurrentTime(enrichedRecord);
421
+ const withCurrentTime = attachCurrentTime(withProcessIds);
299
422
  return {
300
423
  success: true,
301
424
  output: formatRecord(withCurrentTime, outputFormat || 'links-notation'),
@@ -305,12 +428,41 @@ function queryStatus(store, identifier, outputFormat) {
305
428
  }
306
429
  }
307
430
 
431
+ /**
432
+ * Handle execution list query and output the result
433
+ * @param {Object} store - ExecutionStore instance
434
+ * @param {string|null} outputFormat - Output format (links-notation, json, text)
435
+ * @returns {{success: boolean, output?: string, error?: string}}
436
+ */
437
+ function listExecutions(store, outputFormat) {
438
+ if (!store) {
439
+ return { success: false, error: 'Execution tracking is disabled.' };
440
+ }
441
+
442
+ try {
443
+ const records = sortRecordsByStartTimeDesc(
444
+ store.getAll().map(prepareRecordForList)
445
+ );
446
+ return {
447
+ success: true,
448
+ output: formatRecordList(records, outputFormat || 'links-notation'),
449
+ };
450
+ } catch (err) {
451
+ return { success: false, error: err.message };
452
+ }
453
+ }
454
+
308
455
  module.exports = {
309
456
  formatRecordAsLinksNotation,
310
457
  formatRecordAsText,
311
458
  formatRecord,
459
+ formatRecordListAsLinksNotation,
460
+ formatRecordListAsText,
461
+ formatRecordList,
312
462
  queryStatus,
463
+ listExecutions,
313
464
  isDetachedSessionAlive,
314
465
  enrichDetachedStatus,
315
466
  attachCurrentTime,
467
+ attachProcessIds,
316
468
  };
@@ -0,0 +1,74 @@
1
+ /** Print usage information */
2
+ function printUsage() {
3
+ console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--output-format <fmt>] | $ --stop <id> | $ --terminate <id>
4
+
5
+ Options:
6
+ --isolated, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
7
+ --attached, -a Run in attached mode (foreground)
8
+ --detached, -d Run in detached mode (background)
9
+ --session, -s <name> Session name for isolation
10
+ --session-id <uuid> Session UUID for tracking (auto-generated if not provided)
11
+ --session-name <uuid> Alias for --session-id
12
+ --image <image> Docker image (required for docker isolation)
13
+ --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
14
+ --isolated-user, -u [name] Create isolated user with same permissions
15
+ --keep-user Keep isolated user after command completes
16
+ --keep-alive, -k Keep isolation environment alive after command exits
17
+ --auto-remove-docker-container Auto-remove docker container after exit
18
+ --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
19
+ --use-command-stream Use command-stream library for execution (experimental)
20
+ --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
21
+ --list List all tracked executions (--output-format: links-notation|json|text)
22
+ --stop <id> Send CTRL+C/SIGINT to a detached isolated execution
23
+ --terminate <id> Terminate a detached isolated execution immediately
24
+ --cleanup Clean up stale "executing" records (crashed/killed processes)
25
+ --cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
26
+ --version, -v Show version information
27
+
28
+ Examples:
29
+ $ echo "Hello World"
30
+ $ bun test
31
+ $ --isolated tmux -- bun start
32
+ $ -i screen -d bun start
33
+ $ --isolated docker --image oven/bun:latest -- bun install
34
+ $ --isolated ssh --endpoint user@remote.server -- ls -la
35
+ $ --isolated-user -- npm test # Create isolated user
36
+ $ -u myuser -- npm start # Custom username
37
+ $ -i screen --isolated-user -- npm test # Combine with process isolation
38
+ $ --isolated-user --keep-user -- npm start
39
+ $ --list # List stored execution records
40
+ $ --list --output-format json # List stored records as JSON
41
+ $ --stop my-screen-session # Ask detached execution to stop gracefully
42
+ $ --terminate my-screen-session # Terminate detached execution immediately
43
+ $ --use-command-stream echo "Hello" # Use command-stream library`);
44
+ console.log('');
45
+ console.log('Piping with $:');
46
+ console.log(' echo "hi" | $ agent # Preferred - pipe TO $ command');
47
+ console.log(
48
+ ' $ \'echo "hi" | agent\' # Alternative - quote entire pipeline'
49
+ );
50
+ console.log('');
51
+ console.log('Quoting for special characters:');
52
+ console.log(" $ 'npm test && npm build' # Wrap for logical operators");
53
+ console.log(" $ 'cat file > output.txt' # Wrap for redirections");
54
+ console.log('');
55
+ console.log('Features:');
56
+ console.log(' - Logs all output to temporary directory');
57
+ console.log(' - Displays timestamps and exit codes');
58
+ console.log(
59
+ ' - Auto-reports failures for NPM packages (when gh is available)'
60
+ );
61
+ console.log(' - Natural language command aliases (via substitutions.lino)');
62
+ console.log(' - Process isolation via screen, tmux, or docker');
63
+ console.log('');
64
+ console.log('Alias examples:');
65
+ console.log(' $ install lodash npm package -> npm install lodash');
66
+ console.log(
67
+ ' $ install 4.17.21 version of lodash npm package -> npm install lodash@4.17.21'
68
+ );
69
+ console.log(
70
+ ' $ clone https://github.com/user/repo repository -> git clone https://github.com/user/repo'
71
+ );
72
+ }
73
+
74
+ module.exports = { printUsage };
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bun
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('assert');
5
+ const { parseArgs } = require('../src/lib/args-parser');
6
+
7
+ describe('control options', () => {
8
+ it('should parse --stop with UUID or session name', () => {
9
+ const result = parseArgs(['--stop', 'my-session']);
10
+ assert.strictEqual(result.wrapperOptions.stop, 'my-session');
11
+ assert.strictEqual(result.command, '');
12
+ });
13
+
14
+ it('should parse --stop=value format', () => {
15
+ const result = parseArgs(['--stop=my-session']);
16
+ assert.strictEqual(result.wrapperOptions.stop, 'my-session');
17
+ });
18
+
19
+ it('should parse --terminate with UUID or session name', () => {
20
+ const result = parseArgs(['--terminate', 'my-session']);
21
+ assert.strictEqual(result.wrapperOptions.terminate, 'my-session');
22
+ assert.strictEqual(result.command, '');
23
+ });
24
+
25
+ it('should parse --terminate=value format', () => {
26
+ const result = parseArgs(['--terminate=my-session']);
27
+ assert.strictEqual(result.wrapperOptions.terminate, 'my-session');
28
+ });
29
+
30
+ it('should throw error for missing --stop argument', () => {
31
+ assert.throws(() => {
32
+ parseArgs(['--stop']);
33
+ }, /requires a UUID or session name argument/);
34
+ });
35
+
36
+ it('should throw error for empty --stop=value argument', () => {
37
+ assert.throws(() => {
38
+ parseArgs(['--stop=']);
39
+ }, /requires a UUID or session name argument/);
40
+ });
41
+
42
+ it('should throw error for missing --terminate argument', () => {
43
+ assert.throws(() => {
44
+ parseArgs(['--terminate']);
45
+ }, /requires a UUID or session name argument/);
46
+ });
47
+
48
+ it('should throw error for empty --terminate=value argument', () => {
49
+ assert.throws(() => {
50
+ parseArgs(['--terminate=']);
51
+ }, /requires a UUID or session name argument/);
52
+ });
53
+
54
+ it('should reject combining query and control modes', () => {
55
+ assert.throws(() => {
56
+ parseArgs(['--status', 'uuid-here', '--stop', 'my-session']);
57
+ }, /Cannot combine --status, --list, --stop, --terminate, or --cleanup/);
58
+ });
59
+
60
+ it('should reject output-format with control modes', () => {
61
+ assert.throws(() => {
62
+ parseArgs(['--stop', 'my-session', '--output-format', 'json']);
63
+ }, /--output-format option is only valid with --status or --list/);
64
+ });
65
+
66
+ it('should default control options to null', () => {
67
+ const result = parseArgs(['echo', 'hello']);
68
+ assert.strictEqual(result.wrapperOptions.stop, null);
69
+ assert.strictEqual(result.wrapperOptions.terminate, null);
70
+ });
71
+ });
@@ -817,13 +817,13 @@ describe('status option', () => {
817
817
  it('should throw error for missing UUID argument', () => {
818
818
  assert.throws(() => {
819
819
  parseArgs(['--status']);
820
- }, /requires a UUID argument/);
820
+ }, /requires a UUID or session name argument/);
821
821
  });
822
822
 
823
823
  it('should throw error for --status with -flag as next argument', () => {
824
824
  assert.throws(() => {
825
825
  parseArgs(['--status', '--output-format']);
826
- }, /requires a UUID argument/);
826
+ }, /requires a UUID or session name argument/);
827
827
  });
828
828
 
829
829
  it('should default status to null', () => {
@@ -832,6 +832,30 @@ describe('status option', () => {
832
832
  });
833
833
  });
834
834
 
835
+ describe('list option', () => {
836
+ it('should parse --list flag', () => {
837
+ const result = parseArgs(['--list']);
838
+ assert.strictEqual(result.wrapperOptions.list, true);
839
+ assert.strictEqual(result.command, '');
840
+ });
841
+
842
+ it('should parse --list with JSON output format', () => {
843
+ const result = parseArgs(['--list', '--output-format', 'json']);
844
+ assert.strictEqual(result.wrapperOptions.list, true);
845
+ assert.strictEqual(result.wrapperOptions.outputFormat, 'json');
846
+ });
847
+
848
+ it('should normalize output format with --list', () => {
849
+ const result = parseArgs(['--list', '--output-format', 'TEXT']);
850
+ assert.strictEqual(result.wrapperOptions.outputFormat, 'text');
851
+ });
852
+
853
+ it('should default list to false', () => {
854
+ const result = parseArgs(['echo', 'hello']);
855
+ assert.strictEqual(result.wrapperOptions.list, false);
856
+ });
857
+ });
858
+
835
859
  describe('output-format option', () => {
836
860
  it('should parse --output-format with --status', () => {
837
861
  const uuid = '12345678-1234-1234-1234-123456789abc';
@@ -874,7 +898,7 @@ describe('output-format option', () => {
874
898
  it('should throw error for output-format without status', () => {
875
899
  assert.throws(() => {
876
900
  parseArgs(['--output-format', 'json', '--', 'npm', 'test']);
877
- }, /--output-format option is only valid with --status/);
901
+ }, /--output-format option is only valid with --status or --list/);
878
902
  });
879
903
 
880
904
  it('should accept all valid output formats', () => {
@@ -928,4 +952,4 @@ describe('cleanup options', () => {
928
952
  });
929
953
  });
930
954
 
931
- // Shell option tests moved to args-parser-shell.test.js
955
+ // Shell option tests moved to args-parser-shell.js