@outputai/cli 0.1.13-next.91c5d78.0 → 0.1.13-next.934347c.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/generated/api.d.ts +322 -84
- package/dist/api/generated/api.js +67 -9
- package/dist/assets/docker/docker-compose-dev.yml +2 -2
- package/dist/commands/credentials/set.d.ts +16 -0
- package/dist/commands/credentials/set.js +125 -0
- package/dist/commands/credentials/set.spec.d.ts +1 -0
- package/dist/commands/credentials/set.spec.js +160 -0
- package/dist/commands/migrate.d.ts +11 -0
- package/dist/commands/migrate.js +40 -0
- package/dist/commands/workflow/list.d.ts +1 -0
- package/dist/commands/workflow/list.js +12 -6
- package/dist/commands/workflow/list.spec.js +21 -0
- package/dist/commands/workflow/plan.js +1 -1
- package/dist/commands/workflow/run.js +2 -1
- package/dist/commands/workflow/run.spec.js +1 -0
- package/dist/commands/workflow/start.js +2 -1
- package/dist/commands/workflow/start.spec.js +1 -0
- package/dist/commands/workflow/test_eval.js +2 -2
- package/dist/components/command_footer.d.ts +8 -0
- package/dist/components/command_footer.js +4 -0
- package/dist/components/status_icon.d.ts +11 -0
- package/dist/components/status_icon.js +25 -0
- package/dist/components/workflow_summary.d.ts +10 -0
- package/dist/components/workflow_summary.js +4 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/claude_client.d.ts +14 -2
- package/dist/services/claude_client.integration.test.js +2 -2
- package/dist/services/claude_client.js +42 -6
- package/dist/services/claude_client.spec.js +3 -3
- package/dist/services/datasets.d.ts +1 -1
- package/dist/services/datasets.js +41 -37
- package/dist/services/datasets.test.d.ts +1 -0
- package/dist/services/datasets.test.js +202 -0
- package/dist/services/workflow_builder.d.ts +1 -1
- package/dist/services/workflow_builder.js +1 -1
- package/dist/utils/date_formatter.d.ts +11 -1
- package/dist/utils/date_formatter.js +26 -1
- package/dist/utils/format_workflow_result.d.ts +3 -3
- package/dist/utils/open_url.d.ts +1 -0
- package/dist/utils/open_url.js +12 -0
- package/dist/views/dev.js +62 -26
- package/dist/views/workflow/list.d.ts +6 -0
- package/dist/views/workflow/list.js +127 -0
- package/package.json +11 -11
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generated by orval v8.
|
|
2
|
+
* Generated by orval v8.8.0 🍺
|
|
3
3
|
* Do not edit manually.
|
|
4
4
|
* Output.ai API
|
|
5
5
|
* API for managing and executing Output.ai workflows
|
|
@@ -24,11 +24,7 @@ export const WorkflowRunInfoStatus = {
|
|
|
24
24
|
timed_out: 'timed_out',
|
|
25
25
|
continued: 'continued',
|
|
26
26
|
};
|
|
27
|
-
export const
|
|
28
|
-
completed: 'completed',
|
|
29
|
-
failed: 'failed',
|
|
30
|
-
};
|
|
31
|
-
export const GetWorkflowIdStatus200Status = {
|
|
27
|
+
export const WorkflowStatusResponseStatus = {
|
|
32
28
|
canceled: 'canceled',
|
|
33
29
|
completed: 'completed',
|
|
34
30
|
continued_as_new: 'continued_as_new',
|
|
@@ -38,7 +34,7 @@ export const GetWorkflowIdStatus200Status = {
|
|
|
38
34
|
timed_out: 'timed_out',
|
|
39
35
|
unspecified: 'unspecified',
|
|
40
36
|
};
|
|
41
|
-
export const
|
|
37
|
+
export const WorkflowResultResponseStatus = {
|
|
42
38
|
completed: 'completed',
|
|
43
39
|
failed: 'failed',
|
|
44
40
|
canceled: 'canceled',
|
|
@@ -46,6 +42,10 @@ export const GetWorkflowIdResult200Status = {
|
|
|
46
42
|
timed_out: 'timed_out',
|
|
47
43
|
continued: 'continued',
|
|
48
44
|
};
|
|
45
|
+
export const PostWorkflowRun200Status = {
|
|
46
|
+
completed: 'completed',
|
|
47
|
+
failed: 'failed',
|
|
48
|
+
};
|
|
49
49
|
;
|
|
50
50
|
export const getGetHealthUrl = () => {
|
|
51
51
|
return `/health`;
|
|
@@ -87,6 +87,24 @@ export const getWorkflowIdStatus = async (id, options) => {
|
|
|
87
87
|
method: 'GET'
|
|
88
88
|
});
|
|
89
89
|
};
|
|
90
|
+
export const getGetWorkflowIdRunsRidStatusUrl = (id, rid) => {
|
|
91
|
+
return `/workflow/${id}/runs/${rid}/status`;
|
|
92
|
+
};
|
|
93
|
+
export const getWorkflowIdRunsRidStatus = async (id, rid, options) => {
|
|
94
|
+
return customFetchInstance(getGetWorkflowIdRunsRidStatusUrl(id, rid), {
|
|
95
|
+
...options,
|
|
96
|
+
method: 'GET'
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
export const getPatchWorkflowIdRunsRidStopUrl = (id, rid) => {
|
|
100
|
+
return `/workflow/${id}/runs/${rid}/stop`;
|
|
101
|
+
};
|
|
102
|
+
export const patchWorkflowIdRunsRidStop = async (id, rid, options) => {
|
|
103
|
+
return customFetchInstance(getPatchWorkflowIdRunsRidStopUrl(id, rid), {
|
|
104
|
+
...options,
|
|
105
|
+
method: 'PATCH'
|
|
106
|
+
});
|
|
107
|
+
};
|
|
90
108
|
export const getPatchWorkflowIdStopUrl = (id) => {
|
|
91
109
|
return `/workflow/${id}/stop`;
|
|
92
110
|
};
|
|
@@ -96,6 +114,17 @@ export const patchWorkflowIdStop = async (id, options) => {
|
|
|
96
114
|
method: 'PATCH'
|
|
97
115
|
});
|
|
98
116
|
};
|
|
117
|
+
export const getPostWorkflowIdRunsRidTerminateUrl = (id, rid) => {
|
|
118
|
+
return `/workflow/${id}/runs/${rid}/terminate`;
|
|
119
|
+
};
|
|
120
|
+
export const postWorkflowIdRunsRidTerminate = async (id, rid, postWorkflowIdRunsRidTerminateBody, options) => {
|
|
121
|
+
return customFetchInstance(getPostWorkflowIdRunsRidTerminateUrl(id, rid), {
|
|
122
|
+
...options,
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
|
125
|
+
body: JSON.stringify(postWorkflowIdRunsRidTerminateBody)
|
|
126
|
+
});
|
|
127
|
+
};
|
|
99
128
|
export const getPostWorkflowIdTerminateUrl = (id) => {
|
|
100
129
|
return `/workflow/${id}/terminate`;
|
|
101
130
|
};
|
|
@@ -107,15 +136,26 @@ export const postWorkflowIdTerminate = async (id, postWorkflowIdTerminateBody, o
|
|
|
107
136
|
body: JSON.stringify(postWorkflowIdTerminateBody)
|
|
108
137
|
});
|
|
109
138
|
};
|
|
139
|
+
export const getPostWorkflowIdRunsRidResetUrl = (id, rid) => {
|
|
140
|
+
return `/workflow/${id}/runs/${rid}/reset`;
|
|
141
|
+
};
|
|
142
|
+
export const postWorkflowIdRunsRidReset = async (id, rid, resetWorkflowRequest, options) => {
|
|
143
|
+
return customFetchInstance(getPostWorkflowIdRunsRidResetUrl(id, rid), {
|
|
144
|
+
...options,
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
|
147
|
+
body: JSON.stringify(resetWorkflowRequest)
|
|
148
|
+
});
|
|
149
|
+
};
|
|
110
150
|
export const getPostWorkflowIdResetUrl = (id) => {
|
|
111
151
|
return `/workflow/${id}/reset`;
|
|
112
152
|
};
|
|
113
|
-
export const postWorkflowIdReset = async (id,
|
|
153
|
+
export const postWorkflowIdReset = async (id, resetWorkflowRequest, options) => {
|
|
114
154
|
return customFetchInstance(getPostWorkflowIdResetUrl(id), {
|
|
115
155
|
...options,
|
|
116
156
|
method: 'POST',
|
|
117
157
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
|
118
|
-
body: JSON.stringify(
|
|
158
|
+
body: JSON.stringify(resetWorkflowRequest)
|
|
119
159
|
});
|
|
120
160
|
};
|
|
121
161
|
export const getGetWorkflowIdResultUrl = (id) => {
|
|
@@ -127,6 +167,15 @@ export const getWorkflowIdResult = async (id, options) => {
|
|
|
127
167
|
method: 'GET'
|
|
128
168
|
});
|
|
129
169
|
};
|
|
170
|
+
export const getGetWorkflowIdRunsRidResultUrl = (id, rid) => {
|
|
171
|
+
return `/workflow/${id}/runs/${rid}/result`;
|
|
172
|
+
};
|
|
173
|
+
export const getWorkflowIdRunsRidResult = async (id, rid, options) => {
|
|
174
|
+
return customFetchInstance(getGetWorkflowIdRunsRidResultUrl(id, rid), {
|
|
175
|
+
...options,
|
|
176
|
+
method: 'GET'
|
|
177
|
+
});
|
|
178
|
+
};
|
|
130
179
|
export const getGetWorkflowIdTraceLogUrl = (id) => {
|
|
131
180
|
return `/workflow/${id}/trace-log`;
|
|
132
181
|
};
|
|
@@ -136,6 +185,15 @@ export const getWorkflowIdTraceLog = async (id, options) => {
|
|
|
136
185
|
method: 'GET'
|
|
137
186
|
});
|
|
138
187
|
};
|
|
188
|
+
export const getGetWorkflowIdRunsRidTraceLogUrl = (id, rid) => {
|
|
189
|
+
return `/workflow/${id}/runs/${rid}/trace-log`;
|
|
190
|
+
};
|
|
191
|
+
export const getWorkflowIdRunsRidTraceLog = async (id, rid, options) => {
|
|
192
|
+
return customFetchInstance(getGetWorkflowIdRunsRidTraceLogUrl(id, rid), {
|
|
193
|
+
...options,
|
|
194
|
+
method: 'GET'
|
|
195
|
+
});
|
|
196
|
+
};
|
|
139
197
|
export const getGetWorkflowCatalogIdUrl = (id) => {
|
|
140
198
|
return `/workflow/catalog/${id}`;
|
|
141
199
|
};
|
|
@@ -81,7 +81,7 @@ services:
|
|
|
81
81
|
condition: service_healthy
|
|
82
82
|
worker:
|
|
83
83
|
condition: service_healthy
|
|
84
|
-
image: outputai/api:${OUTPUT_API_VERSION:-0.1.13-next.
|
|
84
|
+
image: outputai/api:${OUTPUT_API_VERSION:-0.1.13-next.934347c.0}
|
|
85
85
|
init: true
|
|
86
86
|
networks:
|
|
87
87
|
- main
|
|
@@ -103,7 +103,7 @@ services:
|
|
|
103
103
|
depends_on:
|
|
104
104
|
temporal:
|
|
105
105
|
condition: service_healthy
|
|
106
|
-
image: node:24.
|
|
106
|
+
image: node:24.15.0-slim
|
|
107
107
|
healthcheck:
|
|
108
108
|
test: [ 'CMD-SHELL', 'npx output-healthcheck' ]
|
|
109
109
|
interval: 3s
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class CredentialsSet extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static args: {
|
|
6
|
+
path: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
value: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
|
+
};
|
|
9
|
+
static flags: {
|
|
10
|
+
environment: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
workflow: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
};
|
|
14
|
+
private confirmOverwrite;
|
|
15
|
+
run(): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import { confirm } from '@inquirer/prompts';
|
|
3
|
+
import { load as parseYaml, dump as stringifyYaml } from 'js-yaml';
|
|
4
|
+
import { getErrorMessage } from '#utils/error_utils.js';
|
|
5
|
+
import { decryptCredentials, credentialsExist, writeEncrypted, resolveCredentialsPath } from '#services/credentials_service.js';
|
|
6
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
const detectPathConflict = (obj, dotPath) => {
|
|
8
|
+
const parts = dotPath.split('.');
|
|
9
|
+
const intermediateKeys = parts.slice(0, -1);
|
|
10
|
+
const leafKey = parts[parts.length - 1];
|
|
11
|
+
const walked = intermediateKeys.reduce((state, key, i) => {
|
|
12
|
+
if (state.done) {
|
|
13
|
+
return state;
|
|
14
|
+
}
|
|
15
|
+
const next = state.cursor[key];
|
|
16
|
+
if (next === undefined) {
|
|
17
|
+
return { done: true, conflict: null };
|
|
18
|
+
}
|
|
19
|
+
if (!isPlainObject(next)) {
|
|
20
|
+
return {
|
|
21
|
+
done: true,
|
|
22
|
+
conflict: {
|
|
23
|
+
kind: 'primitive_to_object',
|
|
24
|
+
atPath: parts.slice(0, i + 1).join('.'),
|
|
25
|
+
existingValue: next
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { done: false, cursor: next };
|
|
30
|
+
}, { done: false, cursor: obj });
|
|
31
|
+
if (walked.done) {
|
|
32
|
+
return walked.conflict;
|
|
33
|
+
}
|
|
34
|
+
const leaf = walked.cursor[leafKey];
|
|
35
|
+
if (leaf !== undefined && (isPlainObject(leaf) || Array.isArray(leaf))) {
|
|
36
|
+
return { kind: 'object_to_primitive', atPath: dotPath, existingValue: leaf };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
const setNestedValue = (obj, dotPath, value) => {
|
|
41
|
+
const parts = dotPath.split('.');
|
|
42
|
+
const parent = parts.slice(0, -1).reduce((current, key) => {
|
|
43
|
+
if (!isPlainObject(current[key])) {
|
|
44
|
+
current[key] = {};
|
|
45
|
+
}
|
|
46
|
+
return current[key];
|
|
47
|
+
}, obj);
|
|
48
|
+
parent[parts[parts.length - 1]] = value;
|
|
49
|
+
};
|
|
50
|
+
export default class CredentialsSet extends Command {
|
|
51
|
+
static description = 'Set a credential value by dot-notation path';
|
|
52
|
+
static examples = [
|
|
53
|
+
'<%= config.bin %> <%= command.id %> anthropic.api_key sk-ant-...',
|
|
54
|
+
'<%= config.bin %> <%= command.id %> openai.api_key sk-... --environment production',
|
|
55
|
+
'<%= config.bin %> <%= command.id %> stripe.key sk_live_... --workflow my_workflow'
|
|
56
|
+
];
|
|
57
|
+
static args = {
|
|
58
|
+
path: Args.string({
|
|
59
|
+
description: 'Dot-notation path to the credential (e.g. anthropic.api_key)',
|
|
60
|
+
required: true
|
|
61
|
+
}),
|
|
62
|
+
value: Args.string({
|
|
63
|
+
description: 'Value to set',
|
|
64
|
+
required: true
|
|
65
|
+
})
|
|
66
|
+
};
|
|
67
|
+
static flags = {
|
|
68
|
+
environment: Flags.string({
|
|
69
|
+
char: 'e',
|
|
70
|
+
description: 'Target environment (e.g. production, development)'
|
|
71
|
+
}),
|
|
72
|
+
workflow: Flags.string({
|
|
73
|
+
char: 'w',
|
|
74
|
+
description: 'Target a specific workflow directory'
|
|
75
|
+
}),
|
|
76
|
+
yes: Flags.boolean({
|
|
77
|
+
char: 'y',
|
|
78
|
+
description: 'Skip confirmation prompts when overwriting a value of a different shape',
|
|
79
|
+
default: false
|
|
80
|
+
})
|
|
81
|
+
};
|
|
82
|
+
async confirmOverwrite(conflict, newPath) {
|
|
83
|
+
if (conflict.kind === 'primitive_to_object') {
|
|
84
|
+
this.warn(`Writing to "${newPath}" will convert "${conflict.atPath}" from a value into an object, ` +
|
|
85
|
+
`discarding its current value (${JSON.stringify(conflict.existingValue)}).`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
this.warn(`Writing to "${newPath}" will replace the existing object at that path ` +
|
|
89
|
+
`(${JSON.stringify(conflict.existingValue)}) with a string value.`);
|
|
90
|
+
}
|
|
91
|
+
return confirm({ message: 'Continue?', default: false });
|
|
92
|
+
}
|
|
93
|
+
async run() {
|
|
94
|
+
const { args, flags } = await this.parse(CredentialsSet);
|
|
95
|
+
const environment = flags.environment;
|
|
96
|
+
const workflow = flags.workflow;
|
|
97
|
+
if (environment && workflow) {
|
|
98
|
+
this.error('Cannot specify both --environment and --workflow.');
|
|
99
|
+
}
|
|
100
|
+
if (!credentialsExist(environment, workflow)) {
|
|
101
|
+
this.error(`No credentials file found at ${resolveCredentialsPath(environment, workflow)}. Run "output credentials init" first.`);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const plaintext = decryptCredentials(environment, workflow);
|
|
105
|
+
const data = (parseYaml(plaintext) || {});
|
|
106
|
+
const conflict = detectPathConflict(data, args.path);
|
|
107
|
+
if (conflict && !flags.yes) {
|
|
108
|
+
const shouldContinue = await this.confirmOverwrite(conflict, args.path);
|
|
109
|
+
if (!shouldContinue) {
|
|
110
|
+
this.log('Aborted.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
setNestedValue(data, args.path, args.value);
|
|
115
|
+
writeEncrypted(environment, stringifyYaml(data), workflow);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof Error && error.constructor.name === 'ExitPromptError') {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.error(`Failed to update credentials: ${getErrorMessage(error)}`);
|
|
122
|
+
}
|
|
123
|
+
this.log(`Set ${args.path}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
import { confirm } from '@inquirer/prompts';
|
|
4
|
+
import * as credentialsService from '#services/credentials_service.js';
|
|
5
|
+
import CredentialsSet from './set.js';
|
|
6
|
+
vi.mock('#services/credentials_service.js');
|
|
7
|
+
vi.mock('@inquirer/prompts', () => ({
|
|
8
|
+
confirm: vi.fn()
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('js-yaml', () => ({
|
|
11
|
+
load: vi.fn((yaml) => {
|
|
12
|
+
if (yaml.includes('__PRIMITIVE_AT_X_Y__')) {
|
|
13
|
+
return { x: { y: 'FOO' } };
|
|
14
|
+
}
|
|
15
|
+
if (yaml.includes('__OBJECT_AT_X_Y__')) {
|
|
16
|
+
return { x: { y: { z: 'FOO' } } };
|
|
17
|
+
}
|
|
18
|
+
if (yaml.includes('sk-existing')) {
|
|
19
|
+
return { anthropic: { api_key: 'sk-existing' } };
|
|
20
|
+
}
|
|
21
|
+
return {};
|
|
22
|
+
}),
|
|
23
|
+
dump: vi.fn((obj) => JSON.stringify(obj))
|
|
24
|
+
}));
|
|
25
|
+
describe('credentials set command', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
vi.mocked(credentialsService.credentialsExist).mockReturnValue(true);
|
|
29
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('anthropic:\n api_key: sk-existing\n');
|
|
30
|
+
vi.mocked(credentialsService.writeEncrypted).mockImplementation(() => { });
|
|
31
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
const createTestCommand = (parsedArgs = {}, flags = {}) => {
|
|
37
|
+
const cmd = new CredentialsSet([], {});
|
|
38
|
+
cmd.log = vi.fn();
|
|
39
|
+
cmd.warn = vi.fn();
|
|
40
|
+
cmd.error = vi.fn((msg) => {
|
|
41
|
+
throw new Error(msg);
|
|
42
|
+
});
|
|
43
|
+
Object.defineProperty(cmd, 'parse', {
|
|
44
|
+
value: vi.fn().mockResolvedValue({
|
|
45
|
+
args: { path: 'anthropic.api_key', value: 'sk-new-key', ...parsedArgs },
|
|
46
|
+
flags: { environment: undefined, workflow: undefined, yes: false, ...flags }
|
|
47
|
+
}),
|
|
48
|
+
configurable: true
|
|
49
|
+
});
|
|
50
|
+
return cmd;
|
|
51
|
+
};
|
|
52
|
+
describe('command structure', () => {
|
|
53
|
+
it('should have correct description', () => {
|
|
54
|
+
expect(CredentialsSet.description).toContain('credential value');
|
|
55
|
+
});
|
|
56
|
+
it('should have required path and value arguments', () => {
|
|
57
|
+
expect(CredentialsSet.args.path).toBeDefined();
|
|
58
|
+
expect(CredentialsSet.args.path.required).toBe(true);
|
|
59
|
+
expect(CredentialsSet.args.value).toBeDefined();
|
|
60
|
+
expect(CredentialsSet.args.value.required).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('should have environment, workflow, and yes flags', () => {
|
|
63
|
+
expect(CredentialsSet.flags.environment).toBeDefined();
|
|
64
|
+
expect(CredentialsSet.flags.workflow).toBeDefined();
|
|
65
|
+
expect(CredentialsSet.flags.yes).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('command execution', () => {
|
|
69
|
+
it('should decrypt, update, and re-encrypt credentials', async () => {
|
|
70
|
+
const cmd = createTestCommand();
|
|
71
|
+
await cmd.run();
|
|
72
|
+
expect(credentialsService.decryptCredentials).toHaveBeenCalledWith(undefined, undefined);
|
|
73
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledWith(undefined, expect.any(String), undefined);
|
|
74
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
75
|
+
expect(cmd.log).toHaveBeenCalledWith('Set anthropic.api_key');
|
|
76
|
+
});
|
|
77
|
+
it('should create nested keys that do not exist', async () => {
|
|
78
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('');
|
|
79
|
+
const cmd = createTestCommand({ path: 'new.nested.key', value: 'my-value' });
|
|
80
|
+
await cmd.run();
|
|
81
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
83
|
+
expect(cmd.log).toHaveBeenCalledWith('Set new.nested.key');
|
|
84
|
+
});
|
|
85
|
+
it('should pass environment flag to service functions', async () => {
|
|
86
|
+
const cmd = createTestCommand({}, { environment: 'production' });
|
|
87
|
+
await cmd.run();
|
|
88
|
+
expect(credentialsService.credentialsExist).toHaveBeenCalledWith('production', undefined);
|
|
89
|
+
expect(credentialsService.decryptCredentials).toHaveBeenCalledWith('production', undefined);
|
|
90
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledWith('production', expect.any(String), undefined);
|
|
91
|
+
});
|
|
92
|
+
it('should pass workflow flag to service functions', async () => {
|
|
93
|
+
const cmd = createTestCommand({}, { workflow: 'my_workflow' });
|
|
94
|
+
await cmd.run();
|
|
95
|
+
expect(credentialsService.credentialsExist).toHaveBeenCalledWith(undefined, 'my_workflow');
|
|
96
|
+
expect(credentialsService.decryptCredentials).toHaveBeenCalledWith(undefined, 'my_workflow');
|
|
97
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledWith(undefined, expect.any(String), 'my_workflow');
|
|
98
|
+
});
|
|
99
|
+
it('should error when both environment and workflow are specified', async () => {
|
|
100
|
+
const cmd = createTestCommand({}, { environment: 'production', workflow: 'my_workflow' });
|
|
101
|
+
await expect(cmd.run()).rejects.toThrow('Cannot specify both');
|
|
102
|
+
});
|
|
103
|
+
it('should error when credentials file does not exist', async () => {
|
|
104
|
+
vi.mocked(credentialsService.credentialsExist).mockReturnValue(false);
|
|
105
|
+
vi.mocked(credentialsService.resolveCredentialsPath).mockReturnValue('/project/config/credentials.yml.enc');
|
|
106
|
+
const cmd = createTestCommand();
|
|
107
|
+
await expect(cmd.run()).rejects.toThrow('No credentials file found');
|
|
108
|
+
});
|
|
109
|
+
it('should surface a friendly error when decryption fails', async () => {
|
|
110
|
+
vi.mocked(credentialsService.decryptCredentials).mockImplementation(() => {
|
|
111
|
+
throw new Error('Invalid key');
|
|
112
|
+
});
|
|
113
|
+
const cmd = createTestCommand();
|
|
114
|
+
await expect(cmd.run()).rejects.toThrow('Failed to update credentials: Invalid key');
|
|
115
|
+
});
|
|
116
|
+
it('should surface a friendly error when encryption fails', async () => {
|
|
117
|
+
vi.mocked(credentialsService.writeEncrypted).mockImplementation(() => {
|
|
118
|
+
throw new Error('Permission denied');
|
|
119
|
+
});
|
|
120
|
+
const cmd = createTestCommand();
|
|
121
|
+
await expect(cmd.run()).rejects.toThrow('Failed to update credentials: Permission denied');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('shape-change confirmation', () => {
|
|
125
|
+
it('should prompt before converting a primitive value into an object', async () => {
|
|
126
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('__PRIMITIVE_AT_X_Y__');
|
|
127
|
+
const cmd = createTestCommand({ path: 'x.y.z', value: 'BAR' });
|
|
128
|
+
await cmd.run();
|
|
129
|
+
expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('convert "x.y" from a value into an object'));
|
|
130
|
+
expect(confirm).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(cmd.log).toHaveBeenCalledWith('Set x.y.z');
|
|
133
|
+
});
|
|
134
|
+
it('should prompt before replacing an object with a primitive value', async () => {
|
|
135
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('__OBJECT_AT_X_Y__');
|
|
136
|
+
const cmd = createTestCommand({ path: 'x.y', value: 'BAR' });
|
|
137
|
+
await cmd.run();
|
|
138
|
+
expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('replace the existing object'));
|
|
139
|
+
expect(confirm).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
|
|
141
|
+
});
|
|
142
|
+
it('should abort without writing when the user declines the prompt', async () => {
|
|
143
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('__PRIMITIVE_AT_X_Y__');
|
|
144
|
+
vi.mocked(confirm).mockResolvedValue(false);
|
|
145
|
+
const cmd = createTestCommand({ path: 'x.y.z', value: 'BAR' });
|
|
146
|
+
await cmd.run();
|
|
147
|
+
expect(credentialsService.writeEncrypted).not.toHaveBeenCalled();
|
|
148
|
+
expect(cmd.log).toHaveBeenCalledWith('Aborted.');
|
|
149
|
+
expect(cmd.log).not.toHaveBeenCalledWith('Set x.y.z');
|
|
150
|
+
});
|
|
151
|
+
it('should skip the prompt when --yes is passed', async () => {
|
|
152
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('__PRIMITIVE_AT_X_Y__');
|
|
153
|
+
const cmd = createTestCommand({ path: 'x.y.z', value: 'BAR' }, { yes: true });
|
|
154
|
+
await cmd.run();
|
|
155
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
156
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(cmd.log).toHaveBeenCalledWith('Set x.y.z');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Migrate extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
from: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
to: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
notes: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { ensureOutputAISystem } from '#services/coding_agents.js';
|
|
3
|
+
import { invokeMigrate } from '#services/claude_client.js';
|
|
4
|
+
export default class Migrate extends Command {
|
|
5
|
+
static description = 'Upgrade a project between versions of the Output framework. ' +
|
|
6
|
+
'Fetches the matching migration guide from docs.output.ai and applies it.';
|
|
7
|
+
static examples = [
|
|
8
|
+
'<%= config.bin %> <%= command.id %>',
|
|
9
|
+
'<%= config.bin %> <%= command.id %> --to 0.2.0',
|
|
10
|
+
'<%= config.bin %> <%= command.id %> --from 0.1.12 --to 0.2.0',
|
|
11
|
+
'<%= config.bin %> <%= command.id %> --to 0.2.0 --notes "skip the http changes, we don\'t use that package"'
|
|
12
|
+
];
|
|
13
|
+
static flags = {
|
|
14
|
+
from: Flags.string({
|
|
15
|
+
description: 'Version to migrate from. Defaults to the framework version in your package.json.',
|
|
16
|
+
required: false
|
|
17
|
+
}),
|
|
18
|
+
to: Flags.string({
|
|
19
|
+
description: 'Version to migrate to. Defaults to the latest published version.',
|
|
20
|
+
required: false
|
|
21
|
+
}),
|
|
22
|
+
notes: Flags.string({
|
|
23
|
+
char: 'n',
|
|
24
|
+
description: 'Extra guidance passed through to the migration agent.',
|
|
25
|
+
required: false
|
|
26
|
+
})
|
|
27
|
+
};
|
|
28
|
+
async run() {
|
|
29
|
+
const { flags } = await this.parse(Migrate);
|
|
30
|
+
const projectRoot = process.cwd();
|
|
31
|
+
this.log('Checking .outputai directory structure...');
|
|
32
|
+
await ensureOutputAISystem(projectRoot);
|
|
33
|
+
this.log('\nInvoking the /output-migrate skill...');
|
|
34
|
+
this.log('This may take a moment...\n');
|
|
35
|
+
const summary = await invokeMigrate(flags.from ?? '', flags.to ?? '', flags.notes);
|
|
36
|
+
this.log('\n=========');
|
|
37
|
+
this.log(summary);
|
|
38
|
+
this.log('=========\n');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -17,7 +17,8 @@ export function parseWorkflowForDisplay(workflow) {
|
|
|
17
17
|
description: parsed.description || 'No description',
|
|
18
18
|
inputs: formatParameters(parsed.inputs),
|
|
19
19
|
outputs: formatParameters(parsed.outputs),
|
|
20
|
-
scenarios: scenarioNames.length > 0 ? scenarioNames.join(', ') : 'none'
|
|
20
|
+
scenarios: scenarioNames.length > 0 ? scenarioNames.join(', ') : 'none',
|
|
21
|
+
aliases: workflow.aliases?.length ? workflow.aliases.join(', ') : 'none'
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
function caseInsensitiveIncludes(str, filter) {
|
|
@@ -26,7 +27,10 @@ function caseInsensitiveIncludes(str, filter) {
|
|
|
26
27
|
function matchName(filterString) {
|
|
27
28
|
return workflow => {
|
|
28
29
|
const name = workflow.name || '';
|
|
29
|
-
|
|
30
|
+
if (caseInsensitiveIncludes(name, filterString)) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return (workflow.aliases ?? []).some(alias => caseInsensitiveIncludes(alias, filterString));
|
|
30
34
|
};
|
|
31
35
|
}
|
|
32
36
|
function sortWorkflowsByName(workflows) {
|
|
@@ -38,8 +42,8 @@ function sortWorkflowsByName(workflows) {
|
|
|
38
42
|
}
|
|
39
43
|
function createWorkflowTable(workflows, detailed) {
|
|
40
44
|
const table = new Table({
|
|
41
|
-
head: ['Name', 'Description', 'Inputs', 'Outputs', 'Scenarios'],
|
|
42
|
-
colWidths: detailed ? [
|
|
45
|
+
head: ['Name', 'Description', 'Aliases', 'Inputs', 'Outputs', 'Scenarios'],
|
|
46
|
+
colWidths: detailed ? [28, 36, 36, 36, 36, 48] : [22, 26, 26, 22, 22, 36],
|
|
43
47
|
wordWrap: true,
|
|
44
48
|
style: {
|
|
45
49
|
head: ['cyan']
|
|
@@ -49,13 +53,14 @@ function createWorkflowTable(workflows, detailed) {
|
|
|
49
53
|
sortedWorkflows.forEach(workflow => {
|
|
50
54
|
const display = parseWorkflowForDisplay(workflow);
|
|
51
55
|
if (detailed) {
|
|
56
|
+
const aliases = workflow.aliases?.length ? workflow.aliases.join('\n') : 'none';
|
|
52
57
|
const inputs = display.inputs.split(', ').join('\n');
|
|
53
58
|
const outputs = display.outputs.split(', ').join('\n');
|
|
54
59
|
const scenarios = display.scenarios.split(', ').join('\n');
|
|
55
|
-
table.push([display.name, display.description, inputs, outputs, scenarios]);
|
|
60
|
+
table.push([display.name, display.description, aliases, inputs, outputs, scenarios]);
|
|
56
61
|
}
|
|
57
62
|
else {
|
|
58
|
-
table.push([display.name, display.description, display.inputs, display.outputs, display.scenarios]);
|
|
63
|
+
table.push([display.name, display.description, display.aliases, display.inputs, display.outputs, display.scenarios]);
|
|
59
64
|
}
|
|
60
65
|
});
|
|
61
66
|
return table.toString();
|
|
@@ -72,6 +77,7 @@ function formatWorkflowsAsJson(workflows) {
|
|
|
72
77
|
return {
|
|
73
78
|
name: display.name,
|
|
74
79
|
description: display.description,
|
|
80
|
+
aliases: w.aliases ?? [],
|
|
75
81
|
inputs: display.inputs.split(', '),
|
|
76
82
|
outputs: display.outputs.split(', '),
|
|
77
83
|
scenarios: display.scenarios === 'none' ? [] : display.scenarios.split(', '),
|
|
@@ -64,6 +64,27 @@ describe('workflow list parsing', () => {
|
|
|
64
64
|
expect(parsed.inputs).toBe('none');
|
|
65
65
|
expect(parsed.outputs).toBe('none');
|
|
66
66
|
expect(parsed.scenarios).toBe('none');
|
|
67
|
+
expect(parsed.aliases).toBe('none');
|
|
68
|
+
});
|
|
69
|
+
it('should include aliases when present', async () => {
|
|
70
|
+
const { parseWorkflowForDisplay } = await import('./list.js');
|
|
71
|
+
const mockWorkflow = {
|
|
72
|
+
name: 'aliased-workflow',
|
|
73
|
+
description: 'Has aliases',
|
|
74
|
+
aliases: ['old_name', 'legacy_name']
|
|
75
|
+
};
|
|
76
|
+
const parsed = parseWorkflowForDisplay(mockWorkflow);
|
|
77
|
+
expect(parsed.aliases).toBe('old_name, legacy_name');
|
|
78
|
+
});
|
|
79
|
+
it('should show none when aliases array is empty', async () => {
|
|
80
|
+
const { parseWorkflowForDisplay } = await import('./list.js');
|
|
81
|
+
const mockWorkflow = {
|
|
82
|
+
name: 'no-aliases',
|
|
83
|
+
description: 'Empty aliases',
|
|
84
|
+
aliases: []
|
|
85
|
+
};
|
|
86
|
+
const parsed = parseWorkflowForDisplay(mockWorkflow);
|
|
87
|
+
expect(parsed.aliases).toBe('none');
|
|
67
88
|
});
|
|
68
89
|
it('should include scenario names when scenarios exist', async () => {
|
|
69
90
|
mockListScenarios.mockReturnValueOnce(['basic', 'advanced', 'stress_test']);
|
|
@@ -56,7 +56,7 @@ export default class WorkflowPlan extends Command {
|
|
|
56
56
|
return this.planModificationLoop(modifiedPlanContent);
|
|
57
57
|
}
|
|
58
58
|
async planGenerationLoop(promptDescription, planName, projectRoot) {
|
|
59
|
-
this.log('\nInvoking the /
|
|
59
|
+
this.log('\nInvoking the /output-plan-workflow command...');
|
|
60
60
|
this.log('This may take a moment...\n');
|
|
61
61
|
const planContent = await invokePlanWorkflow(promptDescription);
|
|
62
62
|
const modifiedPlanContent = await this.planModificationLoop(planContent);
|
|
@@ -55,7 +55,8 @@ export default class WorkflowRun extends Command {
|
|
|
55
55
|
}),
|
|
56
56
|
'task-queue': Flags.string({
|
|
57
57
|
char: 'q',
|
|
58
|
-
description: 'Task queue name for workflow execution'
|
|
58
|
+
description: 'Task queue name for workflow execution (defaults to OUTPUT_CATALOG_ID)',
|
|
59
|
+
env: 'OUTPUT_CATALOG_ID'
|
|
59
60
|
}),
|
|
60
61
|
format: Flags.string({
|
|
61
62
|
char: 'f',
|
|
@@ -13,6 +13,7 @@ vi.mock('#utils/sleep.js', () => ({
|
|
|
13
13
|
describe('workflow run command', () => {
|
|
14
14
|
beforeEach(async () => {
|
|
15
15
|
vi.clearAllMocks();
|
|
16
|
+
delete process.env.OUTPUT_CATALOG_ID;
|
|
16
17
|
const { resolveInput } = await import('#utils/resolve_input.js');
|
|
17
18
|
const { sleep } = await import('#utils/sleep.js');
|
|
18
19
|
vi.mocked(resolveInput).mockResolvedValue({});
|
|
@@ -28,7 +28,8 @@ export default class WorkflowStart extends Command {
|
|
|
28
28
|
}),
|
|
29
29
|
'task-queue': Flags.string({
|
|
30
30
|
char: 'q',
|
|
31
|
-
description: 'Task queue name for workflow execution'
|
|
31
|
+
description: 'Task queue name for workflow execution (defaults to OUTPUT_CATALOG_ID)',
|
|
32
|
+
env: 'OUTPUT_CATALOG_ID'
|
|
32
33
|
})
|
|
33
34
|
};
|
|
34
35
|
async run() {
|
|
@@ -5,6 +5,7 @@ vi.mock('../../api/generated/api.js', () => ({
|
|
|
5
5
|
describe('workflow start command', () => {
|
|
6
6
|
beforeEach(() => {
|
|
7
7
|
vi.clearAllMocks();
|
|
8
|
+
delete process.env.OUTPUT_CATALOG_ID;
|
|
8
9
|
});
|
|
9
10
|
describe('command definition', () => {
|
|
10
11
|
it('should export a valid OCLIF command', async () => {
|