@outputai/cli 0.2.1-next.bc8ccee.0 → 0.2.1-next.c3a8722.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/bin/run.js +2 -2
- package/dist/api/generated/api.d.ts +19 -5
- package/dist/api/generated/api.js +1 -1
- package/dist/assets/docker/docker-compose-dev.yml +5 -9
- package/dist/commands/dev/index.js +12 -1
- package/dist/commands/workflow/run.d.ts +1 -1
- package/dist/commands/workflow/run.js +8 -5
- package/dist/commands/workflow/run.spec.js +3 -3
- package/dist/commands/workflow/runs/list.d.ts +1 -0
- package/dist/commands/workflow/runs/list.js +7 -0
- package/dist/commands/workflow/start.d.ts +1 -1
- package/dist/commands/workflow/start.js +8 -5
- package/dist/commands/workflow/start.spec.js +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +13 -1
- package/dist/config.spec.js +54 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/docker.js +5 -2
- package/dist/services/docker.spec.js +74 -3
- package/dist/services/messages.js +2 -1
- package/dist/services/workflow_runs.d.ts +1 -0
- package/dist/services/workflow_runs.js +3 -0
- package/dist/templates/project/.env.example.template +17 -0
- package/dist/utils/credentials_loader.d.ts +1 -0
- package/dist/utils/credentials_loader.js +18 -0
- package/dist/utils/credentials_loader.spec.d.ts +1 -0
- package/dist/utils/credentials_loader.spec.js +84 -0
- package/dist/utils/validation.d.ts +13 -0
- package/dist/utils/validation.js +31 -0
- package/dist/utils/validation.spec.js +47 -1
- package/dist/views/dev.js +3 -3
- package/dist/views/workflow/list.js +2 -2
- package/package.json +10 -10
package/bin/run.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import { execute } from '@oclif/core';
|
|
4
4
|
import { loadEnvironment } from '../dist/utils/env_loader.js';
|
|
5
5
|
import { bootstrapProxy } from '../dist/utils/proxy.js';
|
|
6
|
-
import {
|
|
6
|
+
import { loadCredentialRefs } from '../dist/utils/credentials_loader.js';
|
|
7
7
|
|
|
8
8
|
// Load environment variables from .env files before executing CLI
|
|
9
9
|
loadEnvironment();
|
|
10
10
|
bootstrapProxy();
|
|
11
|
-
|
|
11
|
+
loadCredentialRefs();
|
|
12
12
|
|
|
13
13
|
await execute( { dir: import.meta.url } );
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generated by orval v8.
|
|
2
|
+
* Generated by orval v8.9.0 🍺
|
|
3
3
|
* Do not edit manually.
|
|
4
4
|
* Output.ai API
|
|
5
5
|
* API for managing and executing Output.ai workflows
|
|
@@ -293,7 +293,12 @@ export type PostWorkflowRunBody = {
|
|
|
293
293
|
input: unknown;
|
|
294
294
|
/** (Optional) The workflowId to use. Must be unique */
|
|
295
295
|
workflowId?: string;
|
|
296
|
-
/** The
|
|
296
|
+
/** The catalog (Temporal task queue) to route the execution to. Falls back to the default catalog. */
|
|
297
|
+
catalog?: string;
|
|
298
|
+
/**
|
|
299
|
+
* Deprecated alias for `catalog`. If both are sent, `catalog` wins.
|
|
300
|
+
* @deprecated
|
|
301
|
+
*/
|
|
297
302
|
taskQueue?: string;
|
|
298
303
|
/** (Optional) The max time to wait for the execution, defaults to 30s */
|
|
299
304
|
timeout?: number;
|
|
@@ -327,7 +332,12 @@ export type PostWorkflowStartBody = {
|
|
|
327
332
|
input: unknown;
|
|
328
333
|
/** (Optional) The workflowId to use. Must be unique */
|
|
329
334
|
workflowId?: string;
|
|
330
|
-
/** The
|
|
335
|
+
/** The catalog (Temporal task queue) to route the execution to. Falls back to the default catalog. */
|
|
336
|
+
catalog?: string;
|
|
337
|
+
/**
|
|
338
|
+
* Deprecated alias for `catalog`. If both are sent, `catalog` wins.
|
|
339
|
+
* @deprecated
|
|
340
|
+
*/
|
|
331
341
|
taskQueue?: string;
|
|
332
342
|
};
|
|
333
343
|
export type PostWorkflowStart200 = {
|
|
@@ -439,6 +449,10 @@ export type GetWorkflowRunsParams = {
|
|
|
439
449
|
* Filter by workflow type/name
|
|
440
450
|
*/
|
|
441
451
|
workflowType?: string;
|
|
452
|
+
/**
|
|
453
|
+
* Filter by catalog ID (scopes runs to a single worker's catalog/session)
|
|
454
|
+
*/
|
|
455
|
+
catalog?: string;
|
|
442
456
|
/**
|
|
443
457
|
* Maximum number of runs to return
|
|
444
458
|
* @minimum 1
|
|
@@ -711,7 +725,7 @@ export type postWorkflowIdRunsRidTerminateResponseError = (postWorkflowIdRunsRid
|
|
|
711
725
|
};
|
|
712
726
|
export type postWorkflowIdRunsRidTerminateResponse = (postWorkflowIdRunsRidTerminateResponseSuccess | postWorkflowIdRunsRidTerminateResponseError);
|
|
713
727
|
export declare const getPostWorkflowIdRunsRidTerminateUrl: (id: string, rid: string) => string;
|
|
714
|
-
export declare const postWorkflowIdRunsRidTerminate: (id: string, rid: string, postWorkflowIdRunsRidTerminateBody
|
|
728
|
+
export declare const postWorkflowIdRunsRidTerminate: (id: string, rid: string, postWorkflowIdRunsRidTerminateBody?: PostWorkflowIdRunsRidTerminateBody, options?: ApiRequestOptions) => Promise<postWorkflowIdRunsRidTerminateResponse>;
|
|
715
729
|
/**
|
|
716
730
|
* Force terminates the latest run. Deprecated; use `POST /workflow/{id}/runs/{rid}/terminate` to target a specific run. Scheduled for removal after 2026-07-16.
|
|
717
731
|
* @deprecated
|
|
@@ -745,7 +759,7 @@ export type postWorkflowIdTerminateResponseError = (postWorkflowIdTerminateRespo
|
|
|
745
759
|
};
|
|
746
760
|
export type postWorkflowIdTerminateResponse = (postWorkflowIdTerminateResponseSuccess | postWorkflowIdTerminateResponseError);
|
|
747
761
|
export declare const getPostWorkflowIdTerminateUrl: (id: string) => string;
|
|
748
|
-
export declare const postWorkflowIdTerminate: (id: string, postWorkflowIdTerminateBody
|
|
762
|
+
export declare const postWorkflowIdTerminate: (id: string, postWorkflowIdTerminateBody?: PostWorkflowIdTerminateBody, options?: ApiRequestOptions) => Promise<postWorkflowIdTerminateResponse>;
|
|
749
763
|
/**
|
|
750
764
|
* Resets a pinned workflow run to the point after a completed step, creating a new run that replays from that point. The current execution is terminated.
|
|
751
765
|
* @summary Reset a specific workflow run to re-run from after a completed step
|
|
@@ -4,8 +4,6 @@ services:
|
|
|
4
4
|
image: redis:8-alpine
|
|
5
5
|
networks:
|
|
6
6
|
- main
|
|
7
|
-
ports:
|
|
8
|
-
- '6379:6379'
|
|
9
7
|
volumes:
|
|
10
8
|
- redis:/data
|
|
11
9
|
healthcheck:
|
|
@@ -48,8 +46,6 @@ services:
|
|
|
48
46
|
image: temporalio/auto-setup:latest
|
|
49
47
|
networks:
|
|
50
48
|
- main
|
|
51
|
-
ports:
|
|
52
|
-
- '7233:7233'
|
|
53
49
|
healthcheck:
|
|
54
50
|
test:
|
|
55
51
|
[
|
|
@@ -73,7 +69,7 @@ services:
|
|
|
73
69
|
networks:
|
|
74
70
|
- main
|
|
75
71
|
ports:
|
|
76
|
-
- '8080:8080'
|
|
72
|
+
- '${OUTPUT_TEMPORAL_UI_HOST_PORT:-8080}:8080'
|
|
77
73
|
|
|
78
74
|
api:
|
|
79
75
|
depends_on:
|
|
@@ -81,7 +77,7 @@ services:
|
|
|
81
77
|
condition: service_healthy
|
|
82
78
|
worker:
|
|
83
79
|
condition: service_healthy
|
|
84
|
-
image: outputai/api:${OUTPUT_API_VERSION:-0.2.1-next.
|
|
80
|
+
image: outputai/api:${OUTPUT_API_VERSION:-0.2.1-next.c3a8722.0}
|
|
85
81
|
init: true
|
|
86
82
|
networks:
|
|
87
83
|
- main
|
|
@@ -97,7 +93,7 @@ services:
|
|
|
97
93
|
- OUTPUT_AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
|
98
94
|
- TEMPORAL_ADDRESS=temporal:7233
|
|
99
95
|
ports:
|
|
100
|
-
- '3001:3001'
|
|
96
|
+
- '${OUTPUT_API_HOST_PORT:-3001}:3001'
|
|
101
97
|
|
|
102
98
|
worker:
|
|
103
99
|
depends_on:
|
|
@@ -126,11 +122,11 @@ services:
|
|
|
126
122
|
- OUTPUT_TRACE_HTTP_VERBOSE=${OUTPUT_TRACE_HTTP_VERBOSE:-true}
|
|
127
123
|
- TEMPORAL_ADDRESS=temporal:7233
|
|
128
124
|
- NODE_OPTIONS=${NODE_OPTIONS:---max-old-space-size=4096}
|
|
129
|
-
command: sh -c "corepack enable && npm run output:worker:watch"
|
|
125
|
+
command: sh -c "corepack enable && npm run output:worker:install && npm run output:worker:watch"
|
|
130
126
|
working_dir: /app/${OUTPUT_WORKFLOWS_DIR:-.}
|
|
131
127
|
volumes:
|
|
132
128
|
- ./:/app
|
|
133
|
-
- worker_node_modules:/app
|
|
129
|
+
- worker_node_modules:/app/node_modules
|
|
134
130
|
|
|
135
131
|
volumes:
|
|
136
132
|
postgres:
|
|
@@ -7,8 +7,16 @@ import { validateDockerEnvironment, startDockerCompose, startDockerComposeDetach
|
|
|
7
7
|
import { getErrorMessage } from '#utils/error_utils.js';
|
|
8
8
|
import { ensureClaudePlugin } from '#services/coding_agents.js';
|
|
9
9
|
import { DevApp } from '#views/dev.js';
|
|
10
|
+
import { config } from '#config.js';
|
|
10
11
|
export default class Dev extends Command {
|
|
11
|
-
static description =
|
|
12
|
+
static description = [
|
|
13
|
+
'Start Output development services (auto-restarts worker on file changes)',
|
|
14
|
+
'',
|
|
15
|
+
'To run a second dev stack concurrently, override host ports in .env:',
|
|
16
|
+
'',
|
|
17
|
+
' OUTPUT_API_HOST_PORT=3002',
|
|
18
|
+
' OUTPUT_TEMPORAL_UI_HOST_PORT=8081'
|
|
19
|
+
].join('\n');
|
|
12
20
|
static examples = [
|
|
13
21
|
'<%= config.bin %> <%= command.id %>',
|
|
14
22
|
'<%= config.bin %> <%= command.id %> --compose-file ./custom-docker-compose.yml',
|
|
@@ -38,6 +46,8 @@ export default class Dev extends Command {
|
|
|
38
46
|
// Ensure Claude plugin is configured (fire-and-forget, silent)
|
|
39
47
|
ensureClaudePlugin(process.cwd(), { silent: true }).catch(() => { });
|
|
40
48
|
validateDockerEnvironment();
|
|
49
|
+
// Eagerly resolve ports so InvalidPortError surfaces before Ink mounts.
|
|
50
|
+
void config.ports;
|
|
41
51
|
const dockerComposePath = flags['compose-file'] ?
|
|
42
52
|
path.resolve(process.cwd(), flags['compose-file']) :
|
|
43
53
|
getDefaultDockerComposePath();
|
|
@@ -48,6 +58,7 @@ export default class Dev extends Command {
|
|
|
48
58
|
throw new DockerComposeConfigNotFoundError(dockerComposePath);
|
|
49
59
|
}
|
|
50
60
|
this.log('\n🚀 Starting Output development services...\n');
|
|
61
|
+
this.log(`Docker project name: ${config.dockerServiceName}\n`);
|
|
51
62
|
if (flags['compose-file']) {
|
|
52
63
|
this.log(`Using custom docker-compose file: ${flags['compose-file']}\n`);
|
|
53
64
|
}
|
|
@@ -8,7 +8,7 @@ export default class WorkflowRun extends Command {
|
|
|
8
8
|
};
|
|
9
9
|
static flags: {
|
|
10
10
|
input: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
-
|
|
11
|
+
catalog: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
12
|
format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
13
|
};
|
|
14
14
|
run(): Promise<void>;
|
|
@@ -35,7 +35,7 @@ export default class WorkflowRun extends Command {
|
|
|
35
35
|
'<%= config.bin %> <%= command.id %> simple my_scenario --format json',
|
|
36
36
|
'<%= config.bin %> <%= command.id %> simple --input \'{"values":[1,2,3]}\'',
|
|
37
37
|
'<%= config.bin %> <%= command.id %> simple --input input.json',
|
|
38
|
-
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --
|
|
38
|
+
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --catalog my-catalog'
|
|
39
39
|
];
|
|
40
40
|
static args = {
|
|
41
41
|
workflowName: Args.string({
|
|
@@ -53,9 +53,12 @@ export default class WorkflowRun extends Command {
|
|
|
53
53
|
description: 'Workflow input as JSON string or file path (overrides scenario)',
|
|
54
54
|
required: false
|
|
55
55
|
}),
|
|
56
|
-
|
|
57
|
-
char: '
|
|
58
|
-
|
|
56
|
+
catalog: Flags.string({
|
|
57
|
+
char: 'c',
|
|
58
|
+
aliases: ['task-queue'],
|
|
59
|
+
charAliases: ['q'],
|
|
60
|
+
deprecateAliases: true,
|
|
61
|
+
description: 'Catalog name for workflow execution (defaults to OUTPUT_CATALOG_ID)',
|
|
59
62
|
env: 'OUTPUT_CATALOG_ID'
|
|
60
63
|
}),
|
|
61
64
|
format: Flags.string({
|
|
@@ -73,7 +76,7 @@ export default class WorkflowRun extends Command {
|
|
|
73
76
|
body: {
|
|
74
77
|
workflowName: args.workflowName,
|
|
75
78
|
input,
|
|
76
|
-
|
|
79
|
+
catalog: flags.catalog
|
|
77
80
|
},
|
|
78
81
|
options: { config: { timeout: 600000 } },
|
|
79
82
|
log: msg => this.log(msg)
|
|
@@ -27,7 +27,7 @@ describe('workflow run command', () => {
|
|
|
27
27
|
expect(WorkflowRun.args).toHaveProperty('workflowName');
|
|
28
28
|
expect(WorkflowRun.flags).toHaveProperty('input');
|
|
29
29
|
expect(WorkflowRun.flags).toHaveProperty('format');
|
|
30
|
-
expect(WorkflowRun.flags).toHaveProperty('
|
|
30
|
+
expect(WorkflowRun.flags).toHaveProperty('catalog');
|
|
31
31
|
});
|
|
32
32
|
it('should have correct flag configuration', async () => {
|
|
33
33
|
const WorkflowRun = (await import('./run.js')).default;
|
|
@@ -53,7 +53,7 @@ describe('workflow run command', () => {
|
|
|
53
53
|
});
|
|
54
54
|
cmd.parse = vi.fn().mockResolvedValue({
|
|
55
55
|
args: { workflowName: 'my_workflow', scenario: undefined },
|
|
56
|
-
flags: { input: undefined,
|
|
56
|
+
flags: { input: undefined, catalog: undefined, format: 'text' }
|
|
57
57
|
});
|
|
58
58
|
return { cmd, postWorkflowRun: vi.mocked(postWorkflowRun), resolveInput: vi.mocked(resolveInput) };
|
|
59
59
|
};
|
|
@@ -67,7 +67,7 @@ describe('workflow run command', () => {
|
|
|
67
67
|
});
|
|
68
68
|
await cmd.run();
|
|
69
69
|
expect(postWorkflowRun).toHaveBeenCalledTimes(1);
|
|
70
|
-
expect(postWorkflowRun).toHaveBeenCalledWith({ workflowName: 'my_workflow', input: { key: 'value' },
|
|
70
|
+
expect(postWorkflowRun).toHaveBeenCalledWith({ workflowName: 'my_workflow', input: { key: 'value' }, catalog: undefined }, expect.objectContaining({ config: { timeout: 600000 } }));
|
|
71
71
|
expect(cmd.log).toHaveBeenCalledWith('Executing workflow: my_workflow...');
|
|
72
72
|
expect(cmd.log).toHaveBeenCalledWith(expect.stringMatching(/\n/));
|
|
73
73
|
});
|
|
@@ -6,6 +6,7 @@ export default class WorkflowRunsList extends Command {
|
|
|
6
6
|
workflowName: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
7
7
|
};
|
|
8
8
|
static flags: {
|
|
9
|
+
catalog: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
10
|
limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
11
|
format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
12
|
};
|
|
@@ -55,6 +55,7 @@ export default class WorkflowRunsList extends Command {
|
|
|
55
55
|
'<%= config.bin %> <%= command.id %>',
|
|
56
56
|
'<%= config.bin %> <%= command.id %> simple',
|
|
57
57
|
'<%= config.bin %> <%= command.id %> simple --limit 10',
|
|
58
|
+
'<%= config.bin %> <%= command.id %> --catalog my-catalog',
|
|
58
59
|
'<%= config.bin %> <%= command.id %> --format json',
|
|
59
60
|
'<%= config.bin %> <%= command.id %> --format table'
|
|
60
61
|
];
|
|
@@ -65,6 +66,11 @@ export default class WorkflowRunsList extends Command {
|
|
|
65
66
|
})
|
|
66
67
|
};
|
|
67
68
|
static flags = {
|
|
69
|
+
catalog: Flags.string({
|
|
70
|
+
char: 'c',
|
|
71
|
+
description: 'Filter runs by catalog (defaults to OUTPUT_CATALOG_ID)',
|
|
72
|
+
env: 'OUTPUT_CATALOG_ID'
|
|
73
|
+
}),
|
|
68
74
|
limit: Flags.integer({
|
|
69
75
|
char: 'l',
|
|
70
76
|
description: 'Maximum number of runs to return',
|
|
@@ -81,6 +87,7 @@ export default class WorkflowRunsList extends Command {
|
|
|
81
87
|
const { args, flags } = await this.parse(WorkflowRunsList);
|
|
82
88
|
const { runs, count } = await fetchWorkflowRuns({
|
|
83
89
|
workflowType: args.workflowName,
|
|
90
|
+
catalog: flags.catalog,
|
|
84
91
|
limit: flags.limit
|
|
85
92
|
});
|
|
86
93
|
if (runs.length === 0) {
|
|
@@ -8,7 +8,7 @@ export default class WorkflowStart extends Command {
|
|
|
8
8
|
};
|
|
9
9
|
static flags: {
|
|
10
10
|
input: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
-
|
|
11
|
+
catalog: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
12
|
};
|
|
13
13
|
run(): Promise<void>;
|
|
14
14
|
catch(error: Error): Promise<void>;
|
|
@@ -8,7 +8,7 @@ export default class WorkflowStart extends Command {
|
|
|
8
8
|
'<%= config.bin %> <%= command.id %> simple basic_input',
|
|
9
9
|
'<%= config.bin %> <%= command.id %> simple --input \'{"values":[1,2,3]}\'',
|
|
10
10
|
'<%= config.bin %> <%= command.id %> simple --input input.json',
|
|
11
|
-
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --
|
|
11
|
+
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --catalog my-catalog'
|
|
12
12
|
];
|
|
13
13
|
static args = {
|
|
14
14
|
workflowName: Args.string({
|
|
@@ -26,9 +26,12 @@ export default class WorkflowStart extends Command {
|
|
|
26
26
|
description: 'Workflow input as JSON string or file path (overrides scenario)',
|
|
27
27
|
required: false
|
|
28
28
|
}),
|
|
29
|
-
|
|
30
|
-
char: '
|
|
31
|
-
|
|
29
|
+
catalog: Flags.string({
|
|
30
|
+
char: 'c',
|
|
31
|
+
aliases: ['task-queue'],
|
|
32
|
+
charAliases: ['q'],
|
|
33
|
+
deprecateAliases: true,
|
|
34
|
+
description: 'Catalog name for workflow execution (defaults to OUTPUT_CATALOG_ID)',
|
|
32
35
|
env: 'OUTPUT_CATALOG_ID'
|
|
33
36
|
})
|
|
34
37
|
};
|
|
@@ -39,7 +42,7 @@ export default class WorkflowStart extends Command {
|
|
|
39
42
|
const response = await postWorkflowStart({
|
|
40
43
|
workflowName: args.workflowName,
|
|
41
44
|
input,
|
|
42
|
-
|
|
45
|
+
catalog: flags.catalog
|
|
43
46
|
});
|
|
44
47
|
if (!response || !response.data) {
|
|
45
48
|
this.error('API returned invalid response', { exit: 1 });
|
|
@@ -14,7 +14,7 @@ describe('workflow start command', () => {
|
|
|
14
14
|
expect(WorkflowStart.description).toContain('Start a workflow');
|
|
15
15
|
expect(WorkflowStart.args).toHaveProperty('workflowName');
|
|
16
16
|
expect(WorkflowStart.flags).toHaveProperty('input');
|
|
17
|
-
expect(WorkflowStart.flags).toHaveProperty('
|
|
17
|
+
expect(WorkflowStart.flags).toHaveProperty('catalog');
|
|
18
18
|
});
|
|
19
19
|
it('should have correct flag configuration', async () => {
|
|
20
20
|
const WorkflowStart = (await import('./start.js')).default;
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
import { parsePort } from '#utils/validation.js';
|
|
2
|
+
const DEFAULT_API_PORT = 3001;
|
|
3
|
+
const DEFAULT_TEMPORAL_UI_PORT = 8080;
|
|
1
4
|
export const config = {
|
|
2
5
|
get apiUrl() {
|
|
3
|
-
return process.env.OUTPUT_API_URL ||
|
|
6
|
+
return process.env.OUTPUT_API_URL || `http://localhost:${this.ports.api}`;
|
|
7
|
+
},
|
|
8
|
+
get ports() {
|
|
9
|
+
return {
|
|
10
|
+
temporalUi: parsePort(process.env.OUTPUT_TEMPORAL_UI_HOST_PORT, DEFAULT_TEMPORAL_UI_PORT, 'OUTPUT_TEMPORAL_UI_HOST_PORT'),
|
|
11
|
+
api: parsePort(process.env.OUTPUT_API_HOST_PORT, DEFAULT_API_PORT, 'OUTPUT_API_HOST_PORT')
|
|
12
|
+
};
|
|
13
|
+
},
|
|
14
|
+
get temporalUiUrl() {
|
|
15
|
+
return `http://localhost:${this.ports.temporalUi}`;
|
|
4
16
|
},
|
|
5
17
|
get apiToken() {
|
|
6
18
|
return process.env.OUTPUT_API_AUTH_TOKEN;
|
package/dist/config.spec.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { config } from '#config.js';
|
|
3
|
+
import { InvalidPortError } from '#utils/validation.js';
|
|
3
4
|
describe('config', () => {
|
|
4
5
|
const envVars = [
|
|
5
6
|
'OUTPUT_API_URL',
|
|
7
|
+
'OUTPUT_API_HOST_PORT',
|
|
8
|
+
'OUTPUT_TEMPORAL_UI_HOST_PORT',
|
|
6
9
|
'OUTPUT_API_AUTH_TOKEN',
|
|
7
10
|
'DOCKER_SERVICE_NAME',
|
|
8
11
|
'OUTPUT_DEBUG',
|
|
@@ -36,14 +39,65 @@ describe('config', () => {
|
|
|
36
39
|
});
|
|
37
40
|
it('falls back to defaults when env vars are unset', () => {
|
|
38
41
|
delete process.env.OUTPUT_API_URL;
|
|
42
|
+
delete process.env.OUTPUT_API_HOST_PORT;
|
|
43
|
+
delete process.env.OUTPUT_TEMPORAL_UI_HOST_PORT;
|
|
39
44
|
delete process.env.DOCKER_SERVICE_NAME;
|
|
40
45
|
delete process.env.OUTPUT_DEBUG;
|
|
41
46
|
delete process.env.OUTPUT_CLI_ENV;
|
|
42
47
|
expect(config.apiUrl).toBe('http://localhost:3001');
|
|
48
|
+
expect(config.ports).toEqual({ temporalUi: 8080, api: 3001 });
|
|
49
|
+
expect(config.temporalUiUrl).toBe('http://localhost:8080');
|
|
43
50
|
expect(config.dockerServiceName).toBe('output-sdk');
|
|
44
51
|
expect(config.debugMode).toBe(false);
|
|
45
52
|
expect(config.envFile).toBe('.env');
|
|
46
53
|
});
|
|
54
|
+
it('derives apiUrl from OUTPUT_API_HOST_PORT when OUTPUT_API_URL is unset', () => {
|
|
55
|
+
delete process.env.OUTPUT_API_URL;
|
|
56
|
+
process.env.OUTPUT_API_HOST_PORT = '3002';
|
|
57
|
+
expect(config.apiUrl).toBe('http://localhost:3002');
|
|
58
|
+
});
|
|
59
|
+
it('OUTPUT_API_URL takes precedence over OUTPUT_API_HOST_PORT', () => {
|
|
60
|
+
process.env.OUTPUT_API_URL = 'https://api.example.com';
|
|
61
|
+
process.env.OUTPUT_API_HOST_PORT = '3002';
|
|
62
|
+
expect(config.apiUrl).toBe('https://api.example.com');
|
|
63
|
+
});
|
|
64
|
+
it('empty-string OUTPUT_API_URL falls through to OUTPUT_API_HOST_PORT (|| not ??)', () => {
|
|
65
|
+
process.env.OUTPUT_API_URL = '';
|
|
66
|
+
process.env.OUTPUT_API_HOST_PORT = '3002';
|
|
67
|
+
expect(config.apiUrl).toBe('http://localhost:3002');
|
|
68
|
+
});
|
|
69
|
+
it('reads port overrides from env vars', () => {
|
|
70
|
+
process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '8081';
|
|
71
|
+
process.env.OUTPUT_API_HOST_PORT = '3002';
|
|
72
|
+
expect(config.ports).toEqual({ temporalUi: 8081, api: 3002 });
|
|
73
|
+
expect(config.temporalUiUrl).toBe('http://localhost:8081');
|
|
74
|
+
});
|
|
75
|
+
it('treats empty-string port env vars as unset (matches Compose semantics)', () => {
|
|
76
|
+
delete process.env.OUTPUT_API_URL;
|
|
77
|
+
process.env.OUTPUT_API_HOST_PORT = '';
|
|
78
|
+
process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '';
|
|
79
|
+
expect(config.apiUrl).toBe('http://localhost:3001');
|
|
80
|
+
expect(config.ports).toEqual({ temporalUi: 8080, api: 3001 });
|
|
81
|
+
expect(config.temporalUiUrl).toBe('http://localhost:8080');
|
|
82
|
+
});
|
|
83
|
+
it('throws InvalidPortError on non-numeric port values', () => {
|
|
84
|
+
delete process.env.OUTPUT_API_URL;
|
|
85
|
+
process.env.OUTPUT_API_HOST_PORT = 'abc';
|
|
86
|
+
expect(() => config.ports).toThrow(InvalidPortError);
|
|
87
|
+
expect(() => config.apiUrl).toThrow(InvalidPortError);
|
|
88
|
+
});
|
|
89
|
+
it('throws InvalidPortError on out-of-range port values', () => {
|
|
90
|
+
process.env.OUTPUT_API_HOST_PORT = '99999';
|
|
91
|
+
expect(() => config.ports).toThrow(InvalidPortError);
|
|
92
|
+
});
|
|
93
|
+
it('throws InvalidPortError on trailing-junk port values', () => {
|
|
94
|
+
process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '8080abc';
|
|
95
|
+
expect(() => config.ports).toThrow(InvalidPortError);
|
|
96
|
+
});
|
|
97
|
+
it('throws InvalidPortError on port 0 (Compose treats 0 as ephemeral - prevents CLI/Docker desync)', () => {
|
|
98
|
+
process.env.OUTPUT_API_HOST_PORT = '0';
|
|
99
|
+
expect(() => config.ports).toThrow(InvalidPortError);
|
|
100
|
+
});
|
|
47
101
|
it('reads apiToken from env', () => {
|
|
48
102
|
process.env.OUTPUT_API_AUTH_TOKEN = 'test-token-123';
|
|
49
103
|
expect(config.apiToken).toBe('test-token-123');
|
package/dist/services/docker.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { ux } from '@oclif/core';
|
|
5
5
|
import semver from 'semver';
|
|
6
|
+
import { config } from '#config.js';
|
|
6
7
|
const DEFAULT_COMPOSE_PATH = '../assets/docker/docker-compose-dev.yml';
|
|
7
8
|
export const SERVICE_HEALTH = {
|
|
8
9
|
HEALTHY: 'healthy',
|
|
@@ -100,7 +101,7 @@ export function parseServiceStatus(jsonOutput) {
|
|
|
100
101
|
});
|
|
101
102
|
}
|
|
102
103
|
export async function getServiceStatus(dockerComposePath) {
|
|
103
|
-
const result = execFileSync('docker', ['compose', '-f', dockerComposePath, 'ps', '--all', '--format', 'json'], { encoding: 'utf-8', cwd: process.cwd() });
|
|
104
|
+
const result = execFileSync('docker', ['compose', '-f', dockerComposePath, '--project-name', config.dockerServiceName, 'ps', '--all', '--format', 'json'], { encoding: 'utf-8', cwd: process.cwd() });
|
|
104
105
|
return parseServiceStatus(result);
|
|
105
106
|
}
|
|
106
107
|
export function isServiceHealthy(service) {
|
|
@@ -126,6 +127,7 @@ export async function startDockerCompose(dockerComposePath, pullPolicy) {
|
|
|
126
127
|
'compose',
|
|
127
128
|
'-f', dockerComposePath,
|
|
128
129
|
'--project-directory', process.cwd(),
|
|
130
|
+
'--project-name', config.dockerServiceName,
|
|
129
131
|
'up'
|
|
130
132
|
];
|
|
131
133
|
if (pullPolicy) {
|
|
@@ -143,6 +145,7 @@ export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
|
|
|
143
145
|
'compose',
|
|
144
146
|
'-f', dockerComposePath,
|
|
145
147
|
'--project-directory', process.cwd(),
|
|
148
|
+
'--project-name', config.dockerServiceName,
|
|
146
149
|
'up', '-d'
|
|
147
150
|
];
|
|
148
151
|
if (pullPolicy) {
|
|
@@ -152,6 +155,6 @@ export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
|
|
|
152
155
|
}
|
|
153
156
|
export async function stopDockerCompose(dockerComposePath) {
|
|
154
157
|
ux.stdout('⏹️ Stopping services...\n');
|
|
155
|
-
execFileSync('docker', ['compose', '-f', dockerComposePath, 'down'], { stdio: 'inherit', cwd: process.cwd() });
|
|
158
|
+
execFileSync('docker', ['compose', '-f', dockerComposePath, '--project-directory', process.cwd(), '--project-name', config.dockerServiceName, 'down'], { stdio: 'inherit', cwd: process.cwd() });
|
|
156
159
|
}
|
|
157
160
|
export { isDockerInstalled, DockerValidationError };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { parseServiceStatus, getServiceStatus, waitForServicesHealthy, isServiceHealthy, isServiceFailed } from './docker.js';
|
|
2
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
3
|
+
import { parseServiceStatus, getServiceStatus, startDockerCompose, startDockerComposeDetached, stopDockerCompose, waitForServicesHealthy, isServiceHealthy, isServiceFailed } from './docker.js';
|
|
4
4
|
vi.mock('node:child_process', () => ({
|
|
5
5
|
execSync: vi.fn(),
|
|
6
6
|
execFileSync: vi.fn(),
|
|
@@ -73,7 +73,7 @@ describe('docker service', () => {
|
|
|
73
73
|
const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}';
|
|
74
74
|
vi.mocked(execFileSync).mockReturnValue(mockOutput);
|
|
75
75
|
await getServiceStatus('/path/to/docker-compose.yml');
|
|
76
|
-
expect(execFileSync).toHaveBeenCalledWith('docker', ['compose', '-f', '/path/to/docker-compose.yml', 'ps', '--all', '--format', 'json'], expect.objectContaining({ encoding: 'utf-8' }));
|
|
76
|
+
expect(execFileSync).toHaveBeenCalledWith('docker', ['compose', '-f', '/path/to/docker-compose.yml', '--project-name', 'output-sdk', 'ps', '--all', '--format', 'json'], expect.objectContaining({ encoding: 'utf-8' }));
|
|
77
77
|
});
|
|
78
78
|
it('should return parsed service status', async () => {
|
|
79
79
|
const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}';
|
|
@@ -89,6 +89,77 @@ describe('docker service', () => {
|
|
|
89
89
|
await expect(getServiceStatus('/path/to/docker-compose.yml')).rejects.toThrow();
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
|
+
describe('startDockerCompose', () => {
|
|
93
|
+
it('should pass --project-name to docker compose up', async () => {
|
|
94
|
+
await startDockerCompose('/path/to/docker-compose.yml');
|
|
95
|
+
expect(spawn).toHaveBeenCalledWith('docker', [
|
|
96
|
+
'compose', '-f', '/path/to/docker-compose.yml',
|
|
97
|
+
'--project-directory', process.cwd(),
|
|
98
|
+
'--project-name', 'output-sdk',
|
|
99
|
+
'up'
|
|
100
|
+
], expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], cwd: process.cwd() }));
|
|
101
|
+
});
|
|
102
|
+
it('should append --pull when pullPolicy is provided', async () => {
|
|
103
|
+
await startDockerCompose('/path/to/docker-compose.yml', 'always');
|
|
104
|
+
expect(spawn).toHaveBeenCalledWith('docker', [
|
|
105
|
+
'compose', '-f', '/path/to/docker-compose.yml',
|
|
106
|
+
'--project-directory', process.cwd(),
|
|
107
|
+
'--project-name', 'output-sdk',
|
|
108
|
+
'up', '--pull', 'always'
|
|
109
|
+
], expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], cwd: process.cwd() }));
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('startDockerComposeDetached', () => {
|
|
113
|
+
it('should pass --project-name and -d to docker compose up', () => {
|
|
114
|
+
vi.mocked(execFileSync).mockReturnValue('');
|
|
115
|
+
startDockerComposeDetached('/path/to/docker-compose.yml');
|
|
116
|
+
expect(execFileSync).toHaveBeenCalledWith('docker', [
|
|
117
|
+
'compose', '-f', '/path/to/docker-compose.yml',
|
|
118
|
+
'--project-directory', process.cwd(),
|
|
119
|
+
'--project-name', 'output-sdk',
|
|
120
|
+
'up', '-d'
|
|
121
|
+
], expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }));
|
|
122
|
+
});
|
|
123
|
+
it('should append --pull when pullPolicy is provided', () => {
|
|
124
|
+
vi.mocked(execFileSync).mockReturnValue('');
|
|
125
|
+
startDockerComposeDetached('/path/to/docker-compose.yml', 'missing');
|
|
126
|
+
expect(execFileSync).toHaveBeenCalledWith('docker', [
|
|
127
|
+
'compose', '-f', '/path/to/docker-compose.yml',
|
|
128
|
+
'--project-directory', process.cwd(),
|
|
129
|
+
'--project-name', 'output-sdk',
|
|
130
|
+
'up', '-d', '--pull', 'missing'
|
|
131
|
+
], expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('DOCKER_SERVICE_NAME wiring', () => {
|
|
135
|
+
const saved = process.env.DOCKER_SERVICE_NAME;
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
if (saved === undefined) {
|
|
138
|
+
delete process.env.DOCKER_SERVICE_NAME;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
process.env.DOCKER_SERVICE_NAME = saved;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
it('threads DOCKER_SERVICE_NAME through to --project-name (not hardcoded output-sdk)', async () => {
|
|
145
|
+
process.env.DOCKER_SERVICE_NAME = 'custom-project';
|
|
146
|
+
vi.mocked(execFileSync).mockReturnValue('');
|
|
147
|
+
await stopDockerCompose('/path/to/docker-compose.yml');
|
|
148
|
+
expect(execFileSync).toHaveBeenCalledWith('docker', [
|
|
149
|
+
'compose', '-f', '/path/to/docker-compose.yml',
|
|
150
|
+
'--project-directory', process.cwd(),
|
|
151
|
+
'--project-name', 'custom-project',
|
|
152
|
+
'down'
|
|
153
|
+
], expect.objectContaining({ stdio: 'inherit' }));
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('stopDockerCompose', () => {
|
|
157
|
+
it('should pass --project-name and --project-directory to docker compose down', async () => {
|
|
158
|
+
vi.mocked(execFileSync).mockReturnValue('');
|
|
159
|
+
await stopDockerCompose('/path/to/docker-compose.yml');
|
|
160
|
+
expect(execFileSync).toHaveBeenCalledWith('docker', ['compose', '-f', '/path/to/docker-compose.yml', '--project-directory', process.cwd(), '--project-name', 'output-sdk', 'down'], expect.objectContaining({ stdio: 'inherit' }));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
92
163
|
describe('isServiceHealthy', () => {
|
|
93
164
|
it('should return true for a running service with health: healthy', () => {
|
|
94
165
|
expect(isServiceHealthy({ name: 'redis', state: 'running', health: 'healthy', ports: [] })).toBe(true);
|
|
@@ -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
|
*/
|
|
@@ -180,7 +181,7 @@ export const getProjectSuccessMessage = (folderName, installSuccess, credentials
|
|
|
180
181
|
note: 'Execute in a new terminal after services are running'
|
|
181
182
|
}, {
|
|
182
183
|
step: 'Monitor workflows',
|
|
183
|
-
command:
|
|
184
|
+
command: `open ${config.temporalUiUrl}`,
|
|
184
185
|
note: 'Access Temporal UI for workflow visualization'
|
|
185
186
|
});
|
|
186
187
|
// Format each step with proper indentation and colors
|
|
@@ -9,6 +9,7 @@ export interface WorkflowRunsResult {
|
|
|
9
9
|
}
|
|
10
10
|
export interface FetchWorkflowRunsOptions {
|
|
11
11
|
workflowType?: string;
|
|
12
|
+
catalog?: string;
|
|
12
13
|
limit?: number;
|
|
13
14
|
}
|
|
14
15
|
export declare function fetchWorkflowRuns(options?: FetchWorkflowRunsOptions): Promise<WorkflowRunsResult>;
|
|
@@ -10,6 +10,9 @@ export async function fetchWorkflowRuns(options = {}) {
|
|
|
10
10
|
if (options.workflowType) {
|
|
11
11
|
params.workflowType = options.workflowType;
|
|
12
12
|
}
|
|
13
|
+
if (options.catalog) {
|
|
14
|
+
params.catalog = options.catalog;
|
|
15
|
+
}
|
|
13
16
|
const response = await getWorkflowRuns(params);
|
|
14
17
|
if (!response) {
|
|
15
18
|
throw new Error('Failed to connect to API server. Is it running?');
|
|
@@ -7,3 +7,20 @@ ANTHROPIC_API_KEY=credential:anthropic.api_key
|
|
|
7
7
|
# Configure if you plan to use OpenAI in your LLM prompts
|
|
8
8
|
OPENAI_API_KEY=credential:openai.api_key
|
|
9
9
|
|
|
10
|
+
# --- Host port overrides (for running multiple dev stacks) ---
|
|
11
|
+
# Only the host-side port changes; inter-service traffic (e.g. worker -> Temporal
|
|
12
|
+
# at temporal:7233) is unaffected.
|
|
13
|
+
# OUTPUT_API_HOST_PORT=3001
|
|
14
|
+
# OUTPUT_TEMPORAL_UI_HOST_PORT=8080
|
|
15
|
+
|
|
16
|
+
# --- API connection ---
|
|
17
|
+
# If OUTPUT_API_URL is set, it overrides OUTPUT_API_HOST_PORT.
|
|
18
|
+
# Use OUTPUT_API_URL for remote/staging servers; use OUTPUT_API_HOST_PORT for local port shifts.
|
|
19
|
+
# OUTPUT_API_URL=http://localhost:3001
|
|
20
|
+
|
|
21
|
+
# --- Project isolation ---
|
|
22
|
+
# Compose prefixes named volumes with the project name, so changing
|
|
23
|
+
# DOCKER_SERVICE_NAME creates fresh volumes and leaves the previous ones behind.
|
|
24
|
+
# Run `docker volume ls --filter name=<old-project-name>` to find orphaned
|
|
25
|
+
# volumes and `docker volume rm` to remove them.
|
|
26
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const loadCredentialRefs: () => void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { InvalidCredentialsKeyError, MalformedCredentialsKeyError, MissingKeyError, resolveCredentialRefs } from '@outputai/credentials';
|
|
2
|
+
const isCredentialsConfigError = (error) => error instanceof MissingKeyError ||
|
|
3
|
+
error instanceof InvalidCredentialsKeyError ||
|
|
4
|
+
error instanceof MalformedCredentialsKeyError;
|
|
5
|
+
export const loadCredentialRefs = () => {
|
|
6
|
+
try {
|
|
7
|
+
resolveCredentialRefs();
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (isCredentialsConfigError(error)) {
|
|
11
|
+
console.error(`Error: ${error.message}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import * as credentials from '@outputai/credentials';
|
|
3
|
+
import { loadCredentialRefs } from './credentials_loader.js';
|
|
4
|
+
vi.mock('@outputai/credentials', async () => {
|
|
5
|
+
const actual = await vi.importActual('@outputai/credentials');
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
resolveCredentialRefs: vi.fn()
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
describe('loadCredentialRefs', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
it('should call resolveCredentialRefs without errors when no credentials are misconfigured', () => {
|
|
19
|
+
vi.mocked(credentials.resolveCredentialRefs).mockReturnValue([]);
|
|
20
|
+
expect(() => loadCredentialRefs()).not.toThrow();
|
|
21
|
+
expect(credentials.resolveCredentialRefs).toHaveBeenCalledTimes(1);
|
|
22
|
+
});
|
|
23
|
+
it('should print a clean error message and exit on MissingKeyError without dumping a stack trace', () => {
|
|
24
|
+
vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
|
|
25
|
+
throw new credentials.MissingKeyError();
|
|
26
|
+
});
|
|
27
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
28
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
29
|
+
loadCredentialRefs();
|
|
30
|
+
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
|
31
|
+
const printedMessage = consoleErrorSpy.mock.calls[0]?.[0];
|
|
32
|
+
expect(printedMessage).toContain('No credentials key found');
|
|
33
|
+
expect(printedMessage).toContain('OUTPUT_CREDENTIALS_KEY');
|
|
34
|
+
expect(printedMessage).toContain('config/credentials.key');
|
|
35
|
+
expect(printedMessage).not.toContain(' at ');
|
|
36
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
37
|
+
});
|
|
38
|
+
it('should include the environment-specific hints in the printed message when an environment is set', () => {
|
|
39
|
+
vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
|
|
40
|
+
throw new credentials.MissingKeyError('production');
|
|
41
|
+
});
|
|
42
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
43
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
44
|
+
loadCredentialRefs();
|
|
45
|
+
const printedMessage = consoleErrorSpy.mock.calls[0]?.[0];
|
|
46
|
+
expect(printedMessage).toContain('OUTPUT_CREDENTIALS_KEY_PRODUCTION');
|
|
47
|
+
expect(printedMessage).toContain('config/credentials/production.key');
|
|
48
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
49
|
+
});
|
|
50
|
+
it('should print a clean error message and exit on InvalidCredentialsKeyError', () => {
|
|
51
|
+
vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
|
|
52
|
+
throw new credentials.InvalidCredentialsKeyError('config/credentials.yml.enc');
|
|
53
|
+
});
|
|
54
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
55
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
56
|
+
loadCredentialRefs();
|
|
57
|
+
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
|
58
|
+
const printedMessage = consoleErrorSpy.mock.calls[0]?.[0];
|
|
59
|
+
expect(printedMessage).toContain('Failed to decrypt config/credentials.yml.enc');
|
|
60
|
+
expect(printedMessage).toContain('does not match');
|
|
61
|
+
expect(printedMessage).not.toContain(' at ');
|
|
62
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
63
|
+
});
|
|
64
|
+
it('should print a clean error message and exit on MalformedCredentialsKeyError', () => {
|
|
65
|
+
vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
|
|
66
|
+
throw new credentials.MalformedCredentialsKeyError('config/credentials.yml.enc', 'hex string expected, got unpadded hex of length 55');
|
|
67
|
+
});
|
|
68
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
69
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
70
|
+
loadCredentialRefs();
|
|
71
|
+
const printedMessage = consoleErrorSpy.mock.calls[0]?.[0];
|
|
72
|
+
expect(printedMessage).toContain('is malformed');
|
|
73
|
+
expect(printedMessage).toContain('must be exactly 64 hex characters');
|
|
74
|
+
expect(printedMessage).toContain('unpadded hex of length 55');
|
|
75
|
+
expect(printedMessage).not.toContain(' at ');
|
|
76
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
77
|
+
});
|
|
78
|
+
it('should rethrow unexpected errors so they are not silently swallowed', () => {
|
|
79
|
+
vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
|
|
80
|
+
throw new Error('something else broke');
|
|
81
|
+
});
|
|
82
|
+
expect(() => loadCredentialRefs()).toThrow('something else broke');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -11,3 +11,16 @@ export declare function validateWorkflowName(name: string): void;
|
|
|
11
11
|
* Validate that a directory path is safe to create
|
|
12
12
|
*/
|
|
13
13
|
export declare function validateOutputDirectory(outputDir: string): void;
|
|
14
|
+
export declare class InvalidPortError extends Error {
|
|
15
|
+
constructor(envVarName: string, raw: string, reason: string);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse a port number from an env var. Empty string and undefined fall back to
|
|
19
|
+
* the default silently (matching Compose's `${VAR:-default}` semantics).
|
|
20
|
+
* Throws InvalidPortError on anything else: non-numeric, signed, decimal,
|
|
21
|
+
* trailing junk (e.g. "3001abc"), or out of range 1-65535. Throwing (vs
|
|
22
|
+
* warn-and-fallback) prevents CLI/Docker disagreement: Compose reads the same
|
|
23
|
+
* env var via `${VAR:-default}` and uses its own parser, so a CLI fallback
|
|
24
|
+
* would silently desync from the bound port.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parsePort(raw: string | undefined, defaultPort: number, envVarName: string): number;
|
package/dist/utils/validation.js
CHANGED
|
@@ -23,3 +23,34 @@ export function validateOutputDirectory(outputDir) {
|
|
|
23
23
|
throw new InvalidOutputDirectoryError(outputDir, 'Output directory cannot be empty');
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
+
const MIN_PORT = 1;
|
|
27
|
+
const MAX_PORT = 65535;
|
|
28
|
+
export class InvalidPortError extends Error {
|
|
29
|
+
constructor(envVarName, raw, reason) {
|
|
30
|
+
super(`${envVarName}=${raw} is invalid (${reason}). ` +
|
|
31
|
+
`Set a port in ${MIN_PORT}-${MAX_PORT} in your .env file, or unset the variable to use the default.`);
|
|
32
|
+
this.name = 'InvalidPortError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse a port number from an env var. Empty string and undefined fall back to
|
|
37
|
+
* the default silently (matching Compose's `${VAR:-default}` semantics).
|
|
38
|
+
* Throws InvalidPortError on anything else: non-numeric, signed, decimal,
|
|
39
|
+
* trailing junk (e.g. "3001abc"), or out of range 1-65535. Throwing (vs
|
|
40
|
+
* warn-and-fallback) prevents CLI/Docker disagreement: Compose reads the same
|
|
41
|
+
* env var via `${VAR:-default}` and uses its own parser, so a CLI fallback
|
|
42
|
+
* would silently desync from the bound port.
|
|
43
|
+
*/
|
|
44
|
+
export function parsePort(raw, defaultPort, envVarName) {
|
|
45
|
+
if (raw === undefined || raw === '') {
|
|
46
|
+
return defaultPort;
|
|
47
|
+
}
|
|
48
|
+
if (!/^\d+$/.test(raw)) {
|
|
49
|
+
throw new InvalidPortError(envVarName, raw, 'not a positive integer');
|
|
50
|
+
}
|
|
51
|
+
const n = parseInt(raw, 10);
|
|
52
|
+
if (n < MIN_PORT || n > MAX_PORT) {
|
|
53
|
+
throw new InvalidPortError(envVarName, raw, `out of range ${MIN_PORT}-${MAX_PORT}`);
|
|
54
|
+
}
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { isValidWorkflowName } from './validation.js';
|
|
2
|
+
import { isValidWorkflowName, parsePort, InvalidPortError } from './validation.js';
|
|
3
3
|
describe('isValidWorkflowName', () => {
|
|
4
4
|
describe('valid workflow names', () => {
|
|
5
5
|
it('should accept single letter', () => {
|
|
@@ -138,3 +138,49 @@ describe('isValidWorkflowName', () => {
|
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
|
+
describe('parsePort', () => {
|
|
142
|
+
it('returns the parsed value for a valid port', () => {
|
|
143
|
+
expect(parsePort('3001', 3001, 'P')).toBe(3001);
|
|
144
|
+
expect(parsePort('7234', 7233, 'P')).toBe(7234);
|
|
145
|
+
});
|
|
146
|
+
it('falls back to default for undefined silently', () => {
|
|
147
|
+
expect(parsePort(undefined, 3001, 'P')).toBe(3001);
|
|
148
|
+
});
|
|
149
|
+
it('falls back to default for empty string silently (matches Compose ${VAR:-default})', () => {
|
|
150
|
+
expect(parsePort('', 3001, 'P')).toBe(3001);
|
|
151
|
+
});
|
|
152
|
+
it('throws InvalidPortError for non-numeric input', () => {
|
|
153
|
+
expect(() => parsePort('abc', 3001, 'OUTPUT_API_HOST_PORT')).toThrow(InvalidPortError);
|
|
154
|
+
expect(() => parsePort('abc', 3001, 'OUTPUT_API_HOST_PORT'))
|
|
155
|
+
.toThrow(/OUTPUT_API_HOST_PORT=abc/);
|
|
156
|
+
});
|
|
157
|
+
it('throws for trailing junk (parseInt would silently truncate)', () => {
|
|
158
|
+
expect(() => parsePort('3001abc', 3001, 'P')).toThrow(InvalidPortError);
|
|
159
|
+
});
|
|
160
|
+
it('throws for negative input', () => {
|
|
161
|
+
expect(() => parsePort('-1', 3001, 'P')).toThrow(InvalidPortError);
|
|
162
|
+
});
|
|
163
|
+
it('throws for port 0 (CLI rejects but Compose would treat as ephemeral - prevents desync)', () => {
|
|
164
|
+
expect(() => parsePort('0', 3001, 'P')).toThrow(InvalidPortError);
|
|
165
|
+
});
|
|
166
|
+
it('throws for port above 65535', () => {
|
|
167
|
+
expect(() => parsePort('65536', 3001, 'P')).toThrow(InvalidPortError);
|
|
168
|
+
expect(() => parsePort('99999', 3001, 'P')).toThrow(InvalidPortError);
|
|
169
|
+
});
|
|
170
|
+
it('accepts boundary ports 1 and 65535', () => {
|
|
171
|
+
expect(parsePort('1', 3001, 'P')).toBe(1);
|
|
172
|
+
expect(parsePort('65535', 3001, 'P')).toBe(65535);
|
|
173
|
+
});
|
|
174
|
+
it('error message includes env var name and remediation hint', () => {
|
|
175
|
+
try {
|
|
176
|
+
parsePort('abc', 3001, 'OUTPUT_API_HOST_PORT');
|
|
177
|
+
expect.fail('expected throw');
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
expect(err).toBeInstanceOf(InvalidPortError);
|
|
181
|
+
expect(err.message).toContain('OUTPUT_API_HOST_PORT=abc');
|
|
182
|
+
expect(err.message).toContain('1-65535');
|
|
183
|
+
expect(err.message).toContain('.env file');
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
package/dist/views/dev.js
CHANGED
|
@@ -138,10 +138,10 @@ const DevSuccessMessage = ({ services }) => {
|
|
|
138
138
|
const divider = '─'.repeat(80);
|
|
139
139
|
const sortedNames = services.map(s => s.name).sort().join('|');
|
|
140
140
|
const logsCommand = `docker compose -p ${config.dockerServiceName} logs -f <${sortedNames}>`;
|
|
141
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: '✅ SUCCESS! ' }), _jsx(Text, { bold: true, children: "Development services are running" })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\uD83D\uDC33 SERVICES" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Temporal
|
|
141
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: '✅ SUCCESS! ' }), _jsx(Text, { bold: true, children: "Development services are running" })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\uD83D\uDC33 SERVICES" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Temporal UI: ' }), _jsx(Text, { color: "cyan", children: config.temporalUiUrl })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'API Server: ' }), _jsxs(Text, { color: "yellow", children: ["localhost:", config.ports.api] })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\uD83D\uDE80 RUN A WORKFLOW" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsx(Text, { color: "white", children: "In a new terminal, execute:" }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "cyan", children: "npx output workflow run blog_evaluator paulgraham_hwh" }) })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\u26A1 USEFUL COMMANDS" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Open Temporal UI: ' }), _jsxs(Text, { color: "cyan", children: ["open ", config.temporalUiUrl] })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'View logs: ' }), _jsx(Text, { color: "cyan", children: logsCommand })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Stop services: ' }), _jsx(Text, { color: "cyan", children: "Press Ctrl+C" })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Text, { dimColor: true, children: "\uD83D\uDCA1 Tip: The Temporal UI lets you monitor workflow executions in real-time" })] }));
|
|
142
142
|
};
|
|
143
143
|
const WaitingView = ({ services }) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Waiting for services to become healthy..." })] }), services.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }))] }));
|
|
144
|
-
const RunningView = ({ services, workflowSummary }) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "\uD83D\uDCCA Service Status" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }), _jsx(FailureWarning, { services: services }), workflowSummary && _jsx(WorkflowSummarySection, { summary: workflowSummary }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '🌐 Temporal UI: ' }), _jsx(Text, { bold: true, children:
|
|
144
|
+
const RunningView = ({ services, workflowSummary }) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "\uD83D\uDCCA Service Status" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }), _jsx(FailureWarning, { services: services }), workflowSummary && _jsx(WorkflowSummarySection, { summary: workflowSummary }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '🌐 Temporal UI: ' }), _jsx(Text, { bold: true, children: config.temporalUiUrl })] }), _jsx(CommandFooter, { hints: [
|
|
145
145
|
{ key: 'o', label: 'open ui' },
|
|
146
146
|
{ key: 'w', label: 'view workflow runs' },
|
|
147
147
|
{ key: 'ctrl+c', label: 'stop' }
|
|
@@ -173,7 +173,7 @@ export const DevApp = ({ dockerComposePath, onCleanup }) => {
|
|
|
173
173
|
useStatusRefresh(dockerComposePath, phase === 'running', setServices);
|
|
174
174
|
useWorkflowPolling(phase === 'running' || phase === 'failed', setWorkflowRuns);
|
|
175
175
|
useMainViewInput(activeView === 'main' && phase !== 'waiting', {
|
|
176
|
-
onOpenTemporal: () => openUrl(
|
|
176
|
+
onOpenTemporal: () => openUrl(config.temporalUiUrl),
|
|
177
177
|
onOpenWorkflows: () => setActiveView('workflows')
|
|
178
178
|
});
|
|
179
179
|
useCtrlC(onCleanup);
|
|
@@ -7,7 +7,7 @@ import { StatusIcon, statusColor } from '#components/status_icon.js';
|
|
|
7
7
|
import { elapsedMs, formatDurationCompact } from '#utils/date_formatter.js';
|
|
8
8
|
import { CommandFooter } from '#components/command_footer.js';
|
|
9
9
|
import { openUrl } from '#utils/open_url.js';
|
|
10
|
-
|
|
10
|
+
import { config } from '#config.js';
|
|
11
11
|
const VISIBLE_ROWS = 15;
|
|
12
12
|
const STATUS_ORDER = {
|
|
13
13
|
running: 0,
|
|
@@ -108,7 +108,7 @@ export const WorkflowListView = ({ runs, onBack }) => {
|
|
|
108
108
|
onBack();
|
|
109
109
|
}
|
|
110
110
|
else if (input === 'o' && selectedWorkflowId) {
|
|
111
|
-
openUrl(`${
|
|
111
|
+
openUrl(`${config.temporalUiUrl}/namespaces/default/workflows/${selectedWorkflowId}`);
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
const windowStart = useMemo(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.2.1-next.
|
|
3
|
+
"version": "0.2.1-next.c3a8722.0",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@anthropic-ai/claude-agent-sdk": "0.2.92",
|
|
18
|
-
"@aws-sdk/client-s3": "3.
|
|
18
|
+
"@aws-sdk/client-s3": "3.1038.0",
|
|
19
19
|
"@hackylabs/deep-redact": "3.0.5",
|
|
20
|
-
"@inquirer/prompts": "8.4.
|
|
21
|
-
"@oclif/core": "4.10.
|
|
22
|
-
"@oclif/plugin-help": "6.2.
|
|
20
|
+
"@inquirer/prompts": "8.4.2",
|
|
21
|
+
"@oclif/core": "4.10.6",
|
|
22
|
+
"@oclif/plugin-help": "6.2.45",
|
|
23
23
|
"change-case": "5.4.4",
|
|
24
24
|
"cli-progress": "3.12.0",
|
|
25
25
|
"cli-table3": "0.6.5",
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
"ky": "1.14.3",
|
|
35
35
|
"react": "19.2.5",
|
|
36
36
|
"semver": "7.7.4",
|
|
37
|
-
"undici": "8.0
|
|
37
|
+
"undici": "8.1.0",
|
|
38
38
|
"yaml": "^2.8.3",
|
|
39
|
-
"@outputai/
|
|
40
|
-
"@outputai/
|
|
41
|
-
"@outputai/
|
|
39
|
+
"@outputai/evals": "0.2.1-next.c3a8722.0",
|
|
40
|
+
"@outputai/llm": "0.2.1-next.c3a8722.0",
|
|
41
|
+
"@outputai/credentials": "0.2.1-next.c3a8722.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/cli-progress": "3.11.6",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@types/js-yaml": "4.0.9",
|
|
47
47
|
"@types/react": "19.2.14",
|
|
48
48
|
"@types/semver": "7.7.1",
|
|
49
|
-
"orval": "8.
|
|
49
|
+
"orval": "8.9.0"
|
|
50
50
|
},
|
|
51
51
|
"license": "Apache-2.0",
|
|
52
52
|
"publishConfig": {
|