@ytspar/sweetlink 1.16.1 → 1.18.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/claude-skills/screenshot/SKILL.md +2 -0
- package/dist/cli/sweetlink-dev.js +0 -0
- package/dist/cli/sweetlink.js +338 -29
- package/dist/cli/sweetlink.js.map +1 -1
- package/dist/daemon/browser.d.ts +12 -0
- package/dist/daemon/browser.d.ts.map +1 -1
- package/dist/daemon/browser.js +62 -12
- package/dist/daemon/browser.js.map +1 -1
- package/dist/daemon/cursor.d.ts.map +1 -1
- package/dist/daemon/cursor.js +39 -8
- package/dist/daemon/cursor.js.map +1 -1
- package/dist/daemon/devices.d.ts +9 -4
- package/dist/daemon/devices.d.ts.map +1 -1
- package/dist/daemon/devices.js +25 -4
- package/dist/daemon/devices.js.map +1 -1
- package/dist/daemon/diff.d.ts +6 -0
- package/dist/daemon/diff.d.ts.map +1 -1
- package/dist/daemon/diff.js +46 -26
- package/dist/daemon/diff.js.map +1 -1
- package/dist/daemon/index.js +0 -0
- package/dist/daemon/listeners.d.ts +8 -2
- package/dist/daemon/listeners.d.ts.map +1 -1
- package/dist/daemon/listeners.js +39 -6
- package/dist/daemon/listeners.js.map +1 -1
- package/dist/daemon/recording.d.ts +28 -3
- package/dist/daemon/recording.d.ts.map +1 -1
- package/dist/daemon/recording.js +159 -17
- package/dist/daemon/recording.js.map +1 -1
- package/dist/daemon/refs.d.ts.map +1 -1
- package/dist/daemon/refs.js +7 -2
- package/dist/daemon/refs.js.map +1 -1
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +326 -19
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session.d.ts +2 -0
- package/dist/daemon/session.d.ts.map +1 -1
- package/dist/daemon/summary.d.ts.map +1 -1
- package/dist/daemon/summary.js +57 -11
- package/dist/daemon/summary.js.map +1 -1
- package/dist/daemon/types.d.ts +1 -1
- package/dist/daemon/types.d.ts.map +1 -1
- package/dist/daemon/types.js.map +1 -1
- package/dist/daemon/viewer.d.ts +7 -0
- package/dist/daemon/viewer.d.ts.map +1 -1
- package/dist/daemon/viewer.js +12 -4
- package/dist/daemon/viewer.js.map +1 -1
- package/dist/daemon/visualDiff.d.ts +2 -0
- package/dist/daemon/visualDiff.d.ts.map +1 -1
- package/dist/daemon/visualDiff.js +61 -2
- package/dist/daemon/visualDiff.js.map +1 -1
- package/dist/ruler.d.ts +1 -1
- package/dist/ruler.d.ts.map +1 -1
- package/dist/ruler.js +53 -32
- package/dist/ruler.js.map +1 -1
- package/package.json +10 -11
|
@@ -84,6 +84,8 @@ pnpm sweetlink screenshot --viewport mobile --force-cdp --output .tmp/screenshot
|
|
|
84
84
|
|
|
85
85
|
### Agent-Browser (Any URL)
|
|
86
86
|
|
|
87
|
+
A live observability dashboard auto-opens at http://localhost:4848 on the user's first agent-browser command of a session (wired via PreToolUse hook in `~/.claude/settings.json`). Mention the URL when you start an agent-browser flow so the user can watch along.
|
|
88
|
+
|
|
87
89
|
```bash
|
|
88
90
|
# Open page and screenshot
|
|
89
91
|
agent-browser open http://localhost:3000
|
|
File without changes
|
package/dist/cli/sweetlink.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getCardHeaderPreset, getNavigationPreset, measureViaPlaywright } from '
|
|
|
14
14
|
import { DEFAULT_WS_PORT, MAX_PORT_RETRIES, WS_PORT_OFFSET } from '../types.js';
|
|
15
15
|
import { SCREENSHOT_DIR } from '../urlUtils.js';
|
|
16
16
|
import { daemonRequest, ensureDaemon, getDaemonStatus, stopDaemon } from '../daemon/client.js';
|
|
17
|
+
import { extractPort } from '../daemon/stateFile.js';
|
|
17
18
|
import { uploadEvidence } from '../daemon/evidence.js';
|
|
18
19
|
import { emitJson, printOutputSchema } from './outputSchemas.js';
|
|
19
20
|
const COMMON_APP_PORTS = [3000, 3001, 4000, 5173, 5174, 8000, 8080];
|
|
@@ -111,11 +112,24 @@ function getRelativePath(absolutePath) {
|
|
|
111
112
|
* Report screenshot success to console
|
|
112
113
|
*/
|
|
113
114
|
function reportScreenshotSuccess(outputPath, width, height, method, selector) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
// One compact line by default; the multi-line version is preserved
|
|
116
|
+
// behind SWEETLINK_VERBOSE=1 for log-scrapers and the curious.
|
|
117
|
+
let sizeKb = '';
|
|
118
|
+
try {
|
|
119
|
+
sizeKb = ` · ${Math.round(fs.statSync(outputPath).size / 1024)}KB`;
|
|
120
|
+
}
|
|
121
|
+
catch { /* file may have been moved or removed */ }
|
|
122
|
+
const selPart = selector ? ` · ${selector}` : '';
|
|
123
|
+
if (process.env.SWEETLINK_VERBOSE === '1') {
|
|
124
|
+
console.log(`[Sweetlink] ✓ Screenshot saved to: ${getRelativePath(outputPath)}`);
|
|
125
|
+
console.log(`[Sweetlink] Dimensions: ${width}x${height}`);
|
|
126
|
+
if (selector)
|
|
127
|
+
console.log(`[Sweetlink] Selector: ${selector}`);
|
|
128
|
+
console.log(`[Sweetlink] Method: ${method}`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
console.log(`[Sweetlink] ✓ Screenshot saved: ${getRelativePath(outputPath)} · ${width}x${height}${sizeKb}${selPart} · ${method}`);
|
|
132
|
+
}
|
|
119
133
|
}
|
|
120
134
|
let resolvedWsUrl = null;
|
|
121
135
|
const DEFAULT_WS_URL = process.env.SWEETLINK_WS_URL || 'ws://localhost:9223';
|
|
@@ -457,12 +471,26 @@ async function screenshot(options) {
|
|
|
457
471
|
selector: options.selector,
|
|
458
472
|
fullPage: options.fullPage,
|
|
459
473
|
viewport: options.viewport,
|
|
474
|
+
padding: options.padding,
|
|
475
|
+
theme: options.theme,
|
|
460
476
|
});
|
|
461
477
|
const data = resp.data;
|
|
462
478
|
const outputPath = options.output || getDefaultScreenshotPath();
|
|
463
479
|
ensureDir(outputPath);
|
|
464
480
|
fs.writeFileSync(outputPath, Buffer.from(data.screenshot, 'base64'));
|
|
465
481
|
reportScreenshotSuccess(outputPath, data.width, data.height, 'Daemon (hifi)', options.selector);
|
|
482
|
+
// UX: warn about silent .first() when multiple elements match.
|
|
483
|
+
if (options.selector && data.matchCount && data.matchCount > 1) {
|
|
484
|
+
console.warn(`[Sweetlink] ⚠ Selector '${options.selector}' matched ${data.matchCount} elements; captured the first. ` +
|
|
485
|
+
`Use --index N (with click) or a more specific selector to pick another.`);
|
|
486
|
+
}
|
|
487
|
+
// UX: hint at --full-page when content extends below the viewport.
|
|
488
|
+
if (!options.selector && !options.fullPage &&
|
|
489
|
+
data.pageHeight && data.viewportHeight && data.pageHeight > data.viewportHeight + 4) {
|
|
490
|
+
const overflow = data.pageHeight - data.viewportHeight;
|
|
491
|
+
console.log(`[Sweetlink] ℹ Page extends ${overflow}px below the viewport. ` +
|
|
492
|
+
`Use --full-page to capture all of it.`);
|
|
493
|
+
}
|
|
466
494
|
return {
|
|
467
495
|
path: getRelativePath(outputPath),
|
|
468
496
|
width: data.width,
|
|
@@ -1838,11 +1866,25 @@ const COMMAND_HELP = {
|
|
|
1838
1866
|
stop Stop recording and generate session manifest
|
|
1839
1867
|
status Show recording status (default)
|
|
1840
1868
|
|
|
1869
|
+
Options for start:
|
|
1870
|
+
--label <text> Human-friendly label embedded in the manifest + SUMMARY title
|
|
1871
|
+
--viewport <preset|WxH> Recording viewport (default: 1512x982)
|
|
1872
|
+
|
|
1841
1873
|
Examples:
|
|
1842
|
-
pnpm sweetlink record start
|
|
1874
|
+
pnpm sweetlink record start --label "login flow"
|
|
1843
1875
|
pnpm sweetlink snapshot -i
|
|
1844
1876
|
pnpm sweetlink click @e3
|
|
1845
1877
|
pnpm sweetlink record stop`,
|
|
1878
|
+
sessions: ` sessions [list|open]
|
|
1879
|
+
List or open all recorded sessions in this project.
|
|
1880
|
+
|
|
1881
|
+
Subcommands:
|
|
1882
|
+
list Print every session with label, duration, action count, error count (default)
|
|
1883
|
+
open Open .sweetlink/index.html in the default browser
|
|
1884
|
+
|
|
1885
|
+
Examples:
|
|
1886
|
+
pnpm sweetlink sessions list
|
|
1887
|
+
pnpm sweetlink sessions open`,
|
|
1846
1888
|
report: ` report [options]
|
|
1847
1889
|
Print or share the latest session report.
|
|
1848
1890
|
|
|
@@ -1921,13 +1963,29 @@ Sweetlink CLI - Autonomous Development Bridge
|
|
|
1921
1963
|
|
|
1922
1964
|
Usage:
|
|
1923
1965
|
pnpm sweetlink <command> [options]
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1966
|
+
pnpm sweetlink <command> --help Detailed help for a single command
|
|
1967
|
+
pnpm sweetlink --help --all Show full help for every command
|
|
1968
|
+
|
|
1969
|
+
Commands:`);
|
|
1970
|
+
// Extract the first descriptive sentence from each command's help block
|
|
1971
|
+
// so the top-level help is scannable in <40 lines.
|
|
1972
|
+
for (const [name, help] of Object.entries(COMMAND_HELP)) {
|
|
1973
|
+
// Each block looks like:
|
|
1974
|
+
// " command [args]\n First-line description.\n..."
|
|
1975
|
+
// We pick the first non-empty line after the signature.
|
|
1976
|
+
const lines = help.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
1977
|
+
const desc = lines[1] ?? '';
|
|
1978
|
+
const summary = desc.length > 70 ? desc.slice(0, 67) + '…' : desc;
|
|
1979
|
+
console.log(` ${name.padEnd(14)} ${summary}`);
|
|
1980
|
+
}
|
|
1981
|
+
if (process.argv.includes('--all')) {
|
|
1982
|
+
console.log('\n— Full per-command details —\n');
|
|
1983
|
+
for (const help of Object.values(COMMAND_HELP)) {
|
|
1984
|
+
console.log(help);
|
|
1985
|
+
console.log('');
|
|
1986
|
+
}
|
|
1930
1987
|
}
|
|
1988
|
+
console.log('');
|
|
1931
1989
|
console.log(GLOBAL_HELP);
|
|
1932
1990
|
}
|
|
1933
1991
|
function showCommandHelp(command) {
|
|
@@ -1989,6 +2047,7 @@ if (hasFlag('--output-schema')) {
|
|
|
1989
2047
|
'fill',
|
|
1990
2048
|
'console',
|
|
1991
2049
|
'record',
|
|
2050
|
+
'sessions',
|
|
1992
2051
|
'proof',
|
|
1993
2052
|
'report',
|
|
1994
2053
|
'demo',
|
|
@@ -2116,6 +2175,8 @@ async function handleStatusCommand() {
|
|
|
2116
2175
|
width: getArg('--width') ? parseInt(getArg('--width'), 10) : undefined,
|
|
2117
2176
|
height: getArg('--height') ? parseInt(getArg('--height'), 10) : undefined,
|
|
2118
2177
|
hover: hasFlag('--hover'),
|
|
2178
|
+
padding: getArg('--padding') ? parseInt(getArg('--padding'), 10) : undefined,
|
|
2179
|
+
theme: getArg('--theme'),
|
|
2119
2180
|
url: getArg('--url'),
|
|
2120
2181
|
wait: !hasFlag('--no-wait'), // Wait by default, --no-wait to skip
|
|
2121
2182
|
waitTimeout: getArg('--wait-timeout')
|
|
@@ -2180,22 +2241,50 @@ async function handleStatusCommand() {
|
|
|
2180
2241
|
}
|
|
2181
2242
|
case 'click': {
|
|
2182
2243
|
const clickTarget = getArg('--selector') ?? args[1];
|
|
2244
|
+
const clickText = getArg('--text');
|
|
2245
|
+
const clickIndex = getArg('--index') ? parseInt(getArg('--index'), 10) : 0;
|
|
2246
|
+
const projRoot = findProjectRoot();
|
|
2247
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2183
2248
|
// Route @e refs to daemon
|
|
2184
2249
|
if (clickTarget && /^@e\d+$/.test(clickTarget)) {
|
|
2185
|
-
const projRoot = findProjectRoot();
|
|
2186
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2187
2250
|
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2188
2251
|
await daemonRequest(state, 'click-ref', { ref: clickTarget });
|
|
2189
2252
|
console.log(`[Sweetlink] Clicked ${clickTarget}`);
|
|
2190
2253
|
result = { clicked: clickTarget, found: 1, index: 0 };
|
|
2254
|
+
break;
|
|
2191
2255
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2256
|
+
// If a recording is in progress, route CSS clicks through the daemon
|
|
2257
|
+
// so they target the recording page (which has no devbar/WebSocket
|
|
2258
|
+
// bridge) and get logged into the session manifest.
|
|
2259
|
+
try {
|
|
2260
|
+
const status = await getDaemonStatus(projRoot, extractPort(targetUrl));
|
|
2261
|
+
if (status.running) {
|
|
2262
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2263
|
+
const recStatus = await daemonRequest(state, 'record-status');
|
|
2264
|
+
const recData = recStatus.data;
|
|
2265
|
+
if (recData?.recording) {
|
|
2266
|
+
const resp = await daemonRequest(state, 'click-css', {
|
|
2267
|
+
selector: clickTarget,
|
|
2268
|
+
text: clickText,
|
|
2269
|
+
index: clickIndex,
|
|
2270
|
+
});
|
|
2271
|
+
const data = resp.data;
|
|
2272
|
+
console.log(`[Sweetlink] Clicked (recording): ${data.clicked ?? clickTarget ?? clickText}`);
|
|
2273
|
+
result = {
|
|
2274
|
+
clicked: data.clicked ?? 'unknown',
|
|
2275
|
+
found: data.found ?? 1,
|
|
2276
|
+
index: data.index ?? clickIndex,
|
|
2277
|
+
};
|
|
2278
|
+
break;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2198
2281
|
}
|
|
2282
|
+
catch { /* fall through to WS path */ }
|
|
2283
|
+
result = await click({
|
|
2284
|
+
selector: clickTarget,
|
|
2285
|
+
text: clickText,
|
|
2286
|
+
index: clickIndex,
|
|
2287
|
+
});
|
|
2199
2288
|
break;
|
|
2200
2289
|
}
|
|
2201
2290
|
case 'network': {
|
|
@@ -2350,9 +2439,22 @@ async function handleStatusCommand() {
|
|
|
2350
2439
|
const subcommand = args[1];
|
|
2351
2440
|
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2352
2441
|
if (subcommand === 'start') {
|
|
2353
|
-
const
|
|
2442
|
+
const params = {};
|
|
2443
|
+
const label = getArg('--label');
|
|
2444
|
+
const viewport = getArg('--viewport');
|
|
2445
|
+
const storageState = getArg('--storage-state');
|
|
2446
|
+
if (label)
|
|
2447
|
+
params.label = label;
|
|
2448
|
+
if (viewport)
|
|
2449
|
+
params.viewport = viewport;
|
|
2450
|
+
if (storageState)
|
|
2451
|
+
params.storageState = storageState;
|
|
2452
|
+
if (hasFlag('--trace'))
|
|
2453
|
+
params.trace = true;
|
|
2454
|
+
const resp = await daemonRequest(state, 'record-start', params);
|
|
2354
2455
|
const data = resp.data;
|
|
2355
|
-
console.log(`[Sweetlink] Recording started: ${data.sessionId}`
|
|
2456
|
+
console.log(`[Sweetlink] Recording started: ${data.sessionId}` +
|
|
2457
|
+
(data.label ? ` (${data.label})` : ''));
|
|
2356
2458
|
result = data;
|
|
2357
2459
|
}
|
|
2358
2460
|
else if (subcommand === 'stop') {
|
|
@@ -2361,12 +2463,17 @@ async function handleStatusCommand() {
|
|
|
2361
2463
|
const m = data.manifest;
|
|
2362
2464
|
console.log(`[Sweetlink] Recording stopped: ${m.sessionId}`);
|
|
2363
2465
|
console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ' | Video: ' + m.video : ''}`);
|
|
2364
|
-
// Auto-open the viewer
|
|
2466
|
+
// Auto-open the viewer (cross-platform; --no-open to suppress)
|
|
2365
2467
|
if (data.viewerPath && !hasFlag('--no-open')) {
|
|
2366
2468
|
console.log(` Viewer: ${data.viewerPath}`);
|
|
2367
2469
|
const { execFile } = await import('child_process');
|
|
2368
|
-
|
|
2369
|
-
|
|
2470
|
+
// `start` on Windows is a cmd builtin, not an exe — must invoke via cmd.
|
|
2471
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
2472
|
+
: process.platform === 'win32' ? 'cmd'
|
|
2473
|
+
: 'xdg-open';
|
|
2474
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', data.viewerPath]
|
|
2475
|
+
: [data.viewerPath];
|
|
2476
|
+
execFile(cmd, args, (err) => {
|
|
2370
2477
|
if (err)
|
|
2371
2478
|
console.error(' Could not open viewer:', err.message);
|
|
2372
2479
|
});
|
|
@@ -2377,6 +2484,100 @@ async function handleStatusCommand() {
|
|
|
2377
2484
|
}
|
|
2378
2485
|
result = data;
|
|
2379
2486
|
}
|
|
2487
|
+
else if (subcommand === 'exec') {
|
|
2488
|
+
// record exec "click @e2; fill @e3 hello world; click @e5"
|
|
2489
|
+
// Runs a semicolon-separated DSL inside a fresh recording, then
|
|
2490
|
+
// auto-stops. Each step is one of:
|
|
2491
|
+
// click <selector|@ref>
|
|
2492
|
+
// fill <@ref> <value> (rest of line after ref = value)
|
|
2493
|
+
// press <key>
|
|
2494
|
+
// sleep <ms>
|
|
2495
|
+
// Strip known --flag value pairs from positional args before
|
|
2496
|
+
// joining what remains as the script body.
|
|
2497
|
+
const flagsWithValues = new Set(['--url', '--label', '--viewport', '--storage-state']);
|
|
2498
|
+
const positional = [];
|
|
2499
|
+
for (let i = 2; i < args.length; i++) {
|
|
2500
|
+
const a = args[i];
|
|
2501
|
+
if (a.startsWith('--')) {
|
|
2502
|
+
if (flagsWithValues.has(a))
|
|
2503
|
+
i++; // skip its value
|
|
2504
|
+
continue;
|
|
2505
|
+
}
|
|
2506
|
+
positional.push(a);
|
|
2507
|
+
}
|
|
2508
|
+
const script = positional.join(' ').trim();
|
|
2509
|
+
if (!script) {
|
|
2510
|
+
console.error('[Sweetlink] Error: record exec requires a script. Example: `record exec "click @e2; fill @e3 hello"`');
|
|
2511
|
+
process.exit(1);
|
|
2512
|
+
}
|
|
2513
|
+
const label = getArg('--label');
|
|
2514
|
+
const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
|
|
2515
|
+
const startData = startResp.data;
|
|
2516
|
+
console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
|
|
2517
|
+
const steps = script.split(';').map((s) => s.trim()).filter(Boolean);
|
|
2518
|
+
// Snapshot once up-front so refs resolve.
|
|
2519
|
+
await daemonRequest(state, 'snapshot', { interactive: true });
|
|
2520
|
+
for (const step of steps) {
|
|
2521
|
+
const [verb, ...rest] = step.split(/\s+/);
|
|
2522
|
+
try {
|
|
2523
|
+
if (verb === 'click') {
|
|
2524
|
+
const target = rest[0];
|
|
2525
|
+
if (!target)
|
|
2526
|
+
throw new Error('click needs a target');
|
|
2527
|
+
if (/^@e\d+$/.test(target)) {
|
|
2528
|
+
await daemonRequest(state, 'click-ref', { ref: target });
|
|
2529
|
+
}
|
|
2530
|
+
else {
|
|
2531
|
+
await daemonRequest(state, 'click-css', { selector: target });
|
|
2532
|
+
}
|
|
2533
|
+
console.log(` · click ${target}`);
|
|
2534
|
+
}
|
|
2535
|
+
else if (verb === 'fill') {
|
|
2536
|
+
const ref = rest[0];
|
|
2537
|
+
const value = rest.slice(1).join(' ');
|
|
2538
|
+
if (!ref || !/^@e\d+$/.test(ref))
|
|
2539
|
+
throw new Error('fill needs a @ref and a value');
|
|
2540
|
+
await daemonRequest(state, 'fill-ref', { ref, value });
|
|
2541
|
+
console.log(` · fill ${ref} = "${value}"`);
|
|
2542
|
+
}
|
|
2543
|
+
else if (verb === 'press') {
|
|
2544
|
+
const key = rest[0];
|
|
2545
|
+
if (!key)
|
|
2546
|
+
throw new Error('press needs a key');
|
|
2547
|
+
await daemonRequest(state, 'press-key', { key });
|
|
2548
|
+
console.log(` · press ${key}`);
|
|
2549
|
+
}
|
|
2550
|
+
else if (verb === 'sleep') {
|
|
2551
|
+
const ms = parseInt(rest[0] ?? '0', 10);
|
|
2552
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
2553
|
+
console.log(` · sleep ${ms}ms`);
|
|
2554
|
+
}
|
|
2555
|
+
else {
|
|
2556
|
+
throw new Error(`Unknown verb '${verb}'. Allowed: click, fill, press, sleep.`);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
catch (err) {
|
|
2560
|
+
console.error(` ✗ step "${step}" failed: ${err instanceof Error ? err.message : err}`);
|
|
2561
|
+
// Continue to record-stop so the partial recording is preserved.
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
const stopResp = await daemonRequest(state, 'record-stop');
|
|
2565
|
+
const stopData = stopResp.data;
|
|
2566
|
+
console.log(`[Sweetlink] Done: ${stopData.manifest.commands.length} actions in ` +
|
|
2567
|
+
`${stopData.manifest.duration.toFixed(1)}s${stopData.viewerPath ? ` · ${stopData.viewerPath}` : ''}`);
|
|
2568
|
+
result = stopData;
|
|
2569
|
+
}
|
|
2570
|
+
else if (subcommand === 'pause') {
|
|
2571
|
+
const resp = await daemonRequest(state, 'record-pause');
|
|
2572
|
+
console.log('[Sweetlink] Recording paused. Use `record resume` to continue.');
|
|
2573
|
+
result = resp.data;
|
|
2574
|
+
}
|
|
2575
|
+
else if (subcommand === 'resume') {
|
|
2576
|
+
const resp = await daemonRequest(state, 'record-resume');
|
|
2577
|
+
const d = resp.data;
|
|
2578
|
+
console.log(`[Sweetlink] Recording resumed. Paused for ${(d.pausedDurationMs / 1000).toFixed(1)}s.`);
|
|
2579
|
+
result = resp.data;
|
|
2580
|
+
}
|
|
2380
2581
|
else {
|
|
2381
2582
|
const resp = await daemonRequest(state, 'record-status');
|
|
2382
2583
|
const data = resp.data;
|
|
@@ -2510,6 +2711,99 @@ async function handleStatusCommand() {
|
|
|
2510
2711
|
}
|
|
2511
2712
|
break;
|
|
2512
2713
|
}
|
|
2714
|
+
case 'sessions': {
|
|
2715
|
+
const sub = args[1];
|
|
2716
|
+
const projRoot = findProjectRoot();
|
|
2717
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2718
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2719
|
+
const resp = await daemonRequest(state, 'sessions-list');
|
|
2720
|
+
const data = resp.data;
|
|
2721
|
+
if (sub === 'list' || !sub) {
|
|
2722
|
+
if (data.sessions.length === 0) {
|
|
2723
|
+
console.log('[Sweetlink] No sessions found.');
|
|
2724
|
+
}
|
|
2725
|
+
else {
|
|
2726
|
+
console.log(`[Sweetlink] ${data.sessions.length} session(s):\n`);
|
|
2727
|
+
for (const s of data.sessions) {
|
|
2728
|
+
const errTotal = s.errors ? s.errors.console + s.errors.network + s.errors.server : 0;
|
|
2729
|
+
const errBadge = errTotal > 0 ? ` · ${errTotal} err` : '';
|
|
2730
|
+
const labelTxt = s.label ? ` [${s.label}]` : '';
|
|
2731
|
+
const dur = s.duration ? `${s.duration.toFixed(1)}s` : '—';
|
|
2732
|
+
console.log(` ${s.sessionId}${labelTxt} · ${dur} · ${s.actionCount} actions${errBadge}`);
|
|
2733
|
+
}
|
|
2734
|
+
if (data.indexPath)
|
|
2735
|
+
console.log(`\n Index: ${data.indexPath}`);
|
|
2736
|
+
}
|
|
2737
|
+
result = { sessions: data.sessions };
|
|
2738
|
+
}
|
|
2739
|
+
else if (sub === 'diff') {
|
|
2740
|
+
// sessions diff <a> <b> — compare two recordings
|
|
2741
|
+
const [aId, bId] = [args[2], args[3]];
|
|
2742
|
+
if (!aId || !bId) {
|
|
2743
|
+
console.error('[Sweetlink] Usage: sessions diff <session-A> <session-B>');
|
|
2744
|
+
process.exit(1);
|
|
2745
|
+
}
|
|
2746
|
+
const findSession = (id) => data.sessions.find((s) => s.sessionId === id || s.sessionId.endsWith(id));
|
|
2747
|
+
const a = findSession(aId);
|
|
2748
|
+
const b = findSession(bId);
|
|
2749
|
+
if (!a || !b) {
|
|
2750
|
+
console.error(`[Sweetlink] Could not find session: ${!a ? aId : bId}`);
|
|
2751
|
+
process.exit(1);
|
|
2752
|
+
}
|
|
2753
|
+
const aManifest = JSON.parse(fs.readFileSync(a.manifestPath, 'utf-8'));
|
|
2754
|
+
const bManifest = JSON.parse(fs.readFileSync(b.manifestPath, 'utf-8'));
|
|
2755
|
+
const aActions = aManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
|
|
2756
|
+
const bActions = bManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
|
|
2757
|
+
console.log(`\n${a.sessionId}${a.label ? ` "${a.label}"` : ''} vs ${b.sessionId}${b.label ? ` "${b.label}"` : ''}\n`);
|
|
2758
|
+
console.log(`Duration: ${a.duration?.toFixed(1)}s vs ${b.duration?.toFixed(1)}s`);
|
|
2759
|
+
console.log(`Actions: ${a.actionCount} vs ${b.actionCount}`);
|
|
2760
|
+
const aErr = a.errors ? a.errors.console + a.errors.network + a.errors.server : 0;
|
|
2761
|
+
const bErr = b.errors ? b.errors.console + b.errors.network + b.errors.server : 0;
|
|
2762
|
+
console.log(`Errors: ${aErr} vs ${bErr}`);
|
|
2763
|
+
// Action diff (myers-style "added/removed" by line)
|
|
2764
|
+
const inA = new Set(aActions);
|
|
2765
|
+
const inB = new Set(bActions);
|
|
2766
|
+
const added = bActions.filter((x) => !inA.has(x));
|
|
2767
|
+
const removed = aActions.filter((x) => !inB.has(x));
|
|
2768
|
+
if (removed.length) {
|
|
2769
|
+
console.log(`\nOnly in ${a.sessionId}:`);
|
|
2770
|
+
removed.forEach((s) => console.log(` - ${s}`));
|
|
2771
|
+
}
|
|
2772
|
+
if (added.length) {
|
|
2773
|
+
console.log(`\nOnly in ${b.sessionId}:`);
|
|
2774
|
+
added.forEach((s) => console.log(` + ${s}`));
|
|
2775
|
+
}
|
|
2776
|
+
if (!added.length && !removed.length) {
|
|
2777
|
+
console.log('\nAction sequences are identical.');
|
|
2778
|
+
}
|
|
2779
|
+
result = {
|
|
2780
|
+
a: { id: a.sessionId, label: a.label, duration: a.duration, actions: a.actionCount, errors: aErr },
|
|
2781
|
+
b: { id: b.sessionId, label: b.label, duration: b.duration, actions: b.actionCount, errors: bErr },
|
|
2782
|
+
added,
|
|
2783
|
+
removed,
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
else if (sub === 'open') {
|
|
2787
|
+
// Open the index.html in the browser
|
|
2788
|
+
if (data.indexPath) {
|
|
2789
|
+
const { execFile } = await import('child_process');
|
|
2790
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
2791
|
+
: process.platform === 'win32' ? 'cmd'
|
|
2792
|
+
: 'xdg-open';
|
|
2793
|
+
const cmdArgs = process.platform === 'win32'
|
|
2794
|
+
? ['/c', 'start', '', data.indexPath]
|
|
2795
|
+
: [data.indexPath];
|
|
2796
|
+
execFile(cmd, cmdArgs, () => { });
|
|
2797
|
+
console.log(`[Sweetlink] Opened ${data.indexPath}`);
|
|
2798
|
+
}
|
|
2799
|
+
result = { indexPath: data.indexPath };
|
|
2800
|
+
}
|
|
2801
|
+
else {
|
|
2802
|
+
console.error(`[Sweetlink] Unknown sessions subcommand: ${sub}. Try: list, open`);
|
|
2803
|
+
process.exit(1);
|
|
2804
|
+
}
|
|
2805
|
+
break;
|
|
2806
|
+
}
|
|
2513
2807
|
case 'demo': {
|
|
2514
2808
|
const sub = args[1];
|
|
2515
2809
|
const projRoot = findProjectRoot();
|
|
@@ -2624,13 +2918,18 @@ async function handleStatusCommand() {
|
|
|
2624
2918
|
case 'daemon': {
|
|
2625
2919
|
const subcommand = args[1];
|
|
2626
2920
|
const projRoot = findProjectRoot();
|
|
2921
|
+
// Daemon state files are scoped by app port (`daemon-<port>.json`),
|
|
2922
|
+
// so honour --url for status/stop too — otherwise they look up the
|
|
2923
|
+
// un-suffixed `daemon.json` and miss the daemon that `start`
|
|
2924
|
+
// wrote with --url.
|
|
2925
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2926
|
+
const appPort = extractPort(targetUrl);
|
|
2627
2927
|
if (subcommand === 'stop') {
|
|
2628
|
-
const stopped = await stopDaemon(projRoot);
|
|
2928
|
+
const stopped = await stopDaemon(projRoot, appPort);
|
|
2629
2929
|
console.log(stopped ? '[Sweetlink] Daemon stopped.' : '[Sweetlink] No daemon running.');
|
|
2630
2930
|
result = { running: false };
|
|
2631
2931
|
}
|
|
2632
2932
|
else if (subcommand === 'start') {
|
|
2633
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2634
2933
|
const headedFlag = hasFlag('--headed');
|
|
2635
2934
|
const state = await ensureDaemon(projRoot, targetUrl, { headed: headedFlag });
|
|
2636
2935
|
console.log(`[Sweetlink] Daemon running on port ${state.port} (PID: ${state.pid})`);
|
|
@@ -2643,7 +2942,7 @@ async function handleStatusCommand() {
|
|
|
2643
2942
|
}
|
|
2644
2943
|
else {
|
|
2645
2944
|
// Default: status
|
|
2646
|
-
const status = await getDaemonStatus(projRoot);
|
|
2945
|
+
const status = await getDaemonStatus(projRoot, appPort);
|
|
2647
2946
|
if (status.running) {
|
|
2648
2947
|
console.log(`[Sweetlink] Daemon running: port=${status.port} pid=${status.pid} uptime=${status.uptime}s`);
|
|
2649
2948
|
}
|
|
@@ -2732,7 +3031,17 @@ async function handleStatusCommand() {
|
|
|
2732
3031
|
});
|
|
2733
3032
|
origExit(1);
|
|
2734
3033
|
}
|
|
2735
|
-
|
|
3034
|
+
// For Error objects, print just the message — the stack is rarely useful
|
|
3035
|
+
// to end users and clutters the output. Set SWEETLINK_DEBUG=1 to see it.
|
|
3036
|
+
if (error instanceof Error) {
|
|
3037
|
+
console.error(`[Sweetlink] ${error.message}`);
|
|
3038
|
+
if (process.env.SWEETLINK_DEBUG === '1' && error.stack) {
|
|
3039
|
+
console.error(error.stack);
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
else {
|
|
3043
|
+
console.error('[Sweetlink] Fatal error:', error);
|
|
3044
|
+
}
|
|
2736
3045
|
process.exit(1);
|
|
2737
3046
|
}
|
|
2738
3047
|
})();
|