spiderlint 0.0.0 → 0.143.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 (186) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +244 -0
  3. package/bin/spiderlint.js +11 -0
  4. package/dist/agent.js +46 -0
  5. package/dist/cache/extractors.js +37 -0
  6. package/dist/cache/http.js +99 -0
  7. package/dist/cache/index.js +101 -0
  8. package/dist/cache/purge.js +63 -0
  9. package/dist/cache/status.js +52 -0
  10. package/dist/cli.js +602 -0
  11. package/dist/color.js +10 -0
  12. package/dist/config/environment.js +88 -0
  13. package/dist/config/index.js +104 -0
  14. package/dist/config/policy.js +170 -0
  15. package/dist/config/schema.js +127 -0
  16. package/dist/crawl/body.js +92 -0
  17. package/dist/crawl/browser.js +498 -0
  18. package/dist/crawl/crawl.js +58 -0
  19. package/dist/crawl/diversity.js +153 -0
  20. package/dist/crawl/dns.js +194 -0
  21. package/dist/crawl/fetch.js +48 -0
  22. package/dist/crawl/frontier.js +370 -0
  23. package/dist/crawl/guard.js +69 -0
  24. package/dist/crawl/http.js +171 -0
  25. package/dist/crawl/links.js +89 -0
  26. package/dist/crawl/log.js +27 -0
  27. package/dist/crawl/network.js +88 -0
  28. package/dist/crawl/probe.js +107 -0
  29. package/dist/crawl/profile.js +33 -0
  30. package/dist/crawl/rdap.js +86 -0
  31. package/dist/crawl/resolve.js +132 -0
  32. package/dist/crawl/resources.js +233 -0
  33. package/dist/crawl/robots.js +100 -0
  34. package/dist/crawl/route.js +92 -0
  35. package/dist/crawl/scope.js +32 -0
  36. package/dist/crawl/scratch.js +15 -0
  37. package/dist/crawl/sitemap.js +187 -0
  38. package/dist/crawl/svcb.js +59 -0
  39. package/dist/crawl/tls-hello.js +297 -0
  40. package/dist/crawl/tls-probe.js +71 -0
  41. package/dist/facts/browser.js +138 -0
  42. package/dist/facts/byline.js +17 -0
  43. package/dist/facts/co2.js +27 -0
  44. package/dist/facts/csp.js +71 -0
  45. package/dist/facts/export.js +79 -0
  46. package/dist/facts/flatten.js +33 -0
  47. package/dist/facts/graph.js +73 -0
  48. package/dist/facts/headers.js +330 -0
  49. package/dist/facts/html.js +230 -0
  50. package/dist/facts/labels.js +89 -0
  51. package/dist/facts/language.js +32 -0
  52. package/dist/facts/parity.js +38 -0
  53. package/dist/facts/read-note.js +11 -0
  54. package/dist/facts/resources.js +46 -0
  55. package/dist/facts/robots.js +13 -0
  56. package/dist/facts/sites.js +140 -0
  57. package/dist/facts/transport.js +200 -0
  58. package/dist/facts/types.js +4 -0
  59. package/dist/facts/vendors.js +76 -0
  60. package/dist/fold/index.js +94 -0
  61. package/dist/groups/assign.js +45 -0
  62. package/dist/groups/sample.js +60 -0
  63. package/dist/i18n.js +92 -0
  64. package/dist/index.js +607 -0
  65. package/dist/interrupt.js +37 -0
  66. package/dist/logger.js +95 -0
  67. package/dist/plugins/axe.js +104 -0
  68. package/dist/plugins/carbon-txt.js +96 -0
  69. package/dist/plugins/cookies-registry.js +25 -0
  70. package/dist/plugins/cookies.js +168 -0
  71. package/dist/plugins/css.js +328 -0
  72. package/dist/plugins/dns.js +864 -0
  73. package/dist/plugins/feed-content.js +132 -0
  74. package/dist/plugins/feed-model.js +282 -0
  75. package/dist/plugins/feeds.js +836 -0
  76. package/dist/plugins/html-validate.js +191 -0
  77. package/dist/plugins/htmlhint.js +95 -0
  78. package/dist/plugins/icons.js +302 -0
  79. package/dist/plugins/images.js +269 -0
  80. package/dist/plugins/index.js +286 -0
  81. package/dist/plugins/keyboard.js +194 -0
  82. package/dist/plugins/lighthouse.js +110 -0
  83. package/dist/plugins/link-text.js +73 -0
  84. package/dist/plugins/list.js +22 -0
  85. package/dist/plugins/live.js +254 -0
  86. package/dist/plugins/mail.js +985 -0
  87. package/dist/plugins/manifest.js +97 -0
  88. package/dist/plugins/markup.js +98 -0
  89. package/dist/plugins/messages.js +25 -0
  90. package/dist/plugins/network.js +192 -0
  91. package/dist/plugins/origin.js +488 -0
  92. package/dist/plugins/profile-links.js +102 -0
  93. package/dist/plugins/schema-registry.js +101 -0
  94. package/dist/plugins/sshfp.js +159 -0
  95. package/dist/plugins/stack.js +161 -0
  96. package/dist/plugins/structured-data.js +358 -0
  97. package/dist/plugins/tls-probe.js +298 -0
  98. package/dist/plugins/tls-registry.js +418 -0
  99. package/dist/plugins/tls-scan.js +280 -0
  100. package/dist/plugins/trackers-registry.js +27 -0
  101. package/dist/plugins/trackers.js +56 -0
  102. package/dist/plugins/types.js +7 -0
  103. package/dist/plugins/visit.js +50 -0
  104. package/dist/plugins/well-known-registry.js +111 -0
  105. package/dist/plugins/well-known.js +732 -0
  106. package/dist/progress.js +162 -0
  107. package/dist/report/agent.js +122 -0
  108. package/dist/report/checkstyle.js +22 -0
  109. package/dist/report/csv.js +18 -0
  110. package/dist/report/html.js +266 -0
  111. package/dist/report/human.js +220 -0
  112. package/dist/report/index.js +16 -0
  113. package/dist/report/json.js +8 -0
  114. package/dist/report/printable.js +29 -0
  115. package/dist/report/rating.js +26 -0
  116. package/dist/report/sarif.js +100 -0
  117. package/dist/report/stats.js +46 -0
  118. package/dist/rules/builtin.js +465 -0
  119. package/dist/rules/caching.js +103 -0
  120. package/dist/rules/catalog.js +139 -0
  121. package/dist/rules/clock.js +48 -0
  122. package/dist/rules/declarative.js +234 -0
  123. package/dist/rules/deprecated.js +45 -0
  124. package/dist/rules/disclosure.js +99 -0
  125. package/dist/rules/fix.js +21 -0
  126. package/dist/rules/i18n.js +131 -0
  127. package/dist/rules/insights.js +100 -0
  128. package/dist/rules/length.js +52 -0
  129. package/dist/rules/message.js +48 -0
  130. package/dist/rules/relations.js +88 -0
  131. package/dist/rules/robots.js +75 -0
  132. package/dist/rules/rulesets.js +134 -0
  133. package/dist/rules/run.js +75 -0
  134. package/dist/rules/score.js +61 -0
  135. package/dist/rules/types.js +7 -0
  136. package/dist/rules/url.js +90 -0
  137. package/dist/server/analytics.js +49 -0
  138. package/dist/server/api.js +134 -0
  139. package/dist/server/clients.js +54 -0
  140. package/dist/server/clock.js +33 -0
  141. package/dist/server/compress.js +58 -0
  142. package/dist/server/health.js +14 -0
  143. package/dist/server/jobs.js +82 -0
  144. package/dist/server/main.js +83 -0
  145. package/dist/server/page.js +161 -0
  146. package/dist/server/policy.js +145 -0
  147. package/dist/server/providers.js +60 -0
  148. package/dist/server/queue.js +50 -0
  149. package/dist/server/scan.js +39 -0
  150. package/dist/server/settings.js +197 -0
  151. package/dist/server/web.js +353 -0
  152. package/dist/server/worker.js +129 -0
  153. package/dist/store/disk.js +215 -0
  154. package/dist/store/memory.js +18 -0
  155. package/dist/telemetry-environment.js +9 -0
  156. package/dist/telemetry.js +104 -0
  157. package/locales/es/LC_MESSAGES/messages.po +1864 -0
  158. package/locales/uk/LC_MESSAGES/messages.po +1864 -0
  159. package/package.json +134 -2
  160. package/presets/browser.yaml +26 -0
  161. package/presets/graph.yaml +35 -0
  162. package/presets/i18n.yaml +18 -0
  163. package/presets/insights.yaml +12 -0
  164. package/presets/links.yaml +22 -0
  165. package/presets/parity.yaml +59 -0
  166. package/presets/performance.yaml +271 -0
  167. package/presets/recommended.yaml +50 -0
  168. package/presets/redirects.yaml +23 -0
  169. package/presets/resources.yaml +29 -0
  170. package/presets/robots.yaml +13 -0
  171. package/presets/security-headers.yaml +257 -0
  172. package/presets/seo.yaml +380 -0
  173. package/presets/sitemap.yaml +34 -0
  174. package/presets/suggestions.yaml +97 -0
  175. package/presets/sustainability.yaml +16 -0
  176. package/presets/tls.yaml +72 -0
  177. package/presets/vendor.yaml +14 -0
  178. package/presets/web-comprehensive.yaml +17 -0
  179. package/presets/web-quick.yaml +15 -0
  180. package/src/server/apple-touch-icon.png +0 -0
  181. package/src/server/logo-36.png +0 -0
  182. package/src/server/logo-72.png +0 -0
  183. package/src/server/logo.png +0 -0
  184. package/vendors/dns.yaml +21 -0
  185. package/vendors/paths.yaml +55 -0
  186. package/vendors/stack.yaml +57 -0
package/dist/cli.js ADDED
@@ -0,0 +1,602 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
3
+ //
4
+ // SPDX-License-Identifier: AGPL-3.0-only
5
+ import path from "node:path";
6
+ import { getCACertificates, setDefaultCACertificates } from "node:tls";
7
+ import { Command, CommanderError, Option } from "commander";
8
+ import { DESCRIPTION, HOMEPAGE, VERSION, normalizeVia } from "./agent.js";
9
+ import { painter } from "./color.js";
10
+ import { OfflineMiss, parseDuration, siteDirectory, userCacheDirectory } from "./cache/index.js";
11
+ import { PURGEABLE, purgeCache } from "./cache/purge.js";
12
+ import { cacheStatus } from "./cache/status.js";
13
+ import { audit, crawl, factsStore, lintStore, loadPlugins, reportStore, warmCache } from "./index.js";
14
+ import { ConfigError, PROFILES, ROLES, layered, originOf, proxyOf, seedOf } from "./config/index.js";
15
+ import { BROWSERS, environmentSettings, FETCH_MODES, parseFailOn, parseInteger, pick, SCOPES } from "./config/environment.js";
16
+ import { loadSettings } from "./config/policy.js";
17
+ import { parseResolver } from "./crawl/dns.js";
18
+ import { FACT_FORMATS, formatFacts } from "./facts/export.js";
19
+ import { parsePin } from "./crawl/resolve.js";
20
+ import { formatNames, formatter, withSources } from "./plugins/index.js";
21
+ import { NothingStored } from "./store/disk.js";
22
+ import { writeAgentFiles } from "./report/agent.js";
23
+ import { scoreOf } from "./rules/score.js";
24
+ import { explainRule, formatExplanation, formatPresets, formatRules, listPresets, listRules } from "./rules/catalog.js";
25
+ import { handleInterrupts, Interrupted } from "./interrupt.js";
26
+ import { isLogLevel, log, logColor } from "./logger.js";
27
+ import { enableProgress } from "./progress.js";
28
+ import { inSpan, nameSpan, startTelemetry } from "./telemetry.js";
29
+ const RANK = { never: -1, error: 0, warning: 1, info: 2, hint: 3 };
30
+ const DOMAIN = "A domain is example.com or a URL to start from; without a scheme, https:// is assumed.";
31
+ const TARGETS = "With no domain, the targets come from org.spiderlint in the config, one run per site.";
32
+ const IDS = "Rule IDs and rulesets are comma-separated; an ID may be a glob such as lighthouse/*.";
33
+ // --error, --warning, --info and --hint values in argv order, so the last flag naming a rule wins.
34
+ const overrideOrder = [];
35
+ // A flag whose help ends in its default and environment variable.
36
+ function flag(flags, text, fallback, environment) {
37
+ const hints = [fallback && `default: ${fallback}`, environment && `env: ${environment}`].filter(Boolean);
38
+ return new Option(flags, hints.length > 0 ? `${text} (${hints.join(", ")})` : text);
39
+ }
40
+ // A flag that collects every value it is given.
41
+ function repeatable(flags, text, fallback, environment) {
42
+ return flag(flags, text, fallback, environment).argParser((value, previous) => [...(previous ?? []), value]);
43
+ }
44
+ // A severity override flag, recorded in argv order.
45
+ function severity(name, text) {
46
+ return flag(`--${name} <ids>`, text, undefined, `SPIDERLINT_OVERRIDE_${name.toUpperCase()}`).argParser((value, previous) => {
47
+ overrideOrder.push([name, value]);
48
+ return [...(previous ?? []), value];
49
+ });
50
+ }
51
+ // A --name and --no-name pair, shown as one --[no-]name row.
52
+ function toggle(name, text, fallback, environment) {
53
+ return [Object.assign(flag(`--${name}`, text, fallback, environment), { flags: `--[no-]${name}` }), new Option(`--no-${name}`).hideHelp()];
54
+ }
55
+ // Options under one help heading.
56
+ function section(title, options) {
57
+ return options.map((option) => option.helpGroup(`${title}:`));
58
+ }
59
+ function crawlOptions() {
60
+ return section("Crawl", [
61
+ flag("--fetch <mode>", "auto, http, browser or adaptive", "auto", "SPIDERLINT_FETCH"),
62
+ flag("--browser <name>", "chromium, firefox or webkit", "chromium", "SPIDERLINT_BROWSER"),
63
+ flag("--scope <scope>", "follow links within the origin, host or domain", "origin", "SPIDERLINT_SCOPE"),
64
+ flag("--concurrency <n>", "pages in flight, 0: one per CPU, halved in a browser", "0", "SPIDERLINT_CONCURRENCY"),
65
+ flag("--rate <n>", "requests per minute, 0 for no limit", "0", "SPIDERLINT_RATE"),
66
+ flag("--timeout <seconds>", "seconds one page may take", "60", "SPIDERLINT_TIMEOUT"),
67
+ flag("--profile <name>", "tor or i2p: local proxy, concurrency 4, timeout 240", undefined, "SPIDERLINT_PROFILE"),
68
+ flag("--proxy <url>", "http, https or socks5h proxy for every request", undefined, "SPIDERLINT_PROXY"),
69
+ flag("--via <host>", "instance a web scan names in its user agent", undefined, "SPIDERLINT_VIA"),
70
+ flag("--max-pages <n>", "page limit, 0 for none", "0", "SPIDERLINT_MAX_PAGES"),
71
+ flag("--max-depth <n>", "link depth limit, 0 for none", "0", "SPIDERLINT_MAX_DEPTH"),
72
+ flag("--max-body-size <bytes>", "body size cap", "10000000", "SPIDERLINT_MAX_BODY_SIZE"),
73
+ repeatable("--include-urls <glob>", "crawl only matching paths and queries, repeatable", undefined, "SPIDERLINT_INCLUDE_URLS"),
74
+ repeatable("--exclude-urls <glob>", "skip matching paths and queries, repeatable", undefined, "SPIDERLINT_EXCLUDE_URLS"),
75
+ repeatable("--source <id:arg>", "add a plugin source’s URLs; list:FILE crawls a URL list only, repeatable", undefined, "SPIDERLINT_SOURCES"),
76
+ flag("--no-robots", "ignore robots.txt", undefined, "SPIDERLINT_ROBOTS=false"),
77
+ flag("--no-sitemap", "skip sitemap discovery", undefined, "SPIDERLINT_SITEMAP=false"),
78
+ flag("--no-keepalive", "one connection per request", undefined, "SPIDERLINT_KEEPALIVE=false"),
79
+ flag("--no-resources", "skip scripts, styles, images and fonts", undefined, "SPIDERLINT_RESOURCES=false"),
80
+ flag("--canonical-origin <url>", "origin the pages are built for; its URLs count as the crawled one’s", undefined, "SPIDERLINT_CANONICAL_ORIGIN"),
81
+ flag("--role <role>", "production, staging or development", "production", "SPIDERLINT_ROLE"),
82
+ flag("--resolver <list>", "DNS servers to ask, address[:port],…", "system", "SPIDERLINT_RESOLVER"),
83
+ repeatable("--resolve <pin>", "pin host[:port]:address instead of DNS, repeatable", undefined, "SPIDERLINT_RESOLVE"),
84
+ flag("--no-allow-private", "refuse loopback, private and link-local addresses", undefined, "SPIDERLINT_ALLOW_PRIVATE=false"),
85
+ flag("--no-browser-install", "fail on a missing browser, print its install command", undefined, "SPIDERLINT_BROWSER_INSTALL=false"),
86
+ ]);
87
+ }
88
+ function ruleOptions() {
89
+ return section("Rules", [
90
+ repeatable("--rules <rulesets>", "rulesets or rule IDs to run in every group", "recommended", "SPIDERLINT_RULES"),
91
+ flag("--exclude-rules <ids>", "skip these rules", undefined, "SPIDERLINT_EXCLUDE_RULES"),
92
+ severity("error", "report these rules as errors"),
93
+ severity("warning", "report these rules as warnings"),
94
+ severity("info", "report these rules as info"),
95
+ severity("hint", "report these rules as hints: no grade, no failure"),
96
+ ]);
97
+ }
98
+ function reportOptions() {
99
+ return section("Report", [
100
+ flag("--format <format>", `${formatNames().join(", ")}, …`, "human", "SPIDERLINT_FORMAT"),
101
+ flag("--fail-on <level>", "exit 1 at error, warning, info, 0.1–9.9 or never", "error", "SPIDERLINT_FAIL_ON"),
102
+ flag("--unfold", "one finding per page, every URL and location listed", undefined, "SPIDERLINT_FOLD=false"),
103
+ flag("--show-hints", "list hints in human output, not only their count"),
104
+ flag("--explain", "print each finding’s fix and docs in human output"),
105
+ flag("--stats", "count, min, median, p95, max, total per numeric fact"),
106
+ flag("--output <dir>", "with --format agent, one prompt file per rule in dir"),
107
+ ]);
108
+ }
109
+ // The store directory, its default shown as the path it resolves to.
110
+ function storeOption() {
111
+ return flag("--store <dir>", "store directory", path.join(userCacheDirectory(), "<host>"));
112
+ }
113
+ function storeOptions() {
114
+ return section("Store", [storeOption()]);
115
+ }
116
+ function cacheOptions() {
117
+ return section("Store", [
118
+ storeOption(),
119
+ flag("--resume", "continue an interrupted crawl"),
120
+ flag("--no-cache", "neither read nor write the cache", undefined, "SPIDERLINT_CACHE=off"),
121
+ flag("--refresh", "refetch everything, rewrite the cache", undefined, "SPIDERLINT_CACHE=refresh"),
122
+ flag("--offline", "cache only, a miss exits 3", undefined, "SPIDERLINT_CACHE=offline"),
123
+ ]);
124
+ }
125
+ function factOptions() {
126
+ return section("Output", [flag("--format <format>", FACT_FORMATS.join(", "), "human"), repeatable("--facts <glob>", "fact paths to show, repeatable")]);
127
+ }
128
+ function listingOptions() {
129
+ return section("Output", [flag("--format <format>", "human or json", "human", "SPIDERLINT_FORMAT")]);
130
+ }
131
+ const VERBS = [
132
+ {
133
+ name: "audit",
134
+ args: ["[domain...]"],
135
+ group: "Check a site",
136
+ summary: "crawl a site, then lint it",
137
+ about: ["Keeps the pages in the store, so lint and show-report reuse them offline.", DOMAIN, TARGETS, IDS],
138
+ options: () => [...crawlOptions(), ...ruleOptions(), ...reportOptions(), ...cacheOptions()],
139
+ examples: [
140
+ ["Audit a site with the recommended rules", "spiderlint audit example.com"],
141
+ ["Write a SARIF report for code scanning", "spiderlint audit example.com --format sarif > report.sarif"],
142
+ ["Run every rule except Lighthouse’s", "spiderlint audit example.com --rules all --exclude-rules 'lighthouse/*'"],
143
+ ["Check a single rule", "spiderlint audit example.com --rules http/alt-svc-h3"],
144
+ ["Audit only the URLs listed in a file", "spiderlint audit --source list:urls.txt"],
145
+ ],
146
+ },
147
+ {
148
+ name: "crawl",
149
+ args: ["[domain...]"],
150
+ group: "Check a site",
151
+ summary: "fetch pages into the store, lint nothing",
152
+ about: [DOMAIN, TARGETS],
153
+ options: () => [...crawlOptions(), ...cacheOptions()],
154
+ examples: [
155
+ ["Crawl now, lint later", "spiderlint crawl example.com"],
156
+ ["Continue a crawl that was interrupted", "spiderlint crawl example.com --resume"],
157
+ ],
158
+ },
159
+ {
160
+ name: "lint",
161
+ args: ["[domain...]"],
162
+ group: "Check a site",
163
+ summary: "lint the stored pages, with no network",
164
+ about: [DOMAIN, TARGETS, IDS],
165
+ options: () => [...ruleOptions(), ...reportOptions(), ...storeOptions()],
166
+ examples: [
167
+ ["Lint the last crawl, failing on warnings too", "spiderlint lint example.com --fail-on warning"],
168
+ ["Try every rule on the same pages", "spiderlint lint example.com --rules all"],
169
+ ],
170
+ },
171
+ {
172
+ name: "show-report",
173
+ args: ["[domain...]"],
174
+ group: "Check a site",
175
+ summary: "print the stored report again, in any format",
176
+ about: [DOMAIN, TARGETS],
177
+ options: () => [...reportOptions(), ...storeOptions()],
178
+ examples: [["Turn the last report into a web page", "spiderlint show-report example.com --format html > report.html"]],
179
+ },
180
+ {
181
+ name: "show-facts",
182
+ args: ["<url>"],
183
+ group: "Inspect",
184
+ summary: "fetch one page and print its facts",
185
+ about: ["Facts are what spiderlint records about a page, such as headers, HTML, TLS, timings and sizes; rules judge them. The site’s own facts are under site.", DOMAIN],
186
+ options: () => [...crawlOptions(), ...factOptions()],
187
+ examples: [
188
+ ["See every fact about the home page", "spiderlint show-facts example.com"],
189
+ ["Print one page’s facts as JSON", "spiderlint show-facts example.com/about/ --format json"],
190
+ ],
191
+ },
192
+ {
193
+ name: "export-facts",
194
+ args: ["[domain...]"],
195
+ group: "Inspect",
196
+ summary: "print every stored page’s facts, no network",
197
+ about: ["Facts are what spiderlint records about a page, such as headers, HTML, TLS, timings and sizes; rules judge them.", DOMAIN, TARGETS],
198
+ options: () => [...factOptions(), ...storeOptions()],
199
+ examples: [["Export page weight and timings as CSV", "spiderlint export-facts example.com --format csv --facts 'co2.*' --facts 'http.timing.*'"]],
200
+ },
201
+ {
202
+ name: "list-groups",
203
+ args: ["[domain...]"],
204
+ group: "Inspect",
205
+ summary: "count the pages in each URL group",
206
+ about: ["Crawls the site and names the pages no group in org.spiderlint.groups matched.", DOMAIN, TARGETS],
207
+ options: crawlOptions,
208
+ examples: [["Check the groups match the site’s templates", "spiderlint list-groups example.com --max-pages 200"]],
209
+ },
210
+ {
211
+ name: "list-rules",
212
+ args: ["[ruleset|id...]"],
213
+ group: "Rules",
214
+ summary: "list rules at the severity this config gives",
215
+ about: [IDS],
216
+ options: () => [...ruleOptions(), ...listingOptions()],
217
+ examples: [
218
+ ["List the security header rules", "spiderlint list-rules security-headers"],
219
+ ["List every rule as JSON", "spiderlint list-rules --format json"],
220
+ ],
221
+ },
222
+ {
223
+ name: "list-presets",
224
+ args: [],
225
+ group: "Rules",
226
+ summary: "list the shipped rulesets and their groups",
227
+ about: [],
228
+ options: listingOptions,
229
+ examples: [["See which presets exist", "spiderlint list-presets"]],
230
+ },
231
+ {
232
+ name: "explain-rule",
233
+ args: ["<rule>"],
234
+ group: "Rules",
235
+ summary: "show a rule’s facts, expectation and fix",
236
+ about: [],
237
+ options: listingOptions,
238
+ examples: [["Explain a rule before turning it on", "spiderlint explain-rule html/theme-color-schemes"]],
239
+ },
240
+ {
241
+ name: "show-cache",
242
+ args: ["[domain...]"],
243
+ group: "Cache",
244
+ summary: "show the entries, bytes and age of each bucket",
245
+ about: [DOMAIN],
246
+ options: storeOptions,
247
+ examples: [["See what is cached for a site", "spiderlint show-cache example.com"]],
248
+ },
249
+ {
250
+ name: "purge-cache",
251
+ args: ["[bucket]", "[domain...]"],
252
+ group: "Cache",
253
+ summary: "delete a site’s cached entries",
254
+ about: [`A bucket is one of ${[...PURGEABLE].join(", ")}; with none, every bucket is purged.`, DOMAIN],
255
+ options: () => [...section("Purge", [flag("--older-than <age>", "only entries older than 45s, 30m, 24h or 7d")]), ...storeOptions()],
256
+ examples: [["Drop cached pages older than a week", "spiderlint purge-cache pages example.com --older-than 7d"]],
257
+ },
258
+ {
259
+ name: "warm-cache",
260
+ args: ["[domain...]"],
261
+ group: "Cache",
262
+ summary: "fetch robots.txt and sitemaps without crawling",
263
+ about: [DOMAIN, TARGETS],
264
+ options: () => [...crawlOptions(), ...storeOptions()],
265
+ examples: [["Prefetch robots.txt and sitemaps before an --offline run", "spiderlint warm-cache example.com"]],
266
+ },
267
+ ];
268
+ // Usage, description, command groups, then option groups; a command’s own screen leaves -h to the main one.
269
+ function layout(command, helper) {
270
+ const width = helper.padWidth(command, helper);
271
+ const item = (term, text) => helper.formatItem(term, width, text, helper);
272
+ const list = (heading, items) => (items.length > 0 ? ["", helper.styleTitle(heading), ...items] : []);
273
+ const room = (helper.helpWidth ?? 80) - width - 4;
274
+ // An option whose hint would wrap carries the hint on a line of its own, under the text column.
275
+ const optionItems = (option) => {
276
+ const term = helper.styleOptionTerm(helper.optionTerm(option));
277
+ const text = helper.optionDescription(option);
278
+ const [, head, hint] = /^(.*) (\((?:default|env): [^)]*\))$/.exec(text) ?? [];
279
+ if (head === undefined || hint === undefined || helper.displayWidth(text) <= room)
280
+ return [item(term, helper.styleOptionDescription(text))];
281
+ const hints = helper.displayWidth(hint) <= room ? [hint] : hint.replace(", env: ", ")\n(env: ").split("\n");
282
+ return [item(term, helper.styleOptionDescription(head)), ...hints.map((line) => item("", helper.styleOptionDescription(line)))];
283
+ };
284
+ const commands = helper.groupItems([...command.commands], helper.visibleCommands(command), (sub) => sub.helpGroup() || "Commands:");
285
+ const options = helper.groupItems([...command.options], helper.visibleOptions(command).filter((option) => !command.parent || option.long !== "--help"), (option) => option.helpGroupHeading ?? "Options:");
286
+ return [
287
+ `${helper.styleTitle("Usage:")} ${helper.styleUsage(helper.commandUsage(command))}`,
288
+ helper.boxWrap(helper.styleCommandDescription(helper.commandDescription(command)), helper.helpWidth ?? 80),
289
+ ...[...commands].flatMap(([heading, subs]) => list(heading, subs.map((sub) => item(helper.styleSubcommandTerm(helper.subcommandTerm(sub)), helper.styleSubcommandDescription(helper.subcommandDescription(sub)))))),
290
+ ...[...options].flatMap(([heading, flags]) => list(heading, flags.flatMap((option) => optionItems(option)))),
291
+ "",
292
+ ].join("\n");
293
+ }
294
+ // Commander’s error on one line; an old command name points at the verbs that replaced it.
295
+ function oneLine(root, message) {
296
+ const name = /unknown command '([^']+)'/.exec(message)?.[1];
297
+ const verbs = root.commands.map((command) => command.name()).filter((verb) => name !== undefined && verb.split("-").includes(name));
298
+ log.debug({ name, verbs }, "command line refused");
299
+ return verbs.length > 0
300
+ ? `unknown command '${name}' (did you mean ${verbs.join(" or ")}?)`
301
+ : message
302
+ .replace(/^error: /, "")
303
+ .replaceAll("(Did you", "(did you")
304
+ .split("\n")
305
+ .filter(Boolean)
306
+ .join(" ");
307
+ }
308
+ // The whole command line: tool-wide options, one command per verb, each calling `act`.
309
+ function program(act) {
310
+ const root = new Command("spiderlint");
311
+ const paint = () => painter(process.stdout, root.getOptionValue("color"));
312
+ const hasColors = () => root.getOptionValue("color") !== false;
313
+ root.configureHelp({
314
+ formatHelp: layout,
315
+ styleTitle: (text) => paint()("bold", text),
316
+ styleUsage: (text) => text.replaceAll("...", "…"),
317
+ styleSubcommandTerm: (text) => paint()("cyan", text.replace(" [options]", "").replaceAll("...", "…")),
318
+ styleOptionTerm: (text) => paint()("cyan", text),
319
+ styleOptionDescription: (text) => text.replace(/(^| )(\((default|env): [^)]*\))$/, (_hint, lead, hint) => `${lead}${paint()("dim", hint)}`),
320
+ });
321
+ root.configureOutput({ writeOut: (text) => console.log(text.replace(/\n$/, "")), writeErr: (text) => console.error(text.replace(/\n$/, "")), getOutHasColors: hasColors, getErrHasColors: hasColors, outputError: (message, write) => write(`spiderlint: ${oneLine(root, message)} (see spiderlint --help)\n`) });
322
+ root.exitOverride();
323
+ root.usage("<command> [domain…] [options]").description(DOMAIN);
324
+ root.addOption(flag("--config <path>", "settings file", "projectfile.yaml", "SPIDERLINT_CONFIG"));
325
+ root.addOption(repeatable("--site <names>", "only these org.spiderlint.sites, repeatable", "all"));
326
+ for (const option of [...toggle("color", "force or disable color", "auto", "NO_COLOR, FORCE_COLOR"), ...toggle("progress", "status line on an interactive stderr", "auto")])
327
+ root.addOption(option);
328
+ root.addOption(flag("--log-level <level>", "trace, debug, info, warn, error or silent", "warn", "SPIDERLINT_LOG_LEVEL"));
329
+ root.version(VERSION, "-V, --version", "show the version");
330
+ root.helpOption("-h, --help", "show this screen, or a command’s");
331
+ root.helpCommand("help [command]", "show a command’s options and examples");
332
+ root.addHelpText("before", () => [paint()("bold", "spiderlint"), DESCRIPTION, paint()("underline", HOMEPAGE), ""].join("\n"));
333
+ root.addHelpText("after", () => ["", `Run ${paint()("green", "spiderlint <command> --help")} for a command’s options and examples.`, "", paint()("bold", "Exit codes:"), " 0 clean", " 1 findings at or above --fail-on", " 2 usage or config error", " 3 nothing fetched, or an --offline cache miss", " 4 the run failed"].join("\n"));
334
+ const shared = root.options.filter((option) => !option.hidden && option.long !== "--version").map((option) => option.flags.split(" ", 1)[0]);
335
+ for (const verb of VERBS) {
336
+ const command = root.command(verb.name).helpGroup(`${verb.group}:`).summary(verb.summary);
337
+ command.description([`${verb.summary[0]?.toUpperCase()}${verb.summary.slice(1)}.`, ...(verb.about.length > 0 ? ["", ...verb.about] : [])].join("\n"));
338
+ for (const argument of verb.args)
339
+ command.argument(argument);
340
+ for (const option of verb.options())
341
+ command.addOption(option);
342
+ command.addHelpText("after", () => ["", `Options for every command, see ${paint()("green", "spiderlint --help")}:`, ` ${shared.join(", ")}`, "", paint()("bold", "Examples:"), ...verb.examples.flatMap(([description, line]) => [` ${description}:`, ` ${paint()("green", line)}`])].join("\n"));
343
+ command.action(async (...arguments_) => act(arguments_.at(-1)));
344
+ }
345
+ return root;
346
+ }
347
+ // Flags given on the command line, keyed by long name; defaults and unset flags are left out.
348
+ function flagsOf(command) {
349
+ const given = Object.entries(command.optsWithGlobals()).filter(([key]) => command.getOptionValueSourceWithGlobals(key) === "cli");
350
+ return Object.fromEntries(given.map(([key, value]) => [key.replaceAll(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`), value]));
351
+ }
352
+ // At most one of --no-cache, --refresh, --offline; undefined when none is passed.
353
+ function cacheMode(values) {
354
+ const modes = [values.cache === false && "off", values.refresh === true && "refresh", values.offline === true && "offline"].filter((mode) => mode !== false);
355
+ if (modes.length > 1)
356
+ throw new ConfigError(`--no-cache, --refresh and --offline exclude each other (got ${modes.join(", ")})`);
357
+ return modes[0];
358
+ }
359
+ // 1 once any finding reaches --fail-on, which no hint does; 3 when nothing was fetched.
360
+ function exitCode(report, failOn) {
361
+ if (report.pages.length === 0)
362
+ return 3;
363
+ if (typeof failOn === "number")
364
+ return report.findings.some((finding) => finding.severity !== "hint" && scoreOf(finding) >= failOn) ? 1 : 0;
365
+ return report.findings.some((finding) => finding.severity !== "hint" && RANK[finding.severity] <= RANK[failOn]) ? 1 : 0;
366
+ }
367
+ function groupsOf(report) {
368
+ const counts = {};
369
+ for (const page of report.pages)
370
+ counts[page.group] = (counts[page.group] ?? 0) + 1;
371
+ const modes = new Map(Object.entries(report.summary.fetch ?? {}));
372
+ const lines = Object.entries(counts).map(([group, pages]) => `${group}: ${pages} pages${modes.has(group) ? `, fetch ${modes.get(group)}` : ""}`);
373
+ const fell = report.pages.filter((page) => page.group === "default").map((page) => ` ${page.url.href}`);
374
+ return [...lines, ...(fell.length > 0 ? ["fell through to default:", ...fell] : [])].join("\n");
375
+ }
376
+ function splitIds(raw) {
377
+ return raw.split(/[\s,]+/).filter((entry) => entry.length > 0);
378
+ }
379
+ // `--error`/`--warning`/`--info`/`--hint` in argv order, so the last flag naming a rule wins.
380
+ function overridesInOrder() {
381
+ return Object.fromEntries(overrideOrder.flatMap(([name, raw]) => splitIds(raw).map((id) => [id, name])));
382
+ }
383
+ // Flags actually passed become a Settings patch; an unset flag leaves the ladder's lower tiers alone.
384
+ function flagSettings(values) {
385
+ const overrides = overridesInOrder();
386
+ return {
387
+ ...(values["canonical-origin"] !== undefined && { canonicalOrigin: originOf("--canonical-origin", values["canonical-origin"]) }),
388
+ ...(values.role !== undefined && { role: pick("--role", values.role, ROLES) }),
389
+ ...(values.resolver !== undefined && { resolver: parseResolver(values.resolver) }),
390
+ ...(values.resolve !== undefined && { resolve: values.resolve.map((pin) => parsePin(pin)) }),
391
+ ...(values.fetch !== undefined && { fetch: pick("--fetch", values.fetch, FETCH_MODES) }),
392
+ ...(values.browser !== undefined && { browser: pick("--browser", values.browser, BROWSERS) }),
393
+ ...(values.scope !== undefined && { scope: pick("--scope", values.scope, SCOPES) }),
394
+ ...(values.concurrency !== undefined && { concurrency: parseInteger("--concurrency", values.concurrency) }),
395
+ ...(values.rate !== undefined && { rate: parseInteger("--rate", values.rate) }),
396
+ ...(values.timeout !== undefined && { timeout: parseInteger("--timeout", values.timeout) }),
397
+ ...(values.profile !== undefined && { profile: pick("--profile", values.profile, Object.keys(PROFILES)) }),
398
+ ...(values.proxy !== undefined && { proxy: proxyOf("--proxy", values.proxy) }),
399
+ ...(values.via !== undefined && { via: normalizeVia(values.via) }),
400
+ ...(values["max-pages"] !== undefined && { maxPages: parseInteger("--max-pages", values["max-pages"]) }),
401
+ ...(values["max-depth"] !== undefined && { maxDepth: parseInteger("--max-depth", values["max-depth"]) }),
402
+ ...(values["max-body-size"] !== undefined && { maxBodySize: parseInteger("--max-body-size", values["max-body-size"]) }),
403
+ ...(values["include-urls"] !== undefined && { includeUrls: values["include-urls"] }),
404
+ ...(values.source !== undefined && { sources: values.source }),
405
+ ...(values["exclude-urls"] !== undefined && { excludeUrls: values["exclude-urls"] }),
406
+ ...(values.robots !== undefined && { robots: values.robots }),
407
+ ...(values.sitemap !== undefined && { sitemap: values.sitemap }),
408
+ ...(values.keepalive !== undefined && { keepalive: values.keepalive }),
409
+ ...(values.resources !== undefined && { fetchResources: values.resources }),
410
+ ...(values["allow-private"] !== undefined && { allowPrivate: values["allow-private"] }),
411
+ ...(values["browser-install"] !== undefined && { browserInstall: values["browser-install"] }),
412
+ ...(values.unfold !== undefined && { fold: !values.unfold && { threshold: 0.8, min: 3 } }),
413
+ ...(values["fail-on"] !== undefined && { failOn: parseFailOn("--fail-on", values["fail-on"]) }),
414
+ ...(values.format !== undefined && { format: values.format }),
415
+ ...(values["exclude-rules"] !== undefined && { excludeRules: splitIds(values["exclude-rules"]) }),
416
+ ...(values.rules !== undefined && { rules: values.rules.flatMap((raw) => splitIds(raw)) }),
417
+ ...(Object.keys(overrides).length > 0 && { overrides }),
418
+ cacheMode: cacheMode(values),
419
+ };
420
+ }
421
+ // One line naming the bad flag, pointing at --help; exit code 2.
422
+ function usageError(message) {
423
+ console.error(`spiderlint: ${message} (see spiderlint --help)`);
424
+ return 2;
425
+ }
426
+ // Exit codes: 0 clean, 1 findings, 2 usage or config, 3 no seed fetched or an --offline miss, 4 the run failed.
427
+ async function main(argv) {
428
+ let code = 0;
429
+ const root = program(async (command) => {
430
+ code = await execute(command);
431
+ });
432
+ try {
433
+ await root.parseAsync(argv, { from: "user" });
434
+ }
435
+ catch (error) {
436
+ if (!(error instanceof CommanderError))
437
+ throw error;
438
+ log.debug({ code: error.code, exitCode: error.exitCode }, "command line answered by commander");
439
+ return error.exitCode === 0 ? 0 : 2;
440
+ }
441
+ return code;
442
+ }
443
+ // One verb over every chosen site, with the exit code main documents.
444
+ async function execute(verb) {
445
+ const values = flagsOf(verb);
446
+ if (values["log-level"] !== undefined && !isLogLevel(values["log-level"]))
447
+ return usageError(`--log-level: unknown level ${values["log-level"]}`);
448
+ if (values["log-level"] !== undefined)
449
+ log.level = values["log-level"];
450
+ logColor(values.color);
451
+ const isProgress = values.progress ?? (process.stderr.isTTY && process.stderr.columns > 0 && process.env.SPIDERLINT_LOG_FORMAT !== "json" && log.level !== "silent");
452
+ enableProgress(isProgress);
453
+ log.debug({ isProgress, flag: values.progress, isTTY: process.stderr.isTTY, columns: process.stderr.columns }, "status line chosen");
454
+ const command = verb.name();
455
+ const seeds = verb.args;
456
+ nameSpan(`spiderlint ${command}`);
457
+ try {
458
+ const { settings: fileSettings, sites } = loadSettings(values.config ?? process.env.SPIDERLINT_CONFIG);
459
+ // Shared settings, then the site’s patch, then environment and flags.
460
+ const configFor = (site) => layered([fileSettings, site, environmentSettings(process.env), flagSettings(values)]);
461
+ const config = configFor({});
462
+ if (command === "explain-rule") {
463
+ await loadPlugins(config.plugins, config.pluginSettings);
464
+ const explained = explainRule(config, seeds[0]);
465
+ console.log(config.format === "json" ? JSON.stringify(explained, undefined, 2) : formatExplanation(explained, painter(process.stdout, values.color)));
466
+ return 0;
467
+ }
468
+ if (command === "list-rules" || command === "list-presets") {
469
+ await loadPlugins(config.plugins, config.pluginSettings);
470
+ const paint = painter(process.stdout, values.color);
471
+ const listed = command === "list-rules" ? listRules(config, seeds) : listPresets(config);
472
+ if (config.format === "json")
473
+ console.log(JSON.stringify(listed, undefined, 2));
474
+ else
475
+ console.log(command === "list-rules" ? formatRules(listed, paint) : formatPresets(listed, paint));
476
+ return 0;
477
+ }
478
+ // `purge-cache` may name a bucket before its domains.
479
+ const bucket = command === "purge-cache" && PURGEABLE.has(seeds[0] ?? "") ? seeds[0] : undefined;
480
+ const targets = seeds.slice(bucket ? 1 : 0);
481
+ const named = (values.site ?? []).flatMap((raw) => splitIds(raw));
482
+ const unknown = named.filter((name) => !Object.hasOwn(sites, name));
483
+ if (unknown.length > 0)
484
+ throw new ConfigError(`--site: unknown site ${unknown.join(", ")} (declared: ${Object.keys(sites).join(", ") || "none"})`);
485
+ // Command-line domains win over every declared site; with none declared the shared settings are the one site.
486
+ const chosen = targets.length > 0 || Object.keys(sites).length === 0 ? [["", {}]] : Object.entries(sites).filter(([name]) => named.length === 0 || named.includes(name));
487
+ const isFacts = command === "show-facts" || command === "export-facts";
488
+ if (chosen.length > 1 && (isFacts || config.format !== "human"))
489
+ throw new ConfigError(`${isFacts ? command : `--format ${config.format}`}: one document per run, pick a site with --site (declared: ${Object.keys(sites).join(", ")})`);
490
+ let worst = 0;
491
+ for (const [name, site] of chosen) {
492
+ if (name)
493
+ log.debug({ site: name }, "site selected");
494
+ if (name && chosen.length > 1)
495
+ console.log(`\n${name}`);
496
+ worst = Math.max(worst, await run(verb, targets, bucket, configFor(site), values));
497
+ }
498
+ return worst;
499
+ }
500
+ catch (error) {
501
+ if (error instanceof Interrupted) {
502
+ log.warn({ command, code: error.code }, error.message);
503
+ return error.code;
504
+ }
505
+ const isConfig = error instanceof ConfigError;
506
+ const isOfflineMiss = error instanceof OfflineMiss || error instanceof NothingStored;
507
+ log.error({ command, error: error instanceof Error ? error.message : String(error), isConfig, isOfflineMiss }, `${command} aborted`);
508
+ return isConfig ? 2 : isOfflineMiss ? 3 : 4;
509
+ }
510
+ }
511
+ // One command over one site’s config; the exit code as main documents it.
512
+ async function run(verb, targets, bucket, config, values) {
513
+ const command = verb.name();
514
+ const isStored = ["crawl", "lint", "show-report"].includes(command);
515
+ {
516
+ await loadPlugins(config.plugins, config.pluginSettings);
517
+ if (targets.length > 0)
518
+ config.seeds = targets;
519
+ config.seeds = config.seeds.map((seed) => seedOf(seed));
520
+ config = await withSources(config);
521
+ const invalid = config.seeds.find((seed) => !URL.canParse(seed) || !["http:", "https:"].includes(new URL(seed).protocol));
522
+ if (invalid !== undefined)
523
+ throw new ConfigError(`${invalid}: not an http or https URL`);
524
+ // An explicit --store, else the seeds’ directory in the user cache; `--no-cache` keeps an audit in memory.
525
+ const store = values.store ?? (command === "audit" && config.cacheMode === "off" ? undefined : siteDirectory(config.seeds));
526
+ log.debug({ command, store, seeds: config.seeds.length, cache: config.cacheMode }, "store chosen");
527
+ if (command === "show-facts" || command === "export-facts")
528
+ return await facts(command, config, store, values);
529
+ const formatName = pick("--format", config.format, formatNames());
530
+ const format = formatter(formatName);
531
+ const failOn = typeof config.failOn === "number" ? config.failOn : RANK[config.failOn];
532
+ const requiresSeeds = ["audit", "crawl", "list-groups", "warm-cache"].includes(command);
533
+ if (!format || failOn === undefined || (requiresSeeds && config.seeds.length === 0) || (isStored && !store)) {
534
+ verb.outputHelp({ error: true });
535
+ return 2;
536
+ }
537
+ if (formatName !== "agent" && values.output !== undefined)
538
+ throw new ConfigError(`--output: writes one file per rule for --format agent only, not ${formatName}`);
539
+ if (command === "purge-cache") {
540
+ const olderThan = parseDuration(values["older-than"] ?? "0");
541
+ if (olderThan === undefined)
542
+ throw new ConfigError(`--older-than: invalid duration ${values["older-than"]} (expected seconds or 45s, 30m, 24h, 7d)`);
543
+ const purged = await purgeCache(store, bucket, olderThan);
544
+ for (const [name, count] of Object.entries(purged))
545
+ console.log(`${name.padEnd(9)} ${String(count).padStart(7)} entries purged`);
546
+ return 0;
547
+ }
548
+ if (command === "show-cache") {
549
+ const buckets = await cacheStatus(store);
550
+ for (const entry of buckets)
551
+ console.log(`${entry.bucket.padEnd(9)} ${String(entry.entries).padStart(7)} entries ${String(entry.bytes).padStart(11)} bytes ${entry.oldest} … ${entry.newest}`);
552
+ return 0;
553
+ }
554
+ if (command === "warm-cache") {
555
+ const warmed = await warmCache(config, store);
556
+ console.log(`${warmed.origins} origins, ${warmed.sitemaps} sitemap files, ${warmed.urls} listed URLs cached`);
557
+ return 0;
558
+ }
559
+ if (command === "crawl") {
560
+ const pages = await crawl(config, store, values.resume === true);
561
+ console.log(`${pages.length} pages stored in ${store}`);
562
+ return pages.length === 0 ? 3 : 0;
563
+ }
564
+ // A report to stdout in the chosen format, or one agent prompt per rule under --output.
565
+ const emit = async (report) => values.output === undefined ? console.log(format(report, painter(process.stdout, values.color), config.fold === false, undefined, values["show-hints"] === true, values.explain === true, values.stats === true)) : writeAgentFiles(values.output, report, values["show-hints"] === true);
566
+ if (command === "lint" || command === "show-report") {
567
+ const stored = command === "lint" ? await lintStore(config, store) : await reportStore(store);
568
+ await emit(stored);
569
+ return exitCode(stored, config.failOn);
570
+ }
571
+ const options = command === "audit" ? { store, resume: values.resume === true } : {};
572
+ const report = await audit(config, options);
573
+ if (command === "list-groups")
574
+ console.log(groupsOf(report));
575
+ else
576
+ await emit(report);
577
+ return command === "audit" ? exitCode(report, config.failOn) : report.pages.length === 0 ? 3 : 0;
578
+ }
579
+ }
580
+ // Every stored page with export-facts, else the first seed crawled alone, in human, json, yaml or csv; 3 when there is no page.
581
+ async function facts(command, config, store, values) {
582
+ const format = pick("--format", values.format ?? "human", FACT_FORMATS);
583
+ const isAll = command === "export-facts";
584
+ log.debug({ format, isAll, store, picks: values.facts }, "facts export chosen");
585
+ if (config.seeds.length === 0)
586
+ throw new ConfigError(`${command}: no domain, and org.spiderlint names no targets`);
587
+ const { pages, site } = isAll ? await factsStore(config, store) : await audit({ ...config, maxPages: 1, groups: { default: { rules: [] } } });
588
+ console.log(formatFacts(pages, site, format, values.facts ?? [], !isAll, painter(process.stdout, values.color)));
589
+ return pages.length === 0 ? 3 : 0;
590
+ }
591
+ // Trust the OS store beside Node’s bundled roots, as `node --use-system-ca` does.
592
+ const systemRoots = getCACertificates("system");
593
+ setDefaultCACertificates([...getCACertificates("default"), ...systemRoots]);
594
+ log.debug({ system: systemRoots.length }, "system CA certificates trusted");
595
+ const telemetry = await startTelemetry("spiderlint");
596
+ handleInterrupts();
597
+ process.exitCode = await inSpan("spiderlint", {}, async (span) => {
598
+ const code = await main(process.argv.slice(2));
599
+ span.setAttribute("process.exit.code", code);
600
+ return code;
601
+ });
602
+ await telemetry?.shutdown();
package/dist/color.js ADDED
@@ -0,0 +1,10 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { styleText } from "node:util";
5
+ // Leaves text untouched; the default for formatters called outside a terminal.
6
+ export const plain = (_style, text) => text;
7
+ // --color forces, --no-color disables, unset lets node read the TTY, NO_COLOR and FORCE_COLOR.
8
+ export function painter(stream, hasColor) {
9
+ return hasColor === false ? plain : (style, text) => styleText(style, text, hasColor ? { validateStream: false } : { stream });
10
+ }