@outputai/cli 0.10.1-next.09ed166.0 → 0.10.1-next.2cbd0a2.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.
@@ -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 () => {
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.10.1-next.09ed166.0"
2
+ "framework": "0.10.1-next.2cbd0a2.0"
3
3
  }
@@ -24,10 +24,32 @@ export declare class DockerComposeConfigNotFoundError extends Error {
24
24
  declare const isDockerInstalled: () => boolean;
25
25
  export declare function validateDockerEnvironment(): void;
26
26
  export declare function getDefaultDockerComposePath(): string;
27
+ export declare function resolveDockerComposePath(customPath?: string): Promise<string>;
27
28
  export declare function parseServiceStatus(jsonOutput: string): ServiceStatus[];
28
29
  export declare function getServiceStatus(dockerComposePath: string): Promise<ServiceStatus[]>;
29
30
  export declare function isServiceHealthy(service: ServiceStatus): boolean;
30
31
  export declare function isServiceFailed(service: ServiceStatus): boolean;
32
+ export declare const STACK_STATE: {
33
+ /** Nothing live for this project — a fresh start we own. */
34
+ readonly NONE: "none";
35
+ /** Every container found is running and healthy (or has no healthcheck). */
36
+ readonly RUNNING: "running";
37
+ /** Something is live but not everything is healthy — reconcile. */
38
+ readonly PARTIAL: "partial";
39
+ };
40
+ export type StackState = typeof STACK_STATE[keyof typeof STACK_STATE];
41
+ /**
42
+ * Classify the current state of a project's stack from `docker compose ps`.
43
+ *
44
+ * This is the detection signal `output dev` branches on: nothing live means a
45
+ * fresh start; an all-healthy result means we can attach and monitor without
46
+ * touching the stack; anything in between is reconciled with `up -d`.
47
+ *
48
+ * Scoped to the shared `output-sdk` compose project, which distinguishes our
49
+ * containers from unrelated processes — but not one Output checkout from
50
+ * another, since the project name defaults to a machine-global constant.
51
+ */
52
+ export declare function classifyStackState(services: ServiceStatus[]): StackState;
31
53
  export declare function waitForServicesHealthy(dockerComposePath: string, timeoutMs?: number, pollIntervalMs?: number): Promise<void>;
32
54
  export interface DockerComposeHandlers {
33
55
  onError?: (error: Error, output: string) => void;
@@ -39,6 +61,11 @@ export interface StartDockerComposeOptions extends DockerComposeHandlers {
39
61
  pullPolicy?: PullPolicy;
40
62
  }
41
63
  export declare function startDockerCompose({ dockerComposePath, pullPolicy, onError, onExit }: StartDockerComposeOptions): Promise<ChildProcess>;
42
- export declare function startDockerComposeDetached(dockerComposePath: string, pullPolicy?: PullPolicy): void;
64
+ export interface DetachedUpResult {
65
+ code: number | null;
66
+ signal: NodeJS.Signals | null;
67
+ output: string;
68
+ }
69
+ export declare function runDockerComposeUpDetached(dockerComposePath: string, pullPolicy?: PullPolicy): Promise<DetachedUpResult>;
43
70
  export declare function stopDockerCompose(dockerComposePath: string): Promise<void>;
44
71
  export { isDockerInstalled, DockerValidationError };