@outputai/cli 0.5.2-next.75f5eed.0 → 0.5.2-next.93dd22e.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/assets/docker/docker-compose-dev.yml +4 -1
- package/dist/commands/dev/index.js +14 -2
- package/dist/commands/dev/index.spec.js +46 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/dist/config.spec.js +11 -3
- package/dist/generated/framework_version.json +1 -1
- package/dist/templates/project/.env.example.template +1 -0
- package/dist/utils/port_availability.d.ts +9 -0
- package/dist/utils/port_availability.js +40 -0
- package/dist/utils/port_availability.spec.d.ts +1 -0
- package/dist/utils/port_availability.spec.js +62 -0
- package/dist/utils/port_collision.d.ts +34 -0
- package/dist/utils/port_collision.js +112 -0
- package/dist/utils/port_collision.spec.d.ts +1 -0
- package/dist/utils/port_collision.spec.js +82 -0
- package/package.json +4 -4
|
@@ -43,9 +43,12 @@ services:
|
|
|
43
43
|
- POSTGRES_USER=temporal
|
|
44
44
|
- POSTGRES_PWD=temporal
|
|
45
45
|
- POSTGRES_SEEDS=postgresql
|
|
46
|
+
- DEFAULT_NAMESPACE_RETENTION=720h
|
|
46
47
|
image: temporalio/auto-setup:latest
|
|
47
48
|
networks:
|
|
48
49
|
- main
|
|
50
|
+
ports:
|
|
51
|
+
- '${OUTPUT_TEMPORAL_HOST_PORT:-7233}:7233'
|
|
49
52
|
healthcheck:
|
|
50
53
|
test:
|
|
51
54
|
[
|
|
@@ -77,7 +80,7 @@ services:
|
|
|
77
80
|
condition: service_healthy
|
|
78
81
|
worker:
|
|
79
82
|
condition: service_healthy
|
|
80
|
-
image: outputai/api:${OUTPUT_API_VERSION:-0.5.2-next.
|
|
83
|
+
image: outputai/api:${OUTPUT_API_VERSION:-0.5.2-next.93dd22e.0}
|
|
81
84
|
init: true
|
|
82
85
|
networks:
|
|
83
86
|
- main
|
|
@@ -5,6 +5,8 @@ import { render } from 'ink';
|
|
|
5
5
|
import React from 'react';
|
|
6
6
|
import { validateDockerEnvironment, startDockerCompose, startDockerComposeDetached, stopDockerCompose, DockerComposeConfigNotFoundError, getDefaultDockerComposePath } from '#services/docker.js';
|
|
7
7
|
import { getErrorMessage } from '#utils/error_utils.js';
|
|
8
|
+
import { formatPortCollisionHint, formatPortCollisionsHint } from '#utils/port_collision.js';
|
|
9
|
+
import { findUnavailablePorts } from '#utils/port_availability.js';
|
|
8
10
|
import { ensureClaudePlugin } from '#services/coding_agents.js';
|
|
9
11
|
import { DevApp } from '#views/dev/dev_app.js';
|
|
10
12
|
import { config } from '#config.js';
|
|
@@ -15,7 +17,8 @@ export default class Dev extends Command {
|
|
|
15
17
|
'To run a second dev stack concurrently, override host ports in .env:',
|
|
16
18
|
'',
|
|
17
19
|
' OUTPUT_API_HOST_PORT=3002',
|
|
18
|
-
' OUTPUT_TEMPORAL_UI_HOST_PORT=8081'
|
|
20
|
+
' OUTPUT_TEMPORAL_UI_HOST_PORT=8081',
|
|
21
|
+
' OUTPUT_TEMPORAL_HOST_PORT=7234'
|
|
19
22
|
].join('\n');
|
|
20
23
|
static examples = [
|
|
21
24
|
'<%= config.bin %> <%= command.id %>',
|
|
@@ -48,6 +51,13 @@ export default class Dev extends Command {
|
|
|
48
51
|
validateDockerEnvironment();
|
|
49
52
|
// Eagerly resolve ports so InvalidPortError surfaces before Ink mounts.
|
|
50
53
|
void config.ports;
|
|
54
|
+
// Probe each published host port before docker runs. docker compose up
|
|
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
|
+
}
|
|
51
61
|
const dockerComposePath = flags['compose-file'] ?
|
|
52
62
|
path.resolve(process.cwd(), flags['compose-file']) :
|
|
53
63
|
getDefaultDockerComposePath();
|
|
@@ -137,8 +147,10 @@ export default class Dev extends Command {
|
|
|
137
147
|
return;
|
|
138
148
|
}
|
|
139
149
|
const exitReason = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
|
|
150
|
+
const hint = formatPortCollisionHint(output, config.ports);
|
|
151
|
+
const prefix = hint ? `${hint}\n\n` : '';
|
|
140
152
|
const detail = output ? `\n\nRecent Docker output:\n${output}` : '';
|
|
141
|
-
instance.unmount(new Error(
|
|
153
|
+
instance.unmount(new Error(`${prefix}Docker compose exited with ${exitReason}.${detail}`));
|
|
142
154
|
}
|
|
143
155
|
});
|
|
144
156
|
this.dockerProcess = dockerProc;
|
|
@@ -5,10 +5,14 @@ import fs from 'node:fs/promises';
|
|
|
5
5
|
import { render } from 'ink';
|
|
6
6
|
import * as dockerService from '#services/docker.js';
|
|
7
7
|
import * as codingAgentsService from '#services/coding_agents.js';
|
|
8
|
+
import * as portAvailability from '#utils/port_availability.js';
|
|
8
9
|
import Dev from './index.js';
|
|
9
10
|
vi.mock('#services/coding_agents.js', () => ({
|
|
10
11
|
ensureClaudePlugin: vi.fn().mockResolvedValue(undefined)
|
|
11
12
|
}));
|
|
13
|
+
vi.mock('#utils/port_availability.js', () => ({
|
|
14
|
+
findUnavailablePorts: vi.fn().mockResolvedValue([])
|
|
15
|
+
}));
|
|
12
16
|
vi.mock('#services/docker.js', () => ({
|
|
13
17
|
validateDockerEnvironment: vi.fn(),
|
|
14
18
|
startDockerCompose: vi.fn(),
|
|
@@ -82,6 +86,8 @@ describe('dev command', () => {
|
|
|
82
86
|
vi.clearAllMocks();
|
|
83
87
|
// By default, docker validation succeeds
|
|
84
88
|
vi.mocked(dockerService.validateDockerEnvironment).mockResolvedValue(undefined);
|
|
89
|
+
// By default, no host port is taken — individual tests opt in.
|
|
90
|
+
vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([]);
|
|
85
91
|
// By default, startDockerCompose returns a mock process
|
|
86
92
|
vi.mocked(dockerService.startDockerCompose).mockResolvedValue(createMockDockerProcess());
|
|
87
93
|
// By default, fs.access succeeds (file exists)
|
|
@@ -223,6 +229,42 @@ describe('dev command', () => {
|
|
|
223
229
|
cmd.error = vi.fn();
|
|
224
230
|
await expect(cmd.run()).rejects.toThrow();
|
|
225
231
|
});
|
|
232
|
+
it('aborts with an actionable hint before docker runs when a host port is already taken', async () => {
|
|
233
|
+
vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([3001]);
|
|
234
|
+
const cmd = new Dev([], {});
|
|
235
|
+
cmd.log = vi.fn();
|
|
236
|
+
cmd.error = vi.fn(() => {
|
|
237
|
+
throw new Error('oclif-error-thrown');
|
|
238
|
+
});
|
|
239
|
+
Object.defineProperty(cmd, 'parse', {
|
|
240
|
+
value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
|
|
241
|
+
configurable: true
|
|
242
|
+
});
|
|
243
|
+
await expect(cmd.run()).rejects.toThrow();
|
|
244
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Port 3001 is already in use.'), { exit: 1 });
|
|
245
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('OUTPUT_API_HOST_PORT=<other port>'), { exit: 1 });
|
|
246
|
+
expect(dockerService.startDockerCompose).not.toHaveBeenCalled();
|
|
247
|
+
expect(render).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
it('lists every taken port when multiple collide, not just the first', async () => {
|
|
250
|
+
vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([3001, 7233]);
|
|
251
|
+
const cmd = new Dev([], {});
|
|
252
|
+
cmd.log = vi.fn();
|
|
253
|
+
cmd.error = vi.fn(() => {
|
|
254
|
+
throw new Error('oclif-error-thrown');
|
|
255
|
+
});
|
|
256
|
+
Object.defineProperty(cmd, 'parse', {
|
|
257
|
+
value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
|
|
258
|
+
configurable: true
|
|
259
|
+
});
|
|
260
|
+
await expect(cmd.run()).rejects.toThrow();
|
|
261
|
+
const [message] = vi.mocked(cmd.error).mock.calls[0];
|
|
262
|
+
expect(message).toContain('Multiple host ports are already in use:');
|
|
263
|
+
expect(message).toContain('• Port 3001 — override with OUTPUT_API_HOST_PORT=<other port>');
|
|
264
|
+
expect(message).toContain('• Port 7233 — override with OUTPUT_TEMPORAL_HOST_PORT=<other port>');
|
|
265
|
+
expect(dockerService.startDockerCompose).not.toHaveBeenCalled();
|
|
266
|
+
expect(render).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
226
268
|
it('should handle startDockerCompose errors', async () => {
|
|
227
269
|
vi.mocked(dockerService.startDockerCompose).mockRejectedValue(new Error('Docker error'));
|
|
228
270
|
const cmd = new Dev([], {});
|
|
@@ -250,12 +292,14 @@ describe('dev command', () => {
|
|
|
250
292
|
});
|
|
251
293
|
const runPromise = cmd.run();
|
|
252
294
|
await new Promise(resolve => setImmediate(resolve));
|
|
253
|
-
getStartDockerComposeOptions().onExit?.(1, null, 'failed
|
|
295
|
+
getStartDockerComposeOptions().onExit?.(1, null, 'Bind for 0.0.0.0:3001 failed: port is already allocated');
|
|
254
296
|
await runPromise;
|
|
255
297
|
expect(inkInstance.unmount).toHaveBeenCalledWith(expect.objectContaining({
|
|
256
298
|
message: expect.stringContaining('Docker compose exited with code 1')
|
|
257
299
|
}));
|
|
258
|
-
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Recent Docker output:\
|
|
300
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Recent Docker output:\nBind for 0.0.0.0:3001 failed'), { exit: 1 });
|
|
301
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Port 3001 is already in use.'), { exit: 1 });
|
|
302
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('OUTPUT_API_HOST_PORT=<other port>'), { exit: 1 });
|
|
259
303
|
});
|
|
260
304
|
it('should ignore docker compose exits triggered by cleanup', async () => {
|
|
261
305
|
const dockerProcess = createMockDockerProcess();
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parsePort } from '#utils/validation.js';
|
|
2
2
|
const DEFAULT_API_PORT = 3001;
|
|
3
3
|
const DEFAULT_TEMPORAL_UI_PORT = 8080;
|
|
4
|
+
const DEFAULT_TEMPORAL_PORT = 7233;
|
|
4
5
|
export const config = {
|
|
5
6
|
get apiUrl() {
|
|
6
7
|
return process.env.OUTPUT_API_URL || `http://localhost:${this.ports.api}`;
|
|
@@ -8,6 +9,7 @@ export const config = {
|
|
|
8
9
|
get ports() {
|
|
9
10
|
return {
|
|
10
11
|
temporalUi: parsePort(process.env.OUTPUT_TEMPORAL_UI_HOST_PORT, DEFAULT_TEMPORAL_UI_PORT, 'OUTPUT_TEMPORAL_UI_HOST_PORT'),
|
|
12
|
+
temporal: parsePort(process.env.OUTPUT_TEMPORAL_HOST_PORT, DEFAULT_TEMPORAL_PORT, 'OUTPUT_TEMPORAL_HOST_PORT'),
|
|
11
13
|
api: parsePort(process.env.OUTPUT_API_HOST_PORT, DEFAULT_API_PORT, 'OUTPUT_API_HOST_PORT')
|
|
12
14
|
};
|
|
13
15
|
},
|
package/dist/config.spec.js
CHANGED
|
@@ -6,6 +6,7 @@ describe('config', () => {
|
|
|
6
6
|
'OUTPUT_API_URL',
|
|
7
7
|
'OUTPUT_API_HOST_PORT',
|
|
8
8
|
'OUTPUT_TEMPORAL_UI_HOST_PORT',
|
|
9
|
+
'OUTPUT_TEMPORAL_HOST_PORT',
|
|
9
10
|
'OUTPUT_API_AUTH_TOKEN',
|
|
10
11
|
'DOCKER_SERVICE_NAME',
|
|
11
12
|
'OUTPUT_DEBUG',
|
|
@@ -41,11 +42,12 @@ describe('config', () => {
|
|
|
41
42
|
delete process.env.OUTPUT_API_URL;
|
|
42
43
|
delete process.env.OUTPUT_API_HOST_PORT;
|
|
43
44
|
delete process.env.OUTPUT_TEMPORAL_UI_HOST_PORT;
|
|
45
|
+
delete process.env.OUTPUT_TEMPORAL_HOST_PORT;
|
|
44
46
|
delete process.env.DOCKER_SERVICE_NAME;
|
|
45
47
|
delete process.env.OUTPUT_DEBUG;
|
|
46
48
|
delete process.env.OUTPUT_CLI_ENV;
|
|
47
49
|
expect(config.apiUrl).toBe('http://localhost:3001');
|
|
48
|
-
expect(config.ports).toEqual({ temporalUi: 8080, api: 3001 });
|
|
50
|
+
expect(config.ports).toEqual({ temporalUi: 8080, temporal: 7233, api: 3001 });
|
|
49
51
|
expect(config.temporalUiUrl).toBe('http://localhost:8080');
|
|
50
52
|
expect(config.dockerServiceName).toBe('output-sdk');
|
|
51
53
|
expect(config.debugMode).toBe(false);
|
|
@@ -68,18 +70,24 @@ describe('config', () => {
|
|
|
68
70
|
});
|
|
69
71
|
it('reads port overrides from env vars', () => {
|
|
70
72
|
process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '8081';
|
|
73
|
+
process.env.OUTPUT_TEMPORAL_HOST_PORT = '7234';
|
|
71
74
|
process.env.OUTPUT_API_HOST_PORT = '3002';
|
|
72
|
-
expect(config.ports).toEqual({ temporalUi: 8081, api: 3002 });
|
|
75
|
+
expect(config.ports).toEqual({ temporalUi: 8081, temporal: 7234, api: 3002 });
|
|
73
76
|
expect(config.temporalUiUrl).toBe('http://localhost:8081');
|
|
74
77
|
});
|
|
75
78
|
it('treats empty-string port env vars as unset (matches Compose semantics)', () => {
|
|
76
79
|
delete process.env.OUTPUT_API_URL;
|
|
77
80
|
process.env.OUTPUT_API_HOST_PORT = '';
|
|
78
81
|
process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '';
|
|
82
|
+
process.env.OUTPUT_TEMPORAL_HOST_PORT = '';
|
|
79
83
|
expect(config.apiUrl).toBe('http://localhost:3001');
|
|
80
|
-
expect(config.ports).toEqual({ temporalUi: 8080, api: 3001 });
|
|
84
|
+
expect(config.ports).toEqual({ temporalUi: 8080, temporal: 7233, api: 3001 });
|
|
81
85
|
expect(config.temporalUiUrl).toBe('http://localhost:8080');
|
|
82
86
|
});
|
|
87
|
+
it('throws InvalidPortError on invalid OUTPUT_TEMPORAL_HOST_PORT', () => {
|
|
88
|
+
process.env.OUTPUT_TEMPORAL_HOST_PORT = 'not-a-port';
|
|
89
|
+
expect(() => config.ports).toThrow(InvalidPortError);
|
|
90
|
+
});
|
|
83
91
|
it('throws InvalidPortError on non-numeric port values', () => {
|
|
84
92
|
delete process.env.OUTPUT_API_URL;
|
|
85
93
|
process.env.OUTPUT_API_HOST_PORT = 'abc';
|
|
@@ -12,6 +12,7 @@ OPENAI_API_KEY=credential:openai.api_key
|
|
|
12
12
|
# at temporal:7233) is unaffected.
|
|
13
13
|
# OUTPUT_API_HOST_PORT=3001
|
|
14
14
|
# OUTPUT_TEMPORAL_UI_HOST_PORT=8080
|
|
15
|
+
# OUTPUT_TEMPORAL_HOST_PORT=7233
|
|
15
16
|
|
|
16
17
|
# --- API connection ---
|
|
17
18
|
# If OUTPUT_API_URL is set, it overrides OUTPUT_API_HOST_PORT.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe each port by attempting to bind a TCP server on `0.0.0.0`. Returns
|
|
3
|
+
* all ports that are currently in use (listening with `EADDRINUSE` errors).
|
|
4
|
+
*
|
|
5
|
+
* This function checks multiple ports concurrently and filters to return only
|
|
6
|
+
* those that are unavailable. Docker remains the ultimate decider for
|
|
7
|
+
* ambiguous cases during actual compose up.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findUnavailablePorts(ports: number[]): Promise<number[]>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
/**
|
|
3
|
+
* Check if a specific port is taken by attempting to bind a TCP server.
|
|
4
|
+
*
|
|
5
|
+
* Binds on `0.0.0.0` (not loopback) to match the publish address docker
|
|
6
|
+
* compose uses for `${PORT}:${TARGET}` mappings. Errors other than `EADDRINUSE`
|
|
7
|
+
* are treated as "free" so we don't abort on conditions docker would accept.
|
|
8
|
+
*/
|
|
9
|
+
function isPortTaken(port) {
|
|
10
|
+
return new Promise(resolve => {
|
|
11
|
+
const server = net.createServer();
|
|
12
|
+
const settle = (value) => {
|
|
13
|
+
server.removeAllListeners();
|
|
14
|
+
if (value) {
|
|
15
|
+
resolve(true);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
server.close(() => resolve(false));
|
|
19
|
+
};
|
|
20
|
+
server.once('error', (err) => {
|
|
21
|
+
settle(err.code === 'EADDRINUSE');
|
|
22
|
+
});
|
|
23
|
+
server.once('listening', () => {
|
|
24
|
+
settle(false);
|
|
25
|
+
});
|
|
26
|
+
server.listen(port, '0.0.0.0');
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Probe each port by attempting to bind a TCP server on `0.0.0.0`. Returns
|
|
31
|
+
* all ports that are currently in use (listening with `EADDRINUSE` errors).
|
|
32
|
+
*
|
|
33
|
+
* This function checks multiple ports concurrently and filters to return only
|
|
34
|
+
* those that are unavailable. Docker remains the ultimate decider for
|
|
35
|
+
* ambiguous cases during actual compose up.
|
|
36
|
+
*/
|
|
37
|
+
export async function findUnavailablePorts(ports) {
|
|
38
|
+
const results = await Promise.all(ports.map(async (port) => ({ port, taken: await isPortTaken(port) })));
|
|
39
|
+
return results.filter(r => r.taken).map(r => r.port);
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { findUnavailablePorts } from './port_availability.js';
|
|
4
|
+
/**
|
|
5
|
+
* Open a real TCP server on an ephemeral port and return the bound port plus
|
|
6
|
+
* a close helper. We can't hard-code a "known free" port for testing because
|
|
7
|
+
* any port we pick could be taken on the dev machine — letting the OS hand
|
|
8
|
+
* out an ephemeral one keeps the test deterministic across environments.
|
|
9
|
+
*/
|
|
10
|
+
function listenOnEphemeralPort() {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const server = net.createServer();
|
|
13
|
+
server.once('error', reject);
|
|
14
|
+
server.listen(0, '0.0.0.0', () => {
|
|
15
|
+
const address = server.address();
|
|
16
|
+
if (typeof address !== 'object' || address === null) {
|
|
17
|
+
reject(new Error('failed to resolve ephemeral port address'));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
resolve({
|
|
21
|
+
port: address.port,
|
|
22
|
+
close: () => new Promise(resolveClose => server.close(() => resolveClose()))
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
describe('findUnavailablePorts', () => {
|
|
28
|
+
const fixture = { occupied: null };
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
fixture.occupied = await listenOnEphemeralPort();
|
|
31
|
+
});
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
if (fixture.occupied) {
|
|
34
|
+
await fixture.occupied.close();
|
|
35
|
+
fixture.occupied = null;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('returns an empty array when given no ports', async () => {
|
|
39
|
+
expect(await findUnavailablePorts([])).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
it('returns the occupied port when it is the only one probed', async () => {
|
|
42
|
+
expect(await findUnavailablePorts([fixture.occupied.port])).toEqual([fixture.occupied.port]);
|
|
43
|
+
});
|
|
44
|
+
it('returns all occupied ports, not just the first one', async () => {
|
|
45
|
+
const second = await listenOnEphemeralPort();
|
|
46
|
+
try {
|
|
47
|
+
const free = await listenOnEphemeralPort();
|
|
48
|
+
await free.close();
|
|
49
|
+
const result = await findUnavailablePorts([fixture.occupied.port, free.port, second.port]);
|
|
50
|
+
expect(result.sort()).toEqual([fixture.occupied.port, second.port].sort());
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await second.close();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
it('returns an empty array when every probed port is free', async () => {
|
|
57
|
+
const free = await listenOnEphemeralPort();
|
|
58
|
+
const port = free.port;
|
|
59
|
+
await free.close();
|
|
60
|
+
expect(await findUnavailablePorts([port])).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse docker compose stderr for host-port bind failures and turn them into
|
|
3
|
+
* an actionable hint that names the conflicting port and the env var to
|
|
4
|
+
* override.
|
|
5
|
+
*
|
|
6
|
+
* Docker compose surfaces port collisions through two common error shapes:
|
|
7
|
+
* - "Bind for 0.0.0.0:3001 failed: port is already allocated"
|
|
8
|
+
* - "failed to bind host port for 0.0.0.0:7233:.../tcp: address already in use"
|
|
9
|
+
*
|
|
10
|
+
* We match both, extract the host port, then map it back to the env var that
|
|
11
|
+
* sets it. The map prefers a runtime lookup of resolved ports (so a user who
|
|
12
|
+
* already set OUTPUT_API_HOST_PORT=3050 sees that var named when 3050
|
|
13
|
+
* collides) and falls back to a default-port table for the unresolved case.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Find the first host port mentioned in a docker compose bind failure.
|
|
17
|
+
* Returns null when no recognized pattern matches.
|
|
18
|
+
*/
|
|
19
|
+
export declare function extractCollidedPort(stderr: string): number | null;
|
|
20
|
+
/**
|
|
21
|
+
* Build an actionable, multi-line hint for the first port collision found in
|
|
22
|
+
* stderr. Returns null when no collision is detected. When the colliding port
|
|
23
|
+
* is one we own (api / temporalUi / temporal), the hint names the env var
|
|
24
|
+
* that overrides it; otherwise it suggests freeing the port.
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatPortCollisionHint(stderr: string, resolvedPorts: Record<string, number>): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Build a hint from a known list of colliding ports. For a single collision
|
|
29
|
+
* the output matches `formatPortCollisionHint` exactly so callers stay
|
|
30
|
+
* symmetric. For multiple collisions a bulleted list is rendered, each line
|
|
31
|
+
* naming the env var that overrides that specific port (or a generic
|
|
32
|
+
* suggestion when the port isn't one we own).
|
|
33
|
+
*/
|
|
34
|
+
export declare function formatPortCollisionsHint(ports: number[], resolvedPorts: Record<string, number>): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse docker compose stderr for host-port bind failures and turn them into
|
|
3
|
+
* an actionable hint that names the conflicting port and the env var to
|
|
4
|
+
* override.
|
|
5
|
+
*
|
|
6
|
+
* Docker compose surfaces port collisions through two common error shapes:
|
|
7
|
+
* - "Bind for 0.0.0.0:3001 failed: port is already allocated"
|
|
8
|
+
* - "failed to bind host port for 0.0.0.0:7233:.../tcp: address already in use"
|
|
9
|
+
*
|
|
10
|
+
* We match both, extract the host port, then map it back to the env var that
|
|
11
|
+
* sets it. The map prefers a runtime lookup of resolved ports (so a user who
|
|
12
|
+
* already set OUTPUT_API_HOST_PORT=3050 sees that var named when 3050
|
|
13
|
+
* collides) and falls back to a default-port table for the unresolved case.
|
|
14
|
+
*/
|
|
15
|
+
const PORT_BIND_PATTERNS = [
|
|
16
|
+
/Bind for [^:\s]+:(\d+) failed: port is already allocated/,
|
|
17
|
+
/failed to bind host port for [^:\s]+:(\d+)[^]*?address already in use/,
|
|
18
|
+
/listen tcp [^:\s]+:(\d+):\s*bind: address already in use/
|
|
19
|
+
];
|
|
20
|
+
const DEFAULT_PORT_TO_ENV_VAR = {
|
|
21
|
+
3001: 'OUTPUT_API_HOST_PORT',
|
|
22
|
+
8080: 'OUTPUT_TEMPORAL_UI_HOST_PORT',
|
|
23
|
+
7233: 'OUTPUT_TEMPORAL_HOST_PORT'
|
|
24
|
+
};
|
|
25
|
+
const RESOLVED_PORT_KEY_TO_ENV_VAR = {
|
|
26
|
+
api: 'OUTPUT_API_HOST_PORT',
|
|
27
|
+
temporalUi: 'OUTPUT_TEMPORAL_UI_HOST_PORT',
|
|
28
|
+
temporal: 'OUTPUT_TEMPORAL_HOST_PORT'
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Find the first host port mentioned in a docker compose bind failure.
|
|
32
|
+
* Returns null when no recognized pattern matches.
|
|
33
|
+
*/
|
|
34
|
+
export function extractCollidedPort(stderr) {
|
|
35
|
+
if (!stderr) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
for (const pattern of PORT_BIND_PATTERNS) {
|
|
39
|
+
const match = stderr.match(pattern);
|
|
40
|
+
if (match) {
|
|
41
|
+
return parseInt(match[1], 10);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Map a host port to the env var that controls it. Resolved ports win over
|
|
48
|
+
* the static default map so user-overridden ports still resolve to the right
|
|
49
|
+
* var. Returns null when the port isn't owned by any of our services.
|
|
50
|
+
*/
|
|
51
|
+
function envVarForPort(port, resolvedPorts) {
|
|
52
|
+
for (const [key, value] of Object.entries(resolvedPorts)) {
|
|
53
|
+
if (value === port && RESOLVED_PORT_KEY_TO_ENV_VAR[key]) {
|
|
54
|
+
return RESOLVED_PORT_KEY_TO_ENV_VAR[key];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return DEFAULT_PORT_TO_ENV_VAR[port] ?? null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Render a single port collision as two lines: the "Port N is already in use"
|
|
61
|
+
* statement and either an env-var override suggestion or a generic "stop the
|
|
62
|
+
* process" fallback.
|
|
63
|
+
*/
|
|
64
|
+
function formatSingleCollision(port, resolvedPorts) {
|
|
65
|
+
const envVar = envVarForPort(port, resolvedPorts);
|
|
66
|
+
if (envVar) {
|
|
67
|
+
return [
|
|
68
|
+
`Port ${port} is already in use.`,
|
|
69
|
+
`Override it in your .env file: ${envVar}=<other port>`
|
|
70
|
+
].join('\n');
|
|
71
|
+
}
|
|
72
|
+
return [
|
|
73
|
+
`Port ${port} is already in use.`,
|
|
74
|
+
'Stop the process holding it, or change the host port in your compose file.'
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build an actionable, multi-line hint for the first port collision found in
|
|
79
|
+
* stderr. Returns null when no collision is detected. When the colliding port
|
|
80
|
+
* is one we own (api / temporalUi / temporal), the hint names the env var
|
|
81
|
+
* that overrides it; otherwise it suggests freeing the port.
|
|
82
|
+
*/
|
|
83
|
+
export function formatPortCollisionHint(stderr, resolvedPorts) {
|
|
84
|
+
const port = extractCollidedPort(stderr);
|
|
85
|
+
if (port === null) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return formatSingleCollision(port, resolvedPorts);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Build a hint from a known list of colliding ports. For a single collision
|
|
92
|
+
* the output matches `formatPortCollisionHint` exactly so callers stay
|
|
93
|
+
* symmetric. For multiple collisions a bulleted list is rendered, each line
|
|
94
|
+
* naming the env var that overrides that specific port (or a generic
|
|
95
|
+
* suggestion when the port isn't one we own).
|
|
96
|
+
*/
|
|
97
|
+
export function formatPortCollisionsHint(ports, resolvedPorts) {
|
|
98
|
+
if (ports.length === 0) {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
if (ports.length === 1) {
|
|
102
|
+
return formatSingleCollision(ports[0], resolvedPorts);
|
|
103
|
+
}
|
|
104
|
+
const lines = ['Multiple host ports are already in use:'];
|
|
105
|
+
for (const port of ports) {
|
|
106
|
+
const envVar = envVarForPort(port, resolvedPorts);
|
|
107
|
+
lines.push(envVar ?
|
|
108
|
+
` • Port ${port} — override with ${envVar}=<other port>` :
|
|
109
|
+
` • Port ${port} — stop the process holding it`);
|
|
110
|
+
}
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { extractCollidedPort, formatPortCollisionHint, formatPortCollisionsHint } from './port_collision.js';
|
|
3
|
+
const DEFAULT_PORTS = { api: 3001, temporalUi: 8080, temporal: 7233 };
|
|
4
|
+
describe('extractCollidedPort', () => {
|
|
5
|
+
it('matches the "Bind for ... port is already allocated" shape', () => {
|
|
6
|
+
expect(extractCollidedPort('Bind for 0.0.0.0:3001 failed: port is already allocated')).toBe(3001);
|
|
7
|
+
});
|
|
8
|
+
it('matches the "failed to bind host port ... address already in use" shape', () => {
|
|
9
|
+
const stderr = 'Error: failed to bind host port for 0.0.0.0:7233:172.17.0.2:7233/tcp: address already in use';
|
|
10
|
+
expect(extractCollidedPort(stderr)).toBe(7233);
|
|
11
|
+
});
|
|
12
|
+
it('matches the "listen tcp ... bind: address already in use" shape', () => {
|
|
13
|
+
expect(extractCollidedPort('listen tcp 127.0.0.1:8080: bind: address already in use')).toBe(8080);
|
|
14
|
+
});
|
|
15
|
+
it('returns the first port when stderr contains multiple bind failures', () => {
|
|
16
|
+
const stderr = [
|
|
17
|
+
'Bind for 0.0.0.0:3001 failed: port is already allocated',
|
|
18
|
+
'Bind for 0.0.0.0:8080 failed: port is already allocated'
|
|
19
|
+
].join('\n');
|
|
20
|
+
expect(extractCollidedPort(stderr)).toBe(3001);
|
|
21
|
+
});
|
|
22
|
+
it('returns null when no bind failure is present', () => {
|
|
23
|
+
expect(extractCollidedPort('some unrelated stderr line')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
it('returns null for empty input', () => {
|
|
26
|
+
expect(extractCollidedPort('')).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('formatPortCollisionHint', () => {
|
|
30
|
+
it('names the env var when the colliding port matches a default', () => {
|
|
31
|
+
const hint = formatPortCollisionHint('Bind for 0.0.0.0:3001 failed: port is already allocated', DEFAULT_PORTS);
|
|
32
|
+
expect(hint).toContain('Port 3001 is already in use.');
|
|
33
|
+
expect(hint).toContain('OUTPUT_API_HOST_PORT=<other port>');
|
|
34
|
+
});
|
|
35
|
+
it('names the env var for Temporal gRPC collisions', () => {
|
|
36
|
+
const hint = formatPortCollisionHint('failed to bind host port for 0.0.0.0:7233:172.17.0.2:7233/tcp: address already in use', DEFAULT_PORTS);
|
|
37
|
+
expect(hint).toContain('OUTPUT_TEMPORAL_HOST_PORT=<other port>');
|
|
38
|
+
});
|
|
39
|
+
it('resolves an overridden port back to its env var', () => {
|
|
40
|
+
const hint = formatPortCollisionHint('Bind for 0.0.0.0:3050 failed: port is already allocated', { ...DEFAULT_PORTS, api: 3050 });
|
|
41
|
+
expect(hint).toContain('OUTPUT_API_HOST_PORT=<other port>');
|
|
42
|
+
});
|
|
43
|
+
it('falls back to a generic suggestion for unknown ports', () => {
|
|
44
|
+
const hint = formatPortCollisionHint('Bind for 0.0.0.0:5432 failed: port is already allocated', DEFAULT_PORTS);
|
|
45
|
+
expect(hint).toContain('Port 5432 is already in use.');
|
|
46
|
+
expect(hint).not.toContain('OUTPUT_');
|
|
47
|
+
expect(hint).toContain('Stop the process holding it');
|
|
48
|
+
});
|
|
49
|
+
it('returns null when stderr has no recognizable bind failure', () => {
|
|
50
|
+
expect(formatPortCollisionHint('compose succeeded then exited', DEFAULT_PORTS)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
it('returns null for empty stderr', () => {
|
|
53
|
+
expect(formatPortCollisionHint('', DEFAULT_PORTS)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('formatPortCollisionsHint', () => {
|
|
57
|
+
it('returns an empty string when no ports collide', () => {
|
|
58
|
+
expect(formatPortCollisionsHint([], DEFAULT_PORTS)).toBe('');
|
|
59
|
+
});
|
|
60
|
+
it('matches the single-port hint when exactly one port collides', () => {
|
|
61
|
+
const list = formatPortCollisionsHint([3001], DEFAULT_PORTS);
|
|
62
|
+
const single = formatPortCollisionHint('Bind for 0.0.0.0:3001 failed: port is already allocated', DEFAULT_PORTS);
|
|
63
|
+
expect(list).toBe(single);
|
|
64
|
+
});
|
|
65
|
+
it('renders a bulleted list with one line per port when multiple collide', () => {
|
|
66
|
+
const hint = formatPortCollisionsHint([3001, 7233], DEFAULT_PORTS);
|
|
67
|
+
expect(hint).toContain('Multiple host ports are already in use:');
|
|
68
|
+
expect(hint).toContain('• Port 3001 — override with OUTPUT_API_HOST_PORT=<other port>');
|
|
69
|
+
expect(hint).toContain('• Port 7233 — override with OUTPUT_TEMPORAL_HOST_PORT=<other port>');
|
|
70
|
+
});
|
|
71
|
+
it('preserves the order of ports as supplied', () => {
|
|
72
|
+
const hint = formatPortCollisionsHint([7233, 3001], DEFAULT_PORTS);
|
|
73
|
+
const lines = hint.split('\n');
|
|
74
|
+
expect(lines[1]).toContain('Port 7233');
|
|
75
|
+
expect(lines[2]).toContain('Port 3001');
|
|
76
|
+
});
|
|
77
|
+
it('falls back to a generic suggestion for unknown ports inside a list', () => {
|
|
78
|
+
const hint = formatPortCollisionsHint([3001, 5432], DEFAULT_PORTS);
|
|
79
|
+
expect(hint).toContain('• Port 3001 — override with OUTPUT_API_HOST_PORT=<other port>');
|
|
80
|
+
expect(hint).toContain('• Port 5432 — stop the process holding it');
|
|
81
|
+
});
|
|
82
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.5.2-next.
|
|
3
|
+
"version": "0.5.2-next.93dd22e.0",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"semver": "7.7.4",
|
|
37
37
|
"undici": "8.1.0",
|
|
38
38
|
"yaml": "^2.8.3",
|
|
39
|
-
"@outputai/credentials": "0.5.2-next.
|
|
40
|
-
"@outputai/
|
|
41
|
-
"@outputai/
|
|
39
|
+
"@outputai/credentials": "0.5.2-next.93dd22e.0",
|
|
40
|
+
"@outputai/evals": "0.5.2-next.93dd22e.0",
|
|
41
|
+
"@outputai/llm": "0.5.2-next.93dd22e.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/cli-progress": "3.11.6",
|