@outputai/cli 0.10.1-next.09ed166.0 → 0.10.1-next.2caa4a1.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 (39) hide show
  1. package/dist/api/http_client.js +2 -2
  2. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  3. package/dist/commands/dev/down.d.ts +10 -0
  4. package/dist/commands/dev/down.js +34 -0
  5. package/dist/commands/dev/down.spec.d.ts +1 -0
  6. package/dist/commands/dev/down.spec.js +71 -0
  7. package/dist/commands/dev/index.d.ts +4 -0
  8. package/dist/commands/dev/index.js +200 -53
  9. package/dist/commands/dev/index.spec.js +390 -42
  10. package/dist/commands/workflow/run.js +8 -1
  11. package/dist/commands/workflow/run.spec.js +12 -2
  12. package/dist/commands/workflow/start.d.ts +3 -1
  13. package/dist/commands/workflow/start.js +12 -2
  14. package/dist/commands/workflow/start.spec.js +30 -5
  15. package/dist/generated/framework_version.json +1 -1
  16. package/dist/services/docker.d.ts +28 -1
  17. package/dist/services/docker.js +106 -12
  18. package/dist/services/docker.spec.js +144 -14
  19. package/dist/templates/agent_instructions/CLAUDE.md.template +1 -1
  20. package/dist/templates/project/src/clients/jina.ts.template +4 -4
  21. package/dist/utils/port_collision.d.ts +22 -7
  22. package/dist/utils/port_collision.js +39 -14
  23. package/dist/utils/port_collision.spec.js +40 -1
  24. package/dist/utils/resolve_input.d.ts +9 -1
  25. package/dist/utils/resolve_input.js +8 -2
  26. package/dist/utils/resolve_input.spec.d.ts +1 -0
  27. package/dist/utils/resolve_input.spec.js +75 -0
  28. package/dist/views/dev/chrome/footer.d.ts +2 -0
  29. package/dist/views/dev/chrome/footer.js +4 -4
  30. package/dist/views/dev/dev_app.d.ts +1 -0
  31. package/dist/views/dev/dev_app.js +13 -4
  32. package/dist/views/dev/hooks/use_run_detail.js +3 -1
  33. package/dist/views/dev/hooks/use_step_graph.js +3 -1
  34. package/dist/views/dev/utils/bounded_cache.d.ts +14 -0
  35. package/dist/views/dev/utils/bounded_cache.js +42 -0
  36. package/dist/views/dev/utils/bounded_cache.spec.d.ts +1 -0
  37. package/dist/views/dev/utils/bounded_cache.spec.js +53 -0
  38. package/oclif.manifest.json +48 -4
  39. package/package.json +7 -8
@@ -1,7 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import React from 'react';
3
3
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
- import fs from 'node:fs/promises';
5
4
  import { render } from 'ink';
6
5
  import * as dockerService from '#services/docker.js';
7
6
  import * as codingAgentsService from '#services/coding_agents.js';
@@ -13,34 +12,23 @@ vi.mock('#services/coding_agents.js', () => ({
13
12
  vi.mock('#utils/port_availability.js', () => ({
14
13
  findUnavailablePorts: vi.fn().mockResolvedValue([])
15
14
  }));
16
- vi.mock('#services/docker.js', () => ({
17
- validateDockerEnvironment: vi.fn(),
18
- startDockerCompose: vi.fn(),
19
- stopDockerCompose: vi.fn().mockResolvedValue(undefined),
20
- getServiceStatus: vi.fn().mockResolvedValue([
21
- { name: 'redis', state: 'running', health: 'healthy', ports: ['6379:6379'] },
22
- { name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] }
23
- ]),
24
- isServiceFailed: vi.fn((s) => s.state === 'exited' || s.health === 'unhealthy'),
25
- DockerComposeConfigNotFoundError: Error,
26
- DockerValidationError: Error,
27
- getDefaultDockerComposePath: vi.fn(() => '/path/to/docker-compose-dev.yml'),
28
- SERVICE_HEALTH: {
29
- HEALTHY: 'healthy',
30
- UNHEALTHY: 'unhealthy',
31
- STARTING: 'starting',
32
- NONE: 'none'
33
- },
34
- SERVICE_STATE: {
35
- RUNNING: 'running',
36
- EXITED: 'exited'
37
- }
38
- }));
39
- vi.mock('node:fs/promises', () => ({
40
- default: {
41
- access: vi.fn()
42
- }
43
- }));
15
+ vi.mock('#services/docker.js', async (importActual) => {
16
+ const actual = await importActual();
17
+ return {
18
+ ...actual,
19
+ // Override only the IO surface. Pure helpers (classifyStackState,
20
+ // isServiceHealthy, STACK_STATE, error classes) come from the real module,
21
+ // so branch selection here exercises the same logic production runs.
22
+ validateDockerEnvironment: vi.fn(),
23
+ startDockerCompose: vi.fn(),
24
+ runDockerComposeUpDetached: vi.fn().mockResolvedValue({ code: 0, signal: null, output: '' }),
25
+ stopDockerCompose: vi.fn().mockResolvedValue(undefined),
26
+ // Default: nothing running — a fresh start. Individual tests opt into an
27
+ // existing stack by overriding this to drive attach / reconcile branches.
28
+ getServiceStatus: vi.fn().mockResolvedValue([]),
29
+ resolveDockerComposePath: vi.fn().mockResolvedValue('/path/to/docker-compose-dev.yml')
30
+ };
31
+ });
44
32
  vi.mock('ink', () => ({
45
33
  render: vi.fn().mockReturnValue({
46
34
  waitUntilExit: vi.fn().mockResolvedValue(undefined),
@@ -88,12 +76,22 @@ describe('dev command', () => {
88
76
  vi.mocked(dockerService.validateDockerEnvironment).mockResolvedValue(undefined);
89
77
  // By default, no host port is taken — individual tests opt in.
90
78
  vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([]);
79
+ // By default, no stack is running — a fresh start. Tests opt into the
80
+ // attach / reconcile branches by overriding getServiceStatus.
81
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue([]);
82
+ vi.mocked(dockerService.runDockerComposeUpDetached).mockResolvedValue({ code: 0, signal: null, output: '' });
91
83
  // By default, startDockerCompose returns a mock process
92
84
  vi.mocked(dockerService.startDockerCompose).mockResolvedValue(createMockDockerProcess());
93
- // By default, fs.access succeeds (file exists)
94
- vi.mocked(fs).access.mockResolvedValue(undefined);
85
+ // By default, the compose file resolves and exists.
86
+ vi.mocked(dockerService.resolveDockerComposePath).mockResolvedValue('/path/to/docker-compose-dev.yml');
95
87
  // By default, ensureClaudePlugin succeeds
96
88
  vi.mocked(codingAgentsService.ensureClaudePlugin).mockResolvedValue(undefined);
89
+ // By default, render returns an instance that exits immediately. Tests
90
+ // needing a controllable lifecycle override this.
91
+ vi.mocked(render).mockReturnValue({
92
+ waitUntilExit: vi.fn().mockResolvedValue(undefined),
93
+ unmount: vi.fn()
94
+ });
97
95
  });
98
96
  afterEach(() => {
99
97
  vi.restoreAllMocks();
@@ -154,19 +152,19 @@ describe('dev command', () => {
154
152
  });
155
153
  await expect(cmd.run()).rejects.toThrow('Docker is not installed');
156
154
  });
157
- it('should call validateDockerEnvironment', async () => {
155
+ it('should call validateDockerEnvironment before doing any work', async () => {
158
156
  const cmd = new Dev([], {});
159
157
  cmd.log = vi.fn();
160
158
  cmd.error = vi.fn();
161
- // Mock the subprocess spawn to prevent actual execution
162
- vi.doMock('node:child_process', () => ({
163
- spawn: vi.fn().mockReturnValue({
164
- on: vi.fn(),
165
- kill: vi.fn()
166
- })
167
- }));
168
- // This test just verifies the function is called
169
- expect(vi.mocked(dockerService.validateDockerEnvironment)).toBeDefined();
159
+ Object.defineProperty(cmd, 'parse', {
160
+ value: vi.fn().mockResolvedValue({
161
+ flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: true },
162
+ args: {}
163
+ }),
164
+ configurable: true
165
+ });
166
+ await cmd.run();
167
+ expect(dockerService.validateDockerEnvironment).toHaveBeenCalled();
170
168
  });
171
169
  });
172
170
  describe('Claude plugin update', () => {
@@ -223,7 +221,7 @@ describe('dev command', () => {
223
221
  runPromise.catch(() => { });
224
222
  });
225
223
  it('should handle docker compose configuration not found', async () => {
226
- vi.mocked(fs).access.mockRejectedValue(new Error('File not found'));
224
+ vi.mocked(dockerService.resolveDockerComposePath).mockRejectedValue(new dockerService.DockerComposeConfigNotFoundError('/path/to/docker-compose-dev.yml'));
227
225
  const cmd = new Dev([], {});
228
226
  cmd.log = vi.fn();
229
227
  cmd.error = vi.fn();
@@ -327,6 +325,356 @@ describe('dev command', () => {
327
325
  expect(inkInstance.unmount).not.toHaveBeenCalledWith(expect.any(Error));
328
326
  expect(cmd.error).not.toHaveBeenCalled();
329
327
  });
328
+ // Without this, mutating teardownIfOwned to return unconditionally leaves
329
+ // every test green while each session leaks containers holding ports.
330
+ it('stops compose and kills the foreground process when it owns the stack', async () => {
331
+ const dockerProcess = createMockDockerProcess();
332
+ vi.mocked(dockerService.startDockerCompose).mockResolvedValue(dockerProcess);
333
+ const cmd = new Dev([], {});
334
+ cmd.log = vi.fn();
335
+ cmd.error = vi.fn();
336
+ Object.defineProperty(cmd, 'parse', {
337
+ value: vi.fn().mockResolvedValue({
338
+ flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: false },
339
+ args: {}
340
+ }),
341
+ configurable: true
342
+ });
343
+ const runPromise = cmd.run();
344
+ await new Promise(resolve => setImmediate(resolve));
345
+ const appElement = vi.mocked(render).mock.calls.at(-1)?.[0];
346
+ if (!React.isValidElement(appElement)) {
347
+ throw new Error('Expected render to receive a React element');
348
+ }
349
+ await appElement.props.onCleanup();
350
+ expect(dockerService.stopDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml');
351
+ expect(dockerProcess.kill).toHaveBeenCalledWith('SIGTERM');
352
+ runPromise.catch(() => { });
353
+ });
354
+ // The abnormal-exit route (health timeout, compose crash) resolves outside
355
+ // the signal path and has its own teardown call.
356
+ it('tears down an owned stack when the TUI exits abnormally', async () => {
357
+ const dockerProcess = createMockDockerProcess();
358
+ vi.mocked(dockerService.startDockerCompose).mockResolvedValue(dockerProcess);
359
+ const inkInstance = createControllableInkInstance();
360
+ vi.mocked(render).mockReturnValue(inkInstance);
361
+ const cmd = new Dev([], {});
362
+ cmd.log = vi.fn();
363
+ cmd.error = vi.fn(() => {
364
+ throw new Error('aborted');
365
+ });
366
+ Object.defineProperty(cmd, 'parse', {
367
+ value: vi.fn().mockResolvedValue({
368
+ flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: false },
369
+ args: {}
370
+ }),
371
+ configurable: true
372
+ });
373
+ const runPromise = cmd.run();
374
+ await new Promise(resolve => setImmediate(resolve));
375
+ inkInstance.unmount(new Error('Timeout waiting for services to become healthy'));
376
+ await expect(runPromise).rejects.toThrow('aborted');
377
+ expect(dockerService.stopDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml');
378
+ });
379
+ });
380
+ describe('attach and reconcile behavior', () => {
381
+ const runningStack = [
382
+ { name: 'redis', state: 'running', health: 'healthy', ports: [] },
383
+ { name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] },
384
+ { name: 'api', state: 'running', health: 'none', ports: ['3001:3001'] }
385
+ ];
386
+ const makeCmd = () => {
387
+ const cmd = new Dev([], {});
388
+ cmd.log = vi.fn();
389
+ cmd.error = vi.fn();
390
+ Object.defineProperty(cmd, 'parse', {
391
+ value: vi.fn().mockResolvedValue({
392
+ flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: false },
393
+ args: {}
394
+ }),
395
+ configurable: true
396
+ });
397
+ return cmd;
398
+ };
399
+ const lastRenderedProps = () => {
400
+ const appElement = vi.mocked(render).mock.calls.at(-1)?.[0];
401
+ if (!React.isValidElement(appElement)) {
402
+ throw new Error('Expected render to receive a React element');
403
+ }
404
+ return appElement.props;
405
+ };
406
+ it('attaches to a healthy running stack without a foreground up or port probe', async () => {
407
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue(runningStack);
408
+ const cmd = makeCmd();
409
+ await cmd.run();
410
+ expect(portAvailability.findUnavailablePorts).not.toHaveBeenCalled();
411
+ expect(dockerService.startDockerCompose).not.toHaveBeenCalled();
412
+ expect(dockerService.runDockerComposeUpDetached).not.toHaveBeenCalled();
413
+ expect(cmd.error).not.toHaveBeenCalled();
414
+ expect(lastRenderedProps().attached).toBe(true);
415
+ });
416
+ it('leaves an attached stack running on cleanup (no teardown)', async () => {
417
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue(runningStack);
418
+ const cmd = makeCmd();
419
+ await cmd.run();
420
+ await lastRenderedProps().onCleanup();
421
+ expect(dockerService.stopDockerCompose).not.toHaveBeenCalled();
422
+ });
423
+ it('does not port-probe or error when our own stack already holds the ports', async () => {
424
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue(runningStack);
425
+ vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([3001]);
426
+ const cmd = makeCmd();
427
+ await cmd.run();
428
+ expect(portAvailability.findUnavailablePorts).not.toHaveBeenCalled();
429
+ expect(cmd.error).not.toHaveBeenCalled();
430
+ });
431
+ it('reconciles a partially-failed stack with a detached up, then monitors', async () => {
432
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
433
+ { name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] },
434
+ { name: 'worker', state: 'exited', health: 'none', ports: [] }
435
+ ]);
436
+ const cmd = makeCmd();
437
+ await cmd.run();
438
+ expect(dockerService.runDockerComposeUpDetached).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', 'always');
439
+ // Reconcile monitors via ps polling, not a foreground up.
440
+ expect(dockerService.startDockerCompose).not.toHaveBeenCalled();
441
+ expect(portAvailability.findUnavailablePorts).not.toHaveBeenCalled();
442
+ expect(cmd.error).not.toHaveBeenCalled();
443
+ expect(lastRenderedProps().attached).toBe(true);
444
+ });
445
+ it('aborts with a port-collision hint when the reconcile up fails to bind', async () => {
446
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
447
+ { name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] },
448
+ { name: 'worker', state: 'exited', health: 'none', ports: [] }
449
+ ]);
450
+ vi.mocked(dockerService.runDockerComposeUpDetached).mockResolvedValue({
451
+ code: 1,
452
+ signal: null,
453
+ output: 'Error response from daemon: failed to set up container networking: driver failed ' +
454
+ 'programming external connectivity on endpoint out-api-1 (a1b2c3): ' +
455
+ 'Bind for 0.0.0.0:3001 failed: port is already allocated'
456
+ });
457
+ const cmd = makeCmd();
458
+ // this.error throws in oclif; emulate so the flow stops at the abort.
459
+ const errorMock = vi.fn((_message) => {
460
+ throw new Error('aborted');
461
+ });
462
+ cmd.error = errorMock;
463
+ await expect(cmd.run()).rejects.toThrow('aborted');
464
+ const message = errorMock.mock.calls.at(-1)?.[0];
465
+ expect(message).toContain('Port 3001 is already in use.');
466
+ expect(message).toContain('OUTPUT_API_HOST_PORT=');
467
+ expect(render).not.toHaveBeenCalled();
468
+ });
469
+ it('falls back to a fresh foreground start when no stack is running', async () => {
470
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue([]);
471
+ const cmd = makeCmd();
472
+ const runPromise = cmd.run();
473
+ await new Promise(resolve => setImmediate(resolve));
474
+ expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
475
+ expect(dockerService.startDockerCompose).toHaveBeenCalled();
476
+ expect(dockerService.runDockerComposeUpDetached).not.toHaveBeenCalled();
477
+ expect(lastRenderedProps().attached).toBe(false);
478
+ runPromise.catch(() => { });
479
+ });
480
+ // A stopped stack (reboot, `docker compose stop`, failed teardown) leaves
481
+ // exited rows in `ps --all`. Before the classifier keyed on "is anything
482
+ // live", these reconciled and then disowned themselves: a plain `output dev`
483
+ // started every container and left them running on quit.
484
+ it('treats an all-exited stack as an owned fresh start, not an attach', async () => {
485
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
486
+ { name: 'temporal', state: 'exited', health: 'none', ports: [] },
487
+ { name: 'worker', state: 'exited', health: 'none', ports: [] }
488
+ ]);
489
+ const cmd = makeCmd();
490
+ const runPromise = cmd.run();
491
+ await new Promise(resolve => setImmediate(resolve));
492
+ expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
493
+ expect(dockerService.startDockerCompose).toHaveBeenCalled();
494
+ expect(dockerService.runDockerComposeUpDetached).not.toHaveBeenCalled();
495
+ expect(lastRenderedProps().attached).toBe(false);
496
+ runPromise.catch(() => { });
497
+ });
498
+ it('tears down an all-exited stack it restarted, since it owns it', async () => {
499
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
500
+ { name: 'temporal', state: 'exited', health: 'none', ports: [] }
501
+ ]);
502
+ const cmd = makeCmd();
503
+ const runPromise = cmd.run();
504
+ await new Promise(resolve => setImmediate(resolve));
505
+ await lastRenderedProps().onCleanup();
506
+ expect(dockerService.stopDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml');
507
+ runPromise.catch(() => { });
508
+ });
509
+ // The `ps` query absorbs a broken compose file, a dead daemon, EACCES on the
510
+ // socket, and any JSON.parse throw. Falling back to a fresh start is right;
511
+ // doing it silently is what reported a port collision with the wrong remedy.
512
+ it('falls back to a fresh start and warns when the state query fails', async () => {
513
+ vi.mocked(dockerService.getServiceStatus).mockRejectedValue(new Error('no configuration file provided: not found'));
514
+ const cmd = makeCmd();
515
+ cmd.warn = vi.fn();
516
+ const runPromise = cmd.run();
517
+ await new Promise(resolve => setImmediate(resolve));
518
+ expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
519
+ expect(dockerService.startDockerCompose).toHaveBeenCalled();
520
+ expect(lastRenderedProps().attached).toBe(false);
521
+ const warned = vi.mocked(cmd.warn).mock.calls.map(c => String(c[0])).join('\n');
522
+ expect(warned).toContain('no configuration file provided: not found');
523
+ expect(warned).toContain('output dev down');
524
+ runPromise.catch(() => { });
525
+ });
526
+ it('warns that quitting leaves the stack up when attaching', async () => {
527
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue(runningStack);
528
+ const cmd = makeCmd();
529
+ await cmd.run();
530
+ const logged = vi.mocked(cmd.log).mock.calls.map(c => c[0]).join('\n');
531
+ expect(logged).toContain('Quitting leaves these services running');
532
+ expect(logged).toContain('output dev down');
533
+ });
534
+ it('does not claim the stack survives quitting on an owned fresh start', async () => {
535
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue([]);
536
+ const cmd = makeCmd();
537
+ const runPromise = cmd.run();
538
+ await new Promise(resolve => setImmediate(resolve));
539
+ const logged = vi.mocked(cmd.log).mock.calls.map(c => c[0]).join('\n');
540
+ expect(logged).not.toContain('Quitting leaves these services running');
541
+ runPromise.catch(() => { });
542
+ });
543
+ });
544
+ describe('detached flag', () => {
545
+ const makeDetachedCmd = () => {
546
+ const cmd = new Dev([], {});
547
+ cmd.log = vi.fn();
548
+ cmd.error = vi.fn();
549
+ Object.defineProperty(cmd, 'parse', {
550
+ value: vi.fn().mockResolvedValue({
551
+ flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: true },
552
+ args: {}
553
+ }),
554
+ configurable: true
555
+ });
556
+ return cmd;
557
+ };
558
+ it('brings the stack up detached and exits without mounting the TUI', async () => {
559
+ const cmd = makeDetachedCmd();
560
+ await cmd.run();
561
+ expect(dockerService.runDockerComposeUpDetached).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', 'always');
562
+ expect(render).not.toHaveBeenCalled();
563
+ expect(cmd.error).not.toHaveBeenCalled();
564
+ });
565
+ // The -d path lost its probe when the state check moved inside the NONE
566
+ // branch. It matters more than it looks: a non-container process holding a
567
+ // published port does NOT fail `compose up -d` on Docker Desktop — the
568
+ // container starts and the port keeps answering the other process.
569
+ it('probes host ports before starting a fresh detached stack', async () => {
570
+ const cmd = makeDetachedCmd();
571
+ await cmd.run();
572
+ expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
573
+ expect(dockerService.runDockerComposeUpDetached).toHaveBeenCalled();
574
+ });
575
+ it('aborts before compose when a foreign process holds a port', async () => {
576
+ vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([3001]);
577
+ const cmd = makeDetachedCmd();
578
+ const errorMock = vi.fn((_message) => {
579
+ throw new Error('aborted');
580
+ });
581
+ cmd.error = errorMock;
582
+ await expect(cmd.run()).rejects.toThrow('aborted');
583
+ expect(errorMock.mock.calls.at(-1)?.[0]).toContain('Port 3001 is already in use.');
584
+ expect(dockerService.runDockerComposeUpDetached).not.toHaveBeenCalled();
585
+ });
586
+ it('warns rather than silently assuming a fresh start when the query fails', async () => {
587
+ vi.mocked(dockerService.getServiceStatus).mockRejectedValue(new Error('daemon not running'));
588
+ const cmd = makeDetachedCmd();
589
+ cmd.warn = vi.fn();
590
+ await cmd.run();
591
+ const warned = vi.mocked(cmd.warn).mock.calls.map(c => String(c[0])).join('\n');
592
+ expect(warned).toContain('daemon not running');
593
+ expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
594
+ });
595
+ it('does not probe when our own stack is already running', async () => {
596
+ vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
597
+ { name: 'api', state: 'running', health: 'healthy', ports: ['3001:3001'] }
598
+ ]);
599
+ vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([3001]);
600
+ const cmd = makeDetachedCmd();
601
+ await cmd.run();
602
+ expect(portAvailability.findUnavailablePorts).not.toHaveBeenCalled();
603
+ expect(cmd.error).not.toHaveBeenCalled();
604
+ });
605
+ it('aborts with a port-collision hint when the detached up fails to bind', async () => {
606
+ vi.mocked(dockerService.runDockerComposeUpDetached).mockResolvedValue({
607
+ code: 1,
608
+ signal: null,
609
+ output: 'Error response from daemon: failed to set up container networking: driver failed ' +
610
+ 'programming external connectivity on endpoint out-temporal-1 (d4e5f6): ' +
611
+ 'Bind for 0.0.0.0:7233 failed: port is already allocated'
612
+ });
613
+ const cmd = makeDetachedCmd();
614
+ const errorMock = vi.fn((_message) => {
615
+ throw new Error('aborted');
616
+ });
617
+ cmd.error = errorMock;
618
+ await expect(cmd.run()).rejects.toThrow('aborted');
619
+ const message = errorMock.mock.calls.at(-1)?.[0];
620
+ expect(message).toContain('Port 7233 is already in use.');
621
+ expect(message).toContain('OUTPUT_TEMPORAL_HOST_PORT=');
622
+ });
623
+ });
624
+ describe('fatal error handling', () => {
625
+ it('restores the terminal and exits non-zero on an uncaught exception', async () => {
626
+ const inkInstance = createControllableInkInstance();
627
+ vi.mocked(render).mockReturnValue(inkInstance);
628
+ // Capture the registered handlers instead of letting them attach to the
629
+ // real process, and neutralize process.exit so firing one doesn't kill
630
+ // the test runner.
631
+ const handlers = {};
632
+ const onSpy = vi.spyOn(process, 'on').mockImplementation(((event, handler) => {
633
+ handlers[event] = handler;
634
+ return process;
635
+ }));
636
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
637
+ const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
638
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
639
+ const cmd = new Dev([], {});
640
+ cmd.log = vi.fn();
641
+ cmd.error = vi.fn();
642
+ Object.defineProperty(cmd, 'parse', {
643
+ value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
644
+ configurable: true
645
+ });
646
+ const runPromise = cmd.run();
647
+ await new Promise(resolve => setImmediate(resolve));
648
+ expect(handlers.uncaughtException).toBeInstanceOf(Function);
649
+ expect(handlers.unhandledRejection).toBeInstanceOf(Function);
650
+ const crash = new Error('boom');
651
+ handlers.uncaughtException(crash);
652
+ // Terminal restore, the crash print, and exit all run after docker
653
+ // teardown settles, so they land a tick later.
654
+ await new Promise(resolve => setImmediate(resolve));
655
+ // Docker is torn down before exit, so a crash doesn't orphan the
656
+ // compose stack.
657
+ expect(dockerService.stopDockerCompose).toHaveBeenCalled();
658
+ expect(inkInstance.unmount).toHaveBeenCalled();
659
+ expect(stdoutSpy).toHaveBeenCalledWith('\x1b[?1049l');
660
+ expect(errorSpy).toHaveBeenCalledWith(crash);
661
+ expect(exitSpy).toHaveBeenCalledWith(1);
662
+ // Discriminating order: docker must be fully torn down BEFORE Ink
663
+ // unmounts. Unmounting resolves waitUntilExit() and resumes run(),
664
+ // which strips the signal listeners — so an unmount-first ordering
665
+ // would drop the SIGINT handler mid-teardown and risk orphaning the
666
+ // stack on a Ctrl+C.
667
+ expect(vi.mocked(dockerService.stopDockerCompose).mock.invocationCallOrder[0])
668
+ .toBeLessThan(inkInstance.unmount.mock.invocationCallOrder[0]);
669
+ // Within the restore, Ink unmounts before the alt-screen is left, and
670
+ // the crash prints only after — otherwise console.error paints into a
671
+ // buffer the user never sees.
672
+ const leaveAltScreenCall = stdoutSpy.mock.invocationCallOrder[stdoutSpy.mock.calls.findIndex(([seq]) => seq === '\x1b[?1049l')];
673
+ expect(inkInstance.unmount.mock.invocationCallOrder[0]).toBeLessThan(leaveAltScreenCall);
674
+ expect(leaveAltScreenCall).toBeLessThan(errorSpy.mock.invocationCallOrder[0]);
675
+ onSpy.mockRestore();
676
+ runPromise.catch(() => { });
677
+ });
330
678
  });
331
679
  describe('image pull policy', () => {
332
680
  it('should pass pull policy to startDockerCompose', async () => {
@@ -63,7 +63,14 @@ export default class WorkflowRun extends Command {
63
63
  };
64
64
  async run() {
65
65
  const { args, flags } = await this.parse(WorkflowRun);
66
- const input = await resolveInput(args.workflowName, args.scenario, flags.input, 'run', flags.catalog);
66
+ const input = await resolveInput({
67
+ workflowName: args.workflowName,
68
+ scenario: args.scenario,
69
+ inputFlag: flags.input,
70
+ commandName: 'run',
71
+ catalog: flags.catalog,
72
+ json: this.jsonEnabled()
73
+ });
67
74
  this.log(`Executing workflow: ${args.workflowName}...`);
68
75
  const response = await executeWorkflow({
69
76
  body: {
@@ -64,7 +64,12 @@ describe('workflow run command', () => {
64
64
  headers: new Headers()
65
65
  });
66
66
  await cmd.run();
67
- expect(resolveInput).toHaveBeenCalledWith('my_workflow', undefined, undefined, 'run', undefined);
67
+ expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
68
+ workflowName: 'my_workflow',
69
+ commandName: 'run',
70
+ catalog: undefined,
71
+ json: false
72
+ }));
68
73
  expect(postWorkflowRun).toHaveBeenCalledTimes(1);
69
74
  expect(postWorkflowRun).toHaveBeenCalledWith({ workflowName: 'my_workflow', input: { key: 'value' }, catalog: undefined }, expect.objectContaining({ config: { timeout: 600000 } }));
70
75
  expect(cmd.log).toHaveBeenCalledWith('Executing workflow: my_workflow...');
@@ -83,7 +88,12 @@ describe('workflow run command', () => {
83
88
  headers: new Headers()
84
89
  });
85
90
  await cmd.run();
86
- expect(resolveInput).toHaveBeenCalledWith('my_workflow', 'basic', undefined, 'run', 'my-catalog');
91
+ expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
92
+ workflowName: 'my_workflow',
93
+ scenario: 'basic',
94
+ commandName: 'run',
95
+ catalog: 'my-catalog'
96
+ }));
87
97
  expect(postWorkflowRun).toHaveBeenCalledWith(expect.objectContaining({ catalog: 'my-catalog' }), expect.anything());
88
98
  });
89
99
  it('retries when response has Retry-After and succeeds on second attempt', async () => {
@@ -1,6 +1,8 @@
1
1
  import { Command } from '@oclif/core';
2
+ import { type PostWorkflowStart200 } from '#api/generated/api.js';
2
3
  export default class WorkflowStart extends Command {
3
4
  static description: string;
5
+ static enableJsonFlag: boolean;
4
6
  static examples: string[];
5
7
  static args: {
6
8
  workflowName: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
@@ -10,6 +12,6 @@ export default class WorkflowStart extends Command {
10
12
  input: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
13
  catalog: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
14
  };
13
- run(): Promise<void>;
15
+ run(): Promise<PostWorkflowStart200>;
14
16
  catch(error: Error): Promise<void>;
15
17
  }
@@ -4,11 +4,13 @@ import { handleApiError } from '#utils/error_handler.js';
4
4
  import { resolveInput } from '#utils/resolve_input.js';
5
5
  export default class WorkflowStart extends Command {
6
6
  static description = 'Start a workflow asynchronously without waiting for completion';
7
+ static enableJsonFlag = true;
7
8
  static examples = [
8
9
  '<%= config.bin %> <%= command.id %> simple basic_input',
9
10
  '<%= config.bin %> <%= command.id %> simple --input \'{"values":[1,2,3]}\'',
10
11
  '<%= config.bin %> <%= command.id %> simple --input input.json',
11
- '<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --catalog my-catalog'
12
+ '<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --catalog my-catalog',
13
+ '<%= config.bin %> <%= command.id %> simple --json'
12
14
  ];
13
15
  static args = {
14
16
  workflowName: Args.string({
@@ -37,7 +39,14 @@ export default class WorkflowStart extends Command {
37
39
  };
38
40
  async run() {
39
41
  const { args, flags } = await this.parse(WorkflowStart);
40
- const input = await resolveInput(args.workflowName, args.scenario, flags.input, 'start', flags.catalog);
42
+ const input = await resolveInput({
43
+ workflowName: args.workflowName,
44
+ scenario: args.scenario,
45
+ inputFlag: flags.input,
46
+ commandName: 'start',
47
+ catalog: flags.catalog,
48
+ json: this.jsonEnabled()
49
+ });
41
50
  this.log(`Starting workflow: ${args.workflowName}...`);
42
51
  const response = await postWorkflowStart({
43
52
  workflowName: args.workflowName,
@@ -57,6 +66,7 @@ export default class WorkflowStart extends Command {
57
66
  `Use "workflow result ${result.workflowId || '<workflow-id>'}" to get the workflow result when complete`
58
67
  ].join('\n');
59
68
  this.log(`\n${output}`);
69
+ return result;
60
70
  }
61
71
  async catch(error) {
62
72
  return handleApiError(error, (...args) => this.error(...args), {
@@ -36,13 +36,17 @@ describe('workflow start command', () => {
36
36
  expect(WorkflowStart.flags.catalog.env).toBe('OUTPUT_CATALOG_ID');
37
37
  expect(WorkflowStart.flags.catalog.char).toBe('c');
38
38
  });
39
+ it('enables the built-in --json flag', async () => {
40
+ const WorkflowStart = (await import('./start.js')).default;
41
+ expect(WorkflowStart.enableJsonFlag).toBe(true);
42
+ });
39
43
  });
40
44
  describe('run()', () => {
41
- const createCommand = async (flagOverrides = {}) => {
45
+ const createCommand = async (flagOverrides = {}, argv = ['my_workflow']) => {
42
46
  const WorkflowStart = (await import('./start.js')).default;
43
47
  const { postWorkflowStart } = await import('#api/generated/api.js');
44
48
  const { resolveInput } = await import('#utils/resolve_input.js');
45
- const cmd = new WorkflowStart(['my_workflow'], {});
49
+ const cmd = new WorkflowStart(argv, {});
46
50
  cmd.log = vi.fn();
47
51
  cmd.error = vi.fn(() => {
48
52
  throw new Error('error called');
@@ -61,9 +65,15 @@ describe('workflow start command', () => {
61
65
  status: 200,
62
66
  headers: new Headers()
63
67
  });
64
- await cmd.run();
65
- expect(resolveInput).toHaveBeenCalledWith('my_workflow', undefined, undefined, 'start', 'my-catalog');
68
+ const result = await cmd.run();
69
+ expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
70
+ workflowName: 'my_workflow',
71
+ commandName: 'start',
72
+ catalog: 'my-catalog',
73
+ json: false
74
+ }));
66
75
  expect(postWorkflowStart).toHaveBeenCalledWith(expect.objectContaining({ workflowName: 'my_workflow', catalog: 'my-catalog' }));
76
+ expect(result).toEqual({ workflowId: 'wf-123' });
67
77
  });
68
78
  it('passes undefined catalog through when none is set', async () => {
69
79
  const { cmd, postWorkflowStart, resolveInput } = await createCommand();
@@ -74,7 +84,22 @@ describe('workflow start command', () => {
74
84
  headers: new Headers()
75
85
  });
76
86
  await cmd.run();
77
- expect(resolveInput).toHaveBeenCalledWith('my_workflow', undefined, undefined, 'start', undefined);
87
+ expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
88
+ workflowName: 'my_workflow',
89
+ commandName: 'start',
90
+ catalog: undefined
91
+ }));
92
+ });
93
+ it('tells resolveInput to stay quiet when --json is set', async () => {
94
+ const { cmd, postWorkflowStart, resolveInput } = await createCommand({}, ['my_workflow', 'basic', '--json']);
95
+ resolveInput.mockResolvedValue({});
96
+ postWorkflowStart.mockResolvedValue({
97
+ data: { workflowId: 'wf-123' },
98
+ status: 200,
99
+ headers: new Headers()
100
+ });
101
+ await cmd.run();
102
+ expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({ json: true }));
78
103
  });
79
104
  });
80
105
  });
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.10.1-next.09ed166.0"
2
+ "framework": "0.10.1-next.2caa4a1.0"
3
3
  }