halo-agent 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/detectFormErrors.js +180 -1
- package/index.js +73 -47
- package/orchestrator.js +74 -17
- package/package.json +2 -1
- package/poller.js +11 -7
- package/smartFill.js +44 -1
- package/ui.js +85 -0
package/detectFormErrors.js
CHANGED
|
@@ -286,4 +286,183 @@ async function detectFormErrorsInRoot(page) {
|
|
|
286
286
|
});
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Proactive required-field emptiness check.
|
|
291
|
+
*
|
|
292
|
+
* detectFormErrors is REACTIVE — it only reports a problem if the ATS has
|
|
293
|
+
* already rendered an error (aria-invalid, "is required" text, error class).
|
|
294
|
+
* On Ashby, a required combobox the agent failed to fill stays empty WITHOUT
|
|
295
|
+
* the form rendering a visible error until/unless submit is attempted — and
|
|
296
|
+
* sometimes not even then in the DOM snapshot we capture. So a reactive check
|
|
297
|
+
* sees a clean page and the agent auto-submits an incomplete form (the Plaid
|
|
298
|
+
* failure).
|
|
299
|
+
*
|
|
300
|
+
* This is the ground-truth, ATS-agnostic guard: walk every field the form
|
|
301
|
+
* marks REQUIRED (asterisked label, `required` attr, aria-required="true")
|
|
302
|
+
* and return the ones that are empty — using the same isEmptyField logic
|
|
303
|
+
* detectFormErrors uses, so react-select / Ashby comboboxes are judged by
|
|
304
|
+
* their rendered value chip, not the hidden input's .value.
|
|
305
|
+
*
|
|
306
|
+
* The orchestrator calls this BEFORE submitting: if any required field is
|
|
307
|
+
* empty, never auto-submit — route to REVIEWING with the list.
|
|
308
|
+
*
|
|
309
|
+
* Output: { hasEmptyRequired: boolean, emptyFields: [{label, selector?}] }
|
|
310
|
+
*/
|
|
311
|
+
async function detectRequiredEmpties(page) {
|
|
312
|
+
const roots = [page, ...page.frames().filter((f) => {
|
|
313
|
+
const u = f.url();
|
|
314
|
+
return f !== page.mainFrame() && u && DFE_EMBED_HOSTS.test(u) && !/google\.com|gstatic\.com|recaptcha/i.test(u);
|
|
315
|
+
})];
|
|
316
|
+
let merged = { hasEmptyRequired: false, emptyFields: [] };
|
|
317
|
+
for (const root of roots) {
|
|
318
|
+
const r = await detectRequiredEmptiesInRoot(root).catch(() => null);
|
|
319
|
+
if (r) {
|
|
320
|
+
merged.hasEmptyRequired = merged.hasEmptyRequired || r.hasEmptyRequired;
|
|
321
|
+
merged.emptyFields.push(...(r.emptyFields || []));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Dedup by label
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
merged.emptyFields = merged.emptyFields.filter((f) => {
|
|
327
|
+
const k = (f.label || '').toLowerCase().trim();
|
|
328
|
+
if (!k || seen.has(k)) return false;
|
|
329
|
+
seen.add(k); return true;
|
|
330
|
+
});
|
|
331
|
+
return merged;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function detectRequiredEmptiesInRoot(page) {
|
|
335
|
+
return await page.evaluate(() => {
|
|
336
|
+
function visibleText(el) {
|
|
337
|
+
if (!el) return '';
|
|
338
|
+
const rect = el.getBoundingClientRect();
|
|
339
|
+
if (rect.width === 0 && rect.height === 0) return '';
|
|
340
|
+
const t = (el.innerText || el.textContent || '').trim();
|
|
341
|
+
return t ? t.replace(/\s+/g, ' ').slice(0, 300) : '';
|
|
342
|
+
}
|
|
343
|
+
function nearestLabel(el) {
|
|
344
|
+
if (el.id) {
|
|
345
|
+
const lbl = document.querySelector(`label[for="${el.id}"]`);
|
|
346
|
+
if (lbl) return visibleText(lbl).replace(/\*$/, '').trim();
|
|
347
|
+
}
|
|
348
|
+
if (el.labels && el.labels[0]) return visibleText(el.labels[0]).replace(/\*$/, '').trim();
|
|
349
|
+
const al = el.getAttribute('aria-labelledby');
|
|
350
|
+
if (al) {
|
|
351
|
+
const t = al.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean).map(visibleText).join(' ').trim();
|
|
352
|
+
if (t) return t;
|
|
353
|
+
}
|
|
354
|
+
let p = el.parentElement; let hops = 0;
|
|
355
|
+
while (p && hops < 6) {
|
|
356
|
+
const lbl = p.querySelector('label, legend, h3, h4');
|
|
357
|
+
if (lbl && !lbl.contains(el)) {
|
|
358
|
+
const t = visibleText(lbl).replace(/\*$/, '').trim();
|
|
359
|
+
if (t && t.length < 200) return t;
|
|
360
|
+
}
|
|
361
|
+
p = p.parentElement; hops += 1;
|
|
362
|
+
}
|
|
363
|
+
return el.getAttribute('aria-label') || el.placeholder || el.name || el.id || '(unknown)';
|
|
364
|
+
}
|
|
365
|
+
// Same emptiness logic as detectFormErrors (kept in sync deliberately).
|
|
366
|
+
function isEmptyField(el) {
|
|
367
|
+
const tag = el.tagName.toLowerCase();
|
|
368
|
+
const type = (el.type || '').toLowerCase();
|
|
369
|
+
if (type === 'checkbox' || type === 'radio') return !el.checked;
|
|
370
|
+
if (tag === 'select') {
|
|
371
|
+
if (!el.value) return true;
|
|
372
|
+
const selOpt = el.options[el.selectedIndex];
|
|
373
|
+
if (selOpt && !selOpt.value) return true;
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
if (type === 'file') return !el.files || el.files.length === 0;
|
|
377
|
+
if (el.isContentEditable) return !(el.innerText || '').trim();
|
|
378
|
+
if (el.getAttribute('role') === 'combobox' || tag === 'select') {
|
|
379
|
+
let wrap = el.closest('[class*="select__control"]');
|
|
380
|
+
if (!wrap) { let p = el; for (let i = 0; i < 8 && p; i++, p = p.parentElement) { if (/select__control|select-control/i.test(p.className || '')) { wrap = p; break; } } }
|
|
381
|
+
if (!wrap) wrap = el.closest('[class*="select"], [class*="combobox"], [class*="Select"]') || el.parentElement;
|
|
382
|
+
if (wrap) {
|
|
383
|
+
const valueEl = wrap.querySelector('[class*="single-value"], [class*="multi-value"], [class*="multiValue"], [class*="singleValue"], [class*="chip"], [class*="tag"]');
|
|
384
|
+
if (valueEl && (valueEl.innerText || valueEl.textContent || '').trim()) return false;
|
|
385
|
+
const wrapText = (wrap.innerText || '').trim();
|
|
386
|
+
const placeholderEl = wrap.querySelector('[class*="placeholder"]');
|
|
387
|
+
const placeholderText = placeholderEl ? (placeholderEl.innerText || '').trim() : '';
|
|
388
|
+
if (wrapText && wrapText !== placeholderText && !/^select\b|^choose\b|^start typing|^\.\.\./i.test(wrapText)) return false;
|
|
389
|
+
}
|
|
390
|
+
const v = (el.value || el.innerText || '').trim();
|
|
391
|
+
return !v;
|
|
392
|
+
}
|
|
393
|
+
return !(el.value || '').trim();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// A radiogroup / checkbox-set counts as "filled" if ANY member is checked.
|
|
397
|
+
// We track required groups by name so we don't flag every individual radio.
|
|
398
|
+
function isRequired(el) {
|
|
399
|
+
if (el.required) return true;
|
|
400
|
+
if (el.getAttribute('aria-required') === 'true') return true;
|
|
401
|
+
// Asterisked label is the most common ATS convention (Ashby, Greenhouse).
|
|
402
|
+
const lbl = nearestLabel(el);
|
|
403
|
+
// The raw label may have had its trailing * stripped by nearestLabel, so
|
|
404
|
+
// also look at the surrounding label text for an asterisk.
|
|
405
|
+
let p = el.parentElement; let hops = 0;
|
|
406
|
+
while (p && hops < 5) {
|
|
407
|
+
const labelEl = p.querySelector('label, legend, [class*="label"]');
|
|
408
|
+
if (labelEl) {
|
|
409
|
+
const raw = (labelEl.innerText || labelEl.textContent || '');
|
|
410
|
+
if (/\*\s*$/.test(raw.trim()) || /\brequired\b/i.test(raw)) return true;
|
|
411
|
+
}
|
|
412
|
+
p = p.parentElement; hops += 1;
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const fields = Array.from(document.querySelectorAll(
|
|
418
|
+
'input:not([type=hidden]):not([type=submit]):not([type=button]), textarea, select, [role="combobox"], [contenteditable="true"]'
|
|
419
|
+
));
|
|
420
|
+
const emptyFields = [];
|
|
421
|
+
const seen = new Set();
|
|
422
|
+
const radioGroupsChecked = new Map(); // name -> bool any-checked
|
|
423
|
+
const radioGroupRequired = new Map(); // name -> required
|
|
424
|
+
|
|
425
|
+
// First pass: tally radio/checkbox groups by name.
|
|
426
|
+
for (const el of fields) {
|
|
427
|
+
const type = (el.type || '').toLowerCase();
|
|
428
|
+
if ((type === 'radio' || type === 'checkbox') && el.name) {
|
|
429
|
+
if (el.checked) radioGroupsChecked.set(el.name, true);
|
|
430
|
+
else if (!radioGroupsChecked.has(el.name)) radioGroupsChecked.set(el.name, false);
|
|
431
|
+
if (isRequired(el)) radioGroupRequired.set(el.name, true);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
for (const el of fields) {
|
|
436
|
+
const type = (el.type || '').toLowerCase();
|
|
437
|
+
// Skip non-rendered fields entirely (display:none templates etc).
|
|
438
|
+
const rect = el.getBoundingClientRect();
|
|
439
|
+
const inDom = rect.width > 0 || rect.height > 0 || el.offsetParent !== null;
|
|
440
|
+
if (!inDom && type !== 'file') continue; // file inputs are often 0x0 by design
|
|
441
|
+
|
|
442
|
+
// Radio/checkbox handled at group level.
|
|
443
|
+
if ((type === 'radio' || type === 'checkbox') && el.name) {
|
|
444
|
+
if (!radioGroupRequired.get(el.name)) continue;
|
|
445
|
+
if (radioGroupsChecked.get(el.name)) continue; // satisfied
|
|
446
|
+
const key = 'group:' + el.name;
|
|
447
|
+
if (seen.has(key)) continue;
|
|
448
|
+
seen.add(key);
|
|
449
|
+
if (!radioGroupsChecked.get(el.name)) {
|
|
450
|
+
emptyFields.push({ label: nearestLabel(el).slice(0, 200), selector: `[name="${el.name}"]` });
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!isRequired(el)) continue;
|
|
456
|
+
if (!isEmptyField(el)) continue;
|
|
457
|
+
const label = nearestLabel(el).slice(0, 200);
|
|
458
|
+
const key = label.toLowerCase();
|
|
459
|
+
if (seen.has(key)) continue;
|
|
460
|
+
seen.add(key);
|
|
461
|
+
emptyFields.push({ label, selector: el.id ? `#${el.id}` : (el.name ? `[name="${el.name}"]` : null) });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { hasEmptyRequired: emptyFields.length > 0, emptyFields };
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
module.exports = { detectFormErrors, detectRequiredEmpties };
|
package/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const { loadConfig, saveConfig } = require('./config');
|
|
|
17
17
|
const { connectToChrome } = require('./browser');
|
|
18
18
|
const { startPolling } = require('./poller');
|
|
19
19
|
const { startLocalServer } = require('./localServer');
|
|
20
|
+
const ui = require('./ui');
|
|
20
21
|
|
|
21
22
|
const [,, command, ...cmdArgs] = process.argv;
|
|
22
23
|
|
|
@@ -34,13 +35,18 @@ async function main() {
|
|
|
34
35
|
} else if (command === 'uninstall-autostart') {
|
|
35
36
|
await runUninstallAutostart();
|
|
36
37
|
} else {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
console.log('
|
|
41
|
-
console.log(
|
|
42
|
-
console.log('
|
|
43
|
-
console.log('
|
|
38
|
+
let v = '';
|
|
39
|
+
try { v = require('./package.json').version; } catch {}
|
|
40
|
+
ui.banner(v);
|
|
41
|
+
console.log('');
|
|
42
|
+
console.log(` ${ui.bold('Usage')}`);
|
|
43
|
+
console.log(` ${ui.cyan('halo-agent pair')} ${ui.gray('<code>')} One-click pair with the dashboard`);
|
|
44
|
+
console.log(` ${ui.cyan('halo-agent start')} Start the agent`);
|
|
45
|
+
console.log(` ${ui.cyan('halo-agent install-autostart')} Auto-start at login`);
|
|
46
|
+
console.log(` ${ui.cyan('halo-agent uninstall-autostart')} Remove login auto-start`);
|
|
47
|
+
console.log(` ${ui.cyan('halo-agent init')} First-time setup (legacy: token paste)`);
|
|
48
|
+
console.log(` ${ui.cyan('halo-agent token')} ${ui.gray('<value>')} Update auth token`);
|
|
49
|
+
console.log('');
|
|
44
50
|
process.exit(1);
|
|
45
51
|
}
|
|
46
52
|
}
|
|
@@ -59,7 +65,7 @@ async function runUpdateToken(newToken) {
|
|
|
59
65
|
const config = loadConfig() || {};
|
|
60
66
|
config.token = resolved.trim();
|
|
61
67
|
saveConfig(config);
|
|
62
|
-
|
|
68
|
+
ui.success('Token updated');
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
// One-click pairing. The user generates a 6-char code in the dashboard
|
|
@@ -68,8 +74,11 @@ async function runUpdateToken(newToken) {
|
|
|
68
74
|
// the user to copy-paste an API token.
|
|
69
75
|
async function runPair(code) {
|
|
70
76
|
if (!code) {
|
|
71
|
-
|
|
72
|
-
console.
|
|
77
|
+
ui.fail('Missing pairing code.');
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log(` ${ui.bold('Usage')} ${ui.cyan('halo-agent pair')} ${ui.gray('<code>')}`);
|
|
80
|
+
console.log(` ${ui.gray('Get a code from the HALO dashboard: Profile')} ${ui.gray(ui.sym.arrow)} ${ui.gray('Connect agent.')}`);
|
|
81
|
+
console.log('');
|
|
73
82
|
process.exit(1);
|
|
74
83
|
}
|
|
75
84
|
const trimmed = String(code).trim().toUpperCase();
|
|
@@ -77,7 +86,8 @@ async function runPair(code) {
|
|
|
77
86
|
// Default API URL — overridable via env for self-hosted Workers.
|
|
78
87
|
const apiUrl = process.env.HALO_API_URL || 'https://halo-apply-os.amoghi2tb.workers.dev';
|
|
79
88
|
|
|
80
|
-
console.log(
|
|
89
|
+
console.log('');
|
|
90
|
+
const spin = ui.spinner(`Pairing with ${ui.gray(apiUrl)}`);
|
|
81
91
|
let res;
|
|
82
92
|
try {
|
|
83
93
|
res = await fetch(`${apiUrl}/agent-pair/claim`, {
|
|
@@ -86,19 +96,19 @@ async function runPair(code) {
|
|
|
86
96
|
body: JSON.stringify({ code: trimmed }),
|
|
87
97
|
});
|
|
88
98
|
} catch (e) {
|
|
89
|
-
|
|
99
|
+
spin.stop(`Network error reaching HALO: ${e.message}`, false);
|
|
90
100
|
process.exit(1);
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
if (!res.ok) {
|
|
94
104
|
let msg = `HTTP ${res.status}`;
|
|
95
105
|
try { const j = await res.json(); msg = j.error || msg; } catch {}
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
spin.stop(`Pair failed: ${msg}`, false);
|
|
107
|
+
ui.muted('Codes expire 5 minutes after they are generated. Get a fresh one if needed.');
|
|
98
108
|
process.exit(1);
|
|
99
109
|
}
|
|
100
110
|
const data = await res.json();
|
|
101
|
-
if (!data.token) {
|
|
111
|
+
if (!data.token) { spin.stop('Pair response missing token.', false); process.exit(1); }
|
|
102
112
|
|
|
103
113
|
const { loadConfig, saveConfig } = require('./config');
|
|
104
114
|
const existing = loadConfig() || {};
|
|
@@ -110,8 +120,11 @@ async function runPair(code) {
|
|
|
110
120
|
};
|
|
111
121
|
saveConfig(config);
|
|
112
122
|
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
spin.stop(`Paired with HALO`);
|
|
124
|
+
ui.muted(` Token saved to ~/.halo-agent/config.json`);
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log(` ${ui.gray('Next:')} ${ui.cyan('halo-agent start')}`);
|
|
127
|
+
console.log('');
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
// Auto-start at login. The user picked "always on" — once installed, the
|
|
@@ -166,10 +179,12 @@ async function runInstallAutostart() {
|
|
|
166
179
|
spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
|
|
167
180
|
spawnSync('launchctl', ['load', plistPath], { stdio: 'ignore' });
|
|
168
181
|
} catch { /* user can `launchctl load` manually */ }
|
|
169
|
-
console.log(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
182
|
+
console.log('');
|
|
183
|
+
ui.success('Login auto-start installed (macOS LaunchAgent)');
|
|
184
|
+
ui.muted(` plist: ${plistPath}`);
|
|
185
|
+
ui.muted(` logs: ${logPath}`);
|
|
186
|
+
ui.muted(' The agent will start automatically at login. It is running now.');
|
|
187
|
+
console.log('');
|
|
173
188
|
return;
|
|
174
189
|
}
|
|
175
190
|
|
|
@@ -183,10 +198,12 @@ async function runInstallAutostart() {
|
|
|
183
198
|
WshShell.Run """${nodePath.replace(/"/g, '""')}"" ""${agentPath.replace(/"/g, '""')}"" start", 0, False
|
|
184
199
|
Set WshShell = Nothing`;
|
|
185
200
|
fs.writeFileSync(vbsPath, vbs);
|
|
186
|
-
console.log(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
console.log('');
|
|
202
|
+
ui.success('Login auto-start installed (Windows Startup folder)');
|
|
203
|
+
ui.muted(` vbs: ${vbsPath}`);
|
|
204
|
+
ui.muted(` logs: ${logPath} (agent appends its own output)`);
|
|
205
|
+
ui.muted(` Starts at next login. Run ${ui.cyan('halo-agent start')} now to begin this session.`);
|
|
206
|
+
console.log('');
|
|
190
207
|
return;
|
|
191
208
|
}
|
|
192
209
|
|
|
@@ -217,10 +234,12 @@ WantedBy=default.target
|
|
|
217
234
|
// Linger so the unit survives logout — best effort.
|
|
218
235
|
spawnSync('loginctl', ['enable-linger', os.userInfo().username], { stdio: 'ignore' });
|
|
219
236
|
} catch { /* user can enable manually */ }
|
|
220
|
-
console.log(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
237
|
+
console.log('');
|
|
238
|
+
ui.success('Login auto-start installed (systemd --user)');
|
|
239
|
+
ui.muted(` unit: ${unitPath}`);
|
|
240
|
+
ui.muted(` logs: ${logPath}`);
|
|
241
|
+
ui.muted(` Check status: ${ui.cyan('systemctl --user status halo-agent')}`);
|
|
242
|
+
console.log('');
|
|
224
243
|
}
|
|
225
244
|
|
|
226
245
|
async function runUninstallAutostart() {
|
|
@@ -235,13 +254,13 @@ async function runUninstallAutostart() {
|
|
|
235
254
|
spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
|
|
236
255
|
} catch {}
|
|
237
256
|
if (fs.existsSync(plistPath)) fs.unlinkSync(plistPath);
|
|
238
|
-
|
|
257
|
+
ui.success('Removed macOS LaunchAgent');
|
|
239
258
|
return;
|
|
240
259
|
}
|
|
241
260
|
if (process.platform === 'win32') {
|
|
242
261
|
const vbsPath = path.join(process.env.APPDATA || os.homedir(), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'halo-agent.vbs');
|
|
243
262
|
if (fs.existsSync(vbsPath)) fs.unlinkSync(vbsPath);
|
|
244
|
-
|
|
263
|
+
ui.success('Removed Windows Startup entry');
|
|
245
264
|
return;
|
|
246
265
|
}
|
|
247
266
|
try {
|
|
@@ -250,7 +269,7 @@ async function runUninstallAutostart() {
|
|
|
250
269
|
} catch {}
|
|
251
270
|
const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', 'halo-agent.service');
|
|
252
271
|
if (fs.existsSync(unitPath)) fs.unlinkSync(unitPath);
|
|
253
|
-
|
|
272
|
+
ui.success('Removed systemd --user unit');
|
|
254
273
|
}
|
|
255
274
|
|
|
256
275
|
async function runInit() {
|
|
@@ -287,8 +306,10 @@ async function runInit() {
|
|
|
287
306
|
};
|
|
288
307
|
|
|
289
308
|
saveConfig(config);
|
|
290
|
-
console.log('
|
|
291
|
-
|
|
309
|
+
console.log('');
|
|
310
|
+
ui.success('Config saved to ~/.halo-agent/config.json');
|
|
311
|
+
console.log(` ${ui.gray('Next:')} ${ui.cyan('halo-agent start')}`);
|
|
312
|
+
console.log('');
|
|
292
313
|
}
|
|
293
314
|
|
|
294
315
|
// Is Chrome running without the --remote-debugging-port=9222 flag?
|
|
@@ -325,13 +346,13 @@ async function detectChromeRunningWithoutDebug() {
|
|
|
325
346
|
function offerChromeRestart() {
|
|
326
347
|
return new Promise((resolve) => {
|
|
327
348
|
console.log('');
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
349
|
+
ui.warn('Chrome is running, but without the debug flag the agent needs.');
|
|
350
|
+
ui.muted(' I can restart it for you. Your tabs reopen automatically');
|
|
351
|
+
ui.muted(' (if your "On startup" setting is "Continue where you left off").');
|
|
331
352
|
console.log('');
|
|
332
353
|
const readline = require('readline');
|
|
333
354
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
334
|
-
rl.question(
|
|
355
|
+
rl.question(`${ui.cyan(ui.sym.arrow)} Restart Chrome now? ${ui.gray('[Y/n]')} `, (ans) => {
|
|
335
356
|
rl.close();
|
|
336
357
|
const a = (ans || '').trim().toLowerCase();
|
|
337
358
|
resolve(a === '' || a === 'y' || a === 'yes');
|
|
@@ -446,12 +467,14 @@ async function waitForChromeGone(timeoutMs) {
|
|
|
446
467
|
async function runStart() {
|
|
447
468
|
const config = loadConfig();
|
|
448
469
|
if (!config || !config.token) {
|
|
449
|
-
|
|
470
|
+
ui.fail('No config found.');
|
|
471
|
+
console.log(` ${ui.gray('Run')} ${ui.cyan('halo-agent pair <code>')} ${ui.gray('first. Get a code from the HALO dashboard.')}`);
|
|
450
472
|
process.exit(1);
|
|
451
473
|
}
|
|
452
474
|
|
|
453
|
-
|
|
454
|
-
|
|
475
|
+
let version = '';
|
|
476
|
+
try { version = require('./package.json').version; } catch {}
|
|
477
|
+
ui.banner(version);
|
|
455
478
|
|
|
456
479
|
// The agent runs its own isolated Chrome instance (separate --user-data-dir
|
|
457
480
|
// at ~/.halo-agent/chrome-profile) so it never collides with the user's
|
|
@@ -459,22 +482,25 @@ async function runStart() {
|
|
|
459
482
|
// into Workday / LinkedIn ONCE in the agent's Chrome and those sessions
|
|
460
483
|
// persist across runs. No need to detect or restart the user's Chrome.
|
|
461
484
|
let chromeConn;
|
|
485
|
+
const spin = ui.spinner('Launching Chrome');
|
|
462
486
|
try {
|
|
463
487
|
chromeConn = await connectToChrome(10);
|
|
464
|
-
|
|
465
|
-
console.log('First time? Log into Workday/LinkedIn in this Chrome window — sessions persist.');
|
|
466
|
-
console.log('Go to your HALO dashboard and click "Auto-Apply" on any job.\n');
|
|
488
|
+
spin.stop('Connected to Chrome');
|
|
467
489
|
} catch (err) {
|
|
468
|
-
|
|
490
|
+
spin.stop(`Could not connect to Chrome: ${err.message}`, false);
|
|
469
491
|
process.exit(1);
|
|
470
492
|
}
|
|
493
|
+
ui.muted(` First time? Sign into Workday / LinkedIn once in this Chrome window — sessions persist.`);
|
|
494
|
+
ui.muted(` Then queue a job from your HALO dashboard.`);
|
|
495
|
+
console.log('');
|
|
471
496
|
|
|
472
497
|
// Start the local HTTP server so the HALO dashboard can trigger Manus automation
|
|
473
498
|
startLocalServer();
|
|
474
499
|
|
|
475
500
|
// Graceful shutdown
|
|
476
501
|
process.on('SIGINT', async () => {
|
|
477
|
-
console.log('
|
|
502
|
+
console.log('');
|
|
503
|
+
ui.muted('Shutting down…');
|
|
478
504
|
try { await chromeConn.browser.close(); } catch {}
|
|
479
505
|
process.exit(0);
|
|
480
506
|
});
|
|
@@ -483,6 +509,6 @@ async function runStart() {
|
|
|
483
509
|
}
|
|
484
510
|
|
|
485
511
|
main().catch(err => {
|
|
486
|
-
|
|
512
|
+
ui.fail(`Fatal: ${err.message}`);
|
|
487
513
|
process.exit(1);
|
|
488
514
|
});
|
package/orchestrator.js
CHANGED
|
@@ -13,7 +13,7 @@ const path = require('path');
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const { fillFields: legacyFillFields, uploadFile, findNextButton, findSubmitButton, waitForStableDOM, snapshotFieldLabels } = require('./filler');
|
|
15
15
|
const { smartFillPage } = require('./smartFill');
|
|
16
|
-
const { detectFormErrors } = require('./detectFormErrors');
|
|
16
|
+
const { detectFormErrors, detectRequiredEmpties } = require('./detectFormErrors');
|
|
17
17
|
|
|
18
18
|
// Switchable filler — smart by default, can be killed via config.useSmartFill=false.
|
|
19
19
|
// smartFill.js internally falls back to legacyFillFields if /smartfill/plan-fill
|
|
@@ -432,23 +432,59 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
432
432
|
|
|
433
433
|
console.log(`[orchestrator] Reached review/submit page. Waiting for confirmation...`);
|
|
434
434
|
|
|
435
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
436
|
+
// HONESTY GUARD (the Plaid/Ashby fix). Before we even consider
|
|
437
|
+
// auto-submitting, check — proactively, not waiting for the ATS to
|
|
438
|
+
// render an error — whether any REQUIRED field is still empty. The
|
|
439
|
+
// Plaid run auto-submitted with Location + dates blank because:
|
|
440
|
+
// (a) the comboboxes never filled (element-not-visible, now fixed), and
|
|
441
|
+
// (b) detectFormErrors is reactive — a clean-looking DOM let
|
|
442
|
+
// auto-submit "trust the click."
|
|
443
|
+
// If required fields are empty, auto-submit is OFF for this job no matter
|
|
444
|
+
// what the config/agent_config says: we route to REVIEWING so the human
|
|
445
|
+
// sees exactly which fields are blank. Never silently submit incomplete.
|
|
446
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
447
|
+
const requiredCheck = await detectRequiredEmpties(page).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
|
|
448
|
+
let forceReview = false;
|
|
449
|
+
if (requiredCheck.hasEmptyRequired) {
|
|
450
|
+
const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
451
|
+
console.warn(`[orchestrator] ${requiredCheck.emptyFields.length} required field(s) still EMPTY: ${list}`);
|
|
452
|
+
console.warn(`[orchestrator] Overriding auto-submit → REVIEWING. Will not submit an incomplete form.`);
|
|
453
|
+
forceReview = true;
|
|
454
|
+
}
|
|
455
|
+
|
|
435
456
|
// Take a screenshot of the review page
|
|
436
457
|
const reviewScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
|
|
437
458
|
const reviewKey = await uploadScreenshot(config, reviewScreenshot, `review_${queueId}.jpg`);
|
|
438
459
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
460
|
+
if (forceReview) {
|
|
461
|
+
const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
462
|
+
await reportStatus('NEEDS_ATTENTION', {
|
|
463
|
+
review_screenshot_r2_key: reviewKey || null,
|
|
464
|
+
needs_attention_reason: `${requiredCheck.emptyFields.length} required field(s) could not be filled: ${list}. Fill them in Chrome, then click Submit / Resume.`,
|
|
465
|
+
intervention_type: 'incomplete_fields',
|
|
466
|
+
step: 'REVIEWING',
|
|
467
|
+
step_detail: `Empty required: ${list}`.slice(0, 200),
|
|
468
|
+
fields_filled: cumulativeFilled,
|
|
469
|
+
});
|
|
470
|
+
// Wait for the human to fill + confirm, exactly like the no-auto-submit
|
|
471
|
+
// path. They resolve the blanks in Chrome, then click Submit/Resume.
|
|
451
472
|
await waitForSubmitConfirmation(config, queueId);
|
|
473
|
+
} else {
|
|
474
|
+
await reportStatus('REVIEWING', {
|
|
475
|
+
review_screenshot_r2_key: reviewKey || null,
|
|
476
|
+
step: 'REVIEWING',
|
|
477
|
+
step_detail: `${cumulativeFilled} fields filled · awaiting your confirm`,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Wait for user to confirm submission from dashboard
|
|
481
|
+
// OR auto-submit if config.autoSubmit is true AND the form is complete.
|
|
482
|
+
if (config.autoSubmit || aep.agent_config?.auto_submit) {
|
|
483
|
+
const timeout = (aep.agent_config?.review_timeout_seconds || 30) * 1000;
|
|
484
|
+
await page.waitForTimeout(timeout);
|
|
485
|
+
} else {
|
|
486
|
+
await waitForSubmitConfirmation(config, queueId);
|
|
487
|
+
}
|
|
452
488
|
}
|
|
453
489
|
|
|
454
490
|
// STEP 6: SUBMITTING
|
|
@@ -601,10 +637,31 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
601
637
|
let finalState = 'DONE';
|
|
602
638
|
if (verdict.submitted === null) {
|
|
603
639
|
const autoSubmit = config.autoSubmit || aep.agent_config?.auto_submit;
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
640
|
+
|
|
641
|
+
// Even with auto-submit ON, don't "trust the click" blindly. Two cheap
|
|
642
|
+
// local signals tell us the submit probably did NOT go through:
|
|
643
|
+
// 1. The URL never changed to a confirmation pattern (still on the
|
|
644
|
+
// form). A successful ATS submit almost always redirects.
|
|
645
|
+
// 2. Required fields are STILL empty after the click — proof the form
|
|
646
|
+
// bounced us (this is the Plaid case: blank Location/dates, no
|
|
647
|
+
// redirect, verifier down → it had said "trusting click").
|
|
648
|
+
// If either holds, we refuse the silent DONE and route to REVIEWING.
|
|
649
|
+
const stillOnForm = !/thank|confirm|success|applied|submitted/i.test(verdictUrl);
|
|
650
|
+
const postCheck = await detectRequiredEmpties(page).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
|
|
651
|
+
|
|
652
|
+
if (postCheck.hasEmptyRequired) {
|
|
653
|
+
const list = postCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
654
|
+
console.warn(`[orchestrator] After submit, required field(s) STILL empty: ${list}. Not a real submit — REVIEWING.`);
|
|
655
|
+
finalState = 'REVIEWING';
|
|
656
|
+
} else if (autoSubmit && !stillOnForm) {
|
|
657
|
+
// Form complete + URL redirected away + verifier just unavailable:
|
|
658
|
+
// this is the defensible "trust the click" case. Screenshot is the receipt.
|
|
659
|
+
console.log(`[orchestrator] Verifier unavailable (source: ${verdict.source}); form complete + redirected — trusting click, screenshot is the receipt.`);
|
|
660
|
+
} else if (autoSubmit && stillOnForm) {
|
|
661
|
+
// Auto-submit ON but no redirect and no obvious empties: ambiguous.
|
|
662
|
+
// Don't claim DONE on a page that still looks like the form.
|
|
663
|
+
console.warn(`[orchestrator] Auto-submit ON but still on form URL and verifier unavailable — REVIEWING rather than a blind DONE.`);
|
|
664
|
+
finalState = 'REVIEWING';
|
|
608
665
|
} else {
|
|
609
666
|
// No auto-submit → REVIEWING so the user eyeballs first.
|
|
610
667
|
console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). REVIEWING — please eyeball the screenshot + click Submit.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "halo-agent",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"captcha.js",
|
|
29
29
|
"vision.js",
|
|
30
30
|
"manusAutomate.js",
|
|
31
|
+
"ui.js",
|
|
31
32
|
"README.md"
|
|
32
33
|
],
|
|
33
34
|
"keywords": [
|
package/poller.js
CHANGED
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
const { runJob } = require('./orchestrator');
|
|
10
10
|
const { cfAccessHeaders } = require('./config');
|
|
11
|
+
const ui = require('./ui');
|
|
11
12
|
|
|
12
13
|
let active = false;
|
|
13
14
|
let sessionId = null;
|
|
14
15
|
|
|
15
16
|
async function startPolling(chromeConn, config) {
|
|
16
|
-
|
|
17
|
+
ui.success('Listening for jobs');
|
|
18
|
+
ui.muted(` Polling every 5s. ${ui.cyan('Ctrl-C')} to stop.`);
|
|
19
|
+
console.log('');
|
|
17
20
|
|
|
18
21
|
// Register agent session with backend
|
|
19
22
|
sessionId = await registerSession(config);
|
|
@@ -28,7 +31,8 @@ async function startPolling(chromeConn, config) {
|
|
|
28
31
|
if (!item) continue;
|
|
29
32
|
|
|
30
33
|
active = true;
|
|
31
|
-
console.log(
|
|
34
|
+
console.log('');
|
|
35
|
+
ui.info(`${ui.bold(item.company)} ${ui.gray(ui.sym.dot)} ${item.title}`);
|
|
32
36
|
|
|
33
37
|
// Update session status
|
|
34
38
|
await heartbeat(config, sessionId, 'filling', item.job_id);
|
|
@@ -37,7 +41,7 @@ async function startPolling(chromeConn, config) {
|
|
|
37
41
|
try {
|
|
38
42
|
await runJob(item, chromeConn, config, reportStatus);
|
|
39
43
|
} catch (err) {
|
|
40
|
-
|
|
44
|
+
ui.fail(`Job error: ${err.message}`);
|
|
41
45
|
// Last-resort safety net — orchestrator usually classifies, but if a
|
|
42
46
|
// throw escaped without classification, fall back to 'generic'.
|
|
43
47
|
await reportStatus('NEEDS_ATTENTION', {
|
|
@@ -80,7 +84,7 @@ function makeReporter(config, queueId, sessionId) {
|
|
|
80
84
|
body: JSON.stringify({ status, ...extra }),
|
|
81
85
|
});
|
|
82
86
|
} catch (e) {
|
|
83
|
-
|
|
87
|
+
ui.warn(`Could not report status: ${e.message}`);
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
// Granular step PATCH — drives the live SSE feed. Fire-and-forget; if the
|
|
@@ -142,14 +146,14 @@ async function registerSession(config) {
|
|
|
142
146
|
});
|
|
143
147
|
if (!res.ok) {
|
|
144
148
|
const text = await res.text().catch(() => res.status);
|
|
145
|
-
|
|
149
|
+
ui.warn(`Session registration failed (${res.status}): ${text}`);
|
|
146
150
|
return null;
|
|
147
151
|
}
|
|
148
152
|
const data = await res.json();
|
|
149
|
-
|
|
153
|
+
ui.muted(` Session ${ui.gray(data.session_id)}`);
|
|
150
154
|
return data.session_id;
|
|
151
155
|
} catch (e) {
|
|
152
|
-
|
|
156
|
+
ui.warn(`Session registration error: ${e.message}`);
|
|
153
157
|
return null;
|
|
154
158
|
}
|
|
155
159
|
}
|
package/smartFill.js
CHANGED
|
@@ -58,7 +58,50 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
58
58
|
visible = await locator.isVisible({ timeout: 800 }).catch(() => false);
|
|
59
59
|
}
|
|
60
60
|
if (!visible) {
|
|
61
|
-
|
|
61
|
+
// The scanned element being invisible doesn't always mean "give up." Many
|
|
62
|
+
// ATSes (Ashby, react-select) render the REAL combobox <input> at 0x0 and
|
|
63
|
+
// expose only a clickable WRAPPER (".select__control", a styled div, the
|
|
64
|
+
// value-display button). scanAccessibility tags the inner input, so the
|
|
65
|
+
// executor would FAIL "element not visible" and the required field stays
|
|
66
|
+
// empty — exactly the Ashby Location/School/date failure. Recover by
|
|
67
|
+
// re-pointing the locator at the nearest visible interactive ancestor and
|
|
68
|
+
// letting the dropdown handlers below open it.
|
|
69
|
+
const looksLikeDropdown = field.role === 'combobox' || field.role === 'listbox'
|
|
70
|
+
|| /select|combobox|dropdown|month|year|listbox/i.test(String(field.selectorHint || ''))
|
|
71
|
+
|| (Array.isArray(field.options) && field.options.length > 0);
|
|
72
|
+
const isOpenable = looksLikeDropdown
|
|
73
|
+
|| item.action === 'click_option' || item.action === 'select_option';
|
|
74
|
+
if (isOpenable) {
|
|
75
|
+
const wrapperSel = await locator.evaluate((el) => {
|
|
76
|
+
// Climb to the closest ancestor that's actually rendered (non-zero box)
|
|
77
|
+
// and looks interactive (select-control class, role, or just a sized
|
|
78
|
+
// container holding this input). Tag it so Playwright can target it.
|
|
79
|
+
const sized = (n) => { const r = n.getBoundingClientRect?.(); return r && (r.width > 0 || r.height > 0); };
|
|
80
|
+
let p = el;
|
|
81
|
+
for (let i = 0; i < 8 && p; i++, p = p.parentElement) {
|
|
82
|
+
const cls = String(p.className || '');
|
|
83
|
+
const looksControl = /select__control|select-control|control|combobox|dropdown|trigger|css-/i.test(cls)
|
|
84
|
+
|| p.getAttribute?.('role') === 'combobox'
|
|
85
|
+
|| p.getAttribute?.('aria-haspopup');
|
|
86
|
+
if (sized(p) && (looksControl || p !== el)) {
|
|
87
|
+
const cid = 'halo-wrap-' + Math.random().toString(36).slice(2, 8);
|
|
88
|
+
p.setAttribute('data-halo-wrap', cid);
|
|
89
|
+
return cid;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}).catch(() => null);
|
|
94
|
+
if (wrapperSel) {
|
|
95
|
+
const wrap = root.locator(`[data-halo-wrap="${wrapperSel}"]`).first();
|
|
96
|
+
if (await wrap.isVisible({ timeout: 600 }).catch(() => false)) {
|
|
97
|
+
locator = wrap;
|
|
98
|
+
visible = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!visible) {
|
|
103
|
+
return { ok: false, reason: `element not visible (mmid=${item.mmid}${field.frameUrl ? ', in-frame' : ''})` };
|
|
104
|
+
}
|
|
62
105
|
}
|
|
63
106
|
|
|
64
107
|
switch (item.action) {
|
package/ui.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tiny zero-dep terminal styling helpers. ANSI escapes only — no chalk/ora,
|
|
5
|
+
* because the agent is CommonJS and those packages are ESM-only in v5/v8.
|
|
6
|
+
*
|
|
7
|
+
* Auto-disables color when stdout isn't a TTY (e.g. piped to a file, or
|
|
8
|
+
* the LaunchAgent/systemd log) or when NO_COLOR is set (https://no-color.org).
|
|
9
|
+
*
|
|
10
|
+
* Used everywhere we want the agent to look like a modern CLI on camera
|
|
11
|
+
* (Vercel, Bun, pnpm) instead of plain console.log output.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
15
|
+
|
|
16
|
+
const wrap = (open, close) => (s) => useColor ? `\x1b[${open}m${s}\x1b[${close}m` : String(s);
|
|
17
|
+
|
|
18
|
+
const bold = wrap(1, 22);
|
|
19
|
+
const dim = wrap(2, 22);
|
|
20
|
+
const red = wrap(31, 39);
|
|
21
|
+
const green = wrap(32, 39);
|
|
22
|
+
const yellow = wrap(33, 39);
|
|
23
|
+
const blue = wrap(34, 39);
|
|
24
|
+
const magenta = wrap(35, 39);
|
|
25
|
+
const cyan = wrap(36, 39);
|
|
26
|
+
const gray = wrap(90, 39);
|
|
27
|
+
|
|
28
|
+
// HALO's brand color is ember; render as bright orange when supported.
|
|
29
|
+
const ember = (s) => useColor ? `\x1b[38;5;208m${s}\x1b[39m` : String(s);
|
|
30
|
+
|
|
31
|
+
// Status glyphs — match what modern CLIs do. ASCII fallbacks for terminals
|
|
32
|
+
// that can't render the unicode (rare in 2026, but cheap insurance).
|
|
33
|
+
const sym = process.platform === 'win32' && !process.env.WT_SESSION
|
|
34
|
+
? { tick: '√', cross: '×', bullet: '*', arrow: '→', dot: '·' }
|
|
35
|
+
: { tick: '✓', cross: '✗', bullet: '•', arrow: '→', dot: '·' };
|
|
36
|
+
|
|
37
|
+
const success = (msg) => console.log(`${green(sym.tick)} ${msg}`);
|
|
38
|
+
const fail = (msg) => console.log(`${red(sym.cross)} ${msg}`);
|
|
39
|
+
const info = (msg) => console.log(`${cyan(sym.arrow)} ${msg}`);
|
|
40
|
+
const warn = (msg) => console.log(`${yellow('!')} ${msg}`);
|
|
41
|
+
const muted = (msg) => console.log(gray(msg));
|
|
42
|
+
const step = (msg) => console.log(`${gray(sym.dot)} ${msg}`);
|
|
43
|
+
|
|
44
|
+
// Banner shown on `start`. Three lines so it has presence on camera without
|
|
45
|
+
// being a giant ASCII-art block that ages badly.
|
|
46
|
+
function banner(version) {
|
|
47
|
+
const v = version ? gray(`v${version}`) : '';
|
|
48
|
+
const line = gray('─'.repeat(40));
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(` ${ember(bold('HALO'))} ${bold('Agent')} ${v}`);
|
|
51
|
+
console.log(` ${gray('Auto-apply, in your own Chrome.')}`);
|
|
52
|
+
console.log(` ${line}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Lightweight spinner for waiting states ("Connecting to Chrome..."). Frames
|
|
56
|
+
// match the standard braille spinner most users have seen. Returns a stop()
|
|
57
|
+
// fn — call it with a final message + status to render a success/fail line
|
|
58
|
+
// in place of the spinner.
|
|
59
|
+
function spinner(message) {
|
|
60
|
+
if (!useColor) {
|
|
61
|
+
process.stdout.write(`${gray(sym.dot)} ${message}\n`);
|
|
62
|
+
return { stop: (finalMsg, ok = true) => (ok ? success(finalMsg) : fail(finalMsg)) };
|
|
63
|
+
}
|
|
64
|
+
const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
65
|
+
let i = 0;
|
|
66
|
+
const render = () => {
|
|
67
|
+
process.stdout.write(`\r${cyan(frames[i = (i + 1) % frames.length])} ${message} `);
|
|
68
|
+
};
|
|
69
|
+
const handle = setInterval(render, 80);
|
|
70
|
+
render();
|
|
71
|
+
return {
|
|
72
|
+
update: (newMessage) => { message = newMessage; },
|
|
73
|
+
stop: (finalMsg, ok = true) => {
|
|
74
|
+
clearInterval(handle);
|
|
75
|
+
process.stdout.write('\r\x1b[K'); // clear the spinner line
|
|
76
|
+
if (ok) success(finalMsg);
|
|
77
|
+
else fail(finalMsg);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
bold, dim, red, green, yellow, blue, magenta, cyan, gray, ember,
|
|
84
|
+
sym, success, fail, info, warn, muted, step, banner, spinner,
|
|
85
|
+
};
|