sweetlink 0.1.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 (300) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +237 -0
  4. package/dist/daemon/src/codename.d.ts +8 -0
  5. package/dist/daemon/src/codename.d.ts.map +1 -0
  6. package/dist/daemon/src/codename.js +42 -0
  7. package/dist/daemon/src/codename.js.map +1 -0
  8. package/dist/daemon/src/index.d.ts +3 -0
  9. package/dist/daemon/src/index.d.ts.map +1 -0
  10. package/dist/daemon/src/index.js +675 -0
  11. package/dist/daemon/src/index.js.map +1 -0
  12. package/dist/shared/src/env.d.ts +24 -0
  13. package/dist/shared/src/env.d.ts.map +1 -0
  14. package/dist/shared/src/env.js +39 -0
  15. package/dist/shared/src/env.js.map +1 -0
  16. package/dist/shared/src/index.d.ts +211 -0
  17. package/dist/shared/src/index.d.ts.map +1 -0
  18. package/dist/shared/src/index.js +73 -0
  19. package/dist/shared/src/index.js.map +1 -0
  20. package/dist/shared/src/node.d.ts +12 -0
  21. package/dist/shared/src/node.d.ts.map +1 -0
  22. package/dist/shared/src/node.js +67 -0
  23. package/dist/shared/src/node.js.map +1 -0
  24. package/dist/src/codex.d.ts +11 -0
  25. package/dist/src/codex.d.ts.map +1 -0
  26. package/dist/src/codex.js +67 -0
  27. package/dist/src/codex.js.map +1 -0
  28. package/dist/src/commands/click.d.ts +3 -0
  29. package/dist/src/commands/click.d.ts.map +1 -0
  30. package/dist/src/commands/click.js +93 -0
  31. package/dist/src/commands/click.js.map +1 -0
  32. package/dist/src/commands/run-js.d.ts +4 -0
  33. package/dist/src/commands/run-js.d.ts.map +1 -0
  34. package/dist/src/commands/run-js.js +28 -0
  35. package/dist/src/commands/run-js.js.map +1 -0
  36. package/dist/src/commands/trust-ca.d.ts +3 -0
  37. package/dist/src/commands/trust-ca.d.ts.map +1 -0
  38. package/dist/src/commands/trust-ca.js +43 -0
  39. package/dist/src/commands/trust-ca.js.map +1 -0
  40. package/dist/src/core/config-file.d.ts +41 -0
  41. package/dist/src/core/config-file.d.ts.map +1 -0
  42. package/dist/src/core/config-file.js +284 -0
  43. package/dist/src/core/config-file.js.map +1 -0
  44. package/dist/src/core/config.d.ts +23 -0
  45. package/dist/src/core/config.d.ts.map +1 -0
  46. package/dist/src/core/config.js +132 -0
  47. package/dist/src/core/config.js.map +1 -0
  48. package/dist/src/core/env.d.ts +11 -0
  49. package/dist/src/core/env.d.ts.map +1 -0
  50. package/dist/src/core/env.js +30 -0
  51. package/dist/src/core/env.js.map +1 -0
  52. package/dist/src/devtools-registry.d.ts +3 -0
  53. package/dist/src/devtools-registry.d.ts.map +1 -0
  54. package/dist/src/devtools-registry.js +294 -0
  55. package/dist/src/devtools-registry.js.map +1 -0
  56. package/dist/src/env.d.ts +16 -0
  57. package/dist/src/env.d.ts.map +1 -0
  58. package/dist/src/env.js +17 -0
  59. package/dist/src/env.js.map +1 -0
  60. package/dist/src/http.d.ts +2 -0
  61. package/dist/src/http.d.ts.map +1 -0
  62. package/dist/src/http.js +53 -0
  63. package/dist/src/http.js.map +1 -0
  64. package/dist/src/index.d.ts +19 -0
  65. package/dist/src/index.d.ts.map +1 -0
  66. package/dist/src/index.js +1941 -0
  67. package/dist/src/index.js.map +1 -0
  68. package/dist/src/runtime/browser/client.d.ts +10 -0
  69. package/dist/src/runtime/browser/client.d.ts.map +1 -0
  70. package/dist/src/runtime/browser/client.js +497 -0
  71. package/dist/src/runtime/browser/client.js.map +1 -0
  72. package/dist/src/runtime/browser/commands/index.d.ts +10 -0
  73. package/dist/src/runtime/browser/commands/index.d.ts.map +1 -0
  74. package/dist/src/runtime/browser/commands/index.js +219 -0
  75. package/dist/src/runtime/browser/commands/index.js.map +1 -0
  76. package/dist/src/runtime/browser/dom-to-image-loader.d.ts +6 -0
  77. package/dist/src/runtime/browser/dom-to-image-loader.d.ts.map +1 -0
  78. package/dist/src/runtime/browser/dom-to-image-loader.js +50 -0
  79. package/dist/src/runtime/browser/dom-to-image-loader.js.map +1 -0
  80. package/dist/src/runtime/browser/index.d.ts +4 -0
  81. package/dist/src/runtime/browser/index.d.ts.map +1 -0
  82. package/dist/src/runtime/browser/index.js +4 -0
  83. package/dist/src/runtime/browser/index.js.map +1 -0
  84. package/dist/src/runtime/browser/module-loader.d.ts +6 -0
  85. package/dist/src/runtime/browser/module-loader.d.ts.map +1 -0
  86. package/dist/src/runtime/browser/module-loader.js +26 -0
  87. package/dist/src/runtime/browser/module-loader.js.map +1 -0
  88. package/dist/src/runtime/browser/screenshot/hooks.d.ts +71 -0
  89. package/dist/src/runtime/browser/screenshot/hooks.d.ts.map +1 -0
  90. package/dist/src/runtime/browser/screenshot/hooks.js +219 -0
  91. package/dist/src/runtime/browser/screenshot/hooks.js.map +1 -0
  92. package/dist/src/runtime/browser/screenshot/index.d.ts +9 -0
  93. package/dist/src/runtime/browser/screenshot/index.d.ts.map +1 -0
  94. package/dist/src/runtime/browser/screenshot/index.js +90 -0
  95. package/dist/src/runtime/browser/screenshot/index.js.map +1 -0
  96. package/dist/src/runtime/browser/screenshot/renderers/dom-to-image.d.ts +13 -0
  97. package/dist/src/runtime/browser/screenshot/renderers/dom-to-image.d.ts.map +1 -0
  98. package/dist/src/runtime/browser/screenshot/renderers/dom-to-image.js +51 -0
  99. package/dist/src/runtime/browser/screenshot/renderers/dom-to-image.js.map +1 -0
  100. package/dist/src/runtime/browser/screenshot/renderers/html2canvas.d.ts +12 -0
  101. package/dist/src/runtime/browser/screenshot/renderers/html2canvas.d.ts.map +1 -0
  102. package/dist/src/runtime/browser/screenshot/renderers/html2canvas.js +136 -0
  103. package/dist/src/runtime/browser/screenshot/renderers/html2canvas.js.map +1 -0
  104. package/dist/src/runtime/browser/screenshot/targets.d.ts +6 -0
  105. package/dist/src/runtime/browser/screenshot/targets.d.ts.map +1 -0
  106. package/dist/src/runtime/browser/screenshot/targets.js +53 -0
  107. package/dist/src/runtime/browser/screenshot/targets.js.map +1 -0
  108. package/dist/src/runtime/browser/screenshot/utils.d.ts +9 -0
  109. package/dist/src/runtime/browser/screenshot/utils.d.ts.map +1 -0
  110. package/dist/src/runtime/browser/screenshot/utils.js +471 -0
  111. package/dist/src/runtime/browser/screenshot/utils.js.map +1 -0
  112. package/dist/src/runtime/browser/selector-discovery.d.ts +9 -0
  113. package/dist/src/runtime/browser/selector-discovery.d.ts.map +1 -0
  114. package/dist/src/runtime/browser/selector-discovery.js +218 -0
  115. package/dist/src/runtime/browser/selector-discovery.js.map +1 -0
  116. package/dist/src/runtime/browser/storage/session-storage.d.ts +13 -0
  117. package/dist/src/runtime/browser/storage/session-storage.d.ts.map +1 -0
  118. package/dist/src/runtime/browser/storage/session-storage.js +119 -0
  119. package/dist/src/runtime/browser/storage/session-storage.js.map +1 -0
  120. package/dist/src/runtime/browser/types.d.ts +96 -0
  121. package/dist/src/runtime/browser/types.d.ts.map +1 -0
  122. package/dist/src/runtime/browser/types.js +6 -0
  123. package/dist/src/runtime/browser/types.js.map +1 -0
  124. package/dist/src/runtime/browser/utils/console.d.ts +5 -0
  125. package/dist/src/runtime/browser/utils/console.d.ts.map +1 -0
  126. package/dist/src/runtime/browser/utils/console.js +54 -0
  127. package/dist/src/runtime/browser/utils/console.js.map +1 -0
  128. package/dist/src/runtime/browser/utils/environment.d.ts +3 -0
  129. package/dist/src/runtime/browser/utils/environment.d.ts.map +1 -0
  130. package/dist/src/runtime/browser/utils/environment.js +13 -0
  131. package/dist/src/runtime/browser/utils/environment.js.map +1 -0
  132. package/dist/src/runtime/browser/utils/errors.d.ts +3 -0
  133. package/dist/src/runtime/browser/utils/errors.d.ts.map +1 -0
  134. package/dist/src/runtime/browser/utils/errors.js +42 -0
  135. package/dist/src/runtime/browser/utils/errors.js.map +1 -0
  136. package/dist/src/runtime/browser/utils/number.d.ts +2 -0
  137. package/dist/src/runtime/browser/utils/number.d.ts.map +1 -0
  138. package/dist/src/runtime/browser/utils/number.js +2 -0
  139. package/dist/src/runtime/browser/utils/number.js.map +1 -0
  140. package/dist/src/runtime/browser/utils/object.d.ts +3 -0
  141. package/dist/src/runtime/browser/utils/object.d.ts.map +1 -0
  142. package/dist/src/runtime/browser/utils/object.js +9 -0
  143. package/dist/src/runtime/browser/utils/object.js.map +1 -0
  144. package/dist/src/runtime/browser/utils/sanitize.d.ts +2 -0
  145. package/dist/src/runtime/browser/utils/sanitize.d.ts.map +1 -0
  146. package/dist/src/runtime/browser/utils/sanitize.js +28 -0
  147. package/dist/src/runtime/browser/utils/sanitize.js.map +1 -0
  148. package/dist/src/runtime/browser/utils/time.d.ts +2 -0
  149. package/dist/src/runtime/browser/utils/time.d.ts.map +1 -0
  150. package/dist/src/runtime/browser/utils/time.js +7 -0
  151. package/dist/src/runtime/browser/utils/time.js.map +1 -0
  152. package/dist/src/runtime/chrome/constants.d.ts +5 -0
  153. package/dist/src/runtime/chrome/constants.d.ts.map +1 -0
  154. package/dist/src/runtime/chrome/constants.js +5 -0
  155. package/dist/src/runtime/chrome/constants.js.map +1 -0
  156. package/dist/src/runtime/chrome/cookies.d.ts +20 -0
  157. package/dist/src/runtime/chrome/cookies.d.ts.map +1 -0
  158. package/dist/src/runtime/chrome/cookies.js +139 -0
  159. package/dist/src/runtime/chrome/cookies.js.map +1 -0
  160. package/dist/src/runtime/chrome/diagnostics.d.ts +6 -0
  161. package/dist/src/runtime/chrome/diagnostics.d.ts.map +1 -0
  162. package/dist/src/runtime/chrome/diagnostics.js +49 -0
  163. package/dist/src/runtime/chrome/diagnostics.js.map +1 -0
  164. package/dist/src/runtime/chrome/focus.d.ts +2 -0
  165. package/dist/src/runtime/chrome/focus.d.ts.map +1 -0
  166. package/dist/src/runtime/chrome/focus.js +41 -0
  167. package/dist/src/runtime/chrome/focus.js.map +1 -0
  168. package/dist/src/runtime/chrome/launch.d.ts +23 -0
  169. package/dist/src/runtime/chrome/launch.d.ts.map +1 -0
  170. package/dist/src/runtime/chrome/launch.js +102 -0
  171. package/dist/src/runtime/chrome/launch.js.map +1 -0
  172. package/dist/src/runtime/chrome/puppeteer.d.ts +7 -0
  173. package/dist/src/runtime/chrome/puppeteer.d.ts.map +1 -0
  174. package/dist/src/runtime/chrome/puppeteer.js +87 -0
  175. package/dist/src/runtime/chrome/puppeteer.js.map +1 -0
  176. package/dist/src/runtime/chrome/reuse/constants.d.ts +4 -0
  177. package/dist/src/runtime/chrome/reuse/constants.d.ts.map +1 -0
  178. package/dist/src/runtime/chrome/reuse/constants.js +4 -0
  179. package/dist/src/runtime/chrome/reuse/constants.js.map +1 -0
  180. package/dist/src/runtime/chrome/reuse.d.ts +13 -0
  181. package/dist/src/runtime/chrome/reuse.d.ts.map +1 -0
  182. package/dist/src/runtime/chrome/reuse.js +183 -0
  183. package/dist/src/runtime/chrome/reuse.js.map +1 -0
  184. package/dist/src/runtime/chrome/session.d.ts +13 -0
  185. package/dist/src/runtime/chrome/session.d.ts.map +1 -0
  186. package/dist/src/runtime/chrome/session.js +47 -0
  187. package/dist/src/runtime/chrome/session.js.map +1 -0
  188. package/dist/src/runtime/chrome.d.ts +9 -0
  189. package/dist/src/runtime/chrome.d.ts.map +1 -0
  190. package/dist/src/runtime/chrome.js +10 -0
  191. package/dist/src/runtime/chrome.js.map +1 -0
  192. package/dist/src/runtime/cookies.d.ts +37 -0
  193. package/dist/src/runtime/cookies.d.ts.map +1 -0
  194. package/dist/src/runtime/cookies.js +592 -0
  195. package/dist/src/runtime/cookies.js.map +1 -0
  196. package/dist/src/runtime/devstack.d.ts +14 -0
  197. package/dist/src/runtime/devstack.d.ts.map +1 -0
  198. package/dist/src/runtime/devstack.js +174 -0
  199. package/dist/src/runtime/devstack.js.map +1 -0
  200. package/dist/src/runtime/devtools/background.d.ts +5 -0
  201. package/dist/src/runtime/devtools/background.d.ts.map +1 -0
  202. package/dist/src/runtime/devtools/background.js +109 -0
  203. package/dist/src/runtime/devtools/background.js.map +1 -0
  204. package/dist/src/runtime/devtools/cdp.d.ts +16 -0
  205. package/dist/src/runtime/devtools/cdp.d.ts.map +1 -0
  206. package/dist/src/runtime/devtools/cdp.js +392 -0
  207. package/dist/src/runtime/devtools/cdp.js.map +1 -0
  208. package/dist/src/runtime/devtools/config.d.ts +12 -0
  209. package/dist/src/runtime/devtools/config.d.ts.map +1 -0
  210. package/dist/src/runtime/devtools/config.js +78 -0
  211. package/dist/src/runtime/devtools/config.js.map +1 -0
  212. package/dist/src/runtime/devtools/constants.d.ts +10 -0
  213. package/dist/src/runtime/devtools/constants.d.ts.map +1 -0
  214. package/dist/src/runtime/devtools/constants.js +18 -0
  215. package/dist/src/runtime/devtools/constants.js.map +1 -0
  216. package/dist/src/runtime/devtools/diagnostics.d.ts +8 -0
  217. package/dist/src/runtime/devtools/diagnostics.d.ts.map +1 -0
  218. package/dist/src/runtime/devtools/diagnostics.js +189 -0
  219. package/dist/src/runtime/devtools/diagnostics.js.map +1 -0
  220. package/dist/src/runtime/devtools/oauth.d.ts +9 -0
  221. package/dist/src/runtime/devtools/oauth.d.ts.map +1 -0
  222. package/dist/src/runtime/devtools/oauth.js +130 -0
  223. package/dist/src/runtime/devtools/oauth.js.map +1 -0
  224. package/dist/src/runtime/devtools/types.d.ts +108 -0
  225. package/dist/src/runtime/devtools/types.d.ts.map +1 -0
  226. package/dist/src/runtime/devtools/types.js +2 -0
  227. package/dist/src/runtime/devtools/types.js.map +1 -0
  228. package/dist/src/runtime/devtools.d.ts +8 -0
  229. package/dist/src/runtime/devtools.d.ts.map +1 -0
  230. package/dist/src/runtime/devtools.js +9 -0
  231. package/dist/src/runtime/devtools.js.map +1 -0
  232. package/dist/src/runtime/next-devtools.d.ts +2 -0
  233. package/dist/src/runtime/next-devtools.d.ts.map +1 -0
  234. package/dist/src/runtime/next-devtools.js +72 -0
  235. package/dist/src/runtime/next-devtools.js.map +1 -0
  236. package/dist/src/runtime/screenshot.d.ts +78 -0
  237. package/dist/src/runtime/screenshot.d.ts.map +1 -0
  238. package/dist/src/runtime/screenshot.js +232 -0
  239. package/dist/src/runtime/screenshot.js.map +1 -0
  240. package/dist/src/runtime/scripts.d.ts +16 -0
  241. package/dist/src/runtime/scripts.d.ts.map +1 -0
  242. package/dist/src/runtime/scripts.js +110 -0
  243. package/dist/src/runtime/scripts.js.map +1 -0
  244. package/dist/src/runtime/session.d.ts +64 -0
  245. package/dist/src/runtime/session.d.ts.map +1 -0
  246. package/dist/src/runtime/session.js +205 -0
  247. package/dist/src/runtime/session.js.map +1 -0
  248. package/dist/src/runtime/smoke.d.ts +36 -0
  249. package/dist/src/runtime/smoke.d.ts.map +1 -0
  250. package/dist/src/runtime/smoke.js +417 -0
  251. package/dist/src/runtime/smoke.js.map +1 -0
  252. package/dist/src/runtime/url.d.ts +10 -0
  253. package/dist/src/runtime/url.d.ts.map +1 -0
  254. package/dist/src/runtime/url.js +147 -0
  255. package/dist/src/runtime/url.js.map +1 -0
  256. package/dist/src/screenshot-hooks.d.ts +13 -0
  257. package/dist/src/screenshot-hooks.d.ts.map +1 -0
  258. package/dist/src/screenshot-hooks.js +56 -0
  259. package/dist/src/screenshot-hooks.js.map +1 -0
  260. package/dist/src/shared/env.d.ts +23 -0
  261. package/dist/src/shared/env.d.ts.map +1 -0
  262. package/dist/src/shared/env.js +38 -0
  263. package/dist/src/shared/env.js.map +1 -0
  264. package/dist/src/shared/index.d.ts +211 -0
  265. package/dist/src/shared/index.d.ts.map +1 -0
  266. package/dist/src/shared/index.js +73 -0
  267. package/dist/src/shared/index.js.map +1 -0
  268. package/dist/src/shared/node.d.ts +12 -0
  269. package/dist/src/shared/node.d.ts.map +1 -0
  270. package/dist/src/shared/node.js +67 -0
  271. package/dist/src/shared/node.js.map +1 -0
  272. package/dist/src/token.d.ts +4 -0
  273. package/dist/src/token.d.ts.map +1 -0
  274. package/dist/src/token.js +58 -0
  275. package/dist/src/token.js.map +1 -0
  276. package/dist/src/types.d.ts +17 -0
  277. package/dist/src/types.d.ts.map +1 -0
  278. package/dist/src/types.js +2 -0
  279. package/dist/src/types.js.map +1 -0
  280. package/dist/src/util/app-label.d.ts +5 -0
  281. package/dist/src/util/app-label.d.ts.map +1 -0
  282. package/dist/src/util/app-label.js +21 -0
  283. package/dist/src/util/app-label.js.map +1 -0
  284. package/dist/src/util/errors.d.ts +9 -0
  285. package/dist/src/util/errors.d.ts.map +1 -0
  286. package/dist/src/util/errors.js +56 -0
  287. package/dist/src/util/errors.js.map +1 -0
  288. package/dist/src/util/path.d.ts +3 -0
  289. package/dist/src/util/path.d.ts.map +1 -0
  290. package/dist/src/util/path.js +6 -0
  291. package/dist/src/util/path.js.map +1 -0
  292. package/dist/src/util/regex.d.ts +4 -0
  293. package/dist/src/util/regex.d.ts.map +1 -0
  294. package/dist/src/util/regex.js +5 -0
  295. package/dist/src/util/regex.js.map +1 -0
  296. package/dist/src/util/time.d.ts +3 -0
  297. package/dist/src/util/time.d.ts.map +1 -0
  298. package/dist/src/util/time.js +7 -0
  299. package/dist/src/util/time.js.map +1 -0
  300. package/package.json +86 -0
@@ -0,0 +1,1941 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { mkdir, readFile, rm } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { Command, CommanderError, Option } from 'commander';
8
+ import { compact, uniq } from 'es-toolkit';
9
+ import { createSweetLinkCommandId, } from '../shared/src/index.js';
10
+ import { registerClickCommand } from './commands/click.js';
11
+ import { registerRunJsCommand } from './commands/run-js.js';
12
+ import { registerTrustCaCommand } from './commands/trust-ca.js';
13
+ import { readRootProgramOptions, resolveConfig } from './core/config.js';
14
+ import { loadSweetLinkFileConfig } from './core/config-file.js';
15
+ import { readCommandOptions } from './core/env.js';
16
+ import { cleanupControlledChromeRegistry, registerControlledChromeInstance } from './devtools-registry.js';
17
+ import { sweetLinkCliTestMode, sweetLinkDebug, sweetLinkEnv } from './env.js';
18
+ import { fetchJson } from './http.js';
19
+ import { collectPuppeteerDiagnostics, focusControlledChromePage, launchChrome, launchControlledChrome, prepareChromeLaunch, reuseExistingControlledChrome, signalSweetLinkBootstrap, waitForSweetLinkSession, } from './runtime/chrome.js';
20
+ import { buildCookieOrigins, collectChromeCookies, collectChromeCookiesForDomains, normalizePuppeteerCookie, } from './runtime/cookies.js';
21
+ import { ensureDevStackRunning as ensureDevStackRunningRuntime, isAppReachable as isAppReachableRuntime, maybeInstallMkcertDispatcher, } from './runtime/devstack.js';
22
+ import { attemptTwitterOauthAutoAccept, collectBootstrapDiagnostics, connectToDevTools, createEmptyDevToolsState, createNetworkEntryFromRequest, DEVTOOLS_CONSOLE_LIMIT, DEVTOOLS_NETWORK_LIMIT, DEVTOOLS_STATE_PATH, deriveDevtoolsLinkInfo, diagnosticsContainBlockingIssues, ensureBackgroundDevtoolsListener, fetchDevToolsTabs, formatConsoleArg, loadDevToolsConfig, loadDevToolsState, logBootstrapDiagnostics, saveDevToolsConfig, saveDevToolsState, serializeConsoleMessage, trimBuffer, } from './runtime/devtools.js';
23
+ import { fetchNextDevtoolsErrors } from './runtime/next-devtools.js';
24
+ import { attemptDevToolsCapture, maybeDescribeScreenshot, persistScreenshotResult, tryDevToolsRecovery, tryHtmlToImageFallback, } from './runtime/screenshot.js';
25
+ import { renderCommandResult } from './runtime/scripts.js';
26
+ import { buildClickScript, fetchConsoleEvents, fetchSessionSummaries, formatSessionHeadline, getSessionSummaryById, isSweetLinkSelectorCandidate, isSweetLinkSelectorDiscoveryResult, resolvePromptOption, resolveSessionIdFromHint, } from './runtime/session.js';
27
+ import { buildSmokeRouteUrl, clearSmokeProgress, consoleEventIndicatesAuthIssue, consoleEventIndicatesRuntimeError, DEFAULT_SMOKE_ROUTES, deriveSmokeRoutes, ensureSweetLinkSessionConnected, formatConsoleEventSummary, loadSmokeProgressIndex, navigateSweetLinkSession, saveSmokeProgressIndex, triggerSweetLinkCliAuto, waitForSmokeRouteReady, } from './runtime/smoke.js';
28
+ import { buildWaitCandidateUrls, configurePathRedirects, normalizeUrlForMatch, trimTrailingSlash } from './runtime/url.js';
29
+ import { buildScreenshotHooks } from './screenshot-hooks.js';
30
+ import { fetchCliToken } from './token.js';
31
+ import { describeAppForPrompt, formatAppLabel } from './util/app-label.js';
32
+ import { extractEventMessage, isErrnoException, logDebugError } from './util/errors.js';
33
+ const __filename = fileURLToPath(import.meta.url);
34
+ const __dirname = path.dirname(__filename);
35
+ function resolveRepoRoot(startDir) {
36
+ let currentDir = startDir;
37
+ // Walk up until a package.json exists or we reach the filesystem root.
38
+ // This keeps the CLI working whether we run from `src` (tsx) or `dist/src`.
39
+ while (!existsSync(path.join(currentDir, 'package.json'))) {
40
+ const parent = path.dirname(currentDir);
41
+ if (parent === currentDir) {
42
+ return startDir;
43
+ }
44
+ currentDir = parent;
45
+ }
46
+ return currentDir;
47
+ }
48
+ const repoRoot = resolveRepoRoot(__dirname);
49
+ const packageJsonPath = path.join(repoRoot, 'package.json');
50
+ let packageVersion = '0.0.0';
51
+ try {
52
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
53
+ if (typeof packageJson.version === 'string') {
54
+ packageVersion = packageJson.version;
55
+ }
56
+ }
57
+ catch {
58
+ // Ignore and keep default version when package metadata can't be read.
59
+ }
60
+ const SESSION_RECOVERY_PATTERN = /session not found|session did not exist|session not available/i;
61
+ const LEADING_SLASH_PATTERN = /^\/+/;
62
+ const OAUTH_URL_PATTERN = /oauth|authorize/i;
63
+ const TRAILING_SLASH_PATTERN = /\/?$/;
64
+ function formatDuration(ms) {
65
+ if (!Number.isFinite(ms)) {
66
+ return 'unknown';
67
+ }
68
+ const abs = Math.max(0, ms);
69
+ if (abs < 1000) {
70
+ return `${Math.round(abs)} ms`;
71
+ }
72
+ const seconds = abs / 1000;
73
+ if (seconds < 60) {
74
+ const precision = seconds < 10 ? 1 : 0;
75
+ return `${seconds.toFixed(precision)} s`;
76
+ }
77
+ const minutes = seconds / 60;
78
+ if (minutes < 60) {
79
+ const precision = minutes < 10 ? 1 : 0;
80
+ return `${minutes.toFixed(precision)} min`;
81
+ }
82
+ const hours = minutes / 60;
83
+ if (hours < 24) {
84
+ const precision = hours < 10 ? 1 : 0;
85
+ return `${hours.toFixed(precision)} h`;
86
+ }
87
+ const days = hours / 24;
88
+ const precision = days < 10 ? 1 : 0;
89
+ return `${days.toFixed(precision)} d`;
90
+ }
91
+ const program = new Command();
92
+ program
93
+ .name('sweetlink')
94
+ .description('Interact with SweetLink daemon sessions')
95
+ .version(packageVersion, '-v, --version', 'Show SweetLink CLI version');
96
+ maybeInstallMkcertDispatcher();
97
+ const LOOSE_PATH_SUFFIXES = new Set(['home', 'index', 'overview']);
98
+ const { config: fileConfig } = loadSweetLinkFileConfig();
99
+ configurePathRedirects(fileConfig.redirects);
100
+ const { appUrl: envAppUrl, daemonUrl: envDaemonUrl, localAdminApiKey, adminApiKey: sharedAdminApiKey, prodAppUrl: envProdAppUrl, } = sweetLinkEnv;
101
+ const defaultAppLabel = formatAppLabel(fileConfig.appLabel ?? sweetLinkEnv.appLabel);
102
+ const defaultAppUrl = deriveDefaultAppUrl(envAppUrl, fileConfig);
103
+ const defaultProdAppUrl = fileConfig.prodUrl ?? envProdAppUrl;
104
+ const defaultDaemonUrl = fileConfig.daemonUrl ?? envDaemonUrl;
105
+ const defaultAdminKey = fileConfig.adminKey ?? localAdminApiKey ?? sharedAdminApiKey ?? '';
106
+ const defaultHealthPaths = fileConfig.healthChecks?.paths ?? null;
107
+ const LOCAL_DEFAULT_APP_URL = 'http://localhost:3000';
108
+ function deriveDefaultAppUrl(envUrl, config) {
109
+ const configuredAppUrl = typeof config.appUrl === 'string' ? config.appUrl.trim() : '';
110
+ if (configuredAppUrl.length > 0) {
111
+ return configuredAppUrl;
112
+ }
113
+ if (typeof config.port === 'number' && Number.isFinite(config.port) && config.port > 0) {
114
+ const baseUrl = envUrl ?? LOCAL_DEFAULT_APP_URL;
115
+ return applyPortToUrl(baseUrl, config.port);
116
+ }
117
+ return envUrl ?? LOCAL_DEFAULT_APP_URL;
118
+ }
119
+ function applyPortToUrl(base, port) {
120
+ try {
121
+ const url = new URL(base);
122
+ url.port = String(port);
123
+ return url.toString();
124
+ }
125
+ catch {
126
+ return `http://localhost:${port}`;
127
+ }
128
+ }
129
+ function parseCliPort(value) {
130
+ const parsed = Number.parseInt(value, 10);
131
+ if (!Number.isFinite(parsed) || parsed <= 0) {
132
+ throw new CommanderError(1, 'InvalidPort', `--port expects a positive integer, received "${value}".`);
133
+ }
134
+ return parsed;
135
+ }
136
+ program
137
+ .option('-a, --app-url <url>', 'Application base URL for SweetLink commands', defaultAppUrl)
138
+ .option('--app-label <label>', 'Friendly name for your application (used in help output)', defaultAppLabel)
139
+ .option('--url <url>', 'Alias for --app-url')
140
+ .addOption(new Option('--port <number>', 'Override local app port (defaults to config or 3000)').argParser(parseCliPort))
141
+ .option('-d, --daemon-url <url>', 'SweetLink daemon base URL', defaultDaemonUrl)
142
+ .option('-k, --admin-key <key>', 'Optional admin API key (defaults to SWEETLINK_LOCAL_ADMIN_API_KEY or SWEETLINK_ADMIN_API_KEY env; falls back to legacy SWEETISTICS_* keys)', defaultAdminKey)
143
+ .option('--oauth-script <path>', 'Absolute or relative path to an OAuth automation script (ESM module). Overrides config/env defaults.');
144
+ program
145
+ .command('sessions')
146
+ .description('List active SweetLink sessions')
147
+ .option('--json', 'Output JSON instead of a table', false)
148
+ .action(async (options, command) => {
149
+ const config = resolveConfig(command);
150
+ const token = await fetchCliToken(config);
151
+ const [sessions, devtoolsConfig, devtoolsState] = await Promise.all([
152
+ fetchSessionSummaries(config, token),
153
+ loadDevToolsConfig().catch(() => null),
154
+ loadDevToolsState().catch(() => null),
155
+ ]);
156
+ // Surface which SweetLink session (if any) currently maps to the controlled DevTools window.
157
+ const devtoolsLinkInfo = deriveDevtoolsLinkInfo(devtoolsConfig, devtoolsState);
158
+ const devtoolsSessionIds = devtoolsLinkInfo.sessionIds;
159
+ const devtoolsEndpoint = devtoolsLinkInfo.endpoint;
160
+ if (options.json) {
161
+ const sessionsWithDevtools = sessions.map((session) => ({
162
+ ...session,
163
+ devtoolsLinked: devtoolsSessionIds.has(session.sessionId),
164
+ devtoolsEndpoint: devtoolsSessionIds.has(session.sessionId) ? devtoolsEndpoint : null,
165
+ }));
166
+ process.stdout.write(`${JSON.stringify(sessionsWithDevtools, null, 2)}\n`);
167
+ return;
168
+ }
169
+ if (sessions.length === 0) {
170
+ console.log('No active SweetLink sessions.');
171
+ console.log('Hint: run `pnpm sweetlink open --controlled --path /` to launch an authenticated tab automatically.');
172
+ return;
173
+ }
174
+ const now = Date.now();
175
+ console.log('Active SweetLink sessions:\n');
176
+ for (const session of sessions) {
177
+ const heartbeatMsAgo = typeof session.heartbeatMsAgo === 'number' ? session.heartbeatMsAgo : Math.max(0, now - session.lastSeenAt);
178
+ const socketState = session.socketState ?? 'unknown';
179
+ const consoleEventsBuffered = session.consoleEventsBuffered ?? 0;
180
+ const consoleErrorsBuffered = session.consoleErrorsBuffered ?? 0;
181
+ const pendingCommandCount = session.pendingCommandCount ?? 0;
182
+ const openedMsAgo = Math.max(0, now - session.createdAt);
183
+ console.log(`• ${formatSessionHeadline(session)}`);
184
+ console.log(` - url: ${session.url}`);
185
+ const lastHeartbeatLabel = `${formatDuration(heartbeatMsAgo)} ago`;
186
+ const openedLabel = `${formatDuration(openedMsAgo)} ago`;
187
+ const socketLabel = `socket ${socketState}`;
188
+ console.log(` - last: ${lastHeartbeatLabel} • opened ${openedLabel} • ${socketLabel}`);
189
+ const pendingLabel = pendingCommandCount === 1 ? 'command' : 'commands';
190
+ const consoleLabel = consoleEventsBuffered > 0
191
+ ? `${consoleEventsBuffered} event${consoleEventsBuffered === 1 ? '' : 's'}${consoleErrorsBuffered ? ` (${consoleErrorsBuffered} error${consoleErrorsBuffered === 1 ? '' : 's'})` : ''}`
192
+ : 'none';
193
+ console.log(` - queues: ${pendingCommandCount} ${pendingLabel} • console ${consoleLabel}`);
194
+ if (devtoolsSessionIds.has(session.sessionId)) {
195
+ console.log(` - devtools: linked${devtoolsEndpoint ? ` (${devtoolsEndpoint})` : ''}`);
196
+ }
197
+ if (session.userAgent) {
198
+ console.log(` - ua: ${session.userAgent}`);
199
+ }
200
+ console.log('');
201
+ }
202
+ console.log('Tip: run `pnpm sweetlink console <sessionId> -n 50` to inspect the most recent console events for a session.');
203
+ });
204
+ program
205
+ .command('cookies')
206
+ .description('Dump Chrome cookies for one or more domains or origins')
207
+ .argument('<domains...>', 'Domains or fully-qualified origins (e.g. localhost, https://example.com)')
208
+ .option('--json', 'Output JSON instead of a human-readable list', false)
209
+ .action(async (domains, options) => {
210
+ const uniqueDomains = uniq(compact(domains.map((domain) => domain.trim())));
211
+ if (uniqueDomains.length === 0) {
212
+ console.log('No domains provided; nothing to collect.');
213
+ return;
214
+ }
215
+ const cookiesByDomain = await collectChromeCookiesForDomains(uniqueDomains);
216
+ if (options.json) {
217
+ process.stdout.write(`${JSON.stringify(cookiesByDomain, null, 2)}\n`);
218
+ return;
219
+ }
220
+ for (const domain of uniqueDomains) {
221
+ const domainCookies = cookiesByDomain[domain];
222
+ const cookies = Array.isArray(domainCookies) ? domainCookies : [];
223
+ console.log(`\n${domain} — ${cookies.length} cookie${cookies.length === 1 ? '' : 's'}`);
224
+ if (cookies.length === 0) {
225
+ continue;
226
+ }
227
+ for (const cookie of cookies) {
228
+ const scope = cookie.domain ? `domain=${cookie.domain}` : `url=${cookie.url ?? 'unknown'}`;
229
+ const cookiePath = cookie.path ?? '/';
230
+ const secureFlag = cookie.secure ? '; Secure' : '';
231
+ const httpOnlyFlag = cookie.httpOnly ? '; HttpOnly' : '';
232
+ console.log(` • ${cookie.name} (${scope} ${cookiePath}${secureFlag}${httpOnlyFlag})`);
233
+ console.log(` ${cookie.value}`);
234
+ }
235
+ }
236
+ console.log('');
237
+ });
238
+ registerRunJsCommand(program);
239
+ registerTrustCaCommand(program);
240
+ registerClickCommand(program);
241
+ program
242
+ .command('console <sessionId>')
243
+ .description('Fetch buffered console events for a session')
244
+ .option('-n, --limit <count>', 'Show only the last <count> console events', Number)
245
+ .option('--json', 'Output JSON', false)
246
+ .action(async (sessionId, options, command) => {
247
+ const config = resolveConfig(command);
248
+ const resolvedSessionId = await resolveSessionIdFromHint(sessionId, config);
249
+ const token = await fetchCliToken(config);
250
+ const response = await fetchJson(`${config.daemonBaseUrl}/sessions/${encodeURIComponent(resolvedSessionId)}/console`, {
251
+ headers: { Authorization: `Bearer ${token}` },
252
+ });
253
+ if (options.json) {
254
+ process.stdout.write(`${JSON.stringify(response.events, null, 2)}\n`);
255
+ return;
256
+ }
257
+ if (response.events.length === 0) {
258
+ console.log('No buffered console output.');
259
+ return;
260
+ }
261
+ const limit = typeof options.limit === 'number' && Number.isFinite(options.limit) && options.limit > 0
262
+ ? Math.floor(options.limit)
263
+ : null;
264
+ const events = limit ? response.events.slice(-limit) : response.events;
265
+ const startIndex = response.events.length - events.length;
266
+ for (const [offset, event] of events.entries()) {
267
+ const timestamp = new Date(event.timestamp).toLocaleTimeString();
268
+ const prefix = `${startIndex + offset + 1}.`;
269
+ console.log(`${prefix} [${timestamp}] ${event.level}:`, ...event.args);
270
+ }
271
+ });
272
+ program
273
+ .command('open')
274
+ .description(`Open ${defaultAppLabel} in Chrome with SweetLink auto-enabled`)
275
+ .option('-e, --env <env>', 'Environment to open (dev or prod)', 'dev')
276
+ .option('-p, --path <path>', 'Optional path to append (default "")', '')
277
+ .option('--url <url>', 'Explicit URL to open (overrides --path and --env)')
278
+ .option('--controlled', 'Launch Chrome in controlled mode with DevTools enabled', false)
279
+ .option('--devtools-port <port>', 'Specify DevTools port to use with --controlled', Number)
280
+ .option('--no-cookie-sync', 'Disable copying cookies from your main Chrome profile into the controlled window', false)
281
+ .option('--timeout <seconds>', 'Seconds to wait for a SweetLink session (default 15)', Number)
282
+ .option('--no-devtools', 'Skip DevTools automation when opening in controlled mode')
283
+ .option('--headless', 'Launch the controlled browser headlessly', false)
284
+ .option('--foreground', 'Bring the Chrome window to the foreground after opening (macOS only)', false)
285
+ .action(async (options, command) => {
286
+ await runOpenCommand(options, command, program);
287
+ });
288
+ async function runOpenCommand(options, command, rootProgram) {
289
+ const context = buildOpenCommandContext(options, command, rootProgram);
290
+ if (!context.controlled) {
291
+ if (options.devtools === false) {
292
+ console.log('--no-devtools is ignored when launching an uncontrolled browser window.');
293
+ }
294
+ if (options.headless) {
295
+ console.log('--headless requires --controlled; launching a regular Chrome window instead.');
296
+ }
297
+ }
298
+ if (context.headless && context.foreground) {
299
+ console.log('--foreground is ignored in headless mode.');
300
+ }
301
+ if (context.env === 'dev') {
302
+ await ensureDevStackRunningRuntime(context.targetUrl, {
303
+ repoRoot,
304
+ healthPaths: context.healthCheckPaths ?? undefined,
305
+ server: context.serverConfig ?? undefined,
306
+ });
307
+ }
308
+ const waitToken = await fetchWaitTokenIfNeeded(context);
309
+ const appReachable = await checkOpenCommandReachability(context);
310
+ if (!appReachable) {
311
+ logOpenCommandReachabilityErrors(context);
312
+ process.exitCode = 1;
313
+ return;
314
+ }
315
+ if (context.controlled) {
316
+ await handleControlledOpen(context, waitToken);
317
+ return;
318
+ }
319
+ await handleUncontrolledOpen(context, waitToken);
320
+ }
321
+ function buildOpenCommandContext(options, command, rootProgram) {
322
+ const config = resolveConfig(command);
323
+ const env = normalizeOpenCommandEnvironment(options.env);
324
+ const parent = command.parent ?? rootProgram;
325
+ const parentOptions = readRootProgramOptions(parent);
326
+ const developmentBaseUrl = parentOptions.appUrl;
327
+ const productionBaseUrl = defaultProdAppUrl;
328
+ const baseUrl = env === 'prod' ? productionBaseUrl : developmentBaseUrl;
329
+ const explicitTarget = resolveExplicitTargetUrl(options.url);
330
+ const targetUrl = explicitTarget ?? buildOpenCommandTargetUrl(baseUrl, options.path);
331
+ const serverConfig = config.servers[env] ?? null;
332
+ const preferredPort = typeof options.devtoolsPort === 'number' && Number.isFinite(options.devtoolsPort)
333
+ ? options.devtoolsPort
334
+ : undefined;
335
+ const controlled = Boolean(options.controlled);
336
+ const enableDevtools = controlled ? options.devtools !== false : true;
337
+ const headless = controlled ? Boolean(options.headless) : false;
338
+ const foreground = Boolean(options.foreground);
339
+ return {
340
+ config,
341
+ appLabel: config.appLabel,
342
+ env,
343
+ controlled,
344
+ preferredPort,
345
+ shouldSyncCookies: resolveCookieSyncPreference(command, options.cookieSync),
346
+ timeoutSeconds: resolveOpenCommandTimeoutSeconds(options.timeout),
347
+ targetUrl,
348
+ targetUrlString: targetUrl.toString(),
349
+ enableDevtools,
350
+ headless,
351
+ foreground,
352
+ healthCheckPaths: defaultHealthPaths,
353
+ oauthScriptPath: config.oauthScriptPath,
354
+ serverConfig,
355
+ };
356
+ }
357
+ function normalizeOpenCommandEnvironment(value) {
358
+ const normalized = (value ?? 'dev').trim().toLowerCase();
359
+ if (normalized === 'dev' || normalized === 'prod') {
360
+ return normalized;
361
+ }
362
+ throw new Error('Invalid environment. Use "dev" or "prod".');
363
+ }
364
+ function buildOpenCommandTargetUrl(baseUrl, rawPath) {
365
+ let targetUrl;
366
+ try {
367
+ targetUrl = new URL(baseUrl);
368
+ }
369
+ catch (error) {
370
+ const message = error instanceof Error ? error.message : String(error);
371
+ throw new Error(`Failed to parse base URL "${baseUrl}": ${message}`);
372
+ }
373
+ targetUrl.searchParams.set('sweetlink', 'auto');
374
+ const fallbackPath = '/timeline';
375
+ const trimmedPath = rawPath?.trim();
376
+ const pathSource = trimmedPath && trimmedPath.length > 0 ? trimmedPath : fallbackPath;
377
+ const [pathPartRaw, queryPart] = pathSource.split('?', 2);
378
+ const pathPart = pathPartRaw && pathPartRaw.length > 0 ? pathPartRaw : fallbackPath;
379
+ targetUrl.pathname = pathPart.startsWith('/') ? pathPart : `/${pathPart}`;
380
+ if (queryPart) {
381
+ const extraSearch = new URLSearchParams(queryPart);
382
+ for (const [key, value] of extraSearch.entries()) {
383
+ targetUrl.searchParams.append(key, value);
384
+ }
385
+ }
386
+ return targetUrl;
387
+ }
388
+ function resolveExplicitTargetUrl(raw) {
389
+ if (!raw) {
390
+ return null;
391
+ }
392
+ const trimmed = raw.trim();
393
+ if (trimmed.length === 0) {
394
+ return null;
395
+ }
396
+ try {
397
+ const target = new URL(trimmed);
398
+ target.searchParams.set('sweetlink', target.searchParams.get('sweetlink') ?? 'auto');
399
+ return target;
400
+ }
401
+ catch (error) {
402
+ const message = error instanceof Error ? error.message : String(error);
403
+ throw new Error(`Failed to parse --url value "${trimmed}": ${message}`);
404
+ }
405
+ }
406
+ function resolveCookieSyncPreference(command, cookieSyncOption) {
407
+ const source = command.getOptionValueSource?.('cookieSync');
408
+ if (source === 'default') {
409
+ return true;
410
+ }
411
+ return cookieSyncOption !== false;
412
+ }
413
+ function resolveOpenCommandTimeoutSeconds(value) {
414
+ if (typeof value === 'number' && Number.isFinite(value)) {
415
+ return Math.max(0, value);
416
+ }
417
+ return 30;
418
+ }
419
+ async function fetchWaitTokenIfNeeded(context) {
420
+ if (context.timeoutSeconds <= 0) {
421
+ return null;
422
+ }
423
+ try {
424
+ return await fetchCliToken(context.config);
425
+ }
426
+ catch (error) {
427
+ console.warn('Unable to fetch CLI token for session wait:', error);
428
+ return null;
429
+ }
430
+ }
431
+ async function checkOpenCommandReachability(context) {
432
+ return await isAppReachableRuntime(context.targetUrl.origin, context.healthCheckPaths ?? undefined);
433
+ }
434
+ function logOpenCommandReachabilityErrors(context) {
435
+ console.error(`${context.targetUrl.origin} did not respond. Start ${describeAppForPrompt(context.appLabel)} and retry.`);
436
+ }
437
+ async function handleControlledOpen(context, waitToken) {
438
+ const reuseResult = context.headless
439
+ ? null
440
+ : await reuseExistingControlledChrome(context.targetUrlString, {
441
+ preferredPort: context.preferredPort,
442
+ cookieSync: context.shouldSyncCookies,
443
+ bringToFront: context.foreground,
444
+ });
445
+ if (reuseResult) {
446
+ await handleControlledReuse(context, waitToken, reuseResult);
447
+ return;
448
+ }
449
+ console.warn('No reusable controlled Chrome session matched the target; launching a fresh controlled window.');
450
+ await handleControlledLaunch(context, waitToken);
451
+ }
452
+ async function handleControlledReuse(context, waitToken, reuseResult) {
453
+ const shouldFocus = context.foreground && !context.headless;
454
+ if (context.enableDevtools) {
455
+ await registerControlledChromeInstance(reuseResult.devtoolsUrl, reuseResult.userDataDir);
456
+ await cleanupControlledChromeRegistry(reuseResult.devtoolsUrl);
457
+ await signalSweetLinkBootstrap(reuseResult.devtoolsUrl, context.targetUrlString);
458
+ try {
459
+ const oauthAttempt = await attemptTwitterOauthAutoAccept({
460
+ devtoolsUrl: reuseResult.devtoolsUrl,
461
+ sessionUrl: context.targetUrlString,
462
+ scriptPath: context.oauthScriptPath,
463
+ });
464
+ if (oauthAttempt.handled) {
465
+ console.log(`Automatically approved the OAuth prompt via ${oauthAttempt.action ?? 'click'}${oauthAttempt.clickedText ? ` (${oauthAttempt.clickedText})` : ''}.`);
466
+ }
467
+ else if (oauthAttempt.reason && oauthAttempt.reason !== 'button-not-found') {
468
+ const locationHint = oauthAttempt.url || oauthAttempt.title || oauthAttempt.host
469
+ ? ` (at ${oauthAttempt.title ?? oauthAttempt.host ?? 'unknown page'} ${oauthAttempt.url ?? ''})`
470
+ : '';
471
+ console.log(`OAuth auto-accept skipped: ${oauthAttempt.reason}${locationHint}.`);
472
+ }
473
+ }
474
+ catch (error) {
475
+ if (sweetLinkDebug) {
476
+ console.warn('OAuth auto-accept attempt failed:', error);
477
+ }
478
+ }
479
+ }
480
+ else {
481
+ console.log('DevTools automation disabled (--no-devtools); skipping telemetry bootstrap and OAuth auto-click.');
482
+ }
483
+ console.log(`Reused controlled Chrome at ${reuseResult.devtoolsUrl} (env: ${context.env}).`);
484
+ if (reuseResult.targetAlreadyOpen) {
485
+ console.log('Target tab was already open; activated existing page.');
486
+ }
487
+ else {
488
+ console.log('Opened a new tab in the existing controlled Chrome window.');
489
+ }
490
+ if (context.enableDevtools) {
491
+ console.log('The screenshot command will continue to use this DevTools instance.');
492
+ }
493
+ if (shouldFocus && context.enableDevtools) {
494
+ const focused = await focusControlledChromePage(reuseResult.devtoolsUrl, context.targetUrlString);
495
+ if (!focused && sweetLinkDebug) {
496
+ console.warn('Unable to focus controlled Chrome window automatically.');
497
+ }
498
+ }
499
+ else if (context.foreground && !context.enableDevtools) {
500
+ console.log('--foreground requires DevTools automation; skipping automatic focus.');
501
+ }
502
+ console.log('Remember this session codename. Run `pnpm sweetlink sessions`, copy the session id or codename, and use that handle for every follow-up command instead of rerunning `pnpm sweetlink open`.');
503
+ await waitForSessionAfterOpen(context, waitToken, {
504
+ devtoolsUrl: context.enableDevtools ? reuseResult.devtoolsUrl : undefined,
505
+ trigger: context.enableDevtools
506
+ ? () => triggerSweetLinkCliAuto(reuseResult.devtoolsUrl, context.targetUrlString)
507
+ : undefined,
508
+ retryTrigger: context.enableDevtools
509
+ ? () => triggerSweetLinkCliAuto(reuseResult.devtoolsUrl, context.targetUrlString)
510
+ : undefined,
511
+ failureMessage: context.enableDevtools
512
+ ? 'Controlled Chrome reused but SweetLink did not register automatically.'
513
+ : 'Controlled Chrome reused. DevTools automation disabled; verify the session from the UI if SweetLink does not appear.',
514
+ });
515
+ await surfaceBlockingDiagnosticsAfterNavigation('SweetLink open', context.enableDevtools ? reuseResult.devtoolsUrl : undefined, context.targetUrlString);
516
+ }
517
+ async function handleControlledLaunch(context, waitToken) {
518
+ const info = await launchControlledChrome(context.targetUrlString, {
519
+ port: context.preferredPort,
520
+ cookieSync: context.shouldSyncCookies,
521
+ headless: context.headless,
522
+ foreground: context.foreground,
523
+ });
524
+ const userDataDirectoryDisplay = info.userDataDir.replace(os.homedir(), '~');
525
+ if (context.enableDevtools) {
526
+ await registerControlledChromeInstance(info.devtoolsUrl, info.userDataDir);
527
+ await cleanupControlledChromeRegistry(info.devtoolsUrl);
528
+ await signalSweetLinkBootstrap(info.devtoolsUrl, context.targetUrlString);
529
+ try {
530
+ const oauthAttempt = await attemptTwitterOauthAutoAccept({
531
+ devtoolsUrl: info.devtoolsUrl,
532
+ sessionUrl: context.targetUrlString,
533
+ scriptPath: context.oauthScriptPath,
534
+ });
535
+ if (oauthAttempt.handled) {
536
+ console.log(`Automatically approved the OAuth prompt via ${oauthAttempt.action ?? 'click'}${oauthAttempt.clickedText ? ` (${oauthAttempt.clickedText})` : ''}.`);
537
+ }
538
+ else if (oauthAttempt.reason && oauthAttempt.reason !== 'button-not-found') {
539
+ const locationHint = oauthAttempt.url || oauthAttempt.title || oauthAttempt.host
540
+ ? ` (at ${oauthAttempt.title ?? oauthAttempt.host ?? 'unknown page'} ${oauthAttempt.url ?? ''})`
541
+ : '';
542
+ console.log(`OAuth auto-accept skipped: ${oauthAttempt.reason}${locationHint}.`);
543
+ }
544
+ }
545
+ catch (error) {
546
+ if (sweetLinkDebug) {
547
+ console.warn('OAuth auto-accept attempt failed:', error);
548
+ }
549
+ }
550
+ }
551
+ else {
552
+ console.log('DevTools automation disabled (--no-devtools); launched without OAuth auto-click.');
553
+ }
554
+ console.log(`Opened controlled Chrome window to ${context.targetUrlString} (env: ${context.env}).`);
555
+ console.log(`DevTools endpoint: ${info.devtoolsUrl}`);
556
+ console.log(`User data dir : ${userDataDirectoryDisplay}`);
557
+ if (context.enableDevtools) {
558
+ console.log('The screenshot command will auto-detect this DevTools instance.');
559
+ }
560
+ if (context.headless) {
561
+ console.log('Running in headless mode (--headless).');
562
+ }
563
+ const shouldFocus = context.foreground && !context.headless;
564
+ if (shouldFocus && context.enableDevtools) {
565
+ const focused = await focusControlledChromePage(info.devtoolsUrl, context.targetUrlString);
566
+ if (!focused && sweetLinkDebug) {
567
+ console.warn('Unable to focus controlled Chrome window automatically.');
568
+ }
569
+ }
570
+ else if (context.foreground && !context.enableDevtools) {
571
+ console.log('--foreground requires DevTools automation; skipping automatic focus.');
572
+ }
573
+ await waitForSessionAfterOpen(context, waitToken, {
574
+ devtoolsUrl: context.enableDevtools ? info.devtoolsUrl : undefined,
575
+ failureMessage: context.enableDevtools
576
+ ? 'Controlled Chrome launched but SweetLink did not register automatically; keep the window open and retry from the UI if needed.'
577
+ : 'Controlled Chrome launched without DevTools automation; complete the login manually if SweetLink does not register.',
578
+ });
579
+ await surfaceBlockingDiagnosticsAfterNavigation('SweetLink open', context.enableDevtools ? info.devtoolsUrl : undefined, context.targetUrlString);
580
+ }
581
+ async function waitForSessionAfterOpen(context, waitToken, options) {
582
+ if (!waitToken || context.timeoutSeconds <= 0) {
583
+ return;
584
+ }
585
+ if (options.trigger) {
586
+ await options.trigger();
587
+ }
588
+ const initialTimeoutSeconds = Math.min(12, context.timeoutSeconds);
589
+ const session = await waitForSweetLinkSession({
590
+ config: context.config,
591
+ token: waitToken,
592
+ targetUrl: context.targetUrlString,
593
+ timeoutSeconds: initialTimeoutSeconds,
594
+ devtoolsUrl: options.devtoolsUrl,
595
+ });
596
+ if (session) {
597
+ return;
598
+ }
599
+ if (options.retryTrigger) {
600
+ await options.retryTrigger();
601
+ const remainingSeconds = Math.max(5, context.timeoutSeconds - initialTimeoutSeconds);
602
+ const retrySession = await waitForSweetLinkSession({
603
+ config: context.config,
604
+ token: waitToken,
605
+ targetUrl: context.targetUrlString,
606
+ timeoutSeconds: Math.min(12, remainingSeconds),
607
+ devtoolsUrl: options.devtoolsUrl,
608
+ });
609
+ if (retrySession) {
610
+ return;
611
+ }
612
+ }
613
+ if (!process.exitCode || process.exitCode === 0) {
614
+ process.exitCode = 1;
615
+ }
616
+ if (options.failureMessage) {
617
+ console.warn(options.failureMessage);
618
+ }
619
+ if (options.devtoolsUrl) {
620
+ const candidates = buildWaitCandidateUrls(context.targetUrlString);
621
+ try {
622
+ const diagnostics = await collectBootstrapDiagnostics(options.devtoolsUrl, candidates);
623
+ if (diagnostics) {
624
+ console.warn('SweetLink bootstrap diagnostics (DevTools snapshot):');
625
+ logBootstrapDiagnostics('SweetLink', diagnostics);
626
+ }
627
+ }
628
+ catch (error) {
629
+ console.warn('Failed to collect DevTools diagnostics:', error);
630
+ }
631
+ await logPuppeteerPageSnapshot('SweetLink', options.devtoolsUrl, context.targetUrlString);
632
+ }
633
+ }
634
+ // Runs after every navigation so CLI users see the same crash text the browser shows.
635
+ // Diagnostics come from the chrome-devtools MCP (invoked via mcporter) so we stay
636
+ // in sync with the controlled tab even when the UI is broken.
637
+ async function surfaceBlockingDiagnosticsAfterNavigation(label, devtoolsUrl, targetUrl) {
638
+ if (!devtoolsUrl) {
639
+ return;
640
+ }
641
+ const candidates = buildWaitCandidateUrls(targetUrl);
642
+ let loggedBlocking = false;
643
+ try {
644
+ const diagnostics = await collectBootstrapDiagnostics(devtoolsUrl, candidates);
645
+ if (diagnostics) {
646
+ const bootstrapIncomplete = diagnostics.autoFlag !== true ||
647
+ (typeof diagnostics.sessionStorageAuto === 'string' && diagnostics.sessionStorageAuto.length > 0);
648
+ const hasBlocking = diagnosticsContainBlockingIssues(diagnostics) || bootstrapIncomplete;
649
+ if (hasBlocking) {
650
+ console.warn(`${label}: detected runtime anomalies after navigation.`);
651
+ logBootstrapDiagnostics(label, diagnostics);
652
+ if (bootstrapIncomplete) {
653
+ console.warn(`${label}: SweetLink bootstrap did not complete (autoFlag=${diagnostics.autoFlag}, sessionStorage=${diagnostics.sessionStorageAuto}).`);
654
+ }
655
+ await logPuppeteerPageSnapshot(label, devtoolsUrl, targetUrl);
656
+ loggedBlocking = true;
657
+ if (!process.exitCode || process.exitCode === 0) {
658
+ process.exitCode = 1;
659
+ }
660
+ }
661
+ }
662
+ }
663
+ catch (error) {
664
+ console.warn('Failed to collect DevTools diagnostics after navigation:', error);
665
+ }
666
+ if (!loggedBlocking) {
667
+ const nextErrors = await fetchNextDevtoolsErrors(targetUrl);
668
+ if (nextErrors) {
669
+ console.warn(`${label}: Next.js DevTools error summary:\n${nextErrors}`);
670
+ if (!process.exitCode || process.exitCode === 0) {
671
+ process.exitCode = 1;
672
+ }
673
+ }
674
+ }
675
+ }
676
+ async function logPuppeteerPageSnapshot(label, devtoolsUrl, targetUrl) {
677
+ try {
678
+ // Puppeteer fallback gives us raw body text when the DevTools overlay fails to render,
679
+ // which is handy when we're stuck on intermediate screens (e.g., Twitter OAuth).
680
+ const snapshot = await collectPuppeteerDiagnostics(devtoolsUrl, targetUrl);
681
+ if (!snapshot) {
682
+ console.warn(`${label}: Puppeteer snapshot was unavailable.`);
683
+ return;
684
+ }
685
+ if (snapshot.title) {
686
+ console.warn(`${label} page title: ${snapshot.title}`);
687
+ }
688
+ if (snapshot.overlayText) {
689
+ console.warn(`${label} overlay (via Puppeteer):`);
690
+ for (const line of snapshot.overlayText.split('\n')) {
691
+ const trimmed = line.trim();
692
+ if (trimmed.length > 0) {
693
+ console.warn(` ${trimmed}`);
694
+ }
695
+ }
696
+ return;
697
+ }
698
+ if (snapshot.bodyText && snapshot.bodyText.trim().length > 0) {
699
+ const condensed = snapshot.bodyText.replaceAll(/\s+/g, ' ').trim();
700
+ const snippet = condensed.length > 600 ? `${condensed.slice(0, 600)}…` : condensed;
701
+ console.warn(`${label} body text (via Puppeteer): ${snippet}`);
702
+ }
703
+ else if (!snapshot.overlayText) {
704
+ console.warn(`${label}: Puppeteer snapshot contained no overlay or body text.`);
705
+ }
706
+ }
707
+ catch (error) {
708
+ console.warn(`${label}: Failed to capture Puppeteer diagnostics:`, error);
709
+ }
710
+ }
711
+ async function handleUncontrolledOpen(context, waitToken) {
712
+ await launchChrome(context.targetUrlString, { foreground: context.foreground });
713
+ console.log(`Opened Chrome to ${context.targetUrlString} (env: ${context.env}).`);
714
+ await waitForSessionAfterOpen(context, waitToken, {
715
+ failureMessage: 'Chrome tab opened but SweetLink did not register within the timeout window. Use `pnpm sweetlink sessions` to inspect manually.',
716
+ });
717
+ }
718
+ program
719
+ .command('smoke')
720
+ .description('Run the SweetLink authenticated smoke test across non-admin routes')
721
+ .option('--session <sessionId>', 'Existing SweetLink session id or codename to reuse')
722
+ .option('--routes <routes>', `Comma-separated list of routes (default ${DEFAULT_SMOKE_ROUTES.join(', ')})`)
723
+ .option('--timeout <seconds>', 'Per-route timeout in seconds (default 45)', Number)
724
+ .option('--resume', 'Resume from the last completed route', false)
725
+ .action(async function () {
726
+ const options = readCommandOptions(this);
727
+ const routes = deriveSmokeRoutes(options.routes, DEFAULT_SMOKE_ROUTES);
728
+ if (routes.length === 0) {
729
+ console.log('No routes specified for the smoke test. Provide --routes or keep the default set.');
730
+ return;
731
+ }
732
+ const timeoutSeconds = typeof options.timeout === 'number' && Number.isFinite(options.timeout) ? Math.max(5, options.timeout) : 45;
733
+ await runSweetLinkSmoke({ sessionHint: options.session, routes, timeoutSeconds, resume: options.resume === true }, this);
734
+ });
735
+ program
736
+ .command('screenshot <sessionId>')
737
+ .description('Capture a JPEG screenshot from a SweetLink session')
738
+ .option('-s, --selector <selector>', 'CSS selector to capture (defaults to full page)')
739
+ .option('-q, --quality <0-1>', 'JPEG quality (0-1, default 0.92)', Number)
740
+ .option('-o, --output <path>', 'Output path (defaults to /tmp/sweetlink-<timestamp>.jpg)')
741
+ .option('-t, --timeout <ms>', 'Command timeout in milliseconds (default 30_000)', Number, 30_000)
742
+ .option('--devtools-url <url>', 'DevTools HTTP endpoint (default http://127.0.0.1:9222)')
743
+ .option('--method <method>', 'Capture method: auto, puppeteer, html2canvas, html-to-image', 'auto')
744
+ .option('--scroll-into-view', 'Scroll the target into view before capturing', false)
745
+ .option('--scroll-selector <selector>', 'Selector to scroll into view (defaults to capture selector)')
746
+ .option('--wait-for-selector <selector>', 'Wait for a selector to appear before capturing')
747
+ .option('--wait-visible', 'Require the wait selector to be visible', false)
748
+ .option('--wait-timeout <ms>', 'Timeout for --wait-for-selector (default 10_000)', Number, 10_000)
749
+ .option('--delay <ms>', 'Delay in milliseconds after hooks run (default 0)', Number)
750
+ .option('--before-script <codeOrPath>', 'Inline JS (or @path) to run before capture')
751
+ .option('--preset <name>', 'Hook preset compatibility alias (card-ready is applied automatically and no longer requires this flag)')
752
+ .option('--prompt <prompt>', 'Send the saved screenshot to Codex for analysis')
753
+ .addOption(program.createOption('--question <prompt>').hideHelp())
754
+ .action(async (sessionId, options, command) => {
755
+ const config = resolveConfig(command);
756
+ const resolvedSessionId = await resolveSessionIdFromHint(sessionId, config);
757
+ const token = await fetchCliToken(config);
758
+ const mode = options.selector ? 'element' : 'full';
759
+ const prompt = resolvePromptOption(options);
760
+ const suppressOutput = Boolean(prompt);
761
+ const logInfo = (...args) => {
762
+ if (!suppressOutput) {
763
+ console.log(...args);
764
+ }
765
+ };
766
+ const now = new Date();
767
+ const defaultOutput = path.join(os.tmpdir(), `sweetlink-${now.toISOString().replaceAll(':', '-').replaceAll('.', '-')}.jpg`);
768
+ const outputPath = options.output ? path.resolve(options.output) : defaultOutput;
769
+ const quality = typeof options.quality === 'number' && !Number.isNaN(options.quality) ? options.quality : 0.92;
770
+ const method = normalizeScreenshotMethod(options.method);
771
+ await mkdir(path.dirname(outputPath), { recursive: true }).catch(() => {
772
+ /* ignore directory creation failures; writeFile will surface if it truly fails */
773
+ });
774
+ const devtoolsConfig = await loadDevToolsConfig();
775
+ const devtoolsUrl = options.devtoolsUrl ?? devtoolsConfig?.devtoolsUrl ?? 'http://127.0.0.1:9222';
776
+ let sessionSummary;
777
+ try {
778
+ sessionSummary = await getSessionSummaryById(config, token, resolvedSessionId);
779
+ }
780
+ catch {
781
+ sessionSummary = undefined;
782
+ }
783
+ const wantsPuppeteer = method === 'puppeteer' || method === 'auto';
784
+ if (wantsPuppeteer && sessionSummary?.url) {
785
+ const devtoolsCaptureResult = await attemptDevToolsCapture({
786
+ devtoolsUrl,
787
+ outputPath,
788
+ sessionUrl: sessionSummary.url,
789
+ selector: options.selector,
790
+ quality,
791
+ mode,
792
+ });
793
+ if (devtoolsCaptureResult) {
794
+ logInfo(`Saved screenshot to ${outputPath} (${devtoolsCaptureResult.width}x${devtoolsCaptureResult.height}, ${devtoolsCaptureResult.sizeKb.toFixed(1)} KB, method: ${devtoolsCaptureResult.renderer}).`);
795
+ await maybeDescribeScreenshot(prompt, outputPath, { silent: suppressOutput, appLabel: config.appLabel });
796
+ return;
797
+ }
798
+ if (method === 'puppeteer') {
799
+ throw new Error('Failed to capture via Puppeteer. Ensure Chrome is running with a DevTools port (try `pnpm sweetlink open --controlled`).');
800
+ }
801
+ }
802
+ const rendererOverride = method === 'html2canvas' || method === 'html-to-image' ? method : undefined;
803
+ const beforeScriptCode = await resolveHookSnippet(options.beforeScript);
804
+ const commandOptions = readCommandOptions(command);
805
+ const presetCandidate = commandOptions.preset ?? options.preset;
806
+ const presetRaw = typeof presetCandidate === 'string' ? presetCandidate.trim() : '';
807
+ const presetName = presetRaw.toLowerCase();
808
+ if (presetName.length > 0) {
809
+ if (presetName === 'card-ready') {
810
+ logInfo('Preset "card-ready" is now the default hook stack; the flag is optional.');
811
+ }
812
+ else {
813
+ throw new Error(`Screenshot preset "${presetRaw}" is no longer supported. The built-in hooks cover the previous behaviour.`);
814
+ }
815
+ }
816
+ const hookOptions = {
817
+ selector: options.selector ?? null,
818
+ scrollIntoView: Boolean(options.scrollIntoView),
819
+ scrollSelector: options.scrollSelector,
820
+ waitSelector: options.waitForSelector,
821
+ waitVisible: options.waitVisible === undefined ? undefined : Boolean(options.waitVisible),
822
+ waitTimeout: typeof options.waitTimeout === 'number' && Number.isFinite(options.waitTimeout)
823
+ ? options.waitTimeout
824
+ : undefined,
825
+ delayMs: typeof options.delay === 'number' && Number.isFinite(options.delay) ? options.delay : undefined,
826
+ beforeScript: beforeScriptCode ?? undefined,
827
+ };
828
+ const hooks = buildScreenshotHooks(hookOptions);
829
+ if (hooks.length > 0) {
830
+ logInfo(`Applying ${hooks.length} pre-capture hook${hooks.length === 1 ? '' : 's'} before screenshot.`);
831
+ }
832
+ const payload = {
833
+ type: 'screenshot',
834
+ id: createSweetLinkCommandId(),
835
+ mode,
836
+ selector: options.selector,
837
+ quality,
838
+ timeoutMs: typeof options.timeout === 'number' && !Number.isNaN(options.timeout) ? options.timeout : 30_000,
839
+ renderer: rendererOverride,
840
+ hooks: hooks.length > 0 ? hooks : undefined,
841
+ };
842
+ const response = await fetchJson(`${config.daemonBaseUrl}/sessions/${encodeURIComponent(resolvedSessionId)}/command`, {
843
+ method: 'POST',
844
+ headers: {
845
+ Authorization: `Bearer ${token}`,
846
+ 'Content-Type': 'application/json',
847
+ },
848
+ body: JSON.stringify(payload),
849
+ });
850
+ const { result } = response;
851
+ if (!result.ok) {
852
+ if (rendererOverride) {
853
+ const fallbackOutcome = await tryHtmlToImageFallback({
854
+ config,
855
+ token,
856
+ sessionId: resolvedSessionId,
857
+ payload,
858
+ outputPath,
859
+ prompt,
860
+ suppressOutput,
861
+ rendererOverride,
862
+ failureReason: result.error,
863
+ });
864
+ if (fallbackOutcome.handled) {
865
+ return;
866
+ }
867
+ const fallbackError = fallbackOutcome.fallbackResult.ok ? null : fallbackOutcome.fallbackResult.error;
868
+ const recovered = await tryDevToolsRecovery({
869
+ sessionUrl: sessionSummary?.url,
870
+ devtoolsUrl,
871
+ selector: options.selector,
872
+ quality,
873
+ mode,
874
+ outputPath,
875
+ prompt,
876
+ suppressOutput,
877
+ logInfo,
878
+ appLabel: config.appLabel,
879
+ failureReason: fallbackError ?? result.error,
880
+ });
881
+ if (recovered) {
882
+ return;
883
+ }
884
+ renderCommandResult(fallbackOutcome.fallbackResult);
885
+ process.exitCode = 1;
886
+ return;
887
+ }
888
+ const recovered = await tryDevToolsRecovery({
889
+ sessionUrl: sessionSummary?.url,
890
+ devtoolsUrl,
891
+ selector: options.selector,
892
+ quality,
893
+ mode,
894
+ outputPath,
895
+ prompt,
896
+ suppressOutput,
897
+ logInfo,
898
+ appLabel: config.appLabel,
899
+ failureReason: result.error,
900
+ });
901
+ if (recovered) {
902
+ return;
903
+ }
904
+ renderCommandResult(result);
905
+ process.exitCode = 1;
906
+ return;
907
+ }
908
+ await persistScreenshotResult(outputPath, result, { silent: suppressOutput });
909
+ await maybeDescribeScreenshot(prompt, outputPath, { silent: suppressOutput, appLabel: config.appLabel });
910
+ });
911
+ program
912
+ .command('selectors <sessionId>')
913
+ .description('Discover candidate selectors within a SweetLink session')
914
+ .option('-l, --limit <count>', 'Maximum number of candidates to return (default 20)', Number)
915
+ .option('-m, --max <count>', 'Alias for --limit', Number)
916
+ .option('--scope <selector>', 'Restrict discovery to elements inside this selector')
917
+ .option('--include-hidden', 'Include off-screen or hidden elements', false)
918
+ .option('--json', 'Output JSON payload', false)
919
+ .action(async (sessionId, options, command) => {
920
+ const config = resolveConfig(command);
921
+ const resolvedSessionId = await resolveSessionIdFromHint(sessionId, config);
922
+ const token = await fetchCliToken(config);
923
+ const limit = (() => {
924
+ if (typeof options.max === 'number' && Number.isFinite(options.max)) {
925
+ return Math.max(1, Math.floor(options.max));
926
+ }
927
+ if (typeof options.limit === 'number' && Number.isFinite(options.limit)) {
928
+ return Math.max(1, Math.floor(options.limit));
929
+ }
930
+ return 20;
931
+ })();
932
+ const payload = {
933
+ type: 'discoverSelectors',
934
+ id: createSweetLinkCommandId(),
935
+ scopeSelector: options.scope ?? null,
936
+ limit,
937
+ includeHidden: Boolean(options.includeHidden),
938
+ };
939
+ const response = await fetchJson(`${config.daemonBaseUrl}/sessions/${encodeURIComponent(resolvedSessionId)}/command`, {
940
+ method: 'POST',
941
+ headers: {
942
+ Authorization: `Bearer ${token}`,
943
+ 'Content-Type': 'application/json',
944
+ },
945
+ body: JSON.stringify(payload),
946
+ });
947
+ const { result } = response;
948
+ if (!result.ok) {
949
+ renderCommandResult(result);
950
+ process.exitCode = 1;
951
+ return;
952
+ }
953
+ const raw = result.data;
954
+ let candidates = [];
955
+ if (Array.isArray(raw) && raw.every((candidate) => isSweetLinkSelectorCandidate(candidate))) {
956
+ candidates = [...raw];
957
+ }
958
+ else if (isSweetLinkSelectorDiscoveryResult(raw)) {
959
+ candidates = [...raw.candidates];
960
+ }
961
+ if (options.json) {
962
+ process.stdout.write(`${JSON.stringify({ candidates }, null, 2)}\n`);
963
+ return;
964
+ }
965
+ if (candidates.length === 0) {
966
+ console.log('No candidates were discovered. Ensure the target UI is mounted and try again.');
967
+ console.log('Tip: use --scope to limit discovery or --include-hidden to inspect collapsed panels.');
968
+ return;
969
+ }
970
+ console.log(`Discovered ${candidates.length} selector candidate${candidates.length === 1 ? '' : 's'} (limit ${limit}):\n`);
971
+ for (const candidate of candidates.slice(0, limit)) {
972
+ console.log(`• ${candidate.selector}`);
973
+ console.log(` Hook : ${candidate.hook} (score ${candidate.score})`);
974
+ console.log(` Visible : ${candidate.visible ? 'yes' : 'no'} (${candidate.size.width}x${candidate.size.height})`);
975
+ console.log(` Text : ${candidate.textSnippet}`);
976
+ if (candidate.dataTarget) {
977
+ console.log(` Target : data-sweetlink-target="${candidate.dataTarget}"`);
978
+ }
979
+ else if (candidate.id) {
980
+ console.log(` Target : id="${candidate.id}"`);
981
+ }
982
+ if (candidate.dataTestId) {
983
+ console.log(` Test ID : data-testid="${candidate.dataTestId}"`);
984
+ }
985
+ console.log(` Position : x=${candidate.position.left}, y=${candidate.position.top}`);
986
+ console.log(` Path : ${candidate.path}`);
987
+ console.log('');
988
+ }
989
+ });
990
+ const devtools = program.command('devtools').description('Inspect DevTools-enabled Chrome sessions');
991
+ devtools
992
+ .command('status')
993
+ .description('Show DevTools connection status and telemetry summary')
994
+ .option('--json', 'Output JSON payload', false)
995
+ .action(async (options, command) => {
996
+ await devtoolsStatus(options, command);
997
+ });
998
+ devtools
999
+ .command('tabs')
1000
+ .description('List open tabs in the controlled Chrome window')
1001
+ .option('--json', 'Output JSON payload', false)
1002
+ .action(async (options) => {
1003
+ await devtoolsTabs(options);
1004
+ });
1005
+ devtools
1006
+ .command('console')
1007
+ .description('Print buffered console events from the controlled Chrome window')
1008
+ .option('--tail <count>', 'Number of entries to show (default 50)', Number, 50)
1009
+ .option('--json', 'Output JSON payload', false)
1010
+ .action(async (options) => {
1011
+ await devtoolsShowConsole(options);
1012
+ });
1013
+ devtools
1014
+ .command('network')
1015
+ .description('Print buffered network requests from the controlled Chrome window')
1016
+ .option('--tail <count>', 'Number of entries to show (default 50)', Number, 50)
1017
+ .option('--json', 'Output JSON payload', false)
1018
+ .action(async (options) => {
1019
+ await devtoolsShowNetwork(options);
1020
+ });
1021
+ devtools
1022
+ .command('listen')
1023
+ .description('Attach to DevTools and buffer console/network telemetry locally')
1024
+ .option('--session <sessionId>', 'Associate telemetry with a SweetLink session id')
1025
+ .option('--reset', 'Clear existing telemetry buffers before listening', false)
1026
+ .option('--background', 'Run without interactive prompts (for automation)', false)
1027
+ .action(async (options, command) => {
1028
+ await devtoolsListen(options, command);
1029
+ });
1030
+ devtools
1031
+ .command('authorize')
1032
+ .description('Attempt to auto-click the OAuth authorize prompt in the controlled browser')
1033
+ .option('--url <url>', 'Override the candidate OAuth URL (defaults to the tracked session)')
1034
+ .action(async (options, command) => {
1035
+ await devtoolsAuthorize(options, command);
1036
+ });
1037
+ program.hook('preAction', () => {
1038
+ // Ensure commander does not swallow promise rejections so we can log helpful messages.
1039
+ process.on('unhandledRejection', (error) => {
1040
+ reportError(error);
1041
+ process.exitCode = 1;
1042
+ });
1043
+ });
1044
+ program.addHelpText('afterAll', `
1045
+ DevTools commands:
1046
+ devtools status Show endpoint reachability, viewport, and buffer sizes
1047
+ devtools tabs List open tabs in the controlled Chrome window
1048
+ devtools listen Attach to DevTools and stream console/network telemetry to disk
1049
+ devtools authorize Force an OAuth authorize click in the active controlled tab
1050
+ devtools console [options] Print buffered console events (use --tail / --json)
1051
+ devtools network [options] Print buffered network entries (use --tail / --json)
1052
+ `);
1053
+ program.exitOverride();
1054
+ try {
1055
+ if (!sweetLinkCliTestMode) {
1056
+ await program.parseAsync(process.argv);
1057
+ }
1058
+ }
1059
+ catch (error) {
1060
+ if (error instanceof CommanderError && error.exitCode === 0) {
1061
+ process.exitCode = 0;
1062
+ }
1063
+ else {
1064
+ reportError(error);
1065
+ process.exitCode = error instanceof CommanderError ? error.exitCode : 1;
1066
+ }
1067
+ }
1068
+ async function resolveHookSnippet(value) {
1069
+ if (!value) {
1070
+ return null;
1071
+ }
1072
+ const trimmed = value.trim();
1073
+ if (trimmed.length === 0) {
1074
+ return null;
1075
+ }
1076
+ if (trimmed.startsWith('@')) {
1077
+ const candidatePath = trimmed.slice(1).trim();
1078
+ if (!candidatePath) {
1079
+ throw new Error('Expected a file path after @ for --before-script.');
1080
+ }
1081
+ const absolute = path.isAbsolute(candidatePath) ? candidatePath : path.resolve(candidatePath);
1082
+ const hookContents = await readFile(absolute, 'utf8');
1083
+ return hookContents.toString();
1084
+ }
1085
+ return trimmed;
1086
+ }
1087
+ export function formatPathForDisplay(value) {
1088
+ return value.replace(os.homedir(), '~');
1089
+ }
1090
+ function reportError(error) {
1091
+ if (error instanceof CommanderError) {
1092
+ console.error(error.message);
1093
+ return;
1094
+ }
1095
+ if (error instanceof Error) {
1096
+ console.error(error.message);
1097
+ return;
1098
+ }
1099
+ console.error('Unexpected error', error);
1100
+ }
1101
+ function normalizeScreenshotMethod(input) {
1102
+ if (!input) {
1103
+ return 'auto';
1104
+ }
1105
+ const normalized = input.toLowerCase().trim();
1106
+ switch (normalized) {
1107
+ case 'auto': {
1108
+ return 'auto';
1109
+ }
1110
+ case 'puppeteer': {
1111
+ return 'puppeteer';
1112
+ }
1113
+ case 'html2canvas':
1114
+ case 'html-2-canvas': {
1115
+ return 'html2canvas';
1116
+ }
1117
+ case 'html-to-image':
1118
+ case 'htmltoimage':
1119
+ case 'dom-to-image':
1120
+ case 'domtoimage': {
1121
+ return 'html-to-image';
1122
+ }
1123
+ default: {
1124
+ throw new Error('Invalid screenshot method. Use auto, puppeteer, html2canvas, or html-to-image.');
1125
+ }
1126
+ }
1127
+ }
1128
+ async function resolveSmokePrerequisites(params, command) {
1129
+ const config = resolveConfig(command);
1130
+ const devtoolsConfig = await loadDevToolsConfig();
1131
+ if (!devtoolsConfig?.devtoolsUrl) {
1132
+ console.error('DevTools endpoint not found. Launch a controlled Chrome window with `pnpm sweetlink open --controlled` first.');
1133
+ process.exitCode = 1;
1134
+ return null;
1135
+ }
1136
+ const sessionId = params.sessionHint && params.sessionHint.trim().length > 0
1137
+ ? await resolveSessionIdFromHint(params.sessionHint, config)
1138
+ : (devtoolsConfig.sessionId ?? null);
1139
+ if (!sessionId) {
1140
+ console.error('Unable to determine a SweetLink session. Pass --session or rerun `pnpm sweetlink open --controlled`.');
1141
+ process.exitCode = 1;
1142
+ return null;
1143
+ }
1144
+ let token;
1145
+ try {
1146
+ token = await fetchCliToken(config);
1147
+ }
1148
+ catch (error) {
1149
+ console.error('Unable to fetch SweetLink CLI token:', extractEventMessage(error));
1150
+ process.exitCode = 1;
1151
+ return null;
1152
+ }
1153
+ const session = await getSessionSummaryById(config, token, sessionId);
1154
+ if (!session) {
1155
+ console.error(`SweetLink session ${sessionId} was not found. Reopen the controlled window and retry.`);
1156
+ process.exitCode = 1;
1157
+ return null;
1158
+ }
1159
+ const baseUrl = new URL(config.appBaseUrl);
1160
+ return {
1161
+ config,
1162
+ devtoolsUrl: devtoolsConfig.devtoolsUrl,
1163
+ token,
1164
+ sessionId,
1165
+ session,
1166
+ baseUrl,
1167
+ };
1168
+ }
1169
+ async function determineSmokeStartIndex(options) {
1170
+ if (!options.resume) {
1171
+ try {
1172
+ await clearSmokeProgress(options.baseOrigin, options.routes);
1173
+ }
1174
+ catch (error) {
1175
+ if (sweetLinkDebug) {
1176
+ console.warn('Failed to clear stored smoke progress before run:', error);
1177
+ }
1178
+ }
1179
+ return 0;
1180
+ }
1181
+ const savedIndex = await loadSmokeProgressIndex(options.baseOrigin, options.routes);
1182
+ if (savedIndex === null) {
1183
+ console.log('No prior smoke progress found. Starting from the beginning.');
1184
+ return 0;
1185
+ }
1186
+ if (savedIndex >= options.routes.length) {
1187
+ console.log('Previous smoke run completed every route. Starting from the beginning.');
1188
+ return 0;
1189
+ }
1190
+ if (savedIndex > 0) {
1191
+ const resumeRoute = options.routes[savedIndex];
1192
+ console.log(`Resuming smoke test from route #${savedIndex + 1} (${resumeRoute}). Run without --resume to start over.`);
1193
+ }
1194
+ return Math.max(savedIndex, 0);
1195
+ }
1196
+ async function executeSmokeRoute(context, state, route, routeIndex) {
1197
+ let { session } = state;
1198
+ let lastKnownUrl = state.lastKnownUrl;
1199
+ let activeSessionId = context.sessionId;
1200
+ const setActiveSessionId = (nextId) => {
1201
+ if (!nextId || nextId === activeSessionId) {
1202
+ return;
1203
+ }
1204
+ activeSessionId = nextId;
1205
+ context.sessionId = nextId;
1206
+ };
1207
+ if (route === undefined) {
1208
+ return {
1209
+ session,
1210
+ lastKnownUrl,
1211
+ failure: {
1212
+ route: `#${routeIndex + 1}`,
1213
+ reason: 'Missing route entry in smoke route list.',
1214
+ },
1215
+ };
1216
+ }
1217
+ const targetUrl = buildSmokeRouteUrl(context.baseUrl, route);
1218
+ const displayPath = `${targetUrl.pathname}${targetUrl.search || ''}`;
1219
+ console.log(`\n→ ${displayPath}`);
1220
+ const sessionConnected = await ensureSweetLinkSessionConnected({
1221
+ config: context.config,
1222
+ token: context.token,
1223
+ sessionId: activeSessionId,
1224
+ devtoolsUrl: context.devtoolsUrl,
1225
+ currentUrl: lastKnownUrl,
1226
+ timeoutMs: context.timeoutSeconds * 1000,
1227
+ onSessionIdChanged: setActiveSessionId,
1228
+ candidateUrls: [targetUrl.toString()],
1229
+ });
1230
+ if (!sessionConnected) {
1231
+ console.warn(' Unable to verify active SweetLink session before navigation.');
1232
+ return {
1233
+ session,
1234
+ lastKnownUrl,
1235
+ failure: { route: displayPath, reason: 'session unavailable before navigation' },
1236
+ };
1237
+ }
1238
+ const attemptNavigation = async () => {
1239
+ await navigateSweetLinkSession({ sessionId: activeSessionId, targetUrl, config: context.config });
1240
+ };
1241
+ try {
1242
+ await attemptNavigation();
1243
+ }
1244
+ catch (error) {
1245
+ const reason = extractEventMessage(error, 'navigation failed');
1246
+ if (SESSION_RECOVERY_PATTERN.test(reason)) {
1247
+ console.warn(' Session went offline during navigation command. Attempting recovery…');
1248
+ const recovered = await ensureSweetLinkSessionConnected({
1249
+ config: context.config,
1250
+ token: context.token,
1251
+ sessionId: activeSessionId,
1252
+ devtoolsUrl: context.devtoolsUrl,
1253
+ currentUrl: lastKnownUrl,
1254
+ timeoutMs: context.timeoutSeconds * 1000,
1255
+ onSessionIdChanged: setActiveSessionId,
1256
+ candidateUrls: [targetUrl.toString()],
1257
+ });
1258
+ if (recovered) {
1259
+ try {
1260
+ await attemptNavigation();
1261
+ }
1262
+ catch (retryError) {
1263
+ const retryReason = extractEventMessage(retryError, 'navigation failed');
1264
+ console.warn(` Navigation failed after recovery: ${retryReason}`);
1265
+ return { session, lastKnownUrl, failure: { route: displayPath, reason: retryReason } };
1266
+ }
1267
+ }
1268
+ else {
1269
+ console.warn(` Navigation failed: ${reason}`);
1270
+ return { session, lastKnownUrl, failure: { route: displayPath, reason } };
1271
+ }
1272
+ }
1273
+ else {
1274
+ console.warn(` Navigation failed: ${reason}`);
1275
+ return { session, lastKnownUrl, failure: { route: displayPath, reason } };
1276
+ }
1277
+ }
1278
+ let handshake = await waitForSweetLinkSession({
1279
+ config: context.config,
1280
+ token: context.token,
1281
+ targetUrl: targetUrl.toString(),
1282
+ timeoutSeconds: Math.max(5, context.timeoutSeconds),
1283
+ devtoolsUrl: context.devtoolsUrl,
1284
+ });
1285
+ if (!handshake && context.devtoolsUrl) {
1286
+ console.warn(' SweetLink session did not reconnect after navigation. Retrying bootstrap…');
1287
+ await triggerSweetLinkCliAuto(context.devtoolsUrl, targetUrl.toString());
1288
+ handshake = await waitForSweetLinkSession({
1289
+ config: context.config,
1290
+ token: context.token,
1291
+ targetUrl: targetUrl.toString(),
1292
+ timeoutSeconds: Math.max(5, Math.ceil(context.timeoutSeconds / 2)),
1293
+ devtoolsUrl: context.devtoolsUrl,
1294
+ });
1295
+ if (!handshake) {
1296
+ const recovered = await ensureSweetLinkSessionConnected({
1297
+ config: context.config,
1298
+ token: context.token,
1299
+ sessionId: activeSessionId,
1300
+ devtoolsUrl: context.devtoolsUrl,
1301
+ currentUrl: targetUrl.toString(),
1302
+ timeoutMs: Math.max(5000, Math.ceil(context.timeoutSeconds * 1000)),
1303
+ onSessionIdChanged: setActiveSessionId,
1304
+ candidateUrls: [targetUrl.toString()],
1305
+ });
1306
+ if (recovered) {
1307
+ const refreshed = await getSessionSummaryById(context.config, context.token, activeSessionId);
1308
+ const recoveredUrl = typeof refreshed?.url === 'string' && refreshed.url.length > 0 ? refreshed.url : targetUrl.toString();
1309
+ handshake = {
1310
+ sessionId: activeSessionId,
1311
+ url: recoveredUrl,
1312
+ };
1313
+ }
1314
+ }
1315
+ }
1316
+ if (!handshake) {
1317
+ console.warn(' SweetLink session did not come back online after navigation.');
1318
+ return {
1319
+ session,
1320
+ lastKnownUrl,
1321
+ failure: { route: displayPath, reason: 'session did not reconnect' },
1322
+ };
1323
+ }
1324
+ if (handshake.sessionId && handshake.sessionId !== activeSessionId) {
1325
+ setActiveSessionId(handshake.sessionId);
1326
+ }
1327
+ lastKnownUrl = handshake.url ?? targetUrl.toString();
1328
+ session = (await getSessionSummaryById(context.config, context.token, activeSessionId)) ?? session;
1329
+ const diagnostics = await waitForSmokeRouteReady({
1330
+ devtoolsUrl: context.devtoolsUrl,
1331
+ targetUrl,
1332
+ timeoutMs: context.timeoutSeconds * 1000,
1333
+ });
1334
+ if (!diagnostics) {
1335
+ console.warn(' Timed out waiting for the route to reach a stable state.');
1336
+ return { session, lastKnownUrl, failure: { route: displayPath, reason: 'timeout awaiting route readiness' } };
1337
+ }
1338
+ const finalHref = diagnostics.locationHref ?? 'unknown';
1339
+ if (!urlsRoughlyMatch(finalHref, targetUrl.toString())) {
1340
+ console.warn(` Expected ${targetUrl.toString()} but browser reported ${finalHref}.`);
1341
+ return {
1342
+ session,
1343
+ lastKnownUrl,
1344
+ failure: { route: displayPath, reason: 'unexpected location after navigation' },
1345
+ };
1346
+ }
1347
+ if (diagnosticsContainBlockingIssues(diagnostics)) {
1348
+ console.warn(' Blocking diagnostics detected while loading the route:');
1349
+ logBootstrapDiagnostics('Smoke', diagnostics);
1350
+ return {
1351
+ session,
1352
+ lastKnownUrl,
1353
+ failure: { route: displayPath, reason: 'runtime diagnostics reported blocking issues' },
1354
+ };
1355
+ }
1356
+ const consoleEvents = await fetchConsoleEvents(context.config, activeSessionId).catch(() => []);
1357
+ const newEvents = consoleEvents.filter((event) => !context.seenConsoleIds.has(event.id));
1358
+ for (const event of newEvents) {
1359
+ context.seenConsoleIds.add(event.id);
1360
+ }
1361
+ const authEvents = newEvents.filter((event) => consoleEventIndicatesAuthIssue(event));
1362
+ if (authEvents.length > 0) {
1363
+ console.warn(' Detected authentication failures in the console log:');
1364
+ for (const event of authEvents.slice(-5)) {
1365
+ console.warn(` ${formatConsoleEventSummary(event)}`);
1366
+ }
1367
+ return {
1368
+ session,
1369
+ lastKnownUrl,
1370
+ failure: { route: displayPath, reason: 'authentication failures detected in console output' },
1371
+ };
1372
+ }
1373
+ const runtimeErrorEvents = newEvents.filter((event) => consoleEventIndicatesRuntimeError(event));
1374
+ if (runtimeErrorEvents.length > 0) {
1375
+ console.warn(' Detected console errors after the route finished loading:');
1376
+ for (const event of runtimeErrorEvents.slice(-5)) {
1377
+ console.warn(` ${formatConsoleEventSummary(event)}`);
1378
+ }
1379
+ return {
1380
+ session,
1381
+ lastKnownUrl,
1382
+ failure: { route: displayPath, reason: 'console errors detected after load' },
1383
+ };
1384
+ }
1385
+ try {
1386
+ await saveSmokeProgressIndex(context.baseOrigin, context.routes, routeIndex + 1);
1387
+ }
1388
+ catch (error) {
1389
+ if (sweetLinkDebug) {
1390
+ console.warn('Failed to persist smoke progress after route completion:', error);
1391
+ }
1392
+ }
1393
+ console.log(' ✅ Route passed without authentication or runtime errors.');
1394
+ return {
1395
+ session,
1396
+ lastKnownUrl,
1397
+ failure: null,
1398
+ };
1399
+ }
1400
+ async function runSweetLinkSmoke(params, command) {
1401
+ const prerequisites = await resolveSmokePrerequisites(params, command);
1402
+ if (!prerequisites) {
1403
+ return;
1404
+ }
1405
+ const { config, devtoolsUrl, token, sessionId, session: initialSession, baseUrl } = prerequisites;
1406
+ const baseOrigin = baseUrl.origin;
1407
+ const startIndex = await determineSmokeStartIndex({
1408
+ resume: params.resume,
1409
+ routes: params.routes,
1410
+ baseOrigin,
1411
+ });
1412
+ await ensureBackgroundDevtoolsListener({ sessionId, quiet: true });
1413
+ const initialConsoleEvents = await fetchConsoleEvents(config, sessionId).catch(() => []);
1414
+ const seenConsoleIds = new Set(initialConsoleEvents.map((event) => event.id));
1415
+ const remainingRouteCount = params.routes.length - startIndex;
1416
+ console.log(`Running SweetLink smoke test across ${remainingRouteCount} route${remainingRouteCount === 1 ? '' : 's'} using session ${formatSessionHeadline(initialSession)}.`);
1417
+ if (startIndex > 0) {
1418
+ console.log(`Skipping ${startIndex} route${startIndex === 1 ? '' : 's'} that already passed in a previous run.`);
1419
+ }
1420
+ let lastKnownUrl = initialSession.url ?? baseUrl.toString();
1421
+ const context = {
1422
+ config,
1423
+ token,
1424
+ sessionId,
1425
+ devtoolsUrl,
1426
+ baseUrl,
1427
+ baseOrigin,
1428
+ routes: params.routes,
1429
+ timeoutSeconds: params.timeoutSeconds,
1430
+ seenConsoleIds,
1431
+ };
1432
+ let session = initialSession;
1433
+ const failures = [];
1434
+ for (let routeIndex = startIndex; routeIndex < params.routes.length; routeIndex += 1) {
1435
+ // biome-ignore lint/performance/noAwaitInLoops: smoke routes must execute sequentially to reuse evolving session state.
1436
+ const result = await executeSmokeRoute(context, { session, lastKnownUrl }, params.routes[routeIndex], routeIndex);
1437
+ session = result.session;
1438
+ lastKnownUrl = result.lastKnownUrl;
1439
+ if (result.failure) {
1440
+ failures.push(result.failure);
1441
+ }
1442
+ }
1443
+ if (failures.length > 0) {
1444
+ console.error(`\nSweetLink smoke test detected issues on ${failures.length} route${failures.length === 1 ? '' : 's'}:`);
1445
+ for (const failure of failures) {
1446
+ console.error(` - ${failure.route}: ${failure.reason}`);
1447
+ }
1448
+ console.error('Review the diagnostics above, fix the auth flow, and rerun `pnpm sweetlink smoke`.');
1449
+ if (process.exitCode === undefined || process.exitCode === 0) {
1450
+ process.exitCode = 1;
1451
+ }
1452
+ }
1453
+ else {
1454
+ try {
1455
+ await clearSmokeProgress(baseOrigin, params.routes);
1456
+ }
1457
+ catch (error) {
1458
+ if (sweetLinkDebug) {
1459
+ console.warn('Failed to clear smoke progress after successful run:', error);
1460
+ }
1461
+ }
1462
+ console.log('\nSweetLink smoke test passed for all routes.');
1463
+ }
1464
+ }
1465
+ function extractPathSegments(routePath) {
1466
+ const normalized = trimTrailingSlash(routePath);
1467
+ if (normalized === '/' || normalized.length === 0) {
1468
+ return [];
1469
+ }
1470
+ return normalized.replace(LEADING_SLASH_PATTERN, '').split('/');
1471
+ }
1472
+ function suffixSegmentsAllowed(segments) {
1473
+ if (segments.length === 0) {
1474
+ return true;
1475
+ }
1476
+ return segments.every((segment) => LOOSE_PATH_SUFFIXES.has(segment));
1477
+ }
1478
+ export const __sweetlinkCliTestHelpers = {
1479
+ collectChromeCookies,
1480
+ normalizePuppeteerCookie,
1481
+ buildCookieOrigins,
1482
+ prepareChromeLaunch,
1483
+ buildWaitCandidateUrls,
1484
+ deriveDevtoolsLinkInfo,
1485
+ buildClickScript,
1486
+ };
1487
+ /* biome-ignore lint/performance/noBarrelFile: CLI entrypoint intentionally re-exports runtime helpers for compatibility. */
1488
+ export { diagnosticsContainBlockingIssues, logBootstrapDiagnostics } from './runtime/devtools.js';
1489
+ export { buildClickScript, fetchConsoleEvents, fetchSessionSummaries, formatSessionHeadline, resolvePromptOption, resolveSessionIdFromHint, } from './runtime/session.js';
1490
+ function urlsRoughlyMatch(a, b) {
1491
+ const urlA = normalizeUrlForMatch(a);
1492
+ const urlB = normalizeUrlForMatch(b);
1493
+ if (!(urlA && urlB)) {
1494
+ return a === b;
1495
+ }
1496
+ if (urlA.origin !== urlB.origin) {
1497
+ return false;
1498
+ }
1499
+ const pathA = trimTrailingSlash(urlA.pathname);
1500
+ const pathB = trimTrailingSlash(urlB.pathname);
1501
+ if (pathA === pathB) {
1502
+ return true;
1503
+ }
1504
+ const segmentsA = extractPathSegments(pathA);
1505
+ const segmentsB = extractPathSegments(pathB);
1506
+ const minLength = Math.min(segmentsA.length, segmentsB.length);
1507
+ for (let index = 0; index < minLength; index += 1) {
1508
+ if (segmentsA[index] !== segmentsB[index]) {
1509
+ return false;
1510
+ }
1511
+ }
1512
+ const remainderA = segmentsA.slice(minLength);
1513
+ const remainderB = segmentsB.slice(minLength);
1514
+ return suffixSegmentsAllowed(remainderA) && suffixSegmentsAllowed(remainderB);
1515
+ }
1516
+ async function devtoolsAuthorize(options, command) {
1517
+ const devtoolsConfig = await loadDevToolsConfig();
1518
+ if (!devtoolsConfig?.devtoolsUrl) {
1519
+ console.log('No DevTools session detected. Launch Chrome with `pnpm sweetlink open --controlled` first.');
1520
+ return;
1521
+ }
1522
+ const cliConfig = resolveConfig(command);
1523
+ let sessionUrl = options.url?.trim() || undefined;
1524
+ if (!sessionUrl) {
1525
+ sessionUrl = devtoolsConfig.targetUrl ?? undefined;
1526
+ }
1527
+ if (!sessionUrl && devtoolsConfig.sessionId) {
1528
+ try {
1529
+ const token = await fetchCliToken(cliConfig);
1530
+ const summary = await getSessionSummaryById(cliConfig, token, devtoolsConfig.sessionId);
1531
+ sessionUrl = summary?.url ?? sessionUrl;
1532
+ }
1533
+ catch {
1534
+ /* ignore inability to fetch session summary */
1535
+ }
1536
+ }
1537
+ if (!sessionUrl) {
1538
+ try {
1539
+ const tabs = await fetchDevToolsTabs(devtoolsConfig.devtoolsUrl);
1540
+ const oauthTab = tabs.find((tab) => {
1541
+ if (!tab.url) {
1542
+ return false;
1543
+ }
1544
+ return OAUTH_URL_PATTERN.test(tab.url);
1545
+ });
1546
+ if (oauthTab?.url) {
1547
+ sessionUrl = oauthTab.url;
1548
+ }
1549
+ }
1550
+ catch (error) {
1551
+ if (sweetLinkDebug) {
1552
+ console.warn('Failed to inspect DevTools tabs for authorize command:', error);
1553
+ }
1554
+ }
1555
+ }
1556
+ if (!sessionUrl) {
1557
+ console.error('Unable to determine which tab contains the OAuth consent screen. Pass --url <authorizeUrl> to override.');
1558
+ if (process.exitCode === undefined) {
1559
+ process.exitCode = 1;
1560
+ }
1561
+ return;
1562
+ }
1563
+ try {
1564
+ const result = await attemptTwitterOauthAutoAccept({
1565
+ devtoolsUrl: devtoolsConfig.devtoolsUrl,
1566
+ sessionUrl,
1567
+ scriptPath: cliConfig.oauthScriptPath,
1568
+ });
1569
+ if (result.handled) {
1570
+ console.log(`Authorize prompt handled via ${result.action ?? 'click'}${result.clickedText ? ` (${result.clickedText})` : ''}.`);
1571
+ }
1572
+ else {
1573
+ const reason = result.reason ?? 'no authorize button detected';
1574
+ console.log(`Authorize prompt not handled automatically (${reason}).`);
1575
+ if (reason === 'requires-login') {
1576
+ console.log('Twitter login inputs detected. Complete login manually and rerun the command.');
1577
+ }
1578
+ }
1579
+ }
1580
+ catch (error) {
1581
+ console.error('Failed to trigger OAuth authorize automation:', extractEventMessage(error));
1582
+ if (process.exitCode === undefined) {
1583
+ process.exitCode = 1;
1584
+ }
1585
+ }
1586
+ }
1587
+ async function devtoolsStatus(options, command) {
1588
+ const config = await loadDevToolsConfig();
1589
+ if (!config) {
1590
+ console.log('No DevTools session detected. Launch Chrome with `pnpm sweetlink open --controlled` first.');
1591
+ return;
1592
+ }
1593
+ let reachable;
1594
+ let tabs = [];
1595
+ try {
1596
+ const response = await fetch(`${config.devtoolsUrl.replace(TRAILING_SLASH_PATTERN, '')}/json/version`, { method: 'GET' });
1597
+ if (response.ok) {
1598
+ reachable = true;
1599
+ tabs = await fetchDevToolsTabs(config.devtoolsUrl);
1600
+ }
1601
+ }
1602
+ catch {
1603
+ reachable = false;
1604
+ }
1605
+ const state = await loadDevToolsState();
1606
+ let matchedSessionId = state?.sessionId ?? config.sessionId;
1607
+ let matchedSessionTitle;
1608
+ try {
1609
+ const cliConfig = resolveConfig(command);
1610
+ if (cliConfig.adminApiKey) {
1611
+ const token = await fetchCliToken(cliConfig);
1612
+ const sessions = await fetchSessionSummaries(cliConfig, token);
1613
+ const match = findBestSessionMatch(sessions, config, matchedSessionId);
1614
+ if (match) {
1615
+ matchedSessionId = match.sessionId;
1616
+ matchedSessionTitle = match.title || undefined;
1617
+ }
1618
+ }
1619
+ }
1620
+ catch {
1621
+ // Skip session lookup when admin key is unavailable
1622
+ }
1623
+ const isReachable = reachable === true;
1624
+ const summary = {
1625
+ config,
1626
+ reachable: isReachable,
1627
+ tabs: tabs.map((tab) => ({ id: tab.id, title: tab.title, url: tab.url, type: tab.type })),
1628
+ telemetry: {
1629
+ consoleCount: state?.console.length ?? 0,
1630
+ networkCount: state?.network.length ?? 0,
1631
+ lastUpdated: state?.updatedAt ?? null,
1632
+ },
1633
+ sessionId: matchedSessionId ?? null,
1634
+ sessionTitle: matchedSessionTitle ?? null,
1635
+ };
1636
+ if (options.json) {
1637
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
1638
+ return;
1639
+ }
1640
+ const reachabilityLabel = isReachable ? 'reachable' : 'offline';
1641
+ console.log(`DevTools endpoint : ${config.devtoolsUrl} (${reachabilityLabel})`);
1642
+ console.log(`Last updated : ${new Date(config.updatedAt).toISOString()}`);
1643
+ if (config.viewport) {
1644
+ const dpr = config.viewport.deviceScaleFactor ?? 1;
1645
+ console.log(`Viewport : ${config.viewport.width}x${config.viewport.height} @${dpr}x`);
1646
+ }
1647
+ console.log(`Tabs : ${tabs.length}`);
1648
+ if (matchedSessionId) {
1649
+ console.log(`Linked session : ${matchedSessionId}${matchedSessionTitle ? ` (${matchedSessionTitle})` : ''}`);
1650
+ }
1651
+ console.log(`Console buffer : ${state?.console.length ?? 0} entries`);
1652
+ console.log(`Network buffer : ${state?.network.length ?? 0} entries`);
1653
+ if (state?.updatedAt) {
1654
+ console.log(`Telemetry updated : ${new Date(state.updatedAt).toISOString()}`);
1655
+ }
1656
+ }
1657
+ async function devtoolsTabs(options) {
1658
+ const config = await loadDevToolsConfig();
1659
+ if (!config) {
1660
+ console.log('No DevTools session detected. Launch Chrome with `pnpm sweetlink open --controlled` first.');
1661
+ return;
1662
+ }
1663
+ const tabs = await fetchDevToolsTabs(config.devtoolsUrl);
1664
+ if (options.json) {
1665
+ process.stdout.write(`${JSON.stringify(tabs, null, 2)}\n`);
1666
+ return;
1667
+ }
1668
+ if (tabs.length === 0) {
1669
+ console.log('No tabs reported by DevTools.');
1670
+ return;
1671
+ }
1672
+ for (const tab of tabs) {
1673
+ console.log(`• ${tab.title || '(untitled)'}`);
1674
+ console.log(` URL : ${tab.url}`);
1675
+ console.log(` ID : ${tab.id}`);
1676
+ if (tab.type) {
1677
+ console.log(` Type: ${tab.type}`);
1678
+ }
1679
+ console.log('');
1680
+ }
1681
+ }
1682
+ async function devtoolsShowConsole(options) {
1683
+ const state = await loadDevToolsState();
1684
+ if (!state || state.console.length === 0) {
1685
+ console.log('No console events recorded. Run `pnpm sweetlink devtools listen` to start capturing telemetry.');
1686
+ return;
1687
+ }
1688
+ const tail = Number.isFinite(options.tail) && options.tail > 0 ? Math.floor(options.tail) : 50;
1689
+ const entries = state.console.slice(-tail);
1690
+ if (options.json) {
1691
+ process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
1692
+ return;
1693
+ }
1694
+ for (const entry of entries) {
1695
+ const timestamp = new Date(entry.ts).toISOString();
1696
+ let location = '';
1697
+ if (entry.location?.url) {
1698
+ const lineSuffix = entry.location.lineNumber === undefined ? '' : `:${entry.location.lineNumber}`;
1699
+ const columnSuffix = entry.location.columnNumber === undefined ? '' : `:${entry.location.columnNumber}`;
1700
+ location = ` (${entry.location.url}${lineSuffix}${columnSuffix})`;
1701
+ }
1702
+ let argsSuffix = '';
1703
+ if (entry.args.length > 0) {
1704
+ const formattedArgs = [];
1705
+ for (const value of entry.args) {
1706
+ formattedArgs.push(formatConsoleArg(value));
1707
+ }
1708
+ argsSuffix = ` ${formattedArgs.join(' ')}`;
1709
+ }
1710
+ console.log(`[${timestamp}] ${entry.type}: ${entry.text}${argsSuffix}${location}`);
1711
+ }
1712
+ }
1713
+ async function devtoolsShowNetwork(options) {
1714
+ const state = await loadDevToolsState();
1715
+ if (!state || state.network.length === 0) {
1716
+ console.log('No network entries recorded. Run `pnpm sweetlink devtools listen` to start capturing telemetry.');
1717
+ return;
1718
+ }
1719
+ const tail = Number.isFinite(options.tail) && options.tail > 0 ? Math.floor(options.tail) : 50;
1720
+ const entries = state.network.slice(-tail);
1721
+ if (options.json) {
1722
+ process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
1723
+ return;
1724
+ }
1725
+ for (const entry of entries) {
1726
+ const timestamp = new Date(entry.ts).toISOString();
1727
+ const status = entry.status === undefined ? (entry.failureText ?? 'failed') : entry.status;
1728
+ const type = entry.resourceType ? ` [${entry.resourceType}]` : '';
1729
+ console.log(`[${timestamp}] ${entry.method} ${status} ${entry.url}${type}`);
1730
+ }
1731
+ }
1732
+ async function devtoolsListen(options, command) {
1733
+ const background = Boolean(options.background);
1734
+ const config = await loadDevToolsConfig();
1735
+ if (!config) {
1736
+ if (!background) {
1737
+ console.log('No DevTools session detected. Launch Chrome with `pnpm sweetlink open --controlled` first.');
1738
+ }
1739
+ return;
1740
+ }
1741
+ if (options.reset) {
1742
+ try {
1743
+ await rm(DEVTOOLS_STATE_PATH, { force: true });
1744
+ if (!background) {
1745
+ console.log('Cleared cached DevTools telemetry state.');
1746
+ }
1747
+ }
1748
+ catch (error) {
1749
+ if (!isErrnoException(error) || error.code !== 'ENOENT') {
1750
+ console.warn('Failed to reset DevTools state:', error);
1751
+ }
1752
+ }
1753
+ }
1754
+ let state = options.reset ? null : await loadDevToolsState();
1755
+ if (!state) {
1756
+ state = createEmptyDevToolsState(config.devtoolsUrl);
1757
+ }
1758
+ state.endpoint = config.devtoolsUrl;
1759
+ if (options.reset) {
1760
+ // Clearing telemetry is often all we need (e.g. CI smoke calls). Persist the fresh snapshot and
1761
+ // exit immediately so `pnpm sweetlink devtools listen --reset` doesn't block waiting for SIGINT.
1762
+ try {
1763
+ await saveDevToolsState(state);
1764
+ }
1765
+ catch (error) {
1766
+ console.warn('Failed to persist reset DevTools state:', error);
1767
+ }
1768
+ if (!background) {
1769
+ console.log('Reset complete. Re-run without --reset to resume live capture.');
1770
+ }
1771
+ return;
1772
+ }
1773
+ const devtoolsState = state;
1774
+ devtoolsState.endpoint = config.devtoolsUrl;
1775
+ let sessionId = options.session;
1776
+ if (sessionId == null) {
1777
+ sessionId = await resolveDevToolsSessionId(config, command);
1778
+ }
1779
+ if (sessionId) {
1780
+ devtoolsState.sessionId = sessionId;
1781
+ try {
1782
+ await saveDevToolsConfig({ devtoolsUrl: config.devtoolsUrl, sessionId });
1783
+ }
1784
+ catch (error) {
1785
+ logDebugError('Failed to persist DevTools session binding', error);
1786
+ }
1787
+ }
1788
+ if (!background) {
1789
+ console.log(`Connecting to DevTools at ${config.devtoolsUrl}…`);
1790
+ }
1791
+ let browser;
1792
+ let page;
1793
+ try {
1794
+ ({ browser, page } = await connectToDevTools(config));
1795
+ }
1796
+ catch (error) {
1797
+ console.error('Failed to connect to the DevTools endpoint:', extractEventMessage(error));
1798
+ console.error('Hint: ensure a controlled Chrome window is running via `pnpm sweetlink open --controlled`.');
1799
+ process.exitCode = 1;
1800
+ return;
1801
+ }
1802
+ const viewport = await page.evaluate(() => ({
1803
+ width: window.innerWidth,
1804
+ height: window.innerHeight,
1805
+ deviceScaleFactor: window.devicePixelRatio ?? 1,
1806
+ }));
1807
+ devtoolsState.viewport = viewport;
1808
+ try {
1809
+ await saveDevToolsConfig({ devtoolsUrl: config.devtoolsUrl, viewport });
1810
+ }
1811
+ catch (error) {
1812
+ logDebugError('Failed to persist DevTools viewport configuration', error);
1813
+ }
1814
+ const flush = async () => {
1815
+ await saveDevToolsState(devtoolsState);
1816
+ };
1817
+ const scheduleFlush = (() => {
1818
+ // Debounce persistence to avoid writing to disk for every console/network event.
1819
+ let timer = null;
1820
+ return () => {
1821
+ if (timer) {
1822
+ return;
1823
+ }
1824
+ timer = setTimeout(() => {
1825
+ timer = null;
1826
+ flush().catch((error) => {
1827
+ console.warn('Failed to persist DevTools telemetry:', error);
1828
+ });
1829
+ }, 300);
1830
+ };
1831
+ })();
1832
+ const attachListeners = (p) => {
1833
+ // Capture console and network events for the page and trim buffers as they grow.
1834
+ p.on('console', (message) => {
1835
+ const persistConsole = async () => {
1836
+ const entry = await serializeConsoleMessage(message);
1837
+ devtoolsState.console.push(entry);
1838
+ trimBuffer(devtoolsState.console, DEVTOOLS_CONSOLE_LIMIT);
1839
+ scheduleFlush();
1840
+ };
1841
+ persistConsole().catch((error) => {
1842
+ console.warn('Failed to serialize console message:', error);
1843
+ });
1844
+ });
1845
+ p.on('requestfinished', (request) => {
1846
+ const persistNetworkEntry = async () => {
1847
+ const response = await request.response();
1848
+ const entry = createNetworkEntryFromRequest(request, response?.status());
1849
+ devtoolsState.network.push(entry);
1850
+ trimBuffer(devtoolsState.network, DEVTOOLS_NETWORK_LIMIT);
1851
+ scheduleFlush();
1852
+ };
1853
+ persistNetworkEntry().catch((error) => {
1854
+ console.warn('Failed to serialize network request:', error);
1855
+ });
1856
+ });
1857
+ p.on('requestfailed', (request) => {
1858
+ const failure = request.failure();
1859
+ const entry = createNetworkEntryFromRequest(request, undefined, failure?.errorText ?? 'failed');
1860
+ devtoolsState.network.push(entry);
1861
+ trimBuffer(devtoolsState.network, DEVTOOLS_NETWORK_LIMIT);
1862
+ scheduleFlush();
1863
+ });
1864
+ };
1865
+ attachListeners(page);
1866
+ const context = page.context();
1867
+ context.on('page', (newPage) => {
1868
+ attachListeners(newPage);
1869
+ });
1870
+ if (!background) {
1871
+ console.log('Listening for console and network events… Press Ctrl+C to stop.');
1872
+ }
1873
+ const shutdown = async () => {
1874
+ try {
1875
+ await flush();
1876
+ }
1877
+ catch (error) {
1878
+ if (sweetLinkDebug) {
1879
+ console.warn('Failed to flush DevTools state during shutdown.', error);
1880
+ }
1881
+ }
1882
+ try {
1883
+ await browser.close();
1884
+ }
1885
+ catch (error) {
1886
+ if (sweetLinkDebug) {
1887
+ console.warn('Failed to close DevTools browser during shutdown.', error);
1888
+ }
1889
+ }
1890
+ process.exit(0);
1891
+ };
1892
+ process.once('SIGINT', () => {
1893
+ shutdown().catch((error) => {
1894
+ if (sweetLinkDebug) {
1895
+ console.warn('Graceful shutdown after SIGINT failed:', error);
1896
+ }
1897
+ });
1898
+ });
1899
+ process.once('SIGTERM', () => {
1900
+ shutdown().catch((error) => {
1901
+ if (sweetLinkDebug) {
1902
+ console.warn('Graceful shutdown after SIGTERM failed:', error);
1903
+ }
1904
+ });
1905
+ });
1906
+ await new Promise(() => {
1907
+ /* keep process alive */
1908
+ });
1909
+ }
1910
+ async function resolveDevToolsSessionId(config, command) {
1911
+ try {
1912
+ const cliConfig = resolveConfig(command);
1913
+ if (!cliConfig.adminApiKey) {
1914
+ return config.sessionId;
1915
+ }
1916
+ const token = await fetchCliToken(cliConfig);
1917
+ const sessions = await fetchSessionSummaries(cliConfig, token);
1918
+ const match = findBestSessionMatch(sessions, config, config.sessionId);
1919
+ return match?.sessionId ?? config.sessionId;
1920
+ }
1921
+ catch {
1922
+ return config.sessionId;
1923
+ }
1924
+ }
1925
+ function findBestSessionMatch(sessions, config, hint) {
1926
+ if (hint) {
1927
+ const existing = sessions.find((session) => session.sessionId === hint);
1928
+ if (existing) {
1929
+ return existing;
1930
+ }
1931
+ }
1932
+ const { targetUrl } = config;
1933
+ if (targetUrl) {
1934
+ const match = sessions.find((session) => urlsRoughlyMatch(session.url, targetUrl));
1935
+ if (match) {
1936
+ return match;
1937
+ }
1938
+ }
1939
+ return sessions.length > 0 ? sessions[0] : undefined;
1940
+ }
1941
+ //# sourceMappingURL=index.js.map