start-command 0.27.0 → 0.27.2

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/src/bin/cli.js CHANGED
@@ -890,7 +890,7 @@ async function runDirectWithCommandStream(
890
890
  // Using raw() to avoid auto-escaping that might interfere with complex shell commands
891
891
  const $cmd = $({ mirror: true, capture: true });
892
892
 
893
- let exitCode = 0;
893
+ let exitCode;
894
894
  try {
895
895
  writeLogFile(logFilePath, logContent);
896
896
  // Use raw() to pass the command without auto-escaping
@@ -905,34 +905,25 @@ async function runDirectWithCommandStream(
905
905
 
906
906
  // Collect output for log
907
907
  if (result.stdout) {
908
- logContent += result.stdout;
909
908
  appendLogFile(logFilePath, result.stdout);
910
909
  }
911
910
  if (result.stderr) {
912
- logContent += result.stderr;
913
911
  appendLogFile(logFilePath, result.stderr);
914
912
  }
915
913
  } catch (err) {
916
914
  exitCode = err.code || 1;
917
915
  const errorMessage = `Error executing command: ${err.message}`;
918
- logContent += `\n${errorMessage}\n`;
919
916
  appendLogFile(logFilePath, `\n${errorMessage}\n`);
920
917
  console.error(`\n${errorMessage}`);
921
918
  }
922
919
 
923
920
  const endTime = getTimestamp();
924
921
 
925
- // Log footer
926
- logContent += `\n${'='.repeat(50)}\n`;
927
- logContent += `Finished: ${endTime}\n`;
928
- logContent += `Exit Code: ${exitCode}\n`;
922
+ const logFooter = `\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: ${exitCode}\n`;
929
923
 
930
924
  // Write log file
931
925
  try {
932
- appendLogFile(
933
- logFilePath,
934
- `\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: ${exitCode}\n`
935
- );
926
+ appendLogFile(logFilePath, logFooter);
936
927
  } catch (err) {
937
928
  console.error(`\nWarning: Could not save log file: ${err.message}`);
938
929
  }
@@ -120,7 +120,7 @@ function dockerPullImage(image) {
120
120
  console.log();
121
121
 
122
122
  let output = '';
123
- let success = false;
123
+ let success;
124
124
 
125
125
  try {
126
126
  // Run docker pull with inherited stdio for real-time output
@@ -425,12 +425,13 @@ function formatAsNestedLinksNotation(obj, indent = 2, depth = 0) {
425
425
  if (obj.length === 0) {
426
426
  return '()';
427
427
  }
428
- const indentStr = ' '.repeat(indent * (depth + 1));
428
+ const blockIndent = ' '.repeat(indent * depth);
429
+ const itemIndent = ' '.repeat(indent * (depth + 1));
429
430
  const items = obj.map((item) => {
430
431
  const formatted = formatAsNestedLinksNotation(item, indent, depth + 1);
431
- return `${indentStr}${formatted}`;
432
+ return `${itemIndent}${formatted}`;
432
433
  });
433
- return `(\n${items.join('\n')}\n${' '.repeat(indent * depth)})`;
434
+ return `${blockIndent}(\n${items.join('\n')}\n${blockIndent})`;
434
435
  }
435
436
 
436
437
  // Format objects
@@ -444,7 +445,8 @@ function formatAsNestedLinksNotation(obj, indent = 2, depth = 0) {
444
445
  .filter(([, value]) => value !== null && value !== undefined)
445
446
  .map(([key, value]) => {
446
447
  if (typeof value === 'object') {
447
- const nested = formatAsNestedLinksNotation(value, indent, depth + 1);
448
+ const nestedDepth = Array.isArray(value) ? depth + 2 : depth + 1;
449
+ const nested = formatAsNestedLinksNotation(value, indent, nestedDepth);
448
450
  return `${indentStr}${key}\n${nested}`;
449
451
  }
450
452
  const formattedValue = escapeForLinksNotation(value);
package/src/lib/usage.js CHANGED
@@ -9,7 +9,7 @@ Options:
9
9
  --session, -s <name> Session name for isolation
10
10
  --session-id <uuid> Session UUID for tracking (auto-generated if not provided)
11
11
  --session-name <uuid> Alias for --session-id
12
- --image <image> Docker image (required for docker isolation)
12
+ --image <image> Docker image (optional, defaults to OS-matched image)
13
13
  --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
14
14
  --isolated-user, -u [name] Create isolated user with same permissions
15
15
  --keep-user Keep isolated user after command completes
@@ -30,7 +30,8 @@ Examples:
30
30
  $ bun test
31
31
  $ --isolated tmux -- bun start
32
32
  $ -i screen -d bun start
33
- $ --isolated docker --image oven/bun:latest -- bun install
33
+ $ --isolated docker -- echo "hi" # uses OS-matched default image
34
+ $ --isolated docker --image ghcr.io/link-foundation/box-js:latest -- bun --version
34
35
  $ --isolated ssh --endpoint user@remote.server -- ls -la
35
36
  $ --isolated-user -- npm test # Create isolated user
36
37
  $ -u myuser -- npm start # Custom username
@@ -59,7 +60,7 @@ Examples:
59
60
  ' - Auto-reports failures for NPM packages (when gh is available)'
60
61
  );
61
62
  console.log(' - Natural language command aliases (via substitutions.lino)');
62
- console.log(' - Process isolation via screen, tmux, or docker');
63
+ console.log(' - Process isolation via screen, tmux, docker, or ssh');
63
64
  console.log('');
64
65
  console.log('Alias examples:');
65
66
  console.log(' $ install lodash npm package -> npm install lodash');
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const testDir = dirname(fileURLToPath(import.meta.url));
9
+ const repoRoot = resolve(testDir, '../..');
10
+ const scriptPath = resolve(repoRoot, 'scripts/create-github-release.mjs');
11
+
12
+ function createFakeGhBin(tempDir) {
13
+ const fakeGhJs = join(tempDir, 'fake-gh.cjs');
14
+ writeFileSync(
15
+ fakeGhJs,
16
+ `
17
+ const fs = require('node:fs');
18
+
19
+ let input = '';
20
+ process.stdin.setEncoding('utf8');
21
+ process.stdin.on('data', (chunk) => {
22
+ input += chunk;
23
+ });
24
+ process.stdin.on('end', () => {
25
+ fs.writeFileSync(process.env.FAKE_GH_PAYLOAD_PATH, input);
26
+
27
+ if (process.env.FAKE_GH_MODE === 'already_exists') {
28
+ console.error('gh: Validation Failed (HTTP 422)');
29
+ console.error(JSON.stringify({
30
+ message: 'Validation Failed',
31
+ errors: [{ resource: 'Release', code: 'already_exists', field: 'tag_name' }],
32
+ }));
33
+ process.exit(1);
34
+ }
35
+
36
+ if (process.env.FAKE_GH_MODE === 'failure') {
37
+ console.error('gh: server exploded');
38
+ process.exit(1);
39
+ }
40
+
41
+ console.log(JSON.stringify({ html_url: 'https://github.test/release' }));
42
+ process.exit(0);
43
+ });
44
+ `
45
+ );
46
+
47
+ return fakeGhJs;
48
+ }
49
+
50
+ function runScript(mode) {
51
+ const tempDir = mkdtempSync(join(tmpdir(), 'create-release-test-'));
52
+ const payloadPath = join(tempDir, 'payload.json');
53
+ const changelogPath = join(tempDir, 'CHANGELOG.md');
54
+ writeFileSync(
55
+ changelogPath,
56
+ '# Changelog\n\n## [1.2.3] - 2026-05-03\n\n- Release automation fix.\n'
57
+ );
58
+ const fakeGhJs = createFakeGhBin(tempDir);
59
+
60
+ const result = spawnSync(
61
+ 'node',
62
+ [
63
+ scriptPath,
64
+ '--release-version',
65
+ '1.2.3',
66
+ '--repository',
67
+ 'owner/repo',
68
+ '--prefix',
69
+ 'rust-',
70
+ '--changelog-file',
71
+ changelogPath,
72
+ '--badge-type',
73
+ 'crates',
74
+ '--package-name',
75
+ 'start-command',
76
+ ],
77
+ {
78
+ encoding: 'utf8',
79
+ env: {
80
+ ...process.env,
81
+ FAKE_GH_MODE: mode,
82
+ FAKE_GH_PAYLOAD_PATH: payloadPath,
83
+ START_GH_COMMAND: process.execPath,
84
+ START_GH_COMMAND_ARGS: JSON.stringify([fakeGhJs]),
85
+ },
86
+ }
87
+ );
88
+
89
+ const payload = JSON.parse(readFileSync(payloadPath, 'utf8'));
90
+ rmSync(tempDir, { force: true, recursive: true });
91
+ return { result, payload };
92
+ }
93
+
94
+ describe('create-github-release script', () => {
95
+ it('treats an existing GitHub release as an idempotent skip', () => {
96
+ const { result, payload } = runScript('already_exists');
97
+
98
+ expect(result.status).toBe(0);
99
+ expect(result.stdout).toContain(
100
+ 'GitHub release already exists: rust-v1.2.3'
101
+ );
102
+ expect(result.stdout).not.toContain('Created GitHub release');
103
+ expect(payload).toMatchObject({
104
+ tag_name: 'rust-v1.2.3',
105
+ name: '[Rust] 1.2.3',
106
+ });
107
+ expect(payload.body).toContain('Release automation fix.');
108
+ expect(payload.body).toContain('crates.io');
109
+ });
110
+
111
+ it('fails when gh api returns an unexpected error', () => {
112
+ const { result } = runScript('failure');
113
+
114
+ expect(result.status).not.toBe(0);
115
+ expect(result.stderr).toContain('server exploded');
116
+ expect(result.stderr).toContain('GitHub release creation failed');
117
+ });
118
+ });
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env sh
2
+ if [ "$1" = "-P" ] && [ "$2" = "667120" ]; then
3
+ printf '667121\n667122\n'
4
+ fi
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env sh
2
+ cat <<'EOF'
3
+ There is a screen on:
4
+ 667120.issue-126-screen (Detached)
5
+ 1 Socket in /run/screen/S-box.
6
+ EOF
@@ -241,29 +241,116 @@ describe('Isolation Resource Cleanup Verification', () => {
241
241
  // Use the canRunLinuxDockerImages function from isolation module
242
242
  // to properly detect if Linux containers can run (handles Windows containers mode)
243
243
  const { canRunLinuxDockerImages } = require('../src/lib/isolation');
244
+ const DOCKER_TEST_TIMEOUT = process.platform === 'win32' ? 30000 : 20000;
245
+ const DOCKER_STATE_WAIT_TIMEOUT =
246
+ process.platform === 'win32' ? 20000 : 10000;
247
+
248
+ it(
249
+ 'should show docker container as exited after command completes (auto-exit by default)',
250
+ { timeout: DOCKER_TEST_TIMEOUT },
251
+ async () => {
252
+ if (!canRunLinuxDockerImages()) {
253
+ console.log(
254
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
255
+ );
256
+ return;
257
+ }
244
258
 
245
- it('should show docker container as exited after command completes (auto-exit by default)', async () => {
246
- if (!canRunLinuxDockerImages()) {
247
- console.log(
248
- ' Skipping: docker not available, daemon not running, or Linux containers not supported'
259
+ const containerName = `test-cleanup-docker-${Date.now()}`;
260
+
261
+ // Run a quick command in detached mode
262
+ const result = await runInDocker('echo "test" && sleep 0.1', {
263
+ image: 'alpine:latest',
264
+ session: containerName,
265
+ detached: true,
266
+ keepAlive: false,
267
+ });
268
+
269
+ assert.strictEqual(result.success, true);
270
+
271
+ // Wait for the container to exit
272
+ const containerExited = await waitFor(() => {
273
+ try {
274
+ const status = execSync(
275
+ `docker inspect -f '{{.State.Status}}' ${containerName}`,
276
+ {
277
+ encoding: 'utf8',
278
+ stdio: ['pipe', 'pipe', 'pipe'],
279
+ }
280
+ ).trim();
281
+ return status === 'exited';
282
+ } catch {
283
+ return false;
284
+ }
285
+ }, DOCKER_STATE_WAIT_TIMEOUT);
286
+
287
+ assert.ok(
288
+ containerExited,
289
+ 'Docker container should be in exited state after command completes (auto-exit by default)'
249
290
  );
250
- return;
291
+
292
+ // Verify with docker ps -a that container is exited (not running)
293
+ try {
294
+ const allContainers = execSync('docker ps -a', {
295
+ encoding: 'utf8',
296
+ stdio: ['pipe', 'pipe', 'pipe'],
297
+ });
298
+ assert.ok(
299
+ allContainers.includes(containerName),
300
+ 'Container should appear in docker ps -a'
301
+ );
302
+
303
+ const runningContainers = execSync('docker ps', {
304
+ encoding: 'utf8',
305
+ stdio: ['pipe', 'pipe', 'pipe'],
306
+ });
307
+ assert.ok(
308
+ !runningContainers.includes(containerName),
309
+ 'Container should NOT appear in docker ps (not running)'
310
+ );
311
+ console.log(
312
+ ' ✓ Docker container auto-exited and stopped (resources released, filesystem preserved)'
313
+ );
314
+ } catch (err) {
315
+ assert.fail(`Failed to verify container status: ${err.message}`);
316
+ }
317
+
318
+ // Clean up
319
+ try {
320
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
321
+ } catch {
322
+ // Ignore cleanup errors
323
+ }
251
324
  }
325
+ );
326
+
327
+ it(
328
+ 'should keep docker container running when keepAlive is true',
329
+ { timeout: DOCKER_TEST_TIMEOUT },
330
+ async () => {
331
+ if (!canRunLinuxDockerImages()) {
332
+ console.log(
333
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
334
+ );
335
+ return;
336
+ }
252
337
 
253
- const containerName = `test-cleanup-docker-${Date.now()}`;
338
+ const containerName = `test-keepalive-docker-${Date.now()}`;
254
339
 
255
- // Run a quick command in detached mode
256
- const result = await runInDocker('echo "test" && sleep 0.1', {
257
- image: 'alpine:latest',
258
- session: containerName,
259
- detached: true,
260
- keepAlive: false,
261
- });
340
+ // Run command with keepAlive enabled
341
+ const result = await runInDocker('echo "test"', {
342
+ image: 'alpine:latest',
343
+ session: containerName,
344
+ detached: true,
345
+ keepAlive: true,
346
+ });
262
347
 
263
- assert.strictEqual(result.success, true);
348
+ assert.strictEqual(result.success, true);
349
+
350
+ // Wait a bit for the command to complete
351
+ await new Promise((resolve) => setTimeout(resolve, 1000));
264
352
 
265
- // Wait for the container to exit
266
- const containerExited = await waitFor(() => {
353
+ // Container should still be running
267
354
  try {
268
355
  const status = execSync(
269
356
  `docker inspect -f '{{.State.Status}}' ${containerName}`,
@@ -272,101 +359,25 @@ describe('Isolation Resource Cleanup Verification', () => {
272
359
  stdio: ['pipe', 'pipe', 'pipe'],
273
360
  }
274
361
  ).trim();
275
- return status === 'exited';
276
- } catch {
277
- return false;
362
+ assert.strictEqual(
363
+ status,
364
+ 'running',
365
+ 'Container should still be running with keepAlive=true'
366
+ );
367
+ console.log(
368
+ ' ✓ Docker container kept running as expected with --keep-alive'
369
+ );
370
+ } catch (err) {
371
+ assert.fail(`Failed to verify container is running: ${err.message}`);
278
372
  }
279
- }, 10000);
280
-
281
- assert.ok(
282
- containerExited,
283
- 'Docker container should be in exited state after command completes (auto-exit by default)'
284
- );
285
-
286
- // Verify with docker ps -a that container is exited (not running)
287
- try {
288
- const allContainers = execSync('docker ps -a', {
289
- encoding: 'utf8',
290
- stdio: ['pipe', 'pipe', 'pipe'],
291
- });
292
- assert.ok(
293
- allContainers.includes(containerName),
294
- 'Container should appear in docker ps -a'
295
- );
296
-
297
- const runningContainers = execSync('docker ps', {
298
- encoding: 'utf8',
299
- stdio: ['pipe', 'pipe', 'pipe'],
300
- });
301
- assert.ok(
302
- !runningContainers.includes(containerName),
303
- 'Container should NOT appear in docker ps (not running)'
304
- );
305
- console.log(
306
- ' ✓ Docker container auto-exited and stopped (resources released, filesystem preserved)'
307
- );
308
- } catch (err) {
309
- assert.fail(`Failed to verify container status: ${err.message}`);
310
- }
311
-
312
- // Clean up
313
- try {
314
- execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
315
- } catch {
316
- // Ignore cleanup errors
317
- }
318
- });
319
-
320
- it('should keep docker container running when keepAlive is true', async () => {
321
- if (!canRunLinuxDockerImages()) {
322
- console.log(
323
- ' Skipping: docker not available, daemon not running, or Linux containers not supported'
324
- );
325
- return;
326
- }
327
-
328
- const containerName = `test-keepalive-docker-${Date.now()}`;
329
-
330
- // Run command with keepAlive enabled
331
- const result = await runInDocker('echo "test"', {
332
- image: 'alpine:latest',
333
- session: containerName,
334
- detached: true,
335
- keepAlive: true,
336
- });
337
-
338
- assert.strictEqual(result.success, true);
339
-
340
- // Wait a bit for the command to complete
341
- await new Promise((resolve) => setTimeout(resolve, 1000));
342
373
 
343
- // Container should still be running
344
- try {
345
- const status = execSync(
346
- `docker inspect -f '{{.State.Status}}' ${containerName}`,
347
- {
348
- encoding: 'utf8',
349
- stdio: ['pipe', 'pipe', 'pipe'],
350
- }
351
- ).trim();
352
- assert.strictEqual(
353
- status,
354
- 'running',
355
- 'Container should still be running with keepAlive=true'
356
- );
357
- console.log(
358
- ' ✓ Docker container kept running as expected with --keep-alive'
359
- );
360
- } catch (err) {
361
- assert.fail(`Failed to verify container is running: ${err.message}`);
362
- }
363
-
364
- // Clean up
365
- try {
366
- execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
367
- } catch {
368
- // Ignore cleanup errors
374
+ // Clean up
375
+ try {
376
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
377
+ } catch {
378
+ // Ignore cleanup errors
379
+ }
369
380
  }
370
- });
381
+ );
371
382
  });
372
383
  });
@@ -402,6 +402,31 @@ describe('output-blocks module', () => {
402
402
  it('should handle null', () => {
403
403
  expect(formatAsNestedLinksNotation(null)).toBe('null');
404
404
  });
405
+
406
+ it('should indent nested arrays under their parent key', () => {
407
+ const obj = {
408
+ processIds: {
409
+ wrapperPid: 667105,
410
+ screenPid: 667120,
411
+ commandPids: [667121, 667122],
412
+ },
413
+ };
414
+
415
+ const result = formatAsNestedLinksNotation(obj);
416
+
417
+ expect(result).toContain(
418
+ [
419
+ ' processIds',
420
+ ' wrapperPid 667105',
421
+ ' screenPid 667120',
422
+ ' commandPids',
423
+ ' (',
424
+ ' 667121',
425
+ ' 667122',
426
+ ' )',
427
+ ].join('\n')
428
+ );
429
+ });
405
430
  });
406
431
 
407
432
  describe('Virtual Command API', () => {