surf-cli 2.4.2 → 2.5.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/README.md +84 -6
- package/native/cli.cjs +521 -19
- package/native/do-executor.cjs +333 -73
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -387,6 +387,9 @@ surf do 'go "https://example.com/login" | type "user@example.com" --selector "#e
|
|
|
387
387
|
# From JSON file
|
|
388
388
|
surf do --file workflow.json
|
|
389
389
|
|
|
390
|
+
# Run named workflow with arguments
|
|
391
|
+
surf do my-workflow --url "https://example.com" --max_items 10
|
|
392
|
+
|
|
390
393
|
# Validate without executing
|
|
391
394
|
surf do 'go "url" | click e5 | screenshot' --dry-run
|
|
392
395
|
```
|
|
@@ -400,25 +403,100 @@ surf do 'go "url" | click e5 | screenshot' --dry-run
|
|
|
400
403
|
- `--step-delay <ms>` - Delay between steps (default: 100, use 0 to disable)
|
|
401
404
|
- `--no-auto-wait` - Disable automatic waits between steps
|
|
402
405
|
- `--json` - Output structured JSON result
|
|
406
|
+
- `--<arg> <value>` - Pass arguments to workflow (e.g., `--url "..."`)
|
|
403
407
|
|
|
404
408
|
**Auto-waits:** Commands that trigger page changes automatically wait for completion:
|
|
405
409
|
- Navigation (`go`, `back`, `forward`) → waits for page load
|
|
406
410
|
- Clicks, key presses, form fills → waits for DOM stability
|
|
407
411
|
- Tab switches → waits for tab to load
|
|
408
412
|
|
|
409
|
-
|
|
413
|
+
#### Workflow Files
|
|
414
|
+
|
|
415
|
+
Workflows can be saved as JSON files and run by name. Place them in `~/.surf/workflows/` (user) or `./.surf/workflows/` (project).
|
|
416
|
+
|
|
417
|
+
**Basic format:**
|
|
410
418
|
```json
|
|
411
419
|
{
|
|
412
|
-
"name": "
|
|
420
|
+
"name": "login-flow",
|
|
421
|
+
"description": "Log into example.com",
|
|
422
|
+
"args": {
|
|
423
|
+
"email": { "required": true, "desc": "Login email" },
|
|
424
|
+
"password": { "required": true, "desc": "Login password" }
|
|
425
|
+
},
|
|
413
426
|
"steps": [
|
|
414
427
|
{ "tool": "navigate", "args": { "url": "https://example.com/login" } },
|
|
415
|
-
{ "tool": "type", "args": { "text": "
|
|
416
|
-
{ "tool": "
|
|
417
|
-
{ "tool": "
|
|
428
|
+
{ "tool": "type", "args": { "text": "%{email}", "selector": "input[name=email]" } },
|
|
429
|
+
{ "tool": "type", "args": { "text": "%{password}", "selector": "input[name=password]" } },
|
|
430
|
+
{ "tool": "click", "args": { "selector": "button[type=submit]" } }
|
|
431
|
+
]
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
**Step outputs** - Capture results for use in later steps:
|
|
436
|
+
```json
|
|
437
|
+
{
|
|
438
|
+
"steps": [
|
|
439
|
+
{ "tool": "js", "args": { "code": "return document.title" }, "as": "title" },
|
|
440
|
+
{ "tool": "js", "args": { "code": "return 'Page: ' + '%{title}'" } }
|
|
441
|
+
]
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
**Loops** - `repeat` for fixed iterations, `each` for arrays:
|
|
446
|
+
```json
|
|
447
|
+
{
|
|
448
|
+
"steps": [
|
|
449
|
+
{ "tool": "js", "args": { "code": "return ['a', 'b', 'c']" }, "as": "items" },
|
|
450
|
+
{
|
|
451
|
+
"each": "%{items}",
|
|
452
|
+
"as": "item",
|
|
453
|
+
"steps": [
|
|
454
|
+
{ "tool": "js", "args": { "code": "return 'Processing: %{item}'" } }
|
|
455
|
+
]
|
|
456
|
+
}
|
|
457
|
+
]
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
```json
|
|
462
|
+
{
|
|
463
|
+
"steps": [
|
|
464
|
+
{
|
|
465
|
+
"repeat": 5,
|
|
466
|
+
"steps": [
|
|
467
|
+
{ "tool": "scroll", "args": { "direction": "down" } },
|
|
468
|
+
{ "tool": "wait", "args": { "duration": 500 } }
|
|
469
|
+
]
|
|
470
|
+
}
|
|
418
471
|
]
|
|
419
472
|
}
|
|
420
473
|
```
|
|
421
474
|
|
|
475
|
+
**Loop with exit condition** - Stop early when condition is met:
|
|
476
|
+
```json
|
|
477
|
+
{
|
|
478
|
+
"repeat": 20,
|
|
479
|
+
"until": { "tool": "js", "args": { "code": "return !document.querySelector('.next-page')" } },
|
|
480
|
+
"steps": [
|
|
481
|
+
{ "tool": "click", "args": { "selector": ".next-page" } },
|
|
482
|
+
{ "tool": "wait.load" }
|
|
483
|
+
]
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
#### Workflow Management
|
|
488
|
+
|
|
489
|
+
```bash
|
|
490
|
+
# List available workflows
|
|
491
|
+
surf workflow.list
|
|
492
|
+
|
|
493
|
+
# Show workflow details and arguments
|
|
494
|
+
surf workflow.info my-workflow
|
|
495
|
+
|
|
496
|
+
# Validate workflow JSON
|
|
497
|
+
surf workflow.validate ./my-workflow.json
|
|
498
|
+
```
|
|
499
|
+
|
|
422
500
|
**Supported commands:** All surf commands work in workflows. Use aliases (`go`, `snap`, `read`) or full names (`navigate`, `screenshot`, `page.read`).
|
|
423
501
|
|
|
424
502
|
## Global Options
|
|
@@ -484,7 +562,7 @@ echo '{"type":"tool_request","method":"execute_tool","params":{"tool":"tab.list"
|
|
|
484
562
|
|
|
485
563
|
| Group | Commands |
|
|
486
564
|
|-------|----------|
|
|
487
|
-
| `workflow` | `do` |
|
|
565
|
+
| `workflow` | `do`, `workflow.list`, `workflow.info`, `workflow.validate` |
|
|
488
566
|
| `window.*` | `new`, `list`, `focus`, `close`, `resize` |
|
|
489
567
|
| `tab.*` | `list`, `new`, `switch`, `close`, `name`, `unname`, `named`, `group`, `ungroup`, `groups`, `reload` |
|
|
490
568
|
| `scroll.*` | `top`, `bottom`, `to`, `info` |
|
package/native/cli.cjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const net = require("net");
|
|
3
3
|
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
4
6
|
const { execSync } = require("child_process");
|
|
5
7
|
const { loadConfig, getConfigPath, createStarterConfig } = require("./config.cjs");
|
|
6
8
|
const networkFormatters = require("./formatters/network.cjs");
|
|
@@ -10,6 +12,249 @@ const { executeDoSteps } = require("./do-executor.cjs");
|
|
|
10
12
|
|
|
11
13
|
const SOCKET_PATH = "/tmp/surf.sock";
|
|
12
14
|
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Workflow Resolution and Management
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get workflow search directories
|
|
21
|
+
* @returns {Array<{path: string, scope: string}>}
|
|
22
|
+
*/
|
|
23
|
+
function getWorkflowDirs() {
|
|
24
|
+
return [
|
|
25
|
+
{ path: path.join(process.cwd(), '.surf', 'workflows'), scope: 'project' },
|
|
26
|
+
{ path: path.join(os.homedir(), '.surf', 'workflows'), scope: 'user' },
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a workflow by name or path
|
|
32
|
+
* @param {string} nameOrPath - Workflow name or file path
|
|
33
|
+
* @returns {{ type: 'inline'|'file'|'not_found', content?: string, path?: string, name?: string }}
|
|
34
|
+
*/
|
|
35
|
+
function resolveWorkflow(nameOrPath) {
|
|
36
|
+
// Check if it's an inline workflow (contains pipe)
|
|
37
|
+
if (nameOrPath.includes('|')) {
|
|
38
|
+
return { type: 'inline', content: nameOrPath };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if it's a direct file path (with extension or path separator)
|
|
42
|
+
if (nameOrPath.includes('/') || nameOrPath.includes('\\') || nameOrPath.endsWith('.json')) {
|
|
43
|
+
if (fs.existsSync(nameOrPath)) {
|
|
44
|
+
return { type: 'file', path: nameOrPath };
|
|
45
|
+
}
|
|
46
|
+
return { type: 'not_found', name: nameOrPath };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Look up by name in workflow directories
|
|
50
|
+
const searchDirs = getWorkflowDirs();
|
|
51
|
+
|
|
52
|
+
for (const { path: dir } of searchDirs) {
|
|
53
|
+
const filePath = path.join(dir, `${nameOrPath}.json`);
|
|
54
|
+
if (fs.existsSync(filePath)) {
|
|
55
|
+
return { type: 'file', path: filePath };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { type: 'not_found', name: nameOrPath };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* List all available workflows
|
|
64
|
+
* @returns {Array<{name: string, description: string, scope: string, path: string, args?: object}>}
|
|
65
|
+
*/
|
|
66
|
+
function listWorkflows() {
|
|
67
|
+
const workflows = [];
|
|
68
|
+
const searchDirs = getWorkflowDirs();
|
|
69
|
+
|
|
70
|
+
for (const { path: dir, scope } of searchDirs) {
|
|
71
|
+
if (fs.existsSync(dir)) {
|
|
72
|
+
try {
|
|
73
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const filePath = path.join(dir, file);
|
|
76
|
+
try {
|
|
77
|
+
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
78
|
+
workflows.push({
|
|
79
|
+
name: content.name || file.replace('.json', ''),
|
|
80
|
+
description: content.description || '',
|
|
81
|
+
scope,
|
|
82
|
+
path: filePath,
|
|
83
|
+
args: content.args,
|
|
84
|
+
stepCount: content.steps?.length || 0,
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
// Skip invalid JSON files
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Skip inaccessible directories
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return workflows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get detailed info about a workflow
|
|
101
|
+
* @param {string} name - Workflow name
|
|
102
|
+
* @returns {{ error?: string, name?: string, description?: string, args?: object, steps?: Array, path?: string }}
|
|
103
|
+
*/
|
|
104
|
+
function getWorkflowInfo(name) {
|
|
105
|
+
const resolved = resolveWorkflow(name);
|
|
106
|
+
|
|
107
|
+
if (resolved.type === 'not_found') {
|
|
108
|
+
return { error: `Workflow not found: ${name}` };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (resolved.type === 'inline') {
|
|
112
|
+
return { error: 'Cannot get info for inline workflows' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const content = JSON.parse(fs.readFileSync(resolved.path, 'utf8'));
|
|
117
|
+
return {
|
|
118
|
+
name: content.name || name,
|
|
119
|
+
description: content.description || '',
|
|
120
|
+
args: content.args || {},
|
|
121
|
+
steps: content.steps || [],
|
|
122
|
+
path: resolved.path,
|
|
123
|
+
};
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { error: `Failed to parse workflow: ${e.message}` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validate workflow args against schema
|
|
131
|
+
* @param {object} workflow - Workflow with args schema
|
|
132
|
+
* @param {object} providedArgs - User-provided args
|
|
133
|
+
* @returns {string[]} - Array of error messages
|
|
134
|
+
*/
|
|
135
|
+
function validateWorkflowArgs(workflow, providedArgs) {
|
|
136
|
+
const errors = [];
|
|
137
|
+
if (workflow.args) {
|
|
138
|
+
for (const [name, spec] of Object.entries(workflow.args)) {
|
|
139
|
+
if (spec.required && providedArgs[name] === undefined) {
|
|
140
|
+
errors.push(`Missing required argument: --${name}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return errors;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Apply default values to workflow args
|
|
149
|
+
* @param {object} workflow - Workflow with args schema
|
|
150
|
+
* @param {object} providedArgs - User-provided args
|
|
151
|
+
* @returns {object} - Args with defaults applied
|
|
152
|
+
*/
|
|
153
|
+
function applyArgDefaults(workflow, providedArgs) {
|
|
154
|
+
const vars = { ...providedArgs };
|
|
155
|
+
if (workflow.args) {
|
|
156
|
+
for (const [name, spec] of Object.entries(workflow.args)) {
|
|
157
|
+
if (vars[name] === undefined && spec.default !== undefined) {
|
|
158
|
+
vars[name] = spec.default;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return vars;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Validate a workflow JSON file
|
|
167
|
+
* @param {string} filePath - Path to workflow file
|
|
168
|
+
* @returns {{ valid: boolean, error?: string, workflow?: object }}
|
|
169
|
+
*/
|
|
170
|
+
function validateWorkflowFile(filePath) {
|
|
171
|
+
if (!fs.existsSync(filePath)) {
|
|
172
|
+
return { valid: false, error: `File not found: ${filePath}` };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
177
|
+
const workflow = JSON.parse(content);
|
|
178
|
+
|
|
179
|
+
// Basic structure validation
|
|
180
|
+
if (!workflow.steps || !Array.isArray(workflow.steps)) {
|
|
181
|
+
return { valid: false, error: "Workflow must have a 'steps' array" };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (workflow.steps.length === 0) {
|
|
185
|
+
return { valid: false, error: "Workflow has no steps" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Validate each step
|
|
189
|
+
for (let i = 0; i < workflow.steps.length; i++) {
|
|
190
|
+
const step = workflow.steps[i];
|
|
191
|
+
|
|
192
|
+
// Check for loops
|
|
193
|
+
if (step.repeat !== undefined || step.each !== undefined) {
|
|
194
|
+
if (!step.steps || !Array.isArray(step.steps)) {
|
|
195
|
+
return { valid: false, error: `Step ${i + 1}: loop must have a 'steps' array` };
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Regular step must have tool/cmd
|
|
201
|
+
if (!step.tool && !step.cmd) {
|
|
202
|
+
return { valid: false, error: `Step ${i + 1}: must have 'tool' field` };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Validate args schema if present
|
|
207
|
+
if (workflow.args && typeof workflow.args !== 'object') {
|
|
208
|
+
return { valid: false, error: "'args' must be an object" };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { valid: true, workflow };
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return { valid: false, error: `Invalid JSON: ${e.message}` };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Format a step for display
|
|
219
|
+
* @param {object} step - Workflow step
|
|
220
|
+
* @param {number} indent - Indentation level
|
|
221
|
+
* @returns {string}
|
|
222
|
+
*/
|
|
223
|
+
function formatStep(step, indent = 0) {
|
|
224
|
+
const pad = ' '.repeat(indent);
|
|
225
|
+
|
|
226
|
+
if (step.repeat !== undefined) {
|
|
227
|
+
const lines = [`${pad}repeat ${step.repeat} times:`];
|
|
228
|
+
for (const s of step.steps || []) {
|
|
229
|
+
lines.push(formatStep(s, indent + 1));
|
|
230
|
+
}
|
|
231
|
+
if (step.until) {
|
|
232
|
+
lines.push(`${pad} until: ${step.until.tool || step.until.cmd}`);
|
|
233
|
+
}
|
|
234
|
+
return lines.join('\n');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (step.each !== undefined) {
|
|
238
|
+
const lines = [`${pad}each ${step.each} as ${step.as || 'item'}:`];
|
|
239
|
+
for (const s of step.steps || []) {
|
|
240
|
+
lines.push(formatStep(s, indent + 1));
|
|
241
|
+
}
|
|
242
|
+
return lines.join('\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const tool = step.tool || step.cmd;
|
|
246
|
+
const args = step.args || {};
|
|
247
|
+
const argStr = Object.entries(args)
|
|
248
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
249
|
+
.join(' ');
|
|
250
|
+
|
|
251
|
+
let line = `${pad}${tool}`;
|
|
252
|
+
if (argStr) line += ` ${argStr}`;
|
|
253
|
+
if (step.as) line += ` → ${step.as}`;
|
|
254
|
+
|
|
255
|
+
return line;
|
|
256
|
+
}
|
|
257
|
+
|
|
13
258
|
// Cross-platform image resize (macOS: sips, Linux: ImageMagick)
|
|
14
259
|
function resizeImage(filePath, maxSize) {
|
|
15
260
|
const platform = process.platform;
|
|
@@ -804,7 +1049,7 @@ const TOOLS = {
|
|
|
804
1049
|
}
|
|
805
1050
|
},
|
|
806
1051
|
workflow: {
|
|
807
|
-
desc: "Workflow execution",
|
|
1052
|
+
desc: "Workflow execution and management",
|
|
808
1053
|
commands: {
|
|
809
1054
|
"do": {
|
|
810
1055
|
desc: "Execute multiple commands as a single workflow",
|
|
@@ -819,9 +1064,34 @@ const TOOLS = {
|
|
|
819
1064
|
examples: [
|
|
820
1065
|
{ cmd: 'do \'go "https://example.com" | click e5 | screenshot\'', desc: "Inline workflow" },
|
|
821
1066
|
{ cmd: 'do -f login.json', desc: "From JSON file" },
|
|
1067
|
+
{ cmd: 'do github-login --email "x" --password "y"', desc: "Named workflow with args" },
|
|
822
1068
|
{ cmd: 'do \'go "url" | click e5\' --dry-run', desc: "Validate without running" },
|
|
823
1069
|
]
|
|
824
1070
|
},
|
|
1071
|
+
"workflow.list": {
|
|
1072
|
+
desc: "List available workflows",
|
|
1073
|
+
args: [],
|
|
1074
|
+
opts: {},
|
|
1075
|
+
examples: [
|
|
1076
|
+
{ cmd: 'workflow.list', desc: "Show all workflows" },
|
|
1077
|
+
]
|
|
1078
|
+
},
|
|
1079
|
+
"workflow.info": {
|
|
1080
|
+
desc: "Show workflow details and arguments",
|
|
1081
|
+
args: ["name"],
|
|
1082
|
+
opts: {},
|
|
1083
|
+
examples: [
|
|
1084
|
+
{ cmd: 'workflow.info github-login', desc: "Show workflow details" },
|
|
1085
|
+
]
|
|
1086
|
+
},
|
|
1087
|
+
"workflow.validate": {
|
|
1088
|
+
desc: "Validate workflow JSON file",
|
|
1089
|
+
args: ["file"],
|
|
1090
|
+
opts: {},
|
|
1091
|
+
examples: [
|
|
1092
|
+
{ cmd: 'workflow.validate ./my-flow.json', desc: "Check JSON validity" },
|
|
1093
|
+
]
|
|
1094
|
+
},
|
|
825
1095
|
}
|
|
826
1096
|
},
|
|
827
1097
|
zoom: {
|
|
@@ -1762,6 +2032,12 @@ if (args[0] === "do") {
|
|
|
1762
2032
|
let tabId = undefined;
|
|
1763
2033
|
let windowId = undefined;
|
|
1764
2034
|
|
|
2035
|
+
// Reserved flags that aren't workflow args
|
|
2036
|
+
const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id'];
|
|
2037
|
+
|
|
2038
|
+
// Workflow-specific args (collected for variable substitution)
|
|
2039
|
+
const workflowArgs = {};
|
|
2040
|
+
|
|
1765
2041
|
// Parse do-specific arguments
|
|
1766
2042
|
for (let i = 0; i < doArgs.length; i++) {
|
|
1767
2043
|
const arg = doArgs[i];
|
|
@@ -1787,61 +2063,165 @@ if (args[0] === "do") {
|
|
|
1787
2063
|
} else if (arg === "--window-id") {
|
|
1788
2064
|
windowId = parseInt(doArgs[i + 1], 10);
|
|
1789
2065
|
i++;
|
|
2066
|
+
} else if (arg.startsWith("--")) {
|
|
2067
|
+
// Workflow-specific arg (e.g., --email, --password)
|
|
2068
|
+
const key = arg.slice(2);
|
|
2069
|
+
if (!reservedFlags.includes(key)) {
|
|
2070
|
+
const next = doArgs[i + 1];
|
|
2071
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
2072
|
+
// Type coercion
|
|
2073
|
+
let val = next;
|
|
2074
|
+
if (val === "true") val = true;
|
|
2075
|
+
else if (val === "false") val = false;
|
|
2076
|
+
else if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
|
|
2077
|
+
else if (/^-?\d+\.\d+$/.test(val)) val = parseFloat(val);
|
|
2078
|
+
workflowArgs[key] = val;
|
|
2079
|
+
i++;
|
|
2080
|
+
} else {
|
|
2081
|
+
workflowArgs[key] = true;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
1790
2084
|
} else if (!arg.startsWith("-")) {
|
|
1791
2085
|
commandsInput = arg;
|
|
1792
2086
|
}
|
|
1793
2087
|
}
|
|
1794
2088
|
|
|
1795
2089
|
if (!commandsInput && !fileInput) {
|
|
1796
|
-
console.error("Error: commands string or --file required");
|
|
1797
|
-
console.error('Usage: surf do \'go "url"
|
|
2090
|
+
console.error("Error: commands string, workflow name, or --file required");
|
|
2091
|
+
console.error('Usage: surf do \'go "url" | click e5\'');
|
|
1798
2092
|
console.error(" surf do --file workflow.json");
|
|
2093
|
+
console.error(" surf do my-workflow --arg1 value1 --arg2 value2");
|
|
1799
2094
|
process.exit(1);
|
|
1800
2095
|
}
|
|
1801
2096
|
|
|
1802
2097
|
let steps;
|
|
2098
|
+
let workflow = null; // Full workflow object (for arg validation)
|
|
2099
|
+
let workflowName = null;
|
|
2100
|
+
|
|
1803
2101
|
try {
|
|
1804
2102
|
if (fileInput) {
|
|
2103
|
+
// Explicit file path via --file
|
|
1805
2104
|
if (!fs.existsSync(fileInput)) {
|
|
1806
2105
|
console.error(`Error: File not found: ${fileInput}`);
|
|
1807
2106
|
process.exit(1);
|
|
1808
2107
|
}
|
|
1809
2108
|
const content = fs.readFileSync(fileInput, "utf8");
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
if (!script.steps || !Array.isArray(script.steps)) {
|
|
1813
|
-
throw new Error("JSON must have a 'steps' array");
|
|
1814
|
-
}
|
|
1815
|
-
// Convert --script format { tool, args } to do format { cmd, args }
|
|
1816
|
-
steps = script.steps.map(s => ({ cmd: s.tool, args: s.args || {} }));
|
|
2109
|
+
workflow = JSON.parse(content);
|
|
2110
|
+
workflowName = workflow.name || fileInput;
|
|
1817
2111
|
} else {
|
|
1818
|
-
//
|
|
1819
|
-
|
|
2112
|
+
// Resolve: inline | file path | named workflow
|
|
2113
|
+
const resolved = resolveWorkflow(commandsInput);
|
|
2114
|
+
|
|
2115
|
+
if (resolved.type === 'inline') {
|
|
2116
|
+
// Inline pipe syntax
|
|
2117
|
+
steps = parseDoCommands(resolved.content);
|
|
2118
|
+
} else if (resolved.type === 'file') {
|
|
2119
|
+
// Found workflow file
|
|
2120
|
+
const content = fs.readFileSync(resolved.path, "utf8");
|
|
2121
|
+
workflow = JSON.parse(content);
|
|
2122
|
+
workflowName = workflow.name || commandsInput;
|
|
2123
|
+
} else {
|
|
2124
|
+
// Not found - try parsing as inline (might be a single command)
|
|
2125
|
+
steps = parseDoCommands(commandsInput);
|
|
2126
|
+
if (steps.length === 0) {
|
|
2127
|
+
console.error(`Error: Workflow not found: ${commandsInput}`);
|
|
2128
|
+
console.error(`Searched in:`);
|
|
2129
|
+
for (const { path: dir } of getWorkflowDirs()) {
|
|
2130
|
+
console.error(` ${dir}`);
|
|
2131
|
+
}
|
|
2132
|
+
console.error(`\nRun 'surf workflow.list' to see available workflows.`);
|
|
2133
|
+
process.exit(1);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Process workflow file if loaded
|
|
2139
|
+
if (workflow) {
|
|
2140
|
+
if (!workflow.steps || !Array.isArray(workflow.steps)) {
|
|
2141
|
+
throw new Error("Workflow must have a 'steps' array");
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// Validate required args
|
|
2145
|
+
const argErrors = validateWorkflowArgs(workflow, workflowArgs);
|
|
2146
|
+
if (argErrors.length > 0) {
|
|
2147
|
+
console.error("Error: Missing required arguments:");
|
|
2148
|
+
argErrors.forEach(e => console.error(` ${e}`));
|
|
2149
|
+
if (workflow.args) {
|
|
2150
|
+
console.error(`\nWorkflow arguments:`);
|
|
2151
|
+
for (const [name, spec] of Object.entries(workflow.args)) {
|
|
2152
|
+
const req = spec.required ? ' (required)' : '';
|
|
2153
|
+
const def = spec.default !== undefined ? ` [default: ${spec.default}]` : '';
|
|
2154
|
+
const desc = spec.desc || spec.description || '';
|
|
2155
|
+
console.error(` --${name}${req}${def}${desc ? ` - ${desc}` : ''}`);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
console.error(`\nRun 'surf workflow.info ${workflowName}' for details.`);
|
|
2159
|
+
process.exit(1);
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
// Convert steps: support both { tool, args } and { cmd, args } formats
|
|
2163
|
+
// Also preserve loop steps as-is
|
|
2164
|
+
steps = workflow.steps.map(s => {
|
|
2165
|
+
if (s.repeat !== undefined || s.each !== undefined) {
|
|
2166
|
+
// Loop step - convert nested steps recursively
|
|
2167
|
+
const convertSteps = (stepsArr) => stepsArr.map(ns => {
|
|
2168
|
+
if (ns.repeat !== undefined || ns.each !== undefined) {
|
|
2169
|
+
// Recursively convert nested loop steps and until condition
|
|
2170
|
+
return {
|
|
2171
|
+
...ns,
|
|
2172
|
+
steps: convertSteps(ns.steps || []),
|
|
2173
|
+
until: ns.until ? { cmd: ns.until.tool || ns.until.cmd, args: ns.until.args || {} } : undefined
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
return { cmd: ns.tool || ns.cmd, args: ns.args || {}, as: ns.as };
|
|
2177
|
+
});
|
|
2178
|
+
return {
|
|
2179
|
+
...s,
|
|
2180
|
+
steps: convertSteps(s.steps || []),
|
|
2181
|
+
until: s.until ? { cmd: s.until.tool || s.until.cmd, args: s.until.args || {} } : undefined
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
return { cmd: s.tool || s.cmd, args: s.args || {}, as: s.as };
|
|
2185
|
+
});
|
|
1820
2186
|
}
|
|
1821
2187
|
} catch (e) {
|
|
1822
2188
|
console.error(`Error: Failed to parse workflow: ${e.message}`);
|
|
1823
2189
|
process.exit(1);
|
|
1824
2190
|
}
|
|
1825
2191
|
|
|
1826
|
-
if (steps.length === 0) {
|
|
2192
|
+
if (!steps || steps.length === 0) {
|
|
1827
2193
|
console.error("Error: No commands found in workflow");
|
|
1828
2194
|
process.exit(1);
|
|
1829
2195
|
}
|
|
1830
2196
|
|
|
2197
|
+
// Apply arg defaults
|
|
2198
|
+
const vars = workflow ? applyArgDefaults(workflow, workflowArgs) : workflowArgs;
|
|
2199
|
+
|
|
1831
2200
|
// Validate with --dry-run
|
|
1832
2201
|
if (dryRun) {
|
|
1833
|
-
|
|
2202
|
+
if (workflowName) {
|
|
2203
|
+
console.log(`Workflow: ${workflowName}`);
|
|
2204
|
+
if (workflow?.description) console.log(`Description: ${workflow.description}`);
|
|
2205
|
+
}
|
|
2206
|
+
console.log(`\nWould execute ${steps.length} steps:`);
|
|
1834
2207
|
steps.forEach((s, i) => {
|
|
1835
|
-
|
|
1836
|
-
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
1837
|
-
.join(" ");
|
|
1838
|
-
console.log(` ${i + 1}. ${s.cmd} ${argStr}`);
|
|
2208
|
+
console.log(` ${i + 1}. ${formatStep(s)}`);
|
|
1839
2209
|
});
|
|
2210
|
+
if (Object.keys(vars).length > 0) {
|
|
2211
|
+
console.log(`\nVariables:`);
|
|
2212
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
2213
|
+
console.log(` ${k} = ${JSON.stringify(v)}`);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
1840
2216
|
process.exit(0);
|
|
1841
2217
|
}
|
|
1842
2218
|
|
|
1843
2219
|
if (!wantJson) {
|
|
1844
|
-
|
|
2220
|
+
if (workflowName) {
|
|
2221
|
+
console.log(`Running workflow: ${workflowName} (${steps.length} steps)...\n`);
|
|
2222
|
+
} else {
|
|
2223
|
+
console.log(`Running workflow (${steps.length} steps)...\n`);
|
|
2224
|
+
}
|
|
1845
2225
|
}
|
|
1846
2226
|
|
|
1847
2227
|
const runWorkflow = async () => {
|
|
@@ -1850,6 +2230,7 @@ if (args[0] === "do") {
|
|
|
1850
2230
|
autoWait: !noAutoWait,
|
|
1851
2231
|
stepDelay,
|
|
1852
2232
|
quiet: wantJson,
|
|
2233
|
+
vars,
|
|
1853
2234
|
context: {
|
|
1854
2235
|
tabId,
|
|
1855
2236
|
windowId,
|
|
@@ -1880,6 +2261,127 @@ if (args[0] === "do") {
|
|
|
1880
2261
|
return;
|
|
1881
2262
|
}
|
|
1882
2263
|
|
|
2264
|
+
// Handle workflow management commands
|
|
2265
|
+
if (args[0] === "workflow.list") {
|
|
2266
|
+
const workflows = listWorkflows();
|
|
2267
|
+
|
|
2268
|
+
if (workflows.length === 0) {
|
|
2269
|
+
console.log("No workflows found.");
|
|
2270
|
+
console.log(`\nWorkflow directories:`);
|
|
2271
|
+
for (const { path: dir, scope } of getWorkflowDirs()) {
|
|
2272
|
+
console.log(` ${scope}: ${dir}`);
|
|
2273
|
+
}
|
|
2274
|
+
console.log(`\nCreate a workflow JSON file in one of these directories.`);
|
|
2275
|
+
process.exit(0);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// Group by scope
|
|
2279
|
+
const byScope = { project: [], user: [] };
|
|
2280
|
+
for (const w of workflows) {
|
|
2281
|
+
byScope[w.scope].push(w);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
if (byScope.user.length > 0) {
|
|
2285
|
+
console.log(`User Workflows (~/.surf/workflows/):`);
|
|
2286
|
+
for (const w of byScope.user) {
|
|
2287
|
+
const desc = w.description ? ` - ${w.description}` : '';
|
|
2288
|
+
console.log(` ${w.name.padEnd(20)} ${desc}`);
|
|
2289
|
+
}
|
|
2290
|
+
console.log("");
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
if (byScope.project.length > 0) {
|
|
2294
|
+
console.log(`Project Workflows (./.surf/workflows/):`);
|
|
2295
|
+
for (const w of byScope.project) {
|
|
2296
|
+
const desc = w.description ? ` - ${w.description}` : '';
|
|
2297
|
+
console.log(` ${w.name.padEnd(20)} ${desc}`);
|
|
2298
|
+
}
|
|
2299
|
+
console.log("");
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
console.log(`Run 'surf workflow.info <name>' for details.`);
|
|
2303
|
+
process.exit(0);
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
if (args[0] === "workflow.info") {
|
|
2307
|
+
const name = args[1];
|
|
2308
|
+
if (!name) {
|
|
2309
|
+
console.error("Error: workflow name required");
|
|
2310
|
+
console.error("Usage: surf workflow.info <name>");
|
|
2311
|
+
process.exit(1);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
const info = getWorkflowInfo(name);
|
|
2315
|
+
if (info.error) {
|
|
2316
|
+
console.error(`Error: ${info.error}`);
|
|
2317
|
+
process.exit(1);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
console.log(`${info.name}${info.description ? ` - ${info.description}` : ''}`);
|
|
2321
|
+
console.log("");
|
|
2322
|
+
|
|
2323
|
+
// Arguments
|
|
2324
|
+
if (info.args && Object.keys(info.args).length > 0) {
|
|
2325
|
+
console.log("Arguments:");
|
|
2326
|
+
for (const [argName, spec] of Object.entries(info.args)) {
|
|
2327
|
+
const req = spec.required ? ' (required)' : '';
|
|
2328
|
+
const def = spec.default !== undefined ? ` [default: ${spec.default}]` : '';
|
|
2329
|
+
const desc = spec.desc || spec.description || '';
|
|
2330
|
+
console.log(` --${argName}${req}${def}`);
|
|
2331
|
+
if (desc) console.log(` ${desc}`);
|
|
2332
|
+
}
|
|
2333
|
+
console.log("");
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// Steps
|
|
2337
|
+
console.log(`Steps (${info.steps.length}):`);
|
|
2338
|
+
info.steps.forEach((step, i) => {
|
|
2339
|
+
console.log(` ${i + 1}. ${formatStep(step)}`);
|
|
2340
|
+
});
|
|
2341
|
+
console.log("");
|
|
2342
|
+
|
|
2343
|
+
// Location
|
|
2344
|
+
console.log(`Location: ${info.path}`);
|
|
2345
|
+
console.log("");
|
|
2346
|
+
|
|
2347
|
+
// Example run command
|
|
2348
|
+
const argExample = Object.entries(info.args || {})
|
|
2349
|
+
.filter(([_, spec]) => spec.required)
|
|
2350
|
+
.map(([name, _]) => `--${name} "..."`)
|
|
2351
|
+
.join(' ');
|
|
2352
|
+
console.log(`Run:`);
|
|
2353
|
+
console.log(` surf do ${name}${argExample ? ' ' + argExample : ''}`);
|
|
2354
|
+
|
|
2355
|
+
process.exit(0);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
if (args[0] === "workflow.validate") {
|
|
2359
|
+
const filePath = args[1];
|
|
2360
|
+
if (!filePath) {
|
|
2361
|
+
console.error("Error: file path required");
|
|
2362
|
+
console.error("Usage: surf workflow.validate <file>");
|
|
2363
|
+
process.exit(1);
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const result = validateWorkflowFile(filePath);
|
|
2367
|
+
|
|
2368
|
+
if (result.valid) {
|
|
2369
|
+
console.log(`✓ Valid workflow: ${filePath}`);
|
|
2370
|
+
console.log(` Name: ${result.workflow.name || '(unnamed)'}`);
|
|
2371
|
+
console.log(` Steps: ${result.workflow.steps.length}`);
|
|
2372
|
+
if (result.workflow.args) {
|
|
2373
|
+
const argCount = Object.keys(result.workflow.args).length;
|
|
2374
|
+
const reqCount = Object.values(result.workflow.args).filter(a => a.required).length;
|
|
2375
|
+
console.log(` Args: ${argCount} (${reqCount} required)`);
|
|
2376
|
+
}
|
|
2377
|
+
process.exit(0);
|
|
2378
|
+
} else {
|
|
2379
|
+
console.error(`✗ Invalid workflow: ${filePath}`);
|
|
2380
|
+
console.error(` Error: ${result.error}`);
|
|
2381
|
+
process.exit(1);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
|
|
1883
2385
|
const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl", "no-save", "no-auto-wait"];
|
|
1884
2386
|
|
|
1885
2387
|
const AUTO_SCREENSHOT_TOOLS = ["click", "type", "key", "smart_type", "form.fill", "form_input", "drag", "hover", "scroll", "scroll.top", "scroll.bottom", "scroll.to", "dialog.accept", "dialog.dismiss", "js", "eval"];
|
package/native/do-executor.cjs
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Executor for surf `do` workflow commands
|
|
3
3
|
*
|
|
4
4
|
* Executes steps sequentially with auto-waits and streaming progress output.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - Step outputs: capture results with `as` field
|
|
7
|
+
* - Loops: `repeat` for fixed iterations, `each` for array iteration
|
|
8
|
+
* - Variable substitution: %{varname} syntax
|
|
9
|
+
*
|
|
5
10
|
* Follows the same socket communication pattern as --script mode in cli.cjs.
|
|
6
11
|
*/
|
|
7
12
|
|
|
@@ -9,6 +14,9 @@ const net = require("net");
|
|
|
9
14
|
|
|
10
15
|
const SOCKET_PATH = "/tmp/surf.sock";
|
|
11
16
|
|
|
17
|
+
// Maximum iterations for loops (safety cap)
|
|
18
|
+
const MAX_LOOP_ITERATIONS = 100;
|
|
19
|
+
|
|
12
20
|
// Commands that trigger auto-wait after execution
|
|
13
21
|
// Note: 'type' is intentionally excluded - typing doesn't trigger navigation or DOM changes
|
|
14
22
|
const AUTO_WAIT_COMMANDS = [
|
|
@@ -114,6 +122,32 @@ function sendDoRequest(toolName, toolArgs, context = {}) {
|
|
|
114
122
|
});
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a variable reference or perform string substitution
|
|
127
|
+
* @param {*} template - Value to resolve (may contain %{var} references)
|
|
128
|
+
* @param {object} vars - Variables map
|
|
129
|
+
* @returns {*} - Resolved value
|
|
130
|
+
*/
|
|
131
|
+
function resolveVar(template, vars) {
|
|
132
|
+
if (typeof template !== 'string') return template;
|
|
133
|
+
|
|
134
|
+
// Check if it's a simple variable reference like %{urls}
|
|
135
|
+
const match = template.match(/^%\{(\w+)\}$/);
|
|
136
|
+
if (match) {
|
|
137
|
+
const value = vars[match[1]];
|
|
138
|
+
return value !== undefined ? value : template;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Otherwise do string substitution
|
|
142
|
+
return template.replace(/%\{(\w+)\}/g, (_, name) => {
|
|
143
|
+
const val = vars[name];
|
|
144
|
+
if (val === undefined) return `%{${name}}`;
|
|
145
|
+
// Convert objects/arrays to string for interpolation
|
|
146
|
+
if (typeof val === 'object') return JSON.stringify(val);
|
|
147
|
+
return String(val);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
117
151
|
/**
|
|
118
152
|
* Substitute variables in arguments using %{varname} syntax
|
|
119
153
|
* @param {object} args - Arguments object
|
|
@@ -123,10 +157,28 @@ function sendDoRequest(toolName, toolArgs, context = {}) {
|
|
|
123
157
|
function substituteVars(args, vars) {
|
|
124
158
|
if (!args || typeof args !== 'object') return args;
|
|
125
159
|
|
|
160
|
+
// Handle arrays specially to preserve array type
|
|
161
|
+
if (Array.isArray(args)) {
|
|
162
|
+
return args.map(item => {
|
|
163
|
+
if (typeof item === 'string') {
|
|
164
|
+
return resolveVar(item, vars);
|
|
165
|
+
} else if (typeof item === 'object' && item !== null) {
|
|
166
|
+
return substituteVars(item, vars);
|
|
167
|
+
} else {
|
|
168
|
+
return item;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Handle plain objects
|
|
126
174
|
const result = {};
|
|
127
175
|
for (const [key, val] of Object.entries(args)) {
|
|
128
176
|
if (typeof val === 'string') {
|
|
129
|
-
result[key] = val
|
|
177
|
+
result[key] = resolveVar(val, vars);
|
|
178
|
+
} else if (Array.isArray(val)) {
|
|
179
|
+
result[key] = substituteVars(val, vars);
|
|
180
|
+
} else if (typeof val === 'object' && val !== null) {
|
|
181
|
+
result[key] = substituteVars(val, vars);
|
|
130
182
|
} else {
|
|
131
183
|
result[key] = val;
|
|
132
184
|
}
|
|
@@ -134,9 +186,219 @@ function substituteVars(args, vars) {
|
|
|
134
186
|
return result;
|
|
135
187
|
}
|
|
136
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Extract usable output from a step response for the `as` capture
|
|
191
|
+
* @param {object} resp - Response from sendDoRequest
|
|
192
|
+
* @returns {*} - Extracted value
|
|
193
|
+
*/
|
|
194
|
+
function extractStepOutput(resp) {
|
|
195
|
+
// MCP format: resp.result.content[0].text
|
|
196
|
+
if (resp.result?.content?.[0]?.text) {
|
|
197
|
+
const text = resp.result.content[0].text;
|
|
198
|
+
// Try to parse as JSON, otherwise return raw text
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(text);
|
|
201
|
+
} catch {
|
|
202
|
+
return text;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Direct value (some tools return this)
|
|
207
|
+
if (resp.value !== undefined) return resp.value;
|
|
208
|
+
|
|
209
|
+
// Direct result object
|
|
210
|
+
if (resp.result !== undefined) return resp.result;
|
|
211
|
+
|
|
212
|
+
// Fallback to the whole response
|
|
213
|
+
return resp;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Execute a single tool step (non-loop)
|
|
218
|
+
* @param {object} step - Step to execute { cmd, args, as? }
|
|
219
|
+
* @param {object} vars - Variables map (mutated if step has `as`)
|
|
220
|
+
* @param {object} context - Execution context
|
|
221
|
+
* @param {object} options - Execution options
|
|
222
|
+
* @returns {Promise<object>} - Result { success, error?, output? }
|
|
223
|
+
*/
|
|
224
|
+
async function executeSingleStep(step, vars, context, options) {
|
|
225
|
+
const { autoWait = true, stepDelay = 100 } = options;
|
|
226
|
+
|
|
227
|
+
// Substitute variables in args
|
|
228
|
+
const resolvedArgs = substituteVars(step.args || {}, vars);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const resp = await sendDoRequest(step.cmd, resolvedArgs, context);
|
|
232
|
+
|
|
233
|
+
if (resp.error) {
|
|
234
|
+
const errText = resp.error.content?.[0]?.text || JSON.stringify(resp.error);
|
|
235
|
+
return { success: false, error: errText };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Capture output if step has `as` field
|
|
239
|
+
if (step.as) {
|
|
240
|
+
const output = extractStepOutput(resp);
|
|
241
|
+
vars[step.as] = output;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Command-specific auto-wait
|
|
245
|
+
if (autoWait) {
|
|
246
|
+
const waitCmd = getAutoWaitCommand(step.cmd);
|
|
247
|
+
if (waitCmd) {
|
|
248
|
+
const waitArgs = waitCmd === 'wait.load'
|
|
249
|
+
? { timeout: 10000 }
|
|
250
|
+
: { stable: 100, timeout: 5000 };
|
|
251
|
+
try {
|
|
252
|
+
await sendDoRequest(waitCmd, waitArgs, context);
|
|
253
|
+
} catch {
|
|
254
|
+
// Ignore auto-wait failures silently
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Delay between steps
|
|
260
|
+
if (stepDelay > 0) {
|
|
261
|
+
await new Promise(r => setTimeout(r, stepDelay));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { success: true, output: step.as ? vars[step.as] : undefined };
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return { success: false, error: err.message };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Execute a single step, handling loops recursively
|
|
272
|
+
* @param {object} step - Step to execute (may be a loop or regular step)
|
|
273
|
+
* @param {object} vars - Variables map
|
|
274
|
+
* @param {object} context - Execution context
|
|
275
|
+
* @param {object} options - Execution options
|
|
276
|
+
* @param {function} onProgress - Progress callback for streaming output
|
|
277
|
+
* @returns {Promise<object>} - Result { success, error?, stepsExecuted }
|
|
278
|
+
*/
|
|
279
|
+
async function executeStep(step, vars, context, options, onProgress) {
|
|
280
|
+
const { onError = 'stop' } = options;
|
|
281
|
+
|
|
282
|
+
// Handle `repeat` loop
|
|
283
|
+
if (step.repeat !== undefined) {
|
|
284
|
+
// Resolve repeat count (may be a variable)
|
|
285
|
+
let max = resolveVar(step.repeat, vars);
|
|
286
|
+
if (typeof max === 'string') max = parseInt(max, 10);
|
|
287
|
+
if (typeof max !== 'number' || isNaN(max)) max = 1;
|
|
288
|
+
|
|
289
|
+
// Safety cap
|
|
290
|
+
max = Math.min(max, MAX_LOOP_ITERATIONS);
|
|
291
|
+
|
|
292
|
+
if (!Array.isArray(step.steps) || step.steps.length === 0) {
|
|
293
|
+
return { success: false, error: 'repeat: steps array required', stepsExecuted: 0 };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let totalExecuted = 0;
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < max; i++) {
|
|
299
|
+
// Create loop-scoped variables
|
|
300
|
+
const loopVars = { ...vars, _index: i, _iteration: i + 1 };
|
|
301
|
+
|
|
302
|
+
// Execute nested steps
|
|
303
|
+
for (const nestedStep of step.steps) {
|
|
304
|
+
const result = await executeStep(nestedStep, loopVars, context, options, onProgress);
|
|
305
|
+
totalExecuted += result.stepsExecuted || 1;
|
|
306
|
+
|
|
307
|
+
if (!result.success && onError === 'stop') {
|
|
308
|
+
return { success: false, error: result.error, stepsExecuted: totalExecuted };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Copy captured variables back to parent scope (only from regular steps, not loops)
|
|
313
|
+
for (const nestedStep of step.steps) {
|
|
314
|
+
// Skip loop steps - their 'as' is the loop variable, not an output capture
|
|
315
|
+
const isNestedLoop = nestedStep.repeat !== undefined || nestedStep.each !== undefined;
|
|
316
|
+
if (!isNestedLoop && nestedStep.as && loopVars[nestedStep.as] !== undefined) {
|
|
317
|
+
vars[nestedStep.as] = loopVars[nestedStep.as];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Check `until` condition
|
|
322
|
+
if (step.until) {
|
|
323
|
+
const untilResult = await executeSingleStep(step.until, loopVars, context, options);
|
|
324
|
+
totalExecuted++;
|
|
325
|
+
|
|
326
|
+
// Exit loop if until condition is truthy
|
|
327
|
+
const exitValue = untilResult.output;
|
|
328
|
+
if (exitValue === true || exitValue === 'true' || exitValue) {
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { success: true, stepsExecuted: totalExecuted };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Handle `each` loop
|
|
338
|
+
if (step.each !== undefined) {
|
|
339
|
+
const items = resolveVar(step.each, vars);
|
|
340
|
+
|
|
341
|
+
if (!Array.isArray(items)) {
|
|
342
|
+
return {
|
|
343
|
+
success: false,
|
|
344
|
+
error: `each: expected array, got ${typeof items}${items === undefined ? ' (undefined)' : ''}`,
|
|
345
|
+
stepsExecuted: 0
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!Array.isArray(step.steps) || step.steps.length === 0) {
|
|
350
|
+
return { success: false, error: 'each: steps array required', stepsExecuted: 0 };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Safety cap
|
|
354
|
+
const maxItems = Math.min(items.length, MAX_LOOP_ITERATIONS);
|
|
355
|
+
const itemVar = step.as || 'item';
|
|
356
|
+
let totalExecuted = 0;
|
|
357
|
+
|
|
358
|
+
for (let i = 0; i < maxItems; i++) {
|
|
359
|
+
// Create loop-scoped variables
|
|
360
|
+
const loopVars = { ...vars, [itemVar]: items[i], _index: i, _iteration: i + 1 };
|
|
361
|
+
|
|
362
|
+
// Execute nested steps
|
|
363
|
+
for (const nestedStep of step.steps) {
|
|
364
|
+
const result = await executeStep(nestedStep, loopVars, context, options, onProgress);
|
|
365
|
+
totalExecuted += result.stepsExecuted || 1;
|
|
366
|
+
|
|
367
|
+
if (!result.success && onError === 'stop') {
|
|
368
|
+
return { success: false, error: result.error, stepsExecuted: totalExecuted };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Copy captured variables back to parent scope (only from regular steps, not loops)
|
|
373
|
+
for (const nestedStep of step.steps) {
|
|
374
|
+
// Skip loop steps - their 'as' is the loop variable, not an output capture
|
|
375
|
+
const isNestedLoop = nestedStep.repeat !== undefined || nestedStep.each !== undefined;
|
|
376
|
+
if (!isNestedLoop && nestedStep.as && loopVars[nestedStep.as] !== undefined) {
|
|
377
|
+
vars[nestedStep.as] = loopVars[nestedStep.as];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return { success: true, stepsExecuted: totalExecuted };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Regular step (non-loop)
|
|
386
|
+
if (onProgress) {
|
|
387
|
+
onProgress(step, 'start');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const result = await executeSingleStep(step, vars, context, options);
|
|
391
|
+
|
|
392
|
+
if (onProgress) {
|
|
393
|
+
onProgress(step, result.success ? 'ok' : 'fail', result.error);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { ...result, stepsExecuted: 1 };
|
|
397
|
+
}
|
|
398
|
+
|
|
137
399
|
/**
|
|
138
400
|
* Execute all workflow steps sequentially
|
|
139
|
-
* @param {Array<
|
|
401
|
+
* @param {Array<object>} steps - Steps to execute
|
|
140
402
|
* @param {object} options - Execution options
|
|
141
403
|
* @returns {Promise<object>} - Execution result
|
|
142
404
|
*/
|
|
@@ -147,118 +409,111 @@ async function executeDoSteps(steps, options = {}) {
|
|
|
147
409
|
stepDelay = 100,
|
|
148
410
|
context = {},
|
|
149
411
|
quiet = false, // For --json mode, suppress streaming output
|
|
412
|
+
vars: initialVars = {},
|
|
150
413
|
} = options;
|
|
151
414
|
|
|
152
415
|
const results = [];
|
|
153
|
-
const vars = context.vars || {};
|
|
416
|
+
const vars = { ...initialVars, ...(context.vars || {}) };
|
|
154
417
|
const total = steps.length;
|
|
155
418
|
let failed = 0;
|
|
419
|
+
let stepsExecuted = 0;
|
|
156
420
|
const startTotal = Date.now();
|
|
157
421
|
|
|
158
422
|
for (let i = 0; i < total; i++) {
|
|
159
423
|
const step = steps[i];
|
|
160
424
|
const startTime = Date.now();
|
|
161
|
-
const stepNum = `[${i + 1}/${total}]`;
|
|
162
425
|
|
|
163
|
-
//
|
|
164
|
-
const
|
|
165
|
-
.map(([k, v]) => typeof v === "string" && v.length > 40
|
|
166
|
-
? `${k}="${v.slice(0, 37)}..."`
|
|
167
|
-
: `${k}=${JSON.stringify(v)}`)
|
|
168
|
-
.join(" ");
|
|
169
|
-
const desc = argSummary ? `${step.cmd} ${argSummary}` : step.cmd;
|
|
426
|
+
// Check if this is a loop step
|
|
427
|
+
const isLoop = step.repeat !== undefined || step.each !== undefined;
|
|
170
428
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// Substitute variables in args
|
|
178
|
-
const resolvedArgs = substituteVars(step.args, vars);
|
|
429
|
+
if (isLoop) {
|
|
430
|
+
// Loops handle their own progress output
|
|
431
|
+
if (!quiet) {
|
|
432
|
+
const loopType = step.repeat !== undefined ? `repeat ${step.repeat}` : `each ${step.each}`;
|
|
433
|
+
console.log(`[${i + 1}/${total}] Loop: ${loopType} (${step.steps?.length || 0} nested steps)`);
|
|
434
|
+
}
|
|
179
435
|
|
|
180
|
-
const
|
|
436
|
+
const result = await executeStep(step, vars, context, { onError, autoWait, stepDelay }, null);
|
|
181
437
|
const ms = Date.now() - startTime;
|
|
182
438
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
console.log(`FAIL`);
|
|
188
|
-
console.log(` Error: ${errText}`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
results.push({ step: i + 1, cmd: step.cmd, status: 'error', error: errText, ms });
|
|
439
|
+
stepsExecuted += result.stepsExecuted || 0;
|
|
440
|
+
|
|
441
|
+
if (!result.success) {
|
|
442
|
+
results.push({ step: i + 1, type: 'loop', status: 'error', error: result.error, ms });
|
|
192
443
|
failed++;
|
|
193
444
|
|
|
194
445
|
if (onError === 'stop') {
|
|
195
446
|
return {
|
|
196
447
|
status: 'failed',
|
|
197
|
-
completedSteps:
|
|
448
|
+
completedSteps: stepsExecuted,
|
|
198
449
|
totalSteps: total,
|
|
199
450
|
results,
|
|
200
|
-
error:
|
|
201
|
-
totalMs: Date.now() - startTotal
|
|
451
|
+
error: result.error,
|
|
452
|
+
totalMs: Date.now() - startTotal,
|
|
453
|
+
vars
|
|
202
454
|
};
|
|
203
455
|
}
|
|
204
456
|
} else {
|
|
457
|
+
results.push({ step: i + 1, type: 'loop', status: 'ok', stepsExecuted: result.stepsExecuted, ms });
|
|
205
458
|
if (!quiet) {
|
|
206
|
-
console.log(`
|
|
459
|
+
console.log(` Loop completed: ${result.stepsExecuted} steps (${ms}ms)`);
|
|
207
460
|
}
|
|
208
|
-
|
|
209
|
-
results.push({ step: i + 1, cmd: step.cmd, status: 'ok', ms });
|
|
210
|
-
|
|
211
|
-
// Command-specific auto-wait
|
|
212
|
-
if (autoWait) {
|
|
213
|
-
const waitCmd = getAutoWaitCommand(step.cmd);
|
|
214
|
-
if (waitCmd) {
|
|
215
|
-
const waitArgs = waitCmd === 'wait.load'
|
|
216
|
-
? { timeout: 10000 }
|
|
217
|
-
: { stable: 100, timeout: 5000 };
|
|
218
|
-
try {
|
|
219
|
-
await sendDoRequest(waitCmd, waitArgs, context);
|
|
220
|
-
} catch {
|
|
221
|
-
// Ignore auto-wait failures silently
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Fixed delay between steps
|
|
228
|
-
if (stepDelay > 0 && i < total - 1) {
|
|
229
|
-
await new Promise(r => setTimeout(r, stepDelay));
|
|
230
461
|
}
|
|
231
|
-
}
|
|
232
|
-
|
|
462
|
+
} else {
|
|
463
|
+
// Regular step
|
|
464
|
+
const stepNum = `[${i + 1}/${total}]`;
|
|
465
|
+
const argSummary = Object.entries(step.args || {})
|
|
466
|
+
.map(([k, v]) => typeof v === "string" && v.length > 40
|
|
467
|
+
? `${k}="${v.slice(0, 37)}..."`
|
|
468
|
+
: `${k}=${JSON.stringify(v)}`)
|
|
469
|
+
.join(" ");
|
|
470
|
+
const desc = argSummary ? `${step.cmd} ${argSummary}` : step.cmd;
|
|
233
471
|
|
|
234
472
|
if (!quiet) {
|
|
235
|
-
|
|
236
|
-
console.log(` Error: ${err.message}`);
|
|
473
|
+
process.stdout.write(`${stepNum} ${desc} ... `);
|
|
237
474
|
}
|
|
238
475
|
|
|
239
|
-
|
|
240
|
-
|
|
476
|
+
const result = await executeSingleStep(step, vars, context, { onError, autoWait, stepDelay });
|
|
477
|
+
const ms = Date.now() - startTime;
|
|
478
|
+
stepsExecuted++;
|
|
241
479
|
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
480
|
+
if (!result.success) {
|
|
481
|
+
if (!quiet) {
|
|
482
|
+
console.log('FAIL');
|
|
483
|
+
console.log(` Error: ${result.error}`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
results.push({ step: i + 1, cmd: step.cmd, status: 'error', error: result.error, ms });
|
|
487
|
+
failed++;
|
|
488
|
+
|
|
489
|
+
if (onError === 'stop') {
|
|
490
|
+
return {
|
|
491
|
+
status: 'failed',
|
|
492
|
+
completedSteps: stepsExecuted - 1,
|
|
493
|
+
totalSteps: total,
|
|
494
|
+
results,
|
|
495
|
+
error: result.error,
|
|
496
|
+
totalMs: Date.now() - startTotal,
|
|
497
|
+
vars
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
if (!quiet) {
|
|
502
|
+
console.log(`OK (${ms}ms)`);
|
|
503
|
+
}
|
|
504
|
+
results.push({ step: i + 1, cmd: step.cmd, status: 'ok', ms });
|
|
251
505
|
}
|
|
252
506
|
}
|
|
253
507
|
}
|
|
254
508
|
|
|
255
509
|
return {
|
|
256
510
|
status: failed > 0 ? 'partial' : 'completed',
|
|
257
|
-
completedSteps:
|
|
511
|
+
completedSteps: stepsExecuted,
|
|
258
512
|
totalSteps: total,
|
|
259
513
|
results,
|
|
260
514
|
failed,
|
|
261
|
-
totalMs: Date.now() - startTotal
|
|
515
|
+
totalMs: Date.now() - startTotal,
|
|
516
|
+
vars
|
|
262
517
|
};
|
|
263
518
|
}
|
|
264
519
|
|
|
@@ -268,6 +523,11 @@ module.exports = {
|
|
|
268
523
|
shouldAutoWait,
|
|
269
524
|
getAutoWaitCommand,
|
|
270
525
|
substituteVars,
|
|
526
|
+
resolveVar,
|
|
527
|
+
extractStepOutput,
|
|
528
|
+
executeStep,
|
|
529
|
+
executeSingleStep,
|
|
271
530
|
AUTO_WAIT_COMMANDS,
|
|
272
|
-
AUTO_WAIT_MAP
|
|
531
|
+
AUTO_WAIT_MAP,
|
|
532
|
+
MAX_LOOP_ITERATIONS
|
|
273
533
|
};
|