sourcemonkey 0.0.0-stage → 0.5.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 (367) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -2
  3. package/dist/bytes.js +37 -0
  4. package/dist/channel-placeholder.js +5 -0
  5. package/dist/collection-scan.js +17 -0
  6. package/dist/collections.html +353 -0
  7. package/dist/compare.html +358 -0
  8. package/dist/composed-source.js +205 -0
  9. package/dist/connect-prompt.html +47 -0
  10. package/dist/debug/icons.html +211 -0
  11. package/dist/debug/storage.html +163 -0
  12. package/dist/debug-log.js +75 -0
  13. package/dist/diagnostic-location.js +64 -0
  14. package/dist/eval-hook.js +308 -0
  15. package/dist/extension/background.js +5263 -0
  16. package/dist/extension/channel.js +133 -0
  17. package/dist/extension/code-links.js +187 -0
  18. package/dist/extension/code-tabs.js +460 -0
  19. package/dist/extension/collection-dialog.js +619 -0
  20. package/dist/extension/collection-display.js +322 -0
  21. package/dist/extension/collections.js +2905 -0
  22. package/dist/extension/compare.js +867 -0
  23. package/dist/extension/concurrency.js +186 -0
  24. package/dist/extension/connect-grants.js +109 -0
  25. package/dist/extension/connect-prompt-page.js +70 -0
  26. package/dist/extension/connect-prompt.js +266 -0
  27. package/dist/extension/copy-button.js +63 -0
  28. package/dist/extension/copy-dialog.js +502 -0
  29. package/dist/extension/copy-preference.js +66 -0
  30. package/dist/extension/debug/icons.js +203 -0
  31. package/dist/extension/debug/storage.js +365 -0
  32. package/dist/extension/deep-link.js +77 -0
  33. package/dist/extension/delete-dialog.js +432 -0
  34. package/dist/extension/delete-icon.js +40 -0
  35. package/dist/extension/details-tooltips.js +66 -0
  36. package/dist/extension/directories.js +679 -0
  37. package/dist/extension/export-backup.js +407 -0
  38. package/dist/extension/export-dialog.js +142 -0
  39. package/dist/extension/file-access.js +74 -0
  40. package/dist/extension/file-changes.js +346 -0
  41. package/dist/extension/frame-info.js +85 -0
  42. package/dist/extension/github-provider.js +336 -0
  43. package/dist/extension/glyph.js +84 -0
  44. package/dist/extension/gm-bindings.js +601 -0
  45. package/dist/extension/gm-download.js +419 -0
  46. package/dist/extension/gm-menu.js +228 -0
  47. package/dist/extension/gm-notification.js +141 -0
  48. package/dist/extension/gm-originator-map.js +23 -0
  49. package/dist/extension/gm-tab-data.js +121 -0
  50. package/dist/extension/gm-tab.js +160 -0
  51. package/dist/extension/gm-url-change.js +111 -0
  52. package/dist/extension/gm-url-scheme.js +27 -0
  53. package/dist/extension/gm-values.js +176 -0
  54. package/dist/extension/gm-window-control.js +104 -0
  55. package/dist/extension/gm-xhr-headers.js +333 -0
  56. package/dist/extension/gm-xhr.js +584 -0
  57. package/dist/extension/injector.js +3145 -0
  58. package/dist/extension/install-capture.js +277 -0
  59. package/dist/extension/line-diff.js +233 -0
  60. package/dist/extension/local-copy.js +560 -0
  61. package/dist/extension/local-delete.js +276 -0
  62. package/dist/extension/location-kinds.js +108 -0
  63. package/dist/extension/log-format.js +158 -0
  64. package/dist/extension/managed-changes.js +361 -0
  65. package/dist/extension/managed-items.js +57 -0
  66. package/dist/extension/match-index.js +375 -0
  67. package/dist/extension/missing-scripts.js +66 -0
  68. package/dist/extension/more-menu.js +109 -0
  69. package/dist/extension/move-dialog.js +197 -0
  70. package/dist/extension/offscreen.js +37 -0
  71. package/dist/extension/options.js +504 -0
  72. package/dist/extension/page-heading.js +170 -0
  73. package/dist/extension/page-links.js +199 -0
  74. package/dist/extension/parse-check-client.js +70 -0
  75. package/dist/extension/parse-check.js +161 -0
  76. package/dist/extension/parse-sandbox-host.js +172 -0
  77. package/dist/extension/parse-sandbox.js +175 -0
  78. package/dist/extension/popup-commands-open.js +41 -0
  79. package/dist/extension/popup-view.js +387 -0
  80. package/dist/extension/popup.js +703 -0
  81. package/dist/extension/prism-userscript.js +53 -0
  82. package/dist/extension/refresh-error.js +136 -0
  83. package/dist/extension/refresh-targets.js +160 -0
  84. package/dist/extension/related-versions.js +1144 -0
  85. package/dist/extension/resize-observer-loop.js +19 -0
  86. package/dist/extension/row-cache.js +387 -0
  87. package/dist/extension/script-blobs.js +413 -0
  88. package/dist/extension/script-details.js +658 -0
  89. package/dist/extension/script-enablement.js +285 -0
  90. package/dist/extension/script-identity.js +310 -0
  91. package/dist/extension/script-keys.js +54 -0
  92. package/dist/extension/script-log-id.js +155 -0
  93. package/dist/extension/script-log.js +290 -0
  94. package/dist/extension/script-rejection.js +104 -0
  95. package/dist/extension/script-sites.js +158 -0
  96. package/dist/extension/script-store.js +3507 -0
  97. package/dist/extension/script-updater.js +991 -0
  98. package/dist/extension/script-url-hash.js +61 -0
  99. package/dist/extension/select-all.js +37 -0
  100. package/dist/extension/select-open-guard.js +90 -0
  101. package/dist/extension/site-icon.js +1261 -0
  102. package/dist/extension/sm-dev-link.js +302 -0
  103. package/dist/extension/solo-click.js +58 -0
  104. package/dist/extension/sticky-numbers.js +188 -0
  105. package/dist/extension/storage-breakdown.js +253 -0
  106. package/dist/extension/storage-scan.js +86 -0
  107. package/dist/extension/storage-usage.js +194 -0
  108. package/dist/extension/structural-keys.js +29 -0
  109. package/dist/extension/sw-teardown.js +36 -0
  110. package/dist/extension/tab-status.js +403 -0
  111. package/dist/extension/toolbar-summary.js +105 -0
  112. package/dist/extension/user-scripts-access.js +43 -0
  113. package/dist/extension/userscript.js +3672 -0
  114. package/dist/extension/value-store.js +204 -0
  115. package/dist/extension/version-probe-fetch.js +68 -0
  116. package/dist/extension/worker-client.js +109 -0
  117. package/dist/extension/worker-ops.js +171 -0
  118. package/dist/extension/zip-writer.js +24 -0
  119. package/dist/external-files.js +878 -0
  120. package/dist/file-loader.js +436 -0
  121. package/dist/file-locations.js +439 -0
  122. package/dist/generated-wrappers.js +5 -0
  123. package/dist/github-auth.js +148 -0
  124. package/dist/github-urls.js +46 -0
  125. package/dist/gm-api-catalog.js +217 -0
  126. package/dist/gm-connect.js +273 -0
  127. package/dist/gm-xhr-types.js +5 -0
  128. package/dist/http-read.js +142 -0
  129. package/dist/icons/icon-128.png +0 -0
  130. package/dist/icons/icon-16.png +0 -0
  131. package/dist/icons/icon-48.png +0 -0
  132. package/dist/local-file-overrides.js +558 -0
  133. package/dist/manifest.json +38 -0
  134. package/dist/markup-response.js +126 -0
  135. package/dist/match-targets.js +305 -0
  136. package/dist/menu-captions.js +22 -0
  137. package/dist/offscreen.html +39 -0
  138. package/dist/options.html +197 -0
  139. package/dist/parse-check-protocol.js +51 -0
  140. package/dist/parse-location.js +171 -0
  141. package/dist/parse-sandbox.html +56 -0
  142. package/dist/phase-timing.js +155 -0
  143. package/dist/popup.html +197 -0
  144. package/dist/public-suffixes.js +6957 -0
  145. package/dist/run-context.js +31 -0
  146. package/dist/runtime-messages.js +34 -0
  147. package/dist/sandboxed-page.js +35 -0
  148. package/dist/script-build.js +787 -0
  149. package/dist/script-display.js +383 -0
  150. package/dist/script-icon.js +43 -0
  151. package/dist/script-links.js +204 -0
  152. package/dist/script-source-url.js +214 -0
  153. package/dist/script-world.js +65 -0
  154. package/dist/sm-dev-protocol.js +84 -0
  155. package/dist/source-map.js +177 -0
  156. package/dist/stack-link.js +138 -0
  157. package/dist/styles.css +825 -0
  158. package/dist/unsafe-window-use.js +229 -0
  159. package/dist/url-match.js +734 -0
  160. package/dist/userscript-header.js +248 -0
  161. package/dist/userscript.html +1165 -0
  162. package/dist/vendor/fflate.LICENSE +21 -0
  163. package/dist/vendor/fflate.js +2692 -0
  164. package/dist/vendor/prism/prism-line-numbers.css +1 -0
  165. package/dist/vendor/prism/prism-line-numbers.js +1 -0
  166. package/dist/vendor/prism/prism.css +1 -0
  167. package/dist/vendor/prism/prism.js +1946 -0
  168. package/dist/version-compare.js +141 -0
  169. package/dist/version-probe.js +120 -0
  170. package/lib/build-info.json +1 -0
  171. package/lib/bytes.d.ts +3 -0
  172. package/lib/bytes.js +37 -0
  173. package/lib/channel-placeholder.d.ts +1 -0
  174. package/lib/channel-placeholder.js +5 -0
  175. package/lib/collection-scan.d.ts +8 -0
  176. package/lib/collection-scan.js +17 -0
  177. package/lib/composed-source.d.ts +28 -0
  178. package/lib/composed-source.js +205 -0
  179. package/lib/debug-log.d.ts +9 -0
  180. package/lib/debug-log.js +75 -0
  181. package/lib/diagnostic-location.d.ts +8 -0
  182. package/lib/diagnostic-location.js +64 -0
  183. package/lib/eval-hook.d.ts +34 -0
  184. package/lib/eval-hook.js +308 -0
  185. package/lib/external-files.d.ts +111 -0
  186. package/lib/external-files.js +878 -0
  187. package/lib/file-loader.d.ts +48 -0
  188. package/lib/file-loader.js +436 -0
  189. package/lib/file-locations.d.ts +44 -0
  190. package/lib/file-locations.js +439 -0
  191. package/lib/generated-wrappers.d.ts +11 -0
  192. package/lib/generated-wrappers.js +5 -0
  193. package/lib/github-auth.d.ts +12 -0
  194. package/lib/github-auth.js +148 -0
  195. package/lib/github-urls.d.ts +1 -0
  196. package/lib/github-urls.js +46 -0
  197. package/lib/gm-api-catalog.d.ts +24 -0
  198. package/lib/gm-api-catalog.js +217 -0
  199. package/lib/gm-connect.d.ts +34 -0
  200. package/lib/gm-connect.js +273 -0
  201. package/lib/gm-xhr-types.d.ts +68 -0
  202. package/lib/gm-xhr-types.js +5 -0
  203. package/lib/harness/browser-extensions.d.ts +73 -0
  204. package/lib/harness/browser-extensions.js +285 -0
  205. package/lib/harness/browser-lang.d.ts +20 -0
  206. package/lib/harness/browser-lang.js +86 -0
  207. package/lib/harness/browser-launch.d.ts +14 -0
  208. package/lib/harness/browser-launch.js +184 -0
  209. package/lib/harness/checks.d.ts +110 -0
  210. package/lib/harness/checks.js +321 -0
  211. package/lib/harness/cli.d.ts +2 -0
  212. package/lib/harness/cli.js +2259 -0
  213. package/lib/harness/command-args.d.ts +91 -0
  214. package/lib/harness/command-args.js +369 -0
  215. package/lib/harness/command-lines.d.ts +80 -0
  216. package/lib/harness/command-lines.js +180 -0
  217. package/lib/harness/command-list.d.ts +206 -0
  218. package/lib/harness/command-list.js +1475 -0
  219. package/lib/harness/console-commands.d.ts +463 -0
  220. package/lib/harness/console-commands.js +3216 -0
  221. package/lib/harness/download-watch.d.ts +21 -0
  222. package/lib/harness/download-watch.js +156 -0
  223. package/lib/harness/extension-backend.d.ts +41 -0
  224. package/lib/harness/extension-backend.js +100 -0
  225. package/lib/harness/extension-host.d.ts +112 -0
  226. package/lib/harness/extension-host.js +552 -0
  227. package/lib/harness/extension-launch.d.ts +67 -0
  228. package/lib/harness/extension-launch.js +285 -0
  229. package/lib/harness/extension-link.d.ts +315 -0
  230. package/lib/harness/extension-link.js +607 -0
  231. package/lib/harness/external-manager.d.ts +146 -0
  232. package/lib/harness/external-manager.js +414 -0
  233. package/lib/harness/fixture.d.ts +165 -0
  234. package/lib/harness/fixture.js +461 -0
  235. package/lib/harness/frame-sessions.d.ts +71 -0
  236. package/lib/harness/frame-sessions.js +248 -0
  237. package/lib/harness/frames.d.ts +68 -0
  238. package/lib/harness/frames.js +216 -0
  239. package/lib/harness/help-format.d.ts +17 -0
  240. package/lib/harness/help-format.js +127 -0
  241. package/lib/harness/host.d.ts +352 -0
  242. package/lib/harness/host.js +423 -0
  243. package/lib/harness/index.d.ts +8 -0
  244. package/lib/harness/index.js +10 -0
  245. package/lib/harness/input-file.d.ts +18 -0
  246. package/lib/harness/input-file.js +61 -0
  247. package/lib/harness/json-output.d.ts +565 -0
  248. package/lib/harness/json-output.js +71 -0
  249. package/lib/harness/manager-actions.d.ts +21 -0
  250. package/lib/harness/manager-actions.js +129 -0
  251. package/lib/harness/manager-commands.d.ts +142 -0
  252. package/lib/harness/manager-commands.js +1196 -0
  253. package/lib/harness/manager-fetch.d.ts +33 -0
  254. package/lib/harness/manager-fetch.js +239 -0
  255. package/lib/harness/managers.d.ts +24 -0
  256. package/lib/harness/managers.js +27 -0
  257. package/lib/harness/output-style.d.ts +125 -0
  258. package/lib/harness/output-style.js +322 -0
  259. package/lib/harness/page-commands.d.ts +134 -0
  260. package/lib/harness/page-commands.js +1141 -0
  261. package/lib/harness/page-output.d.ts +67 -0
  262. package/lib/harness/page-output.js +139 -0
  263. package/lib/harness/page-watch.d.ts +171 -0
  264. package/lib/harness/page-watch.js +607 -0
  265. package/lib/harness/parse-check-node.d.ts +3 -0
  266. package/lib/harness/parse-check-node.js +132 -0
  267. package/lib/harness/path-args.d.ts +22 -0
  268. package/lib/harness/path-args.js +148 -0
  269. package/lib/harness/playwright-chromium.d.ts +16 -0
  270. package/lib/harness/playwright-chromium.js +86 -0
  271. package/lib/harness/prepare.d.ts +168 -0
  272. package/lib/harness/prepare.js +364 -0
  273. package/lib/harness/prompt-editor.d.ts +17 -0
  274. package/lib/harness/prompt-editor.js +354 -0
  275. package/lib/harness/prompt-history.d.ts +13 -0
  276. package/lib/harness/prompt-history.js +89 -0
  277. package/lib/harness/rebuild-advice.d.ts +6 -0
  278. package/lib/harness/rebuild-advice.js +39 -0
  279. package/lib/harness/run-log.d.ts +47 -0
  280. package/lib/harness/run-log.js +140 -0
  281. package/lib/harness/run-logs.d.ts +132 -0
  282. package/lib/harness/run-logs.js +241 -0
  283. package/lib/harness/run.d.ts +194 -0
  284. package/lib/harness/run.js +1973 -0
  285. package/lib/harness/runs.d.ts +229 -0
  286. package/lib/harness/runs.js +726 -0
  287. package/lib/harness/save-html.d.ts +39 -0
  288. package/lib/harness/save-html.js +89 -0
  289. package/lib/harness/saved-pages.d.ts +120 -0
  290. package/lib/harness/saved-pages.js +275 -0
  291. package/lib/harness/script-mark.d.ts +17 -0
  292. package/lib/harness/script-mark.js +56 -0
  293. package/lib/harness/snapshot.d.ts +143 -0
  294. package/lib/harness/snapshot.js +474 -0
  295. package/lib/harness/stuck-tabs.d.ts +19 -0
  296. package/lib/harness/stuck-tabs.js +131 -0
  297. package/lib/harness/tab-refs.d.ts +20 -0
  298. package/lib/harness/tab-refs.js +80 -0
  299. package/lib/harness/tampermonkey.d.ts +71 -0
  300. package/lib/harness/tampermonkey.js +356 -0
  301. package/lib/harness/tm-ids.d.ts +18 -0
  302. package/lib/harness/tm-ids.js +88 -0
  303. package/lib/harness/violentmonkey.d.ts +34 -0
  304. package/lib/harness/violentmonkey.js +162 -0
  305. package/lib/harness/wait.d.ts +77 -0
  306. package/lib/harness/wait.js +458 -0
  307. package/lib/http-read.d.ts +30 -0
  308. package/lib/http-read.js +142 -0
  309. package/lib/local-file-overrides.d.ts +35 -0
  310. package/lib/local-file-overrides.js +558 -0
  311. package/lib/manifest.json +38 -0
  312. package/lib/markup-response.d.ts +11 -0
  313. package/lib/markup-response.js +126 -0
  314. package/lib/match-targets.d.ts +18 -0
  315. package/lib/match-targets.js +305 -0
  316. package/lib/menu-captions.d.ts +1 -0
  317. package/lib/menu-captions.js +22 -0
  318. package/lib/parse-check-protocol.d.ts +27 -0
  319. package/lib/parse-check-protocol.js +51 -0
  320. package/lib/parse-location.d.ts +12 -0
  321. package/lib/parse-location.js +171 -0
  322. package/lib/phase-timing.d.ts +12 -0
  323. package/lib/phase-timing.js +155 -0
  324. package/lib/public-suffixes.d.ts +1 -0
  325. package/lib/public-suffixes.js +6957 -0
  326. package/lib/run-context.d.ts +2 -0
  327. package/lib/run-context.js +31 -0
  328. package/lib/runtime-messages.d.ts +10 -0
  329. package/lib/runtime-messages.js +34 -0
  330. package/lib/sandboxed-page.d.ts +5 -0
  331. package/lib/sandboxed-page.js +35 -0
  332. package/lib/script-build.d.ts +111 -0
  333. package/lib/script-build.js +787 -0
  334. package/lib/script-display.d.ts +51 -0
  335. package/lib/script-display.js +383 -0
  336. package/lib/script-icon.d.ts +16 -0
  337. package/lib/script-icon.js +43 -0
  338. package/lib/script-links.d.ts +24 -0
  339. package/lib/script-links.js +204 -0
  340. package/lib/script-source-url.d.ts +8 -0
  341. package/lib/script-source-url.js +214 -0
  342. package/lib/script-world.d.ts +6 -0
  343. package/lib/script-world.js +65 -0
  344. package/lib/sm-dev-protocol.d.ts +251 -0
  345. package/lib/sm-dev-protocol.js +84 -0
  346. package/lib/source-map.d.ts +32 -0
  347. package/lib/source-map.js +177 -0
  348. package/lib/stack-link.d.ts +15 -0
  349. package/lib/stack-link.js +138 -0
  350. package/lib/unsafe-window-use.d.ts +1 -0
  351. package/lib/unsafe-window-use.js +229 -0
  352. package/lib/url-match.d.ts +40 -0
  353. package/lib/url-match.js +734 -0
  354. package/lib/userscript-header.d.ts +33 -0
  355. package/lib/userscript-header.js +248 -0
  356. package/lib/version-compare.d.ts +6 -0
  357. package/lib/version-compare.js +141 -0
  358. package/lib/version-probe.d.ts +17 -0
  359. package/lib/version-probe.js +120 -0
  360. package/package.json +55 -3
  361. package/skills/SourceMonkey-dev/SKILL.md +868 -0
  362. package/skills/SourceMonkey-dev/references/cdp.md +293 -0
  363. package/skills/SourceMonkey-dev/references/commands.md +297 -0
  364. package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
  365. package/skills/SourceMonkey-dev/references/specs.md +46 -0
  366. package/skills/SourceMonkey-friction/SKILL.md +103 -0
  367. package/skills/SourceMonkey-friction/references/template.md +59 -0
@@ -0,0 +1,2259 @@
1
+ #!/usr/bin/env node
2
+ // `sm-dev`: argument parsing and dispatch for the development
3
+ // harness's command line. The usage text below describes each command,
4
+ // and describes it once; this file is the plumbing around it.
5
+ //
6
+ // Each command's work lives elsewhere:
7
+ // - `validate` and `match` call prepare.ts, here in this file.
8
+ // - `start` hands off to run.ts, which also opens the browser every
9
+ // browser command uses.
10
+ // - `list`, `stop`, and the commands sent to a run, reach running
11
+ // runs through runs.ts.
12
+ // - A run reaches the real SourceMonkey, which runs its scripts,
13
+ // through extension-link.ts.
14
+ //
15
+ // Each command loads only the modules it needs. `validate` and `match`
16
+ // never import Playwright, so they run in a repo that has not
17
+ // installed it.
18
+ //
19
+ // Exit codes: 0 when every script checked out (or every URL matched,
20
+ // no script threw, every selector was found), 1 when one didn't or the
21
+ // command failed, 2 for a usage error (a `UsageError`).
22
+ import { existsSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { StringDecoder } from 'node:string_decoder';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { CommandAssembler, NONE_RUN, echoCommand, parseCommandList, shellWord, unclosedBlockMessage } from './command-lines.js';
27
+ import { commandWords, managerCommandOf, managerCommand, onlyInspects, readPromptCommands, runCommandHelp, runCommandMistake, runLogLabelUi, setJsonOutput, writeOutputLine, } from './console-commands.js';
28
+ import { parseLang } from './browser-lang.js';
29
+ import { helpRequest, parseCommandArgs, splitWords } from './command-args.js';
30
+ import { DEFAULT_MANAGER, MANAGERS, managerMistake, managerNamed } from './managers.js';
31
+ import { COMMAND_NAMES, MAX_TIMER_SECONDS, OWN_COMMANDS, RUN_COMMAND_NAMES, SHARED_COMMANDS, TIMEOUT_FLAG, TIMEOUT_NEEDS_SECONDS, GLOBAL_FLAGS, VALUE_GLOBALS, commandHelp, commandNamed, commandSyntax, globalFlagMistake, helpTarget, helpText, timeoutSecondsMs, unsupportedCommand } from './command-list.js';
32
+ import { readJsonFile } from './input-file.js';
33
+ import { echoLine, err as errorLine, ERROR_PREFIX, paintOutput, quoted, SM_DEV_PREFIX, streamHasColor } from './output-style.js';
34
+ // The roles for marked-up output, as S.id(...): their names are common locals here.
35
+ import * as S from './output-style.js';
36
+ import { isJsonLine, jsonLayoutMistake, jsonResult, jsonResultData, STDERR_LINE, withLayout } from './json-output.js';
37
+ // Node built-ins only, so as cheap to load as the rest here.
38
+ import { runIdOf } from './runs.js';
39
+ import { parseLogSources } from './run-logs.js';
40
+ import { preparedScriptName, withDefaultScheme } from './prepare.js';
41
+ import { pagesToOpen } from './saved-pages.js';
42
+ import { REMOTE_SCRIPT, SCRIPT_ARG, UsageError, expandPathArgs, matchJson, matchLines, prepareSource, scriptFile, scriptSources, validateAll, validateSources, } from './checks.js';
43
+ // A line to stderr, painted for it as writeOutputLine paints stdout's.
44
+ function writeErr(line) {
45
+ process.stderr.write(paintOutput(line, streamHasColor(process.stderr)) + '\n');
46
+ }
47
+ // After a mistake, in place of the whole help, which would bury it.
48
+ const LIST_COMMANDS = 'Use `sm-dev help commands` to list the commands.';
49
+ // Whether help goes to a terminal that shows color (see streamHasColor).
50
+ function helpInColor() {
51
+ return streamHasColor(process.stdout);
52
+ }
53
+ // `sm-dev help [topic|command [subcommand]]`, or the mistake for words
54
+ // that name none.
55
+ function helpFor(words) {
56
+ const target = helpTarget(words);
57
+ if (target && 'error' in target)
58
+ throw new UsageError(target.error);
59
+ return helpText(target, helpInColor());
60
+ }
61
+ // `<command> <words> --help`: the command's help, or its subcommand's
62
+ // when the words name one, or the mistake for a name that is none.
63
+ function commandHelpFor(command, words = []) {
64
+ if (!commandNamed(command))
65
+ throw new UsageError(`${quoted(command)} is not a command. ${LIST_COMMANDS}`);
66
+ // With `--help` back on, so a flag the subcommand doesn't take
67
+ // doesn't stand in the way of its help.
68
+ const request = helpRequest(commandNamed(command), { words: [...words, '--help'] });
69
+ if ('error' in request)
70
+ throw new UsageError(request.error);
71
+ return commandHelp(request.command, request.subcommand, helpInColor());
72
+ }
73
+ // The commands' flags that take a value, for finding the command's
74
+ // name past the flags that may come before it: sm-dev's own (`--detach
75
+ // start`), and a run's, to say where one put first goes (`--find x logs`).
76
+ const OWN_VALUE_FLAGS = new Set([...OWN_COMMANDS, ...RUN_COMMAND_NAMES].flatMap((name) => Object.entries(commandSyntax(name).flags)
77
+ .filter(([, kind]) => kind === 'value').map(([flag]) => flag)));
78
+ // Every command's flags.
79
+ const COMMAND_FLAGS = new Set([...OWN_COMMANDS, ...RUN_COMMAND_NAMES].flatMap((name) => Object.keys(commandSyntax(name).flags)));
80
+ // Where the command's name is in `argv`: the first word that is no
81
+ // flag, nor a flag's value. Past the end when there is none.
82
+ function commandIndex(argv) {
83
+ let at = 0;
84
+ while (at < argv.length && argv[at].startsWith('-'))
85
+ at += OWN_VALUE_FLAGS.has(argv[at]) ? 2 : 1;
86
+ return at;
87
+ }
88
+ function parseArgs(argv) {
89
+ const opts = {
90
+ command: null, args: [],
91
+ all: false, noTab: false, watch: false, detach: false, newRun: false, noWait: false, fromSaved: false, offline: false,
92
+ };
93
+ const at = commandIndex(argv);
94
+ // None: said by the caller, as is a name that is no command of sm-dev's own.
95
+ if (at >= argv.length)
96
+ return opts;
97
+ opts.command = argv[at];
98
+ const rest = [...argv.slice(0, at), ...argv.slice(at + 1)];
99
+ if (!OWN_COMMANDS.has(opts.command)) {
100
+ opts.args = rest;
101
+ return opts;
102
+ }
103
+ const parsed = parseCommandArgs(opts.command, { words: rest });
104
+ if ('error' in parsed) {
105
+ const flag = parsed.flag;
106
+ // One for the whole line, which this command doesn't take itself.
107
+ if (flag && GLOBAL_FLAGS.includes(flag) && !Object.hasOwn(commandSyntax(opts.command).flags, flag))
108
+ throw new UsageError(globalFlagMistake(flag));
109
+ // Once a start flag; a start given no scripts is one now.
110
+ if (flag === '--noscript' && opts.command === 'start')
111
+ throw new UsageError('The start command no longer takes --noscript. Give it no scripts to start without any.');
112
+ throw new UsageError(parsed.error);
113
+ }
114
+ if (parsed.help) {
115
+ opts.help = true;
116
+ return opts;
117
+ }
118
+ opts.args = parsed.words;
119
+ // A single-dash word is no argument of these commands' (`-x`).
120
+ const dashed = opts.args.find((word) => /^-./.test(word));
121
+ if (dashed)
122
+ throw new UsageError(`Unknown option: ${quoted(dashed)}`);
123
+ for (const [flag, value] of Object.entries(parsed.flags)) {
124
+ // Each flag's syntax, value or none, is its help's (command-list.ts).
125
+ const text = typeof value === 'string' ? value : '';
126
+ switch (flag) {
127
+ case '--all':
128
+ opts.all = true;
129
+ break;
130
+ case '--notab':
131
+ opts.noTab = true;
132
+ break;
133
+ case '--browser':
134
+ opts.cdp = text;
135
+ break;
136
+ case '--watch':
137
+ opts.watch = true;
138
+ break;
139
+ case '--values':
140
+ case '--values-file': {
141
+ if (opts.valuesFlag)
142
+ throw new UsageError(`The ${quoted(flag)} flag can't be used with ${quoted(opts.valuesFlag)}`);
143
+ let given;
144
+ if (flag === '--values') {
145
+ try {
146
+ given = JSON.parse(text);
147
+ }
148
+ catch {
149
+ throw new UsageError('The --values flag needs a JSON object');
150
+ }
151
+ }
152
+ else {
153
+ try {
154
+ given = readJsonFile(path.resolve(text), text);
155
+ }
156
+ catch (err) {
157
+ throw new UsageError(err.message);
158
+ }
159
+ }
160
+ if (!given || typeof given !== 'object' || Array.isArray(given)) {
161
+ throw new UsageError(flag === '--values' ? 'The --values flag needs a JSON object' : `The file ${text} needs to hold a JSON object, e.g. {"units":"metric"}`);
162
+ }
163
+ opts.values = given;
164
+ opts.valuesFlag = flag;
165
+ break;
166
+ }
167
+ case '--log-source': {
168
+ const sources = parseLogSources(text);
169
+ if ('error' in sources)
170
+ throw new UsageError(sources.error);
171
+ opts.logSources = sources;
172
+ break;
173
+ }
174
+ case '--detach':
175
+ opts.detach = true;
176
+ break;
177
+ case '--new':
178
+ opts.newRun = true;
179
+ break;
180
+ case '--nowait':
181
+ opts.noWait = true;
182
+ break;
183
+ case '--timeout-seconds': {
184
+ const ms = timeoutSecondsMs(text);
185
+ if (ms === null)
186
+ throw new UsageError(TIMEOUT_NEEDS_SECONDS);
187
+ opts.timeoutMs = ms;
188
+ break;
189
+ }
190
+ case '--from-saved':
191
+ opts.fromSaved = true;
192
+ break;
193
+ case '--url':
194
+ opts.url = text;
195
+ break;
196
+ case '--offline':
197
+ opts.offline = true;
198
+ break;
199
+ case '--frames':
200
+ opts.frames = true;
201
+ break;
202
+ case '--json': {
203
+ opts.json = true;
204
+ if (!text)
205
+ break;
206
+ const mistake = jsonLayoutMistake(text);
207
+ if (mistake)
208
+ throw new UsageError(mistake);
209
+ opts.jsonLayout = text;
210
+ break;
211
+ }
212
+ case '--nojson':
213
+ opts.json = false;
214
+ break;
215
+ case '--lang':
216
+ try {
217
+ opts.lang = parseLang(text);
218
+ }
219
+ catch (err) {
220
+ throw new UsageError(err.message);
221
+ }
222
+ break;
223
+ case '--stop-after-idle-seconds': {
224
+ const v = Number(text);
225
+ if (!text || !Number.isFinite(v) || v < 0)
226
+ throw new UsageError('The --stop-after-idle-seconds flag needs a number of seconds');
227
+ if (v > MAX_TIMER_SECONDS)
228
+ throw new UsageError(`The --stop-after-idle-seconds flag can be at most ${MAX_TIMER_SECONDS}`);
229
+ opts.idleSeconds = v;
230
+ break;
231
+ }
232
+ case '--stop-after-seconds': {
233
+ const v = Number(text);
234
+ if (!text || !Number.isFinite(v) || v < 0)
235
+ throw new UsageError('The --stop-after-seconds flag needs a number');
236
+ if (v > MAX_TIMER_SECONDS)
237
+ throw new UsageError(`The --stop-after-seconds flag can be at most ${MAX_TIMER_SECONDS}`);
238
+ opts.seconds = v;
239
+ break;
240
+ }
241
+ }
242
+ }
243
+ return opts;
244
+ }
245
+ function checkValidate(opts) {
246
+ if (opts.args.length === 0)
247
+ throw new UsageError(VALIDATE_NEEDS);
248
+ }
249
+ async function validate(opts) {
250
+ checkValidate(opts);
251
+ return validateAll(await validateSources(opts.args), lineRoots, jsonPrint(opts), opts.json ?? lineJson);
252
+ }
253
+ // `start` and `match` arguments: scripts, and the URLs the rest are,
254
+ // after globs are expanded and directories searched for scripts.
255
+ // `pages`: the rest as typed, beside their URLs.
256
+ function scriptsAndUrls(words) {
257
+ const args = expandPathArgs(words, process.cwd(), { scripts: true });
258
+ // Each once, so a repeat is neither checked nor opened twice.
259
+ const pages = new Map();
260
+ for (const word of args.filter((a) => !SCRIPT_ARG.test(a))) {
261
+ // A script misnamed would otherwise be opened as a page, leaving
262
+ // the run with no scripts.
263
+ if (/\.js$/i.test(word) && !/^[a-z][a-z0-9+.-]*:/i.test(word))
264
+ throw new UsageError(`${quoted(word)} is not a userscript: Its name doesn't end in .user.js`);
265
+ const url = withDefaultScheme(word);
266
+ if (!pages.has(url))
267
+ pages.set(url, { word, url });
268
+ }
269
+ // Each once too, by the file it names, so `x.user.js ./x.user.js` is
270
+ // one script (the extension keeps a run's scripts by the path typed).
271
+ // The first spelling is kept.
272
+ const scripts = new Map();
273
+ for (const word of args.filter((a) => SCRIPT_ARG.test(a))) {
274
+ const file = path.resolve(word);
275
+ if (!scripts.has(file))
276
+ scripts.set(file, word);
277
+ }
278
+ return {
279
+ scripts: [...scripts.values()],
280
+ urls: [...pages.keys()],
281
+ pages: [...pages.values()],
282
+ };
283
+ }
284
+ // Every script against every URL, in the order given. Arguments can
285
+ // come in any order: a `.user.js` one (or a directory of them) is a
286
+ // script, the rest are URLs.
287
+ function checkMatch(opts) {
288
+ const { scripts, urls } = scriptsAndUrls(opts.args);
289
+ if (scripts.length === 0 || urls.length === 0) {
290
+ throw new UsageError(MATCH_NEEDS);
291
+ }
292
+ if (opts.frames)
293
+ throw new UsageError(MATCH_FRAMES_NEEDS_RUN);
294
+ }
295
+ async function match(opts) {
296
+ checkMatch(opts);
297
+ const { scripts, urls } = scriptsAndUrls(opts.args);
298
+ const sources = await scriptSources(scripts);
299
+ if (opts.json ?? lineJson) {
300
+ const results = [];
301
+ for (const source of sources) {
302
+ const prepared = await prepareSource(source, lineRoots);
303
+ results.push({ script: source.label, name: preparedScriptName(prepared), ...matchJson(prepared, urls.map((url) => ({ url }))) });
304
+ }
305
+ jsonPrint(opts)(jsonResult('match', { results }));
306
+ return results.every((r) => r.valid && r.pages.every((p) => p.matches)) ? 0 : 1;
307
+ }
308
+ let misses = 0;
309
+ for (const [i, source] of sources.entries()) {
310
+ // Headed only when there are several, so one script reads as before.
311
+ if (sources.length > 1)
312
+ print(`${i > 0 ? '\n' : ''}${source.label}`);
313
+ const report = matchLines(await prepareSource(source, lineRoots), urls.map((url) => ({ url })), sources.length > 1 ? undefined : source.label);
314
+ misses += report.misses;
315
+ for (const line of report.lines)
316
+ print(line);
317
+ }
318
+ return misses === 0 ? 0 : 1;
319
+ }
320
+ /**
321
+ * `start`: a run in a background process of its own, which it connects
322
+ * to (see Client). It replaces the run the client works with, under the
323
+ * same id; else, with `--detach`, the earlier detached runs, unless
324
+ * `--new`. `words` are the command's own, for that process.
325
+ */
326
+ async function startCommand(opts, words, client) {
327
+ const { urls, files, scripts, saved } = await checkStart(opts);
328
+ // The background process: the run itself.
329
+ if (detachedLog)
330
+ return runHere(opts, { files, given: scripts, urls, saved }, client.runId);
331
+ const { liveRuns, stopRun } = await import('./runs.js');
332
+ const live = await liveRuns();
333
+ // Stopped first, which also frees a tab for this one to reuse. The
334
+ // new one takes the id of the one it replaces.
335
+ const id = client.runId;
336
+ const replaced = id !== undefined ? live.filter((r) => r.id === id)
337
+ : opts.detach && !opts.newRun ? live.filter((r) => r.detached) : [];
338
+ await client.disconnect();
339
+ for (const old of replaced) {
340
+ // Named, stopRun's own line says enough.
341
+ if (id === undefined)
342
+ print(`[sm-dev] ${S.ui `Stopping the earlier detached run ${S.id(old.id)}`}`);
343
+ await stopRun(old, print, true);
344
+ client.stopped(old.id);
345
+ }
346
+ // The values as read here, not the file again: a pipe or `<(...)`
347
+ // is spent by now, and the process doesn't inherit it anyway.
348
+ const valuesAt = words.indexOf('--values-file');
349
+ const sent = valuesAt < 0 ? words : [...words.slice(0, valuesAt), '--values', JSON.stringify(opts.values), ...words.slice(valuesAt + 2)];
350
+ const started = await detachRun(sent, id);
351
+ if (!started.id)
352
+ return started.code;
353
+ client.started(started.id, started.log, started.exited, { detached: opts.detach, lasting: opts.seconds !== undefined, hold: started.hold });
354
+ if (opts.detach) {
355
+ const idle = opts.idleSeconds ?? DEFAULT_IDLE_SECONDS;
356
+ print(`[sm-dev] ${S.ui `Run ${S.id(started.id)} now running detached, with ${idle > 0 ? `idle timeout ${duration(idle)}` : 'no idle timeout'}`}`);
357
+ print(`[sm-dev] ${S.ui `Output log: ${runLogLabelUi(started.log, started.id)}`}`);
358
+ }
359
+ // Following its stream from here, when the line does more than look.
360
+ if (client.follow)
361
+ await client.connect();
362
+ return 0;
363
+ }
364
+ // `start`'s arguments and flags, checked: its URLs, and its script
365
+ // files, which must exist. Also done before a line of several commands
366
+ // runs any.
367
+ async function checkStart(opts) {
368
+ const { scripts, urls, pages } = scriptsAndUrls(opts.args);
369
+ if (opts.all && urls.length > 0)
370
+ throw new UsageError('The --all flag opens each script\'s own page, so it can\'t be used with a URL');
371
+ const remote = scripts.find((s) => REMOTE_SCRIPT.test(s));
372
+ if (remote)
373
+ throw new UsageError(`The start command loads scripts from files, and ${remote} is a URL. Save it to a file first.`);
374
+ // Flags that contradict another, or would silently do nothing with it.
375
+ const clash = (flag, others) => {
376
+ const used = others.filter(([on]) => on).map(([, name]) => name);
377
+ if (used.length > 0)
378
+ throw new UsageError(`The ${quoted(flag)} flag can't be used with ${used.map((f) => (f.startsWith('--') ? quoted(f) : f)).join(' or ')}`);
379
+ };
380
+ if (opts.noTab)
381
+ clash('--notab', [[opts.all, '--all'], [urls.length > 0, 'a URL']]);
382
+ // With no scripts, just a browser to drive: the flags that act on
383
+ // scripts would do nothing.
384
+ if (scripts.length === 0) {
385
+ const used = [
386
+ [opts.all, '--all'], [Boolean(opts.values), opts.valuesFlag ?? '--values'],
387
+ [opts.watch, '--watch'],
388
+ ].filter(([on]) => on).map(([, name]) => name);
389
+ if (used.length > 0)
390
+ throw new UsageError(`The start command was given no scripts, so it can't take ${used.join(' or ')}`);
391
+ }
392
+ if (lineManager !== undefined && lineManager !== 'sourcemonkey' && opts.values) {
393
+ throw new UsageError(`The ${quoted(opts.valuesFlag ?? '--values')} flag works with SourceMonkey only, and ${quoted('--manager')} names ${MANAGERS[lineManager].label}`);
394
+ }
395
+ if (tempBrowserLine() && (opts.cdp ?? lineCdp) !== undefined) {
396
+ // A temp browser's own port, which must be free.
397
+ const { launchEndpointMistake } = await import('./browser-launch.js');
398
+ const { cdpEndpoint } = await import('./run.js');
399
+ const mistake = launchEndpointMistake(cdpEndpoint((opts.cdp ?? lineCdp)), '--browser', 'A temp browser starts');
400
+ if (mistake)
401
+ throw new UsageError(mistake);
402
+ }
403
+ if (lineOpenBrowser && opts.cdp !== undefined)
404
+ throw new UsageError('The `start` command connects to the browser `--open-browser` starts, so give `--browser` once, first: `sm-dev --open-browser --browser ... start ...`');
405
+ if (!opts.detach && !detachedLog && opts.newRun)
406
+ throw new UsageError('The `--new` flag only applies with `--detach`');
407
+ const files = await Promise.all(scripts.map((s) => scriptFile(s)));
408
+ if (opts.fromSaved)
409
+ clash('--from-saved', [[opts.all, '--all'], [opts.noTab, '--notab']]);
410
+ // Read before the run starts, so a file that can't be opened is said now.
411
+ const opened = await pagesToOpen(pages, opts);
412
+ return { urls: opened.map((p) => p.url), files, scripts, saved: opened.map((p) => p.saved ?? null) };
413
+ }
414
+ // The run, in the background process `start` began: until a client
415
+ // sends `stop`, a signal, `--stop-after-seconds`, or its idle timeout. Given the
416
+ // scripts' files (and each as typed), and the pages to open with the
417
+ // saved page each serves, if any.
418
+ async function runHere(opts, { files, given, urls, saved }, runId) {
419
+ // The run has no input: commands come from its clients.
420
+ process.stdin.destroy();
421
+ const { run } = await import('./run.js');
422
+ // Ends on stop, a signal, or after --stop-after-seconds. The timer is
423
+ // unreferenced so it can't hold the process open after an early
424
+ // exit, and the listener comes off once the run has ended.
425
+ let onSigint = null;
426
+ let timer = null;
427
+ let stop = () => undefined;
428
+ const until = new Promise((resolve) => {
429
+ // Also drops the signal handlers, so while a stopped run tears
430
+ // down, a second signal kills it rather than landing here again.
431
+ stop = () => {
432
+ if (onSigint) {
433
+ process.off('SIGINT', onSigint);
434
+ process.off('SIGTERM', onSigint);
435
+ }
436
+ resolve();
437
+ };
438
+ // SIGTERM too, so a plain kill (or sm-dev stop, for a run it
439
+ // can't reach) still ends the run cleanly. Set once the run has
440
+ // connected: before that there is nothing to clean up, and a
441
+ // signal ends it at once, as it would any command (a wait for a
442
+ // stuck tab, say).
443
+ onSigint = () => stop();
444
+ onOutputClosed = stop;
445
+ if (opts.seconds !== undefined)
446
+ timer = setTimeout(resolve, opts.seconds * 1000).unref();
447
+ });
448
+ // The run ends itself once nothing has been sent it for a while, so
449
+ // one a client forgot (or a client that was killed) doesn't hold the
450
+ // browser for good. Counted from the start, and restarted as each
451
+ // command starts and ends; held off while a client follows the run.
452
+ const idleSeconds = opts.idleSeconds ?? DEFAULT_IDLE_SECONDS;
453
+ let idleTimer = null;
454
+ let watched = false;
455
+ const touch = (following = watched) => {
456
+ watched = following;
457
+ if (idleSeconds <= 0)
458
+ return;
459
+ if (idleTimer)
460
+ clearTimeout(idleTimer);
461
+ idleTimer = setTimeout(() => {
462
+ if (watched) {
463
+ touch();
464
+ return;
465
+ }
466
+ writeOutputLine(`[sm-dev] Stopping: no command was sent for ${duration(idleSeconds)}`);
467
+ stop();
468
+ }, idleSeconds * 1000).unref();
469
+ };
470
+ touch();
471
+ try {
472
+ return await run({
473
+ files,
474
+ given,
475
+ urls,
476
+ saved,
477
+ all: opts.all,
478
+ noTab: opts.noTab,
479
+ cdp: opts.cdp ?? lineCdp,
480
+ manager: lineManager,
481
+ tempBrowser: tempBrowserLine(),
482
+ headed: launchHeaded(),
483
+ muted: lineMuted,
484
+ lang: opts.lang,
485
+ watch: opts.watch,
486
+ values: opts.values,
487
+ logSources: opts.logSources,
488
+ collectionRoot: lineRoots.collectionRoot,
489
+ localRoots: lineRoots.localRoots,
490
+ out: writeOutputLine,
491
+ markup: true,
492
+ until,
493
+ onStop: () => stop(),
494
+ onActivity: touch,
495
+ detached: opts.detach,
496
+ log: detachedLog ?? undefined,
497
+ runId,
498
+ primaryPid,
499
+ noWait: opts.noWait,
500
+ timeoutMs: opts.timeoutMs,
501
+ // Tells the client that started it, which then goes on. One that
502
+ // has gone (killed, say) leaves the run going unannounced, rather
503
+ // than crashing it on the closed channel.
504
+ onReady: (id) => {
505
+ if (!process.connected)
506
+ return;
507
+ process.send?.({ ready: id ?? null }, (err) => {
508
+ if (!err && process.connected)
509
+ process.disconnect();
510
+ });
511
+ },
512
+ onConnected: () => {
513
+ process.once('SIGINT', onSigint);
514
+ process.once('SIGTERM', onSigint);
515
+ },
516
+ });
517
+ }
518
+ finally {
519
+ if (timer)
520
+ clearTimeout(timer);
521
+ if (idleTimer)
522
+ clearTimeout(idleTimer);
523
+ if (onSigint) {
524
+ process.off('SIGINT', onSigint);
525
+ process.off('SIGTERM', onSigint);
526
+ }
527
+ }
528
+ }
529
+ const DEFAULT_IDLE_SECONDS = 1800;
530
+ // Set in the background process `start --detach` starts: the file its
531
+ // output goes to. Taken out of the environment, so nothing the run
532
+ // starts takes itself for a detached run too.
533
+ const detachedLog = process.env.SM_DEV_DETACHED_LOG || null;
534
+ delete process.env.SM_DEV_DETACHED_LOG;
535
+ const primaryPid = Number(process.env.SM_DEV_PRIMARY_PID) || undefined;
536
+ delete process.env.SM_DEV_PRIMARY_PID;
537
+ if (detachedLog) {
538
+ const { timestampOutputLines } = await import('./run-log.js');
539
+ timestampOutputLines();
540
+ }
541
+ // `--stop-after-idle-seconds` as the messages say it: whole minutes as minutes.
542
+ function duration(seconds) {
543
+ const [n, unit] = seconds >= 60 && seconds % 60 === 0 ? [seconds / 60, 'minute'] : [seconds, 'second'];
544
+ return `${n} ${unit}${n === 1 ? '' : 's'}`;
545
+ }
546
+ /**
547
+ * Starts the run in a background process of its own, whose output goes
548
+ * to a log file (see run-log.ts). Its output is passed on here
549
+ * until it says it is ready, and then this returns, leaving it running.
550
+ * Its id, or none when it ended first (with its exit code) or can't be
551
+ * reached.
552
+ */
553
+ async function detachRun(words, runId) {
554
+ const { createRunLog, LOG_LINE_PREFIX } = await import('./run-log.js');
555
+ const { spawn } = await import('node:child_process');
556
+ const { open } = await import('node:fs/promises');
557
+ const { file: log, handle: writing } = await createRunLog();
558
+ const reading = await open(log, 'r');
559
+ const child = spawn(process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), ...globalWords(runId), ...words], {
560
+ // Its own process group, so a Ctrl-C or closed terminal here
561
+ // doesn't reach it once it is detached. No console window of its
562
+ // own on Windows.
563
+ detached: true,
564
+ windowsHide: true,
565
+ stdio: ['ignore', writing.fd, writing.fd, 'ipc'],
566
+ // This process, as the client that started the run, whose commands
567
+ // it marks as the primary's.
568
+ // Never color in its log, whatever FORCE_COLOR asks of this process.
569
+ env: { ...process.env, SM_DEV_DETACHED_LOG: log, SM_DEV_PRIMARY_PID: String(process.pid), FORCE_COLOR: '0' },
570
+ });
571
+ await writing.close();
572
+ let ready = null;
573
+ let exitCode = null;
574
+ child.on('message', (m) => { if (m && 'ready' in m)
575
+ ready = { id: m.ready ?? null }; });
576
+ // Its exit code, for a client that waits for it to end (--stop-after-seconds),
577
+ // which keeps this process alive meanwhile.
578
+ const exited = new Promise((resolve) => {
579
+ child.on('exit', (code) => {
580
+ exitCode = code ?? 1;
581
+ resolve(exitCode);
582
+ });
583
+ child.on('error', () => {
584
+ exitCode ??= 1;
585
+ resolve(exitCode);
586
+ });
587
+ });
588
+ // A Ctrl-C (or kill, or closed terminal) while it starts ends the
589
+ // run, as it would a foreground one. A run ended that way can exit 0
590
+ // having torn down cleanly, so that is kept apart from a success.
591
+ let interrupted = false;
592
+ const interrupt = () => {
593
+ interrupted = true;
594
+ child.kill('SIGTERM');
595
+ };
596
+ for (const signal of STOP_SIGNALS)
597
+ process.on(signal, interrupt);
598
+ const buffer = Buffer.alloc(64 * 1024);
599
+ const decoder = new StringDecoder('utf8');
600
+ let position = 0;
601
+ let partial = '';
602
+ // Whole lines, without the timestamps the file has; `end` passes on
603
+ // a last line with no newline too.
604
+ const passOn = async (end = false) => {
605
+ for (;;) {
606
+ const { bytesRead } = await reading.read(buffer, 0, buffer.length, position);
607
+ if (bytesRead === 0)
608
+ break;
609
+ position += bytesRead;
610
+ partial += decoder.write(buffer.subarray(0, bytesRead));
611
+ }
612
+ const lines = partial.split('\n');
613
+ const rest = lines.pop();
614
+ if (end && rest)
615
+ lines.push(rest);
616
+ partial = end ? '' : rest;
617
+ for (const line of lines)
618
+ print(line.replace(LOG_LINE_PREFIX, ''));
619
+ };
620
+ try {
621
+ while (ready === null && exitCode === null) {
622
+ await passOn();
623
+ await new Promise((resolve) => setTimeout(resolve, 100));
624
+ }
625
+ // Written before it said so.
626
+ await passOn(true);
627
+ }
628
+ finally {
629
+ // Closed first: the handlers come off only with nothing left to
630
+ // await, so no signal lands between them and the caller taking the
631
+ // run on as its own.
632
+ await reading.close();
633
+ for (const signal of STOP_SIGNALS)
634
+ process.off(signal, interrupt);
635
+ }
636
+ if (interrupted && ready === null)
637
+ return { code: 130, log, exited };
638
+ // Ended before it was ready, with nothing wrong: `--stop-after-seconds 0`, say.
639
+ if (ready === null)
640
+ return { code: exitCode || 1, log, exited };
641
+ const { id } = ready;
642
+ if (id === null) {
643
+ // It printed why; unreachable, it could only be killed.
644
+ child.kill('SIGTERM');
645
+ return { code: 1, log, exited };
646
+ }
647
+ child.unref();
648
+ return { code: 0, id, log, exited, hold: () => child.ref() };
649
+ }
650
+ const NO_RUNS = 'No sm-dev runs are active';
651
+ // What a closed stdout does: ends the command. A run swaps in its own
652
+ // stop, so it still tears down (and says so to any `sm-dev stop`).
653
+ let onOutputClosed = () => process.exit(process.exitCode ?? 0);
654
+ // Drawn around the prompt, when one is showing.
655
+ function print(line) {
656
+ writeOutputLine(line);
657
+ }
658
+ // A command echoed before its output (`arg > status`, a block's `...`
659
+ // lines): to stderr under --json, so stdout holds only the results.
660
+ function echo(line) {
661
+ print(lineJson ? STDERR_LINE + line : line);
662
+ }
663
+ // `print`, with a result laid out as the command's own `--json=` said.
664
+ function jsonPrint(opts) {
665
+ return (line) => print(opts.jsonLayout && isJsonLine(line) ? withLayout(line, opts.jsonLayout) : line);
666
+ }
667
+ async function listCommand(opts) {
668
+ const { liveRuns, runTable } = await import('./runs.js');
669
+ const runs = await liveRuns();
670
+ if (opts.json ?? lineJson) {
671
+ jsonPrint(opts)(jsonResult('list', {
672
+ runs: runs.map((r) => ({
673
+ id: r.id,
674
+ pid: r.pid,
675
+ started: new Date(r.started).toISOString(),
676
+ browser: { address: r.browser.replace(/^https?:\/\//, ''), owned: r.owned === true, headless: r.headless === true },
677
+ manager: r.manager ?? DEFAULT_MANAGER,
678
+ ...(r.tabs === undefined ? {} : { tabCount: r.tabs }),
679
+ scriptFiles: r.scripts,
680
+ })),
681
+ }));
682
+ return 0;
683
+ }
684
+ if (runs.length === 0)
685
+ print(NO_RUNS);
686
+ else
687
+ for (const line of runTable(runs, Date.now(), { markup: true }))
688
+ print(line);
689
+ return 0;
690
+ }
691
+ /**
692
+ * The sm-dev process as a client of runs. It works with one run at a
693
+ * time, and connects to it once a command needs it: the run `--run`
694
+ * names, else the one it started or attached to, else the only one
695
+ * going. `start` connects to the run it starts, in place of the one it
696
+ * had (which, under the same id, it replaces); `attach` switches; `stop`
697
+ * leaves it with none. A run it started without `--detach` is stopped
698
+ * when it exits.
699
+ */
700
+ class Client {
701
+ follow;
702
+ /** The run it works with, once there is one. */
703
+ runId;
704
+ /** Whether it started the run it works with, which then has the line's `--root` and `--local`. */
705
+ own = false;
706
+ /** The run `attach` attached it to, for `status` to say so. */
707
+ attachedTo;
708
+ /** Set by `exit`: the commands after it aren't run. */
709
+ exiting = false;
710
+ /** The log sources it shows of its run's stream (`attach --log-source`); the run's own when unset. */
711
+ logSources;
712
+ connection = null;
713
+ /** Runs it started without `--detach`, with their logs and exit codes. */
714
+ owned = new Map();
715
+ /** Set by a `start` with `--stop-after-seconds`, whose run ends on its own. */
716
+ lasting;
717
+ /**
718
+ * `follow`: connect for the run's whole stream, as decided for the
719
+ * line up front.
720
+ */
721
+ constructor(runId, follow) {
722
+ this.follow = follow;
723
+ this.runId = runId;
724
+ }
725
+ /**
726
+ * The connection to its run, made now if need be: `wanted`'s, else the
727
+ * one it works with, else the only one going. Works with it from then
728
+ * on; one that can't be reached leaves the client as it was.
729
+ */
730
+ async connect(wanted = this.runId) {
731
+ if (this.finishing)
732
+ throw new Error('This sm-dev is exiting');
733
+ if (this.connection?.open && this.connection.id === (wanted ?? this.connection.id))
734
+ return this.connection;
735
+ const target = await targetRun(wanted);
736
+ await checkRunBrowser(target);
737
+ checkRunManager(target);
738
+ const { RunClient } = await import('./runs.js');
739
+ const connection = await RunClient.open(target.id, print, { follow: this.follow, logSources: this.logSources, markup: true });
740
+ await this.disconnect();
741
+ this.connection = connection;
742
+ this.runId = target.id;
743
+ return connection;
744
+ }
745
+ /** Waits for its run to end on its own (or be stopped from elsewhere). */
746
+ async watch() {
747
+ await this.connection?.ended;
748
+ }
749
+ /** Sends one of a run's commands, from `source`; its exit code. */
750
+ async send(text, source, words) {
751
+ if (rootsGiven && !this.own)
752
+ throw new UsageError(ROOTS_ARE_THE_RUNS);
753
+ if (this.toEveryRun(text))
754
+ return statusWithoutRun(words ?? commandWords(text), source);
755
+ const connection = await this.connect();
756
+ if (commandWords(text)[0] !== 'status')
757
+ return connection.send(text, { words, source, json: lineJson });
758
+ // Headed by how this sm-dev came to the run.
759
+ const { liveRuns } = await import('./runs.js');
760
+ const detached = (await liveRuns()).find((r) => r.id === connection.id)?.detached === true;
761
+ const how = this.own ? 'started' : this.attachedTo === connection.id ? 'attached' : 'selected';
762
+ return connection.send(text, { words, source, json: lineJson, rewrite: statusHeading(how, detached) });
763
+ }
764
+ /**
765
+ * Whether a command needs no run of its own: `status`, with no run
766
+ * picked yet, which shows the browser and every run.
767
+ */
768
+ toEveryRun(text) {
769
+ return this.runId === undefined && commandWords(text)[0] === 'status';
770
+ }
771
+ /** Drops the connection, leaving the run going. */
772
+ async disconnect() {
773
+ const connection = this.connection;
774
+ this.connection = null;
775
+ await connection?.close();
776
+ }
777
+ /** The run it works with, when it is connected to one. */
778
+ get connected() {
779
+ return this.connection?.open ? this.connection.id : undefined;
780
+ }
781
+ /** A run it just started: stopped on exit unless `detached`. */
782
+ started(id, log, exited, { detached, lasting, hold }) {
783
+ this.runId = id;
784
+ this.own = true;
785
+ if (!detached)
786
+ this.owned.set(id, { log, exited, hold });
787
+ this.lasting = lasting && !detached ? id : undefined;
788
+ }
789
+ /**
790
+ * A run that has been stopped, and so is no longer one to work with.
791
+ * Its log, when this started it.
792
+ */
793
+ stopped(id) {
794
+ const log = this.owned.get(id)?.log;
795
+ this.owned.delete(id);
796
+ if (this.lasting === id)
797
+ this.lasting = undefined;
798
+ if (this.runId === id) {
799
+ this.runId = undefined;
800
+ this.own = false;
801
+ }
802
+ return log;
803
+ }
804
+ /**
805
+ * Waits for a run started with `--stop-after-seconds` to end on its own, as the
806
+ * line's last word: its exit code. 0 when there is none.
807
+ */
808
+ async outlast() {
809
+ // Kept, since stopped() can clear lasting while this waits.
810
+ const id = this.lasting;
811
+ const run = id === undefined ? undefined : this.owned.get(id);
812
+ if (!run)
813
+ return 0;
814
+ // Its stream, until it ends; waited for, so this process stays.
815
+ await this.connect().catch(() => undefined);
816
+ run.hold?.();
817
+ const code = await run.exited;
818
+ this.owned.delete(id);
819
+ await this.disconnect();
820
+ print(`[sm-dev] ${S.ui `Output log: ${runLogLabelUi(run.log, id)}`}`);
821
+ return code;
822
+ }
823
+ finishing = null;
824
+ /**
825
+ * Leaves: stops the runs it started without `--detach`. 1 when one
826
+ * wouldn't stop. Once, however often asked (a Ctrl-C, then the end of
827
+ * the line).
828
+ */
829
+ finish() {
830
+ this.finishing ??= this.stopOwned();
831
+ return this.finishing;
832
+ }
833
+ async stopOwned() {
834
+ this.exiting = true;
835
+ const connection = this.connection;
836
+ this.connection = null;
837
+ await connection?.close({ force: true });
838
+ const { liveRuns, stopRun } = await import('./runs.js');
839
+ let code = 0;
840
+ for (const [id, { log }] of this.owned) {
841
+ const record = (await liveRuns()).find((r) => r.id === id);
842
+ if (record && !await stopRun(record, print, true))
843
+ code = 1;
844
+ print(`[sm-dev] ${S.ui `Output log: ${runLogLabelUi(log, id)}`}`);
845
+ }
846
+ this.owned.clear();
847
+ return code;
848
+ }
849
+ }
850
+ const NO_RUN_TO_USE = `${NO_RUNS}. Start one with ${quoted('sm-dev start')}.`;
851
+ // The run a command acts on: the one named, else the only one going.
852
+ async function targetRun(wanted) {
853
+ const { liveRuns, runTable } = await import('./runs.js');
854
+ const runs = await liveRuns();
855
+ if (wanted !== undefined) {
856
+ const found = runs.find((r) => r.id === wanted);
857
+ if (!found)
858
+ throw new Error(`There is no run ${quoted(wanted)}. Use ${quoted('sm-dev list')} to see the runs.`);
859
+ return found;
860
+ }
861
+ if (runs.length === 0)
862
+ throw new Error(NO_RUN_TO_USE);
863
+ if (runs.length > 1)
864
+ throw new UsageError(['Multiple runs are active. Pick one with --run, or attach to one:', ...runTable(runs)].join('\n'));
865
+ return runs[0];
866
+ }
867
+ // A typed run ID as the run's id, or a usage error. A bare number is the
868
+ // likely slip, as with tabs, and gets a hint.
869
+ function checkedRunId(typed, flag = '--run ') {
870
+ const id = runIdOf(typed);
871
+ if (id)
872
+ return id;
873
+ throw new UsageError(/^\d+$/.test(typed)
874
+ ? `Runs are named D1, D2 and so on. Did you mean ${flag}D${typed}?`
875
+ : `The run ID ${typed} is not valid. Run IDs are D and then letters, digits, _ or -.`);
876
+ }
877
+ function isTypedCommand(name) {
878
+ const command = commandNamed(name);
879
+ return command !== null && RUN_COMMAND_NAMES.includes(command);
880
+ }
881
+ // Takes its arguments as typed, not through parseArgs: the command
882
+ // sent may have words of its own that look like flags (`set n -1`).
883
+ async function sendCommand(words, client) {
884
+ const { liveRuns } = await import('./runs.js');
885
+ await checkSend(words);
886
+ // A list of several lines goes to a run whole, as before.
887
+ const typed = words.length === 1 ? commandWords(words[0]) : words;
888
+ if (!sentList(words) && typed[0] === 'manager')
889
+ return managerVerbHere(typed, client);
890
+ if (!sentList(words) && await listsHere(typed, client))
891
+ return managerHere(typed);
892
+ // With no run to work with, what the command needs instead.
893
+ const needs = NEEDS_A_RUN[words[0]];
894
+ if (needs && client.runId === undefined && (await liveRuns()).length === 0)
895
+ throw new UsageError(needs);
896
+ const list = sentList(words);
897
+ if (list) {
898
+ // In order, each done before the next is sent, and each one's
899
+ // output headed by the command as it was written. Stops at one that
900
+ // failed (a `wait` that timed out), as a line of several does. The
901
+ // exit code is the last one's.
902
+ let code = 0;
903
+ for (const [i, command] of list.commands.entries()) {
904
+ for (const line of echoed('arg', command))
905
+ echo(line);
906
+ code = await client.send(command.text, 'arg');
907
+ if (stopsLine(code, commandWords(command.text))) {
908
+ if (i < list.commands.length - 1)
909
+ writeErr(LINE_STOPPED);
910
+ return code;
911
+ }
912
+ }
913
+ return code;
914
+ }
915
+ // The words go as the shell gave them, so a selector or text with
916
+ // spaces reaches a page command as one word.
917
+ return client.send(words.join(' '), 'arg', words);
918
+ }
919
+ /**
920
+ * `status` with no run picked: the browser the line names, if any, with
921
+ * its managers, then every active run's status, each headed `Other run:`.
922
+ * Under --json, one result holding them all, so its shape depends only
923
+ * on whether a run was picked. 1 when a run couldn't be read.
924
+ */
925
+ async function statusWithoutRun(words, source) {
926
+ const { liveRuns, RunClient } = await import('./runs.js');
927
+ // The command's own --json too, which the runs read.
928
+ const json = lineJson || words.some((w) => /^--json(=|$)/.test(w));
929
+ if (lineCdp !== undefined)
930
+ await needBrowserWithoutRun('status');
931
+ if (!json)
932
+ print(S.message('No current run. (Use `start` or `attach` to connect to one.)'));
933
+ // A temp browser comes with a run, so there's a browser to show only
934
+ // when the line names one.
935
+ const browser = lineOpenBrowser || lineExistingBrowser ? await browserStatus(json) : undefined;
936
+ const runs = await liveRuns();
937
+ if (!json) {
938
+ print('');
939
+ print(runs.length === 0 ? 'No other active runs' : S.ui `${S.heading('Other active runs')}:`);
940
+ }
941
+ const statuses = [];
942
+ // The command's own --json=, which the runs' results carry too, but
943
+ // there may be none.
944
+ let layout = words.map((w) => /^--json=(single|multi)$/.exec(w)?.[1]).find(Boolean);
945
+ let code = 0;
946
+ for (const run of runs) {
947
+ if (!json)
948
+ print('');
949
+ const out = (line) => {
950
+ const result = json ? jsonResultData(line) : null;
951
+ if (!result)
952
+ return print(line);
953
+ statuses.push(result.data);
954
+ layout ??= result.layout;
955
+ };
956
+ try {
957
+ const connection = await RunClient.open(run.id, out, { markup: true });
958
+ code = Math.max(code, await connection.send(words.join(' '), { words, source, json: lineJson, rewrite: statusHeading('other', run.detached === true) }));
959
+ await connection.close();
960
+ }
961
+ catch (err) {
962
+ // Ended since it was listed, say.
963
+ print(`[sm-dev] ${err.message}`);
964
+ code = 1;
965
+ }
966
+ }
967
+ if (json) {
968
+ const line = jsonResult('status', { ...(browser ? { browser } : {}), runs: statuses });
969
+ print(layout ? withLayout(line, layout) : line);
970
+ }
971
+ return code;
972
+ }
973
+ /**
974
+ * The `--existing-browser` or `--open-browser` browser, with its
975
+ * managers, for `status` with no run. Printed too unless `json`.
976
+ */
977
+ async function browserStatus(json) {
978
+ const { addressArg, browserArg, browserSetting } = await import('./run.js');
979
+ const { browserVersion, managersIn, managersText, otherManagersWarning } = await import('./browser-extensions.js');
980
+ const endpoint = browserSetting(lineCdp);
981
+ const address = endpoint.replace(/^https?:\/\//, '');
982
+ const chosen = lineManager ?? DEFAULT_MANAGER;
983
+ const version = await browserVersion(endpoint);
984
+ const managers = version ? await managersIn(endpoint) : null;
985
+ if (!json) {
986
+ print(version ? S.ui `${S.heading('Browser')}: ${address} (Chrome ${version.version}${version.headless ? ', headless' : ''})`
987
+ : S.ui `${S.heading('Browser')}: ${S.message(`None answering at ${address}. Start one with ${quoted(`sm-dev --open-browser${addressArg(endpoint)}`)}.`)}`);
988
+ if (managers) {
989
+ print(S.ui `${S.heading('Managers')}: ${managersText(managers, chosen, 'chosen')}`);
990
+ for (const line of otherManagersWarning(managers, chosen, browserArg(endpoint)))
991
+ print(line);
992
+ }
993
+ }
994
+ return {
995
+ address,
996
+ answering: version !== null,
997
+ ...(version ? { version: version.version, headless: version.headless } : {}),
998
+ manager: chosen,
999
+ ...(managers ? { managers: managers.map((m) => ({ manager: m.manager, name: m.name, version: m.version, id: m.id, enabled: m.enabled, userScripts: m.userScripts })) } : {}),
1000
+ };
1001
+ }
1002
+ /**
1003
+ * Heads a run's `status` by how this sm-dev came to it: its `Run:` line
1004
+ * becomes `Attached run:` and the like, and its JSON gets `connection`.
1005
+ */
1006
+ function statusHeading(how, detached) {
1007
+ const label = { started: 'Started run', attached: 'Attached run', selected: 'Selected run', other: 'Other run' }[how];
1008
+ const note = how === 'started' && detached ? ' (detached)' : '';
1009
+ // The line as a run sends it: plain from an older run, else marked up.
1010
+ const markedRun = `${S.MARKUP}<h>Run</h>: `;
1011
+ return (line) => {
1012
+ if (line.startsWith('Run: '))
1013
+ return `${label}${note}: ${line.slice('Run: '.length)}`;
1014
+ if (line.startsWith(markedRun))
1015
+ return S.ui `${S.heading(label)}${note}: ` + line.slice(markedRun.length);
1016
+ const result = jsonResultData(line);
1017
+ const run = result?.data.run;
1018
+ if (!result || !run)
1019
+ return line;
1020
+ const marked = jsonResult('status', { ...result.data, run: { ...run, connection: how, ...(detached ? { detached: true } : {}) } });
1021
+ return result.layout ? withLayout(marked, result.layout) : marked;
1022
+ };
1023
+ }
1024
+ const MATCH_FRAMES_NEEDS_RUN = 'The `--frames` flag checks the iframes of a run\'s tab, so `match` needs a run for it. Name a frame\'s URL to check it.';
1025
+ const MATCH_NEEDS = 'The `match` command needs a script (a .user.js file) and at least one URL, or a run to take them from';
1026
+ const VALIDATE_NEEDS = 'The `validate` command needs at least one path, or a run to take the script from';
1027
+ const NEEDS_A_RUN = {
1028
+ match: MATCH_NEEDS,
1029
+ validate: VALIDATE_NEEDS,
1030
+ // It would only pause, with nothing to show meanwhile.
1031
+ wait: 'The `wait` command needs a run to watch. Start or attach one first.',
1032
+ };
1033
+ // The words `match` and `validate` take in a run only: a loaded
1034
+ // script, a tab, or all of them.
1035
+ const SESSION_REF = {
1036
+ match: /^(all|all-scripts|all-tabs|\d+|[Tt]\d+(\.\d+)?)$/i,
1037
+ validate: /^(all|\d+|[Tt]\d+\.\d+)$/i,
1038
+ };
1039
+ /**
1040
+ * Whether a `match` or `validate` needs a run: it names a loaded script
1041
+ * or tab, or leaves out what the command line form has to be given.
1042
+ * Otherwise it runs here, run or no run. A word that is also a file or
1043
+ * directory here (`all`, `2024`) is taken for it.
1044
+ */
1045
+ function needsRun(words) {
1046
+ const [name, ...args] = words;
1047
+ if (args.some((a) => SESSION_REF[name].test(a) && !existsSync(a)))
1048
+ return true;
1049
+ if (name === 'validate')
1050
+ return args.length === 0;
1051
+ const { scripts, urls } = scriptsAndUrls(args);
1052
+ return scripts.length === 0 || urls.length === 0;
1053
+ }
1054
+ /**
1055
+ * A command sent to a run, checked: a command a run takes, with flags
1056
+ * it takes. Also done before a line of several commands runs any.
1057
+ */
1058
+ async function checkSend(words) {
1059
+ const list = sentList(words);
1060
+ // A command can come as one word (`sm-dev 'eval T1 x'`), split then
1061
+ // as the prompt would.
1062
+ const commands = list ? list.commands.map((c) => ({ line: c.text }))
1063
+ : words.length === 1 ? [{ line: words[0] }] : [{ words }];
1064
+ for (const command of commands) {
1065
+ const mistake = commandMistake(command);
1066
+ if (mistake)
1067
+ throw new UsageError(mistake);
1068
+ }
1069
+ }
1070
+ // What's wrong with a run command, before it's sent: as typed (a
1071
+ // line), or as the sender's words.
1072
+ function commandMistake(input) {
1073
+ // A global option typed where only the command line takes it: on a
1074
+ // line of stdin or the prompt, or after a command.
1075
+ const lineFlag = (word) => {
1076
+ const flag = word?.split('=')[0];
1077
+ return flag !== undefined && GLOBAL_FLAGS.includes(flag) ? flag : undefined;
1078
+ };
1079
+ const words = 'line' in input ? splitWords(input.line).map((w) => w.text) : input.words;
1080
+ let name = words[0] ?? '';
1081
+ if (name.startsWith('-')) {
1082
+ const flagMistake = leadingFlagMistake(words);
1083
+ if (flagMistake)
1084
+ return flagMistake;
1085
+ // Flags some command takes, before a word that is none.
1086
+ name = words[commandIndex(words)];
1087
+ }
1088
+ if (!isTypedCommand(name))
1089
+ return unknownRunCommand(name);
1090
+ const mistake = runCommandMistake(input);
1091
+ if (!mistake)
1092
+ return null;
1093
+ const flag = lineFlag(mistake.flag);
1094
+ return flag ? globalFlagMistake(flag) : mistake.error;
1095
+ }
1096
+ // The list in one argument of several lines, checked; null for a
1097
+ // single command.
1098
+ function sentList(words) {
1099
+ return words.length === 1 && words[0].includes('\n') ? checkedList(words[0]) : null;
1100
+ }
1101
+ function checkedList(text) {
1102
+ const list = parseCommandList(text);
1103
+ if ('error' in list)
1104
+ throw new UsageError(list.error);
1105
+ if (list.commands.length === 0)
1106
+ throw new UsageError(NO_COMMANDS);
1107
+ return list;
1108
+ }
1109
+ function unknownRunCommand(name) {
1110
+ return `${quoted(name)} is not a command a run takes. Use ${quoted('sm-dev help commands')} to list them.`;
1111
+ }
1112
+ /**
1113
+ * A command as this client echoes it, headed by where it came from
1114
+ * (`stdin > eval {{`, then `... ` lines); the run heads other clients'
1115
+ * commands with who sent them too (see commandMark in run.ts).
1116
+ */
1117
+ function echoed(source, command) {
1118
+ return echoCommand(command, `${source} >`);
1119
+ }
1120
+ const NO_COMMANDS = 'No commands given on either the command line or stdin';
1121
+ /**
1122
+ * A command read from stdin or typed at the prompt, as its words: one
1123
+ * or more commands, split at `--` as on the command line. A `{{`
1124
+ * block's several lines stay one. Each with the text to send a run.
1125
+ */
1126
+ function typedCommands(text) {
1127
+ if (text.includes('\n'))
1128
+ return [{ words: commandWords(text), text }];
1129
+ // The typed text of each, as typed: eval takes its quotes as they are.
1130
+ return text.split(/(?:^|\s)--(?=\s|$)/).map((part) => part.trim()).filter(Boolean)
1131
+ .map((part) => ({ words: commandWords(part), text: part }));
1132
+ }
1133
+ /** What is wrong with a command read from stdin or typed, before it runs; null when nothing. */
1134
+ async function typedMistake({ words, text }) {
1135
+ if (!runsHere(words))
1136
+ return commandMistake({ line: text });
1137
+ try {
1138
+ await checkCommand(words);
1139
+ return null;
1140
+ }
1141
+ catch (err) {
1142
+ return err.message;
1143
+ }
1144
+ }
1145
+ /**
1146
+ * Runs a command read from stdin or typed: its exit code. `echo` heads
1147
+ * its output: once the run it goes to is found, so a missing run is
1148
+ * said without a heading for a command that never ran.
1149
+ */
1150
+ async function runTyped(command, client, source, echo = () => undefined) {
1151
+ // `--help` on a run's command: its help, from here, with no run needed.
1152
+ const help = runCommandHelp({ line: command.text });
1153
+ if (help) {
1154
+ echo();
1155
+ if ('error' in help)
1156
+ return printMistake(new UsageError(help.error));
1157
+ print(commandHelp(help.command, help.subcommand, helpInColor()).trimEnd());
1158
+ return 0;
1159
+ }
1160
+ if (runsHere(command.words)) {
1161
+ echo();
1162
+ return runOne(command.words, client);
1163
+ }
1164
+ try {
1165
+ if (command.words[0] === 'manager') {
1166
+ echo();
1167
+ return await managerVerbHere(command.words, client);
1168
+ }
1169
+ if (await listsHere(command.words, client)) {
1170
+ echo();
1171
+ return await managerHere(command.words);
1172
+ }
1173
+ if (!client.toEveryRun(command.text))
1174
+ await client.connect();
1175
+ echo();
1176
+ // As typed, for the run to split as its prompt would.
1177
+ return await client.send(command.text, source);
1178
+ }
1179
+ catch (err) {
1180
+ return printMistake(err);
1181
+ }
1182
+ }
1183
+ // How long piped input can pause and still count as arriving at once,
1184
+ // for checking in full before any of it runs.
1185
+ const PIPED_BATCH_GAP_MS = 50;
1186
+ /**
1187
+ * `--stdin`, or no command and piped input: commands read from stdin,
1188
+ * one per line. A run's go to the run; sm-dev's own (`list`, `start`,
1189
+ * `match` on files) run here. Returns the last command's exit code (1
1190
+ * after a mistake), or null when stdin had no commands.
1191
+ *
1192
+ * - A file, or what a pipe delivers at once (a heredoc, `printf`), is
1193
+ * checked in full before any is run, so a typo on its fifth line
1194
+ * leaves the first four unrun.
1195
+ * - After that, each command runs once it is read.
1196
+ * - A mistake, or a command that fails, stops the rest, as on a line of
1197
+ * several. A `match` or `validate` miss is an answer, and doesn't.
1198
+ */
1199
+ async function stdinCommands(client) {
1200
+ const { createInterface } = await import('node:readline');
1201
+ const { fstatSync } = await import('node:fs');
1202
+ // A file ends; only a pipe needs the pause to tell a batch.
1203
+ const file = !process.stdin.isTTY && fstatSync(0).isFile();
1204
+ const lines = createInterface({ input: process.stdin, terminal: false, crlfDelay: Infinity });
1205
+ const queue = [];
1206
+ let ended = false;
1207
+ let wake = null;
1208
+ // readline holds back a final line with no newline until the input
1209
+ // ends, so a line half-written to a pipe is never taken early.
1210
+ lines.on('line', (line) => { queue.push(line); wake?.(); });
1211
+ lines.on('close', () => { ended = true; wake?.(); });
1212
+ const QUIET = Symbol('quiet');
1213
+ // The next line, null once the input ends, or QUIET when none came
1214
+ // within `ms`.
1215
+ const next = async (ms) => {
1216
+ while (queue.length === 0 && !ended) {
1217
+ let timer;
1218
+ const quiet = await new Promise((resolve) => {
1219
+ wake = () => resolve(false);
1220
+ if (ms !== undefined)
1221
+ timer = setTimeout(() => resolve(true), ms);
1222
+ });
1223
+ wake = null;
1224
+ clearTimeout(timer);
1225
+ if (quiet)
1226
+ return QUIET;
1227
+ }
1228
+ return queue.shift() ?? null;
1229
+ };
1230
+ const blocks = new CommandAssembler();
1231
+ let ran = 0;
1232
+ let code = 0;
1233
+ // Lines read, for naming the one a block mistake is on.
1234
+ let n = 0;
1235
+ // The commands in a line read, each run in turn, echoed first. False
1236
+ // when one stopped the rest.
1237
+ const run = async (commands, source, more) => {
1238
+ for (const [i, command] of commands.entries()) {
1239
+ const heading = commands.length > 1 ? [echoLine('stdin >', command.text)] : echoed('stdin', source);
1240
+ code = await runTyped(command, client, 'stdin', () => { for (const l of heading)
1241
+ echo(l); });
1242
+ ran++;
1243
+ if (client.exiting)
1244
+ return false;
1245
+ if (stopsLine(code, command.words)) {
1246
+ if (more || i < commands.length - 1 || !ended || queue.length > 0)
1247
+ writeErr(LINE_STOPPED);
1248
+ return false;
1249
+ }
1250
+ }
1251
+ return true;
1252
+ };
1253
+ try {
1254
+ const batch = [];
1255
+ const mistakes = [];
1256
+ let line = await next();
1257
+ while (typeof line === 'string') {
1258
+ n++;
1259
+ const got = blocks.push(line);
1260
+ if (got && 'error' in got)
1261
+ mistakes.push(`${ERROR_PREFIX}Line ${n}: ${got.error}`);
1262
+ else if (got) {
1263
+ const commands = typedCommands(got.command.text);
1264
+ let wrong = false;
1265
+ for (const command of commands) {
1266
+ const mistake = await typedMistake(command);
1267
+ if (!mistake)
1268
+ continue;
1269
+ if (!wrong)
1270
+ mistakes.push(...echoed('stdin', got.command));
1271
+ wrong = true;
1272
+ mistakes.push(`${ERROR_PREFIX}${mistake}`);
1273
+ }
1274
+ if (!wrong)
1275
+ batch.push({ commands, source: got.command });
1276
+ }
1277
+ line = await next(file ? undefined : PIPED_BATCH_GAP_MS);
1278
+ }
1279
+ if (line === null) {
1280
+ const unclosed = blocks.drop();
1281
+ if (unclosed !== null)
1282
+ mistakes.push(`${ERROR_PREFIX}${unclosedBlockMessage(unclosed)}`);
1283
+ }
1284
+ if (mistakes.length > 0) {
1285
+ for (const m of mistakes)
1286
+ writeErr(m);
1287
+ writeErr(NONE_RUN);
1288
+ return 2;
1289
+ }
1290
+ for (const [i, { commands, source }] of batch.entries()) {
1291
+ if (!await run(commands, source, i < batch.length - 1))
1292
+ return ran === 0 ? null : code;
1293
+ }
1294
+ for (let got = await next(); got !== null; got = await next()) {
1295
+ if (typeof got !== 'string')
1296
+ continue;
1297
+ n++;
1298
+ const assembled = blocks.push(got);
1299
+ if (!assembled)
1300
+ continue;
1301
+ if ('error' in assembled) {
1302
+ writeErr(`${ERROR_PREFIX}Line ${n}: ${assembled.error}`);
1303
+ writeErr(LINE_STOPPED);
1304
+ return 1;
1305
+ }
1306
+ const commands = typedCommands(assembled.command.text);
1307
+ for (const command of commands) {
1308
+ const mistake = await typedMistake(command);
1309
+ if (!mistake)
1310
+ continue;
1311
+ for (const l of echoed('stdin', assembled.command))
1312
+ writeErr(l);
1313
+ writeErr(`${ERROR_PREFIX}${mistake}`);
1314
+ writeErr(LINE_STOPPED);
1315
+ return 1;
1316
+ }
1317
+ if (!await run(commands, assembled.command, false))
1318
+ return code;
1319
+ }
1320
+ const unclosed = blocks.drop();
1321
+ if (unclosed !== null) {
1322
+ writeErr(`${ERROR_PREFIX}${unclosedBlockMessage(unclosed)}, so it was not run`);
1323
+ return 1;
1324
+ }
1325
+ }
1326
+ finally {
1327
+ lines.close();
1328
+ process.stdin.pause();
1329
+ }
1330
+ return ran === 0 ? null : code;
1331
+ }
1332
+ /**
1333
+ * `--interactive`: the `> ` prompt, until `exit`, Ctrl-D, or Ctrl-C on
1334
+ * an empty line. Each command runs in turn, as typed; one that fails
1335
+ * doesn't stop the next, since the person decides. The last one's exit
1336
+ * code.
1337
+ */
1338
+ function promptCommands(client) {
1339
+ return new Promise((resolve) => {
1340
+ let code = 0;
1341
+ let queue = Promise.resolve();
1342
+ let close = () => undefined;
1343
+ let done = false;
1344
+ const leave = (now = false) => {
1345
+ // A second Ctrl-C while a command runs: as a signal would.
1346
+ if (now)
1347
+ void client.finish().finally(() => process.exit(130));
1348
+ if (done)
1349
+ return;
1350
+ done = true;
1351
+ // After the command running now, if any.
1352
+ void queue.then(() => {
1353
+ close();
1354
+ resolve(code);
1355
+ });
1356
+ };
1357
+ close = readPromptCommands({ out: print, exit: leave }, (line, hooks) => {
1358
+ const turn = queue.then(async () => {
1359
+ hooks?.start?.();
1360
+ if (!line || done)
1361
+ return;
1362
+ const commands = typedCommands(line);
1363
+ // All checked before any runs, as on the command line.
1364
+ for (const command of commands) {
1365
+ const mistake = await typedMistake(command);
1366
+ if (mistake) {
1367
+ print(`${ERROR_PREFIX}${mistake}`);
1368
+ code = 2;
1369
+ return;
1370
+ }
1371
+ }
1372
+ for (const command of commands) {
1373
+ code = await runTyped(command, client, 'prompt');
1374
+ if (client.exiting) {
1375
+ leave();
1376
+ return;
1377
+ }
1378
+ if (stopsLine(code, command.words))
1379
+ return;
1380
+ }
1381
+ });
1382
+ queue = turn.catch(() => undefined);
1383
+ return turn;
1384
+ });
1385
+ });
1386
+ }
1387
+ /**
1388
+ * Whether a `collection` or `script` command for what's installed runs
1389
+ * here rather than in a run: when no run is picked and none is going, or
1390
+ * the line names a browser no run is in (see `noRunToUse`). They need
1391
+ * SourceMonkey, not a run.
1392
+ */
1393
+ async function listsHere(words, client) {
1394
+ return managerCommandOf(words) !== null && await noRunToUse(client);
1395
+ }
1396
+ // Whether a command for the browser itself, rather than a run's tabs,
1397
+ // has no run to go to: no run is picked and none is going, or the line
1398
+ // names a browser no run is in (`--existing-browser`, `--open-browser`
1399
+ // or `--browser`).
1400
+ async function noRunToUse(client) {
1401
+ if (client.runId !== undefined)
1402
+ return false;
1403
+ const { liveRuns } = await import('./runs.js');
1404
+ const runs = await liveRuns();
1405
+ if (runs.length === 0)
1406
+ return true;
1407
+ const { browserSetting, sameBrowser } = await import('./run.js');
1408
+ const named = tempBrowserLine() ? lineCdp : browserSetting(lineCdp);
1409
+ if (named === undefined)
1410
+ return false;
1411
+ return !runs.some((run) => sameBrowser(run.browser, named));
1412
+ }
1413
+ // Throws for a command that acts on a browser outside a run, when the
1414
+ // line names none: a temp browser comes only with a run. `--browser`
1415
+ // alone is a temp browser's port, so it's refused too rather than
1416
+ // taken as the browser to connect to.
1417
+ async function needBrowserWithoutRun(command) {
1418
+ if (!tempBrowserLine())
1419
+ return;
1420
+ const { liveRuns } = await import('./runs.js');
1421
+ const { browserSetting } = await import('./run.js');
1422
+ const address = browserSetting(lineCdp).replace(/^https?:\/\//, '');
1423
+ const existing = quoted('--existing-browser');
1424
+ if (lineCdp !== undefined) {
1425
+ throw new UsageError(`The ${quoted(command)} command connects to a browser, and ${quoted('--browser')} alone is the port for a temp browser. Use ${existing} to connect to the browser at ${address}.`);
1426
+ }
1427
+ if ((await liveRuns()).length > 0) {
1428
+ throw new UsageError(`The ${quoted(command)} command needs a browser. Pick a run with ${quoted('--run')}, or use ${existing} for the browser at ${address}.`);
1429
+ }
1430
+ throw new UsageError(`The ${quoted(command)} command needs a browser, and no run is going. Use ${existing} for the browser at ${address}, or ${quoted('--open-browser')} to start one there.`);
1431
+ }
1432
+ /**
1433
+ * A `manager` command, which sm-dev does itself: in the browser of the
1434
+ * run picked (by --run, start or attach), else the --existing-browser
1435
+ * or --open-browser one, as `status` shows it with no run. Never an
1436
+ * active run that isn't picked. `fetch` needs no browser.
1437
+ */
1438
+ async function managerVerbHere(words, client) {
1439
+ const parsed = parseCommandArgs('manager', { words: words.slice(1) });
1440
+ if ('error' in parsed)
1441
+ throw new UsageError(parsed.error);
1442
+ if (parsed.help) {
1443
+ print(commandHelpFor('manager', words.slice(1).filter((w) => !HELP_FLAGS.has(w))).trimEnd());
1444
+ return 0;
1445
+ }
1446
+ const verb = parsed.form;
1447
+ if (verb === undefined) {
1448
+ throw new UsageError(parsed.words.length > 0 ? `The ${quoted('manager')} command has no ${quoted(parsed.words[0])} verb. It takes ${quoted('fetch')}, ${quoted('install')}, ${quoted('enable')} or ${quoted('disable')}.`
1449
+ : 'The `manager` command needs a verb: `fetch`, `install`, `enable` or `disable`');
1450
+ }
1451
+ const { liveRuns } = await import('./runs.js');
1452
+ const { browserArg, browserSetting, sameBrowser } = await import('./run.js');
1453
+ // The run picked, if any.
1454
+ const picked = client.runId ?? globalRun;
1455
+ const run = picked ? await targetRun(picked) : null;
1456
+ // Not checkRunManager: these act on the browser's extensions, not the
1457
+ // run, so --manager can name another manager than the run's (to turn
1458
+ // off one a warning names, say).
1459
+ if (run)
1460
+ await checkRunBrowser(run);
1461
+ else if (verb !== 'fetch')
1462
+ await needBrowserWithoutRun(`manager ${verb}`);
1463
+ const manager = lineManager ?? run?.manager ?? DEFAULT_MANAGER;
1464
+ const endpoint = run?.browser ?? browserSetting(lineCdp);
1465
+ const runsUsing = verb === 'disable' && manager === 'sourcemonkey'
1466
+ ? (await liveRuns()).filter((r) => (r.manager ?? DEFAULT_MANAGER) === 'sourcemonkey' && sameBrowser(r.browser, endpoint)).map((r) => r.id)
1467
+ : [];
1468
+ const { ManagerUsageError, managerAction } = await import('./manager-actions.js');
1469
+ try {
1470
+ const done = await managerAction({ verb, manager, endpoint, browserArg: browserArg(endpoint), runsUsing, out: print, note: writeErr });
1471
+ return done ? 0 : 1;
1472
+ }
1473
+ catch (err) {
1474
+ if (err instanceof ManagerUsageError)
1475
+ throw new UsageError(err.message);
1476
+ writeErr(errorLine(err.message));
1477
+ return 1;
1478
+ }
1479
+ }
1480
+ /**
1481
+ * A `collection` or `script` command with no run: connects to the
1482
+ * --existing-browser or --open-browser browser for it, and disconnects
1483
+ * after.
1484
+ */
1485
+ async function managerHere(words) {
1486
+ const [, ...rest] = words;
1487
+ const name = managerCommandOf(words).command;
1488
+ const parsed = parseCommandArgs(name, { words: rest });
1489
+ if ('error' in parsed)
1490
+ throw new UsageError(parsed.error);
1491
+ if (parsed.help) {
1492
+ print(commandHelpFor(words[0], rest.filter((w) => !HELP_FLAGS.has(w))).trimEnd());
1493
+ return 0;
1494
+ }
1495
+ await needBrowserWithoutRun(words[0]);
1496
+ const chosen = lineManager ?? DEFAULT_MANAGER;
1497
+ const unsupported = unsupportedCommand(name, chosen, '--manager names');
1498
+ if (unsupported)
1499
+ throw new UsageError(unsupported);
1500
+ const { noSuchVerb } = await import('./manager-commands.js');
1501
+ if (parsed.form === undefined && parsed.words.length > 0)
1502
+ throw new UsageError(noSuchVerb(parsed.words));
1503
+ const own = parsed.flags['--json'];
1504
+ if (typeof own === 'string') {
1505
+ const mistake = jsonLayoutMistake(own);
1506
+ if (mistake)
1507
+ throw new UsageError(mistake);
1508
+ }
1509
+ const json = parsed.flags['--nojson'] ? false : own ? true : lineJson;
1510
+ const out = (line) => print(typeof own === 'string' && isJsonLine(line) ? withLayout(line, own) : line);
1511
+ const { connectWithoutRun } = await import('./run.js');
1512
+ const { firstLine, openManagerLink } = await import('./extension-link.js');
1513
+ // What went wrong connecting goes to stderr, as other mistakes do.
1514
+ const session = await connectWithoutRun(lineCdp, (line) => writeErr(errorLine(line)));
1515
+ if (!session)
1516
+ return 1;
1517
+ let closeManager;
1518
+ try {
1519
+ const { managersIn, otherManagersWarning } = await import('./browser-extensions.js');
1520
+ const { browserArg } = await import('./run.js');
1521
+ for (const line of otherManagersWarning(await managersIn(session.endpoint) ?? [], chosen, browserArg(session.endpoint)))
1522
+ writeErr(line);
1523
+ let manager;
1524
+ if (chosen !== 'sourcemonkey') {
1525
+ const { externalControl, openExternalManager } = await import('./external-manager.js');
1526
+ const external = await openExternalManager(chosen, session.endpoint, browserArg(session.endpoint));
1527
+ closeManager = () => external.close();
1528
+ manager = externalControl(external);
1529
+ }
1530
+ else {
1531
+ const link = await openManagerLink(session.context);
1532
+ if (link.versionNote)
1533
+ writeErr(S.message(link.versionNote));
1534
+ manager = link.manager;
1535
+ }
1536
+ // Its failures go to stderr, as sm-dev's own commands' do, with the
1537
+ // lines that follow one (the files it lists).
1538
+ let failed = false;
1539
+ const said = (line) => {
1540
+ if (line.startsWith(SM_DEV_PREFIX)) {
1541
+ failed = true;
1542
+ writeErr(errorLine(line.slice(SM_DEV_PREFIX.length)));
1543
+ }
1544
+ else if (failed)
1545
+ writeErr(line);
1546
+ else
1547
+ out(line);
1548
+ };
1549
+ return await managerCommand({ manager, out: said, json }, name, parsed) === false ? 1 : 0;
1550
+ }
1551
+ catch (err) {
1552
+ writeErr(errorLine(firstLine(err)));
1553
+ return 1;
1554
+ }
1555
+ finally {
1556
+ await closeManager?.().catch(() => undefined);
1557
+ await session.done();
1558
+ }
1559
+ }
1560
+ // sm-dev's own commands, which run in this process rather than going
1561
+ // to a run, when read from stdin or typed at the prompt.
1562
+ function runsHere(words) {
1563
+ const name = words[0];
1564
+ if (name === 'match' || name === 'validate')
1565
+ return !needsRun(words);
1566
+ return name !== undefined && HERE.has(name);
1567
+ }
1568
+ // Ones that need no run, or act on runs themselves.
1569
+ const HERE = new Set(['start', 'attach', 'stop', 'exit', 'list', 'help']);
1570
+ /**
1571
+ * Whether a command only looks, so a line of only such commands needn't
1572
+ * follow the run's stream: just their own answers.
1573
+ */
1574
+ function isQuiet(words) {
1575
+ const name = words[0];
1576
+ if (name === undefined)
1577
+ return true;
1578
+ if (name === 'start' || name === 'attach')
1579
+ return false;
1580
+ if (HERE.has(name) || name === 'match' || name === 'validate')
1581
+ return true;
1582
+ const list = words.length === 1 && words[0].includes('\n') ? parseCommandList(words[0]) : null;
1583
+ if (list && !('error' in list))
1584
+ return list.commands.every((c) => onlyInspects(c.text));
1585
+ return onlyInspects(words.join(' '));
1586
+ }
1587
+ function checkStop(opts) {
1588
+ const named = opts.args[0] === undefined ? undefined : checkedRunId(opts.args[0], 'stop ');
1589
+ if (opts.all && named !== undefined)
1590
+ throw new UsageError('The --all flag stops every run, so the stop command can\'t take a run ID too');
1591
+ return named;
1592
+ }
1593
+ /**
1594
+ * `stop`: the run it works with (or the only one going), the one named,
1595
+ * or with `--all` every run. Leaves it with no run to work with.
1596
+ */
1597
+ async function stopCommand(opts, client) {
1598
+ const { liveRuns, stopRun } = await import('./runs.js');
1599
+ const named = checkStop(opts);
1600
+ let targets;
1601
+ if (opts.all) {
1602
+ const runs = await liveRuns();
1603
+ if (runs.length === 0) {
1604
+ print(NO_RUNS);
1605
+ return 0;
1606
+ }
1607
+ targets = runs;
1608
+ }
1609
+ else {
1610
+ targets = [await targetRun(named ?? client.runId)];
1611
+ }
1612
+ if (targets.some((r) => r.id === client.runId))
1613
+ await client.disconnect();
1614
+ // Together, each run's lines tagged with its id when there are
1615
+ // several.
1616
+ const several = targets.length > 1;
1617
+ const stopped = await Promise.all(targets.map((r) => stopRun(r, several ? (line) => print(`[${r.id}] ${line}`) : print, true)));
1618
+ for (const r of targets) {
1619
+ const log = client.stopped(r.id);
1620
+ if (log)
1621
+ print(`[sm-dev] ${S.ui `Output log: ${runLogLabelUi(log, r.id)}`}`);
1622
+ }
1623
+ return stopped.every(Boolean) ? 0 : 1;
1624
+ }
1625
+ function checkAttach(opts) {
1626
+ return opts.args[0] === undefined ? undefined : checkedRunId(opts.args[0], 'attach ');
1627
+ }
1628
+ /**
1629
+ * `attach`: work with a run that is going, the one named (else `--run`'s,
1630
+ * else the only one), and show what it prints from now on.
1631
+ */
1632
+ async function attachCommand(opts, client) {
1633
+ const named = checkAttach(opts);
1634
+ // A new connection, whose hello asks for these.
1635
+ if (opts.logSources) {
1636
+ client.logSources = opts.logSources;
1637
+ await client.disconnect();
1638
+ }
1639
+ const { id } = await client.connect(named ?? globalRun);
1640
+ client.own = false;
1641
+ client.attachedTo = id;
1642
+ print(`[sm-dev] ${S.ui `Attached to run ${S.id(id)}`}`);
1643
+ return 0;
1644
+ }
1645
+ const HELP_FLAGS = new Set(['--help', '-h']);
1646
+ // The `help` command's words (a topic, or a command and subcommand),
1647
+ // or null for another command; empty for the help index. `--help` is
1648
+ // handled for the whole line (`lineHelp`).
1649
+ function helpWords(argv) {
1650
+ // Any command takes --timeout-seconds, and help ignores it, before
1651
+ // `help` or after.
1652
+ const words = argv.filter((w, i) => w !== TIMEOUT_FLAG && argv[i - 1] !== TIMEOUT_FLAG && !w.startsWith(`${TIMEOUT_FLAG}=`));
1653
+ if (words[0] !== 'help' && words[0] !== '?')
1654
+ return null;
1655
+ // `help --help`: help's own help, as for any command.
1656
+ const asked = words.slice(1).filter((w) => !HELP_FLAGS.has(w));
1657
+ return asked.length === 0 && words.length > 1 ? ['help'] : asked;
1658
+ }
1659
+ /**
1660
+ * `--help` (or `-h`) anywhere on the line: the help for each command
1661
+ * the line names, once each, and nothing run. Looked for before the
1662
+ * commands are checked, so a mistake elsewhere doesn't stand in the
1663
+ * way. With no command named, the help index. Null when not asked.
1664
+ */
1665
+ function lineHelp(commands) {
1666
+ if (!commands.some((words) => words.some((w) => HELP_FLAGS.has(w))))
1667
+ return null;
1668
+ // `help` commands' text first, then the commands', in the full help's order.
1669
+ const topics = [];
1670
+ const named = [];
1671
+ for (const words of commands) {
1672
+ const rest = words.filter((w) => !HELP_FLAGS.has(w));
1673
+ // `help status`: what it names. `help --help` alone: help's own.
1674
+ const asked = helpWords(rest);
1675
+ if (asked) {
1676
+ if (asked.length === 0)
1677
+ named.push({ order: COMMAND_NAMES.indexOf('help'), text: commandHelpFor('help') });
1678
+ else
1679
+ topics.push(helpFor(asked));
1680
+ continue;
1681
+ }
1682
+ // The command's name, which may come after its flags and their
1683
+ // values (`--find x logs`), and the rest of its words, which may
1684
+ // name a subcommand.
1685
+ const at = commandIndex(rest);
1686
+ if (rest[at] !== undefined)
1687
+ named.push({ order: COMMAND_NAMES.indexOf(commandNamed(rest[at]) ?? ''), text: commandHelpFor(rest[at], rest.filter((_, i) => i !== at)) });
1688
+ }
1689
+ // The index, with nothing named. Each help once.
1690
+ if (topics.length === 0 && named.length === 0)
1691
+ return helpFor([]);
1692
+ const texts = [...topics, ...named.sort((a, b) => a.order - b.order).map((n) => n.text)];
1693
+ return [...new Set(texts)].join('\n');
1694
+ }
1695
+ // The signals that end sm-dev: Ctrl-C, kill, and a closed terminal.
1696
+ // Windows has no SIGHUP to listen for.
1697
+ const STOP_SIGNALS = process.platform === 'win32' ? ['SIGINT', 'SIGTERM'] : ['SIGINT', 'SIGTERM', 'SIGHUP'];
1698
+ // After a command of several that failed.
1699
+ const LINE_STOPPED = 'That command failed, so the commands after it were not run';
1700
+ /**
1701
+ * Throws when the line's `--browser` names a browser other than the
1702
+ * run's, which the run's commands act in: the flag would otherwise be
1703
+ * passed over without a word.
1704
+ */
1705
+ async function checkRunBrowser(run) {
1706
+ if (lineCdp === undefined)
1707
+ return;
1708
+ const { cdpEndpoint, sameBrowser } = await import('./run.js');
1709
+ if (sameBrowser(run.browser, lineCdp))
1710
+ return;
1711
+ const named = cdpEndpoint(lineCdp.trim());
1712
+ const shown = (endpoint) => endpoint.replace(/^https?:\/\//, '');
1713
+ throw new UsageError(`The --browser flag names the browser at ${shown(named)}, but run ${run.id} uses the one at ${shown(run.browser)}. `
1714
+ + `Leave out --browser to use run ${run.id}, or pick a run in that browser with --run.`);
1715
+ }
1716
+ /**
1717
+ * Throws when the line's `--manager` isn't the run's: the whole run
1718
+ * works with the manager it started with.
1719
+ */
1720
+ function checkRunManager(run) {
1721
+ const theirs = run.manager ?? DEFAULT_MANAGER;
1722
+ if (lineManager === undefined || lineManager === theirs)
1723
+ return;
1724
+ throw new UsageError(`The --manager flag names ${MANAGERS[lineManager].label}, but run ${run.id} uses ${MANAGERS[theirs].label}. `
1725
+ + `Leave out --manager to use run ${run.id}, or name a ${MANAGERS[lineManager].label} run with --run.`);
1726
+ }
1727
+ const ROOTS_ARE_THE_RUNS = 'The --root and --local flags are set when a run starts, and a command sent to a run uses the run\'s own';
1728
+ // `--root` and `--local`, for every command of the line: where the
1729
+ // scripts it reads find local copies of their @requires. A run started
1730
+ // on the line keeps them; commands sent to another run use its own.
1731
+ let lineRoots = { collectionRoot: process.cwd(), localRoots: [] };
1732
+ // Whether the line gave either, so one sent to a run can be refused.
1733
+ let rootsGiven = false;
1734
+ // `--run`: the run the line works with.
1735
+ let globalRun;
1736
+ // `--browser`, for the commands that connect to a browser (their own
1737
+ // `--browser` wins) and for `--open-browser`. With a temp browser, the
1738
+ // port to launch it on.
1739
+ let lineCdp;
1740
+ // The browser mode: `--open-browser` (a lasting browser to start first,
1741
+ // with `--profile`) or `--existing-browser`. Neither is a temp browser
1742
+ // for each run, closed with it; `lineTempBrowser` is that given outright.
1743
+ let lineOpenBrowser = false;
1744
+ let lineExistingBrowser = false;
1745
+ let lineTempBrowser = false;
1746
+ let lineProfile;
1747
+ // `--headed` (true) or `--headless` (false): the window of a browser the
1748
+ // line launches. Unset is headed for --open-browser, and for a temp
1749
+ // browser when the line goes to a prompt (`linePrompt`).
1750
+ let lineHeaded;
1751
+ let linePrompt = false;
1752
+ // `--mute` / `--nomute`: sound in the browser the line launches. Unset
1753
+ // is muted for a temp browser, and with sound for --open-browser.
1754
+ let lineMuted;
1755
+ // `--json`: the commands with a JSON form print it (see json-output.ts).
1756
+ let lineJson = false;
1757
+ // `--manager`: the userscript manager the line works with. Unset is
1758
+ // SourceMonkey, or a run's own when sending to one.
1759
+ let lineManager;
1760
+ /**
1761
+ * Takes the global options off the front of the line: `--run ID`,
1762
+ * `--root DIR`, `--local DIR` (repeatable), `--stdin`, `--interactive`
1763
+ * and the browser's: `--temp-browser`, `--open-browser`,
1764
+ * `--existing-browser`, `--browser`, `--manager`, `--profile`, `--headed`,
1765
+ * `--headless`, `--mute`, `--nomute`. One later on the line, among a
1766
+ * command's words, is said to go first.
1767
+ */
1768
+ function takeGlobalFlags(argv) {
1769
+ let collectionRoot = process.cwd();
1770
+ const localRoots = [];
1771
+ const flags = { stdin: false, interactive: false, rest: [] };
1772
+ let i = 0;
1773
+ for (; i < argv.length; i++) {
1774
+ const [word, inline] = splitInline(argv[i]);
1775
+ if (!GLOBAL_FLAGS.includes(word))
1776
+ break;
1777
+ if (word === '--stdin')
1778
+ flags.stdin = true;
1779
+ else if (word === '--interactive')
1780
+ flags.interactive = true;
1781
+ else if (word === '--open-browser')
1782
+ lineOpenBrowser = true;
1783
+ else if (word === '--existing-browser')
1784
+ lineExistingBrowser = true;
1785
+ else if (word === '--temp-browser')
1786
+ lineTempBrowser = true;
1787
+ else if (word === '--headed' || word === '--headless') {
1788
+ const headed = word === '--headed';
1789
+ if (lineHeaded !== undefined && lineHeaded !== headed)
1790
+ throw new UsageError('The command line takes --headed or --headless, not both');
1791
+ lineHeaded = headed;
1792
+ }
1793
+ else if (word === '--mute' || word === '--nomute') {
1794
+ const muted = word === '--mute';
1795
+ if (lineMuted !== undefined && lineMuted !== muted)
1796
+ throw new UsageError('The command line takes --mute or --nomute, not both');
1797
+ lineMuted = muted;
1798
+ }
1799
+ else if (word === '--json') {
1800
+ const mistake = inline === undefined ? null : jsonLayoutMistake(inline);
1801
+ if (mistake)
1802
+ throw new UsageError(mistake);
1803
+ lineJson = true;
1804
+ setJsonOutput(inline);
1805
+ }
1806
+ else {
1807
+ const value = inline ?? argv[++i];
1808
+ // Not the next option taken for this one's value (`--browser --open-browser`).
1809
+ if (value === undefined || value === '' || value === SEPARATOR || (inline === undefined && value.startsWith('--'))) {
1810
+ throw new UsageError(word === '--run' ? 'The `--run` flag needs a run ID, e.g. `--run D2`' : `The ${quoted(word)} flag needs a value`);
1811
+ }
1812
+ if (word === '--run')
1813
+ globalRun = checkedRunId(value);
1814
+ else if (word === '--browser')
1815
+ lineCdp = value;
1816
+ else if (word === '--manager') {
1817
+ const named = managerNamed(value);
1818
+ if (!named)
1819
+ throw new UsageError(managerMistake(value));
1820
+ lineManager = named;
1821
+ }
1822
+ else if (word === '--profile')
1823
+ lineProfile = path.resolve(value);
1824
+ else if (word === '--root')
1825
+ collectionRoot = path.resolve(value);
1826
+ else
1827
+ localRoots.push(path.resolve(value));
1828
+ if (word === '--root' || word === '--local')
1829
+ rootsGiven = true;
1830
+ }
1831
+ }
1832
+ lineRoots = { collectionRoot, localRoots };
1833
+ flags.rest = argv.slice(i);
1834
+ // At most one browser mode.
1835
+ const modes = [[lineTempBrowser, '--temp-browser'], [lineOpenBrowser, '--open-browser'], [lineExistingBrowser, '--existing-browser']];
1836
+ const given = modes.filter(([on]) => on).map(([, name]) => name);
1837
+ if (given.length > 1)
1838
+ throw new UsageError(`The ${quoted(given[0])} flag can't be used with ${given.slice(1).map((f) => quoted(f)).join(' or ')}`);
1839
+ if (lineExistingBrowser) {
1840
+ const launching = [[lineHeaded !== undefined, lineHeaded ? '--headed' : '--headless'], [lineMuted !== undefined, lineMuted ? '--mute' : '--nomute']];
1841
+ for (const [on, name] of launching) {
1842
+ if (on)
1843
+ throw new UsageError(`The ${quoted(name)} flag only applies when sm-dev launches the browser, so not with ${quoted('--existing-browser')}`);
1844
+ }
1845
+ }
1846
+ if (lineProfile !== undefined && !lineOpenBrowser)
1847
+ throw new UsageError('The `--profile` flag only applies with `--open-browser`');
1848
+ return flags;
1849
+ }
1850
+ // A global option with its value in the same word (`--browser=9300`,
1851
+ // `--json=multi`), as the option and the value; else the word and no
1852
+ // value.
1853
+ function splitInline(word) {
1854
+ const eq = word.indexOf('=');
1855
+ if (eq > 0 && (VALUE_GLOBALS.has(word.slice(0, eq)) || word.slice(0, eq) === '--json'))
1856
+ return [word.slice(0, eq), word.slice(eq + 1)];
1857
+ return [word, undefined];
1858
+ }
1859
+ // The line with its leading global options (and their values) taken
1860
+ // off, for --help to look at the commands alone.
1861
+ function withoutGlobalFlags(argv) {
1862
+ let i = 0;
1863
+ while (i < argv.length && GLOBAL_FLAGS.includes(splitInline(argv[i])[0])) {
1864
+ const [word, inline] = splitInline(argv[i]);
1865
+ i += VALUE_GLOBALS.has(word) && inline === undefined ? 2 : 1;
1866
+ }
1867
+ return argv.slice(i);
1868
+ }
1869
+ // The line's own flags again, for a run started in the background.
1870
+ function globalWords(runId) {
1871
+ return [
1872
+ ...(rootsGiven ? ['--root', lineRoots.collectionRoot, ...lineRoots.localRoots.flatMap((dir) => ['--local', dir])] : []),
1873
+ ...(runId ? ['--run', runId] : []),
1874
+ ...(lineCdp !== undefined ? ['--browser', lineCdp] : []),
1875
+ ...(lineManager !== undefined ? ['--manager', lineManager] : []),
1876
+ // The run connects to the browser --open-browser started, or the
1877
+ // one --existing-browser names.
1878
+ ...(lineOpenBrowser || lineExistingBrowser ? ['--existing-browser'] : []),
1879
+ // A detached run has no prompt, so the prompt's headed default goes
1880
+ // as the flag.
1881
+ ...(tempBrowserLine() && launchHeaded() ? ['--headed'] : []),
1882
+ ...(tempBrowserLine() && lineMuted !== undefined ? [lineMuted ? '--mute' : '--nomute'] : []),
1883
+ ];
1884
+ }
1885
+ // Whether runs the line starts launch a temp browser: the default mode.
1886
+ function tempBrowserLine() {
1887
+ return !lineOpenBrowser && !lineExistingBrowser;
1888
+ }
1889
+ // Whether a browser the line launches is headed.
1890
+ function launchHeaded() {
1891
+ return lineHeaded ?? (lineOpenBrowser || linePrompt);
1892
+ }
1893
+ // Where one command ends and the next begins, when a line has several.
1894
+ const SEPARATOR = '--';
1895
+ function splitCommands(argv) {
1896
+ const commands = [[]];
1897
+ for (const arg of argv) {
1898
+ if (arg === SEPARATOR)
1899
+ commands.push([]);
1900
+ else
1901
+ commands[commands.length - 1].push(arg);
1902
+ }
1903
+ return commands.filter((c) => c.length > 0);
1904
+ }
1905
+ // Whether a command's exit code stops the commands after it: any
1906
+ // failure but a miss from match or validate, which is an answer.
1907
+ function stopsLine(code, words) {
1908
+ return code !== 0 && !(code === 1 && isVerdict(words));
1909
+ }
1910
+ // The commands whose exit code answers a question.
1911
+ function isVerdict(words) {
1912
+ return words[0] === 'match' || words[0] === 'validate';
1913
+ }
1914
+ function parseCommand(words) {
1915
+ const help = helpWords(words);
1916
+ if (help)
1917
+ return { kind: 'help', text: () => helpFor(help) };
1918
+ if (words.length === 0)
1919
+ throw new UsageError(`A command is needed. ${LIST_COMMANDS}`);
1920
+ // A run's command may come as one argument: `'eval T1 x'`. One of
1921
+ // several lines is a list of them, and never sm-dev's own.
1922
+ if (words.length === 1 && words[0].includes('\n'))
1923
+ return { kind: 'send', words };
1924
+ if (SHARED_COMMANDS.has(words[0]) && needsRun(words))
1925
+ return { kind: 'send', words };
1926
+ const head = words.length === 1 ? words[0].trim().split(/\s+/)[0] : words[0];
1927
+ if (!OWN_COMMANDS.has(words[0]) && isTypedCommand(head))
1928
+ return { kind: 'send', words };
1929
+ // One of sm-dev's own sent as one word (`'stop D1'`): split as typed.
1930
+ if (words.length === 1 && OWN_COMMANDS.has(head) && head !== words[0])
1931
+ return parseCommand(commandWords(words[0]));
1932
+ // Or a command sent as one word after a flag (`'--x eval 1'`): its
1933
+ // words, to say what is wrong with the flag.
1934
+ if (words.length === 1 && head.startsWith('-') && head !== words[0])
1935
+ return parseCommand(commandWords(words[0]));
1936
+ const opts = parseArgs(words);
1937
+ // A flag before any command is the first mistake, read left to right:
1938
+ // `--stdin --xx` is about `--xx`, not the missing command.
1939
+ if (opts.command === null)
1940
+ throw new UsageError(leadingFlagMistake(words));
1941
+ if (opts.help) {
1942
+ const command = opts.command;
1943
+ return { kind: 'help', text: () => commandHelpFor(command) };
1944
+ }
1945
+ if (!OWN_COMMANDS.has(opts.command)) {
1946
+ const flagMistake = words[0].startsWith('-') ? leadingFlagMistake(words) : null;
1947
+ if (flagMistake)
1948
+ throw new UsageError(flagMistake);
1949
+ // A run's ID where the command goes: the old `sm-dev D1 status`.
1950
+ const run = runIdOf(opts.command);
1951
+ if (run)
1952
+ throw new UsageError(`Name the run with ${quoted('--run')}, before the command: ${quoted(`sm-dev --run ${run} <command>`)}`);
1953
+ throw new UsageError(`Unknown command: ${quoted(opts.command)}. ${LIST_COMMANDS}`);
1954
+ }
1955
+ return { kind: 'cli', opts, words };
1956
+ }
1957
+ // Flags where a run's command goes, before it or with none after them:
1958
+ // the first mistake among them, read left to right. Only the line's own
1959
+ // flags go first, and those were taken already. Null when the flags are
1960
+ // all some command's and what follows is no command: that is the
1961
+ // mistake to say. With no command after them, never null.
1962
+ function leadingFlagMistake(words) {
1963
+ const at = commandIndex(words);
1964
+ const command = words[at];
1965
+ const own = command !== undefined && isTypedCommand(command) ? commandSyntax(commandNamed(command)).flags : null;
1966
+ for (const word of words.slice(0, at)) {
1967
+ // A value, after its flag.
1968
+ if (!word.startsWith('-'))
1969
+ continue;
1970
+ const flag = word.split('=')[0];
1971
+ // The command's own first: `--json probe x` has `probe x --json`.
1972
+ if (own && (Object.hasOwn(own, flag) || flag === TIMEOUT_FLAG))
1973
+ continue;
1974
+ if (GLOBAL_FLAGS.includes(flag))
1975
+ return globalFlagMistake(flag);
1976
+ if (own)
1977
+ return commandMistake({ words: [command, word] }) ?? `Unknown option: ${quoted(word)}`;
1978
+ if (!COMMAND_FLAGS.has(flag))
1979
+ return `There is no ${quoted(flag)} flag. Use ${quoted('sm-dev help flags')} to list the flags.`;
1980
+ if (command === undefined)
1981
+ return `The ${quoted(flag)} flag goes after the command it is for`;
1982
+ }
1983
+ if (!own)
1984
+ return null;
1985
+ return `The ${quoted(words[0].split('=')[0])} flag goes after the command (${quoted(command)}), not before it`;
1986
+ }
1987
+ /**
1988
+ * Everything about one command that can be checked before any command
1989
+ * of the line runs: its name, flags and arguments, and that `start`'s
1990
+ * script files exist. Not whether the run, tab or script it names is
1991
+ * there, since earlier commands can change that. Throws the mistake.
1992
+ */
1993
+ async function checkCommand(words) {
1994
+ const parsed = parseCommand(words);
1995
+ if (parsed.kind === 'help') {
1996
+ parsed.text();
1997
+ return;
1998
+ }
1999
+ if (parsed.kind === 'send')
2000
+ return checkSend(parsed.words);
2001
+ const { opts } = parsed;
2002
+ switch (opts.command) {
2003
+ case 'validate': return checkValidate(opts);
2004
+ case 'match': return checkMatch(opts);
2005
+ case 'start':
2006
+ await checkStart(opts);
2007
+ return;
2008
+ case 'attach':
2009
+ checkAttach(opts);
2010
+ return;
2011
+ case 'stop':
2012
+ checkStop(opts);
2013
+ return;
2014
+ }
2015
+ }
2016
+ // Prints a command's mistake, and returns its exit code. One in how the
2017
+ // command was typed is marked as such, apart from a failure doing it.
2018
+ function printMistake(err) {
2019
+ const usage = err instanceof UsageError;
2020
+ const text = err.message;
2021
+ writeErr(usage ? `${ERROR_PREFIX}${text}` : errorLine(text));
2022
+ return usage ? 2 : 1;
2023
+ }
2024
+ /** One command. Prints its own errors, and returns its exit code. */
2025
+ async function runOne(words, client) {
2026
+ try {
2027
+ const parsed = parseCommand(words);
2028
+ if (parsed.kind === 'help') {
2029
+ for (const line of parsed.text().trimEnd().split('\n'))
2030
+ print(line);
2031
+ return 0;
2032
+ }
2033
+ if (parsed.kind === 'send')
2034
+ return await sendCommand(parsed.words, client);
2035
+ const { opts } = parsed;
2036
+ switch (opts.command) {
2037
+ case 'validate': return await validate(opts);
2038
+ case 'match': return await match(opts);
2039
+ case 'start': return await startCommand(opts, parsed.words, client);
2040
+ case 'attach': return await attachCommand(opts, client);
2041
+ case 'list': return await listCommand(opts);
2042
+ case 'exit':
2043
+ client.exiting = true;
2044
+ return 0;
2045
+ default: return await stopCommand(opts, client);
2046
+ }
2047
+ }
2048
+ catch (err) {
2049
+ return printMistake(err);
2050
+ }
2051
+ }
2052
+ /** Whether stdin and stdout are both a terminal, for the prompt by default. */
2053
+ function atTerminal() {
2054
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
2055
+ }
2056
+ const START_ALONE = 'The start command with nothing after it would stop the run as soon as it started. Add --interactive for a prompt, --detach to leave it running, --stop-after-seconds N to watch it for a while, or commands after --.';
2057
+ async function main() {
2058
+ // Output piped into something that stops reading early (`| head`)
2059
+ // ends the command quietly, as other command-line tools do, rather
2060
+ // than with a stack trace.
2061
+ process.stdout.on('error', (err) => {
2062
+ // Writes after the first failure fail as a destroyed stream.
2063
+ // EIO: the terminal closed.
2064
+ if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED' && err.code !== 'EIO')
2065
+ throw err;
2066
+ onOutputClosed();
2067
+ });
2068
+ const argv = process.argv.slice(2);
2069
+ // Before anything else is checked, so a mistake doesn't stand in the
2070
+ // way of help.
2071
+ let help;
2072
+ try {
2073
+ help = lineHelp(splitCommands(withoutGlobalFlags(argv)));
2074
+ }
2075
+ catch (err) {
2076
+ process.exitCode = printMistake(err);
2077
+ return;
2078
+ }
2079
+ if (help !== null) {
2080
+ process.stdout.write(help);
2081
+ return;
2082
+ }
2083
+ let flags;
2084
+ try {
2085
+ flags = takeGlobalFlags(argv);
2086
+ if (flags.interactive && flags.stdin)
2087
+ throw new UsageError('The --interactive flag reads the terminal, so it can\'t be used with --stdin');
2088
+ if (flags.interactive && !process.stdin.isTTY)
2089
+ throw new UsageError('The --interactive flag needs a terminal. To read commands from a pipe or file, use --stdin.');
2090
+ }
2091
+ catch (err) {
2092
+ process.exitCode = printMistake(err);
2093
+ return;
2094
+ }
2095
+ const commands = splitCommands(flags.rest);
2096
+ // No commands, with stdin and stdout both a terminal: the prompt, as
2097
+ // with --interactive. Not with --stdin, in a detached run, or after
2098
+ // `--open-browser` alone, which is a whole line: start a browser.
2099
+ if (commands.length === 0 && !flags.stdin && !lineOpenBrowser && !detachedLog && atTerminal()) {
2100
+ flags.interactive = true;
2101
+ }
2102
+ // The background process a `start` began: the run itself, which
2103
+ // handles its own signals.
2104
+ if (detachedLog) {
2105
+ process.exitCode = await runOne(commands[0] ?? [], new Client(globalRun, false));
2106
+ return;
2107
+ }
2108
+ // Piped in with no command, stdin can only be commands. (Not after
2109
+ // `--open-browser` alone, which is a whole line: start a browser.)
2110
+ const stdin = flags.stdin || (commands.length === 0 && !flags.interactive && !lineOpenBrowser && !process.stdin.isTTY);
2111
+ if (commands.length === 0 && !stdin && !flags.interactive && !lineOpenBrowser) {
2112
+ process.stdout.write(helpFor([]));
2113
+ process.exitCode = 2;
2114
+ return;
2115
+ }
2116
+ // Every command checked before any runs, so a typo late in the line
2117
+ // doesn't leave the ones before it done.
2118
+ let failed = 0;
2119
+ for (const words of commands) {
2120
+ try {
2121
+ await checkCommand(words);
2122
+ }
2123
+ catch (err) {
2124
+ if (commands.length > 1)
2125
+ writeErr(echoLine('arg >', words.map(shellWord).join(' ')));
2126
+ failed = Math.max(failed, printMistake(err));
2127
+ }
2128
+ }
2129
+ if (failed) {
2130
+ if (commands.length > 1)
2131
+ writeErr(NONE_RUN);
2132
+ process.exitCode = failed;
2133
+ return;
2134
+ }
2135
+ // A `start` with nothing after it to keep it going: it would start the
2136
+ // run and stop it again straight away. Its flags can come first. At a
2137
+ // terminal, the prompt keeps it going, as with --interactive.
2138
+ const last = commands.at(-1);
2139
+ const ending = last && parseCommand(last).kind === 'cli' ? parseArgs(last) : null;
2140
+ if (ending?.command === 'start' && !ending.detach && ending.seconds === undefined && !stdin && !flags.interactive) {
2141
+ if (atTerminal()) {
2142
+ flags.interactive = true;
2143
+ }
2144
+ else {
2145
+ process.exitCode = printMistake(new UsageError(START_ALONE));
2146
+ return;
2147
+ }
2148
+ }
2149
+ // Decided by now: a temp browser opened from a prompt is headed.
2150
+ linePrompt = flags.interactive;
2151
+ // --temp-browser, --headed, --headless, --mute and --nomute shape the
2152
+ // runs this line starts (or --open-browser's browser); with no start
2153
+ // to use them, they'd be silently ignored.
2154
+ const starts = commands.some((words) => parseCommand(words).kind === 'cli' && parseArgs(words).command === 'start');
2155
+ const shaping = lineTempBrowser ? '--temp-browser'
2156
+ : lineOpenBrowser ? null
2157
+ : lineHeaded !== undefined ? (lineHeaded ? '--headed' : '--headless')
2158
+ : lineMuted !== undefined ? (lineMuted ? '--mute' : '--nomute')
2159
+ : null;
2160
+ if (shaping && !starts && !stdin && !flags.interactive) {
2161
+ process.exitCode = printMistake(new UsageError(`The ${shaping} flag is for the runs a start begins, and this line has no start`));
2162
+ return;
2163
+ }
2164
+ // The lasting browser first, for the commands to connect to.
2165
+ if (lineOpenBrowser) {
2166
+ const { launchBrowser, launchEndpointMistake } = await import('./browser-launch.js');
2167
+ const { browserSetting, browserSettingName } = await import('./run.js');
2168
+ try {
2169
+ const endpoint = browserSetting(lineCdp);
2170
+ const mistake = launchEndpointMistake(endpoint, browserSettingName(lineCdp));
2171
+ if (mistake)
2172
+ throw new UsageError(mistake);
2173
+ await launchBrowser(endpoint, lineProfile, print, lineMuted === true, !launchHeaded());
2174
+ }
2175
+ catch (err) {
2176
+ process.exitCode = printMistake(err);
2177
+ return;
2178
+ }
2179
+ if (commands.length === 0 && !stdin && !flags.interactive)
2180
+ return;
2181
+ }
2182
+ // A line that only attaches: it watches the run until it ends.
2183
+ const watching = ending?.command === 'attach' && !stdin && !flags.interactive;
2184
+ // Whether to follow the run's stream, decided once, up front: when
2185
+ // the line does more than look, or has no known end.
2186
+ const follow = flags.interactive || stdin || commands.some((words) => !isQuiet(words));
2187
+ const client = new Client(globalRun, follow);
2188
+ // Ctrl-C (or kill, or a closed terminal) leaves as `exit` does: the
2189
+ // runs this started are stopped, and ones it only attached to go on.
2190
+ // A second one kills it.
2191
+ let interrupted = false;
2192
+ const interrupt = () => {
2193
+ if (interrupted)
2194
+ process.exit(130);
2195
+ interrupted = true;
2196
+ void client.finish().finally(() => process.exit(130));
2197
+ };
2198
+ for (const signal of STOP_SIGNALS)
2199
+ process.on(signal, interrupt);
2200
+ // Output piped into something that stopped reading (`| head`): the
2201
+ // same, once, since what finish prints fails there too.
2202
+ let closing = false;
2203
+ onOutputClosed = () => {
2204
+ if (closing)
2205
+ return;
2206
+ closing = true;
2207
+ void client.finish().finally(() => process.exit(process.exitCode ?? 0));
2208
+ };
2209
+ let code = 0;
2210
+ let stopped = false;
2211
+ try {
2212
+ // In order, stopping at the first that fails, as a shell's && would.
2213
+ for (const [i, words] of commands.entries()) {
2214
+ if (commands.length > 1)
2215
+ echo(echoLine('arg >', words.map(shellWord).join(' ')));
2216
+ code = await runOne(words, client);
2217
+ if (client.exiting)
2218
+ break;
2219
+ // A miss from match or validate is an answer, not a failure: the
2220
+ // line goes on, and it only sets the exit code if it is last.
2221
+ if (stopsLine(code, words)) {
2222
+ if (i < commands.length - 1 || stdin || flags.interactive)
2223
+ writeErr(LINE_STOPPED);
2224
+ stopped = true;
2225
+ break;
2226
+ }
2227
+ }
2228
+ if (!stopped && !client.exiting) {
2229
+ if (stdin) {
2230
+ const read = await stdinCommands(client);
2231
+ if (read !== null)
2232
+ code = read;
2233
+ else if (commands.length === 0)
2234
+ code = printMistake(new UsageError(NO_COMMANDS));
2235
+ }
2236
+ else if (flags.interactive) {
2237
+ code = await promptCommands(client);
2238
+ }
2239
+ else if (watching) {
2240
+ await client.watch();
2241
+ }
2242
+ else {
2243
+ // A run started with --stop-after-seconds, left to end on its own.
2244
+ const lasted = await client.outlast();
2245
+ if (code === 0)
2246
+ code = lasted;
2247
+ }
2248
+ }
2249
+ }
2250
+ finally {
2251
+ const ended = await client.finish();
2252
+ if (code === 0)
2253
+ code = ended;
2254
+ for (const signal of STOP_SIGNALS)
2255
+ process.off(signal, interrupt);
2256
+ }
2257
+ process.exitCode = code;
2258
+ }
2259
+ await main();