cli4ai 1.2.0 → 1.2.1
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/README.md +39 -0
- package/dist/bin.d.ts +6 -0
- package/dist/bin.js +105 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +335 -0
- package/dist/commands/add.d.ts +11 -0
- package/dist/commands/add.js +459 -0
- package/dist/commands/browse.d.ts +4 -0
- package/dist/commands/browse.js +379 -0
- package/dist/commands/config.d.ts +10 -0
- package/dist/commands/config.js +121 -0
- package/dist/commands/info.d.ts +9 -0
- package/dist/commands/info.js +122 -0
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +458 -0
- package/dist/commands/list.d.ts +10 -0
- package/dist/commands/list.js +76 -0
- package/dist/commands/mcp-config.d.ts +10 -0
- package/dist/commands/mcp-config.js +49 -0
- package/dist/commands/remotes.d.ts +22 -0
- package/dist/commands/remotes.js +196 -0
- package/dist/commands/remove.d.ts +8 -0
- package/dist/commands/remove.js +61 -0
- package/dist/commands/routines.d.ts +29 -0
- package/dist/commands/routines.js +363 -0
- package/dist/commands/run.d.ts +12 -0
- package/dist/commands/run.js +104 -0
- package/dist/commands/scheduler.d.ts +27 -0
- package/dist/commands/scheduler.js +350 -0
- package/dist/commands/search.d.ts +9 -0
- package/dist/commands/search.js +159 -0
- package/dist/commands/secrets.d.ts +28 -0
- package/dist/commands/secrets.js +236 -0
- package/dist/commands/serve.d.ts +13 -0
- package/dist/commands/serve.js +49 -0
- package/dist/commands/start.d.ts +8 -0
- package/dist/commands/start.js +27 -0
- package/dist/commands/update.d.ts +17 -0
- package/dist/commands/update.js +210 -0
- package/dist/core/config.d.ts +91 -0
- package/dist/core/config.js +738 -0
- package/dist/core/execute.d.ts +51 -0
- package/dist/core/execute.js +475 -0
- package/dist/core/link.d.ts +39 -0
- package/dist/core/link.js +214 -0
- package/dist/core/lockfile.d.ts +63 -0
- package/dist/core/lockfile.js +140 -0
- package/dist/core/manifest.d.ts +96 -0
- package/dist/core/manifest.js +224 -0
- package/dist/core/registry.d.ts +74 -0
- package/dist/core/registry.js +116 -0
- package/dist/core/remote-client.d.ts +98 -0
- package/dist/core/remote-client.js +252 -0
- package/dist/core/remotes.d.ts +88 -0
- package/dist/core/remotes.js +206 -0
- package/dist/core/routine-engine.d.ts +124 -0
- package/dist/core/routine-engine.js +699 -0
- package/dist/core/routines.d.ts +36 -0
- package/dist/core/routines.js +132 -0
- package/dist/core/scheduler-daemon.d.ts +10 -0
- package/dist/core/scheduler-daemon.js +77 -0
- package/dist/core/scheduler.d.ts +131 -0
- package/dist/core/scheduler.js +492 -0
- package/dist/core/secrets.d.ts +48 -0
- package/dist/core/secrets.js +384 -0
- package/dist/lib/cli.d.ts +84 -0
- package/dist/lib/cli.js +216 -0
- package/dist/mcp/adapter.d.ts +35 -0
- package/dist/mcp/adapter.js +94 -0
- package/dist/mcp/config-gen.d.ts +31 -0
- package/dist/mcp/config-gen.js +75 -0
- package/dist/mcp/server.d.ts +41 -0
- package/dist/mcp/server.js +296 -0
- package/dist/server/service.d.ts +85 -0
- package/dist/server/service.js +304 -0
- package/package.json +6 -3
- package/src/bin.ts +0 -118
- package/src/cli.ts +0 -412
- package/src/commands/add.ts +0 -562
- package/src/commands/browse.ts +0 -449
- package/src/commands/config.ts +0 -154
- package/src/commands/info.ts +0 -133
- package/src/commands/init.ts +0 -514
- package/src/commands/list.ts +0 -95
- package/src/commands/mcp-config.ts +0 -69
- package/src/commands/remotes.ts +0 -253
- package/src/commands/remove.ts +0 -78
- package/src/commands/routines.ts +0 -427
- package/src/commands/run.ts +0 -127
- package/src/commands/scheduler.ts +0 -438
- package/src/commands/search.ts +0 -185
- package/src/commands/secrets.ts +0 -292
- package/src/commands/serve.ts +0 -66
- package/src/commands/start.ts +0 -40
- package/src/commands/update.ts +0 -252
- package/src/core/config.ts +0 -845
- package/src/core/execute.ts +0 -569
- package/src/core/link.ts +0 -246
- package/src/core/lockfile.ts +0 -187
- package/src/core/manifest.ts +0 -327
- package/src/core/registry.ts +0 -165
- package/src/core/remote-client.ts +0 -419
- package/src/core/remotes.ts +0 -268
- package/src/core/routine-engine.ts +0 -895
- package/src/core/routines.ts +0 -171
- package/src/core/scheduler-daemon.ts +0 -94
- package/src/core/scheduler.ts +0 -606
- package/src/core/secrets.ts +0 -430
- package/src/lib/cli.ts +0 -261
- package/src/mcp/adapter.ts +0 -131
- package/src/mcp/config-gen.ts +0 -106
- package/src/mcp/server.ts +0 -365
- package/src/server/service.ts +0 -434
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured routine runner (v1).
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import { parse as parseYaml } from 'yaml';
|
|
7
|
+
import { executeTool, ExecuteToolError } from './execute.js';
|
|
8
|
+
import { remoteRunTool, RemoteConnectionError, RemoteApiError } from './remote-client.js';
|
|
9
|
+
import { getRemote } from './remotes.js';
|
|
10
|
+
export class RoutineParseError extends Error {
|
|
11
|
+
path;
|
|
12
|
+
constructor(path, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.path = path;
|
|
15
|
+
this.name = 'RoutineParseError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class RoutineValidationError extends Error {
|
|
19
|
+
details;
|
|
20
|
+
constructor(message, details) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.details = details;
|
|
23
|
+
this.name = 'RoutineValidationError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class RoutineTemplateError extends Error {
|
|
27
|
+
details;
|
|
28
|
+
constructor(message, details) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.details = details;
|
|
31
|
+
this.name = 'RoutineTemplateError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function loadRoutineDefinition(path) {
|
|
35
|
+
let content;
|
|
36
|
+
try {
|
|
37
|
+
content = readFileSync(path, 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
throw new RoutineParseError(path, `Failed to read: ${err instanceof Error ? err.message : String(err)}`);
|
|
41
|
+
}
|
|
42
|
+
const isYaml = path.endsWith('.yaml') || path.endsWith('.yml');
|
|
43
|
+
let data;
|
|
44
|
+
try {
|
|
45
|
+
data = isYaml ? parseYaml(content) : JSON.parse(content);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
const format = isYaml ? 'YAML' : 'JSON';
|
|
49
|
+
throw new RoutineParseError(path, `Invalid ${format}: ${err instanceof Error ? err.message : String(err)}`);
|
|
50
|
+
}
|
|
51
|
+
return validateRoutineDefinition(data, path);
|
|
52
|
+
}
|
|
53
|
+
const INTERVAL_PATTERN = /^(\d+)(s|m|h|d)$/;
|
|
54
|
+
/**
|
|
55
|
+
* Validate a schedule configuration.
|
|
56
|
+
* Exported for use by scheduler and tests.
|
|
57
|
+
*/
|
|
58
|
+
export function validateScheduleConfig(schedule, source) {
|
|
59
|
+
if (!schedule || typeof schedule !== 'object') {
|
|
60
|
+
throw new RoutineValidationError('Schedule must be an object', { source });
|
|
61
|
+
}
|
|
62
|
+
const s = schedule;
|
|
63
|
+
// Must have either cron or interval (or both)
|
|
64
|
+
if (s.cron === undefined && s.interval === undefined) {
|
|
65
|
+
throw new RoutineValidationError('Schedule must have "cron" or "interval"', { source });
|
|
66
|
+
}
|
|
67
|
+
// Validate cron (basic format check - full validation happens at runtime with cron-parser)
|
|
68
|
+
if (s.cron !== undefined) {
|
|
69
|
+
if (typeof s.cron !== 'string' || s.cron.trim().length === 0) {
|
|
70
|
+
throw new RoutineValidationError('Schedule "cron" must be a non-empty string', { source, got: s.cron });
|
|
71
|
+
}
|
|
72
|
+
// Basic cron format: should have 5 space-separated parts
|
|
73
|
+
const parts = s.cron.trim().split(/\s+/);
|
|
74
|
+
if (parts.length < 5 || parts.length > 6) {
|
|
75
|
+
throw new RoutineValidationError('Schedule "cron" must have 5 or 6 fields', { source, got: s.cron });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Validate interval
|
|
79
|
+
if (s.interval !== undefined) {
|
|
80
|
+
if (typeof s.interval !== 'string') {
|
|
81
|
+
throw new RoutineValidationError('Schedule "interval" must be a string', { source, got: s.interval });
|
|
82
|
+
}
|
|
83
|
+
if (!INTERVAL_PATTERN.test(s.interval)) {
|
|
84
|
+
throw new RoutineValidationError('Schedule "interval" must be like "30s", "5m", "1h", or "1d"', { source, got: s.interval });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Validate timezone (just check it's a string - actual validation happens at runtime)
|
|
88
|
+
if (s.timezone !== undefined && typeof s.timezone !== 'string') {
|
|
89
|
+
throw new RoutineValidationError('Schedule "timezone" must be a string', { source, got: s.timezone });
|
|
90
|
+
}
|
|
91
|
+
// Validate enabled
|
|
92
|
+
if (s.enabled !== undefined && typeof s.enabled !== 'boolean') {
|
|
93
|
+
throw new RoutineValidationError('Schedule "enabled" must be a boolean', { source, got: s.enabled });
|
|
94
|
+
}
|
|
95
|
+
// Validate retries
|
|
96
|
+
if (s.retries !== undefined) {
|
|
97
|
+
if (typeof s.retries !== 'number' || !Number.isInteger(s.retries) || s.retries < 0) {
|
|
98
|
+
throw new RoutineValidationError('Schedule "retries" must be a non-negative integer', { source, got: s.retries });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Validate retryDelayMs
|
|
102
|
+
if (s.retryDelayMs !== undefined) {
|
|
103
|
+
if (typeof s.retryDelayMs !== 'number' || !Number.isInteger(s.retryDelayMs) || s.retryDelayMs < 0) {
|
|
104
|
+
throw new RoutineValidationError('Schedule "retryDelayMs" must be a non-negative integer', { source, got: s.retryDelayMs });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Validate concurrency
|
|
108
|
+
if (s.concurrency !== undefined) {
|
|
109
|
+
if (s.concurrency !== 'skip' && s.concurrency !== 'queue') {
|
|
110
|
+
throw new RoutineValidationError('Schedule "concurrency" must be "skip" or "queue"', { source, got: s.concurrency });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return s;
|
|
114
|
+
}
|
|
115
|
+
function validateRoutineDefinition(value, source) {
|
|
116
|
+
if (!value || typeof value !== 'object') {
|
|
117
|
+
throw new RoutineValidationError('Routine must be an object', { source });
|
|
118
|
+
}
|
|
119
|
+
const obj = value;
|
|
120
|
+
if (obj.version !== 1) {
|
|
121
|
+
throw new RoutineValidationError('Invalid or missing "version" (must be 1)', { source, got: obj.version });
|
|
122
|
+
}
|
|
123
|
+
if (typeof obj.name !== 'string' || obj.name.length === 0) {
|
|
124
|
+
throw new RoutineValidationError('Invalid or missing "name"', { source, got: obj.name });
|
|
125
|
+
}
|
|
126
|
+
// Validate schedule if present
|
|
127
|
+
if (obj.schedule !== undefined) {
|
|
128
|
+
validateScheduleConfig(obj.schedule, source);
|
|
129
|
+
}
|
|
130
|
+
if (!Array.isArray(obj.steps)) {
|
|
131
|
+
throw new RoutineValidationError('Invalid or missing "steps" (must be an array)', { source });
|
|
132
|
+
}
|
|
133
|
+
const steps = obj.steps;
|
|
134
|
+
const ids = new Set();
|
|
135
|
+
for (const [idx, step] of steps.entries()) {
|
|
136
|
+
if (!step || typeof step !== 'object') {
|
|
137
|
+
throw new RoutineValidationError('Invalid step (must be an object)', { source, index: idx });
|
|
138
|
+
}
|
|
139
|
+
const s = step;
|
|
140
|
+
if (typeof s.id !== 'string' || s.id.length === 0) {
|
|
141
|
+
throw new RoutineValidationError('Step missing "id"', { source, index: idx });
|
|
142
|
+
}
|
|
143
|
+
if (ids.has(s.id)) {
|
|
144
|
+
throw new RoutineValidationError(`Duplicate step id: ${s.id}`, { source, id: s.id });
|
|
145
|
+
}
|
|
146
|
+
ids.add(s.id);
|
|
147
|
+
if (typeof s.type !== 'string' || !['cli4ai', 'set', 'exec'].includes(s.type)) {
|
|
148
|
+
throw new RoutineValidationError(`Invalid step type for "${s.id}"`, { source, id: s.id, got: s.type });
|
|
149
|
+
}
|
|
150
|
+
if (s.timeout !== undefined && typeof s.timeout !== 'number') {
|
|
151
|
+
throw new RoutineValidationError(`Invalid timeout for "${s.id}" (must be number ms)`, { source, id: s.id, got: s.timeout });
|
|
152
|
+
}
|
|
153
|
+
if (s.continueOnError !== undefined && typeof s.continueOnError !== 'boolean') {
|
|
154
|
+
throw new RoutineValidationError(`Invalid continueOnError for "${s.id}" (must be boolean)`, { source, id: s.id, got: s.continueOnError });
|
|
155
|
+
}
|
|
156
|
+
if (s.type === 'cli4ai') {
|
|
157
|
+
if (typeof s.package !== 'string' || s.package.length === 0) {
|
|
158
|
+
throw new RoutineValidationError(`Step "${s.id}" missing "package"`, { source, id: s.id });
|
|
159
|
+
}
|
|
160
|
+
if (s.command !== undefined && typeof s.command !== 'string') {
|
|
161
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "command"`, { source, id: s.id });
|
|
162
|
+
}
|
|
163
|
+
if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
|
|
164
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
|
|
165
|
+
}
|
|
166
|
+
if (s.env !== undefined) {
|
|
167
|
+
if (typeof s.env !== 'object' || s.env === null) {
|
|
168
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: s.id });
|
|
169
|
+
}
|
|
170
|
+
for (const [k, v] of Object.entries(s.env)) {
|
|
171
|
+
if (typeof v !== 'string') {
|
|
172
|
+
throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (s.stdin !== undefined && typeof s.stdin !== 'string') {
|
|
177
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
|
|
178
|
+
}
|
|
179
|
+
if (s.capture !== undefined && !['inherit', 'text', 'json'].includes(String(s.capture))) {
|
|
180
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
|
|
181
|
+
}
|
|
182
|
+
if (s.remote !== undefined && typeof s.remote !== 'string') {
|
|
183
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "remote" (must be string)`, { source, id: s.id, got: s.remote });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (s.type === 'set') {
|
|
187
|
+
if (typeof s.vars !== 'object' || s.vars === null) {
|
|
188
|
+
throw new RoutineValidationError(`Step "${s.id}" missing "vars"`, { source, id: s.id });
|
|
189
|
+
}
|
|
190
|
+
for (const [k, v] of Object.entries(s.vars)) {
|
|
191
|
+
if (typeof v !== 'string') {
|
|
192
|
+
throw new RoutineValidationError(`Step "${s.id}" vars.${k} must be a string template`, { source, id: s.id });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (s.type === 'exec') {
|
|
197
|
+
if (typeof s.cmd !== 'string' || s.cmd.length === 0) {
|
|
198
|
+
throw new RoutineValidationError(`Step "${s.id}" missing "cmd"`, { source, id: s.id });
|
|
199
|
+
}
|
|
200
|
+
if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
|
|
201
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
|
|
202
|
+
}
|
|
203
|
+
if (s.env !== undefined) {
|
|
204
|
+
if (typeof s.env !== 'object' || s.env === null) {
|
|
205
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: s.id });
|
|
206
|
+
}
|
|
207
|
+
for (const [k, v] of Object.entries(s.env)) {
|
|
208
|
+
if (typeof v !== 'string') {
|
|
209
|
+
throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (s.stdin !== undefined && typeof s.stdin !== 'string') {
|
|
214
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
|
|
215
|
+
}
|
|
216
|
+
if (s.capture !== undefined && !['text', 'json'].includes(String(s.capture))) {
|
|
217
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return obj;
|
|
222
|
+
}
|
|
223
|
+
function getPath(root, segments) {
|
|
224
|
+
let current = root;
|
|
225
|
+
for (const seg of segments) {
|
|
226
|
+
if (current === null || current === undefined)
|
|
227
|
+
return undefined;
|
|
228
|
+
if (Array.isArray(current)) {
|
|
229
|
+
const idx = Number(seg);
|
|
230
|
+
if (!Number.isInteger(idx))
|
|
231
|
+
return undefined;
|
|
232
|
+
current = current[idx];
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (typeof current !== 'object')
|
|
236
|
+
return undefined;
|
|
237
|
+
current = current[seg];
|
|
238
|
+
}
|
|
239
|
+
return current;
|
|
240
|
+
}
|
|
241
|
+
function renderTemplateString(template, ctx, meta) {
|
|
242
|
+
const regex = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
243
|
+
const matches = [...template.matchAll(regex)];
|
|
244
|
+
if (matches.length === 0)
|
|
245
|
+
return template;
|
|
246
|
+
const resolveExpr = (expr) => {
|
|
247
|
+
const parts = expr.split('.').map(s => s.trim()).filter(Boolean);
|
|
248
|
+
const value = getPath(ctx, parts);
|
|
249
|
+
if (value === undefined) {
|
|
250
|
+
if (meta.strict === false) {
|
|
251
|
+
return `{{${expr}}}`;
|
|
252
|
+
}
|
|
253
|
+
throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: path "${expr}" not found`, {
|
|
254
|
+
step: meta.stepId,
|
|
255
|
+
field: meta.field,
|
|
256
|
+
path: expr
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return value;
|
|
260
|
+
};
|
|
261
|
+
// If the template is exactly a single {{expr}}, return the raw value.
|
|
262
|
+
if (matches.length === 1 && matches[0].index === 0 && matches[0][0].length === template.length) {
|
|
263
|
+
return resolveExpr(matches[0][1]);
|
|
264
|
+
}
|
|
265
|
+
let out = '';
|
|
266
|
+
let lastIndex = 0;
|
|
267
|
+
for (const m of matches) {
|
|
268
|
+
const full = m[0];
|
|
269
|
+
const expr = m[1];
|
|
270
|
+
const idx = m.index ?? 0;
|
|
271
|
+
out += template.slice(lastIndex, idx);
|
|
272
|
+
const value = resolveExpr(expr);
|
|
273
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
274
|
+
out += String(value);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
out += JSON.stringify(value);
|
|
278
|
+
}
|
|
279
|
+
lastIndex = idx + full.length;
|
|
280
|
+
}
|
|
281
|
+
out += template.slice(lastIndex);
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
function renderString(value, ctx, meta) {
|
|
285
|
+
const rendered = renderTemplateString(value, ctx, meta);
|
|
286
|
+
if (rendered === undefined || rendered === null) {
|
|
287
|
+
throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" resolved to empty`, {
|
|
288
|
+
step: meta.stepId,
|
|
289
|
+
field: meta.field
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (typeof rendered === 'string')
|
|
293
|
+
return rendered;
|
|
294
|
+
if (typeof rendered === 'number' || typeof rendered === 'boolean')
|
|
295
|
+
return String(rendered);
|
|
296
|
+
try {
|
|
297
|
+
return JSON.stringify(rendered);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" resolved to non-serializable value`, {
|
|
301
|
+
step: meta.stepId,
|
|
302
|
+
field: meta.field
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function renderScalarString(value, ctx, meta) {
|
|
307
|
+
const rendered = renderTemplateString(value, ctx, meta);
|
|
308
|
+
if (rendered === undefined || rendered === null) {
|
|
309
|
+
throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" resolved to empty`, {
|
|
310
|
+
step: meta.stepId,
|
|
311
|
+
field: meta.field
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (typeof rendered === 'string' || typeof rendered === 'number' || typeof rendered === 'boolean') {
|
|
315
|
+
return String(rendered);
|
|
316
|
+
}
|
|
317
|
+
throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" did not resolve to a string`, {
|
|
318
|
+
step: meta.stepId,
|
|
319
|
+
field: meta.field,
|
|
320
|
+
gotType: Array.isArray(rendered) ? 'array' : typeof rendered
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
function renderStringArray(values, ctx, meta) {
|
|
324
|
+
if (!values)
|
|
325
|
+
return [];
|
|
326
|
+
return values.map((v, i) => renderString(v, ctx, { ...meta, field: `${meta.field ?? 'args'}.${i}` }));
|
|
327
|
+
}
|
|
328
|
+
function renderEnv(env, ctx, meta) {
|
|
329
|
+
if (!env)
|
|
330
|
+
return {};
|
|
331
|
+
const out = {};
|
|
332
|
+
for (const [k, v] of Object.entries(env)) {
|
|
333
|
+
out[k] = renderString(v, ctx, { ...meta, field: `${meta.field ?? 'env'}.${k}` });
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
function renderTemplateValue(value, ctx, meta) {
|
|
338
|
+
if (typeof value === 'string') {
|
|
339
|
+
return renderTemplateString(value, ctx, meta);
|
|
340
|
+
}
|
|
341
|
+
if (Array.isArray(value)) {
|
|
342
|
+
return value.map((item, idx) => renderTemplateValue(item, ctx, { ...meta, field: `${meta.field ?? 'value'}.${idx}` }));
|
|
343
|
+
}
|
|
344
|
+
if (value && typeof value === 'object') {
|
|
345
|
+
const out = {};
|
|
346
|
+
for (const [k, v] of Object.entries(value)) {
|
|
347
|
+
out[k] = renderTemplateValue(v, ctx, { ...meta, field: `${meta.field ?? 'value'}.${k}` });
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
353
|
+
function collectStream(stream) {
|
|
354
|
+
return new Promise((resolve, reject) => {
|
|
355
|
+
const chunks = [];
|
|
356
|
+
stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))));
|
|
357
|
+
stream.on('error', reject);
|
|
358
|
+
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
async function runExecStep(step, ctx, invocationDir) {
|
|
362
|
+
const startTime = Date.now();
|
|
363
|
+
const cmd = renderScalarString(step.cmd, ctx, { stepId: step.id, field: 'cmd' });
|
|
364
|
+
const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
|
|
365
|
+
const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
|
|
366
|
+
const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
|
|
367
|
+
const child = spawn(cmd, args, {
|
|
368
|
+
cwd: invocationDir,
|
|
369
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
370
|
+
env: {
|
|
371
|
+
...process.env,
|
|
372
|
+
...(env ?? {})
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
if (stdin !== undefined && child.stdin) {
|
|
376
|
+
child.stdin.write(stdin);
|
|
377
|
+
}
|
|
378
|
+
if (child.stdin)
|
|
379
|
+
child.stdin.end();
|
|
380
|
+
const stdoutPromise = child.stdout ? collectStream(child.stdout) : Promise.resolve('');
|
|
381
|
+
const stderrPromise = child.stderr ? collectStream(child.stderr) : Promise.resolve('');
|
|
382
|
+
if (child.stderr) {
|
|
383
|
+
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
|
384
|
+
}
|
|
385
|
+
let timeout;
|
|
386
|
+
if (step.timeout && step.timeout > 0) {
|
|
387
|
+
timeout = setTimeout(() => {
|
|
388
|
+
try {
|
|
389
|
+
child.kill('SIGTERM');
|
|
390
|
+
}
|
|
391
|
+
catch { }
|
|
392
|
+
setTimeout(() => {
|
|
393
|
+
try {
|
|
394
|
+
child.kill('SIGKILL');
|
|
395
|
+
}
|
|
396
|
+
catch { }
|
|
397
|
+
}, 250);
|
|
398
|
+
}, step.timeout);
|
|
399
|
+
}
|
|
400
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
401
|
+
child.on('close', (code) => resolve(code ?? 0));
|
|
402
|
+
child.on('error', (err) => reject(new ExecuteToolError('API_ERROR', `Failed to execute: ${err.message}`)));
|
|
403
|
+
}).finally(() => {
|
|
404
|
+
if (timeout)
|
|
405
|
+
clearTimeout(timeout);
|
|
406
|
+
});
|
|
407
|
+
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
408
|
+
const capture = step.capture ?? 'text';
|
|
409
|
+
let json;
|
|
410
|
+
if (capture === 'json') {
|
|
411
|
+
try {
|
|
412
|
+
json = JSON.parse(stdout);
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
|
|
416
|
+
step: step.id,
|
|
417
|
+
stdout: stdout.slice(0, 200)
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
id: step.id,
|
|
423
|
+
type: step.type,
|
|
424
|
+
status: exitCode === 0 ? 'success' : 'failed',
|
|
425
|
+
exitCode,
|
|
426
|
+
durationMs: Date.now() - startTime,
|
|
427
|
+
stdout,
|
|
428
|
+
stderr,
|
|
429
|
+
json
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
export async function dryRunRoutine(def, vars, invocationDir) {
|
|
433
|
+
const ctxVars = {};
|
|
434
|
+
for (const [k, v] of Object.entries(def.vars ?? {})) {
|
|
435
|
+
if (v.default !== undefined)
|
|
436
|
+
ctxVars[k] = v.default;
|
|
437
|
+
}
|
|
438
|
+
for (const [k, v] of Object.entries(vars))
|
|
439
|
+
ctxVars[k] = v;
|
|
440
|
+
const ctx = {
|
|
441
|
+
vars: ctxVars,
|
|
442
|
+
steps: {}
|
|
443
|
+
};
|
|
444
|
+
const steps = [];
|
|
445
|
+
for (const step of def.steps) {
|
|
446
|
+
if (step.type === 'set') {
|
|
447
|
+
const renderedVars = {};
|
|
448
|
+
for (const [k, v] of Object.entries(step.vars)) {
|
|
449
|
+
renderedVars[k] = renderTemplateString(v, ctx, { stepId: step.id, field: `vars.${k}`, strict: false });
|
|
450
|
+
}
|
|
451
|
+
// Apply to context for subsequent steps
|
|
452
|
+
for (const [k, v] of Object.entries(renderedVars))
|
|
453
|
+
ctxVars[k] = v;
|
|
454
|
+
steps.push({ id: step.id, type: step.type, rendered: { vars: renderedVars } });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (step.type === 'cli4ai') {
|
|
458
|
+
const rendered = {
|
|
459
|
+
package: renderScalarString(step.package, ctx, { stepId: step.id, field: 'package', strict: false }),
|
|
460
|
+
command: step.command ? renderScalarString(step.command, ctx, { stepId: step.id, field: 'command', strict: false }) : undefined,
|
|
461
|
+
args: renderStringArray(step.args, ctx, { stepId: step.id, field: 'args', strict: false }),
|
|
462
|
+
env: renderEnv(step.env, ctx, { stepId: step.id, field: 'env', strict: false }),
|
|
463
|
+
stdin: step.stdin ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin', strict: false }) : undefined,
|
|
464
|
+
timeout: step.timeout
|
|
465
|
+
};
|
|
466
|
+
steps.push({ id: step.id, type: step.type, rendered });
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (step.type === 'exec') {
|
|
470
|
+
const rendered = {
|
|
471
|
+
cmd: renderScalarString(step.cmd, ctx, { stepId: step.id, field: 'cmd', strict: false }),
|
|
472
|
+
args: renderStringArray(step.args, ctx, { stepId: step.id, field: 'args', strict: false }),
|
|
473
|
+
env: renderEnv(step.env, ctx, { stepId: step.id, field: 'env', strict: false }),
|
|
474
|
+
stdin: step.stdin ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin', strict: false }) : undefined,
|
|
475
|
+
timeout: step.timeout
|
|
476
|
+
};
|
|
477
|
+
steps.push({ id: step.id, type: step.type, rendered });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const result = def.result !== undefined ? renderTemplateValue(def.result, ctx, { field: 'result', strict: false }) : undefined;
|
|
482
|
+
return {
|
|
483
|
+
routine: def.name,
|
|
484
|
+
version: def.version,
|
|
485
|
+
vars: ctxVars,
|
|
486
|
+
steps,
|
|
487
|
+
result
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
export async function runRoutine(def, vars, invocationDir) {
|
|
491
|
+
const startTime = Date.now();
|
|
492
|
+
const ctxVars = {};
|
|
493
|
+
for (const [k, v] of Object.entries(def.vars ?? {})) {
|
|
494
|
+
if (v.default !== undefined)
|
|
495
|
+
ctxVars[k] = v.default;
|
|
496
|
+
}
|
|
497
|
+
for (const [k, v] of Object.entries(vars))
|
|
498
|
+
ctxVars[k] = v;
|
|
499
|
+
const stepsById = {};
|
|
500
|
+
const ctx = {
|
|
501
|
+
vars: ctxVars,
|
|
502
|
+
steps: stepsById
|
|
503
|
+
};
|
|
504
|
+
const steps = [];
|
|
505
|
+
for (const step of def.steps) {
|
|
506
|
+
if (step.type === 'set') {
|
|
507
|
+
const assigned = {};
|
|
508
|
+
for (const [k, v] of Object.entries(step.vars)) {
|
|
509
|
+
assigned[k] = renderTemplateString(v, ctx, { stepId: step.id, field: `vars.${k}` });
|
|
510
|
+
}
|
|
511
|
+
for (const [k, v] of Object.entries(assigned))
|
|
512
|
+
ctxVars[k] = v;
|
|
513
|
+
const res = {
|
|
514
|
+
id: step.id,
|
|
515
|
+
type: step.type,
|
|
516
|
+
status: 'success',
|
|
517
|
+
exitCode: 0,
|
|
518
|
+
durationMs: 0,
|
|
519
|
+
json: assigned
|
|
520
|
+
};
|
|
521
|
+
stepsById[step.id] = res;
|
|
522
|
+
steps.push(res);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (step.type === 'exec') {
|
|
526
|
+
try {
|
|
527
|
+
const res = await runExecStep(step, ctx, invocationDir);
|
|
528
|
+
stepsById[step.id] = res;
|
|
529
|
+
steps.push(res);
|
|
530
|
+
if (res.exitCode !== 0 && !step.continueOnError) {
|
|
531
|
+
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, res.exitCode ?? 1);
|
|
532
|
+
}
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
const error = normalizeError(err);
|
|
537
|
+
const res = { id: step.id, type: step.type, status: 'failed', exitCode: 1, error };
|
|
538
|
+
stepsById[step.id] = res;
|
|
539
|
+
steps.push(res);
|
|
540
|
+
if (!step.continueOnError) {
|
|
541
|
+
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
|
|
542
|
+
}
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (step.type === 'cli4ai') {
|
|
547
|
+
const pkg = renderScalarString(step.package, ctx, { stepId: step.id, field: 'package' });
|
|
548
|
+
const cmd = step.command !== undefined ? renderScalarString(step.command, ctx, { stepId: step.id, field: 'command' }) : undefined;
|
|
549
|
+
const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
|
|
550
|
+
const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
|
|
551
|
+
const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
|
|
552
|
+
const remoteName = step.remote !== undefined ? renderScalarString(step.remote, ctx, { stepId: step.id, field: 'remote' }) : undefined;
|
|
553
|
+
const capture = step.capture ?? 'text';
|
|
554
|
+
try {
|
|
555
|
+
let execResult;
|
|
556
|
+
if (remoteName) {
|
|
557
|
+
// Remote execution
|
|
558
|
+
const remote = getRemote(remoteName);
|
|
559
|
+
if (!remote) {
|
|
560
|
+
throw new ExecuteToolError('NOT_FOUND', `Remote "${remoteName}" not found`, {
|
|
561
|
+
step: step.id,
|
|
562
|
+
hint: 'Use "cli4ai remotes add <name> <url>" to configure a remote'
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
const remoteRes = await remoteRunTool(remoteName, {
|
|
566
|
+
package: pkg,
|
|
567
|
+
command: cmd,
|
|
568
|
+
args,
|
|
569
|
+
env: Object.keys(env).length > 0 ? env : undefined,
|
|
570
|
+
stdin,
|
|
571
|
+
timeout: step.timeout
|
|
572
|
+
});
|
|
573
|
+
execResult = {
|
|
574
|
+
exitCode: remoteRes.exitCode,
|
|
575
|
+
durationMs: remoteRes.durationMs,
|
|
576
|
+
stdout: remoteRes.stdout,
|
|
577
|
+
stderr: remoteRes.stderr
|
|
578
|
+
};
|
|
579
|
+
// Log stderr from remote
|
|
580
|
+
if (remoteRes.stderr) {
|
|
581
|
+
process.stderr.write(remoteRes.stderr);
|
|
582
|
+
}
|
|
583
|
+
// Handle remote-level errors
|
|
584
|
+
if (remoteRes.error && !remoteRes.success) {
|
|
585
|
+
throw new ExecuteToolError(remoteRes.error.code, remoteRes.error.message, remoteRes.error.details);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
// Local execution
|
|
590
|
+
const localRes = await executeTool({
|
|
591
|
+
packageName: pkg,
|
|
592
|
+
command: cmd,
|
|
593
|
+
args,
|
|
594
|
+
cwd: invocationDir,
|
|
595
|
+
env,
|
|
596
|
+
stdin,
|
|
597
|
+
capture: 'pipe',
|
|
598
|
+
timeoutMs: step.timeout,
|
|
599
|
+
teeStderr: true
|
|
600
|
+
});
|
|
601
|
+
execResult = {
|
|
602
|
+
exitCode: localRes.exitCode,
|
|
603
|
+
durationMs: localRes.durationMs,
|
|
604
|
+
stdout: localRes.stdout,
|
|
605
|
+
stderr: localRes.stderr
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const res = {
|
|
609
|
+
id: step.id,
|
|
610
|
+
type: step.type,
|
|
611
|
+
status: execResult.exitCode === 0 ? 'success' : 'failed',
|
|
612
|
+
exitCode: execResult.exitCode,
|
|
613
|
+
durationMs: execResult.durationMs,
|
|
614
|
+
stdout: execResult.stdout,
|
|
615
|
+
stderr: execResult.stderr
|
|
616
|
+
};
|
|
617
|
+
if (capture === 'json') {
|
|
618
|
+
try {
|
|
619
|
+
res.json = execResult.stdout ? JSON.parse(execResult.stdout) : null;
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
|
|
623
|
+
step: step.id,
|
|
624
|
+
stdout: (execResult.stdout ?? '').slice(0, 200)
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
stepsById[step.id] = res;
|
|
629
|
+
steps.push(res);
|
|
630
|
+
if (execResult.exitCode !== 0 && !step.continueOnError) {
|
|
631
|
+
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, execResult.exitCode);
|
|
632
|
+
}
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
const error = normalizeError(err);
|
|
637
|
+
const res = {
|
|
638
|
+
id: step.id,
|
|
639
|
+
type: step.type,
|
|
640
|
+
status: 'failed',
|
|
641
|
+
exitCode: error.code === 'INTEGRITY_ERROR' ? 1 : 1,
|
|
642
|
+
error
|
|
643
|
+
};
|
|
644
|
+
stepsById[step.id] = res;
|
|
645
|
+
steps.push(res);
|
|
646
|
+
if (!step.continueOnError) {
|
|
647
|
+
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
|
|
648
|
+
}
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const result = def.result !== undefined ? renderTemplateValue(def.result, ctx, { field: 'result' }) : undefined;
|
|
654
|
+
return {
|
|
655
|
+
routine: def.name,
|
|
656
|
+
version: def.version,
|
|
657
|
+
status: 'success',
|
|
658
|
+
exitCode: 0,
|
|
659
|
+
durationMs: Date.now() - startTime,
|
|
660
|
+
vars: ctxVars,
|
|
661
|
+
steps,
|
|
662
|
+
result
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
function normalizeError(err) {
|
|
666
|
+
if (err instanceof ExecuteToolError) {
|
|
667
|
+
return { code: err.code, message: err.message, details: err.details };
|
|
668
|
+
}
|
|
669
|
+
if (err instanceof RemoteConnectionError) {
|
|
670
|
+
return { code: 'NETWORK_ERROR', message: err.message, details: { remote: err.remoteName, url: err.url } };
|
|
671
|
+
}
|
|
672
|
+
if (err instanceof RemoteApiError) {
|
|
673
|
+
return { code: err.code, message: err.message, details: err.details };
|
|
674
|
+
}
|
|
675
|
+
if (err instanceof RoutineTemplateError) {
|
|
676
|
+
return { code: 'INVALID_INPUT', message: err.message, details: err.details };
|
|
677
|
+
}
|
|
678
|
+
if (err instanceof RoutineValidationError) {
|
|
679
|
+
return { code: 'INVALID_INPUT', message: err.message, details: err.details };
|
|
680
|
+
}
|
|
681
|
+
if (err instanceof RoutineParseError) {
|
|
682
|
+
return { code: 'PARSE_ERROR', message: err.message, details: { path: err.path } };
|
|
683
|
+
}
|
|
684
|
+
if (err instanceof Error) {
|
|
685
|
+
return { code: 'API_ERROR', message: err.message };
|
|
686
|
+
}
|
|
687
|
+
return { code: 'API_ERROR', message: String(err) };
|
|
688
|
+
}
|
|
689
|
+
function finalizeFailure(def, vars, steps, durationMs, exitCode) {
|
|
690
|
+
return {
|
|
691
|
+
routine: def.name,
|
|
692
|
+
version: def.version,
|
|
693
|
+
status: 'failed',
|
|
694
|
+
exitCode,
|
|
695
|
+
durationMs,
|
|
696
|
+
vars,
|
|
697
|
+
steps
|
|
698
|
+
};
|
|
699
|
+
}
|