browsable 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require "erb"
6
+ require "pastel"
7
+
8
+ module Browsable
9
+ # The `browsable` command-line interface.
10
+ #
11
+ # CLI is a thin shell: it parses flags, runs the audit pipeline (sources →
12
+ # analyzers → report), and hands the Report to a formatter. Every command's
13
+ # real logic lives in the classes it orchestrates.
14
+ class CLI < Thor
15
+ # The analyzer used for each routed file kind.
16
+ ANALYZERS = {
17
+ css: Analyzers::CSS,
18
+ erb: Analyzers::ERB,
19
+ html: Analyzers::HTML,
20
+ js: Analyzers::Javascript
21
+ }.freeze
22
+
23
+ SKIP_REASONS = {
24
+ css: "stylelint not found — run `browsable doctor`",
25
+ js: "eslint / eslint-plugin-compat not found — run `browsable doctor`"
26
+ }.freeze
27
+
28
+ def self.exit_on_failure? = true
29
+
30
+ class_option :config, type: :string, aliases: "-c", desc: "Path to a config file"
31
+ class_option :format, type: :string, enum: %w[human json github], desc: "Output format"
32
+ class_option :json, type: :boolean, desc: "Shortcut for --format json"
33
+
34
+ desc "audit [PATH]", "Audit a Rails app's frontend for browser-compatibility issues"
35
+ long_desc <<~DESC
36
+ Discovers the project's CSS, ERB/HTML and JavaScript, audits each against
37
+ the browser-support target inferred from ApplicationController's
38
+ allow_browser policy, and reports what your code requires versus what your
39
+ policy permits. Runs with zero configuration.
40
+ DESC
41
+ option :target, type: :string, desc: "Override the inferred browserslist query"
42
+ option :"no-build", type: :boolean, default: false,
43
+ desc: "Scan only what is on disk (browsable never builds assets itself)"
44
+ option :include, type: :array, default: [], desc: "Extra path globs to scan (repeatable)"
45
+ option :exclude, type: :array, default: [], desc: "Path globs to exclude (repeatable)"
46
+ option :"fail-on", type: :string, enum: %w[warning error], default: "error",
47
+ desc: "Exit non-zero when a finding of this severity (or higher) exists"
48
+ def audit(path = ".")
49
+ root = File.expand_path(path)
50
+ config = load_config(root)
51
+ target = resolve_target(config)
52
+
53
+ warn_missing_dependencies
54
+ report = run_audit(root: root, config: config, target: target)
55
+ emit(report)
56
+ exit(report.exit_code(fail_on: options["fail-on"] || "error"))
57
+ end
58
+ default_command :audit
59
+
60
+ desc "doctor", "Check that browsable's system dependencies are installed"
61
+ option :fix, type: :boolean, default: false,
62
+ desc: "Attempt to install missing dependencies via brew/npm"
63
+ def doctor
64
+ doc = Doctor.new
65
+ puts doc.render(color: color?)
66
+
67
+ if options[:fix] && !doc.ok?
68
+ puts
69
+ doc.fix!
70
+ puts
71
+ puts doc.render(color: color?)
72
+ end
73
+
74
+ exit(doc.ok? ? 0 : 1)
75
+ end
76
+
77
+ desc "check FILE [FILE...]", "Audit specific files (used by editor integrations)"
78
+ option :target, type: :string, desc: "Override the inferred browserslist query"
79
+ def check(*files)
80
+ abort_with("`check` needs at least one file path.") if files.empty?
81
+
82
+ paths = files.map { |file| File.expand_path(file) }
83
+ root = detect_root(paths.first)
84
+ config = load_config(root)
85
+ target = resolve_target(config)
86
+
87
+ report = run_audit(root: root, config: config, target: target, file_list: paths)
88
+ emit(report)
89
+ exit(report.exit_code(fail_on: "error"))
90
+ end
91
+
92
+ desc "init", "Generate a .browsable.yml in the current directory"
93
+ long_desc <<~DESC
94
+ Writes a fully-commented .browsable.yml. Rails users should prefer the
95
+ generator: `rails g browsable:install` (writes config/browsable.yml).
96
+ DESC
97
+ option :force, type: :boolean, default: false, desc: "Overwrite an existing file"
98
+ option :minimal, type: :boolean, default: false, desc: "Write section headers only"
99
+ option :target, type: :string, desc: "Pre-populate a manual target query"
100
+ def init
101
+ destination = File.expand_path(".browsable.yml")
102
+ if File.exist?(destination) && !options[:force]
103
+ abort_with("#{destination} already exists. Pass --force to overwrite.")
104
+ end
105
+
106
+ File.write(destination, render_config_template)
107
+ puts pastel.green("Created #{destination}")
108
+ puts pastel.dim("This file is optional — delete it and browsable still works. " \
109
+ "Uncomment a line to override a default.")
110
+ puts pastel.dim("Run `browsable audit` to try it out.")
111
+ end
112
+
113
+ desc "target [PATH]", "Show the browser-support target browsable inferred"
114
+ def target(path = ".")
115
+ config = load_config(File.expand_path(path))
116
+ resolved = resolve_target(config)
117
+
118
+ unconstrained = options[:target] ? [] : config.unconstrained_browsers
119
+ caveats = []
120
+ unless options[:target]
121
+ caveats << config.policy_note if config.policy_note
122
+ caveats.concat(config.target_notes)
123
+ end
124
+ caveats << resolved.note if resolved.note
125
+
126
+ if json_output?
127
+ puts JSON.pretty_generate(
128
+ resolved.as_json.merge(unconstrained_browsers: unconstrained, notes: caveats)
129
+ )
130
+ else
131
+ puts pastel.bold("Target: #{resolved.query}")
132
+ resolved.browsers.each { |browser, version| puts " #{browser.ljust(9)}>= #{version}" }
133
+ unconstrained.each do |browser|
134
+ puts pastel.dim(" #{browser.ljust(9)}any version (not listed in allow_browser)")
135
+ end
136
+ caveats.each { |note| puts pastel.yellow("! #{note}") }
137
+ end
138
+ end
139
+
140
+ desc "version", "Print the browsable version"
141
+ def version
142
+ puts "browsable #{Browsable::VERSION}"
143
+ end
144
+ map %w[--version -v] => :version
145
+
146
+ private
147
+
148
+ # --- pipeline ------------------------------------------------------------
149
+
150
+ def run_audit(root:, config:, target:, file_list: nil)
151
+ available = Doctor.new.available_kinds
152
+ skips = []
153
+ files_by_kind = file_list ? route_files(file_list) : discover_files(root: root, config: config)
154
+
155
+ collect_importmap(root: root, config: config, files_by_kind: files_by_kind, skips: skips) if file_list.nil?
156
+
157
+ findings = []
158
+ ANALYZERS.each do |kind, analyzer_class|
159
+ files = files_by_kind[kind] || []
160
+ next if files.empty?
161
+
162
+ unless available.include?(kind)
163
+ skips << Report::Skip.new(kind: kind, reason: SKIP_REASONS.fetch(kind, "tooling unavailable"))
164
+ next
165
+ end
166
+
167
+ analyzer = analyzer_class.new(target: target, config: config)
168
+ findings.concat(analyzer.analyze(files))
169
+ rescue StandardError => e
170
+ skips << Report::Skip.new(kind: kind, reason: "#{kind} analysis failed: #{e.message}")
171
+ end
172
+
173
+ notes = []
174
+ # Surface target caveats — a policy that could not be inferred, or the
175
+ # Rails rule that browsers absent from an allow_browser hash are
176
+ # unconstrained — unless the user overrode the target on the command line.
177
+ unless options[:target]
178
+ notes << config.policy_note if config.policy_note
179
+ notes.concat(config.target_notes)
180
+ end
181
+ # How the target itself resolved (e.g. a built-in fallback) is relevant
182
+ # even when --target was passed.
183
+ notes << target.note if target.note
184
+
185
+ # The full policy landscape — every allow_browser callsite across the
186
+ # app's controllers, not just ApplicationController. Skipped for `check`,
187
+ # which audits specific files for editor integration.
188
+ policies = file_list ? [] : PolicyScanner.call(root)
189
+
190
+ Report.new(findings: findings, skips: skips, notes: notes, policies: policies,
191
+ target: target, root: root, config_file: config.config_file)
192
+ end
193
+
194
+ def collect_importmap(root:, config:, files_by_kind:, skips:)
195
+ return unless config.importmap_enabled?
196
+
197
+ importmap = Sources::Importmap.new(root: root)
198
+ return unless importmap.present?
199
+
200
+ if ENV["BROWSABLE_OFFLINE"] == "1"
201
+ skips << Report::Skip.new(kind: :importmap,
202
+ reason: "BROWSABLE_OFFLINE=1 — remote pins were not fetched")
203
+ return
204
+ end
205
+
206
+ (files_by_kind[:js] ||= []).concat(importmap.fetch)
207
+ end
208
+
209
+ def discover_files(root:, config:)
210
+ src = config.sources
211
+ excludes = Array(options[:exclude]) + config.ignore_files
212
+
213
+ sources = [
214
+ Sources::Stylesheets.new(root: root, globs: src["stylesheets"], excludes: excludes),
215
+ Sources::Builds.new(root: root, globs: src["builds"], excludes: excludes),
216
+ Sources::Views.new(root: root, globs: src["views"], excludes: excludes),
217
+ Sources::Javascripts.new(root: root, globs: src["javascript"], excludes: excludes),
218
+ Sources::PublicAssets.new(root: root, globs: src["public"], excludes: excludes)
219
+ ]
220
+
221
+ extra = Array(src["custom"]) + Array(options[:include])
222
+ sources << Sources::Base.new(root: root, globs: extra, excludes: excludes) if extra.any?
223
+
224
+ route_files(sources.flat_map(&:files).uniq)
225
+ end
226
+
227
+ # Route files to an analyzer by extension. `*.html.erb` ends in `.erb`.
228
+ def route_files(files)
229
+ buckets = { css: [], erb: [], html: [], js: [] }
230
+ files.each do |file|
231
+ case File.extname(file).downcase
232
+ when ".css", ".scss" then buckets[:css] << file
233
+ when ".js", ".mjs" then buckets[:js] << file
234
+ when ".erb" then buckets[:erb] << file
235
+ when ".html", ".htm" then buckets[:html] << file
236
+ end
237
+ # TODO(v0.2): route Ruby view components (app/components/**/*.rb).
238
+ end
239
+ buckets
240
+ end
241
+
242
+ # --- output --------------------------------------------------------------
243
+
244
+ def emit(report)
245
+ puts formatter_class.new(report).render
246
+ end
247
+
248
+ def formatter_class
249
+ name = json_output? ? "json" : (options[:format] || "human")
250
+ { "human" => Formatters::Human, "json" => Formatters::Json,
251
+ "github" => Formatters::Github }.fetch(name)
252
+ end
253
+
254
+ def json_output?
255
+ options[:json] || options[:format] == "json"
256
+ end
257
+
258
+ # --- config / target ----------------------------------------------------
259
+
260
+ def load_config(root)
261
+ Config.load(root: root, path: options[:config])
262
+ rescue ConfigError => e
263
+ abort_with(e.message)
264
+ end
265
+
266
+ def resolve_target(config)
267
+ options[:target] ? Target.new(options[:target]) : config.target
268
+ end
269
+
270
+ # --- helpers -------------------------------------------------------------
271
+
272
+ def warn_missing_dependencies
273
+ return if ENV.key?("BROWSABLE_DRY_RUN")
274
+
275
+ missing = %i[css js] - Doctor.new.available_kinds
276
+ return if missing.empty?
277
+
278
+ $stderr.puts pastel(io: $stderr).yellow(
279
+ "! #{missing.join(' and ')} analysis is disabled (missing tools). " \
280
+ "Run `browsable doctor` for setup instructions."
281
+ )
282
+ end
283
+
284
+ # Walk up from a file to find the project root.
285
+ def detect_root(start)
286
+ dir = File.directory?(start) ? start : File.dirname(start)
287
+ markers = %w[Gemfile config.ru .browsable.yml config/browsable.yml]
288
+ current = dir
289
+ loop do
290
+ return current if markers.any? { |m| File.exist?(File.join(current, m)) }
291
+
292
+ parent = File.dirname(current)
293
+ return dir if parent == current
294
+
295
+ current = parent
296
+ end
297
+ end
298
+
299
+ def render_config_template
300
+ template_path = File.expand_path(
301
+ "../generators/browsable/install/templates/browsable.yml.tt", __dir__
302
+ )
303
+ policy = Config.load(root: Dir.pwd).detected_policy
304
+ comment =
305
+ case policy
306
+ when nil
307
+ "# (No allow_browser call detected — browsable will fall back to browserslist defaults.)"
308
+ when Hash
309
+ "# Detected: ApplicationController declares an explicit allow_browser versions hash."
310
+ else
311
+ "# Detected: ApplicationController uses `allow_browser versions: :#{policy}`"
312
+ end
313
+
314
+ ERB.new(File.read(template_path), trim_mode: "-")
315
+ .result_with_hash(detected_comment: comment,
316
+ manual_query: options[:target],
317
+ minimal: options[:minimal])
318
+ end
319
+
320
+ def color?
321
+ $stdout.tty?
322
+ end
323
+
324
+ def pastel(io: $stdout)
325
+ Pastel.new(enabled: io.tty?)
326
+ end
327
+
328
+ def abort_with(message)
329
+ $stderr.puts pastel(io: $stderr).red("Error: #{message}")
330
+ exit(1)
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Browsable
6
+ # Resolves the effective configuration for an audit run.
7
+ #
8
+ # browsable runs fully zero-config: when no config file is present every
9
+ # value below is inferred. A config file only exists to *override* defaults.
10
+ #
11
+ # Resolution precedence (highest wins) is applied by the caller:
12
+ # 1. CLI flags (handled in CLI)
13
+ # 2. config file (loaded here)
14
+ # 3. inferred Rails (allow_browser policy, read here)
15
+ # 4. gem defaults (DEFAULTS below)
16
+ class Config
17
+ DEFAULTS = {
18
+ "target" => {
19
+ "source" => "allow_browsers", # allow_browsers | browserslist | manual
20
+ "manual_query" => "defaults"
21
+ },
22
+ "sources" => {
23
+ "stylesheets" => ["app/assets/stylesheets/**/*.{css,scss}"],
24
+ "builds" => ["app/assets/builds/**/*.css"],
25
+ "views" => ["app/views/**/*.{html.erb,turbo_stream.erb}",
26
+ "app/components/**/*.{rb,html.erb}"],
27
+ "javascript" => ["app/javascript/**/*.{js,mjs}"],
28
+ "importmap" => true,
29
+ "public" => ["public/**/*.{html,css,js}"],
30
+ "custom" => []
31
+ },
32
+ "severity" => {
33
+ "baseline_newly_available" => "warning",
34
+ "baseline_limited" => "error",
35
+ "below_target" => "error"
36
+ },
37
+ "ignore" => {
38
+ "features" => [],
39
+ "files" => []
40
+ }
41
+ }.freeze
42
+
43
+ # Discovery order for an implicit config file, relative to the project root.
44
+ CONFIG_FILENAMES = ["config/browsable.yml", ".browsable.yml"].freeze
45
+
46
+ attr_reader :root, :data, :config_file, :detected_policy, :policy_note
47
+
48
+ # Load and merge configuration for a project rooted at `root`.
49
+ #
50
+ # @param root [String] the Rails app (or project) root
51
+ # @param path [String, nil] an explicit config file path (from --config)
52
+ def self.load(root:, path: nil)
53
+ root = File.expand_path(root)
54
+ config_file = locate_file(root, path)
55
+ file_data = config_file ? parse_file(config_file) : {}
56
+ merged = deep_merge(DEFAULTS, file_data)
57
+ new(root: root, data: merged, config_file: config_file)
58
+ end
59
+
60
+ def self.locate_file(root, explicit)
61
+ if explicit
62
+ full = File.expand_path(explicit, root)
63
+ raise ConfigError, "Config file not found: #{explicit}" unless File.file?(full)
64
+
65
+ return full
66
+ end
67
+
68
+ CONFIG_FILENAMES
69
+ .map { |name| File.join(root, name) }
70
+ .find { |candidate| File.file?(candidate) }
71
+ end
72
+
73
+ def self.parse_file(path)
74
+ loaded = YAML.safe_load_file(path) || {}
75
+ raise ConfigError, "#{path} must contain a YAML mapping" unless loaded.is_a?(Hash)
76
+
77
+ loaded
78
+ rescue Psych::SyntaxError => e
79
+ raise ConfigError, "Could not parse #{path}: #{e.message}"
80
+ end
81
+
82
+ def self.deep_merge(base, override)
83
+ base.merge(override) do |_key, base_val, override_val|
84
+ if base_val.is_a?(Hash) && override_val.is_a?(Hash)
85
+ deep_merge(base_val, override_val)
86
+ else
87
+ override_val
88
+ end
89
+ end
90
+ end
91
+
92
+ def initialize(root:, data:, config_file: nil)
93
+ @root = root
94
+ @data = data
95
+ @config_file = config_file
96
+ # PolicyDetector statically resolves the Rails allow_browser policy.
97
+ # `policy_note` is set when a call was found but could not be resolved.
98
+ result = PolicyDetector.call(root)
99
+ @detected_policy = result.policy
100
+ @policy_note = result.note
101
+ end
102
+
103
+ def sources = data.fetch("sources")
104
+ def severity = data.fetch("severity")
105
+ def ignore_features = Array(data.dig("ignore", "features"))
106
+ def ignore_files = Array(data.dig("ignore", "files"))
107
+ def importmap_enabled? = sources.fetch("importmap", true) != false
108
+
109
+ # Resolve the browser-support Target implied by this config.
110
+ def target
111
+ cfg = data.fetch("target")
112
+ case cfg["source"]
113
+ when "manual"
114
+ Target.new(cfg.fetch("manual_query", "defaults"))
115
+ when "browserslist"
116
+ # Defer entirely to the project's browserslist config (.browserslistrc).
117
+ Target.new("defaults")
118
+ else # "allow_browsers" (the default)
119
+ detected_policy ? Target.from_rails_policy(detected_policy) : Target.new("defaults")
120
+ end
121
+ end
122
+
123
+ # Major browsers an explicit allow_browser *hash* neither pins to a version
124
+ # nor blocks. Rails allows these at any version — it only ever blocks a
125
+ # browser it was given a minimum (or `false`) for — so browsable has no
126
+ # floor to audit them against. Empty unless the policy is an explicit hash.
127
+ def unconstrained_browsers
128
+ return [] unless detected_policy.is_a?(Hash)
129
+
130
+ Target::MODERN.keys - detected_policy.keys
131
+ end
132
+
133
+ # Informational caveats about the resolved target — so the user is never
134
+ # left guessing why a particular set of browsers is (or isn't) audited.
135
+ def target_notes
136
+ notes = []
137
+ inferring = data.dig("target", "source") == "allow_browsers"
138
+
139
+ # No allow_browser policy at all — explain the browserslist defaults fallback.
140
+ if inferring && detected_policy.nil? && policy_note.nil?
141
+ notes << "No allow_browser policy was found in ApplicationController, so browsable " \
142
+ "is auditing against the browserslist `defaults` baseline. Add an " \
143
+ "allow_browser call, or set `target:` in config/browsable.yml, to pick the " \
144
+ "browsers to audit against explicitly."
145
+ end
146
+
147
+ # A partial hash policy — explain the browsers Rails leaves unconstrained.
148
+ if unconstrained_browsers.any?
149
+ pinned = detected_policy.keys.join(", ")
150
+ omitted = unconstrained_browsers.join(", ")
151
+ notes << "Your allow_browser policy pins a version only for #{pinned}. Rails leaves " \
152
+ "every browser you don't list (#{omitted}) allowed at any version, so " \
153
+ "browsable audits only #{pinned}. Add a `target:` block to " \
154
+ "config/browsable.yml to audit the others."
155
+ end
156
+
157
+ notes
158
+ end
159
+
160
+ # True when an explicit config file was found and loaded.
161
+ def file_present? = !config_file.nil?
162
+ end
163
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require "rbconfig"
6
+ require "pastel"
7
+
8
+ module Browsable
9
+ # Verifies that the external tools browsable shells out to are installed, and
10
+ # guides the user through installing whatever is missing.
11
+ #
12
+ # Herb is a gem dependency and runs in-process, so it is never checked here —
13
+ # only node, npm, stylelint, eslint and eslint-plugin-compat.
14
+ class Doctor
15
+ # A tool browsable depends on. `binary` is nil for packages that ship no
16
+ # executable (checked via the global npm tree instead). `enables` lists the
17
+ # analyzer kinds the tool unlocks.
18
+ Tool = Data.define(:key, :label, :binary, :npm_package, :purpose, :enables, :required) do
19
+ def binary? = !binary.nil?
20
+ end
21
+
22
+ # The resolved state of a tool on this machine.
23
+ Status = Data.define(:tool, :installed, :detail) do
24
+ def installed? = installed
25
+ end
26
+
27
+ TOOLS = [
28
+ Tool.new(key: :node, label: "node", binary: "node", npm_package: nil,
29
+ purpose: "JavaScript runtime that stylelint and eslint run on",
30
+ enables: %i[css js], required: true),
31
+ Tool.new(key: :npm, label: "npm", binary: "npm", npm_package: nil,
32
+ purpose: "installs the CSS/JS tooling (used by `doctor --fix`)",
33
+ enables: [], required: false),
34
+ Tool.new(key: :stylelint, label: "stylelint", binary: "stylelint",
35
+ npm_package: "stylelint stylelint-no-unsupported-browser-features",
36
+ purpose: "audits CSS for unsupported browser features",
37
+ enables: %i[css], required: true),
38
+ Tool.new(key: :eslint, label: "eslint", binary: "eslint",
39
+ npm_package: "eslint eslint-plugin-compat",
40
+ purpose: "audits JavaScript for unsupported browser features",
41
+ enables: %i[js], required: true),
42
+ Tool.new(key: :eslint_plugin_compat, label: "eslint-plugin-compat", binary: nil,
43
+ npm_package: "eslint-plugin-compat",
44
+ purpose: "the eslint plugin that performs the JS compat checks",
45
+ enables: %i[js], required: true)
46
+ ].freeze
47
+
48
+ # Analyzer kinds that need no external tooling at all.
49
+ ALWAYS_AVAILABLE = %i[erb html].freeze
50
+
51
+ def statuses
52
+ @statuses ||= TOOLS.map do |tool|
53
+ present = installed?(tool)
54
+ Status.new(tool: tool, installed: present, detail: detail_for(tool, present))
55
+ end
56
+ end
57
+
58
+ # True when every *required* tool is present.
59
+ def ok?
60
+ statuses.select { |s| s.tool.required }.all?(&:installed?)
61
+ end
62
+
63
+ def missing
64
+ statuses.reject(&:installed?).map(&:tool)
65
+ end
66
+
67
+ # Which analyzer kinds can actually run on this machine right now.
68
+ def available_kinds
69
+ # In dry-run mode the external tools are never invoked, so treat them all
70
+ # as available — this keeps specs and `BROWSABLE_DRY_RUN` audits working.
71
+ return %i[css erb html js] if ENV.key?("BROWSABLE_DRY_RUN")
72
+
73
+ kinds = ALWAYS_AVAILABLE.dup
74
+ %i[css js].each do |kind|
75
+ needed = TOOLS.select { |tool| tool.enables.include?(kind) }
76
+ kinds << kind if needed.all? { |tool| installed?(tool) }
77
+ end
78
+ kinds
79
+ end
80
+
81
+ # A formatted, colourised dependency report.
82
+ def render(color: $stdout.tty?)
83
+ pastel = Pastel.new(enabled: color)
84
+ lines = [pastel.bold("browsable doctor — system dependencies"), ""]
85
+
86
+ statuses.each do |status|
87
+ mark = status.installed? ? pastel.green("✓") : pastel.red("✗")
88
+ lines << " #{mark} #{pastel.bold(status.tool.label)} — #{status.tool.purpose}"
89
+ lines << pastel.dim(" #{status.detail}") if status.detail
90
+ end
91
+
92
+ lines << ""
93
+ if ok?
94
+ lines << pastel.green.bold("All required tools are installed. You're ready to audit.")
95
+ else
96
+ lines << pastel.red.bold("Missing required tools — install them with:")
97
+ install_commands.each { |cmd| lines << " #{pastel.cyan(cmd)}" }
98
+ lines << ""
99
+ lines << pastel.dim("Or run `browsable doctor --fix` to install them automatically.")
100
+ end
101
+ lines.join("\n")
102
+ end
103
+
104
+ # Attempt to install everything that is missing. Runnable commands only
105
+ # (npm/brew); anything that needs a manual download is reported, not run.
106
+ def fix!(io: $stdout, input: $stdin, assume_yes: false)
107
+ return true if ok?
108
+
109
+ runnable, manual = install_commands.partition { |cmd| cmd.start_with?("npm ", "brew ") }
110
+
111
+ manual.each { |cmd| io.puts "Manual step required: #{cmd}" }
112
+ return ok? if runnable.empty?
113
+
114
+ io.puts "browsable will run:"
115
+ runnable.each { |cmd| io.puts " #{cmd}" }
116
+ unless assume_yes
117
+ io.print "Proceed? [y/N] "
118
+ answer = input.gets&.strip&.downcase
119
+ return false unless %w[y yes].include?(answer)
120
+ end
121
+
122
+ runnable.each do |cmd|
123
+ io.puts "+ #{cmd}"
124
+ system(cmd)
125
+ end
126
+ @statuses = nil
127
+ @installed_cache = nil
128
+ ok?
129
+ end
130
+
131
+ private
132
+
133
+ def install_commands
134
+ missing.map { |tool| install_command(tool) }.uniq
135
+ end
136
+
137
+ def install_command(tool)
138
+ case tool.key
139
+ when :node, :npm
140
+ mac? ? "brew install node" : "install Node.js — see https://nodejs.org/en/download"
141
+ else
142
+ "npm install -g #{tool.npm_package}"
143
+ end
144
+ end
145
+
146
+ def detail_for(tool, present)
147
+ if present
148
+ tool.binary? ? (tool_version(tool) || "installed") : "installed"
149
+ else
150
+ "not found — #{install_command(tool)}"
151
+ end
152
+ end
153
+
154
+ def installed?(tool)
155
+ @installed_cache ||= {}
156
+ @installed_cache.fetch(tool.key) do
157
+ @installed_cache[tool.key] = tool.binary? ? binary_on_path?(tool.binary) : npm_package_installed?(tool.npm_package)
158
+ end
159
+ end
160
+
161
+ def binary_on_path?(binary)
162
+ _out, status = Open3.capture2e("sh", "-c", "command -v #{Shellwords.escape(binary)}")
163
+ status.success?
164
+ rescue StandardError
165
+ false
166
+ end
167
+
168
+ # eslint-plugin-compat ships no executable, so check the global npm tree.
169
+ def npm_package_installed?(package)
170
+ _out, status = Open3.capture2e("npm", "ls", "-g", "--depth=0", package.to_s.split.first)
171
+ status.success?
172
+ rescue Errno::ENOENT
173
+ false # npm itself is missing
174
+ rescue StandardError
175
+ false
176
+ end
177
+
178
+ def tool_version(tool)
179
+ out, status = Open3.capture2e(tool.binary, "--version")
180
+ status.success? ? out.strip.lines.first&.strip : nil
181
+ rescue StandardError
182
+ nil
183
+ end
184
+
185
+ def mac?
186
+ RbConfig::CONFIG["host_os"].to_s.match?(/darwin|mac os/i)
187
+ end
188
+ end
189
+ end