sf-builder-agent 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.d.ts +19 -0
- package/dist/browser.js +258 -0
- package/dist/browser.js.map +1 -0
- package/dist/heartbeat.d.ts +6 -0
- package/dist/heartbeat.js +35 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +160 -0
- package/dist/index.js.map +1 -0
- package/dist/pairing.d.ts +7 -0
- package/dist/pairing.js +21 -0
- package/dist/pairing.js.map +1 -0
- package/dist/reporter.d.ts +4 -0
- package/dist/reporter.js +24 -0
- package/dist/reporter.js.map +1 -0
- package/dist/sse-client.d.ts +13 -0
- package/dist/sse-client.js +59 -0
- package/dist/sse-client.js.map +1 -0
- package/package.json +23 -0
- package/src/browser.ts +338 -0
- package/src/heartbeat.ts +48 -0
- package/src/index.ts +206 -0
- package/src/pairing.ts +32 -0
- package/src/reporter.ts +35 -0
- package/src/sse-client.ts +87 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE client — listens for browser-automation commands from the server.
|
|
3
|
+
*/
|
|
4
|
+
import { EventSource } from "eventsource";
|
|
5
|
+
export function connectSSE(baseUrl, sessionId, token, onCommand, onError) {
|
|
6
|
+
const url = `${baseUrl}/api/sessions/${sessionId}/agent/commands?token=${encodeURIComponent(token)}`;
|
|
7
|
+
let es = null;
|
|
8
|
+
let closed = false;
|
|
9
|
+
let retryDelay = 1_000;
|
|
10
|
+
const MAX_RETRY = 30_000;
|
|
11
|
+
function connect() {
|
|
12
|
+
if (closed)
|
|
13
|
+
return;
|
|
14
|
+
es = new EventSource(url);
|
|
15
|
+
es.addEventListener("command", (evt) => {
|
|
16
|
+
try {
|
|
17
|
+
const data = JSON.parse(evt.data);
|
|
18
|
+
onCommand(data);
|
|
19
|
+
// Reset backoff on successful message
|
|
20
|
+
retryDelay = 1_000;
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
console.error("\u274C Failed to parse command:", err instanceof Error ? err.message : err);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
es.addEventListener("ping", () => {
|
|
27
|
+
// Keep-alive — nothing to do
|
|
28
|
+
});
|
|
29
|
+
es.addEventListener("open", () => {
|
|
30
|
+
console.log("\u2705 Connected to command stream");
|
|
31
|
+
retryDelay = 1_000;
|
|
32
|
+
});
|
|
33
|
+
es.onerror = (err) => {
|
|
34
|
+
if (closed)
|
|
35
|
+
return;
|
|
36
|
+
const message = err && typeof err === "object" && "message" in err
|
|
37
|
+
? err.message
|
|
38
|
+
: "SSE connection error";
|
|
39
|
+
console.error(`\u274C SSE error: ${message} — reconnecting in ${retryDelay / 1_000}s`);
|
|
40
|
+
onError(new Error(message));
|
|
41
|
+
// Close the broken connection and schedule reconnect
|
|
42
|
+
es?.close();
|
|
43
|
+
es = null;
|
|
44
|
+
setTimeout(() => {
|
|
45
|
+
retryDelay = Math.min(retryDelay * 2, MAX_RETRY);
|
|
46
|
+
connect();
|
|
47
|
+
}, retryDelay);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
connect();
|
|
51
|
+
return {
|
|
52
|
+
close() {
|
|
53
|
+
closed = true;
|
|
54
|
+
es?.close();
|
|
55
|
+
es = null;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=sse-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse-client.js","sourceRoot":"","sources":["../src/sse-client.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAS1C,MAAM,UAAU,UAAU,CACxB,OAAe,EACf,SAAiB,EACjB,KAAa,EACb,SAA6C,EAC7C,OAA+B;IAE/B,MAAM,GAAG,GAAG,GAAG,OAAO,iBAAiB,SAAS,yBAAyB,kBAAkB,CAAC,KAAK,CAAC,EAAE,CAAC;IAErG,IAAI,EAAE,GAAuB,IAAI,CAAC;IAClC,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,IAAI,UAAU,GAAG,KAAK,CAAC;IACvB,MAAM,SAAS,GAAG,MAAM,CAAC;IAEzB,SAAS,OAAO;QACd,IAAI,MAAM;YAAE,OAAO;QAEnB,EAAE,GAAG,IAAI,WAAW,CAAC,GAAG,CAAC,CAAC;QAE1B,EAAE,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,GAAiB,EAAE,EAAE;YACnD,IAAI,CAAC;gBACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAoB,CAAC;gBACrD,SAAS,CAAC,IAAI,CAAC,CAAC;gBAChB,sCAAsC;gBACtC,UAAU,GAAG,KAAK,CAAC;YACrB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,OAAO,CAAC,KAAK,CACX,iCAAiC,EACjC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CACzC,CAAC;YACJ,CAAC;QACH,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE;YAC/B,6BAA6B;QAC/B,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE;YAC/B,OAAO,CAAC,GAAG,CAAC,oCAAoC,CAAC,CAAC;YAClD,UAAU,GAAG,KAAK,CAAC;QACrB,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,OAAO,GAAG,CAAC,GAAU,EAAE,EAAE;YAC1B,IAAI,MAAM;gBAAE,OAAO;YAEnB,MAAM,OAAO,GACX,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,SAAS,IAAI,GAAG;gBAChD,CAAC,CAAE,GAAsC,CAAC,OAAO;gBACjD,CAAC,CAAC,sBAAsB,CAAC;YAE7B,OAAO,CAAC,KAAK,CAAC,qBAAqB,OAAO,uBAAuB,UAAU,GAAG,KAAK,GAAG,CAAC,CAAC;YACxF,OAAO,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;YAE5B,qDAAqD;YACrD,EAAE,EAAE,KAAK,EAAE,CAAC;YACZ,EAAE,GAAG,IAAI,CAAC;YAEV,UAAU,CAAC,GAAG,EAAE;gBACd,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,GAAG,CAAC,EAAE,SAAS,CAAC,CAAC;gBACjD,OAAO,EAAE,CAAC;YACZ,CAAC,EAAE,UAAU,CAAC,CAAC;QACjB,CAAC,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,CAAC;IAEV,OAAO;QACL,KAAK;YACH,MAAM,GAAG,IAAI,CAAC;YACd,EAAE,EAAE,KAAK,EAAE,CAAC;YACZ,EAAE,GAAG,IAAI,CAAC;QACZ,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sf-builder-agent",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Desktop agent for SalesForce Agent Creator — executes browser automation commands",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sf-builder-agent": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/index.js",
|
|
12
|
+
"dev": "tsx src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"puppeteer": "^23.0.0",
|
|
16
|
+
"eventsource": "^3.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22.0.0",
|
|
20
|
+
"typescript": "^5.8.0",
|
|
21
|
+
"tsx": "^4.19.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser — Puppeteer launcher and command executor.
|
|
3
|
+
*
|
|
4
|
+
* Handles all CommandType operations including shadow-DOM traversal,
|
|
5
|
+
* label-based element lookup, and screenshot capture.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import puppeteer, { type Browser, type Page, type ElementHandle } from "puppeteer";
|
|
9
|
+
|
|
10
|
+
// ─── Launch ──────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export async function launchBrowser(): Promise<{ browser: Browser; page: Page }> {
|
|
13
|
+
const browser = await puppeteer.launch({
|
|
14
|
+
headless: false,
|
|
15
|
+
defaultViewport: { width: 1440, height: 900 },
|
|
16
|
+
args: [
|
|
17
|
+
"--start-maximized",
|
|
18
|
+
"--disable-blink-features=AutomationControlled",
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const pages = await browser.pages();
|
|
23
|
+
const page = pages[0] ?? (await browser.newPage());
|
|
24
|
+
|
|
25
|
+
return { browser, page };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Walk through shadow roots and resolve the final selector.
|
|
32
|
+
*/
|
|
33
|
+
async function findElement(
|
|
34
|
+
page: Page,
|
|
35
|
+
selector: string,
|
|
36
|
+
shadowPath?: string[]
|
|
37
|
+
): Promise<ElementHandle<Element>> {
|
|
38
|
+
if (shadowPath && shadowPath.length > 0) {
|
|
39
|
+
// Walk shadow DOM tree
|
|
40
|
+
let root = await page.evaluateHandle(() => document as unknown as Element);
|
|
41
|
+
|
|
42
|
+
for (const pathSelector of shadowPath) {
|
|
43
|
+
root = await page.evaluateHandle(
|
|
44
|
+
(el: Element, sel: string) => {
|
|
45
|
+
const host = el.querySelector(sel);
|
|
46
|
+
if (!host) throw new Error(`Shadow host not found: ${sel}`);
|
|
47
|
+
return (host.shadowRoot as unknown as Element) ?? host;
|
|
48
|
+
},
|
|
49
|
+
root,
|
|
50
|
+
pathSelector
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handle = await page.evaluateHandle(
|
|
55
|
+
(el: Element, sel: string) => {
|
|
56
|
+
const found = el.querySelector(sel);
|
|
57
|
+
if (!found) throw new Error(`Element not found in shadow DOM: ${sel}`);
|
|
58
|
+
return found;
|
|
59
|
+
},
|
|
60
|
+
root,
|
|
61
|
+
selector
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return handle as ElementHandle<Element>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const el = await page.$(selector);
|
|
68
|
+
if (!el) throw new Error(`Element not found: ${selector}`);
|
|
69
|
+
return el;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Try to locate an input/textarea/select by its associated <label> text.
|
|
74
|
+
*/
|
|
75
|
+
async function findByLabel(
|
|
76
|
+
page: Page,
|
|
77
|
+
label: string
|
|
78
|
+
): Promise<ElementHandle<Element> | null> {
|
|
79
|
+
const handle = await page.evaluateHandle((labelText: string) => {
|
|
80
|
+
const labels = [...document.querySelectorAll("label")];
|
|
81
|
+
const match = labels.find((l) =>
|
|
82
|
+
l.textContent?.trim().includes(labelText)
|
|
83
|
+
);
|
|
84
|
+
if (!match) return null;
|
|
85
|
+
|
|
86
|
+
// If the label has a `for` attribute, use it
|
|
87
|
+
if (match.htmlFor) {
|
|
88
|
+
return document.getElementById(match.htmlFor);
|
|
89
|
+
}
|
|
90
|
+
// Otherwise look for an input nested inside the label
|
|
91
|
+
return match.querySelector("input, textarea, select");
|
|
92
|
+
}, label);
|
|
93
|
+
|
|
94
|
+
// evaluateHandle returns a JSHandle — check if the inner value is null
|
|
95
|
+
const element = handle.asElement();
|
|
96
|
+
if (!element) {
|
|
97
|
+
await handle.dispose();
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return element as ElementHandle<Element>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Find an element by its visible text content.
|
|
106
|
+
*/
|
|
107
|
+
async function findByText(
|
|
108
|
+
page: Page,
|
|
109
|
+
selector: string,
|
|
110
|
+
text: string
|
|
111
|
+
): Promise<ElementHandle<Element>> {
|
|
112
|
+
const handle = await page.evaluateHandle(
|
|
113
|
+
(sel: string, txt: string) => {
|
|
114
|
+
const candidates = [...document.querySelectorAll(sel || "*")];
|
|
115
|
+
const match = candidates.find((el) =>
|
|
116
|
+
el.textContent?.trim().includes(txt)
|
|
117
|
+
);
|
|
118
|
+
if (!match) throw new Error(`No element with text "${txt}" found`);
|
|
119
|
+
return match;
|
|
120
|
+
},
|
|
121
|
+
selector,
|
|
122
|
+
text
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return handle as ElementHandle<Element>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Command Executor ────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export interface CommandResult {
|
|
131
|
+
success: boolean;
|
|
132
|
+
result?: unknown;
|
|
133
|
+
error?: string;
|
|
134
|
+
screenshot?: string;
|
|
135
|
+
shutdown?: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function executeCommand(
|
|
139
|
+
page: Page,
|
|
140
|
+
commandType: string,
|
|
141
|
+
payload: Record<string, unknown>
|
|
142
|
+
): Promise<CommandResult> {
|
|
143
|
+
try {
|
|
144
|
+
switch (commandType) {
|
|
145
|
+
// ── navigate ──
|
|
146
|
+
case "navigate": {
|
|
147
|
+
const url = payload.url as string;
|
|
148
|
+
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
|
|
149
|
+
return { success: true, result: { url } };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── click ──
|
|
153
|
+
case "click": {
|
|
154
|
+
const selector = payload.selector as string;
|
|
155
|
+
const shadowPath = payload.shadowPath as string[] | undefined;
|
|
156
|
+
const text = payload.text as string | undefined;
|
|
157
|
+
|
|
158
|
+
let el: ElementHandle<Element>;
|
|
159
|
+
if (text) {
|
|
160
|
+
el = await findByText(page, selector, text);
|
|
161
|
+
} else {
|
|
162
|
+
el = await findElement(page, selector, shadowPath);
|
|
163
|
+
}
|
|
164
|
+
await el.click();
|
|
165
|
+
return { success: true };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── fill ──
|
|
169
|
+
case "fill": {
|
|
170
|
+
const selector = payload.selector as string;
|
|
171
|
+
const value = payload.value as string;
|
|
172
|
+
const shadowPath = payload.shadowPath as string[] | undefined;
|
|
173
|
+
const label = payload.label as string | undefined;
|
|
174
|
+
|
|
175
|
+
let el: ElementHandle<Element> | null = null;
|
|
176
|
+
|
|
177
|
+
// Try label-based lookup first
|
|
178
|
+
if (label) {
|
|
179
|
+
el = await findByLabel(page, label);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Fall back to selector / shadow path
|
|
183
|
+
if (!el) {
|
|
184
|
+
el = await findElement(page, selector, shadowPath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Clear existing value then type the new one
|
|
188
|
+
await el.click({ clickCount: 3 }); // select all
|
|
189
|
+
await el.press("Backspace");
|
|
190
|
+
await el.type(value);
|
|
191
|
+
return { success: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── select ──
|
|
195
|
+
case "select": {
|
|
196
|
+
const selector = payload.selector as string;
|
|
197
|
+
const value = payload.value as string;
|
|
198
|
+
const shadowPath = payload.shadowPath as string[] | undefined;
|
|
199
|
+
|
|
200
|
+
if (shadowPath && shadowPath.length > 0) {
|
|
201
|
+
const el = await findElement(page, selector, shadowPath);
|
|
202
|
+
await page.evaluate(
|
|
203
|
+
(selectEl: Element, val: string) => {
|
|
204
|
+
(selectEl as HTMLSelectElement).value = val;
|
|
205
|
+
selectEl.dispatchEvent(new Event("change", { bubbles: true }));
|
|
206
|
+
},
|
|
207
|
+
el,
|
|
208
|
+
value
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
await page.select(selector, value);
|
|
212
|
+
}
|
|
213
|
+
return { success: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── screenshot ──
|
|
217
|
+
case "screenshot": {
|
|
218
|
+
const fullPage = (payload.fullPage as boolean) ?? false;
|
|
219
|
+
const buf = await page.screenshot({
|
|
220
|
+
fullPage,
|
|
221
|
+
encoding: "base64",
|
|
222
|
+
});
|
|
223
|
+
return { success: true, screenshot: buf as string };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── evaluate ──
|
|
227
|
+
case "evaluate": {
|
|
228
|
+
const script = payload.script as string;
|
|
229
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
230
|
+
const result = await page.evaluate(script);
|
|
231
|
+
return { success: true, result };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── waitForSelector ──
|
|
235
|
+
case "waitForSelector": {
|
|
236
|
+
const selector = payload.selector as string;
|
|
237
|
+
const timeout = (payload.timeout as number) ?? 10_000;
|
|
238
|
+
const shadowPath = payload.shadowPath as string[] | undefined;
|
|
239
|
+
|
|
240
|
+
if (shadowPath && shadowPath.length > 0) {
|
|
241
|
+
// Poll for element inside shadow DOM
|
|
242
|
+
const deadline = Date.now() + timeout;
|
|
243
|
+
let found = false;
|
|
244
|
+
while (Date.now() < deadline) {
|
|
245
|
+
try {
|
|
246
|
+
await findElement(page, selector, shadowPath);
|
|
247
|
+
found = true;
|
|
248
|
+
break;
|
|
249
|
+
} catch {
|
|
250
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!found) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Timed out waiting for ${selector} in shadow DOM`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
await page.waitForSelector(selector, { timeout });
|
|
260
|
+
}
|
|
261
|
+
return { success: true };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── waitForNavigation ──
|
|
265
|
+
case "waitForNavigation": {
|
|
266
|
+
const timeout = (payload.timeout as number) ?? 15_000;
|
|
267
|
+
await page.waitForNavigation({ timeout });
|
|
268
|
+
return { success: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── keyboard ──
|
|
272
|
+
case "keyboard": {
|
|
273
|
+
const key = payload.key as string;
|
|
274
|
+
await page.keyboard.press(key as Parameters<typeof page.keyboard.press>[0]);
|
|
275
|
+
return { success: true };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── scroll ──
|
|
279
|
+
case "scroll": {
|
|
280
|
+
const selector = (payload.selector as string) ?? null;
|
|
281
|
+
const direction = payload.direction as "up" | "down";
|
|
282
|
+
const amount = (payload.amount as number) ?? 300;
|
|
283
|
+
|
|
284
|
+
await page.evaluate(
|
|
285
|
+
(sel: string | null, dir: string, amt: number) => {
|
|
286
|
+
const target = sel ? document.querySelector(sel) : window;
|
|
287
|
+
if (!target) throw new Error(`Scroll target not found: ${sel}`);
|
|
288
|
+
|
|
289
|
+
const delta = dir === "down" ? amt : -amt;
|
|
290
|
+
if (target === window) {
|
|
291
|
+
window.scrollBy(0, delta);
|
|
292
|
+
} else {
|
|
293
|
+
(target as Element).scrollBy(0, delta);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
selector,
|
|
297
|
+
direction,
|
|
298
|
+
amount
|
|
299
|
+
);
|
|
300
|
+
return { success: true };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── hover ──
|
|
304
|
+
case "hover": {
|
|
305
|
+
const selector = payload.selector as string;
|
|
306
|
+
const shadowPath = payload.shadowPath as string[] | undefined;
|
|
307
|
+
const el = await findElement(page, selector, shadowPath);
|
|
308
|
+
await el.hover();
|
|
309
|
+
return { success: true };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── delay ──
|
|
313
|
+
case "delay": {
|
|
314
|
+
const ms = (payload.ms as number) ?? 1_000;
|
|
315
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
316
|
+
return { success: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── ping ──
|
|
320
|
+
case "ping": {
|
|
321
|
+
return { success: true };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── shutdown ──
|
|
325
|
+
case "shutdown": {
|
|
326
|
+
return { success: true, shutdown: true };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
default:
|
|
330
|
+
return { success: false, error: `Unknown command type: ${commandType}` };
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
return {
|
|
334
|
+
success: false,
|
|
335
|
+
error: err instanceof Error ? err.message : String(err),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
package/src/heartbeat.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat — periodically pings the server so it knows we're alive.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function startHeartbeat(
|
|
6
|
+
baseUrl: string,
|
|
7
|
+
sessionId: string,
|
|
8
|
+
token: string,
|
|
9
|
+
intervalMs = 10_000
|
|
10
|
+
): { stop: () => void } {
|
|
11
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
12
|
+
|
|
13
|
+
const tick = async () => {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(
|
|
16
|
+
`${baseUrl}/api/sessions/${sessionId}/agent/heartbeat`,
|
|
17
|
+
{
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
if (res.ok) {
|
|
23
|
+
console.log("\uD83D\uDC93 Heartbeat sent");
|
|
24
|
+
} else {
|
|
25
|
+
console.error(`\u274C Heartbeat rejected (${res.status})`);
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error(
|
|
29
|
+
"\u274C Heartbeat error:",
|
|
30
|
+
err instanceof Error ? err.message : err
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
timer = setInterval(tick, intervalMs);
|
|
36
|
+
|
|
37
|
+
// Send an initial heartbeat immediately
|
|
38
|
+
tick();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
stop() {
|
|
42
|
+
if (timer) {
|
|
43
|
+
clearInterval(timer);
|
|
44
|
+
timer = null;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sf-builder-agent — Desktop CLI for SalesForce Agent Creator.
|
|
5
|
+
*
|
|
6
|
+
* Connects to the web app, receives browser automation commands via SSE,
|
|
7
|
+
* executes them in a visible Chrome window, and reports results back.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createInterface } from "node:readline/promises";
|
|
11
|
+
import { stdin, stdout } from "node:process";
|
|
12
|
+
import { pair } from "./pairing.js";
|
|
13
|
+
import { connectSSE } from "./sse-client.js";
|
|
14
|
+
import { launchBrowser, executeCommand } from "./browser.js";
|
|
15
|
+
import { reportResult } from "./reporter.js";
|
|
16
|
+
import { startHeartbeat } from "./heartbeat.js";
|
|
17
|
+
import type { Browser } from "puppeteer";
|
|
18
|
+
|
|
19
|
+
// ─── Arg Parsing ─────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function parseArgs(argv: string[]): {
|
|
22
|
+
code?: string;
|
|
23
|
+
session?: string;
|
|
24
|
+
url: string;
|
|
25
|
+
} {
|
|
26
|
+
const args: Record<string, string> = {};
|
|
27
|
+
for (let i = 2; i < argv.length; i++) {
|
|
28
|
+
const key = argv[i];
|
|
29
|
+
const val = argv[i + 1];
|
|
30
|
+
if (key === "--code" && val) {
|
|
31
|
+
args.code = val;
|
|
32
|
+
i++;
|
|
33
|
+
} else if (key === "--session" && val) {
|
|
34
|
+
args.session = val;
|
|
35
|
+
i++;
|
|
36
|
+
} else if (key === "--url" && val) {
|
|
37
|
+
args.url = val;
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
code: args.code,
|
|
43
|
+
session: args.session,
|
|
44
|
+
url: args.url ?? "https://sf-builder.cmgfinancial.ai",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Prompt Helper ───────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
async function prompt(question: string): Promise<string> {
|
|
51
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
52
|
+
try {
|
|
53
|
+
const answer = await rl.question(question);
|
|
54
|
+
return answer.trim();
|
|
55
|
+
} finally {
|
|
56
|
+
rl.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Main ────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
async function main() {
|
|
63
|
+
const args = parseArgs(process.argv);
|
|
64
|
+
|
|
65
|
+
let code = args.code;
|
|
66
|
+
let sessionId = args.session;
|
|
67
|
+
const baseUrl = args.url;
|
|
68
|
+
|
|
69
|
+
// Interactive prompts if args are missing
|
|
70
|
+
if (!sessionId) {
|
|
71
|
+
sessionId = await prompt("Enter session ID: ");
|
|
72
|
+
if (!sessionId) {
|
|
73
|
+
console.error("\u274C Session ID is required");
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!code) {
|
|
78
|
+
code = await prompt("Enter pairing code: ");
|
|
79
|
+
if (!code) {
|
|
80
|
+
console.error("\u274C Pairing code is required");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Step 1: Pair ──
|
|
86
|
+
console.log("\uD83D\uDD17 Pairing...");
|
|
87
|
+
let token: string;
|
|
88
|
+
try {
|
|
89
|
+
const pairResult = await pair(baseUrl, sessionId, code);
|
|
90
|
+
token = pairResult.token;
|
|
91
|
+
sessionId = pairResult.sessionId;
|
|
92
|
+
console.log("\u2705 Paired successfully");
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error(
|
|
95
|
+
"\u274C Pairing failed:",
|
|
96
|
+
err instanceof Error ? err.message : err
|
|
97
|
+
);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Step 2: Launch Browser ──
|
|
102
|
+
console.log("\uD83C\uDF10 Launching browser...");
|
|
103
|
+
let browser: Browser;
|
|
104
|
+
let page: Awaited<ReturnType<typeof launchBrowser>>["page"];
|
|
105
|
+
try {
|
|
106
|
+
const launched = await launchBrowser();
|
|
107
|
+
browser = launched.browser;
|
|
108
|
+
page = launched.page;
|
|
109
|
+
console.log("\u2705 Browser ready");
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(
|
|
112
|
+
"\u274C Browser launch failed:",
|
|
113
|
+
err instanceof Error ? err.message : err
|
|
114
|
+
);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Step 3: Start Heartbeat ──
|
|
119
|
+
const heartbeat = startHeartbeat(baseUrl, sessionId, token);
|
|
120
|
+
|
|
121
|
+
// ── Step 4: Connect to command stream ──
|
|
122
|
+
console.log("\uD83D\uDCE1 Connecting to command stream...");
|
|
123
|
+
|
|
124
|
+
const sse = connectSSE(
|
|
125
|
+
baseUrl,
|
|
126
|
+
sessionId,
|
|
127
|
+
token,
|
|
128
|
+
async (command) => {
|
|
129
|
+
const label = command.stepKey
|
|
130
|
+
? `${command.commandType} (step: ${command.stepKey})`
|
|
131
|
+
: command.commandType;
|
|
132
|
+
|
|
133
|
+
const payloadHint =
|
|
134
|
+
command.commandType === "navigate"
|
|
135
|
+
? ` \u2192 ${command.payload.url}`
|
|
136
|
+
: "";
|
|
137
|
+
|
|
138
|
+
console.log(`\u26A1 Executing: ${label}${payloadHint}`);
|
|
139
|
+
|
|
140
|
+
const result = await executeCommand(page, command.commandType, command.payload);
|
|
141
|
+
|
|
142
|
+
if (result.success) {
|
|
143
|
+
console.log(`\u2705 Command complete: ${command.commandType}`);
|
|
144
|
+
} else {
|
|
145
|
+
console.error(
|
|
146
|
+
`\u274C Command failed: ${command.commandType} \u2014 ${result.error}`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Report back to server
|
|
151
|
+
const resultPayload: Record<string, unknown> = {};
|
|
152
|
+
if (result.result !== undefined) resultPayload.result = result.result;
|
|
153
|
+
if (result.screenshot) resultPayload.screenshot = result.screenshot;
|
|
154
|
+
if (result.error) resultPayload.error = result.error;
|
|
155
|
+
|
|
156
|
+
await reportResult(
|
|
157
|
+
baseUrl,
|
|
158
|
+
sessionId!,
|
|
159
|
+
token,
|
|
160
|
+
command.id,
|
|
161
|
+
result.success ? "success" : "error",
|
|
162
|
+
Object.keys(resultPayload).length > 0 ? resultPayload : undefined
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Handle shutdown command
|
|
166
|
+
if (result.shutdown) {
|
|
167
|
+
console.log("\uD83D\uDC4B Shutting down (server requested)...");
|
|
168
|
+
shutdown();
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
(err) => {
|
|
172
|
+
console.error(`\u274C SSE error: ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// ── Graceful Shutdown ──
|
|
177
|
+
let shuttingDown = false;
|
|
178
|
+
|
|
179
|
+
function shutdown() {
|
|
180
|
+
if (shuttingDown) return;
|
|
181
|
+
shuttingDown = true;
|
|
182
|
+
|
|
183
|
+
console.log("\uD83D\uDC4B Shutting down...");
|
|
184
|
+
|
|
185
|
+
sse.close();
|
|
186
|
+
heartbeat.stop();
|
|
187
|
+
|
|
188
|
+
browser
|
|
189
|
+
.close()
|
|
190
|
+
.catch(() => {})
|
|
191
|
+
.finally(() => {
|
|
192
|
+
process.exit(0);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
process.on("SIGINT", shutdown);
|
|
197
|
+
process.on("SIGTERM", shutdown);
|
|
198
|
+
|
|
199
|
+
// Keep alive
|
|
200
|
+
process.stdin.resume();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main().catch((err) => {
|
|
204
|
+
console.error("\u274C Fatal error:", err instanceof Error ? err.message : err);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|