@outputai/cli 0.10.1-dev.b7b2fbe.0 → 0.10.1-next.1f20c98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/generated/api.d.ts +12 -0
- package/dist/api/http_client.js +2 -2
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/dev/down.d.ts +10 -0
- package/dist/commands/dev/down.js +34 -0
- package/dist/commands/dev/down.spec.d.ts +1 -0
- package/dist/commands/dev/down.spec.js +71 -0
- package/dist/commands/dev/index.d.ts +4 -0
- package/dist/commands/dev/index.js +200 -53
- package/dist/commands/dev/index.spec.js +390 -42
- package/dist/commands/workflow/history.js +3 -3
- package/dist/commands/workflow/history.spec.js +31 -2
- package/dist/commands/workflow/monitor.d.ts +49 -0
- package/dist/commands/workflow/monitor.js +230 -0
- package/dist/commands/workflow/monitor.spec.d.ts +1 -0
- package/dist/commands/workflow/monitor.spec.js +243 -0
- package/dist/commands/workflow/run.js +8 -1
- package/dist/commands/workflow/run.spec.js +12 -2
- package/dist/commands/workflow/start.d.ts +3 -1
- package/dist/commands/workflow/start.js +12 -2
- package/dist/commands/workflow/start.spec.js +30 -5
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/docker.d.ts +28 -1
- package/dist/services/docker.js +106 -12
- package/dist/services/docker.spec.js +144 -14
- package/dist/services/workflow_history/correlator.d.ts +2 -0
- package/dist/services/workflow_history/correlator.js +2 -2
- package/dist/services/workflow_history.d.ts +28 -0
- package/dist/services/workflow_history.js +95 -12
- package/dist/services/workflow_history.spec.js +183 -1
- package/dist/templates/agent_instructions/CLAUDE.md.template +1 -1
- package/dist/templates/project/src/clients/jina.ts.template +4 -4
- package/dist/utils/color.d.ts +7 -0
- package/dist/utils/color.js +12 -0
- package/dist/utils/color.spec.d.ts +1 -0
- package/dist/utils/color.spec.js +43 -0
- package/dist/utils/format_workflow_result.d.ts +1 -0
- package/dist/utils/format_workflow_result.js +4 -0
- package/dist/utils/monitor_log.d.ts +20 -0
- package/dist/utils/monitor_log.js +48 -0
- package/dist/utils/monitor_log.spec.d.ts +1 -0
- package/dist/utils/monitor_log.spec.js +71 -0
- package/dist/utils/port_collision.d.ts +22 -7
- package/dist/utils/port_collision.js +39 -14
- package/dist/utils/port_collision.spec.js +40 -1
- package/dist/utils/resolve_input.d.ts +9 -1
- package/dist/utils/resolve_input.js +8 -2
- package/dist/utils/resolve_input.spec.d.ts +1 -0
- package/dist/utils/resolve_input.spec.js +75 -0
- package/dist/utils/waterfall.d.ts +3 -1
- package/dist/utils/waterfall.js +8 -2
- package/dist/views/dev/chrome/footer.d.ts +2 -0
- package/dist/views/dev/chrome/footer.js +4 -4
- package/dist/views/dev/dev_app.d.ts +1 -0
- package/dist/views/dev/dev_app.js +13 -4
- package/dist/views/dev/hooks/use_run_detail.js +7 -8
- package/dist/views/dev/hooks/use_step_graph.js +3 -1
- package/dist/views/dev/utils/bounded_cache.d.ts +14 -0
- package/dist/views/dev/utils/bounded_cache.js +42 -0
- package/dist/views/dev/utils/bounded_cache.spec.d.ts +1 -0
- package/dist/views/dev/utils/bounded_cache.spec.js +53 -0
- package/oclif.manifest.json +122 -4
- package/package.json +7 -8
|
@@ -393,6 +393,12 @@ export type GetWorkflowIdHistoryParams = {
|
|
|
393
393
|
* Include decoded input/output payloads in events
|
|
394
394
|
*/
|
|
395
395
|
includePayloads?: boolean;
|
|
396
|
+
/**
|
|
397
|
+
* When set, long-poll for a new event once caught up to the end of history instead of returning immediately, bounding the block by this many milliseconds. Clamped to the server's configured maximum — a caller can shorten the wait but never exceed it. Omit for an immediate response; on timeout returns the same page's cursor unchanged with an empty events array so the caller can retry. Lets a poller keep the block roughly aligned with its own tick interval.
|
|
398
|
+
|
|
399
|
+
* @minimum 1
|
|
400
|
+
*/
|
|
401
|
+
longPollTimeoutMs?: number;
|
|
396
402
|
};
|
|
397
403
|
/**
|
|
398
404
|
* Workflow metadata (null on subsequent pages)
|
|
@@ -431,6 +437,12 @@ export type GetWorkflowIdRunsRidHistoryParams = {
|
|
|
431
437
|
* Include decoded input/output payloads in events
|
|
432
438
|
*/
|
|
433
439
|
includePayloads?: boolean;
|
|
440
|
+
/**
|
|
441
|
+
* When set, long-poll for a new event once caught up to the end of history instead of returning immediately, bounding the block by this many milliseconds. Clamped to the server's configured maximum — a caller can shorten the wait but never exceed it. Omit for an immediate response; on timeout returns the same page's cursor unchanged with an empty events array so the caller can retry. Lets a poller keep the block roughly aligned with its own tick interval.
|
|
442
|
+
|
|
443
|
+
* @minimum 1
|
|
444
|
+
*/
|
|
445
|
+
longPollTimeoutMs?: number;
|
|
434
446
|
};
|
|
435
447
|
/**
|
|
436
448
|
* Workflow metadata (null on subsequent pages)
|
package/dist/api/http_client.js
CHANGED
|
@@ -18,7 +18,7 @@ const apiState = {};
|
|
|
18
18
|
function getApi() {
|
|
19
19
|
if (!apiState.api) {
|
|
20
20
|
apiState.api = ky.create({
|
|
21
|
-
|
|
21
|
+
prefix: config.apiUrl,
|
|
22
22
|
timeout: config.requestTimeout,
|
|
23
23
|
retry: {
|
|
24
24
|
limit: 2,
|
|
@@ -28,7 +28,7 @@ function getApi() {
|
|
|
28
28
|
throwHttpErrors: false,
|
|
29
29
|
hooks: {
|
|
30
30
|
beforeRequest: [
|
|
31
|
-
request => {
|
|
31
|
+
({ request }) => {
|
|
32
32
|
if (config.apiToken) {
|
|
33
33
|
request.headers.set('Authorization', `Basic ${config.apiToken}`);
|
|
34
34
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class DevDown extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static args: {};
|
|
6
|
+
static flags: {
|
|
7
|
+
'compose-file': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
};
|
|
9
|
+
run(): Promise<void>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { validateDockerEnvironment, stopDockerCompose, resolveDockerComposePath } from '#services/docker.js';
|
|
3
|
+
import { getErrorMessage } from '#utils/error_utils.js';
|
|
4
|
+
export default class DevDown extends Command {
|
|
5
|
+
static description = [
|
|
6
|
+
'Stop Output development services started by `output dev`',
|
|
7
|
+
'',
|
|
8
|
+
'Useful after `output dev -d`, or when an attached `output dev` session',
|
|
9
|
+
'left the services running on quit.'
|
|
10
|
+
].join('\n');
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> <%= command.id %>',
|
|
13
|
+
'<%= config.bin %> <%= command.id %> --compose-file ./custom-docker-compose.yml'
|
|
14
|
+
];
|
|
15
|
+
static args = {};
|
|
16
|
+
static flags = {
|
|
17
|
+
'compose-file': Flags.string({
|
|
18
|
+
description: 'Path to a custom docker-compose file',
|
|
19
|
+
required: false,
|
|
20
|
+
char: 'f'
|
|
21
|
+
})
|
|
22
|
+
};
|
|
23
|
+
async run() {
|
|
24
|
+
const { flags } = await this.parse(DevDown);
|
|
25
|
+
validateDockerEnvironment();
|
|
26
|
+
try {
|
|
27
|
+
const dockerComposePath = await resolveDockerComposePath(flags['compose-file']);
|
|
28
|
+
await stopDockerCompose(dockerComposePath);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
this.error(getErrorMessage(error), { exit: 1 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import * as dockerService from '#services/docker.js';
|
|
4
|
+
import DevDown from './down.js';
|
|
5
|
+
vi.mock('#services/docker.js', () => ({
|
|
6
|
+
validateDockerEnvironment: vi.fn(),
|
|
7
|
+
stopDockerCompose: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
resolveDockerComposePath: vi.fn().mockResolvedValue('/path/to/docker-compose-dev.yml')
|
|
9
|
+
}));
|
|
10
|
+
const makeCmd = () => {
|
|
11
|
+
const cmd = new DevDown([], {});
|
|
12
|
+
cmd.log = vi.fn();
|
|
13
|
+
cmd.error = vi.fn();
|
|
14
|
+
Object.defineProperty(cmd, 'parse', {
|
|
15
|
+
value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined }, args: {} }),
|
|
16
|
+
configurable: true
|
|
17
|
+
});
|
|
18
|
+
return cmd;
|
|
19
|
+
};
|
|
20
|
+
describe('dev down command', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
vi.mocked(dockerService.validateDockerEnvironment).mockReturnValue(undefined);
|
|
24
|
+
vi.mocked(dockerService.stopDockerCompose).mockResolvedValue(undefined);
|
|
25
|
+
vi.mocked(dockerService.resolveDockerComposePath).mockResolvedValue('/path/to/docker-compose-dev.yml');
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
describe('command structure', () => {
|
|
31
|
+
it('should have a description mentioning stopping services', () => {
|
|
32
|
+
expect(DevDown.description).toBeDefined();
|
|
33
|
+
expect(DevDown.description).toContain('Stop');
|
|
34
|
+
});
|
|
35
|
+
it('should have no required arguments', () => {
|
|
36
|
+
expect(Object.keys(DevDown.args)).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
it('should have a compose-file flag', () => {
|
|
39
|
+
expect(DevDown.flags['compose-file']).toBeDefined();
|
|
40
|
+
expect(DevDown.flags['compose-file'].char).toBe('f');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('run', () => {
|
|
44
|
+
it('validates docker and stops the default stack', async () => {
|
|
45
|
+
const cmd = makeCmd();
|
|
46
|
+
await cmd.run();
|
|
47
|
+
expect(dockerService.validateDockerEnvironment).toHaveBeenCalled();
|
|
48
|
+
expect(dockerService.stopDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml');
|
|
49
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
it('errors with the command formatter when the compose file is missing', async () => {
|
|
52
|
+
vi.mocked(dockerService.resolveDockerComposePath).mockRejectedValue(new Error('File not found'));
|
|
53
|
+
const cmd = makeCmd();
|
|
54
|
+
cmd.error = vi.fn(() => {
|
|
55
|
+
throw new Error('oclif-error-thrown');
|
|
56
|
+
});
|
|
57
|
+
await expect(cmd.run()).rejects.toThrow();
|
|
58
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('File not found'), { exit: 1 });
|
|
59
|
+
expect(dockerService.stopDockerCompose).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
it('surfaces a teardown failure as an actionable error', async () => {
|
|
62
|
+
vi.mocked(dockerService.stopDockerCompose).mockRejectedValue(new Error('compose down failed'));
|
|
63
|
+
const cmd = makeCmd();
|
|
64
|
+
cmd.error = vi.fn(() => {
|
|
65
|
+
throw new Error('oclif-error-thrown');
|
|
66
|
+
});
|
|
67
|
+
await expect(cmd.run()).rejects.toThrow();
|
|
68
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('compose down failed'), { exit: 1 });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { Command, Flags } from '@oclif/core';
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
2
|
import { render } from 'ink';
|
|
5
3
|
import React from 'react';
|
|
6
|
-
import { validateDockerEnvironment, startDockerCompose,
|
|
4
|
+
import { validateDockerEnvironment, startDockerCompose, runDockerComposeUpDetached, stopDockerCompose, getServiceStatus, classifyStackState, STACK_STATE, resolveDockerComposePath } from '#services/docker.js';
|
|
7
5
|
import { getErrorMessage } from '#utils/error_utils.js';
|
|
8
|
-
import {
|
|
6
|
+
import { formatComposeFailure, formatPortCollisionsHint } from '#utils/port_collision.js';
|
|
9
7
|
import { findUnavailablePorts } from '#utils/port_availability.js';
|
|
10
8
|
import { ensureClaudePlugin } from '#services/coding_agents.js';
|
|
11
9
|
import { DevApp } from '#views/dev/dev_app.js';
|
|
@@ -14,14 +12,23 @@ export default class Dev extends Command {
|
|
|
14
12
|
static description = [
|
|
15
13
|
'Start Output development services (auto-restarts worker on file changes)',
|
|
16
14
|
'',
|
|
17
|
-
'
|
|
15
|
+
'If services are already running (e.g. after `output dev -d`), this attaches',
|
|
16
|
+
'to monitor them rather than treating our own containers as a port collision.',
|
|
17
|
+
'Quitting an attached session leaves the services running — stop them with',
|
|
18
|
+
'`output dev down`.',
|
|
18
19
|
'',
|
|
20
|
+
'To run a second dev stack concurrently, give it its own compose project and',
|
|
21
|
+
'host ports in .env — without DOCKER_SERVICE_NAME both checkouts share one',
|
|
22
|
+
'stack, and the second will attach to the first instead of starting:',
|
|
23
|
+
'',
|
|
24
|
+
' DOCKER_SERVICE_NAME=output-sdk-two',
|
|
19
25
|
' OUTPUT_API_HOST_PORT=3002',
|
|
20
26
|
' OUTPUT_TEMPORAL_UI_HOST_PORT=8081',
|
|
21
27
|
' OUTPUT_TEMPORAL_HOST_PORT=7234'
|
|
22
28
|
].join('\n');
|
|
23
29
|
static examples = [
|
|
24
30
|
'<%= config.bin %> <%= command.id %>',
|
|
31
|
+
'<%= config.bin %> <%= command.id %> --detached',
|
|
25
32
|
'<%= config.bin %> <%= command.id %> --compose-file ./custom-docker-compose.yml',
|
|
26
33
|
'<%= config.bin %> <%= command.id %> --image-pull-policy missing'
|
|
27
34
|
];
|
|
@@ -51,40 +58,71 @@ export default class Dev extends Command {
|
|
|
51
58
|
validateDockerEnvironment();
|
|
52
59
|
// Eagerly resolve ports so InvalidPortError surfaces before Ink mounts.
|
|
53
60
|
void config.ports;
|
|
54
|
-
|
|
55
|
-
// doesn't exit on partial container failure, so a bind collision would
|
|
56
|
-
// otherwise leave the Ink TUI in limbo with no actionable feedback.
|
|
57
|
-
const takenPorts = await findUnavailablePorts(Object.values(config.ports));
|
|
58
|
-
if (takenPorts.length > 0) {
|
|
59
|
-
this.error(formatPortCollisionsHint(takenPorts, config.ports), { exit: 1 });
|
|
60
|
-
}
|
|
61
|
-
const dockerComposePath = flags['compose-file'] ?
|
|
62
|
-
path.resolve(process.cwd(), flags['compose-file']) :
|
|
63
|
-
getDefaultDockerComposePath();
|
|
64
|
-
try {
|
|
65
|
-
await fs.access(dockerComposePath);
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
throw new DockerComposeConfigNotFoundError(dockerComposePath);
|
|
69
|
-
}
|
|
61
|
+
const dockerComposePath = await resolveDockerComposePath(flags['compose-file']);
|
|
70
62
|
const pullPolicy = flags['image-pull-policy'];
|
|
71
63
|
if (flags.detached) {
|
|
72
64
|
this.log('🐳 Starting services in detached mode...\n');
|
|
73
|
-
|
|
65
|
+
// Probe only for a fresh start, same rule as the foreground path — our own
|
|
66
|
+
// running containers must not read as a collision. Compose alone isn't
|
|
67
|
+
// enough of a check: on Docker Desktop for macOS (verified on 29.4.0) a
|
|
68
|
+
// non-container process holding a published port doesn't fail `up -d` at
|
|
69
|
+
// all — the container starts and that port keeps answering the other
|
|
70
|
+
// process. The probe is the only thing that catches it there.
|
|
71
|
+
await this.probePortsIfFreshStart(dockerComposePath);
|
|
72
|
+
await this.upDetachedOrError(dockerComposePath, pullPolicy);
|
|
74
73
|
this.log('✅ Services started. Run `output dev` without --detached to monitor status.\n');
|
|
75
74
|
return;
|
|
76
75
|
}
|
|
76
|
+
// Detect an existing stack for this project so a re-run attaches instead of
|
|
77
|
+
// colliding. A raw port probe can't tell our own running containers from a
|
|
78
|
+
// foreign process; `docker compose ps` can — though only within the shared
|
|
79
|
+
// compose project name, which doesn't separate one checkout from another.
|
|
80
|
+
const existingServices = await this.getServiceStatusOrWarn(dockerComposePath);
|
|
81
|
+
const stackState = classifyStackState(existingServices);
|
|
82
|
+
if (stackState === STACK_STATE.NONE) {
|
|
83
|
+
await this.probePorts();
|
|
84
|
+
}
|
|
85
|
+
else if (stackState === STACK_STATE.PARTIAL) {
|
|
86
|
+
// Reconcile: converge a stack that's up but incomplete to the compose
|
|
87
|
+
// spec, then monitor. Inspected (not fire-and-forget) so a failed bind
|
|
88
|
+
// surfaces the same actionable port-collision hint the fresh-start path
|
|
89
|
+
// gives. Recovers the orphaned stack a crashed session leaves behind.
|
|
90
|
+
this.log('🔄 Existing services detected — reconciling before attaching...\n');
|
|
91
|
+
await this.upDetachedOrError(dockerComposePath, pullPolicy);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
this.log('🔗 Services already running — attaching to monitor.\n');
|
|
95
|
+
}
|
|
96
|
+
// We own the stack only when we started it ourselves. Attaching to (or
|
|
97
|
+
// reconciling) a stack the user already had running leaves it up on quit.
|
|
98
|
+
const ownsStack = stackState === STACK_STATE.NONE;
|
|
99
|
+
if (!ownsStack) {
|
|
100
|
+
// The stack outliving this session is the surprising half of attach mode,
|
|
101
|
+
// and neither log line above says so. State the consequence plainly —
|
|
102
|
+
// container state alone can't tell a backgrounded `-d` stack from a
|
|
103
|
+
// crashed foreground session, so we can't infer intent, only report it.
|
|
104
|
+
this.log('Quitting leaves these services running — stop them with `output dev down`.\n');
|
|
105
|
+
}
|
|
77
106
|
const state = {
|
|
78
107
|
cleaningUp: false
|
|
79
108
|
};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
109
|
+
// Only an invocation that started the stack owns its teardown; attach and
|
|
110
|
+
// reconcile sessions leave it running. Both the signal-driven cleanup and
|
|
111
|
+
// the abnormal-exit catch route through here so the rule lives in one place.
|
|
112
|
+
const teardownIfOwned = async () => {
|
|
113
|
+
if (!ownsStack) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
83
116
|
if (this.dockerProcess) {
|
|
84
117
|
this.dockerProcess.kill('SIGTERM');
|
|
85
118
|
}
|
|
86
119
|
await stopDockerCompose(dockerComposePath);
|
|
87
120
|
};
|
|
121
|
+
const cleanup = async () => {
|
|
122
|
+
state.cleaningUp = true;
|
|
123
|
+
this.log('\n');
|
|
124
|
+
await teardownIfOwned();
|
|
125
|
+
};
|
|
88
126
|
// INK paints onto the alternate screen buffer so log-update has a
|
|
89
127
|
// fixed-height canvas and doesn't scroll old frames into the user's
|
|
90
128
|
// scrollback when the rendered tree exceeds the visible terminal rows.
|
|
@@ -110,7 +148,28 @@ export default class Dev extends Command {
|
|
|
110
148
|
// `instance` ref is filled in once `render()` returns; until then,
|
|
111
149
|
// signal handlers just stop docker and exit.
|
|
112
150
|
const instanceRef = { current: null };
|
|
113
|
-
|
|
151
|
+
// Single terminal-restore sequence shared by every exit path: stop Ink
|
|
152
|
+
// (frees raw mode) then leave the alt-screen. The `finally` guarantees
|
|
153
|
+
// the alt-screen is left even if `unmount()` throws — otherwise a crash
|
|
154
|
+
// could strand the user in the blank alt buffer. This is the one place
|
|
155
|
+
// the unmount → leave-alt-screen order lives, so the paths can't drift.
|
|
156
|
+
const restoreTerminal = () => {
|
|
157
|
+
try {
|
|
158
|
+
instanceRef.current?.unmount();
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
exitAltScreenOnce();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
// Collect a disposer for every process listener so registration and
|
|
165
|
+
// teardown can't drift: a handler added through `on` is always removed by
|
|
166
|
+
// the `finally` below, with no separate removeListener list to keep in sync.
|
|
167
|
+
const disposers = [];
|
|
168
|
+
const on = (event, handler) => {
|
|
169
|
+
process.on(event, handler);
|
|
170
|
+
disposers.push(() => process.removeListener(event, handler));
|
|
171
|
+
};
|
|
172
|
+
on('exit', exitAltScreenOnce);
|
|
114
173
|
// `process.on` doesn't await the handler, so the cleanup promise would
|
|
115
174
|
// float and any rejection would surface as an unhandled rejection.
|
|
116
175
|
// Wrap the async work in a sync registration that explicitly logs
|
|
@@ -124,43 +183,131 @@ export default class Dev extends Command {
|
|
|
124
183
|
exitAltScreenOnce();
|
|
125
184
|
console.error('Cleanup failed:', getErrorMessage(err));
|
|
126
185
|
})
|
|
127
|
-
.finally(
|
|
186
|
+
.finally(restoreTerminal);
|
|
187
|
+
};
|
|
188
|
+
on('SIGINT', handleSignal);
|
|
189
|
+
on('SIGTERM', handleSignal);
|
|
190
|
+
// A fatal crash (uncaught exception / unhandled rejection) skips both the
|
|
191
|
+
// clean-exit path and the signal handlers. Tear docker down via cleanup()
|
|
192
|
+
// FIRST, then restore the terminal and print the crash: unmounting Ink
|
|
193
|
+
// resolves the awaited waitUntilExit(), which resumes run() and strips the
|
|
194
|
+
// signal listeners — so unmounting before cleanup would drop them
|
|
195
|
+
// mid-teardown and let a Ctrl+C orphan the stack. Print the raw error so
|
|
196
|
+
// Node's stack trace survives, then re-exit non-zero. Fire-once: a second
|
|
197
|
+
// catchable crash during teardown is a no-op. A hard V8 abort() (SIGABRT)
|
|
198
|
+
// is uncatchable and not covered here.
|
|
199
|
+
const fatalState = { handled: false };
|
|
200
|
+
const handleFatalError = (err) => {
|
|
201
|
+
if (fatalState.handled) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
fatalState.handled = true;
|
|
205
|
+
cleanup()
|
|
206
|
+
.catch(cleanupErr => console.error('Cleanup failed:', getErrorMessage(cleanupErr)))
|
|
207
|
+
.finally(() => {
|
|
208
|
+
restoreTerminal();
|
|
209
|
+
console.error(err);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
});
|
|
128
212
|
};
|
|
129
|
-
|
|
130
|
-
|
|
213
|
+
on('uncaughtException', handleFatalError);
|
|
214
|
+
on('unhandledRejection', handleFatalError);
|
|
131
215
|
try {
|
|
132
216
|
enterAltScreen();
|
|
133
|
-
const instance = render(React.createElement(DevApp, { dockerComposePath, onCleanup: cleanup }), { exitOnCtrlC: false });
|
|
217
|
+
const instance = render(React.createElement(DevApp, { dockerComposePath, onCleanup: cleanup, attached: !ownsStack }), { exitOnCtrlC: false });
|
|
134
218
|
instanceRef.current = instance;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
219
|
+
// Attach mode monitors via `docker compose ps` polling (inside DevApp) —
|
|
220
|
+
// it must not own a foreground `up`, which stops the whole project on exit.
|
|
221
|
+
if (ownsStack) {
|
|
222
|
+
const dockerProc = await startDockerCompose({
|
|
223
|
+
dockerComposePath,
|
|
224
|
+
pullPolicy,
|
|
225
|
+
onError: error => {
|
|
226
|
+
instance.unmount(new Error(`Docker process error: ${getErrorMessage(error)}`));
|
|
227
|
+
},
|
|
228
|
+
onExit: (code, signal, output) => {
|
|
229
|
+
if (state.cleaningUp) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (code === 0) {
|
|
233
|
+
instance.unmount();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const exitReason = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
|
|
237
|
+
instance.unmount(new Error(formatComposeFailure(`Docker compose exited with ${exitReason}.`, output, config.ports)));
|
|
144
238
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
const exitReason = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
|
|
150
|
-
const hint = formatPortCollisionHint(output, config.ports);
|
|
151
|
-
const prefix = hint ? `${hint}\n\n` : '';
|
|
152
|
-
const detail = output ? `\n\nRecent Docker output:\n${output}` : '';
|
|
153
|
-
instance.unmount(new Error(`${prefix}Docker compose exited with ${exitReason}.${detail}`));
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
this.dockerProcess = dockerProc;
|
|
239
|
+
});
|
|
240
|
+
this.dockerProcess = dockerProc;
|
|
241
|
+
}
|
|
157
242
|
await instance.waitUntilExit();
|
|
158
243
|
exitAltScreenOnce();
|
|
159
244
|
}
|
|
160
245
|
catch (error) {
|
|
161
|
-
|
|
162
|
-
|
|
246
|
+
restoreTerminal();
|
|
247
|
+
// An abnormal exit (health timeout, docker crash) resolves outside the
|
|
248
|
+
// signal path. Tear down here only if cleanup hasn't already run, so an
|
|
249
|
+
// owned stack never leaks containers that keep holding published ports.
|
|
250
|
+
// teardownIfOwned no-ops for attach/reconcile sessions.
|
|
251
|
+
if (!state.cleaningUp) {
|
|
252
|
+
await teardownIfOwned().catch(teardownError => {
|
|
253
|
+
// Don't mask the original failure, but the stack may still be up
|
|
254
|
+
// holding ports — tell the user how to stop it.
|
|
255
|
+
this.warn('Failed to stop services during cleanup — they may still be running. ' +
|
|
256
|
+
`Stop them with \`output dev down\`.\n${getErrorMessage(teardownError)}`);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
163
259
|
this.error(getErrorMessage(error), { exit: 1 });
|
|
164
260
|
}
|
|
261
|
+
finally {
|
|
262
|
+
// Remove every process-global listener registered above; otherwise each
|
|
263
|
+
// run() (test invocations included) leaks a live handler that force-
|
|
264
|
+
// exits the process on the next stray signal or rejection.
|
|
265
|
+
disposers.forEach(dispose => dispose());
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Probe each published host port before docker runs. Compose won't collide
|
|
269
|
+
// with its own containers, but a foreign process on one of these ports would
|
|
270
|
+
// leave the Ink TUI in limbo (or, detached, produce a container nobody can
|
|
271
|
+
// reach), so surface it now with an actionable hint.
|
|
272
|
+
async probePorts() {
|
|
273
|
+
const takenPorts = await findUnavailablePorts(Object.values(config.ports));
|
|
274
|
+
if (takenPorts.length > 0) {
|
|
275
|
+
this.error(formatPortCollisionsHint(takenPorts, config.ports), { exit: 1 });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Probe only when no container of ours is live — otherwise our own stack is
|
|
279
|
+
// legitimately holding the ports and the probe would abort on it, which is
|
|
280
|
+
// the failure this detection replaced.
|
|
281
|
+
async probePortsIfFreshStart(dockerComposePath) {
|
|
282
|
+
const services = await this.getServiceStatusOrWarn(dockerComposePath);
|
|
283
|
+
if (classifyStackState(services) === STACK_STATE.NONE) {
|
|
284
|
+
await this.probePorts();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Query the stack, falling back to "nothing running" when the query itself
|
|
288
|
+
// fails. A fresh start is the right recovery, but [] is not a neutral default
|
|
289
|
+
// — it asserts "no containers exist", which every caller acts on. Warn, or a
|
|
290
|
+
// broken `ps` silently port-probes onto the user's own containers and reports
|
|
291
|
+
// a port collision whose remedy (change the port) is wrong for the cause.
|
|
292
|
+
getServiceStatusOrWarn = (dockerComposePath) => getServiceStatus(dockerComposePath).catch((error) => {
|
|
293
|
+
this.warn(`Could not query existing services (${getErrorMessage(error)}). ` +
|
|
294
|
+
'Continuing as a fresh start — if services are already running, stop them ' +
|
|
295
|
+
'with `output dev down` first.');
|
|
296
|
+
return [];
|
|
297
|
+
});
|
|
298
|
+
// Bring the stack up detached and fail with an actionable message if compose
|
|
299
|
+
// couldn't launch it (most often a foreign process holding a published
|
|
300
|
+
// port). Shared by the `--detached` flag and the PARTIAL reconcile branch so
|
|
301
|
+
// both surface the port-collision hint instead of a raw compose error.
|
|
302
|
+
async upDetachedOrError(dockerComposePath, pullPolicy) {
|
|
303
|
+
const { code, signal, output } = await runDockerComposeUpDetached(dockerComposePath, pullPolicy);
|
|
304
|
+
if (code === 0) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// A signalled compose (an OOM-killed pull, a SIGKILL) has no exit code —
|
|
308
|
+
// report the signal rather than "unknown", which discards a known cause.
|
|
309
|
+
const reason = signal ? `signal ${signal}` : `exit code ${code ?? 'unknown'}`;
|
|
310
|
+
this.error(formatComposeFailure(`Docker compose failed to start services (${reason}). ` +
|
|
311
|
+
'Some containers may have started — stop them with `output dev down`.', output, config.ports), { exit: 1 });
|
|
165
312
|
}
|
|
166
313
|
}
|