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,1196 @@
1
+ // The `collection` and `script` commands: what the manager has
2
+ // installed, its collections and scripts, listed, shown and changed, as
3
+ // text or JSON. The same in a run (through its ExtensionLink) and
4
+ // without one (a connection of their own, see `openManagerLink`). See
5
+ // docs/sm-dev-managers.md. Under Violentmonkey or Tampermonkey, the
6
+ // same commands over `externalControl` (external-manager.ts).
7
+ //
8
+ // Collections are named `C3` and scripts `E12` by the numbers
9
+ // SourceMonkey gives them (sticky-numbers.ts), so a name means the same
10
+ // thing in every run and every command. Another manager's scripts go
11
+ // by its own numbers, in one collection, `C0`.
12
+ import { existsSync } from 'node:fs';
13
+ import { mkdir, writeFile } from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { LISTED_BY_LOCATION, RUN_COLLECTION_LOCKED, } from '../sm-dev-protocol.js';
17
+ import { pendingSummary } from '../script-display.js';
18
+ import { jsonResult, } from './json-output.js';
19
+ import { commandSyntax } from './command-list.js';
20
+ import { errorSentence, writeProblem } from './input-file.js';
21
+ import { shownPath } from './prepare.js';
22
+ import { asError, cmd, code, err as errorLine, heading, id as idRole, MISTAKE_PREFIX, name as nameRole, plainLine, quoted, selected, SM_DEV_PREFIX, ui, url, } from './output-style.js';
23
+ /**
24
+ * A run's id in the extension (`D1-ab12`, see `linkRunId`) as sm-dev
25
+ * names the run (`D1`).
26
+ */
27
+ export function runOfLinkId(id) {
28
+ return id.replace(/-[0-9a-f]{4}$/, '');
29
+ }
30
+ // How a collection is named: `C3`, a run's test collection by its run,
31
+ // and (not yet numbered) by its own id.
32
+ function collectionLabel(c) {
33
+ return collectionId(c) ?? `id=${c.id}`;
34
+ }
35
+ // A script in a run's test collection has no E-id: only its run knows
36
+ // its number (`1`), so another run's is shown by name alone. Under
37
+ // another manager, a run's scripts are its scripts too, with E-ids.
38
+ function scriptLabel(s, view) {
39
+ return s.number !== undefined ? `E${s.number}` : view?.own.get(s.id);
40
+ }
41
+ function isSelected(s, view) {
42
+ const selected = view?.selected;
43
+ if (!selected)
44
+ return false;
45
+ return 'internalId' in selected ? selected.internalId === s.id : selected.label === `E${s.number}`;
46
+ }
47
+ // Numbered ones by number, then the rest (runs' test collections) by name.
48
+ function sortedCollections(collections) {
49
+ return [...collections].sort((a, b) => (a.number ?? Infinity) - (b.number ?? Infinity) || a.name.localeCompare(b.name));
50
+ }
51
+ /** `1 script`, `2 scripts`. */
52
+ export function count(n, noun) {
53
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
54
+ }
55
+ // Its id in JSON: `C3`, or a run's test collection's run (`D1`).
56
+ function collectionId(c) {
57
+ if (c.number !== undefined)
58
+ return `C${c.number}`;
59
+ return c.run !== undefined ? runOfLinkId(c.run) : undefined;
60
+ }
61
+ function collectionRef(c) {
62
+ const id = collectionId(c);
63
+ return { ...(id !== undefined ? { id } : {}), name: c.name };
64
+ }
65
+ function collectionJson(c, disk) {
66
+ const id = collectionId(c);
67
+ return {
68
+ ...(id !== undefined ? { id } : {}),
69
+ internalId: c.id,
70
+ name: c.name,
71
+ kind: c.kind,
72
+ ...(c.location !== undefined ? { location: c.location } : {}),
73
+ ...(c.run !== undefined ? { runCollection: runOfLinkId(c.run) } : {}),
74
+ enabled: c.enabled,
75
+ status: c.error ? 'load-failed' : 'ok',
76
+ ...(c.error ? { error: c.error } : {}),
77
+ scriptCount: c.scriptCount,
78
+ ...(disk?.collections[c.id] ? { onDisk: disk.collections[c.id] } : {}),
79
+ };
80
+ }
81
+ function scriptJson(s, collection, view, disk) {
82
+ const id = scriptLabel(s, view);
83
+ return {
84
+ ...(id !== undefined ? { id } : {}),
85
+ kind: collection?.run !== undefined || s.run !== undefined ? 'run' : 'installed',
86
+ internalId: s.id,
87
+ name: s.name,
88
+ ...(s.namespace !== undefined ? { namespace: s.namespace } : {}),
89
+ ...(s.version !== undefined ? { version: s.version } : {}),
90
+ // A run's script: the file it was read from, and its path as given.
91
+ location: s.sourceFile ?? s.location,
92
+ ...(s.loadedAs !== undefined ? { file: s.loadedAs } : {}),
93
+ ...(collection ? { collection: collectionRef(collection) } : {}),
94
+ enabled: s.enabled,
95
+ status: s.status,
96
+ ...(s.error !== undefined ? { error: s.error } : {}),
97
+ ...(s.refreshError !== undefined ? { refreshError: s.refreshError } : {}),
98
+ ...(disk?.scripts[s.id] ? { onDisk: disk.scripts[s.id] } : {}),
99
+ ...(isSelected(s, view) ? { selected: true } : {}),
100
+ ...(s.run !== undefined ? { run: s.run } : {}),
101
+ ...(s.leftBy !== undefined ? { leftBy: s.leftBy } : {}),
102
+ };
103
+ }
104
+ /** The verbs each command takes. `script` without one picks a script instead. */
105
+ export const MANAGER_VERBS = {
106
+ collection: ['list', 'show', 'enable', 'disable', 'refresh', 'rename'],
107
+ script: ['list', 'show', 'print', 'save', 'enable', 'disable', 'refresh', 'install', 'uninstall', 'move'],
108
+ };
109
+ /** Said for `collection <words>` with no verb it takes: `C3 frob`, or one word alone. */
110
+ export function noSuchVerb(words) {
111
+ const verbs = MANAGER_VERBS.collection.join(', ');
112
+ if (words.length === 1)
113
+ return `The ${quoted('collection')} command needs a verb: ${quoted(`collection ${words[0]} <verb>`)}. It takes ${verbs}.`;
114
+ return `The ${quoted('collection')} command has no ${quoted(words[1])} verb. It takes ${verbs}.`;
115
+ }
116
+ /** `collection list`: each collection, numbered ones first. */
117
+ export async function collectionList(manager, out, json) {
118
+ const [listed, disk] = await Promise.all([manager.collections(), manager.onDisk()]);
119
+ const collections = sortedCollections(listed);
120
+ if (json) {
121
+ out(jsonResult('collection list', { collections: collections.map((c) => collectionJson(c, disk)) }));
122
+ return;
123
+ }
124
+ out(ui `${heading(`Collections in ${manager.label}`)}:`);
125
+ if (collections.length === 0)
126
+ out(' None');
127
+ for (const c of collections) {
128
+ const notes = [count(c.scriptCount, 'script'), ...(c.enabled ? [] : ['disabled'])];
129
+ out(ui ` ${collectionMarked(c)} (${c.kind}): ${notes.join(', ')}`);
130
+ // Not when its name is its location, as a directory's is by default.
131
+ if (c.location !== undefined && c.location !== c.name)
132
+ out(ui ` ${url(c.location)}`);
133
+ if (c.error)
134
+ out(asError(indentLines(` Failed to load: ${c.error}`, ' ')));
135
+ const pending = pendingText(c, disk);
136
+ if (pending)
137
+ out(` ${pending}`);
138
+ }
139
+ }
140
+ // `Refresh pending: 1 file modified on disk`, as the collections page's chip says.
141
+ function pendingText(c, disk) {
142
+ const counts = disk?.collections[c.id];
143
+ return counts ? `Refresh pending: ${pendingSummary(counts)} on disk` : undefined;
144
+ }
145
+ // A script's line in `script list` and `collection show`: `* E3: Name 1.2 (disabled): INVALID`.
146
+ function scriptLine(s, view, disk) {
147
+ const label = scriptLabel(s, view);
148
+ const onDisk = disk?.scripts[s.id];
149
+ const notes = [
150
+ ...(s.enabled ? [] : ['disabled']),
151
+ ...(onDisk ? [`${onDisk} on disk`] : []),
152
+ // Under another manager, where a run's scripts are among the rest.
153
+ ...(s.run !== undefined ? [`run ${s.run}'s${view?.own.has(s.id) ? ` script ${view.own.get(s.id)}` : ''}`] : []),
154
+ ...(s.leftBy !== undefined ? [`left by run ${s.leftBy}, which ended`] : []),
155
+ ];
156
+ return ui `${isSelected(s, view) ? ui `${selected('*')} ` : ' '}${label !== undefined ? ui `${idRole(label)}: ` : ''}${nameRole(s.name)}${s.version ? ` ${s.version}` : ''}${notes.length > 0 ? ` (${notes.join(', ')})` : ''}${s.status === 'invalid' ? ': INVALID' : ''}`;
157
+ }
158
+ /**
159
+ * The collection `name` names: `C3`, `id=<id>`, a run's (`D1`), or its
160
+ * name, whole or a part only it has, in any case. For none or several,
161
+ * throws an error with the reason.
162
+ */
163
+ export function pickCollection(collections, name) {
164
+ const numbered = /^[Cc](\d+)$/.exec(name);
165
+ if (numbered) {
166
+ const found = collections.find((c) => c.number === Number(numbered[1]));
167
+ if (!found)
168
+ throw new Error(`There is no collection ${quoted(`C${numbered[1]}`)}. Use ${quoted('collection list')} to list them.`);
169
+ return found;
170
+ }
171
+ if (name.startsWith('id=')) {
172
+ const found = collections.find((c) => c.id === name.slice(3));
173
+ if (!found)
174
+ throw new Error(`There is no collection with ${quoted(name)}. Use ${quoted('collection list')} to list them.`);
175
+ return found;
176
+ }
177
+ const lower = name.toLowerCase();
178
+ // A run's test collection by its run (`D1`), as `collection list` shows it.
179
+ const run = collections.find((c) => c.run !== undefined && runOfLinkId(c.run).toLowerCase() === lower);
180
+ if (run)
181
+ return run;
182
+ const whole = collections.filter((c) => c.name.toLowerCase() === lower);
183
+ // Not runs' test collections by a part: they all share most of a name.
184
+ const found = whole.length > 0 ? whole : collections.filter((c) => c.run === undefined && c.name.toLowerCase().includes(lower));
185
+ if (found.length === 1)
186
+ return found[0];
187
+ if (found.length === 0)
188
+ throw new Error(`No collection is named ${quoted(name)}. Use ${quoted('collection list')} to list them.`);
189
+ const which = sortedCollections(found).map((c) => `${collectionLabel(c)} (${c.name})`).join(', ');
190
+ throw new Error(`${quoted(name)} names more than one collection: ${which}. Name one by its number (${quoted('C1')}), or ${quoted('id=<id>')}.`);
191
+ }
192
+ /**
193
+ * `script list [collection]`: the installed scripts, under their
194
+ * collections, or one collection's. Throws for a collection it can't
195
+ * find.
196
+ */
197
+ export async function scriptList(manager, name, out, json, view) {
198
+ const [collections, scripts, disk] = await Promise.all([manager.collections(), manager.scripts(), manager.onDisk()]);
199
+ const picked = name === undefined ? undefined : pickCollection(collections, name);
200
+ const shown = sortedCollections(picked ? [picked] : collections);
201
+ const byCollection = new Map(shown.map((c) => [c.id, []]));
202
+ for (const s of scripts)
203
+ byCollection.get(s.collectionId)?.push(s);
204
+ if (json) {
205
+ out(jsonResult('script list', {
206
+ ...(picked ? { collection: collectionRef(picked) } : {}),
207
+ scripts: shown.flatMap((c) => byCollection.get(c.id).map((s) => scriptJson(s, c, view, disk))),
208
+ }));
209
+ return;
210
+ }
211
+ const listed = shown.filter((c) => picked || byCollection.get(c.id).length > 0);
212
+ if (listed.length === 0)
213
+ out(`No scripts are installed in ${manager.label}`);
214
+ for (const c of listed) {
215
+ out(ui `${collectionMarked(c)}`);
216
+ const these = byCollection.get(c.id);
217
+ if (these.length === 0)
218
+ out(' No scripts');
219
+ for (const s of these) {
220
+ out(scriptLine(s, view, disk));
221
+ // A run's script by its path as the run was given it, rather than
222
+ // its `scratch:` URL.
223
+ const where = s.loadedAs ?? s.location;
224
+ if (where)
225
+ out(ui ` ${url(where)}`);
226
+ if (s.error)
227
+ out(` ${s.error}`);
228
+ if (s.refreshError)
229
+ out(asError(` Its last refresh failed: ${s.refreshError}`));
230
+ }
231
+ }
232
+ }
233
+ // What each verb takes after it, for the message when it's missing.
234
+ const NEEDS = {
235
+ 'script install': 'a .user.js file or URL', 'script save': 'a directory to save into', 'script move': 'the collection to move it to', 'collection rename': 'the new name',
236
+ };
237
+ /**
238
+ * The script `name` names: a run's own by its number (`2`), `E12`,
239
+ * `id=<id>`, or its name, whole or a part only it has, in any case. For
240
+ * none or several, throws an error with the reason.
241
+ */
242
+ export function pickManagedScript(scripts, name, view) {
243
+ const none = noSuchScript(name);
244
+ if (/^\d+$/.test(name)) {
245
+ const id = [...view?.own ?? []].find(([, n]) => n === name)?.[0];
246
+ const found = id === undefined ? undefined : scripts.find((s) => s.id === id);
247
+ if (!found)
248
+ throw new Error(view ? none : `There is no script ${quoted(name)}: A number names one of a run's scripts, in that run. Name an installed script as ${quoted('E1')}.`);
249
+ return found;
250
+ }
251
+ const numbered = /^[Ee](\d+)$/.exec(name);
252
+ if (numbered) {
253
+ const found = scripts.find((s) => s.number === Number(numbered[1]));
254
+ if (!found)
255
+ throw new Error(none);
256
+ return found;
257
+ }
258
+ if (name.startsWith('id=')) {
259
+ const found = scripts.find((s) => s.id === name.slice(3));
260
+ if (!found)
261
+ throw new Error(none);
262
+ return found;
263
+ }
264
+ const lower = name.toLowerCase();
265
+ const whole = scripts.filter((s) => s.name.toLowerCase() === lower);
266
+ const found = whole.length > 0 ? whole : scripts.filter((s) => s.name.toLowerCase().includes(lower));
267
+ if (found.length === 1)
268
+ return found[0];
269
+ if (found.length === 0)
270
+ throw new Error(`No script is named ${quoted(name)}. Use ${quoted('script list')} to list them.`);
271
+ const which = found.map((s) => scriptShown(s, view)).join(', ');
272
+ throw new Error(`${quoted(name)} names more than one script: ${which}. Name one by its number (${quoted('E1')}), or ${quoted('id=<id>')}.`);
273
+ }
274
+ /** Another manager won't replace the person's own script `shown` (`E3: Name`) without `replace`. */
275
+ export class ReplaceRefused extends Error {
276
+ shown;
277
+ constructor(shown) {
278
+ super(`${shown} has the same name and namespace, and would be replaced`);
279
+ this.shown = shown;
280
+ }
281
+ }
282
+ /** Said for a script name that names none. */
283
+ export function noSuchScript(name) {
284
+ return `There is no script ${quoted(name)}. Use ${quoted('script list')} to list the installed scripts.`;
285
+ }
286
+ // The run's selected script, for a script verb given none.
287
+ function selectedScript(scripts, view) {
288
+ const selected = view.selected;
289
+ if ('label' in selected)
290
+ return pickManagedScript(scripts, selected.label, view);
291
+ const found = scripts.find((s) => s.id === selected.internalId);
292
+ if (!found)
293
+ throw new Error('The selected script is not installed. Name one: `script <script> <verb>`.');
294
+ return found;
295
+ }
296
+ // `E3: Name`, or a script with no label by its name alone.
297
+ function scriptShown(s, view) {
298
+ const label = scriptLabel(s, view);
299
+ return label !== undefined ? `${label}: ${s.name}` : s.name;
300
+ }
301
+ // `C3: Name`.
302
+ function collectionShown(c) {
303
+ return `${collectionLabel(c)}: ${c.name}`;
304
+ }
305
+ // scriptShown and collectionShown marked up, for output lines (not errors).
306
+ function scriptMarked(s, view) {
307
+ const label = scriptLabel(s, view);
308
+ return label !== undefined ? ui `${idRole(label)}: ${nameRole(s.name)}` : nameRole(s.name);
309
+ }
310
+ function collectionMarked(c) {
311
+ return ui `${idRole(collectionLabel(c))}: ${nameRole(c.name)}`;
312
+ }
313
+ /**
314
+ * `collection <C> <verb>`, `script [script] <verb>` and `script
315
+ * install`. `subject`: what it acts on, named before the verb; a script
316
+ * left out is the selected one. `args`: the words after the verb;
317
+ * `flags`: the command's own. Returns false when it failed, having said
318
+ * why.
319
+ */
320
+ export async function managerVerb(ctx, command, verb, subject, args, flags) {
321
+ const usage = commandSyntax(command).usage.find((u) => new RegExp(`^\\S+ (\\S+ )?${verb}(\\s|$)`).test(u));
322
+ // Not uninstall: removing the selected script by leaving it out is too easy a slip.
323
+ const selectable = command === 'script' && verb !== 'uninstall' && ctx.run?.selected !== undefined;
324
+ // The old order: `collection rename C3 New name`.
325
+ if (command === 'collection' && subject === undefined && args.length > 0) {
326
+ ctx.out(`${MISTAKE_PREFIX}Name the collection before the verb: ${quoted(`collection ${args[0]} ${verb}${args.length > 1 ? ' ...' : ''}`)}`);
327
+ return false;
328
+ }
329
+ if (verb !== 'install' && subject === undefined && !selectable) {
330
+ // Named, not optional, in the usage shown: it is needed here.
331
+ const named = usage?.replace(`${command} [${command}]`, `${command} <${command}>`);
332
+ const why = command === 'script' && verb !== 'uninstall' ? ` There is no selected script ${ctx.run ? 'in this run' : 'outside a run'}.` : '';
333
+ ctx.out(`${MISTAKE_PREFIX}The ${quoted(`${command} ${verb}`)} command needs ${command === 'script' ? 'a script' : 'a collection'}: ${quoted(named)}${why ? `.${why}` : ''}`);
334
+ return false;
335
+ }
336
+ const need = NEEDS[`${command} ${verb}`];
337
+ if (need && args.length === 0) {
338
+ ctx.out(`${MISTAKE_PREFIX}The ${quoted(`${command} ${verb}`)} command needs ${need}: ${quoted(usage)}`);
339
+ return false;
340
+ }
341
+ try {
342
+ if (command === 'collection')
343
+ return await collectionVerb(ctx, verb, subject, args);
344
+ return await scriptVerb(ctx, verb, subject, args, flags);
345
+ }
346
+ catch (err) {
347
+ if (err instanceof MarkedFailure) {
348
+ ctx.out(SM_DEV_PREFIX + err.lines[0]);
349
+ for (const line of err.lines.slice(1))
350
+ ctx.out(line);
351
+ return false;
352
+ }
353
+ // Its own message: these start with what they're about (`E2: One`).
354
+ ctx.out(`[sm-dev] ${err.message}`);
355
+ return false;
356
+ }
357
+ }
358
+ // A change the manager refused, said with what it was: `Could not
359
+ // disable E3: One: <why>`.
360
+ async function attempt(what, change) {
361
+ try {
362
+ return await change;
363
+ }
364
+ catch (err) {
365
+ throw new Error(`Could not ${what}: ${err.message}`, { cause: err });
366
+ }
367
+ }
368
+ // Refused, saying why, when `--force` is needed and wasn't given.
369
+ function forced(ctx, flags, what, change) {
370
+ if (!ctx.guarded || flags['--force'] === true)
371
+ return true;
372
+ ctx.out(`${MISTAKE_PREFIX}${what} is installed in ${ctx.manager.label} in this browser, so this would ${change} for real. Use ${quoted('--force')} to continue.`);
373
+ return false;
374
+ }
375
+ async function collectionVerb(ctx, verb, subject, args) {
376
+ const collections = await ctx.manager.collections();
377
+ const picked = pickCollection(collections, subject);
378
+ const shown = collectionShown(picked);
379
+ const changed = (c, text) => {
380
+ if (ctx.json)
381
+ ctx.out(jsonResult(`collection ${verb}`, { collection: collectionJson(c) }));
382
+ else
383
+ ctx.out(text);
384
+ };
385
+ switch (verb) {
386
+ case 'show':
387
+ await collectionShow(ctx, picked);
388
+ return true;
389
+ case 'enable':
390
+ case 'disable': {
391
+ const enabled = verb === 'enable';
392
+ // Refused whatever --force says, so said first.
393
+ if (picked.run !== undefined)
394
+ throw new Error(`Could not ${verb} ${shown}: ${RUN_COLLECTION_LOCKED}`);
395
+ const next = await attempt(`${verb} ${shown}`, ctx.manager.setCollectionEnabled(picked.id, enabled));
396
+ changed(next, ui `${enabled ? 'Enabled' : 'Disabled'} ${collectionMarked(next)}`);
397
+ return true;
398
+ }
399
+ case 'rename': {
400
+ const next = await attempt(`rename ${shown}`, ctx.manager.renameCollection(picked.id, args.join(' ')));
401
+ changed(next, ui `Renamed ${idRole(collectionLabel(next))} to ${nameRole(next.name)}`);
402
+ return true;
403
+ }
404
+ case 'refresh': {
405
+ const result = await attempt(`refresh ${shown}`, ctx.manager.refreshCollection(picked.id));
406
+ const view = ctx.run;
407
+ if (ctx.json) {
408
+ ctx.out(jsonResult('collection refresh', {
409
+ collection: collectionJson(result.collection),
410
+ updated: result.updated.map((s) => scriptJson(s, result.collection, view)),
411
+ unchanged: result.unchanged,
412
+ removed: result.removed.map((s) => scriptJson(s, result.collection, view)),
413
+ ...(result.failed ? { failed: result.failed.map((s) => scriptJson(s, result.collection, view)) } : {}),
414
+ }));
415
+ return true;
416
+ }
417
+ const failed = result.failed ?? [];
418
+ const notes = [`${result.updated.length} updated`, `${result.unchanged} unchanged`, ...(result.removed.length > 0 ? [`${result.removed.length} removed`] : []),
419
+ ...(failed.length > 0 ? [`${failed.length} failed`] : [])];
420
+ ctx.out(ui `Refreshed ${collectionMarked(result.collection)}: ${notes.join(', ')}`);
421
+ if (result.collection.error)
422
+ ctx.out(asError(indentLines(` Failed to load: ${result.collection.error}`, ' ')));
423
+ for (const s of result.updated) {
424
+ ctx.out(ui ` Updated ${scriptMarked(s, view)}${s.status === 'invalid' ? ': INVALID' : ''}`);
425
+ if (s.error)
426
+ ctx.out(` ${s.error}`);
427
+ if (s.refreshError)
428
+ ctx.out(asError(` Its last refresh failed: ${s.refreshError}`));
429
+ }
430
+ for (const s of result.removed)
431
+ ctx.out(ui ` Removed ${scriptMarked(s, view)}`);
432
+ for (const s of failed)
433
+ ctx.out(asError(ui ` Failed to refresh ${scriptMarked(s, view)}: ${s.refreshError ?? ''}`));
434
+ return true;
435
+ }
436
+ }
437
+ return false;
438
+ }
439
+ // `collection show`: the collection, then its scripts as `script list` shows them.
440
+ async function collectionShow(ctx, picked) {
441
+ const [all, disk] = await Promise.all([ctx.manager.scripts(), ctx.manager.onDisk()]);
442
+ const scripts = all.filter((s) => s.collectionId === picked.id);
443
+ if (ctx.json) {
444
+ ctx.out(jsonResult('collection show', {
445
+ collection: { ...collectionJson(picked, disk), scripts: scripts.map((s) => scriptJson(s, picked, ctx.run, disk)) },
446
+ }));
447
+ return;
448
+ }
449
+ ctx.out(collectionMarked(picked));
450
+ // Values are markup.
451
+ const rows = [
452
+ ['Kind', picked.kind],
453
+ ...(picked.location !== undefined ? [['Location', url(picked.location)]] : []),
454
+ ...(picked.run !== undefined ? [['Run', ui `The test collection of run ${idRole(runOfLinkId(picked.run))}`]] : []),
455
+ ['Status', `${picked.enabled ? 'Enabled' : 'Disabled'}${picked.error ? `, failed to load: ${picked.error}` : ''}`],
456
+ ...(disk?.collections[picked.id] ? [['On disk', `${pendingSummary(disk.collections[picked.id])}, not loaded yet`]] : []),
457
+ ['Scripts', scripts.length === 0 ? 'None' : count(scripts.length, 'script')],
458
+ ];
459
+ for (const [name, value] of rows)
460
+ ctx.out(ui ` ${heading(name)}:${' '.repeat(Math.max(0, 11 - name.length))}${value}`);
461
+ for (const s of scripts)
462
+ ctx.out(scriptLine(s, ctx.run, disk));
463
+ }
464
+ async function scriptVerb(ctx, verb, subject, args, flags) {
465
+ if (verb === 'install')
466
+ return scriptInstall(ctx, args[0], flags);
467
+ const [scripts, collections] = await Promise.all([ctx.manager.scripts(), ctx.manager.collections()]);
468
+ const view = ctx.run;
469
+ const picked = subject !== undefined ? pickManagedScript(scripts, subject, view) : selectedScript(scripts, view);
470
+ const collection = collections.find((c) => c.id === picked.collectionId);
471
+ const shown = scriptShown(picked, view);
472
+ const own = view?.own.has(picked.id) === true;
473
+ // Another run's script, or one left by a run that ended. Under
474
+ // another manager, only a live run's.
475
+ const othersRun = own ? undefined : collection?.run !== undefined ? runOfLinkId(collection.run) : picked.run;
476
+ const changed = (s, text, extra = {}) => {
477
+ const into = collections.find((c) => c.id === s.collectionId);
478
+ if (ctx.json)
479
+ ctx.out(jsonResult(`script ${verb}`, { script: scriptJson(s, into, view), ...extra }));
480
+ else
481
+ ctx.out(text);
482
+ };
483
+ // With what turning it on did besides.
484
+ const enableChanged = (result, text) => {
485
+ const into = collections.find((c) => c.id === result.script.collectionId);
486
+ if (ctx.json) {
487
+ ctx.out(jsonResult(`script ${verb}`, {
488
+ script: scriptJson(result.script, into, view), ...enableEffectsJson(result, into, collections, view),
489
+ }));
490
+ return;
491
+ }
492
+ ctx.out(text);
493
+ for (const line of enableEffects(result, into, collections, view))
494
+ ctx.out(` ${line}`);
495
+ };
496
+ switch (verb) {
497
+ case 'show':
498
+ await scriptShow(ctx, picked, collection);
499
+ return true;
500
+ case 'print':
501
+ return scriptPrint(ctx, picked, flags);
502
+ case 'save':
503
+ return scriptSave(ctx, picked, args[0], flags);
504
+ case 'enable':
505
+ case 'disable': {
506
+ const enabled = verb === 'enable';
507
+ if (othersRun !== undefined)
508
+ throw new Error(`${shown} is a script of run ${othersRun}, which only that run changes`);
509
+ const next = await attempt(`${verb} ${shown}`, ctx.manager.setScriptEnabled(picked.id, enabled));
510
+ enableChanged(next, ui `${enabled ? 'Enabled' : 'Disabled'} ${scriptMarked(next.script, view)}`);
511
+ return true;
512
+ }
513
+ case 'refresh': {
514
+ // From disk, as `update` does, so the run knows its new text.
515
+ if (own) {
516
+ const before = await ctx.manager.scriptText(picked.id);
517
+ await view.updateOwn(picked.id);
518
+ if (ctx.json) {
519
+ const [after] = (await ctx.manager.scripts()).filter((s) => s.id === picked.id);
520
+ const updated = (await ctx.manager.scriptText(picked.id)) !== before;
521
+ ctx.out(jsonResult('script refresh', { script: scriptJson(after ?? picked, collection, view), updated }));
522
+ }
523
+ return true;
524
+ }
525
+ if (othersRun !== undefined)
526
+ throw new Error(`${shown} is a script of run ${othersRun}, which only that run changes`);
527
+ const result = await attempt(`refresh ${shown}`, ctx.manager.refreshScript(picked.id));
528
+ const s = result.script;
529
+ if (ctx.json) {
530
+ ctx.out(jsonResult('script refresh', { script: scriptJson(s, collection, view), updated: result.updated }));
531
+ return true;
532
+ }
533
+ ctx.out(ui `Refreshed ${scriptMarked(s, view)}: ${result.updated ? 'updated' : 'no change'}${s.status === 'invalid' ? ', and it is INVALID' : ''}`);
534
+ if (s.error)
535
+ ctx.out(` ${s.error}`);
536
+ if (s.refreshError)
537
+ ctx.out(` The refresh failed, so its last good copy still runs: ${s.refreshError}`);
538
+ return true;
539
+ }
540
+ case 'uninstall': {
541
+ if (own)
542
+ throw new Error(`${shown} is one of this run's own scripts, which go when the run ends`);
543
+ if (othersRun !== undefined)
544
+ throw new Error(`${shown} is a script of run ${othersRun}, which only that run changes`);
545
+ // Refused whatever --force says, so said first.
546
+ if (collection && collection.kind !== 'container')
547
+ throw new Error(`Could not uninstall ${shown}: ${LISTED_BY_LOCATION}`);
548
+ if (!forced(ctx, flags, shown, 'remove it'))
549
+ return false;
550
+ const was = await attempt(`uninstall ${shown}`, ctx.manager.uninstallScript(picked.id));
551
+ changed(was, ui `Uninstalled ${scriptMarked(was, view)}${collection ? ui ` from ${collectionMarked(collection)}` : ''}`, { removed: true });
552
+ return true;
553
+ }
554
+ case 'move': {
555
+ if (own)
556
+ throw new Error(`${shown} is one of this run's own scripts, which stay in its test collection`);
557
+ const target = pickCollection(collections, args[0]);
558
+ if (target.id === picked.collectionId)
559
+ throw new Error(`${shown} is already in ${collectionShown(target)}`);
560
+ const moved = await attempt(`move ${shown} to ${collectionShown(target)}`, ctx.manager.moveScript(picked.id, target.id));
561
+ enableChanged(moved, ui `Moved ${scriptMarked(moved.script, view)} to ${collectionMarked(target)}`);
562
+ return true;
563
+ }
564
+ }
565
+ return false;
566
+ }
567
+ // ---------- Printing and saving a script's files ----------
568
+ /**
569
+ * A failure said as lines of markup: the first after `[sm-dev] `, the
570
+ * rest as they are. Its message is the same as plain text.
571
+ */
572
+ class MarkedFailure extends Error {
573
+ lines;
574
+ constructor(lines) {
575
+ super(lines.map(plainLine).join('\n'));
576
+ this.lines = lines;
577
+ }
578
+ }
579
+ // Said when the manager doesn't give sm-dev the files.
580
+ function noFilesUnder(label) {
581
+ return `${label} doesn't give sm-dev its scripts' @require, @resource and @icon files, so only the script itself can be printed or saved`;
582
+ }
583
+ // Said when the manager doesn't look up site icons.
584
+ function noSiteIconsUnder(label) {
585
+ return `Site icons are SourceMonkey's own, so ${label} doesn't give them`;
586
+ }
587
+ // The script's `@require` and `@resource` files as installed.
588
+ async function filesOf(ctx, script) {
589
+ if (!ctx.manager.scriptFiles)
590
+ throw new Error(noFilesUnder(ctx.manager.label));
591
+ const files = await ctx.manager.scriptFiles(script.id);
592
+ if (files === null)
593
+ throw new Error(noSuchScript(scriptShown(script, ctx.run)));
594
+ return files;
595
+ }
596
+ // The script's icons, reading or looking up what `load` names.
597
+ async function iconsOf(ctx, script, load) {
598
+ if (!ctx.manager.scriptIcons)
599
+ throw new Error(load.sites ? noSiteIconsUnder(ctx.manager.label) : noFilesUnder(ctx.manager.label));
600
+ const icons = await ctx.manager.scriptIcons(script.id, load);
601
+ if (icons === null)
602
+ throw new Error(noSuchScript(scriptShown(script, ctx.run)));
603
+ return icons;
604
+ }
605
+ // The installed text.
606
+ async function textOf(ctx, script) {
607
+ const text = await ctx.manager.scriptText(script.id);
608
+ if (text === null)
609
+ throw new Error(noSuchScript(scriptShown(script, ctx.run)));
610
+ return text;
611
+ }
612
+ // A file's URL as shown: a `data:` URL by its type alone, since it
613
+ // holds the whole file.
614
+ function fileUrlShown(f) {
615
+ return f.url.startsWith('data:') ? `${f.url.split(',')[0]},...` : f.url;
616
+ }
617
+ // `@resource icon https://x/icon.png`, `@require https://x/lib.js`, marked up.
618
+ function fileMarked(f) {
619
+ return ui `@${f.kind} ${f.name !== undefined ? ui `${code(f.name)} ` : ''}${url(fileUrlShown(f))}`;
620
+ }
621
+ // Image types' file extensions, for the icons `script save` names.
622
+ const ICON_EXTENSIONS = {
623
+ 'image/png': 'png', 'image/svg+xml': 'svg', 'image/x-icon': 'ico', 'image/vnd.microsoft.icon': 'ico',
624
+ 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif', 'image/bmp': 'bmp',
625
+ };
626
+ function iconExtension(contentType) {
627
+ const type = (contentType ?? '').split(';')[0].trim().toLowerCase();
628
+ return ICON_EXTENSIONS[type] ?? (type.split('/')[1]?.replace(/^x-/, '').replace(/\W.*$/, '') || 'img');
629
+ }
630
+ // The `@icon` as a file: none when it couldn't be read. A `data:` one
631
+ // is saved as `icon.<ext>`.
632
+ function iconFile(icons) {
633
+ const icon = icons.icon;
634
+ if (icon?.base64 === undefined)
635
+ return undefined;
636
+ const path = icon.path ?? (icon.url.startsWith('data:') ? `icon.${iconExtension(icon.contentType)}` : undefined);
637
+ return { kind: 'icon', url: icon.url, ...(path ? { path } : {}), base64: icon.base64, ...(icon.contentType ? { contentType: icon.contentType } : {}) };
638
+ }
639
+ // Where `script save` writes a file, relative to its directory: a
640
+ // `file:` one beside the script as it is there, else by its URL.
641
+ // Undefined for none.
642
+ function savedPath(f, script, flat) {
643
+ const kept = f.url.startsWith('file:') ? besideScript(f.url, script) ?? f.path : f.path;
644
+ return kept === undefined ? undefined : flat ? path.posix.basename(kept) : kept;
645
+ }
646
+ // The `@icon`'s path as `script show` gives it, read or not.
647
+ function iconPath(icons, script) {
648
+ const icon = icons.icon;
649
+ const dataType = /^data:([^;,]*)/.exec(icon.url)?.[1];
650
+ return savedPath({ url: icon.url, path: icon.path ?? (dataType !== undefined ? `icon.${iconExtension(dataType)}` : undefined) }, script, false);
651
+ }
652
+ // The site icons as files, in `site-icons/`, or with `flat` at the top
653
+ // with `_` for the `/` in a page's path.
654
+ function siteIconFiles(icons, flat) {
655
+ return icons.map((icon) => {
656
+ if (icon.base64 === undefined)
657
+ return { sites: icon.sites };
658
+ const name = `${flat ? icon.name.replaceAll('/', '_') : `site-icons/${icon.name}`}.${iconExtension(icon.contentType)}`;
659
+ return { path: name, ...(icon.source ? { source: icon.source } : {}), sites: icon.sites, bytes: Buffer.from(icon.base64, 'base64') };
660
+ });
661
+ }
662
+ function siteIconJson(f) {
663
+ return { ...(f.path !== undefined ? { path: f.path } : {}), ...(f.source ? { source: f.source } : {}), sites: f.sites };
664
+ }
665
+ // A site as a site icon's line shows it: `npmjs.com/package/`, without
666
+ // the scheme or `www.`, and `github.com` without a lone `/`.
667
+ function siteShown(linkUrl) {
668
+ return linkUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^www\./, '').replace(/^([^/]*)\/$/, '$1');
669
+ }
670
+ // A site icon's lines: its file (or `No icon`) and the sites it stands
671
+ // for, in columns. `shown`: a path as shown.
672
+ function siteIconLines(files, shown) {
673
+ const left = files.map((f) => (f.path !== undefined ? shown(f.path) : 'No icon'));
674
+ const width = Math.max(...left.map((l) => l.length)) + 2;
675
+ return files.map((f, i) => {
676
+ const sites = f.sites.map(siteShown).join(', ');
677
+ const pad = ' '.repeat(width - left[i].length);
678
+ return f.path !== undefined ? ui `${url(left[i])}${pad}${sites}` : ui `${left[i]}${pad}${sites}`;
679
+ });
680
+ }
681
+ /**
682
+ * The file of `files` that `name` names: a `@resource`'s name, exactly,
683
+ * or a part of its URL only it has; `script` for the script itself;
684
+ * `icon` for the `@icon`. For none or multiple, throws a failure
685
+ * listing them. `shown`: the script, marked up.
686
+ */
687
+ export function pickScriptFile(files, name, shown) {
688
+ const named = files.filter((f) => f.name === name);
689
+ if (named.length === 1)
690
+ return named[0];
691
+ if (name === 'script')
692
+ return 'script';
693
+ const icon = files.find((f) => f.kind === 'icon');
694
+ if (name === 'icon' && icon)
695
+ return icon;
696
+ const lower = name.toLowerCase();
697
+ const found = files.filter((f) => f.url.toLowerCase().includes(lower));
698
+ if (found.length === 1)
699
+ return found[0];
700
+ if (files.length === 0)
701
+ throw new MarkedFailure([ui `${shown} has no @require, @resource or @icon files`]);
702
+ const listed = (these) => these.map((f) => ui ` ${fileMarked(f)}`);
703
+ if (found.length === 0)
704
+ throw new MarkedFailure([ui `No file of ${shown} matches ${cmd(name)}. Its files:`, ...listed(files)]);
705
+ throw new MarkedFailure([ui `The name ${cmd(name)} matches multiple files of ${shown}. Give more of the URL, or a @resource's name:`, ...listed(found)]);
706
+ }
707
+ /**
708
+ * The site icon that `name` (`site-icons/github.com`) names: the one
709
+ * whose name has the part after `site-icons/`. For none or multiple,
710
+ * throws a failure listing them.
711
+ */
712
+ export function pickSiteIcon(files, name, shown) {
713
+ const icons = files.filter((f) => f.path !== undefined && f.bytes !== undefined);
714
+ // Compared after `site-icons/`, which they all have.
715
+ const part = (p) => p.toLowerCase().replace(/^site-icons\/?/, '');
716
+ const wanted = part(name);
717
+ const exact = icons.filter((f) => part(f.path) === wanted || part(f.path).replace(/\.[^./]*$/, '') === wanted);
718
+ const found = exact.length === 1 ? exact : icons.filter((f) => part(f.path).includes(wanted));
719
+ if (found.length === 1)
720
+ return found[0];
721
+ if (icons.length === 0)
722
+ throw new MarkedFailure([ui `${shown} has no site icons`]);
723
+ const listed = (these) => siteIconLines(these, (p) => p).map((line) => ui ` ${line}`);
724
+ if (found.length === 0)
725
+ throw new MarkedFailure([ui `No site icon of ${shown} matches ${cmd(name)}. Its site icons:`, ...listed(icons)]);
726
+ throw new MarkedFailure([ui `The name ${cmd(name)} matches multiple site icons of ${shown}. Give more of it:`, ...listed(found)]);
727
+ }
728
+ // Bytes as text, or null for bytes that aren't text: not UTF-8, or with a NUL.
729
+ function asText(bytes) {
730
+ if (bytes.includes(0))
731
+ return null;
732
+ try {
733
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
734
+ }
735
+ catch {
736
+ return null;
737
+ }
738
+ }
739
+ // Why a file or directory couldn't be written.
740
+ function writeFailure(err) {
741
+ return writeProblem(err) ?? errorSentence(err);
742
+ }
743
+ // Does `work`, which writes `shown`, saying which and why when it can't.
744
+ async function writing(shown, work) {
745
+ try {
746
+ await work();
747
+ }
748
+ catch (err) {
749
+ throw new MarkedFailure([ui `Could not write ${url(shown)}: ${writeFailure(err)}`]);
750
+ }
751
+ }
752
+ // What `print --file <name>` names.
753
+ async function printedFile(ctx, script, name) {
754
+ const shown = scriptMarked(script, ctx.run);
755
+ if (/^site-icons(\/|$)/i.test(name)) {
756
+ const icons = await iconsOf(ctx, script, { sites: true });
757
+ const f = pickSiteIcon(siteIconFiles(icons.siteIcons ?? [], false), name, shown);
758
+ return { bytes: f.bytes, file: { kind: 'site-icon', path: f.path, sites: f.sites }, marked: ui `The site icon ${url(f.path)}` };
759
+ }
760
+ const files = await filesOf(ctx, script);
761
+ // The `@icon` too, by its URL, where the manager gives it. Asked for
762
+ // by name, it has to. Read only once picked.
763
+ const byName = name === 'icon' && !files.some((f) => f.name === name);
764
+ const icons = byName ? await iconsOf(ctx, script, {}) : await iconsOf(ctx, script, {}).catch(() => undefined);
765
+ if (byName && !icons?.icon)
766
+ throw new MarkedFailure([ui `${shown} has no @icon`]);
767
+ const declared = icons?.icon ? [{ kind: 'icon', url: icons.icon.url, base64: '' }] : [];
768
+ let picked = pickScriptFile([...files, ...declared], name, shown);
769
+ if (picked === 'script')
770
+ return 'script';
771
+ if (picked.kind === 'icon') {
772
+ const read = iconFile(await iconsOf(ctx, script, { icon: true }));
773
+ if (!read)
774
+ throw new MarkedFailure([ui `Could not read the @icon of ${shown}, ${url(fileUrlShown(picked))}`]);
775
+ picked = read;
776
+ }
777
+ return {
778
+ bytes: Buffer.from(picked.base64, 'base64'),
779
+ file: { kind: picked.kind, url: picked.url, ...(picked.name !== undefined ? { name: picked.name } : {}) },
780
+ marked: fileMarked(picked),
781
+ ...(picked.contentType ? { contentType: picked.contentType } : {}),
782
+ };
783
+ }
784
+ // `script [script] print [--file <name>] [--out <file>]`.
785
+ async function scriptPrint(ctx, script, flags) {
786
+ const view = ctx.run;
787
+ const which = typeof flags['--file'] === 'string' ? await printedFile(ctx, script, flags['--file']) : 'script';
788
+ const printed = which === 'script' ? { bytes: Buffer.from(await textOf(ctx, script)) } : which;
789
+ const id = scriptLabel(script, view);
790
+ const about = {
791
+ ...(id !== undefined ? { script: id } : {}),
792
+ name: script.name,
793
+ ...(printed.file ? { file: printed.file } : {}),
794
+ };
795
+ const outFile = flags['--out'];
796
+ if (typeof outFile === 'string') {
797
+ const target = path.resolve(ctx.cwd, outFile);
798
+ const shown = shownPath(target, ctx.cwd);
799
+ await writing(shown, () => writeFile(target, printed.bytes));
800
+ if (ctx.json)
801
+ ctx.out(jsonResult('script print', { ...about, out: target }));
802
+ else
803
+ ctx.out(ui `Wrote ${url(shown)} (${count(printed.bytes.length, 'byte')})`);
804
+ return true;
805
+ }
806
+ const text = asText(printed.bytes);
807
+ if (text === null) {
808
+ throw new MarkedFailure([
809
+ ui `${printed.marked} is not text${printed.contentType ? ` (${printed.contentType})` : ''}, so it can't be printed. Use ${cmd('--out <file>')} to write it to a file.`,
810
+ ]);
811
+ }
812
+ if (ctx.json)
813
+ ctx.out(jsonResult('script print', { ...about, text }));
814
+ else
815
+ for (const line of text.replace(/\n$/, '').split('\n'))
816
+ ctx.out(line);
817
+ return true;
818
+ }
819
+ /** What `script save --include` takes, besides `all`. */
820
+ const SAVE_PARTS = ['script', 'requires', 'resources', 'icon', 'site-icons'];
821
+ /** What `script save` saves without `--include`, and what `all` stands for. */
822
+ const SAVE_ALL = ['script', 'requires', 'resources', 'icon'];
823
+ // What each part is, for saying there was none.
824
+ const SAVE_PART_NAMES = {
825
+ 'script': 'script', 'requires': '@require', 'resources': '@resource', 'icon': '@icon', 'site-icons': 'site icon',
826
+ };
827
+ // The file name a saved script takes: its own, when that's a
828
+ // `.user.js`, else its name made one.
829
+ function scriptFileName(s) {
830
+ const where = s.loadedAs ?? s.location ?? '';
831
+ let last = where.split(/[?#]/)[0].split(/[\\/]/).pop() ?? '';
832
+ try {
833
+ last = decodeURIComponent(last);
834
+ }
835
+ catch {
836
+ // Kept as written.
837
+ }
838
+ if (/\.user\.js$/i.test(last) && !/[\\/:*?"<>|]/.test(last))
839
+ return last;
840
+ return `${s.name.replace(/[\\/:*?"<>|]/g, '_')}.user.js`;
841
+ }
842
+ // A `file:` dependency's path from the script's own folder, as a
843
+ // relative `@require` names it (`lib/util.js`), so a saved copy finds
844
+ // it there too. Undefined for one outside that folder. A run's script
845
+ // by the file it was read from.
846
+ function besideScript(fileUrl, script) {
847
+ const own = script.sourceFile ?? script.location;
848
+ if (!own?.startsWith('file:'))
849
+ return undefined;
850
+ const folder = own.slice(0, own.lastIndexOf('/') + 1);
851
+ if (!fileUrl.startsWith(folder))
852
+ return undefined;
853
+ try {
854
+ return decodeURIComponent(fileUrl.slice(folder.length));
855
+ }
856
+ catch {
857
+ return undefined;
858
+ }
859
+ }
860
+ // `--include`'s parts, or a mistake to show.
861
+ function saveParts(given) {
862
+ const words = given.split(',').map((p) => p.trim()).filter(Boolean);
863
+ const unknown = words.find((p) => p !== 'all' && !SAVE_PARTS.includes(p));
864
+ if (unknown === undefined && words.length > 0)
865
+ return new Set(words.flatMap((p) => (p === 'all' ? SAVE_ALL : [p])));
866
+ return `The ${quoted('--include')} flag takes ${[...SAVE_PARTS, 'all'].map((p) => quoted(p)).join(', ')}, comma-separated${unknown !== undefined ? `, and not ${quoted(unknown)}` : ''}`;
867
+ }
868
+ // `script [script] save <dir> [--include <parts>] [--flat] [--force]`.
869
+ async function scriptSave(ctx, script, dirGiven, flags) {
870
+ const view = ctx.run;
871
+ const shown = scriptMarked(script, view);
872
+ const included = typeof flags['--include'] === 'string';
873
+ let parts = new Set(SAVE_ALL);
874
+ if (included) {
875
+ const given = saveParts(flags['--include']);
876
+ if (typeof given === 'string') {
877
+ ctx.out(`${MISTAKE_PREFIX}${given}`);
878
+ return false;
879
+ }
880
+ parts = given;
881
+ }
882
+ const flat = flags['--flat'] === true;
883
+ const dir = path.resolve(ctx.cwd, dirGiven);
884
+ const files = [];
885
+ // What wasn't saved and why: plain for JSON, and marked up.
886
+ const notes = [];
887
+ const note = (marked) => {
888
+ notes.push({ plain: plainLine(marked), marked });
889
+ };
890
+ // Not one that would land outside the directory.
891
+ const inside = (where) => where !== undefined && path.resolve(dir, where).startsWith(dir + path.sep);
892
+ if (parts.has('script'))
893
+ files.push({ kind: 'script', path: scriptFileName(script), bytes: Buffer.from(await textOf(ctx, script)) });
894
+ // Without `--include`, under a manager that doesn't give them, the script alone.
895
+ const filesGiven = included || ctx.manager.scriptFiles !== undefined;
896
+ if (!filesGiven && (parts.has('requires') || parts.has('resources')))
897
+ note(ui `${noFilesUnder(ctx.manager.label)}`);
898
+ const addFile = (f) => {
899
+ const where = savedPath(f, script, flat);
900
+ if (!inside(where)) {
901
+ note(ui `Skipped ${fileMarked(f)}: It has no file name to save it as`);
902
+ return;
903
+ }
904
+ files.push({ kind: f.kind, url: f.url, ...(f.name !== undefined ? { name: f.name } : {}), path: where, bytes: Buffer.from(f.base64, 'base64') });
905
+ };
906
+ if (filesGiven && (parts.has('requires') || parts.has('resources'))) {
907
+ for (const f of await filesOf(ctx, script))
908
+ if (parts.has(`${f.kind}s`))
909
+ addFile(f);
910
+ }
911
+ let icons;
912
+ if (parts.has('site-icons') || (parts.has('icon') && (included || ctx.manager.scriptIcons !== undefined))) {
913
+ const load = { icon: parts.has('icon'), sites: parts.has('site-icons') };
914
+ try {
915
+ icons = await iconsOf(ctx, script, load);
916
+ }
917
+ catch (err) {
918
+ // Not asked for by name: the rest is saved without it.
919
+ if (included)
920
+ throw err;
921
+ note(ui `Skipped its @icon: ${errorSentence(err)}`);
922
+ }
923
+ }
924
+ if (parts.has('icon') && icons?.icon) {
925
+ const icon = iconFile(icons);
926
+ if (icon)
927
+ addFile(icon);
928
+ else
929
+ note(ui `Skipped its @icon ${url(fileUrlShown(icons.icon))}: It could not be read`);
930
+ }
931
+ let siteIcons;
932
+ if (parts.has('site-icons') && icons) {
933
+ siteIcons = siteIconFiles(icons.siteIcons ?? [], flat);
934
+ if (siteIcons.length === 0)
935
+ note(ui `${shown} names no sites to show icons for`);
936
+ for (const f of siteIcons) {
937
+ if (f.path === undefined || f.bytes === undefined)
938
+ continue;
939
+ if (!inside(f.path))
940
+ note(ui `Skipped the site icon of ${f.sites.map(siteShown).join(', ')}: It has no file name to save it as`);
941
+ else
942
+ files.push({ kind: 'site-icon', path: f.path, bytes: f.bytes });
943
+ }
944
+ }
945
+ // One URL declared twice writes one file; two files with one name can't be.
946
+ const byPath = new Map();
947
+ const clashes = [];
948
+ for (const f of files) {
949
+ const same = byPath.get(f.path);
950
+ if (!same)
951
+ byPath.set(f.path, f);
952
+ else if (!same.bytes.equals(f.bytes))
953
+ clashes.push(f.path);
954
+ }
955
+ const shownFile = (relative) => shownPath(path.join(dir, relative), ctx.cwd);
956
+ if (clashes.length > 0) {
957
+ throw new MarkedFailure([
958
+ ui `Multiple files of ${shown} would be saved under the same name${flat ? ui `. Leave out ${cmd('--flat')} to save them in folders by URL` : ''}:`,
959
+ ...[...new Set(clashes)].map((p) => ui ` ${url(shownFile(p))}`),
960
+ ]);
961
+ }
962
+ const saving = [...byPath.values()];
963
+ if (saving.length === 0) {
964
+ const what = [...parts].map((p) => SAVE_PART_NAMES[p]).join(' or ');
965
+ throw new MarkedFailure([ui `${shown} has no ${what} files to save`, ...notes.map((n) => ui ` ${n.marked}`)]);
966
+ }
967
+ const existing = saving.filter((f) => existsSync(path.join(dir, f.path)));
968
+ if (existing.length > 0 && flags['--force'] !== true) {
969
+ ctx.out(MISTAKE_PREFIX + errorLine(ui `These files already exist. Use ${cmd('--force')} to replace them:`));
970
+ for (const f of existing)
971
+ ctx.out(ui ` ${url(shownFile(f.path))}`);
972
+ return false;
973
+ }
974
+ const dirShown = shownPath(dir, ctx.cwd);
975
+ try {
976
+ await mkdir(dir, { recursive: true });
977
+ }
978
+ catch (error) {
979
+ throw new MarkedFailure([ui `Could not create the directory ${url(dirShown)}: ${writeFailure(error)}`]);
980
+ }
981
+ for (const f of saving) {
982
+ const target = path.join(dir, f.path);
983
+ await writing(shownFile(f.path), async () => {
984
+ await mkdir(path.dirname(target), { recursive: true });
985
+ await writeFile(target, f.bytes);
986
+ });
987
+ }
988
+ const id = scriptLabel(script, view);
989
+ const plainFiles = saving.filter((f) => f.kind !== 'site-icon');
990
+ if (ctx.json) {
991
+ ctx.out(jsonResult('script save', {
992
+ ...(id !== undefined ? { script: id } : {}),
993
+ name: script.name,
994
+ dir,
995
+ files: plainFiles.map((f) => ({
996
+ kind: f.kind, ...(f.url !== undefined ? { url: f.url } : {}), ...(f.name !== undefined ? { name: f.name } : {}), path: f.path,
997
+ })),
998
+ ...(siteIcons ? { siteIcons: siteIcons.map(siteIconJson) } : {}),
999
+ ...(notes.length > 0 ? { notes: notes.map((n) => n.plain) } : {}),
1000
+ }));
1001
+ return true;
1002
+ }
1003
+ const iconCount = saving.length - plainFiles.length;
1004
+ const counted = [
1005
+ ...(plainFiles.length > 0 ? [count(plainFiles.length, 'file')] : []),
1006
+ ...(iconCount > 0 ? [count(iconCount, 'site icon')] : []),
1007
+ ].join(' and ');
1008
+ ctx.out(ui `Saved ${shown} to ${url(dirShown)}: ${counted}`);
1009
+ for (const f of plainFiles)
1010
+ ctx.out(ui ` ${url(shownFile(f.path))}`);
1011
+ for (const n of notes)
1012
+ ctx.out(ui ` ${n.marked}`);
1013
+ if (siteIcons && siteIcons.length > 0) {
1014
+ ctx.out('Site icons, in page order:');
1015
+ for (const line of siteIconLines(siteIcons, shownFile))
1016
+ ctx.out(ui ` ${line}`);
1017
+ }
1018
+ return true;
1019
+ }
1020
+ // `script install <file|url>`: a path is the sender's, made a `file://` URL.
1021
+ async function scriptInstall(ctx, given, flags) {
1022
+ const location = /^[a-z][a-z0-9+.-]*:/i.test(given) && !/^[a-z]:[\\/]/i.test(given)
1023
+ ? given
1024
+ : pathToFileURL(path.resolve(ctx.cwd, given)).href;
1025
+ if (!/\.user\.js$/i.test(new URL(location).pathname)) {
1026
+ throw new Error(`${quoted(given)} is not a .user.js file or URL, so it can't be installed as a script`);
1027
+ }
1028
+ const collections = await ctx.manager.collections();
1029
+ const into = typeof flags['--collection'] === 'string' ? pickCollection(collections, flags['--collection']) : null;
1030
+ // A guarded one stops before it replaces the person's own script.
1031
+ const replace = !ctx.guarded || flags['--force'] === true;
1032
+ let result;
1033
+ try {
1034
+ result = await attempt(`install ${quoted(given)}`, ctx.manager.installScript(location, into?.id ?? null, replace));
1035
+ }
1036
+ catch (err) {
1037
+ const refused = err.cause;
1038
+ if (!(refused instanceof ReplaceRefused))
1039
+ throw err;
1040
+ forced(ctx, flags, refused.shown, 'replace it');
1041
+ return false;
1042
+ }
1043
+ const s = result.script;
1044
+ // The collection it went into, perhaps made just now.
1045
+ const holder = into ?? (await ctx.manager.collections()).find((c) => c.id === s.collectionId);
1046
+ if (ctx.json) {
1047
+ ctx.out(jsonResult('script install', {
1048
+ script: scriptJson(s, holder, ctx.run),
1049
+ ...enableEffectsJson(result, holder, collections, ctx.run),
1050
+ ...(result.already ? { already: true } : {}),
1051
+ ...(result.replaced ? { replaced: true } : {}),
1052
+ }));
1053
+ return true;
1054
+ }
1055
+ const where = holder ? ui ` in ${collectionMarked(holder)}` : '';
1056
+ if (result.already) {
1057
+ ctx.out(ui `${scriptMarked(s, ctx.run)} is already installed${where}`);
1058
+ return true;
1059
+ }
1060
+ ctx.out(ui `${result.replaced ? 'Replaced the script with the same name and namespace: ' : 'Installed '}${scriptMarked(s, ctx.run)}${where}${s.enabled ? '' : ', disabled'}${s.status === 'invalid' ? ', but it is INVALID' : ''}`);
1061
+ if (s.error)
1062
+ ctx.out(` ${s.error}`);
1063
+ for (const line of enableEffects(result, holder, collections, ctx.run))
1064
+ ctx.out(` ${line}`);
1065
+ return true;
1066
+ }
1067
+ // Whether an enabled script sits in a disabled collection, so it doesn't run.
1068
+ function inDisabledCollection(s, collection) {
1069
+ return s.enabled && collection !== undefined && !collection.enabled;
1070
+ }
1071
+ // What turning a script on did besides, one line each, marked up for
1072
+ // output: its other copies it turned off, as the viewer does, and
1073
+ // whether it runs at all.
1074
+ function enableEffects(result, collection, collections, view) {
1075
+ const lines = result.turnedOff.map((s) => {
1076
+ const label = scriptLabel(s, view);
1077
+ const shown = s.version !== undefined ? ui `Version ${s.version}${label !== undefined ? ui ` (${idRole(label)})` : ''}` : scriptMarked(s, view);
1078
+ const holder = collections.find((c) => c.id === s.collectionId);
1079
+ return ui `Another version of this script was disabled: ${shown}${holder ? ui ` in collection ${collectionMarked(holder)}` : ''}`;
1080
+ });
1081
+ if (inDisabledCollection(result.script, collection)) {
1082
+ lines.push(ui `${scriptMarked(result.script, view)} is enabled, but won't run because it's inside disabled collection ${collectionMarked(collection)}`);
1083
+ }
1084
+ return lines;
1085
+ }
1086
+ function enableEffectsJson(result, collection, collections, view) {
1087
+ return {
1088
+ ...(result.turnedOff.length > 0
1089
+ ? { turnedOff: result.turnedOff.map((s) => scriptJson(s, collections.find((c) => c.id === s.collectionId), view)) }
1090
+ : {}),
1091
+ ...(inDisabledCollection(result.script, collection) ? { collectionDisabled: true } : {}),
1092
+ };
1093
+ }
1094
+ // `2026-10-03 08:12`, in local time.
1095
+ function shortTime(ms) {
1096
+ const d = new Date(ms);
1097
+ const two = (n) => String(n).padStart(2, '0');
1098
+ return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
1099
+ }
1100
+ function fileJson(f) {
1101
+ return {
1102
+ url: f.url,
1103
+ ...(f.loaded !== undefined ? { status: f.loaded ? 'ok' : 'not-loaded' } : {}),
1104
+ ...(f.local !== undefined ? { local: f.local } : {}),
1105
+ };
1106
+ }
1107
+ // `https://x/lib.js (ok)`, with its local stand-in, marked up. No
1108
+ // status when the manager doesn't say.
1109
+ function fileText(f) {
1110
+ const status = f.loaded === undefined ? undefined : f.loaded ? (f.local !== undefined ? ui `ok, read from ${url(f.local)}` : 'ok') : 'not loaded';
1111
+ return ui `${f.name !== undefined ? ui `${code(f.name)} ` : ''}${url(f.url)}${status ? ui ` (${status})` : ''}`;
1112
+ }
1113
+ // `script show`: one script in full.
1114
+ async function scriptShow(ctx, picked, collection) {
1115
+ const [details, disk, icons] = await Promise.all([
1116
+ ctx.manager.scriptDetails(picked.id),
1117
+ ctx.manager.onDisk(),
1118
+ // What it has at hand: none under a manager or an extension that
1119
+ // doesn't give them, which `show` does without.
1120
+ ctx.manager.scriptIcons?.(picked.id, {}).catch(() => null) ?? null,
1121
+ ]);
1122
+ if (!details)
1123
+ throw new Error(noSuchScript(scriptShown(picked, ctx.run)));
1124
+ const iconAt = icons?.icon ? { url: icons.icon.url, path: iconPath(icons, picked), local: icons.icon.local } : undefined;
1125
+ const siteIcons = icons?.siteIcons ? siteIconFiles(icons.siteIcons, false) : undefined;
1126
+ const onDisk = disk?.scripts[details.id];
1127
+ const label = scriptLabel(details, ctx.run);
1128
+ const tabs = ctx.run ? await ctx.run.tabsOf(details, label) : [];
1129
+ if (ctx.json) {
1130
+ ctx.out(jsonResult('script show', {
1131
+ script: {
1132
+ ...scriptJson(details, collection, ctx.run, disk),
1133
+ ...(details.description !== undefined ? { description: details.description } : {}),
1134
+ ...(details.author !== undefined ? { author: details.author } : {}),
1135
+ ...(details.updatedAt !== undefined ? { updatedAt: new Date(details.updatedAt).toISOString() } : {}),
1136
+ matching: { match: details.match, include: details.include, exclude: details.exclude, excludeMatch: details.excludeMatch },
1137
+ runAt: details.runAt ?? 'document-idle',
1138
+ grants: details.grants,
1139
+ requires: details.requires.map(fileJson),
1140
+ resources: details.resources.map((f) => ({ ...fileJson(f), name: f.name ?? '' })),
1141
+ ...(iconAt ? { icon: { url: iconAt.url, ...(iconAt.path !== undefined ? { path: iconAt.path } : {}), ...(iconAt.local !== undefined ? { local: iconAt.local } : {}) } } : {}),
1142
+ ...(siteIcons ? { siteIcons: siteIcons.map(siteIconJson) } : {}),
1143
+ ...(ctx.run ? { tabs: tabs.map((t) => t.activity) } : {}),
1144
+ ...(details.valueCount !== undefined ? { valueCount: details.valueCount } : {}),
1145
+ },
1146
+ }));
1147
+ return;
1148
+ }
1149
+ ctx.out(ui `${scriptMarked(details, ctx.run)}${details.version ? ` ${details.version}` : ''}`);
1150
+ // Values are markup.
1151
+ const rows = [];
1152
+ const add = (name, value) => {
1153
+ if (value !== undefined && value.length > 0)
1154
+ rows.push([name, value]);
1155
+ };
1156
+ // Plain text, escaped as markup; none when empty, as `add` skips.
1157
+ const plain = (text) => (text ? ui `${text}` : undefined);
1158
+ const codes = (values) => values.map((v) => code(v));
1159
+ add('Namespace', plain(details.namespace));
1160
+ add('Description', plain(details.description));
1161
+ add('Author', plain(details.author));
1162
+ add('Collection', collection ? collectionMarked(collection) : undefined);
1163
+ add('Run', details.run !== undefined ? ui `A script of run ${idRole(details.run)}` : details.leftBy !== undefined ? ui `Left by run ${idRole(details.leftBy)}, which ended` : undefined);
1164
+ const location = details.loadedAs ?? details.location;
1165
+ add('Location', location ? url(location) : undefined);
1166
+ add('Status', plain(`${details.enabled ? 'Enabled' : 'Disabled'}, ${details.status === 'invalid' ? 'INVALID' : 'ok'}${onDisk ? `, ${onDisk} on disk` : ''}`));
1167
+ add('Error', details.error ? asError(details.error) : undefined);
1168
+ add('Refresh', details.refreshError ? asError(`The last refresh failed, so its last good copy runs: ${details.refreshError}`) : undefined);
1169
+ add('Updated', details.updatedAt !== undefined ? plain(shortTime(details.updatedAt)) : undefined);
1170
+ add('@match', codes(details.match));
1171
+ add('@include', codes(details.include));
1172
+ add('@exclude', codes(details.exclude));
1173
+ add('@exclude-match', codes(details.excludeMatch));
1174
+ add('@run-at', details.runAt ? code(details.runAt) : undefined);
1175
+ add('@grant', details.grants.length > 0 ? details.grants.map((g) => code(g)).join(', ') : undefined);
1176
+ add('@require', details.requires.map(fileText));
1177
+ add('@resource', details.resources.map(fileText));
1178
+ add('@icon', iconAt ? ui `${url(fileUrlShown(iconAt))}${iconAt.local !== undefined ? ui ` (read from ${url(iconAt.local)})` : ''}` : undefined);
1179
+ // Only once looked up, which a page showing them does too.
1180
+ add('Site icons', siteIcons ? siteIconLines(siteIcons, (p) => p) : undefined);
1181
+ if (ctx.run)
1182
+ add('Tabs', tabs.length > 0 ? tabs.map((t) => ui `${idRole(t.activity.tab)}: ${t.text}`) : 'None open');
1183
+ if (details.valueCount !== undefined) {
1184
+ add('Values', details.valueCount === 0 ? 'None' : ui `${details.valueCount} stored${ctx.run && label !== undefined ? ui ` (${cmd(`values ${label}`)} to see them)` : ''}`);
1185
+ }
1186
+ const width = Math.max(...rows.map(([name]) => name.length)) + 2;
1187
+ for (const [name, value] of rows) {
1188
+ const lines = typeof value === 'string' ? [value] : value;
1189
+ lines.forEach((line, i) => ctx.out(ui ` ${i === 0 ? ui `${heading(name)}:` : ''}${' '.repeat(Math.max(0, width - (i === 0 ? name.length + 1 : 0)))}${line}`));
1190
+ }
1191
+ }
1192
+ // A message's later lines, indented to sit under its first (a GitHub
1193
+ // read's hint starts a line of its own).
1194
+ function indentLines(text, indent) {
1195
+ return text.replace(/\n/g, `\n${indent}`);
1196
+ }