surf-cli 2.8.0 → 2.10.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 +146 -8
- package/native/abort.cjs +65 -0
- package/native/activity-journal.cjs +55 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +2 -2
- package/native/chatgpt-client.cjs +49 -31
- package/native/cli.cjs +352 -482
- package/native/client-transport.cjs +168 -0
- package/native/do-executor.cjs +68 -510
- package/native/do-parser.cjs +8 -249
- package/native/doctor.cjs +55 -5
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +156 -71
- package/native/grok-client.cjs +98 -89
- package/native/host-helpers.cjs +43 -26
- package/native/host-sessions.cjs +287 -0
- package/native/host.cjs +998 -620
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -65
- package/native/network-export.cjs +116 -0
- package/native/network-store.cjs +38 -58
- package/native/perplexity-client.cjs +46 -17
- package/native/playbook-authoring.cjs +44 -0
- package/native/playbook-cli.cjs +157 -0
- package/native/playbook-client.cjs +259 -0
- package/native/playbook-receipts.cjs +109 -0
- package/native/playbook-records.cjs +208 -0
- package/native/playbook-runtime.cjs +177 -0
- package/native/playbooks.cjs +235 -0
- package/native/private-state.cjs +156 -0
- package/native/redaction.cjs +104 -0
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +1 -1
- package/native/workflow-definition.cjs +368 -0
- package/native/workflow-runtime.cjs +225 -0
- package/package.json +9 -6
- package/playbooks/page/ops/read.json +22 -0
- package/playbooks/page/playbook.json +7 -0
- package/scripts/install-native-host.cjs +36 -5
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +72 -5
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const { abortError } = require("./abort.cjs");
|
|
2
|
+
|
|
3
|
+
class RequestPendingMap extends Map {
|
|
4
|
+
constructor({ getRequest = () => undefined } = {}) {
|
|
5
|
+
super();
|
|
6
|
+
this.getRequest = getRequest;
|
|
7
|
+
this.drainWaiters = new Map();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
set(id, data) {
|
|
11
|
+
const request = data.request || this.getRequest();
|
|
12
|
+
const cleanup = Boolean(data.cleanup || data.tool === "close_tab");
|
|
13
|
+
const entry = { ...data, id, request, cleanup, aborted: false, settled: false };
|
|
14
|
+
if (request) {
|
|
15
|
+
if (!request.pendingEntries) request.pendingEntries = new Set();
|
|
16
|
+
request.pendingEntries.add(entry);
|
|
17
|
+
if (cleanup) entry.abortCleanup = null;
|
|
18
|
+
else {
|
|
19
|
+
const onAbort = () => {
|
|
20
|
+
if (entry.abortNotified || entry.settled) return;
|
|
21
|
+
entry.abortNotified = true;
|
|
22
|
+
entry.aborted = true;
|
|
23
|
+
const error = abortError(request.signal);
|
|
24
|
+
if (entry.reject) entry.reject(error);
|
|
25
|
+
else entry.onAbort?.(error);
|
|
26
|
+
if (!entry.reject && !entry.onAbort) entry.onComplete?.({ error: error.message, cancelled: true });
|
|
27
|
+
};
|
|
28
|
+
entry.abortCleanup = () => request.signal.removeEventListener("abort", onAbort);
|
|
29
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
30
|
+
if (request.signal.aborted) onAbort();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
super.set(id, entry);
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get(id) {
|
|
38
|
+
return super.get(id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
delete(id) {
|
|
42
|
+
const entry = super.get(id);
|
|
43
|
+
if (!entry) return false;
|
|
44
|
+
this.#removeEntry(entry);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#removeEntry(entry, notify = true) {
|
|
49
|
+
super.delete(entry.id);
|
|
50
|
+
entry.abortCleanup?.();
|
|
51
|
+
if (entry.tombstoneTimer) clearTimeout(entry.tombstoneTimer);
|
|
52
|
+
entry.request?.pendingEntries?.delete(entry);
|
|
53
|
+
if (notify) this.#notifyDrain(entry.request);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#notifyDrain(request) {
|
|
57
|
+
if (!request || request.pendingEntries?.size) return;
|
|
58
|
+
const waiters = this.drainWaiters.get(request);
|
|
59
|
+
if (!waiters) return;
|
|
60
|
+
this.drainWaiters.delete(request);
|
|
61
|
+
for (const waiter of waiters) waiter();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onDrain(request, callback) {
|
|
65
|
+
if (!request?.pendingEntries?.size) {
|
|
66
|
+
callback();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const waiters = this.drainWaiters.get(request) || [];
|
|
70
|
+
waiters.push(callback);
|
|
71
|
+
this.drainWaiters.set(request, waiters);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
resolve(id, value) {
|
|
75
|
+
const entry = this.get(id);
|
|
76
|
+
if (!entry) return false;
|
|
77
|
+
this.#removeEntry(entry, false);
|
|
78
|
+
try {
|
|
79
|
+
if (!entry.aborted && !entry.hardBoundary && !entry.settled) {
|
|
80
|
+
entry.settled = true;
|
|
81
|
+
if (entry.resolve) entry.resolve(value);
|
|
82
|
+
else if (entry.onComplete) entry.onComplete(value);
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
this.#notifyDrain(entry.request);
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
expire(id, error) {
|
|
91
|
+
const entry = this.get(id);
|
|
92
|
+
if (!entry) return false;
|
|
93
|
+
if (!entry.settled) {
|
|
94
|
+
entry.settled = true;
|
|
95
|
+
entry.aborted = true;
|
|
96
|
+
entry.reject?.(error);
|
|
97
|
+
const request = entry.request;
|
|
98
|
+
const remaining = request ? Math.max(0, request.startedAt + request.deadlineMs - Date.now()) : 0;
|
|
99
|
+
entry.tombstoneTimer = setTimeout(() => this.delete(entry.id), remaining);
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
reject(id, error) {
|
|
105
|
+
const entry = this.get(id);
|
|
106
|
+
if (!entry) return false;
|
|
107
|
+
this.#removeEntry(entry, false);
|
|
108
|
+
try {
|
|
109
|
+
if (!entry.settled) {
|
|
110
|
+
entry.settled = true;
|
|
111
|
+
entry.reject?.(error);
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
this.#notifyDrain(entry.request);
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
tombstoneAfterAbort(request) {
|
|
120
|
+
if (!request?.pendingEntries) return;
|
|
121
|
+
for (const entry of request.pendingEntries) {
|
|
122
|
+
if (entry.cleanup || entry.tombstoneTimer) continue;
|
|
123
|
+
const remaining = Math.max(0, request.startedAt + request.deadlineMs - Date.now());
|
|
124
|
+
entry.tombstoneTimer = setTimeout(() => this.delete(entry.id), remaining);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
hardDeadline(request) {
|
|
129
|
+
if (!request) return;
|
|
130
|
+
request.hardBoundary = true;
|
|
131
|
+
for (const entry of [...(request.pendingEntries || [])]) {
|
|
132
|
+
entry.hardBoundary = true;
|
|
133
|
+
this.#removeEntry(entry);
|
|
134
|
+
if (!entry.settled) {
|
|
135
|
+
entry.settled = true;
|
|
136
|
+
entry.reject?.(abortError(request.signal, "Request timed out"));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
clear() {
|
|
142
|
+
for (const entry of this.values()) this.#removeEntry(entry);
|
|
143
|
+
this.drainWaiters.clear();
|
|
144
|
+
super.clear();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { RequestPendingMap };
|
package/native/socket-path.cjs
CHANGED
|
@@ -4,7 +4,7 @@ const os = require("os");
|
|
|
4
4
|
const IS_WIN = process.platform === "win32";
|
|
5
5
|
const DEFAULT_SOCKET_PATH = IS_WIN ? "//./pipe/surf" : "/tmp/surf.sock";
|
|
6
6
|
const SOCKET_PATH = process.env.SURF_SOCKET || DEFAULT_SOCKET_PATH;
|
|
7
|
-
const SURF_TMP = IS_WIN ? path.join(os.tmpdir(), "surf") : "/tmp";
|
|
7
|
+
const SURF_TMP = process.env.SURF_TMP || (IS_WIN ? path.join(os.tmpdir(), "surf") : "/tmp");
|
|
8
8
|
|
|
9
9
|
function getSocketTroubleshootingHint() {
|
|
10
10
|
const lines = [
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { isSensitiveName, redactSensitiveFields, redactUrlSecrets } = require("./redaction.cjs");
|
|
5
|
+
|
|
6
|
+
const COMMANDS = {
|
|
7
|
+
ai: { primaryArg: "query", effect: "read", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
|
|
8
|
+
gemini: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
|
|
9
|
+
chatgpt: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
|
|
10
|
+
perplexity: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
|
|
11
|
+
grok: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
|
|
12
|
+
navigate: { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
|
|
13
|
+
go: { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
|
|
14
|
+
back: { effect: "navigation" },
|
|
15
|
+
forward: { effect: "navigation" },
|
|
16
|
+
reload: { effect: "navigation" },
|
|
17
|
+
js: { primaryArg: "code", effect: "unknown", recordable: false, argKinds: { code: "code" }, sensitiveArgs: ["code"] },
|
|
18
|
+
javascript_tool: { primaryArg: "code", effect: "unknown", recordable: false, argKinds: { code: "code" }, sensitiveArgs: ["code"] },
|
|
19
|
+
click: { effect: "page-write", argKinds: { ref: "element-ref", selector: "selector", x: "number", y: "number" } },
|
|
20
|
+
key: { primaryArg: "key", effect: "page-write", argKinds: { key: "key" } },
|
|
21
|
+
submit: { effect: "page-write" },
|
|
22
|
+
hover: { effect: "read", argKinds: { ref: "element-ref", selector: "selector" } },
|
|
23
|
+
scroll: { effect: "page-write", argKinds: { direction: "name", scroll_pixels: "number" } },
|
|
24
|
+
"scroll.top": { effect: "page-write", argKinds: { selector: "selector" } },
|
|
25
|
+
"scroll.bottom": { effect: "page-write", argKinds: { selector: "selector" } },
|
|
26
|
+
"scroll.info": { effect: "read", argKinds: { selector: "selector" } },
|
|
27
|
+
wait: { primaryArg: "duration", effect: "read", recordable: false, argKinds: { duration: "duration" } },
|
|
28
|
+
health: { primaryArg: "url", effect: "read", argKinds: { url: "url" } },
|
|
29
|
+
new_tab: { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
|
|
30
|
+
"tab.new": { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
|
|
31
|
+
switch_tab: { primaryArg: "tab_id", effect: "navigation", argKinds: { tab_id: "tab-id" } },
|
|
32
|
+
"tab.switch": { primaryArg: "id", effect: "navigation", argKinds: { id: "tab-id" } },
|
|
33
|
+
close_tab: { primaryArg: "tab_id", effect: "page-write", argKinds: { tab_id: "tab-id" } },
|
|
34
|
+
"tab.close": { primaryArg: "id", effect: "page-write", argKinds: { id: "tab-id" } },
|
|
35
|
+
"tab.name": { primaryArg: "name", effect: "page-write", argKinds: { name: "name" } },
|
|
36
|
+
"tab.unname": { primaryArg: "name", effect: "page-write", argKinds: { name: "name" } },
|
|
37
|
+
scroll_to_position: { primaryArg: "position", effect: "page-write", argKinds: { position: "position" } },
|
|
38
|
+
type: { primaryArg: "text", effect: "page-write", argKinds: { selector: "selector", text: "user-input" }, sensitiveArgs: ["text"] },
|
|
39
|
+
smart_type: { primaryArg: "text", effect: "page-write", argKinds: { selector: "selector", text: "user-input" }, sensitiveArgs: ["text"] },
|
|
40
|
+
find_and_type: { effect: "page-write", argKinds: { text: "user-input" }, sensitiveArgs: ["text"] },
|
|
41
|
+
form_input: { effect: "page-write", argKinds: { value: "user-input" }, sensitiveArgs: ["value"] },
|
|
42
|
+
"cookie.set": { effect: "page-write", argKinds: { value: "secret" }, sensitiveArgs: ["value"] },
|
|
43
|
+
"emulate.network": { primaryArg: "preset", effect: "page-write", argKinds: { preset: "name" } },
|
|
44
|
+
"emulate.cpu": { primaryArg: "rate", effect: "page-write", argKinds: { rate: "number" } },
|
|
45
|
+
search: { primaryArg: "term", effect: "read", argKinds: { term: "user-input" }, sensitiveArgs: ["term"] },
|
|
46
|
+
"wait.element": { primaryArg: "selector", effect: "read", recordable: false, argKinds: { selector: "selector" } },
|
|
47
|
+
"wait.url": { primaryArg: "pattern", effect: "read", recordable: false, argKinds: { pattern: "url-pattern" } },
|
|
48
|
+
zoom: { primaryArg: "level", effect: "page-write", argKinds: { level: "number" } },
|
|
49
|
+
"history.search": { primaryArg: "query", effect: "read", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
|
|
50
|
+
"network.get": { primaryArg: "id", effect: "read", argKinds: { id: "request-id" } },
|
|
51
|
+
"network.body": { primaryArg: "id", effect: "read", argKinds: { id: "request-id" } },
|
|
52
|
+
"network.curl": { primaryArg: "id", effect: "read", argKinds: { id: "request-id" } },
|
|
53
|
+
"network.path": { primaryArg: "id", effect: "read", argKinds: { id: "request-id" } },
|
|
54
|
+
"page.read": { effect: "read" },
|
|
55
|
+
"page.text": { effect: "read" },
|
|
56
|
+
"page.state": { effect: "read" },
|
|
57
|
+
screenshot: { effect: "read" },
|
|
58
|
+
"window.new": { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
|
|
59
|
+
"window.focus": { primaryArg: "id", effect: "navigation", argKinds: { id: "window-id" } },
|
|
60
|
+
"window.close": { primaryArg: "id", effect: "page-write", argKinds: { id: "window-id" } },
|
|
61
|
+
"locate.role": { primaryArg: "role", effect: "read", argKinds: { role: "role" } },
|
|
62
|
+
"locate.text": { primaryArg: "text", effect: "read", argKinds: { text: "user-input" }, sensitiveArgs: ["text"] },
|
|
63
|
+
"locate.label": { primaryArg: "label", effect: "read", argKinds: { label: "user-input" }, sensitiveArgs: ["label"] },
|
|
64
|
+
"emulate.device": { primaryArg: "device", effect: "page-write", argKinds: { device: "name" } },
|
|
65
|
+
"frame.js": { primaryArg: "code", effect: "unknown", recordable: false, argKinds: { code: "code" }, sensitiveArgs: ["code"] },
|
|
66
|
+
"element.styles": { primaryArg: "selector", effect: "read", argKinds: { selector: "selector" } },
|
|
67
|
+
select: { primaryArg: "selector", effect: "page-write", argKinds: { selector: "selector", values: "user-input" }, sensitiveArgs: ["values"] },
|
|
68
|
+
"form.fill": { effect: "page-write", argKinds: { data: "user-input" }, sensitiveArgs: ["data"] },
|
|
69
|
+
"dialog.accept": { effect: "page-write", argKinds: { text: "user-input" }, sensitiveArgs: ["text"] },
|
|
70
|
+
"dialog.dismiss": { effect: "page-write" },
|
|
71
|
+
"dialog.info": { effect: "read" },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const ALIASES = {
|
|
75
|
+
snap: "screenshot",
|
|
76
|
+
read: "page.read",
|
|
77
|
+
find: "search",
|
|
78
|
+
go: "navigate",
|
|
79
|
+
net: "network",
|
|
80
|
+
"network.dump": "network.get",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const PRIMARY_ARG_MAP = Object.fromEntries(
|
|
84
|
+
Object.entries(COMMANDS).filter(([, value]) => value.primaryArg).map(([name, value]) => [name, value.primaryArg]),
|
|
85
|
+
);
|
|
86
|
+
PRIMARY_ARG_MAP.go = "url";
|
|
87
|
+
PRIMARY_ARG_MAP.find = "term";
|
|
88
|
+
|
|
89
|
+
function commandMetadata(command) {
|
|
90
|
+
const name = ALIASES[command] || command;
|
|
91
|
+
const metadata = COMMANDS[name];
|
|
92
|
+
return {
|
|
93
|
+
name,
|
|
94
|
+
primaryArg: metadata?.primaryArg,
|
|
95
|
+
effect: metadata?.effect || "unknown",
|
|
96
|
+
recordable: Boolean(metadata) && metadata.recordable !== false,
|
|
97
|
+
argKinds: metadata?.argKinds || {},
|
|
98
|
+
sensitiveArgs: metadata?.sensitiveArgs || [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function redactCommandArgs(command, args, includeInputValues = false) {
|
|
103
|
+
const metadata = commandMetadata(command);
|
|
104
|
+
const redacted = redactSensitiveFields({ ...(args || {}) });
|
|
105
|
+
for (const name of metadata.sensitiveArgs) {
|
|
106
|
+
if (!includeInputValues && Object.hasOwn(redacted, name)) redacted[name] = `<${name}>`;
|
|
107
|
+
}
|
|
108
|
+
for (const [name, kind] of Object.entries(metadata.argKinds)) {
|
|
109
|
+
if (kind === "url" && Object.hasOwn(redacted, name)) redacted[name] = redactUrlSecrets(redacted[name]);
|
|
110
|
+
}
|
|
111
|
+
for (const name of Object.keys(redacted)) {
|
|
112
|
+
if (isSensitiveName(name)) redacted[name] = "<redacted>";
|
|
113
|
+
}
|
|
114
|
+
return redacted;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function templateRedactedArgs(value, args = {}) {
|
|
118
|
+
if (typeof value === "string") {
|
|
119
|
+
const match = value.match(/^<([a-z0-9._-]+)>$/);
|
|
120
|
+
if (!match) return value;
|
|
121
|
+
args[match[1]] = { required: true, desc: `Recorded ${match[1]}` };
|
|
122
|
+
return `{{${match[1]}}}`;
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(value)) return value.map((item) => templateRedactedArgs(item, args));
|
|
125
|
+
if (value && typeof value === "object") {
|
|
126
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, templateRedactedArgs(item, args)]));
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function promoteRedactedStepArgs(steps) {
|
|
132
|
+
const args = {};
|
|
133
|
+
return {
|
|
134
|
+
args,
|
|
135
|
+
steps: steps.map((step) => ({ ...step, args: templateRedactedArgs(step.args || {}, args) })),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function tokenize(line) {
|
|
140
|
+
const tokens = [];
|
|
141
|
+
let current = "";
|
|
142
|
+
let inQuote = null;
|
|
143
|
+
for (const ch of line) {
|
|
144
|
+
if (inQuote) {
|
|
145
|
+
if (ch === inQuote) inQuote = null;
|
|
146
|
+
else current += ch;
|
|
147
|
+
} else if (ch === '"' || ch === "'") inQuote = ch;
|
|
148
|
+
else if (ch === " " || ch === "\t") {
|
|
149
|
+
if (current) {
|
|
150
|
+
tokens.push(current);
|
|
151
|
+
current = "";
|
|
152
|
+
}
|
|
153
|
+
} else current += ch;
|
|
154
|
+
}
|
|
155
|
+
if (current) tokens.push(current);
|
|
156
|
+
return tokens;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function coerceValue(value) {
|
|
160
|
+
if (value === "true") return true;
|
|
161
|
+
if (value === "false") return false;
|
|
162
|
+
if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10);
|
|
163
|
+
if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value);
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseCommandLine(line) {
|
|
168
|
+
const tokens = tokenize(line);
|
|
169
|
+
if (tokens.length === 0) return null;
|
|
170
|
+
let cmd = ALIASES[tokens[0]] || tokens[0];
|
|
171
|
+
const args = {};
|
|
172
|
+
let i = 1;
|
|
173
|
+
if (i < tokens.length && !tokens[i].startsWith("--")) {
|
|
174
|
+
const firstArg = tokens[i];
|
|
175
|
+
if (cmd === "click") {
|
|
176
|
+
if (/^e\d+$/.test(firstArg)) {
|
|
177
|
+
args.ref = firstArg;
|
|
178
|
+
i++;
|
|
179
|
+
} else if (/^\d+$/.test(firstArg) && /^\d+$/.test(tokens[i + 1] || "")) {
|
|
180
|
+
args.x = Number.parseInt(firstArg, 10);
|
|
181
|
+
args.y = Number.parseInt(tokens[i + 1], 10);
|
|
182
|
+
i += 2;
|
|
183
|
+
}
|
|
184
|
+
} else if (cmd === "select") {
|
|
185
|
+
args.selector = firstArg;
|
|
186
|
+
i++;
|
|
187
|
+
const values = [];
|
|
188
|
+
while (i < tokens.length && !tokens[i].startsWith("--")) values.push(tokens[i++]);
|
|
189
|
+
if (values.length === 1) args.values = values[0];
|
|
190
|
+
else if (values.length > 1) args.values = values;
|
|
191
|
+
} else if (cmd === "scroll") {
|
|
192
|
+
if (firstArg === "top" || firstArg === "bottom") {
|
|
193
|
+
cmd = `scroll.${firstArg}`;
|
|
194
|
+
i++;
|
|
195
|
+
} else if (["up", "down", "left", "right"].includes(firstArg)) {
|
|
196
|
+
args.direction = firstArg;
|
|
197
|
+
i++;
|
|
198
|
+
if (/^-?\d+$/.test(tokens[i] || "")) args.scroll_pixels = Number.parseInt(tokens[i++], 10);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
const primaryKey = PRIMARY_ARG_MAP[cmd];
|
|
202
|
+
if (primaryKey) {
|
|
203
|
+
args[primaryKey] = firstArg;
|
|
204
|
+
i++;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
while (i < tokens.length) {
|
|
209
|
+
const token = tokens[i];
|
|
210
|
+
if (!token.startsWith("--")) {
|
|
211
|
+
i++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const key = token.slice(2);
|
|
215
|
+
const next = tokens[i + 1];
|
|
216
|
+
if (next && !next.startsWith("--")) {
|
|
217
|
+
args[key] = coerceValue(next);
|
|
218
|
+
i += 2;
|
|
219
|
+
} else {
|
|
220
|
+
args[key] = true;
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { cmd, args };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseDoCommands(input) {
|
|
228
|
+
const hasPipe = input.includes("|");
|
|
229
|
+
const normalized = hasPipe ? input : input.replace(/\\n/g, "\n");
|
|
230
|
+
return normalized
|
|
231
|
+
.split(hasPipe ? "|" : "\n")
|
|
232
|
+
.map((line) => line.trim())
|
|
233
|
+
.filter((line) => line && !line.startsWith("#"))
|
|
234
|
+
.map(parseCommandLine)
|
|
235
|
+
.filter(Boolean);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getWorkflowDirs({ cwd = process.cwd(), home = os.homedir() } = {}) {
|
|
239
|
+
return [
|
|
240
|
+
{ path: path.join(cwd, ".surf", "workflows"), scope: "project" },
|
|
241
|
+
{ path: path.join(home, ".surf", "workflows"), scope: "user" },
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function resolveWorkflow(nameOrPath, options = {}) {
|
|
246
|
+
if (nameOrPath.includes("|")) return { type: "inline", content: nameOrPath };
|
|
247
|
+
if (nameOrPath.includes("/") || nameOrPath.includes("\\") || nameOrPath.endsWith(".json")) {
|
|
248
|
+
return fs.existsSync(nameOrPath) ? { type: "file", path: nameOrPath } : { type: "not_found", name: nameOrPath };
|
|
249
|
+
}
|
|
250
|
+
for (const { path: dir } of getWorkflowDirs(options)) {
|
|
251
|
+
const filePath = path.join(dir, `${nameOrPath}.json`);
|
|
252
|
+
if (fs.existsSync(filePath)) return { type: "file", path: filePath };
|
|
253
|
+
}
|
|
254
|
+
return { type: "not_found", name: nameOrPath };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeStep(step) {
|
|
258
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) throw new Error("workflow step must be an object");
|
|
259
|
+
if (step.repeat !== undefined || step.each !== undefined) {
|
|
260
|
+
if (!Array.isArray(step.steps) || step.steps.length === 0) throw new Error("loop must have a non-empty 'steps' array");
|
|
261
|
+
return {
|
|
262
|
+
...step,
|
|
263
|
+
steps: step.steps.map(normalizeStep),
|
|
264
|
+
...(step.until ? { until: normalizeStep(step.until) } : {}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const cmd = step.tool || step.cmd;
|
|
268
|
+
if (typeof cmd !== "string" || !cmd) throw new Error("workflow step must have a 'tool' field");
|
|
269
|
+
return { cmd: ALIASES[cmd] || cmd, args: step.args || {}, ...(step.as ? { as: step.as } : {}) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function normalizeWorkflow(workflow) {
|
|
273
|
+
if (!workflow || typeof workflow !== "object" || Array.isArray(workflow)) throw new Error("workflow must be an object");
|
|
274
|
+
if (!Array.isArray(workflow.steps)) throw new Error("Workflow must have a 'steps' array");
|
|
275
|
+
if (workflow.steps.length === 0) throw new Error("Workflow has no steps");
|
|
276
|
+
if (workflow.args !== undefined && (!workflow.args || typeof workflow.args !== "object" || Array.isArray(workflow.args))) {
|
|
277
|
+
throw new Error("'args' must be an object");
|
|
278
|
+
}
|
|
279
|
+
return { ...workflow, args: workflow.args || {}, steps: workflow.steps.map(normalizeStep) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function validateWorkflowArgs(workflow, providedArgs) {
|
|
283
|
+
const errors = [];
|
|
284
|
+
for (const [name, spec] of Object.entries(workflow.args || {})) {
|
|
285
|
+
if (spec.required && providedArgs[name] === undefined) errors.push(`Missing required argument: --${name}`);
|
|
286
|
+
}
|
|
287
|
+
return errors;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function applyArgDefaults(workflow, providedArgs) {
|
|
291
|
+
const vars = { ...providedArgs };
|
|
292
|
+
for (const [name, spec] of Object.entries(workflow.args || {})) {
|
|
293
|
+
if (vars[name] === undefined && spec.default !== undefined) vars[name] = spec.default;
|
|
294
|
+
}
|
|
295
|
+
return vars;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function validateWorkflowFile(filePath) {
|
|
299
|
+
if (!fs.existsSync(filePath)) return { valid: false, error: `File not found: ${filePath}` };
|
|
300
|
+
try {
|
|
301
|
+
const workflow = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
302
|
+
normalizeWorkflow(workflow);
|
|
303
|
+
return { valid: true, workflow };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return { valid: false, error: error instanceof SyntaxError ? `Invalid JSON: ${error.message}` : error.message };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function listWorkflows(options = {}) {
|
|
310
|
+
const workflows = [];
|
|
311
|
+
for (const { path: dir, scope } of getWorkflowDirs(options)) {
|
|
312
|
+
if (!fs.existsSync(dir)) continue;
|
|
313
|
+
for (const file of fs.readdirSync(dir).filter((name) => name.endsWith(".json"))) {
|
|
314
|
+
const filePath = path.join(dir, file);
|
|
315
|
+
try {
|
|
316
|
+
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
317
|
+
workflows.push({ name: content.name || file.slice(0, -5), description: content.description || "", scope, path: filePath, args: content.args, stepCount: content.steps?.length || 0 });
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return workflows;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function getWorkflowInfo(name, options = {}) {
|
|
325
|
+
const resolved = resolveWorkflow(name, options);
|
|
326
|
+
if (resolved.type === "not_found") return { error: `Workflow not found: ${name}` };
|
|
327
|
+
if (resolved.type === "inline") return { error: "Cannot get info for inline workflows" };
|
|
328
|
+
try {
|
|
329
|
+
const content = JSON.parse(fs.readFileSync(resolved.path, "utf8"));
|
|
330
|
+
return { name: content.name || name, description: content.description || "", args: content.args || {}, steps: content.steps || [], path: resolved.path };
|
|
331
|
+
} catch (error) {
|
|
332
|
+
return { error: `Failed to parse workflow: ${error.message}` };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function formatStep(step, indent = 0) {
|
|
337
|
+
const pad = " ".repeat(indent);
|
|
338
|
+
if (step.repeat !== undefined || step.each !== undefined) {
|
|
339
|
+
const label = step.repeat !== undefined ? `repeat ${step.repeat} times:` : `each ${step.each} as ${step.as || "item"}:`;
|
|
340
|
+
const lines = [`${pad}${label}`, ...(step.steps || []).map((nested) => formatStep(nested, indent + 1))];
|
|
341
|
+
if (step.until) lines.push(`${pad} until: ${step.until.tool || step.until.cmd}`);
|
|
342
|
+
return lines.join("\n");
|
|
343
|
+
}
|
|
344
|
+
const tool = step.tool || step.cmd;
|
|
345
|
+
const argStr = Object.entries(step.args || {}).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(" ");
|
|
346
|
+
return `${pad}${tool}${argStr ? ` ${argStr}` : ""}${step.as ? ` → ${step.as}` : ""}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
module.exports = {
|
|
350
|
+
ALIASES,
|
|
351
|
+
PRIMARY_ARG_MAP,
|
|
352
|
+
applyArgDefaults,
|
|
353
|
+
commandMetadata,
|
|
354
|
+
formatStep,
|
|
355
|
+
getWorkflowDirs,
|
|
356
|
+
getWorkflowInfo,
|
|
357
|
+
listWorkflows,
|
|
358
|
+
normalizeStep,
|
|
359
|
+
normalizeWorkflow,
|
|
360
|
+
parseCommandLine,
|
|
361
|
+
parseDoCommands,
|
|
362
|
+
promoteRedactedStepArgs,
|
|
363
|
+
redactCommandArgs,
|
|
364
|
+
resolveWorkflow,
|
|
365
|
+
tokenize,
|
|
366
|
+
validateWorkflowArgs,
|
|
367
|
+
validateWorkflowFile,
|
|
368
|
+
};
|