@ytspar/sweetlink 1.23.0 → 1.24.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.
Files changed (92) hide show
  1. package/dist/auto.d.ts.map +1 -1
  2. package/dist/auto.js +3 -4
  3. package/dist/auto.js.map +1 -1
  4. package/dist/browser/commands/exec.d.ts.map +1 -1
  5. package/dist/browser/commands/exec.js +54 -1
  6. package/dist/browser/commands/exec.js.map +1 -1
  7. package/dist/browser/consoleCapture.d.ts +10 -1
  8. package/dist/browser/consoleCapture.d.ts.map +1 -1
  9. package/dist/browser/consoleCapture.js +44 -13
  10. package/dist/browser/consoleCapture.js.map +1 -1
  11. package/dist/cdp.d.ts +1 -1
  12. package/dist/cdp.d.ts.map +1 -1
  13. package/dist/cdp.js +1 -11
  14. package/dist/cdp.js.map +1 -1
  15. package/dist/cli/sweetlink-dev.js +4 -8
  16. package/dist/cli/sweetlink-dev.js.map +1 -1
  17. package/dist/cli/sweetlink.js +1048 -1020
  18. package/dist/cli/sweetlink.js.map +1 -1
  19. package/dist/daemon/client.d.ts +2 -2
  20. package/dist/daemon/client.d.ts.map +1 -1
  21. package/dist/daemon/client.js.map +1 -1
  22. package/dist/daemon/diff.d.ts.map +1 -1
  23. package/dist/daemon/diff.js +21 -1
  24. package/dist/daemon/diff.js.map +1 -1
  25. package/dist/daemon/index.js +17 -18
  26. package/dist/daemon/index.js.map +1 -1
  27. package/dist/daemon/listeners.d.ts.map +1 -1
  28. package/dist/daemon/listeners.js +12 -7
  29. package/dist/daemon/listeners.js.map +1 -1
  30. package/dist/daemon/recording.d.ts.map +1 -1
  31. package/dist/daemon/recording.js +50 -8
  32. package/dist/daemon/recording.js.map +1 -1
  33. package/dist/daemon/refs.d.ts.map +1 -1
  34. package/dist/daemon/refs.js +6 -2
  35. package/dist/daemon/refs.js.map +1 -1
  36. package/dist/daemon/ringBuffer.d.ts +10 -0
  37. package/dist/daemon/ringBuffer.d.ts.map +1 -1
  38. package/dist/daemon/ringBuffer.js +13 -0
  39. package/dist/daemon/ringBuffer.js.map +1 -1
  40. package/dist/daemon/server.d.ts +12 -0
  41. package/dist/daemon/server.d.ts.map +1 -1
  42. package/dist/daemon/server.js +99 -11
  43. package/dist/daemon/server.js.map +1 -1
  44. package/dist/daemon/stateFile.d.ts +15 -1
  45. package/dist/daemon/stateFile.d.ts.map +1 -1
  46. package/dist/daemon/stateFile.js +96 -33
  47. package/dist/daemon/stateFile.js.map +1 -1
  48. package/dist/daemon/types.d.ts +99 -10
  49. package/dist/daemon/types.d.ts.map +1 -1
  50. package/dist/daemon/types.js.map +1 -1
  51. package/dist/daemon/utils.d.ts +17 -4
  52. package/dist/daemon/utils.d.ts.map +1 -1
  53. package/dist/daemon/utils.js +30 -4
  54. package/dist/daemon/utils.js.map +1 -1
  55. package/dist/daemon/viewer.d.ts.map +1 -1
  56. package/dist/daemon/viewer.js +13 -5
  57. package/dist/daemon/viewer.js.map +1 -1
  58. package/dist/index.d.ts +1 -1
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/playwright.d.ts +1 -1
  62. package/dist/playwright.d.ts.map +1 -1
  63. package/dist/playwright.js +3 -22
  64. package/dist/playwright.js.map +1 -1
  65. package/dist/screenshotConstants.d.ts +13 -0
  66. package/dist/screenshotConstants.d.ts.map +1 -0
  67. package/dist/screenshotConstants.js +20 -0
  68. package/dist/screenshotConstants.js.map +1 -0
  69. package/dist/server/index.d.ts.map +1 -1
  70. package/dist/server/index.js +125 -97
  71. package/dist/server/index.js.map +1 -1
  72. package/dist/simulator/android.d.ts.map +1 -1
  73. package/dist/simulator/android.js +2 -22
  74. package/dist/simulator/android.js.map +1 -1
  75. package/dist/simulator/env.d.ts +10 -0
  76. package/dist/simulator/env.d.ts.map +1 -0
  77. package/dist/simulator/env.js +30 -0
  78. package/dist/simulator/env.js.map +1 -0
  79. package/dist/simulator/ios.d.ts.map +1 -1
  80. package/dist/simulator/ios.js +2 -22
  81. package/dist/simulator/ios.js.map +1 -1
  82. package/dist/types.d.ts +100 -70
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js +2 -0
  85. package/dist/types.js.map +1 -1
  86. package/dist/urlUtils.d.ts.map +1 -1
  87. package/dist/urlUtils.js +13 -2
  88. package/dist/urlUtils.js.map +1 -1
  89. package/dist/viewportUtils.d.ts +10 -1
  90. package/dist/viewportUtils.d.ts.map +1 -1
  91. package/dist/viewportUtils.js.map +1 -1
  92. package/package.json +1 -1
@@ -13,6 +13,7 @@ import { detectCDP, getNetworkRequestsViaCDP } from '../cdp.js';
13
13
  import { DaemonRequestError, daemonRequest, ensureDaemon, getDaemonStatus, stopDaemon, } from '../daemon/client.js';
14
14
  import { uploadEvidence } from '../daemon/evidence.js';
15
15
  import { extractPort } from '../daemon/stateFile.js';
16
+ import { ensureDir } from '../daemon/utils.js';
16
17
  import { screenshotViaPlaywright } from '../playwright.js';
17
18
  import { getCardHeaderPreset, getNavigationPreset, measureViaPlaywright } from '../ruler.js';
18
19
  import { DEFAULT_WS_PORT, MAX_PORT_RETRIES, WS_PORT_OFFSET } from '../types.js';
@@ -28,7 +29,19 @@ const COMMON_APP_PORTS = [3000, 3001, 4000, 5173, 5174, 8000, 8080];
28
29
  * 2. Script location - Fallback for edge cases
29
30
  * 3. cwd as final fallback
30
31
  */
32
+ // Memoize findProjectRoot — its result depends only on process.cwd() and
33
+ // the filesystem, both of which are effectively immutable for a CLI run.
34
+ // Without this, a single CLI invocation can call findProjectRoot 5–10
35
+ // times (assertOutputInRoot, getDefaultScreenshotPath, getRelativePath,
36
+ // reportScreenshotSuccess, …), each walking up the directory tree.
37
+ let cachedProjectRoot = null;
31
38
  function findProjectRoot() {
39
+ if (cachedProjectRoot !== null)
40
+ return cachedProjectRoot;
41
+ cachedProjectRoot = findProjectRootUncached();
42
+ return cachedProjectRoot;
43
+ }
44
+ function findProjectRootUncached() {
32
45
  const debug = process.env.SWEETLINK_DEBUG === '1';
33
46
  const root = path.parse(process.cwd()).root;
34
47
  const cwd = process.cwd();
@@ -82,15 +95,7 @@ function findProjectRoot() {
82
95
  console.error('[Sweetlink Debug] Using final fallback cwd:', cwd);
83
96
  return cwd;
84
97
  }
85
- /**
86
- * Ensure the directory for a file path exists
87
- */
88
- function ensureDir(filePath) {
89
- const dir = path.dirname(filePath);
90
- if (dir && dir !== '.' && !fs.existsSync(dir)) {
91
- fs.mkdirSync(dir, { recursive: true });
92
- }
93
- }
98
+ // ensureDir is imported from daemon/utils — single source of truth.
94
99
  /**
95
100
  * Find the most recent recording-session directory.
96
101
  * Returns the absolute path or null if no sessions exist.
@@ -2526,6 +2531,995 @@ async function handleStatusCommand() {
2526
2531
  process.exit(1);
2527
2532
  }
2528
2533
  }
2534
+ async function handleScreenshotCmd() {
2535
+ return screenshot({
2536
+ selector: getArg('--selector'),
2537
+ output: getArg('--output'),
2538
+ fullPage: hasFlag('--full-page'),
2539
+ forceCDP: hasFlag('--force-cdp'),
2540
+ forceWS: hasFlag('--force-ws'),
2541
+ hifi: hasFlag('--hifi'),
2542
+ responsive: hasFlag('--responsive'),
2543
+ a11y: hasFlag('--a11y'),
2544
+ viewport: getArg('--viewport'),
2545
+ width: getArg('--width') ? parseInt(getArg('--width'), 10) : undefined,
2546
+ height: getArg('--height') ? parseInt(getArg('--height'), 10) : undefined,
2547
+ hover: hasFlag('--hover'),
2548
+ hideDevbar: hasFlag('--hide-devbar'),
2549
+ padding: getArg('--padding') ? parseInt(getArg('--padding'), 10) : undefined,
2550
+ theme: getArg('--theme'),
2551
+ url: getArg('--url'),
2552
+ wait: !hasFlag('--no-wait'),
2553
+ waitTimeout: getArg('--wait-timeout') ? parseInt(getArg('--wait-timeout'), 10) : undefined,
2554
+ });
2555
+ }
2556
+ async function handleInspectCmd() {
2557
+ const projRoot = findProjectRoot();
2558
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2559
+ const state = await ensureDaemon(projRoot, targetUrl);
2560
+ const actionTranscript = [];
2561
+ args.forEach((arg, index) => {
2562
+ if (arg !== '--action')
2563
+ return;
2564
+ const value = args[index + 1];
2565
+ if (!value)
2566
+ return;
2567
+ actionTranscript.push({ action: value });
2568
+ });
2569
+ const resp = await daemonRequest(state, 'inspect', {
2570
+ last: getArg('--last') ? parseInt(getArg('--last'), 10) : undefined,
2571
+ label: getArg('--label'),
2572
+ expectedOutcome: getArg('--expected'),
2573
+ actionTranscript,
2574
+ includeA11y: !hasFlag('--no-a11y'),
2575
+ });
2576
+ const data = resp.data;
2577
+ const output = getArg('--output');
2578
+ if (output) {
2579
+ ensureDir(output);
2580
+ fs.writeFileSync(output, JSON.stringify(data, null, 2), 'utf-8');
2581
+ }
2582
+ if (getArg('--format') === 'json') {
2583
+ console.log(JSON.stringify(data, null, 2));
2584
+ }
2585
+ else {
2586
+ printInspectSummary(data);
2587
+ }
2588
+ return data;
2589
+ }
2590
+ async function handleQueryCmd() {
2591
+ const selector = getArg('--selector');
2592
+ if (!selector) {
2593
+ console.error('[Sweetlink] Error: --selector is required for query command');
2594
+ process.exit(1);
2595
+ }
2596
+ if (getArg('--url')) {
2597
+ const navigated = await navigateBrowser(getArg('--url'));
2598
+ if (!navigated) {
2599
+ console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
2600
+ process.exit(1);
2601
+ }
2602
+ }
2603
+ return queryDOM({
2604
+ selector,
2605
+ property: getArg('--property'),
2606
+ waitFor: getArg('--wait-for'),
2607
+ waitTimeout: getArg('--wait-timeout') ? parseInt(getArg('--wait-timeout'), 10) : undefined,
2608
+ });
2609
+ }
2610
+ async function handleLogsCmd() {
2611
+ const format = getArg('--format');
2612
+ return getLogs({
2613
+ filter: getArg('--filter'),
2614
+ format: format || 'text',
2615
+ dedupe: hasFlag('--dedupe'),
2616
+ output: getArg('--output'),
2617
+ });
2618
+ }
2619
+ async function handleExecCmd() {
2620
+ const code = getArg('--code');
2621
+ if (!code) {
2622
+ console.error('[Sweetlink] Error: --code is required for exec command');
2623
+ process.exit(1);
2624
+ }
2625
+ if (getArg('--url')) {
2626
+ const navigated = await navigateBrowser(getArg('--url'));
2627
+ if (!navigated) {
2628
+ console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
2629
+ process.exit(1);
2630
+ }
2631
+ }
2632
+ return execJS({
2633
+ code,
2634
+ waitFor: getArg('--wait-for'),
2635
+ waitTimeout: getArg('--wait-timeout') ? parseInt(getArg('--wait-timeout'), 10) : undefined,
2636
+ });
2637
+ }
2638
+ async function handleClickCmd() {
2639
+ const clickTarget = getArg('--selector') ?? args[1];
2640
+ const clickText = getArg('--text');
2641
+ const clickIndex = getArg('--index') ? parseInt(getArg('--index'), 10) : 0;
2642
+ const projRoot = findProjectRoot();
2643
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2644
+ // Route @e refs to daemon
2645
+ if (clickTarget && /^@e\d+$/.test(clickTarget)) {
2646
+ const state = await ensureDaemon(projRoot, targetUrl);
2647
+ await daemonRequest(state, 'click-ref', { ref: clickTarget });
2648
+ console.log(`[Sweetlink] Clicked ${clickTarget}`);
2649
+ return { clicked: clickTarget, found: 1, index: 0 };
2650
+ }
2651
+ // If a recording is in progress, route CSS clicks through the daemon
2652
+ // so they target the recording page (which has no devbar/WebSocket
2653
+ // bridge) and get logged into the session manifest.
2654
+ try {
2655
+ const status = await getDaemonStatus(projRoot, extractPort(targetUrl));
2656
+ if (status.running) {
2657
+ const state = await ensureDaemon(projRoot, targetUrl);
2658
+ const recStatus = await daemonRequest(state, 'record-status');
2659
+ const recData = recStatus.data;
2660
+ if (recData?.recording) {
2661
+ const resp = await daemonRequest(state, 'click-css', {
2662
+ selector: clickTarget,
2663
+ text: clickText,
2664
+ index: clickIndex,
2665
+ });
2666
+ const data = resp.data;
2667
+ console.log(`[Sweetlink] Clicked (recording): ${data.clicked ?? clickTarget ?? clickText}`);
2668
+ return {
2669
+ clicked: data.clicked ?? 'unknown',
2670
+ found: data.found ?? 1,
2671
+ index: data.index ?? clickIndex,
2672
+ };
2673
+ }
2674
+ }
2675
+ }
2676
+ catch {
2677
+ /* fall through to WS path */
2678
+ }
2679
+ return click({
2680
+ selector: clickTarget,
2681
+ text: clickText,
2682
+ index: clickIndex,
2683
+ });
2684
+ }
2685
+ async function handleNetworkCmd() {
2686
+ if (hasFlag('--failed')) {
2687
+ const projRoot = findProjectRoot();
2688
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2689
+ const lastN = getArg('--last') ? parseInt(getArg('--last'), 10) : undefined;
2690
+ const state = await ensureDaemon(projRoot, targetUrl);
2691
+ const resp = await daemonRequest(state, 'network-read', {
2692
+ failed: true,
2693
+ last: lastN,
2694
+ });
2695
+ const data = resp.data;
2696
+ console.log(data.formatted);
2697
+ console.log(`\nTotal: ${data.total} | Failed: ${data.failedCount}`);
2698
+ return data;
2699
+ }
2700
+ return getNetwork({ filter: getArg('--filter') });
2701
+ }
2702
+ async function handleRefreshCmd() {
2703
+ return refresh({ hard: hasFlag('--hard') });
2704
+ }
2705
+ async function handleRulerCmd() {
2706
+ const rulerSelectors = [];
2707
+ args.forEach((arg, i) => {
2708
+ if (arg === '--selector' && args[i + 1]) {
2709
+ rulerSelectors.push(args[i + 1]);
2710
+ }
2711
+ });
2712
+ return ruler({
2713
+ selectors: rulerSelectors.length > 0 ? rulerSelectors : undefined,
2714
+ preset: getArg('--preset'),
2715
+ url: getArg('--url'),
2716
+ output: getArg('--output'),
2717
+ showCenterLines: !hasFlag('--no-center-lines'),
2718
+ showDimensions: !hasFlag('--no-dimensions'),
2719
+ showPosition: hasFlag('--show-position'),
2720
+ showAlignment: !hasFlag('--no-alignment'),
2721
+ limit: getArg('--limit') ? parseInt(getArg('--limit'), 10) : undefined,
2722
+ format: getArg('--format'),
2723
+ });
2724
+ }
2725
+ async function handleSchemaCmd() {
2726
+ return getSchema({
2727
+ format: getArg('--format'),
2728
+ output: getArg('--output'),
2729
+ });
2730
+ }
2731
+ async function handleOutlineCmd() {
2732
+ return getOutline({
2733
+ format: getArg('--format'),
2734
+ output: getArg('--output'),
2735
+ });
2736
+ }
2737
+ async function handleA11yCmd() {
2738
+ return getA11y({
2739
+ format: getArg('--format'),
2740
+ output: getArg('--output'),
2741
+ });
2742
+ }
2743
+ async function handleVitalsCmd() {
2744
+ return getVitals({ format: getArg('--format') });
2745
+ }
2746
+ async function handleCleanupCmd() {
2747
+ return cleanup({ force: hasFlag('--force'), verbose: hasFlag('--verbose') });
2748
+ }
2749
+ async function handleSetupCmd() {
2750
+ const { execFileSync } = await import('child_process');
2751
+ const scriptDir = path.dirname(import.meta.url.replace('file://', ''));
2752
+ const setupScript = path.resolve(scriptDir, '..', '..', 'scripts', 'setup-claude-context.mjs');
2753
+ execFileSync('node', [setupScript], { stdio: 'inherit' });
2754
+ return undefined;
2755
+ }
2756
+ async function handleConsoleCmd() {
2757
+ const projRoot = findProjectRoot();
2758
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2759
+ const errorsOnly = hasFlag('--errors');
2760
+ const lastN = getArg('--last') ? parseInt(getArg('--last'), 10) : undefined;
2761
+ const state = await ensureDaemon(projRoot, targetUrl);
2762
+ const resp = await daemonRequest(state, 'console-read', {
2763
+ errors: errorsOnly,
2764
+ last: lastN,
2765
+ });
2766
+ const data = resp.data;
2767
+ console.log(data.formatted);
2768
+ console.log(`\nTotal: ${data.total} | Errors: ${data.errorCount} | Warnings: ${data.warningCount}`);
2769
+ return data;
2770
+ }
2771
+ async function handleSessionsCmd() {
2772
+ const sub = args[1];
2773
+ const projRoot = findProjectRoot();
2774
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2775
+ const state = await ensureDaemon(projRoot, targetUrl);
2776
+ const resp = await daemonRequest(state, 'sessions-list');
2777
+ const data = resp.data;
2778
+ if (sub === 'list' || !sub) {
2779
+ if (data.sessions.length === 0) {
2780
+ console.log('[Sweetlink] No sessions found.');
2781
+ }
2782
+ else {
2783
+ console.log(`[Sweetlink] ${data.sessions.length} session(s):\n`);
2784
+ for (const s of data.sessions) {
2785
+ const errTotal = s.errors ? s.errors.console + s.errors.network + s.errors.server : 0;
2786
+ const errBadge = errTotal > 0 ? ` · ${errTotal} err` : '';
2787
+ const labelTxt = s.label ? ` [${s.label}]` : '';
2788
+ const dur = s.duration ? `${s.duration.toFixed(1)}s` : '—';
2789
+ console.log(` ${s.sessionId}${labelTxt} · ${dur} · ${s.actionCount} actions${errBadge}`);
2790
+ }
2791
+ if (data.indexPath)
2792
+ console.log(`\n Index: ${data.indexPath}`);
2793
+ }
2794
+ return { sessions: data.sessions };
2795
+ }
2796
+ if (sub === 'diff') {
2797
+ // sessions diff <a> <b> — compare two recordings
2798
+ const [aId, bId] = [args[2], args[3]];
2799
+ if (!aId || !bId) {
2800
+ console.error('[Sweetlink] Usage: sessions diff <session-A> <session-B>');
2801
+ process.exit(1);
2802
+ }
2803
+ const findSession = (id) => data.sessions.find((s) => s.sessionId === id || s.sessionId.endsWith(id));
2804
+ const a = findSession(aId);
2805
+ const b = findSession(bId);
2806
+ if (!a || !b) {
2807
+ console.error(`[Sweetlink] Could not find session: ${!a ? aId : bId}`);
2808
+ process.exit(1);
2809
+ }
2810
+ const aManifest = JSON.parse(fs.readFileSync(a.manifestPath, 'utf-8'));
2811
+ const bManifest = JSON.parse(fs.readFileSync(b.manifestPath, 'utf-8'));
2812
+ const aActions = aManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
2813
+ const bActions = bManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
2814
+ console.log(`\n${a.sessionId}${a.label ? ` "${a.label}"` : ''} vs ${b.sessionId}${b.label ? ` "${b.label}"` : ''}\n`);
2815
+ console.log(`Duration: ${a.duration?.toFixed(1)}s vs ${b.duration?.toFixed(1)}s`);
2816
+ console.log(`Actions: ${a.actionCount} vs ${b.actionCount}`);
2817
+ const aErr = a.errors ? a.errors.console + a.errors.network + a.errors.server : 0;
2818
+ const bErr = b.errors ? b.errors.console + b.errors.network + b.errors.server : 0;
2819
+ console.log(`Errors: ${aErr} vs ${bErr}`);
2820
+ // Action diff (myers-style "added/removed" by line)
2821
+ const inA = new Set(aActions);
2822
+ const inB = new Set(bActions);
2823
+ const added = bActions.filter((x) => !inA.has(x));
2824
+ const removed = aActions.filter((x) => !inB.has(x));
2825
+ if (removed.length) {
2826
+ console.log(`\nOnly in ${a.sessionId}:`);
2827
+ removed.forEach((s) => console.log(` - ${s}`));
2828
+ }
2829
+ if (added.length) {
2830
+ console.log(`\nOnly in ${b.sessionId}:`);
2831
+ added.forEach((s) => console.log(` + ${s}`));
2832
+ }
2833
+ if (!added.length && !removed.length) {
2834
+ console.log('\nAction sequences are identical.');
2835
+ }
2836
+ return {
2837
+ a: {
2838
+ id: a.sessionId,
2839
+ label: a.label,
2840
+ duration: a.duration,
2841
+ actions: a.actionCount,
2842
+ errors: aErr,
2843
+ },
2844
+ b: {
2845
+ id: b.sessionId,
2846
+ label: b.label,
2847
+ duration: b.duration,
2848
+ actions: b.actionCount,
2849
+ errors: bErr,
2850
+ },
2851
+ added,
2852
+ removed,
2853
+ };
2854
+ }
2855
+ if (sub === 'open') {
2856
+ if (data.indexPath) {
2857
+ openInBrowser(data.indexPath);
2858
+ console.log(`[Sweetlink] Opened ${data.indexPath}`);
2859
+ }
2860
+ return { indexPath: data.indexPath };
2861
+ }
2862
+ console.error(`[Sweetlink] Unknown sessions subcommand: ${sub}. Try: list, open`);
2863
+ process.exit(1);
2864
+ }
2865
+ async function handleDemoCmd() {
2866
+ const sub = args[1];
2867
+ const projRoot = findProjectRoot();
2868
+ const demoDir = getArg('--output') ?? path.join(projRoot, '.sweetlink', 'demo');
2869
+ const stateFile = path.join(demoDir, 'demo-state.json');
2870
+ // Lazy import demo module
2871
+ const demoMod = await import('../daemon/demo.js');
2872
+ if (sub === 'init') {
2873
+ const title = args[2];
2874
+ if (!title) {
2875
+ console.error('[Sweetlink] Error: demo init requires a title');
2876
+ process.exit(1);
2877
+ }
2878
+ const demoState = await demoMod.initDemo(title, demoDir, { url: getArg('--url') });
2879
+ await demoMod.writeDemo(demoState);
2880
+ console.log(`[Sweetlink] Demo initialized: ${demoState.filePath}`);
2881
+ return { filePath: demoState.filePath };
2882
+ }
2883
+ if (sub === 'note') {
2884
+ const text = args.slice(2).join(' ');
2885
+ if (!text) {
2886
+ console.error('[Sweetlink] Error: demo note requires text');
2887
+ process.exit(1);
2888
+ }
2889
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
2890
+ const updated = demoMod.addNote(state, text);
2891
+ await demoMod.writeDemo(updated);
2892
+ console.log(`[Sweetlink] Note added (${updated.sections.length} sections)`);
2893
+ return { sections: updated.sections.length };
2894
+ }
2895
+ if (sub === 'exec') {
2896
+ const cmd = args.slice(2).join(' ');
2897
+ if (!cmd) {
2898
+ console.error('[Sweetlink] Error: demo exec requires a command');
2899
+ process.exit(1);
2900
+ }
2901
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
2902
+ const updated = await demoMod.addExec(state, cmd, []);
2903
+ await demoMod.writeDemo(updated);
2904
+ const lastSection = updated.sections[updated.sections.length - 1];
2905
+ console.log(`[Sweetlink] Exec added: ${cmd} (exit ${lastSection.exitCode ?? 0})`);
2906
+ return { sections: updated.sections.length, exitCode: lastSection.exitCode };
2907
+ }
2908
+ if (sub === 'screenshot') {
2909
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2910
+ const caption = getArg('--caption') ?? 'Screenshot';
2911
+ const daemonState = await ensureDaemon(projRoot, targetUrl);
2912
+ const resp = await daemonRequest(daemonState, 'screenshot', {});
2913
+ const data = resp.data;
2914
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
2915
+ const updated = await demoMod.addScreenshot(state, Buffer.from(data.screenshot, 'base64'), caption);
2916
+ await demoMod.writeDemo(updated);
2917
+ console.log(`[Sweetlink] Screenshot added: ${caption}`);
2918
+ return { sections: updated.sections.length };
2919
+ }
2920
+ if (sub === 'snapshot') {
2921
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2922
+ const daemonState = await ensureDaemon(projRoot, targetUrl);
2923
+ const resp = await daemonRequest(daemonState, 'snapshot', { interactive: true });
2924
+ const data = resp.data;
2925
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
2926
+ const updated = demoMod.addSnapshot(state, data.tree);
2927
+ await demoMod.writeDemo(updated);
2928
+ console.log(`[Sweetlink] Snapshot added (${updated.sections.length} sections)`);
2929
+ return { sections: updated.sections.length };
2930
+ }
2931
+ if (sub === 'pop') {
2932
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
2933
+ const updated = demoMod.popSection(state);
2934
+ await demoMod.writeDemo(updated);
2935
+ console.log(`[Sweetlink] Last section removed (${updated.sections.length} remaining)`);
2936
+ return { sections: updated.sections.length };
2937
+ }
2938
+ if (sub === 'verify') {
2939
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
2940
+ const verifyResult = await demoMod.verifyDemo(state);
2941
+ if (verifyResult.passed) {
2942
+ console.log('[Sweetlink] Demo verified: all outputs match');
2943
+ }
2944
+ else {
2945
+ console.log(`[Sweetlink] Demo verification FAILED: ${verifyResult.failures.length} mismatch(es)`);
2946
+ for (const f of verifyResult.failures) {
2947
+ console.log(` Section ${f.index}: ${f.command}`);
2948
+ console.log(` Expected: ${f.expected.substring(0, 80)}...`);
2949
+ console.log(` Actual: ${f.actual.substring(0, 80)}...`);
2950
+ }
2951
+ }
2952
+ return verifyResult;
2953
+ }
2954
+ // Default: status
2955
+ if (fs.existsSync(stateFile)) {
2956
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
2957
+ console.log(`[Sweetlink] Demo: "${state.title}" (${state.sections.length} sections)`);
2958
+ console.log(` File: ${state.filePath}`);
2959
+ for (const s of state.sections) {
2960
+ const preview = s.type === 'note'
2961
+ ? s.content.substring(0, 60)
2962
+ : s.type === 'exec'
2963
+ ? `$ ${s.command}`
2964
+ : s.type === 'screenshot'
2965
+ ? `[image] ${s.screenshotFile}`
2966
+ : '[snapshot]';
2967
+ console.log(` ${s.type.padEnd(12)} ${preview}`);
2968
+ }
2969
+ return state;
2970
+ }
2971
+ console.log('[Sweetlink] No demo in progress. Run `demo init <title>` to start.');
2972
+ return null;
2973
+ }
2974
+ async function handleDaemonCmd() {
2975
+ const subcommand = args[1];
2976
+ const projRoot = findProjectRoot();
2977
+ // Daemon state files are scoped by app port (`daemon-<port>.json`),
2978
+ // so honour --url for status/stop too — otherwise they look up the
2979
+ // un-suffixed `daemon.json` and miss the daemon that `start`
2980
+ // wrote with --url.
2981
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2982
+ const appPort = extractPort(targetUrl);
2983
+ if (subcommand === 'stop') {
2984
+ const stopped = await stopDaemon(projRoot, appPort);
2985
+ console.log(stopped ? '[Sweetlink] Daemon stopped.' : '[Sweetlink] No daemon running.');
2986
+ return { running: false };
2987
+ }
2988
+ if (subcommand === 'start') {
2989
+ const headedFlag = hasFlag('--headed');
2990
+ const state = await ensureDaemon(projRoot, targetUrl, { headed: headedFlag });
2991
+ console.log(`[Sweetlink] Daemon running on port ${state.port} (PID: ${state.pid})`);
2992
+ return {
2993
+ running: true,
2994
+ pid: state.pid,
2995
+ port: state.port,
2996
+ url: state.url,
2997
+ };
2998
+ }
2999
+ // Default: status
3000
+ const status = await getDaemonStatus(projRoot, appPort);
3001
+ if (status.running) {
3002
+ console.log(`[Sweetlink] Daemon running: port=${status.port} pid=${status.pid} uptime=${status.uptime}s`);
3003
+ }
3004
+ else {
3005
+ console.log('[Sweetlink] No daemon running.');
3006
+ }
3007
+ return status;
3008
+ }
3009
+ async function handleFillCmd() {
3010
+ const fillTarget = getArg('--selector') ?? args[1];
3011
+ const fillValue = getArg('--value') ?? args[2];
3012
+ if (!fillTarget) {
3013
+ console.error('[Sweetlink] Error: fill requires a target (@ref or --selector)');
3014
+ process.exit(1);
3015
+ }
3016
+ if (fillValue === undefined) {
3017
+ console.error('[Sweetlink] Error: fill requires a value (--value or positional arg)');
3018
+ process.exit(1);
3019
+ }
3020
+ if (/^@e\d+$/.test(fillTarget)) {
3021
+ const projRoot = findProjectRoot();
3022
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
3023
+ const state = await ensureDaemon(projRoot, targetUrl);
3024
+ await daemonRequest(state, 'fill-ref', { ref: fillTarget, value: fillValue });
3025
+ console.log(`[Sweetlink] Filled ${fillTarget} with "${fillValue}"`);
3026
+ return { clicked: fillTarget, found: 1, index: 0 };
3027
+ }
3028
+ console.error('[Sweetlink] Error: fill currently only supports @e refs. Run `snapshot -i` first.');
3029
+ process.exit(1);
3030
+ }
3031
+ async function handleSnapshotCmd() {
3032
+ const projRoot = findProjectRoot();
3033
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
3034
+ const interactive = hasFlag('-i') || hasFlag('--interactive');
3035
+ const doDiff = hasFlag('-D') || hasFlag('--diff');
3036
+ const doAnnotate = hasFlag('-a') || hasFlag('--annotate');
3037
+ const state = await ensureDaemon(projRoot, targetUrl);
3038
+ const resp = await daemonRequest(state, 'snapshot', {
3039
+ interactive,
3040
+ diff: doDiff,
3041
+ annotate: doAnnotate,
3042
+ });
3043
+ const data = resp.data;
3044
+ if (doDiff && data.diff) {
3045
+ console.log(data.diff);
3046
+ }
3047
+ else if (doAnnotate && data.screenshot) {
3048
+ const outputPath = getArg('--output') ?? getArg('-o') ?? 'annotated-snapshot.png';
3049
+ fs.writeFileSync(outputPath, Buffer.from(data.screenshot, 'base64'));
3050
+ console.log(`[Sweetlink] Annotated screenshot saved: ${outputPath}`);
3051
+ }
3052
+ else {
3053
+ console.log(data.tree);
3054
+ }
3055
+ console.log(`\n${data.count} elements found`);
3056
+ return {
3057
+ tree: data.tree,
3058
+ refs: data.refs,
3059
+ diff: data.diff,
3060
+ };
3061
+ }
3062
+ async function handleReportCmd() {
3063
+ const sessionDirArg = getArg('--session') ?? '.sweetlink';
3064
+ if (!fs.existsSync(sessionDirArg)) {
3065
+ console.error(`[Sweetlink] Session directory not found: ${sessionDirArg}`);
3066
+ process.exit(1);
3067
+ }
3068
+ const reportSessionDir = findLatestSessionDir(sessionDirArg);
3069
+ if (!reportSessionDir) {
3070
+ console.error('[Sweetlink] No session found. Run `record start` and `record stop` first.');
3071
+ process.exit(1);
3072
+ }
3073
+ if (hasFlag('--clipboard')) {
3074
+ const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
3075
+ if (!fs.existsSync(summaryPath)) {
3076
+ console.error(`[Sweetlink] SUMMARY.md not found at ${summaryPath}`);
3077
+ process.exit(1);
3078
+ }
3079
+ const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
3080
+ const { execFileSync } = await import('child_process');
3081
+ const clipCmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip';
3082
+ const clipArgs = process.platform === 'darwin' ? [] : ['-selection', 'clipboard'];
3083
+ try {
3084
+ execFileSync(clipCmd, clipArgs, { input: summaryContent });
3085
+ console.log('[Sweetlink] SUMMARY.md copied to clipboard.');
3086
+ }
3087
+ catch (err) {
3088
+ console.error(`[Sweetlink] Failed to copy to clipboard (${clipCmd}):`, err instanceof Error ? err.message : err);
3089
+ process.exit(1);
3090
+ }
3091
+ return { mode: 'clipboard', session: path.basename(reportSessionDir) };
3092
+ }
3093
+ if (hasFlag('--serve')) {
3094
+ const viewerPath = path.join(reportSessionDir, 'viewer.html');
3095
+ if (!fs.existsSync(viewerPath)) {
3096
+ console.error(`[Sweetlink] viewer.html not found at ${viewerPath}`);
3097
+ process.exit(1);
3098
+ }
3099
+ const viewerContent = fs.readFileSync(viewerPath, 'utf-8');
3100
+ const http = await import('http');
3101
+ const port = 10000 + Math.floor(Math.random() * 50000);
3102
+ const server = http.createServer((_req, res) => {
3103
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
3104
+ res.end(viewerContent);
3105
+ });
3106
+ server.listen(port, '0.0.0.0', () => {
3107
+ const os = require('os');
3108
+ const nets = os.networkInterfaces();
3109
+ let lanIp = 'localhost';
3110
+ for (const name of Object.keys(nets)) {
3111
+ for (const net of nets[name]) {
3112
+ if (net.family === 'IPv4' && !net.internal) {
3113
+ lanIp = net.address;
3114
+ break;
3115
+ }
3116
+ }
3117
+ if (lanIp !== 'localhost')
3118
+ break;
3119
+ }
3120
+ console.log(`[Sweetlink] Serving viewer at:`);
3121
+ console.log(` Local: http://localhost:${port}`);
3122
+ console.log(` Network: http://${lanIp}:${port}`);
3123
+ console.log(' Press Ctrl+C to stop.');
3124
+ });
3125
+ // Keep running until Ctrl+C
3126
+ await new Promise(() => { });
3127
+ return undefined;
3128
+ }
3129
+ if (getArg('--webhook')) {
3130
+ const webhookUrl = getArg('--webhook');
3131
+ const manifestPath = path.join(reportSessionDir, 'sweetlink-session.json');
3132
+ const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
3133
+ if (!fs.existsSync(manifestPath)) {
3134
+ console.error(`[Sweetlink] Manifest not found at ${manifestPath}`);
3135
+ process.exit(1);
3136
+ }
3137
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
3138
+ const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
3139
+ const payload = {
3140
+ summary,
3141
+ manifest,
3142
+ };
3143
+ // Include viewer HTML for Slack/Discord webhooks
3144
+ if (/slack|discord/i.test(webhookUrl)) {
3145
+ const viewerPath = path.join(reportSessionDir, 'viewer.html');
3146
+ if (fs.existsSync(viewerPath)) {
3147
+ payload.viewerHtml = fs.readFileSync(viewerPath, 'utf-8');
3148
+ }
3149
+ }
3150
+ const body = JSON.stringify(payload);
3151
+ const res = await fetch(webhookUrl, {
3152
+ method: 'POST',
3153
+ headers: { 'Content-Type': 'application/json' },
3154
+ body,
3155
+ });
3156
+ if (res.ok) {
3157
+ console.log(`[Sweetlink] Report posted to ${webhookUrl} (${res.status})`);
3158
+ }
3159
+ else {
3160
+ console.error(`[Sweetlink] Webhook failed: ${res.status} ${res.statusText}`);
3161
+ process.exit(1);
3162
+ }
3163
+ return { mode: 'webhook', url: webhookUrl, status: res.status };
3164
+ }
3165
+ // Default: print SUMMARY.md to stdout
3166
+ const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
3167
+ if (!fs.existsSync(summaryPath)) {
3168
+ console.error(`[Sweetlink] SUMMARY.md not found at ${summaryPath}`);
3169
+ process.exit(1);
3170
+ }
3171
+ const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
3172
+ process.stdout.write(summaryContent);
3173
+ return { mode: 'stdout', session: path.basename(reportSessionDir) };
3174
+ }
3175
+ async function handleSimCmd() {
3176
+ // Record iOS Simulator or Android Emulator screen while running a command.
3177
+ const platform = args[1];
3178
+ if (platform !== 'ios' && platform !== 'android') {
3179
+ console.error('[Sweetlink] Usage: sweetlink sim <ios|android> "<command>" [--output path] [--device <name>]');
3180
+ process.exit(1);
3181
+ }
3182
+ const flagsWithValues = new Set([
3183
+ '--output',
3184
+ '--label',
3185
+ '--device',
3186
+ '--time-limit',
3187
+ '--app',
3188
+ '--run',
3189
+ ]);
3190
+ const positional = [];
3191
+ for (let i = 2; i < args.length; i++) {
3192
+ const a = args[i];
3193
+ if (a.startsWith('--')) {
3194
+ if (flagsWithValues.has(a))
3195
+ i++;
3196
+ continue;
3197
+ }
3198
+ positional.push(a);
3199
+ }
3200
+ const command = positional.join(' ').trim();
3201
+ if (!command) {
3202
+ console.error(`[Sweetlink] Error: sim ${platform} requires a command. Example: sweetlink sim ${platform} "fastlane scan"`);
3203
+ process.exit(1);
3204
+ }
3205
+ const label = getArg('--label');
3206
+ const labelSlug = label
3207
+ ? label
3208
+ .replace(/[^a-z0-9]/gi, '-')
3209
+ .toLowerCase()
3210
+ .slice(0, 40)
3211
+ : `sim-${platform}`;
3212
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
3213
+ const { runSlot: simRunSlot } = await import('../runs.js');
3214
+ const defaultDir = simRunSlot({
3215
+ baseDir: findProjectRoot(),
3216
+ app: getArg('--app'),
3217
+ run: getArg('--run'),
3218
+ kind: 'sim',
3219
+ });
3220
+ const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.mp4`);
3221
+ ensureDir(output);
3222
+ const device = getArg('--device');
3223
+ console.log(`[Sweetlink] Recording ${platform} simulator: ${command}`);
3224
+ let recResult;
3225
+ if (platform === 'ios') {
3226
+ const { recordIosSimulator } = await import('../simulator/ios.js');
3227
+ recResult = await recordIosSimulator({ command, output, device });
3228
+ }
3229
+ else {
3230
+ const { recordAndroidEmulator } = await import('../simulator/android.js');
3231
+ const tl = getArg('--time-limit');
3232
+ recResult = await recordAndroidEmulator({
3233
+ command,
3234
+ output,
3235
+ device,
3236
+ timeLimit: tl ? parseInt(tl, 10) : undefined,
3237
+ overlays: !hasFlag('--no-overlays'),
3238
+ });
3239
+ }
3240
+ let sizeKb = '?';
3241
+ try {
3242
+ sizeKb = String(Math.round(fs.statSync(output).size / 1024));
3243
+ }
3244
+ catch {
3245
+ /* file may not exist if recordingClosed is false */
3246
+ }
3247
+ const tapSuffix = (recResult.tapCount ?? 0) > 0
3248
+ ? ` · ${recResult.tapCount} taps${recResult.overlaysApplied ? ' (overlaid)' : ' (sidecar only — install ffmpeg for overlays)'}`
3249
+ : '';
3250
+ console.log(`[Sweetlink] ${recResult.recordingClosed ? '✓' : '⚠'} ${getRelativePath(output)} · ` +
3251
+ `${recResult.durationSec.toFixed(1)}s · ${sizeKb}KB · ${recResult.device} · exit=${recResult.exitCode}` +
3252
+ tapSuffix +
3253
+ (recResult.recordingClosed
3254
+ ? ''
3255
+ : ' (recording was force-killed; mp4 may be incomplete)'));
3256
+ const result = {
3257
+ path: output,
3258
+ device: recResult.device,
3259
+ durationSec: recResult.durationSec,
3260
+ exitCode: recResult.exitCode,
3261
+ recordingClosed: recResult.recordingClosed,
3262
+ tapCount: recResult.tapCount,
3263
+ tapsJsonPath: recResult.tapsJsonPath,
3264
+ overlaysApplied: recResult.overlaysApplied,
3265
+ };
3266
+ if (recResult.exitCode !== 0 && !hasFlag('--ignore-exit')) {
3267
+ process.exit(recResult.exitCode);
3268
+ }
3269
+ return result;
3270
+ }
3271
+ async function handleTermCmd() {
3272
+ // Record a shell command's stdout/stderr into asciicast v2 + HTML player.
3273
+ const flagsWithValues = new Set([
3274
+ '--output',
3275
+ '--label',
3276
+ '--shell',
3277
+ '--cols',
3278
+ '--rows',
3279
+ '--app',
3280
+ '--run',
3281
+ ]);
3282
+ const positional = [];
3283
+ for (let i = 1; i < args.length; i++) {
3284
+ const a = args[i];
3285
+ if (a.startsWith('--')) {
3286
+ if (flagsWithValues.has(a))
3287
+ i++;
3288
+ continue;
3289
+ }
3290
+ positional.push(a);
3291
+ }
3292
+ const command = positional.join(' ').trim();
3293
+ if (!command) {
3294
+ console.error('[Sweetlink] Error: term requires a command. Example: sweetlink term "pytest tests/"');
3295
+ process.exit(1);
3296
+ }
3297
+ const label = getArg('--label');
3298
+ const labelSlug = label
3299
+ ? label
3300
+ .replace(/[^a-z0-9]/gi, '-')
3301
+ .toLowerCase()
3302
+ .slice(0, 40)
3303
+ : 'term';
3304
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
3305
+ const { runSlot } = await import('../runs.js');
3306
+ const defaultDir = runSlot({
3307
+ baseDir: findProjectRoot(),
3308
+ app: getArg('--app'),
3309
+ run: getArg('--run'),
3310
+ kind: 'term',
3311
+ });
3312
+ const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.cast`);
3313
+ ensureDir(output);
3314
+ console.log(`[Sweetlink] Recording terminal: ${command}`);
3315
+ const { captureTerminal } = await import('../term/recorder.js');
3316
+ const { generatePlayer } = await import('../term/player.js');
3317
+ const cap = await captureTerminal({
3318
+ command,
3319
+ output,
3320
+ label,
3321
+ shell: getArg('--shell'),
3322
+ cols: getArg('--cols') ? parseInt(getArg('--cols'), 10) : undefined,
3323
+ rows: getArg('--rows') ? parseInt(getArg('--rows'), 10) : undefined,
3324
+ });
3325
+ const playerPath = await generatePlayer({ castPath: output });
3326
+ console.log(`[Sweetlink] ✓ ${getRelativePath(output)} · ${cap.durationSec.toFixed(1)}s · ` +
3327
+ `${cap.events} events · ${(cap.bytes / 1024).toFixed(0)}KB · exit=${cap.exitCode}`);
3328
+ console.log(`[Sweetlink] ▶ ${getRelativePath(playerPath)}`);
3329
+ const result = {
3330
+ castPath: output,
3331
+ playerPath,
3332
+ durationSec: cap.durationSec,
3333
+ bytes: cap.bytes,
3334
+ events: cap.events,
3335
+ exitCode: cap.exitCode,
3336
+ };
3337
+ // Propagate the recorded command's exit code by default so CI fails
3338
+ // when the wrapped tests fail.
3339
+ if (cap.exitCode !== 0 && !hasFlag('--ignore-exit')) {
3340
+ process.exit(cap.exitCode);
3341
+ }
3342
+ return result;
3343
+ }
3344
+ async function handleRecordCmd() {
3345
+ const projRoot = findProjectRoot();
3346
+ const targetUrl = getArg('--url') ?? 'http://localhost:3000';
3347
+ const subcommand = args[1];
3348
+ const state = await ensureDaemon(projRoot, targetUrl);
3349
+ if (subcommand === 'start') {
3350
+ const params = {};
3351
+ const label = getArg('--label');
3352
+ const viewport = getArg('--viewport');
3353
+ const storageState = getArg('--storage-state');
3354
+ if (label)
3355
+ params.label = label;
3356
+ if (viewport)
3357
+ params.viewport = viewport;
3358
+ if (storageState)
3359
+ params.storageState = storageState;
3360
+ if (hasFlag('--trace'))
3361
+ params.trace = true;
3362
+ const resp = await daemonRequest(state, 'record-start', params);
3363
+ const data = resp.data;
3364
+ console.log(`[Sweetlink] Recording started: ${data.sessionId}` +
3365
+ (data.label ? ` (${data.label})` : ''));
3366
+ return data;
3367
+ }
3368
+ if (subcommand === 'stop') {
3369
+ const resp = await daemonRequest(state, 'record-stop');
3370
+ const data = resp.data;
3371
+ const m = data.manifest;
3372
+ console.log(`[Sweetlink] Recording stopped: ${m.sessionId}`);
3373
+ console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ` | Video: ${m.video}` : ''}`);
3374
+ // Auto-open the viewer (cross-platform; --no-open to suppress)
3375
+ if (data.viewerPath && !hasFlag('--no-open')) {
3376
+ console.log(` Viewer: ${data.viewerPath}`);
3377
+ openInBrowser(data.viewerPath);
3378
+ console.log(` Opened in browser.`);
3379
+ }
3380
+ else if (data.viewerPath) {
3381
+ console.log(` Viewer: ${data.viewerPath}`);
3382
+ }
3383
+ return data;
3384
+ }
3385
+ if (subcommand === 'exec') {
3386
+ // record exec "click @e2; fill @e3 hello world; click @e5"
3387
+ // Runs a semicolon-separated DSL inside a fresh recording, then
3388
+ // auto-stops. Each step is one of:
3389
+ // click <selector|@ref>
3390
+ // fill <@ref> <value> (rest of line after ref = value)
3391
+ // press <key>
3392
+ // sleep <ms>
3393
+ // Strip known --flag value pairs from positional args before
3394
+ // joining what remains as the script body.
3395
+ const flagsWithValues = new Set(['--url', '--label', '--viewport', '--storage-state']);
3396
+ const positional = [];
3397
+ for (let i = 2; i < args.length; i++) {
3398
+ const a = args[i];
3399
+ if (a.startsWith('--')) {
3400
+ if (flagsWithValues.has(a))
3401
+ i++; // skip its value
3402
+ continue;
3403
+ }
3404
+ positional.push(a);
3405
+ }
3406
+ const script = positional.join(' ').trim();
3407
+ if (!script) {
3408
+ console.error('[Sweetlink] Error: record exec requires a script. Example: `record exec "click @e2; fill @e3 hello"`');
3409
+ process.exit(1);
3410
+ }
3411
+ const label = getArg('--label');
3412
+ const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
3413
+ const startData = startResp.data;
3414
+ console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
3415
+ const steps = script
3416
+ .split(';')
3417
+ .map((s) => s.trim())
3418
+ .filter(Boolean);
3419
+ // Snapshot once up-front so refs resolve.
3420
+ await daemonRequest(state, 'snapshot', { interactive: true });
3421
+ for (const step of steps) {
3422
+ const [verb, ...rest] = step.split(/\s+/);
3423
+ try {
3424
+ if (verb === 'click') {
3425
+ const target = rest[0];
3426
+ if (!target)
3427
+ throw new Error('click needs a target');
3428
+ if (/^@e\d+$/.test(target)) {
3429
+ await daemonRequest(state, 'click-ref', { ref: target });
3430
+ }
3431
+ else {
3432
+ await daemonRequest(state, 'click-css', { selector: target });
3433
+ }
3434
+ console.log(` · click ${target}`);
3435
+ }
3436
+ else if (verb === 'fill') {
3437
+ const ref = rest[0];
3438
+ const value = rest.slice(1).join(' ');
3439
+ if (!ref || !/^@e\d+$/.test(ref))
3440
+ throw new Error('fill needs a @ref and a value');
3441
+ await daemonRequest(state, 'fill-ref', { ref, value });
3442
+ console.log(` · fill ${ref} = "${value}"`);
3443
+ }
3444
+ else if (verb === 'press') {
3445
+ const key = rest[0];
3446
+ if (!key)
3447
+ throw new Error('press needs a key');
3448
+ await daemonRequest(state, 'press-key', { key });
3449
+ console.log(` · press ${key}`);
3450
+ }
3451
+ else if (verb === 'sleep') {
3452
+ const ms = parseInt(rest[0] ?? '0', 10);
3453
+ await new Promise((r) => setTimeout(r, ms));
3454
+ console.log(` · sleep ${ms}ms`);
3455
+ }
3456
+ else {
3457
+ throw new Error(`Unknown verb '${verb}'. Allowed: click, fill, press, sleep.`);
3458
+ }
3459
+ }
3460
+ catch (err) {
3461
+ console.error(` ✗ step "${step}" failed: ${err instanceof Error ? err.message : err}`);
3462
+ // Continue to record-stop so the partial recording is preserved.
3463
+ }
3464
+ }
3465
+ const stopResp = await daemonRequest(state, 'record-stop');
3466
+ const stopData = stopResp.data;
3467
+ console.log(`[Sweetlink] Done: ${stopData.manifest.commands.length} actions in ` +
3468
+ `${stopData.manifest.duration.toFixed(1)}s${stopData.viewerPath ? ` · ${stopData.viewerPath}` : ''}`);
3469
+ return stopData;
3470
+ }
3471
+ if (subcommand === 'pause') {
3472
+ const resp = await daemonRequest(state, 'record-pause');
3473
+ console.log('[Sweetlink] Recording paused. Use `record resume` to continue.');
3474
+ return resp.data;
3475
+ }
3476
+ if (subcommand === 'resume') {
3477
+ const resp = await daemonRequest(state, 'record-resume');
3478
+ const d = resp.data;
3479
+ console.log(`[Sweetlink] Recording resumed. Paused for ${(d.pausedDurationMs / 1000).toFixed(1)}s.`);
3480
+ return resp.data;
3481
+ }
3482
+ // Default: status
3483
+ const resp = await daemonRequest(state, 'record-status');
3484
+ const data = resp.data;
3485
+ if (data.recording) {
3486
+ console.log(`[Sweetlink] Recording in progress: ${data.sessionId} (${Math.round(data.duration ?? 0)}s, ${data.actionCount} actions)`);
3487
+ }
3488
+ else {
3489
+ console.log('[Sweetlink] No recording in progress.');
3490
+ }
3491
+ return data;
3492
+ }
3493
+ async function handleProofCmd() {
3494
+ const prNum = getArg('--pr');
3495
+ if (!prNum) {
3496
+ console.error('[Sweetlink] Error: --pr <number> is required');
3497
+ process.exit(1);
3498
+ }
3499
+ const sessionDirArg = getArg('--session') ?? '.sweetlink';
3500
+ const latestSession = findLatestSessionDir(sessionDirArg);
3501
+ if (!latestSession) {
3502
+ console.error('[Sweetlink] No session found. Run `record start` and `record stop` first.');
3503
+ process.exit(1);
3504
+ }
3505
+ const manifestPath = path.join(latestSession, 'sweetlink-session.json');
3506
+ if (!fs.existsSync(manifestPath)) {
3507
+ console.error(`[Sweetlink] No manifest found at ${manifestPath}`);
3508
+ process.exit(1);
3509
+ }
3510
+ const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
3511
+ try {
3512
+ const { commentUrl } = await uploadEvidence(manifestData, latestSession, parseInt(prNum, 10), {
3513
+ repo: getArg('--repo') ?? undefined,
3514
+ });
3515
+ console.log(`[Sweetlink] Evidence posted: ${commentUrl}`);
3516
+ return { commentUrl };
3517
+ }
3518
+ catch (error) {
3519
+ console.error('[Sweetlink] Failed to upload evidence:', error instanceof Error ? error.message : error);
3520
+ process.exit(1);
3521
+ }
3522
+ }
2529
3523
  (async () => {
2530
3524
  const startTime = Date.now();
2531
3525
  // Resolve --app flag: for WS-bridge commands, this discovers the matching
@@ -2554,221 +3548,34 @@ async function handleStatusCommand() {
2554
3548
  let result;
2555
3549
  switch (commandType) {
2556
3550
  case 'screenshot':
2557
- result = await screenshot({
2558
- selector: getArg('--selector'),
2559
- output: getArg('--output'),
2560
- fullPage: hasFlag('--full-page'),
2561
- forceCDP: hasFlag('--force-cdp'),
2562
- forceWS: hasFlag('--force-ws'),
2563
- hifi: hasFlag('--hifi'),
2564
- responsive: hasFlag('--responsive'),
2565
- a11y: hasFlag('--a11y'),
2566
- viewport: getArg('--viewport'),
2567
- width: getArg('--width') ? parseInt(getArg('--width'), 10) : undefined,
2568
- height: getArg('--height') ? parseInt(getArg('--height'), 10) : undefined,
2569
- hover: hasFlag('--hover'),
2570
- hideDevbar: hasFlag('--hide-devbar'),
2571
- padding: getArg('--padding') ? parseInt(getArg('--padding'), 10) : undefined,
2572
- theme: getArg('--theme'),
2573
- url: getArg('--url'),
2574
- wait: !hasFlag('--no-wait'), // Wait by default, --no-wait to skip
2575
- waitTimeout: getArg('--wait-timeout')
2576
- ? parseInt(getArg('--wait-timeout'), 10)
2577
- : undefined,
2578
- });
3551
+ result = await handleScreenshotCmd();
2579
3552
  break;
2580
3553
  case 'inspect':
2581
- case 'context': {
2582
- const projRoot = findProjectRoot();
2583
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2584
- const state = await ensureDaemon(projRoot, targetUrl);
2585
- const actionTranscript = [];
2586
- args.forEach((arg, index) => {
2587
- if (arg !== '--action')
2588
- return;
2589
- const value = args[index + 1];
2590
- if (!value)
2591
- return;
2592
- actionTranscript.push({ action: value });
2593
- });
2594
- const resp = await daemonRequest(state, 'inspect', {
2595
- last: getArg('--last') ? parseInt(getArg('--last'), 10) : undefined,
2596
- label: getArg('--label'),
2597
- expectedOutcome: getArg('--expected'),
2598
- actionTranscript,
2599
- includeA11y: !hasFlag('--no-a11y'),
2600
- });
2601
- const data = resp.data;
2602
- const output = getArg('--output');
2603
- if (output) {
2604
- ensureDir(output);
2605
- fs.writeFileSync(output, JSON.stringify(data, null, 2), 'utf-8');
2606
- }
2607
- if (getArg('--format') === 'json') {
2608
- console.log(JSON.stringify(data, null, 2));
2609
- }
2610
- else {
2611
- printInspectSummary(data);
2612
- }
2613
- result = data;
3554
+ case 'context':
3555
+ result = await handleInspectCmd();
2614
3556
  break;
2615
- }
2616
- case 'query': {
2617
- const selector = getArg('--selector');
2618
- if (!selector) {
2619
- console.error('[Sweetlink] Error: --selector is required for query command');
2620
- process.exit(1);
2621
- }
2622
- if (getArg('--url')) {
2623
- const navigated = await navigateBrowser(getArg('--url'));
2624
- if (!navigated) {
2625
- console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
2626
- process.exit(1);
2627
- }
2628
- }
2629
- result = await queryDOM({
2630
- selector,
2631
- property: getArg('--property'),
2632
- waitFor: getArg('--wait-for'),
2633
- waitTimeout: getArg('--wait-timeout')
2634
- ? parseInt(getArg('--wait-timeout'), 10)
2635
- : undefined,
2636
- });
3557
+ case 'query':
3558
+ result = await handleQueryCmd();
2637
3559
  break;
2638
- }
2639
- case 'logs': {
2640
- const format = getArg('--format');
2641
- result = await getLogs({
2642
- filter: getArg('--filter'),
2643
- format: format || 'text',
2644
- dedupe: hasFlag('--dedupe'),
2645
- output: getArg('--output'),
2646
- });
3560
+ case 'logs':
3561
+ result = await handleLogsCmd();
2647
3562
  break;
2648
- }
2649
- case 'exec': {
2650
- const code = getArg('--code');
2651
- if (!code) {
2652
- console.error('[Sweetlink] Error: --code is required for exec command');
2653
- process.exit(1);
2654
- }
2655
- if (getArg('--url')) {
2656
- const navigated = await navigateBrowser(getArg('--url'));
2657
- if (!navigated) {
2658
- console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
2659
- process.exit(1);
2660
- }
2661
- }
2662
- result = await execJS({
2663
- code,
2664
- waitFor: getArg('--wait-for'),
2665
- waitTimeout: getArg('--wait-timeout')
2666
- ? parseInt(getArg('--wait-timeout'), 10)
2667
- : undefined,
2668
- });
3563
+ case 'exec':
3564
+ result = await handleExecCmd();
2669
3565
  break;
2670
- }
2671
- case 'click': {
2672
- const clickTarget = getArg('--selector') ?? args[1];
2673
- const clickText = getArg('--text');
2674
- const clickIndex = getArg('--index') ? parseInt(getArg('--index'), 10) : 0;
2675
- const projRoot = findProjectRoot();
2676
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2677
- // Route @e refs to daemon
2678
- if (clickTarget && /^@e\d+$/.test(clickTarget)) {
2679
- const state = await ensureDaemon(projRoot, targetUrl);
2680
- await daemonRequest(state, 'click-ref', { ref: clickTarget });
2681
- console.log(`[Sweetlink] Clicked ${clickTarget}`);
2682
- result = { clicked: clickTarget, found: 1, index: 0 };
2683
- break;
2684
- }
2685
- // If a recording is in progress, route CSS clicks through the daemon
2686
- // so they target the recording page (which has no devbar/WebSocket
2687
- // bridge) and get logged into the session manifest.
2688
- try {
2689
- const status = await getDaemonStatus(projRoot, extractPort(targetUrl));
2690
- if (status.running) {
2691
- const state = await ensureDaemon(projRoot, targetUrl);
2692
- const recStatus = await daemonRequest(state, 'record-status');
2693
- const recData = recStatus.data;
2694
- if (recData?.recording) {
2695
- const resp = await daemonRequest(state, 'click-css', {
2696
- selector: clickTarget,
2697
- text: clickText,
2698
- index: clickIndex,
2699
- });
2700
- const data = resp.data;
2701
- console.log(`[Sweetlink] Clicked (recording): ${data.clicked ?? clickTarget ?? clickText}`);
2702
- result = {
2703
- clicked: data.clicked ?? 'unknown',
2704
- found: data.found ?? 1,
2705
- index: data.index ?? clickIndex,
2706
- };
2707
- break;
2708
- }
2709
- }
2710
- }
2711
- catch {
2712
- /* fall through to WS path */
2713
- }
2714
- result = await click({
2715
- selector: clickTarget,
2716
- text: clickText,
2717
- index: clickIndex,
2718
- });
3566
+ case 'click':
3567
+ result = await handleClickCmd();
2719
3568
  break;
2720
- }
2721
- case 'network': {
2722
- // If --failed flag is present and daemon is running, use daemon ring buffer
2723
- if (hasFlag('--failed')) {
2724
- const projRoot = findProjectRoot();
2725
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2726
- const lastN = getArg('--last') ? parseInt(getArg('--last'), 10) : undefined;
2727
- const state = await ensureDaemon(projRoot, targetUrl);
2728
- const resp = await daemonRequest(state, 'network-read', {
2729
- failed: true,
2730
- last: lastN,
2731
- });
2732
- const data = resp.data;
2733
- console.log(data.formatted);
2734
- console.log(`\nTotal: ${data.total} | Failed: ${data.failedCount}`);
2735
- result = data;
2736
- }
2737
- else {
2738
- result = await getNetwork({
2739
- filter: getArg('--filter'),
2740
- });
2741
- }
3569
+ case 'network':
3570
+ result = await handleNetworkCmd();
2742
3571
  break;
2743
- }
2744
3572
  case 'refresh':
2745
- result = await refresh({
2746
- hard: hasFlag('--hard'),
2747
- });
3573
+ result = await handleRefreshCmd();
2748
3574
  break;
2749
3575
  case 'ruler':
2750
- case 'measure': {
2751
- // Collect all --selector arguments
2752
- const rulerSelectors = [];
2753
- args.forEach((arg, i) => {
2754
- if (arg === '--selector' && args[i + 1]) {
2755
- rulerSelectors.push(args[i + 1]);
2756
- }
2757
- });
2758
- result = await ruler({
2759
- selectors: rulerSelectors.length > 0 ? rulerSelectors : undefined,
2760
- preset: getArg('--preset'),
2761
- url: getArg('--url'),
2762
- output: getArg('--output'),
2763
- showCenterLines: !hasFlag('--no-center-lines'),
2764
- showDimensions: !hasFlag('--no-dimensions'),
2765
- showPosition: hasFlag('--show-position'),
2766
- showAlignment: !hasFlag('--no-alignment'),
2767
- limit: getArg('--limit') ? parseInt(getArg('--limit'), 10) : undefined,
2768
- format: getArg('--format'),
2769
- });
3576
+ case 'measure':
3577
+ result = await handleRulerCmd();
2770
3578
  break;
2771
- }
2772
3579
  case 'wait':
2773
3580
  result = await handleWaitCommand();
2774
3581
  break;
@@ -2776,836 +3583,57 @@ async function handleStatusCommand() {
2776
3583
  result = await handleStatusCommand();
2777
3584
  break;
2778
3585
  case 'schema':
2779
- result = await getSchema({
2780
- format: getArg('--format'),
2781
- output: getArg('--output'),
2782
- });
3586
+ result = await handleSchemaCmd();
2783
3587
  break;
2784
3588
  case 'outline':
2785
- result = await getOutline({
2786
- format: getArg('--format'),
2787
- output: getArg('--output'),
2788
- });
3589
+ result = await handleOutlineCmd();
2789
3590
  break;
2790
3591
  case 'a11y':
2791
3592
  case 'accessibility':
2792
- result = await getA11y({
2793
- format: getArg('--format'),
2794
- output: getArg('--output'),
2795
- });
3593
+ result = await handleA11yCmd();
2796
3594
  break;
2797
3595
  case 'vitals':
2798
- result = await getVitals({
2799
- format: getArg('--format'),
2800
- });
3596
+ result = await handleVitalsCmd();
2801
3597
  break;
2802
3598
  case 'cleanup':
2803
- result = await cleanup({
2804
- force: hasFlag('--force'),
2805
- verbose: hasFlag('--verbose'),
2806
- });
3599
+ result = await handleCleanupCmd();
2807
3600
  break;
2808
- case 'setup': {
2809
- // Run the setup script to symlink Claude context and skills
2810
- const { execFileSync } = await import('child_process');
2811
- const scriptDir = path.dirname(import.meta.url.replace('file://', ''));
2812
- const setupScript = path.resolve(scriptDir, '..', '..', 'scripts', 'setup-claude-context.mjs');
2813
- execFileSync('node', [setupScript], { stdio: 'inherit' });
3601
+ case 'setup':
3602
+ result = await handleSetupCmd();
2814
3603
  break;
2815
- }
2816
- case 'console': {
2817
- // Route to daemon ring buffer when daemon is alive
2818
- const projRoot = findProjectRoot();
2819
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2820
- const errorsOnly = hasFlag('--errors');
2821
- const lastN = getArg('--last') ? parseInt(getArg('--last'), 10) : undefined;
2822
- const state = await ensureDaemon(projRoot, targetUrl);
2823
- const resp = await daemonRequest(state, 'console-read', {
2824
- errors: errorsOnly,
2825
- last: lastN,
2826
- });
2827
- const data = resp.data;
2828
- console.log(data.formatted);
2829
- console.log(`\nTotal: ${data.total} | Errors: ${data.errorCount} | Warnings: ${data.warningCount}`);
2830
- result = data;
3604
+ case 'console':
3605
+ result = await handleConsoleCmd();
2831
3606
  break;
2832
- }
2833
- case 'proof': {
2834
- const prNum = getArg('--pr');
2835
- if (!prNum) {
2836
- console.error('[Sweetlink] Error: --pr <number> is required');
2837
- process.exit(1);
2838
- }
2839
- const sessionDirArg = getArg('--session') ?? '.sweetlink';
2840
- const latestSession = findLatestSessionDir(sessionDirArg);
2841
- if (!latestSession) {
2842
- console.error('[Sweetlink] No session found. Run `record start` and `record stop` first.');
2843
- process.exit(1);
2844
- }
2845
- const manifestPath = path.join(latestSession, 'sweetlink-session.json');
2846
- if (!fs.existsSync(manifestPath)) {
2847
- console.error(`[Sweetlink] No manifest found at ${manifestPath}`);
2848
- process.exit(1);
2849
- }
2850
- const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
2851
- try {
2852
- const { commentUrl } = await uploadEvidence(manifestData, latestSession, parseInt(prNum, 10), { repo: getArg('--repo') ?? undefined });
2853
- console.log(`[Sweetlink] Evidence posted: ${commentUrl}`);
2854
- result = { commentUrl };
2855
- }
2856
- catch (error) {
2857
- console.error('[Sweetlink] Failed to upload evidence:', error instanceof Error ? error.message : error);
2858
- process.exit(1);
2859
- }
3607
+ case 'proof':
3608
+ result = await handleProofCmd();
2860
3609
  break;
2861
- }
2862
- case 'record': {
2863
- const projRoot = findProjectRoot();
2864
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
2865
- const subcommand = args[1];
2866
- const state = await ensureDaemon(projRoot, targetUrl);
2867
- if (subcommand === 'start') {
2868
- const params = {};
2869
- const label = getArg('--label');
2870
- const viewport = getArg('--viewport');
2871
- const storageState = getArg('--storage-state');
2872
- if (label)
2873
- params.label = label;
2874
- if (viewport)
2875
- params.viewport = viewport;
2876
- if (storageState)
2877
- params.storageState = storageState;
2878
- if (hasFlag('--trace'))
2879
- params.trace = true;
2880
- const resp = await daemonRequest(state, 'record-start', params);
2881
- const data = resp.data;
2882
- console.log(`[Sweetlink] Recording started: ${data.sessionId}` +
2883
- (data.label ? ` (${data.label})` : ''));
2884
- result = data;
2885
- }
2886
- else if (subcommand === 'stop') {
2887
- const resp = await daemonRequest(state, 'record-stop');
2888
- const data = resp.data;
2889
- const m = data.manifest;
2890
- console.log(`[Sweetlink] Recording stopped: ${m.sessionId}`);
2891
- console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ` | Video: ${m.video}` : ''}`);
2892
- // Auto-open the viewer (cross-platform; --no-open to suppress)
2893
- if (data.viewerPath && !hasFlag('--no-open')) {
2894
- console.log(` Viewer: ${data.viewerPath}`);
2895
- openInBrowser(data.viewerPath);
2896
- console.log(` Opened in browser.`);
2897
- }
2898
- else if (data.viewerPath) {
2899
- console.log(` Viewer: ${data.viewerPath}`);
2900
- }
2901
- result = data;
2902
- }
2903
- else if (subcommand === 'exec') {
2904
- // record exec "click @e2; fill @e3 hello world; click @e5"
2905
- // Runs a semicolon-separated DSL inside a fresh recording, then
2906
- // auto-stops. Each step is one of:
2907
- // click <selector|@ref>
2908
- // fill <@ref> <value> (rest of line after ref = value)
2909
- // press <key>
2910
- // sleep <ms>
2911
- // Strip known --flag value pairs from positional args before
2912
- // joining what remains as the script body.
2913
- const flagsWithValues = new Set(['--url', '--label', '--viewport', '--storage-state']);
2914
- const positional = [];
2915
- for (let i = 2; i < args.length; i++) {
2916
- const a = args[i];
2917
- if (a.startsWith('--')) {
2918
- if (flagsWithValues.has(a))
2919
- i++; // skip its value
2920
- continue;
2921
- }
2922
- positional.push(a);
2923
- }
2924
- const script = positional.join(' ').trim();
2925
- if (!script) {
2926
- console.error('[Sweetlink] Error: record exec requires a script. Example: `record exec "click @e2; fill @e3 hello"`');
2927
- process.exit(1);
2928
- }
2929
- const label = getArg('--label');
2930
- const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
2931
- const startData = startResp.data;
2932
- console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
2933
- const steps = script
2934
- .split(';')
2935
- .map((s) => s.trim())
2936
- .filter(Boolean);
2937
- // Snapshot once up-front so refs resolve.
2938
- await daemonRequest(state, 'snapshot', { interactive: true });
2939
- for (const step of steps) {
2940
- const [verb, ...rest] = step.split(/\s+/);
2941
- try {
2942
- if (verb === 'click') {
2943
- const target = rest[0];
2944
- if (!target)
2945
- throw new Error('click needs a target');
2946
- if (/^@e\d+$/.test(target)) {
2947
- await daemonRequest(state, 'click-ref', { ref: target });
2948
- }
2949
- else {
2950
- await daemonRequest(state, 'click-css', { selector: target });
2951
- }
2952
- console.log(` · click ${target}`);
2953
- }
2954
- else if (verb === 'fill') {
2955
- const ref = rest[0];
2956
- const value = rest.slice(1).join(' ');
2957
- if (!ref || !/^@e\d+$/.test(ref))
2958
- throw new Error('fill needs a @ref and a value');
2959
- await daemonRequest(state, 'fill-ref', { ref, value });
2960
- console.log(` · fill ${ref} = "${value}"`);
2961
- }
2962
- else if (verb === 'press') {
2963
- const key = rest[0];
2964
- if (!key)
2965
- throw new Error('press needs a key');
2966
- await daemonRequest(state, 'press-key', { key });
2967
- console.log(` · press ${key}`);
2968
- }
2969
- else if (verb === 'sleep') {
2970
- const ms = parseInt(rest[0] ?? '0', 10);
2971
- await new Promise((r) => setTimeout(r, ms));
2972
- console.log(` · sleep ${ms}ms`);
2973
- }
2974
- else {
2975
- throw new Error(`Unknown verb '${verb}'. Allowed: click, fill, press, sleep.`);
2976
- }
2977
- }
2978
- catch (err) {
2979
- console.error(` ✗ step "${step}" failed: ${err instanceof Error ? err.message : err}`);
2980
- // Continue to record-stop so the partial recording is preserved.
2981
- }
2982
- }
2983
- const stopResp = await daemonRequest(state, 'record-stop');
2984
- const stopData = stopResp.data;
2985
- console.log(`[Sweetlink] Done: ${stopData.manifest.commands.length} actions in ` +
2986
- `${stopData.manifest.duration.toFixed(1)}s${stopData.viewerPath ? ` · ${stopData.viewerPath}` : ''}`);
2987
- result = stopData;
2988
- }
2989
- else if (subcommand === 'pause') {
2990
- const resp = await daemonRequest(state, 'record-pause');
2991
- console.log('[Sweetlink] Recording paused. Use `record resume` to continue.');
2992
- result = resp.data;
2993
- }
2994
- else if (subcommand === 'resume') {
2995
- const resp = await daemonRequest(state, 'record-resume');
2996
- const d = resp.data;
2997
- console.log(`[Sweetlink] Recording resumed. Paused for ${(d.pausedDurationMs / 1000).toFixed(1)}s.`);
2998
- result = resp.data;
2999
- }
3000
- else {
3001
- const resp = await daemonRequest(state, 'record-status');
3002
- const data = resp.data;
3003
- if (data.recording) {
3004
- console.log(`[Sweetlink] Recording in progress: ${data.sessionId} (${Math.round(data.duration ?? 0)}s, ${data.actionCount} actions)`);
3005
- }
3006
- else {
3007
- console.log('[Sweetlink] No recording in progress.');
3008
- }
3009
- result = data;
3010
- }
3610
+ case 'record':
3611
+ result = await handleRecordCmd();
3011
3612
  break;
3012
- }
3013
- case 'report': {
3014
- const sessionDirArg = getArg('--session') ?? '.sweetlink';
3015
- if (!fs.existsSync(sessionDirArg)) {
3016
- console.error(`[Sweetlink] Session directory not found: ${sessionDirArg}`);
3017
- process.exit(1);
3018
- }
3019
- const reportSessionDir = findLatestSessionDir(sessionDirArg);
3020
- if (!reportSessionDir) {
3021
- console.error('[Sweetlink] No session found. Run `record start` and `record stop` first.');
3022
- process.exit(1);
3023
- }
3024
- if (hasFlag('--clipboard')) {
3025
- // Copy SUMMARY.md to clipboard
3026
- const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
3027
- if (!fs.existsSync(summaryPath)) {
3028
- console.error(`[Sweetlink] SUMMARY.md not found at ${summaryPath}`);
3029
- process.exit(1);
3030
- }
3031
- const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
3032
- const { execFileSync } = await import('child_process');
3033
- const clipCmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip';
3034
- const clipArgs = process.platform === 'darwin' ? [] : ['-selection', 'clipboard'];
3035
- try {
3036
- execFileSync(clipCmd, clipArgs, { input: summaryContent });
3037
- console.log('[Sweetlink] SUMMARY.md copied to clipboard.');
3038
- }
3039
- catch (err) {
3040
- console.error(`[Sweetlink] Failed to copy to clipboard (${clipCmd}):`, err instanceof Error ? err.message : err);
3041
- process.exit(1);
3042
- }
3043
- result = { mode: 'clipboard', session: path.basename(reportSessionDir) };
3044
- }
3045
- else if (hasFlag('--serve')) {
3046
- // Serve viewer.html on a random port
3047
- const viewerPath = path.join(reportSessionDir, 'viewer.html');
3048
- if (!fs.existsSync(viewerPath)) {
3049
- console.error(`[Sweetlink] viewer.html not found at ${viewerPath}`);
3050
- process.exit(1);
3051
- }
3052
- const viewerContent = fs.readFileSync(viewerPath, 'utf-8');
3053
- const http = await import('http');
3054
- const port = 10000 + Math.floor(Math.random() * 50000);
3055
- const server = http.createServer((_req, res) => {
3056
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
3057
- res.end(viewerContent);
3058
- });
3059
- server.listen(port, '0.0.0.0', () => {
3060
- const os = require('os');
3061
- const nets = os.networkInterfaces();
3062
- let lanIp = 'localhost';
3063
- for (const name of Object.keys(nets)) {
3064
- for (const net of nets[name]) {
3065
- if (net.family === 'IPv4' && !net.internal) {
3066
- lanIp = net.address;
3067
- break;
3068
- }
3069
- }
3070
- if (lanIp !== 'localhost')
3071
- break;
3072
- }
3073
- console.log(`[Sweetlink] Serving viewer at:`);
3074
- console.log(` Local: http://localhost:${port}`);
3075
- console.log(` Network: http://${lanIp}:${port}`);
3076
- console.log(' Press Ctrl+C to stop.');
3077
- });
3078
- // Keep running until Ctrl+C
3079
- await new Promise(() => { });
3080
- }
3081
- else if (getArg('--webhook')) {
3082
- // POST session data to webhook
3083
- const webhookUrl = getArg('--webhook');
3084
- const manifestPath = path.join(reportSessionDir, 'sweetlink-session.json');
3085
- const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
3086
- if (!fs.existsSync(manifestPath)) {
3087
- console.error(`[Sweetlink] Manifest not found at ${manifestPath}`);
3088
- process.exit(1);
3089
- }
3090
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
3091
- const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
3092
- const payload = {
3093
- summary,
3094
- manifest,
3095
- };
3096
- // Include viewer HTML for Slack/Discord webhooks
3097
- if (/slack|discord/i.test(webhookUrl)) {
3098
- const viewerPath = path.join(reportSessionDir, 'viewer.html');
3099
- if (fs.existsSync(viewerPath)) {
3100
- payload.viewerHtml = fs.readFileSync(viewerPath, 'utf-8');
3101
- }
3102
- }
3103
- const body = JSON.stringify(payload);
3104
- const res = await fetch(webhookUrl, {
3105
- method: 'POST',
3106
- headers: { 'Content-Type': 'application/json' },
3107
- body,
3108
- });
3109
- if (res.ok) {
3110
- console.log(`[Sweetlink] Report posted to ${webhookUrl} (${res.status})`);
3111
- }
3112
- else {
3113
- console.error(`[Sweetlink] Webhook failed: ${res.status} ${res.statusText}`);
3114
- process.exit(1);
3115
- }
3116
- result = { mode: 'webhook', url: webhookUrl, status: res.status };
3117
- }
3118
- else {
3119
- // Default: print SUMMARY.md to stdout
3120
- const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
3121
- if (!fs.existsSync(summaryPath)) {
3122
- console.error(`[Sweetlink] SUMMARY.md not found at ${summaryPath}`);
3123
- process.exit(1);
3124
- }
3125
- const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
3126
- process.stdout.write(summaryContent);
3127
- result = { mode: 'stdout', session: path.basename(reportSessionDir) };
3128
- }
3613
+ case 'report':
3614
+ result = await handleReportCmd();
3129
3615
  break;
3130
- }
3131
- case 'sim': {
3132
- // Record iOS Simulator or Android Emulator screen while running a command.
3133
- // Example: sweetlink sim ios "fastlane scan" --device "iPhone 15"
3134
- const platform = args[1];
3135
- if (platform !== 'ios' && platform !== 'android') {
3136
- console.error('[Sweetlink] Usage: sweetlink sim <ios|android> "<command>" [--output path] [--device <name>]');
3137
- process.exit(1);
3138
- }
3139
- const flagsWithValues = new Set([
3140
- '--output',
3141
- '--label',
3142
- '--device',
3143
- '--time-limit',
3144
- '--app',
3145
- '--run',
3146
- ]);
3147
- const positional = [];
3148
- for (let i = 2; i < args.length; i++) {
3149
- const a = args[i];
3150
- if (a.startsWith('--')) {
3151
- if (flagsWithValues.has(a))
3152
- i++;
3153
- continue;
3154
- }
3155
- positional.push(a);
3156
- }
3157
- const command = positional.join(' ').trim();
3158
- if (!command) {
3159
- console.error(`[Sweetlink] Error: sim ${platform} requires a command. Example: sweetlink sim ${platform} "fastlane scan"`);
3160
- process.exit(1);
3161
- }
3162
- const label = getArg('--label');
3163
- const labelSlug = label
3164
- ? label
3165
- .replace(/[^a-z0-9]/gi, '-')
3166
- .toLowerCase()
3167
- .slice(0, 40)
3168
- : `sim-${platform}`;
3169
- const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
3170
- const { runSlot: simRunSlot } = await import('../runs.js');
3171
- const defaultDir = simRunSlot({
3172
- baseDir: findProjectRoot(),
3173
- app: getArg('--app'),
3174
- run: getArg('--run'),
3175
- kind: 'sim',
3176
- });
3177
- const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.mp4`);
3178
- ensureDir(output);
3179
- const device = getArg('--device');
3180
- console.log(`[Sweetlink] Recording ${platform} simulator: ${command}`);
3181
- let recResult;
3182
- if (platform === 'ios') {
3183
- const { recordIosSimulator } = await import('../simulator/ios.js');
3184
- recResult = await recordIosSimulator({ command, output, device });
3185
- }
3186
- else {
3187
- const { recordAndroidEmulator } = await import('../simulator/android.js');
3188
- const tl = getArg('--time-limit');
3189
- recResult = await recordAndroidEmulator({
3190
- command,
3191
- output,
3192
- device,
3193
- timeLimit: tl ? parseInt(tl, 10) : undefined,
3194
- overlays: !hasFlag('--no-overlays'),
3195
- });
3196
- }
3197
- let sizeKb = '?';
3198
- try {
3199
- sizeKb = String(Math.round(fs.statSync(output).size / 1024));
3200
- }
3201
- catch {
3202
- /* file may not exist if recordingClosed is false */
3203
- }
3204
- const tapSuffix = (recResult.tapCount ?? 0) > 0
3205
- ? ` · ${recResult.tapCount} taps${recResult.overlaysApplied ? ' (overlaid)' : ' (sidecar only — install ffmpeg for overlays)'}`
3206
- : '';
3207
- console.log(`[Sweetlink] ${recResult.recordingClosed ? '✓' : '⚠'} ${getRelativePath(output)} · ` +
3208
- `${recResult.durationSec.toFixed(1)}s · ${sizeKb}KB · ${recResult.device} · exit=${recResult.exitCode}` +
3209
- tapSuffix +
3210
- (recResult.recordingClosed
3211
- ? ''
3212
- : ' (recording was force-killed; mp4 may be incomplete)'));
3213
- result = {
3214
- path: output,
3215
- device: recResult.device,
3216
- durationSec: recResult.durationSec,
3217
- exitCode: recResult.exitCode,
3218
- recordingClosed: recResult.recordingClosed,
3219
- tapCount: recResult.tapCount,
3220
- tapsJsonPath: recResult.tapsJsonPath,
3221
- overlaysApplied: recResult.overlaysApplied,
3222
- };
3223
- if (recResult.exitCode !== 0 && !hasFlag('--ignore-exit')) {
3224
- process.exit(recResult.exitCode);
3225
- }
3616
+ case 'sim':
3617
+ result = await handleSimCmd();
3226
3618
  break;
3227
- }
3228
- case 'term': {
3229
- // Record a shell command's stdout/stderr into asciicast v2 + HTML player.
3230
- // Example: sweetlink term "pytest -v" --label api-tests --app my-app
3231
- const flagsWithValues = new Set([
3232
- '--output',
3233
- '--label',
3234
- '--shell',
3235
- '--cols',
3236
- '--rows',
3237
- '--app',
3238
- '--run',
3239
- ]);
3240
- const positional = [];
3241
- for (let i = 1; i < args.length; i++) {
3242
- const a = args[i];
3243
- if (a.startsWith('--')) {
3244
- if (flagsWithValues.has(a))
3245
- i++;
3246
- continue;
3247
- }
3248
- positional.push(a);
3249
- }
3250
- const command = positional.join(' ').trim();
3251
- if (!command) {
3252
- console.error('[Sweetlink] Error: term requires a command. Example: sweetlink term "pytest tests/"');
3253
- process.exit(1);
3254
- }
3255
- const label = getArg('--label');
3256
- const labelSlug = label
3257
- ? label
3258
- .replace(/[^a-z0-9]/gi, '-')
3259
- .toLowerCase()
3260
- .slice(0, 40)
3261
- : 'term';
3262
- const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
3263
- const { runSlot } = await import('../runs.js');
3264
- const defaultDir = runSlot({
3265
- baseDir: findProjectRoot(),
3266
- app: getArg('--app'),
3267
- run: getArg('--run'),
3268
- kind: 'term',
3269
- });
3270
- const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.cast`);
3271
- ensureDir(output);
3272
- console.log(`[Sweetlink] Recording terminal: ${command}`);
3273
- const { captureTerminal } = await import('../term/recorder.js');
3274
- const { generatePlayer } = await import('../term/player.js');
3275
- const cap = await captureTerminal({
3276
- command,
3277
- output,
3278
- label,
3279
- shell: getArg('--shell'),
3280
- cols: getArg('--cols') ? parseInt(getArg('--cols'), 10) : undefined,
3281
- rows: getArg('--rows') ? parseInt(getArg('--rows'), 10) : undefined,
3282
- });
3283
- const playerPath = await generatePlayer({ castPath: output });
3284
- console.log(`[Sweetlink] ✓ ${getRelativePath(output)} · ${cap.durationSec.toFixed(1)}s · ` +
3285
- `${cap.events} events · ${(cap.bytes / 1024).toFixed(0)}KB · exit=${cap.exitCode}`);
3286
- console.log(`[Sweetlink] ▶ ${getRelativePath(playerPath)}`);
3287
- result = {
3288
- castPath: output,
3289
- playerPath,
3290
- durationSec: cap.durationSec,
3291
- bytes: cap.bytes,
3292
- events: cap.events,
3293
- exitCode: cap.exitCode,
3294
- };
3295
- // Propagate the recorded command's exit code by default so CI fails
3296
- // when the wrapped tests fail.
3297
- if (cap.exitCode !== 0 && !hasFlag('--ignore-exit')) {
3298
- process.exit(cap.exitCode);
3299
- }
3619
+ case 'term':
3620
+ result = await handleTermCmd();
3300
3621
  break;
3301
- }
3302
- case 'sessions': {
3303
- const sub = args[1];
3304
- const projRoot = findProjectRoot();
3305
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
3306
- const state = await ensureDaemon(projRoot, targetUrl);
3307
- const resp = await daemonRequest(state, 'sessions-list');
3308
- const data = resp.data;
3309
- if (sub === 'list' || !sub) {
3310
- if (data.sessions.length === 0) {
3311
- console.log('[Sweetlink] No sessions found.');
3312
- }
3313
- else {
3314
- console.log(`[Sweetlink] ${data.sessions.length} session(s):\n`);
3315
- for (const s of data.sessions) {
3316
- const errTotal = s.errors ? s.errors.console + s.errors.network + s.errors.server : 0;
3317
- const errBadge = errTotal > 0 ? ` · ${errTotal} err` : '';
3318
- const labelTxt = s.label ? ` [${s.label}]` : '';
3319
- const dur = s.duration ? `${s.duration.toFixed(1)}s` : '—';
3320
- console.log(` ${s.sessionId}${labelTxt} · ${dur} · ${s.actionCount} actions${errBadge}`);
3321
- }
3322
- if (data.indexPath)
3323
- console.log(`\n Index: ${data.indexPath}`);
3324
- }
3325
- result = { sessions: data.sessions };
3326
- }
3327
- else if (sub === 'diff') {
3328
- // sessions diff <a> <b> — compare two recordings
3329
- const [aId, bId] = [args[2], args[3]];
3330
- if (!aId || !bId) {
3331
- console.error('[Sweetlink] Usage: sessions diff <session-A> <session-B>');
3332
- process.exit(1);
3333
- }
3334
- const findSession = (id) => data.sessions.find((s) => s.sessionId === id || s.sessionId.endsWith(id));
3335
- const a = findSession(aId);
3336
- const b = findSession(bId);
3337
- if (!a || !b) {
3338
- console.error(`[Sweetlink] Could not find session: ${!a ? aId : bId}`);
3339
- process.exit(1);
3340
- }
3341
- const aManifest = JSON.parse(fs.readFileSync(a.manifestPath, 'utf-8'));
3342
- const bManifest = JSON.parse(fs.readFileSync(b.manifestPath, 'utf-8'));
3343
- const aActions = aManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
3344
- const bActions = bManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
3345
- console.log(`\n${a.sessionId}${a.label ? ` "${a.label}"` : ''} vs ${b.sessionId}${b.label ? ` "${b.label}"` : ''}\n`);
3346
- console.log(`Duration: ${a.duration?.toFixed(1)}s vs ${b.duration?.toFixed(1)}s`);
3347
- console.log(`Actions: ${a.actionCount} vs ${b.actionCount}`);
3348
- const aErr = a.errors ? a.errors.console + a.errors.network + a.errors.server : 0;
3349
- const bErr = b.errors ? b.errors.console + b.errors.network + b.errors.server : 0;
3350
- console.log(`Errors: ${aErr} vs ${bErr}`);
3351
- // Action diff (myers-style "added/removed" by line)
3352
- const inA = new Set(aActions);
3353
- const inB = new Set(bActions);
3354
- const added = bActions.filter((x) => !inA.has(x));
3355
- const removed = aActions.filter((x) => !inB.has(x));
3356
- if (removed.length) {
3357
- console.log(`\nOnly in ${a.sessionId}:`);
3358
- removed.forEach((s) => console.log(` - ${s}`));
3359
- }
3360
- if (added.length) {
3361
- console.log(`\nOnly in ${b.sessionId}:`);
3362
- added.forEach((s) => console.log(` + ${s}`));
3363
- }
3364
- if (!added.length && !removed.length) {
3365
- console.log('\nAction sequences are identical.');
3366
- }
3367
- result = {
3368
- a: {
3369
- id: a.sessionId,
3370
- label: a.label,
3371
- duration: a.duration,
3372
- actions: a.actionCount,
3373
- errors: aErr,
3374
- },
3375
- b: {
3376
- id: b.sessionId,
3377
- label: b.label,
3378
- duration: b.duration,
3379
- actions: b.actionCount,
3380
- errors: bErr,
3381
- },
3382
- added,
3383
- removed,
3384
- };
3385
- }
3386
- else if (sub === 'open') {
3387
- // Open the index.html in the browser
3388
- if (data.indexPath) {
3389
- openInBrowser(data.indexPath);
3390
- console.log(`[Sweetlink] Opened ${data.indexPath}`);
3391
- }
3392
- result = { indexPath: data.indexPath };
3393
- }
3394
- else {
3395
- console.error(`[Sweetlink] Unknown sessions subcommand: ${sub}. Try: list, open`);
3396
- process.exit(1);
3397
- }
3622
+ case 'sessions':
3623
+ result = await handleSessionsCmd();
3398
3624
  break;
3399
- }
3400
- case 'demo': {
3401
- const sub = args[1];
3402
- const projRoot = findProjectRoot();
3403
- const demoDir = getArg('--output') ?? path.join(projRoot, '.sweetlink', 'demo');
3404
- const stateFile = path.join(demoDir, 'demo-state.json');
3405
- // Lazy import demo module
3406
- const demoMod = await import('../daemon/demo.js');
3407
- if (sub === 'init') {
3408
- const title = args[2];
3409
- if (!title) {
3410
- console.error('[Sweetlink] Error: demo init requires a title');
3411
- process.exit(1);
3412
- }
3413
- const demoState = await demoMod.initDemo(title, demoDir, { url: getArg('--url') });
3414
- await demoMod.writeDemo(demoState);
3415
- console.log(`[Sweetlink] Demo initialized: ${demoState.filePath}`);
3416
- result = { filePath: demoState.filePath };
3417
- }
3418
- else if (sub === 'note') {
3419
- const text = args.slice(2).join(' ');
3420
- if (!text) {
3421
- console.error('[Sweetlink] Error: demo note requires text');
3422
- process.exit(1);
3423
- }
3424
- const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
3425
- const updated = demoMod.addNote(state, text);
3426
- await demoMod.writeDemo(updated);
3427
- console.log(`[Sweetlink] Note added (${updated.sections.length} sections)`);
3428
- result = { sections: updated.sections.length };
3429
- }
3430
- else if (sub === 'exec') {
3431
- const cmd = args.slice(2).join(' ');
3432
- if (!cmd) {
3433
- console.error('[Sweetlink] Error: demo exec requires a command');
3434
- process.exit(1);
3435
- }
3436
- const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
3437
- const updated = await demoMod.addExec(state, cmd, []);
3438
- await demoMod.writeDemo(updated);
3439
- const lastSection = updated.sections[updated.sections.length - 1];
3440
- console.log(`[Sweetlink] Exec added: ${cmd} (exit ${lastSection.exitCode ?? 0})`);
3441
- result = { sections: updated.sections.length, exitCode: lastSection.exitCode };
3442
- }
3443
- else if (sub === 'screenshot') {
3444
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
3445
- const caption = getArg('--caption') ?? 'Screenshot';
3446
- const daemonState = await ensureDaemon(projRoot, targetUrl);
3447
- const resp = await daemonRequest(daemonState, 'screenshot', {});
3448
- const data = resp.data;
3449
- const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
3450
- const updated = await demoMod.addScreenshot(state, Buffer.from(data.screenshot, 'base64'), caption);
3451
- await demoMod.writeDemo(updated);
3452
- console.log(`[Sweetlink] Screenshot added: ${caption}`);
3453
- result = { sections: updated.sections.length };
3454
- }
3455
- else if (sub === 'snapshot') {
3456
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
3457
- const daemonState = await ensureDaemon(projRoot, targetUrl);
3458
- const resp = await daemonRequest(daemonState, 'snapshot', { interactive: true });
3459
- const data = resp.data;
3460
- const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
3461
- const updated = demoMod.addSnapshot(state, data.tree);
3462
- await demoMod.writeDemo(updated);
3463
- console.log(`[Sweetlink] Snapshot added (${updated.sections.length} sections)`);
3464
- result = { sections: updated.sections.length };
3465
- }
3466
- else if (sub === 'pop') {
3467
- const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
3468
- const updated = demoMod.popSection(state);
3469
- await demoMod.writeDemo(updated);
3470
- console.log(`[Sweetlink] Last section removed (${updated.sections.length} remaining)`);
3471
- result = { sections: updated.sections.length };
3472
- }
3473
- else if (sub === 'verify') {
3474
- const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
3475
- const verifyResult = await demoMod.verifyDemo(state);
3476
- if (verifyResult.passed) {
3477
- console.log('[Sweetlink] Demo verified: all outputs match');
3478
- }
3479
- else {
3480
- console.log(`[Sweetlink] Demo verification FAILED: ${verifyResult.failures.length} mismatch(es)`);
3481
- for (const f of verifyResult.failures) {
3482
- console.log(` Section ${f.index}: ${f.command}`);
3483
- console.log(` Expected: ${f.expected.substring(0, 80)}...`);
3484
- console.log(` Actual: ${f.actual.substring(0, 80)}...`);
3485
- }
3486
- }
3487
- result = verifyResult;
3488
- }
3489
- else {
3490
- // status
3491
- if (fs.existsSync(stateFile)) {
3492
- const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
3493
- console.log(`[Sweetlink] Demo: "${state.title}" (${state.sections.length} sections)`);
3494
- console.log(` File: ${state.filePath}`);
3495
- for (const s of state.sections) {
3496
- const preview = s.type === 'note'
3497
- ? s.content.substring(0, 60)
3498
- : s.type === 'exec'
3499
- ? `$ ${s.command}`
3500
- : s.type === 'screenshot'
3501
- ? `[image] ${s.screenshotFile}`
3502
- : '[snapshot]';
3503
- console.log(` ${s.type.padEnd(12)} ${preview}`);
3504
- }
3505
- result = state;
3506
- }
3507
- else {
3508
- console.log('[Sweetlink] No demo in progress. Run `demo init <title>` to start.');
3509
- result = null;
3510
- }
3511
- }
3625
+ case 'demo':
3626
+ result = await handleDemoCmd();
3512
3627
  break;
3513
- }
3514
- case 'daemon': {
3515
- const subcommand = args[1];
3516
- const projRoot = findProjectRoot();
3517
- // Daemon state files are scoped by app port (`daemon-<port>.json`),
3518
- // so honour --url for status/stop too — otherwise they look up the
3519
- // un-suffixed `daemon.json` and miss the daemon that `start`
3520
- // wrote with --url.
3521
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
3522
- const appPort = extractPort(targetUrl);
3523
- if (subcommand === 'stop') {
3524
- const stopped = await stopDaemon(projRoot, appPort);
3525
- console.log(stopped ? '[Sweetlink] Daemon stopped.' : '[Sweetlink] No daemon running.');
3526
- result = { running: false };
3527
- }
3528
- else if (subcommand === 'start') {
3529
- const headedFlag = hasFlag('--headed');
3530
- const state = await ensureDaemon(projRoot, targetUrl, { headed: headedFlag });
3531
- console.log(`[Sweetlink] Daemon running on port ${state.port} (PID: ${state.pid})`);
3532
- result = {
3533
- running: true,
3534
- pid: state.pid,
3535
- port: state.port,
3536
- url: state.url,
3537
- };
3538
- }
3539
- else {
3540
- // Default: status
3541
- const status = await getDaemonStatus(projRoot, appPort);
3542
- if (status.running) {
3543
- console.log(`[Sweetlink] Daemon running: port=${status.port} pid=${status.pid} uptime=${status.uptime}s`);
3544
- }
3545
- else {
3546
- console.log('[Sweetlink] No daemon running.');
3547
- }
3548
- result = status;
3549
- }
3628
+ case 'daemon':
3629
+ result = await handleDaemonCmd();
3550
3630
  break;
3551
- }
3552
- case 'fill': {
3553
- const fillTarget = getArg('--selector') ?? args[1];
3554
- const fillValue = getArg('--value') ?? args[2];
3555
- if (!fillTarget) {
3556
- console.error('[Sweetlink] Error: fill requires a target (@ref or --selector)');
3557
- process.exit(1);
3558
- }
3559
- if (fillValue === undefined) {
3560
- console.error('[Sweetlink] Error: fill requires a value (--value or positional arg)');
3561
- process.exit(1);
3562
- }
3563
- if (/^@e\d+$/.test(fillTarget)) {
3564
- const projRoot = findProjectRoot();
3565
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
3566
- const state = await ensureDaemon(projRoot, targetUrl);
3567
- await daemonRequest(state, 'fill-ref', { ref: fillTarget, value: fillValue });
3568
- console.log(`[Sweetlink] Filled ${fillTarget} with "${fillValue}"`);
3569
- result = { clicked: fillTarget, found: 1, index: 0 };
3570
- }
3571
- else {
3572
- console.error('[Sweetlink] Error: fill currently only supports @e refs. Run `snapshot -i` first.');
3573
- process.exit(1);
3574
- }
3631
+ case 'fill':
3632
+ result = await handleFillCmd();
3575
3633
  break;
3576
- }
3577
- case 'snapshot': {
3578
- const projRoot = findProjectRoot();
3579
- const targetUrl = getArg('--url') ?? 'http://localhost:3000';
3580
- const interactive = hasFlag('-i') || hasFlag('--interactive');
3581
- const doDiff = hasFlag('-D') || hasFlag('--diff');
3582
- const doAnnotate = hasFlag('-a') || hasFlag('--annotate');
3583
- const state = await ensureDaemon(projRoot, targetUrl);
3584
- const resp = await daemonRequest(state, 'snapshot', {
3585
- interactive,
3586
- diff: doDiff,
3587
- annotate: doAnnotate,
3588
- });
3589
- const data = resp.data;
3590
- if (doDiff && data.diff) {
3591
- console.log(data.diff);
3592
- }
3593
- else if (doAnnotate && data.screenshot) {
3594
- const outputPath = getArg('--output') ?? getArg('-o') ?? 'annotated-snapshot.png';
3595
- fs.writeFileSync(outputPath, Buffer.from(data.screenshot, 'base64'));
3596
- console.log(`[Sweetlink] Annotated screenshot saved: ${outputPath}`);
3597
- }
3598
- else {
3599
- console.log(data.tree);
3600
- }
3601
- console.log(`\n${data.count} elements found`);
3602
- result = {
3603
- tree: data.tree,
3604
- refs: data.refs,
3605
- diff: data.diff,
3606
- };
3634
+ case 'snapshot':
3635
+ result = await handleSnapshotCmd();
3607
3636
  break;
3608
- }
3609
3637
  default:
3610
3638
  console.error(`[Sweetlink] Unknown command: ${commandType}`);
3611
3639
  console.log('Run "pnpm sweetlink --help" for usage information');