@output.ai/cli 0.5.1 → 0.5.3
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/commands/dev/index.d.ts +2 -0
- package/dist/commands/dev/index.js +85 -2
- package/dist/commands/dev/index.spec.js +40 -4
- package/dist/config.d.ts +5 -0
- package/dist/config.js +6 -1
- package/dist/services/docker.d.ts +26 -1
- package/dist/services/docker.js +89 -21
- package/dist/services/docker.spec.d.ts +1 -0
- package/dist/services/docker.spec.js +119 -0
- package/dist/services/env_configurator.d.ts +6 -2
- package/dist/services/env_configurator.js +11 -4
- package/dist/services/env_configurator.spec.js +89 -16
- package/dist/services/messages.d.ts +3 -0
- package/dist/services/messages.js +45 -4
- package/dist/services/messages.spec.d.ts +1 -0
- package/dist/services/messages.spec.js +55 -0
- package/dist/services/workflow_generator.spec.js +0 -1
- package/dist/templates/agent_instructions/skills/output-error-zod-import/SKILL.md.template +1 -1
- package/dist/templates/agent_instructions/skills/output-workflow-list/SKILL.md.template +1 -1
- package/package.json +2 -2
- package/dist/templates/workflow/.env.template +0 -7
- /package/dist/templates/project/{.env.template → .env.example.template} +0 -0
|
@@ -7,5 +7,7 @@ export default class Dev extends Command {
|
|
|
7
7
|
'compose-file': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
8
8
|
'no-watch': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
9
9
|
};
|
|
10
|
+
private dockerProcess;
|
|
10
11
|
run(): Promise<void>;
|
|
12
|
+
private pollServiceStatus;
|
|
11
13
|
}
|
|
@@ -1,8 +1,45 @@
|
|
|
1
1
|
import { Command, Flags } from '@oclif/core';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { validateDockerEnvironment, startDockerCompose, DockerComposeConfigNotFoundError, getDefaultDockerComposePath } from '#services/docker.js';
|
|
4
|
+
import { validateDockerEnvironment, startDockerCompose, stopDockerCompose, getServiceStatus, DockerComposeConfigNotFoundError, getDefaultDockerComposePath, SERVICE_HEALTH, SERVICE_STATE } from '#services/docker.js';
|
|
5
5
|
import { getErrorMessage } from '#utils/error_utils.js';
|
|
6
|
+
import { getDevSuccessMessage } from '#services/messages.js';
|
|
7
|
+
const ANSI = {
|
|
8
|
+
RESET: '\x1b[0m',
|
|
9
|
+
DIM: '\x1b[2m',
|
|
10
|
+
BOLD: '\x1b[1m',
|
|
11
|
+
CYAN: '\x1b[36m'
|
|
12
|
+
};
|
|
13
|
+
const STATUS_ICONS = {
|
|
14
|
+
[SERVICE_HEALTH.HEALTHY]: '●',
|
|
15
|
+
[SERVICE_HEALTH.UNHEALTHY]: '○',
|
|
16
|
+
[SERVICE_HEALTH.STARTING]: '◐',
|
|
17
|
+
[SERVICE_HEALTH.NONE]: '●',
|
|
18
|
+
[SERVICE_STATE.RUNNING]: '●'
|
|
19
|
+
};
|
|
20
|
+
const STATUS_COLORS = {
|
|
21
|
+
[SERVICE_HEALTH.HEALTHY]: '\x1b[32m',
|
|
22
|
+
[SERVICE_HEALTH.UNHEALTHY]: '\x1b[31m',
|
|
23
|
+
[SERVICE_HEALTH.STARTING]: '\x1b[33m',
|
|
24
|
+
[SERVICE_HEALTH.NONE]: '\x1b[34m',
|
|
25
|
+
[SERVICE_STATE.RUNNING]: '\x1b[34m'
|
|
26
|
+
};
|
|
27
|
+
const formatService = (service) => {
|
|
28
|
+
const healthKey = service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
|
|
29
|
+
const icon = STATUS_ICONS[healthKey] || '?';
|
|
30
|
+
const color = STATUS_COLORS[healthKey] || '';
|
|
31
|
+
const ports = service.ports.length ? service.ports.join(', ') : '-';
|
|
32
|
+
const status = service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
|
|
33
|
+
const name = service.name.padEnd(15);
|
|
34
|
+
const statusPadded = status.padEnd(10);
|
|
35
|
+
return ` ${color}${icon}${ANSI.RESET} ${name} ${ANSI.DIM}${statusPadded}${ANSI.RESET} ${ANSI.DIM}${ports}${ANSI.RESET}`;
|
|
36
|
+
};
|
|
37
|
+
const poll = async (fn, intervalMs) => {
|
|
38
|
+
for (;;) {
|
|
39
|
+
await fn();
|
|
40
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
6
43
|
export default class Dev extends Command {
|
|
7
44
|
static description = 'Start Output development services (auto-restarts worker on file changes)';
|
|
8
45
|
static examples = [
|
|
@@ -22,6 +59,7 @@ export default class Dev extends Command {
|
|
|
22
59
|
default: false
|
|
23
60
|
})
|
|
24
61
|
};
|
|
62
|
+
dockerProcess = null;
|
|
25
63
|
async run() {
|
|
26
64
|
const { flags } = await this.parse(Dev);
|
|
27
65
|
validateDockerEnvironment();
|
|
@@ -44,11 +82,56 @@ export default class Dev extends Command {
|
|
|
44
82
|
else {
|
|
45
83
|
this.log('ℹ️ File watching disabled (--no-watch flag used)\n');
|
|
46
84
|
}
|
|
85
|
+
const cleanup = async () => {
|
|
86
|
+
this.log('\n');
|
|
87
|
+
if (this.dockerProcess) {
|
|
88
|
+
this.dockerProcess.kill('SIGTERM');
|
|
89
|
+
}
|
|
90
|
+
await stopDockerCompose(dockerComposePath);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
};
|
|
93
|
+
process.on('SIGINT', cleanup);
|
|
94
|
+
process.on('SIGTERM', cleanup);
|
|
47
95
|
try {
|
|
48
|
-
await startDockerCompose(dockerComposePath, !flags['no-watch']);
|
|
96
|
+
const { process: dockerProc, waitForHealthy } = await startDockerCompose(dockerComposePath, !flags['no-watch']);
|
|
97
|
+
this.dockerProcess = dockerProc;
|
|
98
|
+
dockerProc.on('error', error => {
|
|
99
|
+
this.error(`Docker process error: ${getErrorMessage(error)}`, { exit: 1 });
|
|
100
|
+
});
|
|
101
|
+
this.log('⏳ Waiting for services to become healthy...\n');
|
|
102
|
+
await waitForHealthy();
|
|
103
|
+
const services = await getServiceStatus(dockerComposePath);
|
|
104
|
+
this.log(getDevSuccessMessage(services));
|
|
105
|
+
await this.pollServiceStatus(dockerComposePath);
|
|
49
106
|
}
|
|
50
107
|
catch (error) {
|
|
51
108
|
this.error(getErrorMessage(error), { exit: 1 });
|
|
52
109
|
}
|
|
53
110
|
}
|
|
111
|
+
async pollServiceStatus(dockerComposePath) {
|
|
112
|
+
const state = { lastLineCount: 0 };
|
|
113
|
+
const outputServiceStatus = async () => {
|
|
114
|
+
try {
|
|
115
|
+
const services = await getServiceStatus(dockerComposePath);
|
|
116
|
+
const lines = [
|
|
117
|
+
`${ANSI.BOLD}📊 Service Status${ANSI.RESET}`,
|
|
118
|
+
'',
|
|
119
|
+
...services.map(formatService),
|
|
120
|
+
'',
|
|
121
|
+
`${ANSI.CYAN}🌐 Temporal UI:${ANSI.RESET} ${ANSI.BOLD}http://localhost:8080${ANSI.RESET}`,
|
|
122
|
+
'',
|
|
123
|
+
`${ANSI.DIM}Press Ctrl+C to stop services${ANSI.RESET}`
|
|
124
|
+
];
|
|
125
|
+
if (state.lastLineCount > 0) {
|
|
126
|
+
process.stdout.write(`\x1b[${state.lastLineCount}A\x1b[J`);
|
|
127
|
+
}
|
|
128
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
129
|
+
state.lastLineCount = lines.length;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// silent retry on next poll
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
await poll(outputServiceStatus, 2000);
|
|
136
|
+
}
|
|
54
137
|
}
|
|
@@ -5,21 +5,47 @@ import * as dockerService from '#services/docker.js';
|
|
|
5
5
|
import Dev from './index.js';
|
|
6
6
|
vi.mock('#services/docker.js', () => ({
|
|
7
7
|
validateDockerEnvironment: vi.fn(),
|
|
8
|
-
startDockerCompose: vi.fn()
|
|
8
|
+
startDockerCompose: vi.fn(),
|
|
9
|
+
stopDockerCompose: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
getServiceStatus: vi.fn().mockResolvedValue([
|
|
11
|
+
{ name: 'redis', state: 'running', health: 'healthy', ports: ['6379:6379'] },
|
|
12
|
+
{ name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] }
|
|
13
|
+
]),
|
|
9
14
|
DockerComposeConfigNotFoundError: Error,
|
|
10
15
|
DockerValidationError: Error,
|
|
11
|
-
getDefaultDockerComposePath: vi.fn(() => '/path/to/docker-compose-dev.yml')
|
|
16
|
+
getDefaultDockerComposePath: vi.fn(() => '/path/to/docker-compose-dev.yml'),
|
|
17
|
+
SERVICE_HEALTH: {
|
|
18
|
+
HEALTHY: 'healthy',
|
|
19
|
+
UNHEALTHY: 'unhealthy',
|
|
20
|
+
STARTING: 'starting',
|
|
21
|
+
NONE: 'none'
|
|
22
|
+
},
|
|
23
|
+
SERVICE_STATE: {
|
|
24
|
+
RUNNING: 'running',
|
|
25
|
+
EXITED: 'exited'
|
|
26
|
+
}
|
|
12
27
|
}));
|
|
13
28
|
vi.mock('node:fs/promises', () => ({
|
|
14
29
|
default: {
|
|
15
30
|
access: vi.fn()
|
|
16
31
|
}
|
|
17
32
|
}));
|
|
33
|
+
const createMockDockerProcess = () => ({
|
|
34
|
+
process: {
|
|
35
|
+
on: vi.fn(),
|
|
36
|
+
kill: vi.fn(),
|
|
37
|
+
stdout: { on: vi.fn() },
|
|
38
|
+
stderr: { on: vi.fn() }
|
|
39
|
+
},
|
|
40
|
+
waitForHealthy: vi.fn().mockResolvedValue(undefined)
|
|
41
|
+
});
|
|
18
42
|
describe('dev command', () => {
|
|
19
43
|
beforeEach(() => {
|
|
20
44
|
vi.clearAllMocks();
|
|
21
45
|
// By default, docker validation succeeds
|
|
22
46
|
vi.mocked(dockerService.validateDockerEnvironment).mockResolvedValue(undefined);
|
|
47
|
+
// By default, startDockerCompose returns a mock process
|
|
48
|
+
vi.mocked(dockerService.startDockerCompose).mockResolvedValue(createMockDockerProcess());
|
|
23
49
|
// By default, fs.access succeeds (file exists)
|
|
24
50
|
vi.mocked(fs).access.mockResolvedValue(undefined);
|
|
25
51
|
});
|
|
@@ -107,10 +133,15 @@ describe('dev command', () => {
|
|
|
107
133
|
value: vi.fn().mockResolvedValue({ flags: { 'no-watch': false, 'compose-file': undefined }, args: {} }),
|
|
108
134
|
configurable: true
|
|
109
135
|
});
|
|
110
|
-
await
|
|
136
|
+
// Run the command but don't await it since it waits forever after startup
|
|
137
|
+
const runPromise = cmd.run();
|
|
138
|
+
// Wait a tick for startDockerCompose to be called
|
|
139
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
111
140
|
expect(dockerService.startDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', true // enableWatch should be true
|
|
112
141
|
);
|
|
113
142
|
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('File watching enabled'));
|
|
143
|
+
// Cancel the promise (it will be rejected but we don't care)
|
|
144
|
+
runPromise.catch(() => { });
|
|
114
145
|
});
|
|
115
146
|
it('should disable watch with --no-watch flag', async () => {
|
|
116
147
|
const cmd = new Dev(['--no-watch'], {});
|
|
@@ -121,10 +152,15 @@ describe('dev command', () => {
|
|
|
121
152
|
value: vi.fn().mockResolvedValue({ flags: { 'no-watch': true, 'compose-file': undefined }, args: {} }),
|
|
122
153
|
configurable: true
|
|
123
154
|
});
|
|
124
|
-
await
|
|
155
|
+
// Run the command but don't await it since it waits forever after startup
|
|
156
|
+
const runPromise = cmd.run();
|
|
157
|
+
// Wait a tick for startDockerCompose to be called
|
|
158
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
125
159
|
expect(dockerService.startDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', false // enableWatch should be false
|
|
126
160
|
);
|
|
127
161
|
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('File watching disabled'));
|
|
162
|
+
// Cancel the promise (it will be rejected but we don't care)
|
|
163
|
+
runPromise.catch(() => { });
|
|
128
164
|
});
|
|
129
165
|
it('should handle docker compose configuration not found', async () => {
|
|
130
166
|
vi.mocked(fs).access.mockRejectedValue(new Error('File not found'));
|
package/dist/config.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export declare const config: {
|
|
|
16
16
|
* Default timeout for API requests (in milliseconds)
|
|
17
17
|
*/
|
|
18
18
|
requestTimeout: number;
|
|
19
|
+
/**
|
|
20
|
+
* Docker Compose project name
|
|
21
|
+
* Can be overridden with DOCKER_SERVICE_NAME environment variable
|
|
22
|
+
*/
|
|
23
|
+
dockerServiceName: string;
|
|
19
24
|
};
|
|
20
25
|
/**
|
|
21
26
|
* Agent configuration directory name
|
package/dist/config.js
CHANGED
|
@@ -15,7 +15,12 @@ export const config = {
|
|
|
15
15
|
/**
|
|
16
16
|
* Default timeout for API requests (in milliseconds)
|
|
17
17
|
*/
|
|
18
|
-
requestTimeout: 30000
|
|
18
|
+
requestTimeout: 30000,
|
|
19
|
+
/**
|
|
20
|
+
* Docker Compose project name
|
|
21
|
+
* Can be overridden with DOCKER_SERVICE_NAME environment variable
|
|
22
|
+
*/
|
|
23
|
+
dockerServiceName: process.env.DOCKER_SERVICE_NAME || 'output-sdk'
|
|
19
24
|
};
|
|
20
25
|
/**
|
|
21
26
|
* Agent configuration directory name
|
|
@@ -1,5 +1,22 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
export declare const SERVICE_HEALTH: {
|
|
3
|
+
readonly HEALTHY: "healthy";
|
|
4
|
+
readonly UNHEALTHY: "unhealthy";
|
|
5
|
+
readonly STARTING: "starting";
|
|
6
|
+
readonly NONE: "none";
|
|
7
|
+
};
|
|
8
|
+
export declare const SERVICE_STATE: {
|
|
9
|
+
readonly RUNNING: "running";
|
|
10
|
+
readonly EXITED: "exited";
|
|
11
|
+
};
|
|
1
12
|
declare class DockerValidationError extends Error {
|
|
2
13
|
}
|
|
14
|
+
export interface ServiceStatus {
|
|
15
|
+
name: string;
|
|
16
|
+
state: string;
|
|
17
|
+
health: string;
|
|
18
|
+
ports: string[];
|
|
19
|
+
}
|
|
3
20
|
export declare class DockerComposeConfigNotFoundError extends Error {
|
|
4
21
|
constructor(dockerComposePath: string);
|
|
5
22
|
}
|
|
@@ -7,6 +24,14 @@ declare const isDockerInstalled: () => boolean;
|
|
|
7
24
|
declare const isDockerComposeAvailable: () => boolean;
|
|
8
25
|
declare const isDockerDaemonRunning: () => boolean;
|
|
9
26
|
export declare function validateDockerEnvironment(): void;
|
|
10
|
-
export declare function startDockerCompose(dockerComposePath: string, enableWatch?: boolean): Promise<void>;
|
|
11
27
|
export declare function getDefaultDockerComposePath(): string;
|
|
28
|
+
export declare function parseServiceStatus(jsonOutput: string): ServiceStatus[];
|
|
29
|
+
export declare function getServiceStatus(dockerComposePath: string): Promise<ServiceStatus[]>;
|
|
30
|
+
export declare function waitForServicesHealthy(dockerComposePath: string, timeoutMs?: number, pollIntervalMs?: number): Promise<void>;
|
|
31
|
+
export interface DockerComposeProcess {
|
|
32
|
+
process: ChildProcess;
|
|
33
|
+
waitForHealthy: () => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export declare function startDockerCompose(dockerComposePath: string, enableWatch?: boolean): Promise<DockerComposeProcess>;
|
|
36
|
+
export declare function stopDockerCompose(dockerComposePath: string): Promise<void>;
|
|
12
37
|
export { isDockerInstalled, isDockerComposeAvailable, isDockerDaemonRunning, DockerValidationError };
|
package/dist/services/docker.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import { execSync, spawn } from 'node:child_process';
|
|
1
|
+
import { execFileSync, execSync, spawn } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { ux } from '@oclif/core';
|
|
5
|
+
const DEFAULT_COMPOSE_PATH = '../assets/docker/docker-compose-dev.yml';
|
|
6
|
+
export const SERVICE_HEALTH = {
|
|
7
|
+
HEALTHY: 'healthy',
|
|
8
|
+
UNHEALTHY: 'unhealthy',
|
|
9
|
+
STARTING: 'starting',
|
|
10
|
+
NONE: 'none'
|
|
11
|
+
};
|
|
12
|
+
export const SERVICE_STATE = {
|
|
13
|
+
RUNNING: 'running',
|
|
14
|
+
EXITED: 'exited'
|
|
15
|
+
};
|
|
5
16
|
class DockerValidationError extends Error {
|
|
6
17
|
}
|
|
7
18
|
export class DockerComposeConfigNotFoundError extends Error {
|
|
@@ -42,6 +53,73 @@ export function validateDockerEnvironment() {
|
|
|
42
53
|
throw new DockerValidationError(failedValidation.error);
|
|
43
54
|
}
|
|
44
55
|
}
|
|
56
|
+
export function getDefaultDockerComposePath() {
|
|
57
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), DEFAULT_COMPOSE_PATH);
|
|
58
|
+
}
|
|
59
|
+
export function parseServiceStatus(jsonOutput) {
|
|
60
|
+
if (!jsonOutput.trim()) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
return jsonOutput
|
|
64
|
+
.trim()
|
|
65
|
+
.split('\n')
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map(line => {
|
|
68
|
+
const data = JSON.parse(line);
|
|
69
|
+
return {
|
|
70
|
+
name: data.Service || data.Name || 'unknown',
|
|
71
|
+
state: data.State,
|
|
72
|
+
health: data.Health || SERVICE_HEALTH.NONE,
|
|
73
|
+
ports: data.Publishers?.map(p => `${p.PublishedPort}:${p.TargetPort}`) || []
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export async function getServiceStatus(dockerComposePath) {
|
|
78
|
+
const result = execFileSync('docker', ['compose', '-f', dockerComposePath, 'ps', '--format', 'json'], { encoding: 'utf-8', cwd: process.cwd() });
|
|
79
|
+
return parseServiceStatus(result);
|
|
80
|
+
}
|
|
81
|
+
const STATUS_ICONS = {
|
|
82
|
+
[SERVICE_HEALTH.HEALTHY]: '✓',
|
|
83
|
+
[SERVICE_HEALTH.UNHEALTHY]: '✗',
|
|
84
|
+
[SERVICE_HEALTH.STARTING]: '◐',
|
|
85
|
+
[SERVICE_HEALTH.NONE]: '✓',
|
|
86
|
+
[SERVICE_STATE.RUNNING]: '●',
|
|
87
|
+
[SERVICE_STATE.EXITED]: '✗'
|
|
88
|
+
};
|
|
89
|
+
const STATUS_COLORS = {
|
|
90
|
+
[SERVICE_HEALTH.HEALTHY]: '\x1b[32m',
|
|
91
|
+
[SERVICE_HEALTH.UNHEALTHY]: '\x1b[31m',
|
|
92
|
+
[SERVICE_HEALTH.STARTING]: '\x1b[33m',
|
|
93
|
+
[SERVICE_HEALTH.NONE]: '\x1b[32m',
|
|
94
|
+
[SERVICE_STATE.RUNNING]: '\x1b[34m',
|
|
95
|
+
[SERVICE_STATE.EXITED]: '\x1b[31m'
|
|
96
|
+
};
|
|
97
|
+
const ANSI_RESET = '\x1b[0m';
|
|
98
|
+
const formatServiceStatus = (services) => services.map(s => {
|
|
99
|
+
const healthKey = s.health === SERVICE_HEALTH.NONE ? s.state : s.health;
|
|
100
|
+
const icon = STATUS_ICONS[healthKey] || '?';
|
|
101
|
+
const color = STATUS_COLORS[healthKey] || '';
|
|
102
|
+
const status = s.health === SERVICE_HEALTH.NONE ? s.state : s.health;
|
|
103
|
+
return ` ${color}${icon}${ANSI_RESET} ${s.name}: ${status}`;
|
|
104
|
+
}).join('\n');
|
|
105
|
+
export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 120000, pollIntervalMs = 2000) {
|
|
106
|
+
const startTime = Date.now();
|
|
107
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
108
|
+
const services = await getServiceStatus(dockerComposePath);
|
|
109
|
+
const allHealthy = services.every(s => s.health === SERVICE_HEALTH.HEALTHY || s.health === SERVICE_HEALTH.NONE);
|
|
110
|
+
if (services.length > 0) {
|
|
111
|
+
const lineCount = services.length + 1;
|
|
112
|
+
process.stdout.write(`\x1b[${lineCount}A\x1b[J`);
|
|
113
|
+
ux.stdout('⏳ Waiting for services to become healthy...\n');
|
|
114
|
+
ux.stdout(formatServiceStatus(services) + '\n');
|
|
115
|
+
}
|
|
116
|
+
if (allHealthy && services.length > 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
120
|
+
}
|
|
121
|
+
throw new Error('Timeout waiting for services to become healthy');
|
|
122
|
+
}
|
|
45
123
|
export async function startDockerCompose(dockerComposePath, enableWatch = false) {
|
|
46
124
|
const args = [
|
|
47
125
|
'compose',
|
|
@@ -52,28 +130,18 @@ export async function startDockerCompose(dockerComposePath, enableWatch = false)
|
|
|
52
130
|
if (enableWatch) {
|
|
53
131
|
args.push('--watch');
|
|
54
132
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
cwd: process.cwd()
|
|
133
|
+
ux.stdout('🐳 Starting Docker services...\n');
|
|
134
|
+
const dockerProcess = spawn('docker', args, {
|
|
135
|
+
cwd: process.cwd(),
|
|
136
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
58
137
|
});
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
138
|
+
return {
|
|
139
|
+
process: dockerProcess,
|
|
140
|
+
waitForHealthy: () => waitForServicesHealthy(dockerComposePath)
|
|
62
141
|
};
|
|
63
|
-
process.on('SIGINT', cleanup);
|
|
64
|
-
process.on('SIGTERM', cleanup);
|
|
65
|
-
return new Promise((resolve, reject) => {
|
|
66
|
-
childProcess.on('exit', _code => {
|
|
67
|
-
process.removeListener('SIGINT', cleanup);
|
|
68
|
-
process.removeListener('SIGTERM', cleanup);
|
|
69
|
-
resolve();
|
|
70
|
-
});
|
|
71
|
-
childProcess.on('error', error => {
|
|
72
|
-
reject(new Error(`Failed to start services: ${error.message}`));
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
142
|
}
|
|
76
|
-
export function
|
|
77
|
-
|
|
143
|
+
export async function stopDockerCompose(dockerComposePath) {
|
|
144
|
+
ux.stdout('⏹️ Stopping services...\n');
|
|
145
|
+
execFileSync('docker', ['compose', '-f', dockerComposePath, 'down'], { stdio: 'inherit', cwd: process.cwd() });
|
|
78
146
|
}
|
|
79
147
|
export { isDockerInstalled, isDockerComposeAvailable, isDockerDaemonRunning, DockerValidationError };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { parseServiceStatus, getServiceStatus, waitForServicesHealthy } from './docker.js';
|
|
4
|
+
vi.mock('node:child_process', () => ({
|
|
5
|
+
execSync: vi.fn(),
|
|
6
|
+
execFileSync: vi.fn(),
|
|
7
|
+
spawn: vi.fn()
|
|
8
|
+
}));
|
|
9
|
+
describe('docker service', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.clearAllMocks();
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
describe('parseServiceStatus', () => {
|
|
17
|
+
it('should parse single service JSON output', () => {
|
|
18
|
+
const jsonOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}';
|
|
19
|
+
const result = parseServiceStatus(jsonOutput);
|
|
20
|
+
expect(result).toHaveLength(1);
|
|
21
|
+
expect(result[0]).toEqual({
|
|
22
|
+
name: 'redis',
|
|
23
|
+
state: 'running',
|
|
24
|
+
health: 'healthy',
|
|
25
|
+
ports: ['6379:6379']
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it('should parse multiple services from JSON lines output', () => {
|
|
29
|
+
const jsonOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}
|
|
30
|
+
{"Service":"temporal","State":"running","Health":"healthy","Publishers":[{"PublishedPort":7233,"TargetPort":7233}]}
|
|
31
|
+
{"Service":"temporal-ui","State":"running","Health":"","Publishers":[{"PublishedPort":8080,"TargetPort":8080}]}`;
|
|
32
|
+
const result = parseServiceStatus(jsonOutput);
|
|
33
|
+
expect(result).toHaveLength(3);
|
|
34
|
+
expect(result[0].name).toBe('redis');
|
|
35
|
+
expect(result[1].name).toBe('temporal');
|
|
36
|
+
expect(result[2].name).toBe('temporal-ui');
|
|
37
|
+
});
|
|
38
|
+
it('should handle empty health status', () => {
|
|
39
|
+
const jsonOutput = '{"Service":"api","State":"running","Health":"","Publishers":[]}';
|
|
40
|
+
const result = parseServiceStatus(jsonOutput);
|
|
41
|
+
expect(result[0].health).toBe('none');
|
|
42
|
+
});
|
|
43
|
+
it('should handle missing Publishers array', () => {
|
|
44
|
+
const jsonOutput = '{"Service":"worker","State":"running","Health":"healthy"}';
|
|
45
|
+
const result = parseServiceStatus(jsonOutput);
|
|
46
|
+
expect(result[0].ports).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it('should handle empty output', () => {
|
|
49
|
+
const result = parseServiceStatus('');
|
|
50
|
+
expect(result).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
it('should filter out empty lines', () => {
|
|
53
|
+
const jsonOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
|
|
54
|
+
|
|
55
|
+
{"Service":"api","State":"running","Health":"","Publishers":[]}
|
|
56
|
+
`;
|
|
57
|
+
const result = parseServiceStatus(jsonOutput);
|
|
58
|
+
expect(result).toHaveLength(2);
|
|
59
|
+
});
|
|
60
|
+
it('should use Name field as fallback when Service is missing', () => {
|
|
61
|
+
const jsonOutput = '{"Name":"output-sdk-redis-1","State":"running","Health":"healthy","Publishers":[]}';
|
|
62
|
+
const result = parseServiceStatus(jsonOutput);
|
|
63
|
+
expect(result[0].name).toBe('output-sdk-redis-1');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('getServiceStatus', () => {
|
|
67
|
+
it('should call docker compose ps with correct arguments', async () => {
|
|
68
|
+
const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}';
|
|
69
|
+
vi.mocked(execFileSync).mockReturnValue(mockOutput);
|
|
70
|
+
await getServiceStatus('/path/to/docker-compose.yml');
|
|
71
|
+
expect(execFileSync).toHaveBeenCalledWith('docker', ['compose', '-f', '/path/to/docker-compose.yml', 'ps', '--format', 'json'], expect.objectContaining({ encoding: 'utf-8' }));
|
|
72
|
+
});
|
|
73
|
+
it('should return parsed service status', async () => {
|
|
74
|
+
const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}';
|
|
75
|
+
vi.mocked(execFileSync).mockReturnValue(mockOutput);
|
|
76
|
+
const result = await getServiceStatus('/path/to/docker-compose.yml');
|
|
77
|
+
expect(result).toHaveLength(1);
|
|
78
|
+
expect(result[0].name).toBe('redis');
|
|
79
|
+
});
|
|
80
|
+
it('should throw error when docker compose command fails', async () => {
|
|
81
|
+
vi.mocked(execFileSync).mockImplementation(() => {
|
|
82
|
+
throw new Error('Docker command failed');
|
|
83
|
+
});
|
|
84
|
+
await expect(getServiceStatus('/path/to/docker-compose.yml')).rejects.toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('waitForServicesHealthy', () => {
|
|
88
|
+
it('should resolve when all services are healthy', async () => {
|
|
89
|
+
const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
|
|
90
|
+
{"Service":"temporal","State":"running","Health":"healthy","Publishers":[]}`;
|
|
91
|
+
vi.mocked(execFileSync).mockReturnValue(mockOutput);
|
|
92
|
+
await expect(waitForServicesHealthy('/path/to/docker-compose.yml', 5000)).resolves.toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
it('should resolve when services have no health check (health: none)', async () => {
|
|
95
|
+
const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
|
|
96
|
+
{"Service":"api","State":"running","Health":"","Publishers":[]}`;
|
|
97
|
+
vi.mocked(execFileSync).mockReturnValue(mockOutput);
|
|
98
|
+
await expect(waitForServicesHealthy('/path/to/docker-compose.yml', 5000)).resolves.toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
it('should timeout when services remain unhealthy', async () => {
|
|
101
|
+
const mockOutput = '{"Service":"redis","State":"running","Health":"starting","Publishers":[]}';
|
|
102
|
+
vi.mocked(execFileSync).mockReturnValue(mockOutput);
|
|
103
|
+
const promise = waitForServicesHealthy('/path/to/docker-compose.yml', 100);
|
|
104
|
+
await expect(promise).rejects.toThrow('Timeout waiting for services to become healthy');
|
|
105
|
+
}, 10000);
|
|
106
|
+
it('should poll multiple times until healthy', async () => {
|
|
107
|
+
const callTracker = { count: 0 };
|
|
108
|
+
vi.mocked(execFileSync).mockImplementation(() => {
|
|
109
|
+
callTracker.count++;
|
|
110
|
+
if (callTracker.count < 3) {
|
|
111
|
+
return '{"Service":"redis","State":"running","Health":"starting","Publishers":[]}';
|
|
112
|
+
}
|
|
113
|
+
return '{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}';
|
|
114
|
+
});
|
|
115
|
+
await waitForServicesHealthy('/path/to/docker-compose.yml', 10000, 50);
|
|
116
|
+
expect(callTracker.count).toBeGreaterThanOrEqual(3);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
* Interactively configures environment variables for a project by prompting the user
|
|
3
3
|
* to provide values for empty variables or variables marked as secrets.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* This function reads from .env.example and, when the user confirms configuration,
|
|
6
|
+
* copies it to .env before prompting for values. The .env.example file remains
|
|
7
|
+
* unchanged as a template for other developers.
|
|
8
|
+
*
|
|
9
|
+
* @param projectPath - The absolute path to the project directory containing the .env.example file
|
|
6
10
|
* @param skipPrompt - If true, skips the configuration prompt and returns false immediately
|
|
7
11
|
* @returns A promise that resolves to true if environment variables were successfully configured,
|
|
8
|
-
* false if configuration was skipped (no .env file, user declined, no variables to configure,
|
|
12
|
+
* false if configuration was skipped (no .env.example file, user declined, no variables to configure,
|
|
9
13
|
* or an error occurred)
|
|
10
14
|
*/
|
|
11
15
|
export declare function configureEnvironmentVariables(projectPath: string, skipPrompt?: boolean): Promise<boolean>;
|
|
@@ -115,10 +115,14 @@ async function writeEnvFile(filePath, variables) {
|
|
|
115
115
|
* Interactively configures environment variables for a project by prompting the user
|
|
116
116
|
* to provide values for empty variables or variables marked as secrets.
|
|
117
117
|
*
|
|
118
|
-
*
|
|
118
|
+
* This function reads from .env.example and, when the user confirms configuration,
|
|
119
|
+
* copies it to .env before prompting for values. The .env.example file remains
|
|
120
|
+
* unchanged as a template for other developers.
|
|
121
|
+
*
|
|
122
|
+
* @param projectPath - The absolute path to the project directory containing the .env.example file
|
|
119
123
|
* @param skipPrompt - If true, skips the configuration prompt and returns false immediately
|
|
120
124
|
* @returns A promise that resolves to true if environment variables were successfully configured,
|
|
121
|
-
* false if configuration was skipped (no .env file, user declined, no variables to configure,
|
|
125
|
+
* false if configuration was skipped (no .env.example file, user declined, no variables to configure,
|
|
122
126
|
* or an error occurred)
|
|
123
127
|
*/
|
|
124
128
|
export async function configureEnvironmentVariables(projectPath, skipPrompt = false) {
|
|
@@ -126,12 +130,13 @@ export async function configureEnvironmentVariables(projectPath, skipPrompt = fa
|
|
|
126
130
|
return false;
|
|
127
131
|
}
|
|
128
132
|
ux.stdout('configuring environment variables...');
|
|
133
|
+
const envExamplePath = path.join(projectPath, '.env.example');
|
|
129
134
|
const envPath = path.join(projectPath, '.env');
|
|
130
135
|
try {
|
|
131
|
-
await fs.access(
|
|
136
|
+
await fs.access(envExamplePath);
|
|
132
137
|
}
|
|
133
138
|
catch {
|
|
134
|
-
ux.stdout(ux.colorize('red', '
|
|
139
|
+
ux.stdout(ux.colorize('red', '.env.example file does not exist, nothing to configure'));
|
|
135
140
|
return false;
|
|
136
141
|
}
|
|
137
142
|
const shouldConfigure = await confirm({
|
|
@@ -142,6 +147,8 @@ export async function configureEnvironmentVariables(projectPath, skipPrompt = fa
|
|
|
142
147
|
return false;
|
|
143
148
|
}
|
|
144
149
|
try {
|
|
150
|
+
// Copy .env.example to .env before configuring
|
|
151
|
+
await fs.copyFile(envExamplePath, envPath);
|
|
145
152
|
const variables = await parseEnvFile(envPath);
|
|
146
153
|
const variablesToConfigure = variables.filter(v => isEmpty(v.value) || v.isSecret);
|
|
147
154
|
if (variablesToConfigure.length === 0) {
|
|
@@ -5,16 +5,18 @@ import { configureEnvironmentVariables } from './env_configurator.js';
|
|
|
5
5
|
// Mock inquirer prompts
|
|
6
6
|
vi.mock('@inquirer/prompts', () => ({
|
|
7
7
|
input: vi.fn(),
|
|
8
|
-
confirm: vi.fn()
|
|
8
|
+
confirm: vi.fn(),
|
|
9
|
+
password: vi.fn()
|
|
9
10
|
}));
|
|
10
11
|
describe('configureEnvironmentVariables', () => {
|
|
11
|
-
const testState = { tempDir: '', envPath: '' };
|
|
12
|
+
const testState = { tempDir: '', envExamplePath: '', envPath: '' };
|
|
12
13
|
beforeEach(async () => {
|
|
13
14
|
// Clear all mocks before each test
|
|
14
15
|
vi.clearAllMocks();
|
|
15
16
|
// Create temporary directory for test files
|
|
16
17
|
testState.tempDir = path.join('/tmp', `test-env-${Date.now()}`);
|
|
17
18
|
await fs.mkdir(testState.tempDir, { recursive: true });
|
|
19
|
+
testState.envExamplePath = path.join(testState.tempDir, '.env.example');
|
|
18
20
|
testState.envPath = path.join(testState.tempDir, '.env');
|
|
19
21
|
});
|
|
20
22
|
afterEach(async () => {
|
|
@@ -27,36 +29,74 @@ describe('configureEnvironmentVariables', () => {
|
|
|
27
29
|
}
|
|
28
30
|
});
|
|
29
31
|
it('should return false if skipPrompt is true', async () => {
|
|
30
|
-
// Create a minimal .env file
|
|
31
|
-
await fs.writeFile(testState.
|
|
32
|
+
// Create a minimal .env.example file
|
|
33
|
+
await fs.writeFile(testState.envExamplePath, 'KEY=value');
|
|
32
34
|
const result = await configureEnvironmentVariables(testState.tempDir, true);
|
|
33
35
|
expect(result).toBe(false);
|
|
34
36
|
});
|
|
35
|
-
it('should return false if .env file does not exist', async () => {
|
|
37
|
+
it('should return false if .env.example file does not exist', async () => {
|
|
36
38
|
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
37
39
|
expect(result).toBe(false);
|
|
38
40
|
});
|
|
39
41
|
it('should return false if user declines configuration', async () => {
|
|
40
42
|
const { confirm } = await import('@inquirer/prompts');
|
|
41
43
|
vi.mocked(confirm).mockResolvedValue(false);
|
|
42
|
-
await fs.writeFile(testState.
|
|
44
|
+
await fs.writeFile(testState.envExamplePath, '# API key\nAPIKEY=');
|
|
43
45
|
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
44
46
|
expect(result).toBe(false);
|
|
45
47
|
expect(vi.mocked(confirm)).toHaveBeenCalled();
|
|
46
48
|
});
|
|
49
|
+
it('should NOT create .env when user declines configuration', async () => {
|
|
50
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
51
|
+
vi.mocked(confirm).mockResolvedValue(false);
|
|
52
|
+
await fs.writeFile(testState.envExamplePath, '# API key\nAPIKEY=');
|
|
53
|
+
await configureEnvironmentVariables(testState.tempDir, false);
|
|
54
|
+
// .env should NOT exist when user declines
|
|
55
|
+
await expect(fs.access(testState.envPath)).rejects.toThrow();
|
|
56
|
+
// .env.example should still exist
|
|
57
|
+
await expect(fs.access(testState.envExamplePath)).resolves.toBeUndefined();
|
|
58
|
+
});
|
|
47
59
|
it('should return false if no empty variables exist', async () => {
|
|
48
60
|
const { confirm } = await import('@inquirer/prompts');
|
|
49
61
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
50
|
-
await fs.writeFile(testState.
|
|
62
|
+
await fs.writeFile(testState.envExamplePath, 'APIKEY=my-secret-key');
|
|
51
63
|
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
52
64
|
expect(result).toBe(false);
|
|
53
65
|
});
|
|
66
|
+
it('should copy .env.example to .env when user confirms configuration', async () => {
|
|
67
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
68
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
69
|
+
vi.mocked(input).mockResolvedValueOnce('sk-proj-123');
|
|
70
|
+
const originalContent = `# API key
|
|
71
|
+
APIKEY=`;
|
|
72
|
+
await fs.writeFile(testState.envExamplePath, originalContent);
|
|
73
|
+
await configureEnvironmentVariables(testState.tempDir, false);
|
|
74
|
+
// Both files should exist
|
|
75
|
+
await expect(fs.access(testState.envExamplePath)).resolves.toBeUndefined();
|
|
76
|
+
await expect(fs.access(testState.envPath)).resolves.toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
it('should write configured values to .env while leaving .env.example unchanged', async () => {
|
|
79
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
80
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
81
|
+
vi.mocked(input).mockResolvedValueOnce('sk-proj-123');
|
|
82
|
+
const originalContent = `# API key
|
|
83
|
+
APIKEY=`;
|
|
84
|
+
await fs.writeFile(testState.envExamplePath, originalContent);
|
|
85
|
+
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
86
|
+
expect(result).toBe(true);
|
|
87
|
+
// .env should have the configured value
|
|
88
|
+
const envContent = await fs.readFile(testState.envPath, 'utf-8');
|
|
89
|
+
expect(envContent).toContain('APIKEY=sk-proj-123');
|
|
90
|
+
// .env.example should remain unchanged
|
|
91
|
+
const envExampleContent = await fs.readFile(testState.envExamplePath, 'utf-8');
|
|
92
|
+
expect(envExampleContent).toBe(originalContent);
|
|
93
|
+
});
|
|
54
94
|
it('should prompt for empty variables and update .env', async () => {
|
|
55
95
|
const { input, confirm } = await import('@inquirer/prompts');
|
|
56
96
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
57
97
|
vi.mocked(input).mockResolvedValueOnce('sk-proj-123');
|
|
58
98
|
vi.mocked(input).mockResolvedValueOnce('');
|
|
59
|
-
await fs.writeFile(testState.
|
|
99
|
+
await fs.writeFile(testState.envExamplePath, `# API key for Anthropic
|
|
60
100
|
ANTHROPIC_API_KEY=
|
|
61
101
|
|
|
62
102
|
# API key for OpenAI
|
|
@@ -78,7 +118,7 @@ APIKEY=
|
|
|
78
118
|
|
|
79
119
|
# Another comment
|
|
80
120
|
OTHER=value`;
|
|
81
|
-
await fs.writeFile(testState.
|
|
121
|
+
await fs.writeFile(testState.envExamplePath, originalContent);
|
|
82
122
|
await configureEnvironmentVariables(testState.tempDir, false);
|
|
83
123
|
const content = await fs.readFile(testState.envPath, 'utf-8');
|
|
84
124
|
expect(content).toContain('# This is a comment');
|
|
@@ -90,7 +130,7 @@ OTHER=value`;
|
|
|
90
130
|
const { input, confirm } = await import('@inquirer/prompts');
|
|
91
131
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
92
132
|
vi.mocked(input).mockResolvedValueOnce('new-key');
|
|
93
|
-
await fs.writeFile(testState.
|
|
133
|
+
await fs.writeFile(testState.envExamplePath, `APIKEY=your_api_key_here
|
|
94
134
|
EMPTY_KEY=`);
|
|
95
135
|
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
96
136
|
expect(result).toBe(true);
|
|
@@ -103,21 +143,54 @@ EMPTY_KEY=`);
|
|
|
103
143
|
const { input, confirm } = await import('@inquirer/prompts');
|
|
104
144
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
105
145
|
vi.mocked(input).mockResolvedValueOnce('new-key');
|
|
106
|
-
await fs.writeFile(testState.
|
|
146
|
+
await fs.writeFile(testState.envExamplePath, `EXISTING_KEY=existing-value
|
|
107
147
|
|
|
108
148
|
EMPTY_KEY=`);
|
|
109
149
|
await configureEnvironmentVariables(testState.tempDir, false);
|
|
110
150
|
// Should only prompt for EMPTY_KEY, not EXISTING_KEY
|
|
111
151
|
expect(vi.mocked(input)).toHaveBeenCalledTimes(1);
|
|
112
152
|
});
|
|
153
|
+
it('should handle case where .env already exists (overwrite with copy)', async () => {
|
|
154
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
155
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
156
|
+
vi.mocked(input).mockResolvedValueOnce('new-configured-value');
|
|
157
|
+
// Create existing .env with old content
|
|
158
|
+
await fs.writeFile(testState.envPath, 'OLD_KEY=old-value');
|
|
159
|
+
// Create .env.example with new content
|
|
160
|
+
await fs.writeFile(testState.envExamplePath, 'NEW_KEY=');
|
|
161
|
+
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
162
|
+
expect(result).toBe(true);
|
|
163
|
+
// .env should be overwritten with .env.example content and configured values
|
|
164
|
+
const envContent = await fs.readFile(testState.envPath, 'utf-8');
|
|
165
|
+
expect(envContent).toContain('NEW_KEY=new-configured-value');
|
|
166
|
+
expect(envContent).not.toContain('OLD_KEY');
|
|
167
|
+
});
|
|
113
168
|
it('should return false if an error occurs during parsing', async () => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
await fs.writeFile(testState.
|
|
117
|
-
// Delete the file
|
|
118
|
-
|
|
169
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
170
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
171
|
+
await fs.writeFile(testState.envExamplePath, 'KEY=');
|
|
172
|
+
// Delete the .env.example file after access check but before parsing would happen
|
|
173
|
+
// We simulate this by deleting during the copy operation
|
|
174
|
+
const originalCopyFile = fs.copyFile;
|
|
175
|
+
vi.spyOn(fs, 'copyFile').mockImplementation(async () => {
|
|
176
|
+
throw new Error('Copy failed');
|
|
177
|
+
});
|
|
119
178
|
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
120
179
|
// Should return false when error occurs
|
|
121
180
|
expect(result).toBe(false);
|
|
181
|
+
// Restore original function
|
|
182
|
+
vi.mocked(fs.copyFile).mockImplementation(originalCopyFile);
|
|
183
|
+
});
|
|
184
|
+
it('should prompt for SECRET marker values with password input', async () => {
|
|
185
|
+
const { password, confirm } = await import('@inquirer/prompts');
|
|
186
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
187
|
+
vi.mocked(password).mockResolvedValueOnce('my-secret-api-key');
|
|
188
|
+
await fs.writeFile(testState.envExamplePath, `# API Key
|
|
189
|
+
ANTHROPIC_API_KEY=<SECRET>`);
|
|
190
|
+
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
191
|
+
expect(result).toBe(true);
|
|
192
|
+
expect(vi.mocked(password)).toHaveBeenCalledTimes(1);
|
|
193
|
+
const envContent = await fs.readFile(testState.envPath, 'utf-8');
|
|
194
|
+
expect(envContent).toContain('ANTHROPIC_API_KEY=my-secret-api-key');
|
|
122
195
|
});
|
|
123
196
|
});
|
|
@@ -4,3 +4,6 @@
|
|
|
4
4
|
export declare const getEjectSuccessMessage: (destPath: string, outputFile: string, binName: string) => string;
|
|
5
5
|
export declare const getProjectSuccessMessage: (folderName: string, installSuccess: boolean, envConfigured?: boolean) => string;
|
|
6
6
|
export declare const getWorkflowGenerateSuccessMessage: (workflowName: string, targetDir: string, filesCreated: string[]) => string;
|
|
7
|
+
export declare const getDevSuccessMessage: (services: Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
}>) => string;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Success and informational messages for project initialization
|
|
3
3
|
*/
|
|
4
4
|
import { ux } from '@oclif/core';
|
|
5
|
+
import { config } from '#config.js';
|
|
5
6
|
/**
|
|
6
7
|
* Creates a colored ASCII art banner for Output.ai
|
|
7
8
|
*/
|
|
@@ -166,8 +167,8 @@ export const getProjectSuccessMessage = (folderName, installSuccess, envConfigur
|
|
|
166
167
|
if (!envConfigured) {
|
|
167
168
|
steps.push({
|
|
168
169
|
step: 'Configure environment variables',
|
|
169
|
-
command: '
|
|
170
|
-
note: '
|
|
170
|
+
command: 'cp .env.example .env',
|
|
171
|
+
note: 'Copy .env.example to .env and add your API keys'
|
|
171
172
|
});
|
|
172
173
|
}
|
|
173
174
|
steps.push({
|
|
@@ -248,8 +249,8 @@ export const getWorkflowGenerateSuccessMessage = (workflowName, targetDir, files
|
|
|
248
249
|
},
|
|
249
250
|
{
|
|
250
251
|
step: 'Configure environment',
|
|
251
|
-
command: '
|
|
252
|
-
note: '
|
|
252
|
+
command: 'cp .env.example .env',
|
|
253
|
+
note: 'Copy .env.example to .env and add your LLM provider credentials'
|
|
253
254
|
},
|
|
254
255
|
{
|
|
255
256
|
step: 'Test with example scenario',
|
|
@@ -295,3 +296,43 @@ ${ux.colorize('dim', '💡 Tip: Check the README.md in your workflow directory f
|
|
|
295
296
|
${ux.colorize('green', ux.colorize('bold', 'Happy building! 🛠️'))}
|
|
296
297
|
`;
|
|
297
298
|
};
|
|
299
|
+
export const getDevSuccessMessage = (services) => {
|
|
300
|
+
const divider = ux.colorize('dim', '─'.repeat(80));
|
|
301
|
+
const bulletPoint = ux.colorize('green', '▸');
|
|
302
|
+
const serviceNames = services.map(s => s.name).sort().join('|');
|
|
303
|
+
const logsCommand = `docker compose -p ${config.dockerServiceName} logs -f <${serviceNames}>`;
|
|
304
|
+
return `
|
|
305
|
+
${divider}
|
|
306
|
+
|
|
307
|
+
${ux.colorize('bold', ux.colorize('green', '✅ SUCCESS!'))} ${ux.colorize('bold', 'Development services are running')}
|
|
308
|
+
|
|
309
|
+
${divider}
|
|
310
|
+
|
|
311
|
+
${createSectionHeader('SERVICES', '🐳')}
|
|
312
|
+
|
|
313
|
+
${bulletPoint} ${ux.colorize('white', 'Temporal:')} ${formatPath('localhost:7233')}
|
|
314
|
+
${bulletPoint} ${ux.colorize('white', 'Temporal UI:')} ${formatCommand('http://localhost:8080')}
|
|
315
|
+
${bulletPoint} ${ux.colorize('white', 'API Server:')} ${formatPath('localhost:3001')}
|
|
316
|
+
${bulletPoint} ${ux.colorize('white', 'Redis:')} ${formatPath('localhost:6379')}
|
|
317
|
+
|
|
318
|
+
${divider}
|
|
319
|
+
|
|
320
|
+
${createSectionHeader('RUN A WORKFLOW', '🚀')}
|
|
321
|
+
|
|
322
|
+
${ux.colorize('white', 'In a new terminal, execute:')}
|
|
323
|
+
|
|
324
|
+
${formatCommand('output workflow run simple --input \'{"question": "Hello!"}\'')}
|
|
325
|
+
|
|
326
|
+
${divider}
|
|
327
|
+
|
|
328
|
+
${createSectionHeader('USEFUL COMMANDS', '⚡')}
|
|
329
|
+
|
|
330
|
+
${bulletPoint} ${ux.colorize('white', 'Open Temporal UI:')} ${formatCommand('open http://localhost:8080')}
|
|
331
|
+
${bulletPoint} ${ux.colorize('white', 'View logs:')} ${formatCommand(logsCommand)}
|
|
332
|
+
${bulletPoint} ${ux.colorize('white', 'Stop services:')} ${formatCommand('Press Ctrl+C')}
|
|
333
|
+
|
|
334
|
+
${divider}
|
|
335
|
+
|
|
336
|
+
${ux.colorize('dim', '💡 Tip: The Temporal UI lets you monitor workflow executions in real-time')}
|
|
337
|
+
`;
|
|
338
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getDevSuccessMessage } from './messages.js';
|
|
3
|
+
const mockServices = [
|
|
4
|
+
{ name: 'api' },
|
|
5
|
+
{ name: 'postgresql' },
|
|
6
|
+
{ name: 'redis' },
|
|
7
|
+
{ name: 'temporal' },
|
|
8
|
+
{ name: 'temporal-ui' },
|
|
9
|
+
{ name: 'worker' }
|
|
10
|
+
];
|
|
11
|
+
describe('messages', () => {
|
|
12
|
+
describe('getDevSuccessMessage', () => {
|
|
13
|
+
it('should return a string', () => {
|
|
14
|
+
const message = getDevSuccessMessage(mockServices);
|
|
15
|
+
expect(typeof message).toBe('string');
|
|
16
|
+
});
|
|
17
|
+
it('should include the Temporal UI URL', () => {
|
|
18
|
+
const message = getDevSuccessMessage(mockServices);
|
|
19
|
+
expect(message).toContain('http://localhost:8080');
|
|
20
|
+
});
|
|
21
|
+
it('should include the Temporal server address', () => {
|
|
22
|
+
const message = getDevSuccessMessage(mockServices);
|
|
23
|
+
expect(message).toContain('localhost:7233');
|
|
24
|
+
});
|
|
25
|
+
it('should include the API server address', () => {
|
|
26
|
+
const message = getDevSuccessMessage(mockServices);
|
|
27
|
+
expect(message).toContain('localhost:3001');
|
|
28
|
+
});
|
|
29
|
+
it('should include workflow run example', () => {
|
|
30
|
+
const message = getDevSuccessMessage(mockServices);
|
|
31
|
+
expect(message).toContain('output workflow run');
|
|
32
|
+
});
|
|
33
|
+
it('should include success indicator', () => {
|
|
34
|
+
const message = getDevSuccessMessage(mockServices);
|
|
35
|
+
expect(message).toContain('SUCCESS');
|
|
36
|
+
});
|
|
37
|
+
it('should include services section', () => {
|
|
38
|
+
const message = getDevSuccessMessage(mockServices);
|
|
39
|
+
expect(message).toContain('Temporal UI');
|
|
40
|
+
expect(message).toContain('API Server');
|
|
41
|
+
expect(message).toContain('Redis');
|
|
42
|
+
});
|
|
43
|
+
it('should include helpful tip about Temporal UI', () => {
|
|
44
|
+
const message = getDevSuccessMessage(mockServices);
|
|
45
|
+
expect(message).toContain('Temporal UI');
|
|
46
|
+
expect(message).toContain('workflow');
|
|
47
|
+
});
|
|
48
|
+
it('should include dynamic docker logs command with service names', () => {
|
|
49
|
+
const message = getDevSuccessMessage(mockServices);
|
|
50
|
+
expect(message).toContain('docker compose -p');
|
|
51
|
+
expect(message).toContain('logs -f');
|
|
52
|
+
expect(message).toContain('api|postgresql|redis|temporal|temporal-ui|worker');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -108,7 +108,7 @@ cat src/workflows/simple/workflow.ts
|
|
|
108
108
|
### Workflow not showing
|
|
109
109
|
- Check the file exports a valid workflow definition
|
|
110
110
|
- Ensure the workflow file compiles without errors
|
|
111
|
-
- Run `npm run build` to check for TypeScript errors
|
|
111
|
+
- Run `npm run output:workflow:build` to check for TypeScript errors
|
|
112
112
|
|
|
113
113
|
## Related Commands
|
|
114
114
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@output.ai/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"handlebars": "4.7.8",
|
|
36
36
|
"json-schema-library": "10.3.0",
|
|
37
37
|
"ky": "1.12.0",
|
|
38
|
-
"validator": "13.15.
|
|
38
|
+
"validator": "13.15.22"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/cli-progress": "3.11.6",
|
|
File without changes
|