Module: VivlioStarter::CLI::DoctorCommands

Defined in:
lib/vivlio_starter/cli/doctor.rb,
lib/vivlio_starter/cli/doctor.rb,
lib/vivlio_starter/cli/doctor/tool_upgrader.rb,
lib/vivlio_starter/cli/doctor/config_salvager.rb

Overview

環境診断・ツールインストールコマンド

Defined Under Namespace

Modules: ConfigSalvager, ToolUpgrader

Constant Summary collapse

SCAFFOLD_CONFIG_DIR =

scaffold 側 config/ への絶対パス(設定ファイル復元の供給元)

File.expand_path('../../project_scaffold/config', __dir__).freeze
OPTIONAL_CONFIG_FILES =

必須 YAML(Common::REQUIRED_YAML_FILES)以外で scaffold から復元する設定ファイル。 破損判定はせず「欠落時のみ」復元する(spec §3.1)

%w[textlint_allowlist.yml textlint_rewrite.yml .textlintrc.yml _README.md].freeze
CONFIG_DIR_ENTRIES =

欠落時のみ scaffold から再帰コピーで復元する辞書ディレクトリ(中身の個別検証はしない)

%w[spellcheck_dictionaries textlint_dictionaries].freeze
OCR_OPTIONAL_TOOLS =

Enhanced Mode(vivlio-starter-pdf)専用の OCR 系ツール。 プラグイン未導入時は不足してもエラー扱いせず 🟡 注記に回す(spec §5.1)。 poppler(pdfinfo / pdftoppm)は本体のビルドでも使うため含めない

%w[tesseract tesseract-lang vips].freeze
KINDLEPREVIEWER_COMMAND =

Kindle Previewer 3 同梱の CLI(targets: kindle の KPF 変換専用の任意ツール)。 Build::EpubBuilder::KINDLEPREVIEWER_COMMAND と同値だが、doctor を軽量に保つため epub_builder を require せず独立に持つ。kindle を使わない利用者には不足を ハードエラーにせず 🟡 注記に回す。

'kindlepreviewer'
KINDLE_PREVIEWER_APP_BIN =

macOS の Kindle Previewer 3 アプリ内 CLI 実行ファイル(ラッパーが呼ぶ実体)。

'/Applications/Kindle Previewer 3.app/Contents/MacOS/Kindle Previewer 3'
VERSION_ARGS =

機能チェック(--version 実起動)でのバージョン確認フラグ。既定は --version。 poppler 系(pdfinfo / pdftoppm)は --version を解さず -v が正しい(実測 exit 差)。

{ 'pdfinfo' => '-v', 'pdftoppm' => '-v' }.freeze
PDF_PLUGIN_GEM_NAME =

Enhanced Mode プラグインの gem 名(Pdf::PLUGIN_GEM_NAME と同値。 provider.rb は pdf/reader 等の重い require を伴うため doctor からは参照しない)

'vivlio-starter-pdf'
TEXTLINT_NPM_PACKAGES =

textlint と推奨ルール一式(npm -g)。定義の正典は ToolUpgrader (vs upgrade の更新と --fix のインストールで共用し、二重管理しない)

ToolUpgrader::TEXTLINT_NPM_PACKAGES
DOCTOR_DESC =

img2pdfの依存排除に伴い、診断対象および説明からimg2pdfを削除しています。

{
  short: '必要ツール(Xcode Command Line Tools, qpdf, pdfinfo, pdftoppm, gs, ImageMagick, Inkscape)の診断とセットアップを行います',
  long: <<~DESC
    環境診断を行い、以下の外部コマンドが実際に動作するかをチェックします
    (存在だけでなく --version 実起動で確認し、壊れたラッパー等も検出します):
      - Xcode Command Line Tools (macOS)
      - qpdf
      - pdfinfo / pdftoppm (poppler)
      - node
      - vivliostyle
      - textlint
      - gs
      - imagemagick
      - inkscape
      - rsvg-convert (librsvg)
      - vips / tesseract / tesseract-lang (Enhanced Mode の OCR 用)
      - mecab
      - rouge
      - mathjax (mathjax-full)
      - mermaid (mmdc・@mermaid-js/mermaid-cli)
      - waifu2x
      - kindlepreviewer (Kindle Previewer 3・targets: kindle の KPF 変換時のみ。任意)

    役割の補足:
      - 圧縮は Ghostscript(pdfwrite) を使用します
      - qpdf は分割/結合・ページ抽出などの PDF 操作用に使用します(圧縮用途ではありません)。
        入稿用 PDF の導出(--update-from-json / --overlay)にバージョン 11 以上が必要です

    --fix オプション指定時、macOS かつ Homebrew が利用可能であれば
    不足しているツールの自動インストールを試みます。

    導入済みツールの一括更新は vs upgrade が担います
    (vivlio-starter 本体・プロジェクト雛形の追従とあわせて実行されます)。

    例:
      vs doctor
      vs doctor --fix
      vs doctor --fix --yes
  DESC
}.freeze

Class Method Summary collapse

Class Method Details

.backup_corrupt_file!(path) ⇒ Object

破損ファイルを .bak. へ退避する(機能 A の安全規約)



729
730
731
732
733
734
# File 'lib/vivlio_starter/cli/doctor.rb', line 729

def backup_corrupt_file!(path)
  backup_path = "#{path}.bak.#{Time.now.strftime('%Y%m%d_%H%M%S')}"
  FileUtils.mv(path, backup_path)
  Common.log_always("        破損したファイルを #{backup_path} へ退避しました")
  backup_path
end

.book_project_dir?Boolean

書籍プロジェクトの中かどうか(復元対象の config/ か、プロジェクトの 目印である vivliostyle.config.js があれば対象とみなす)

Returns:

  • (Boolean)


668
# File 'lib/vivlio_starter/cli/doctor.rb', line 668

def book_project_dir? = Dir.exist?(Common::CONFIG_DIR) || File.file?(Common::VIVLIOSTYLE_CONFIG_FILE)

.broken_vivliostyle_chrome_entriesObject

vivliostyle のブラウザキャッシュ内で「不完全な Chrome」のパス一覧を返す。 中断時は (1) 展開途中の .zip が残り(成功時は削除される)、(2) バージョン ディレクトリの Framework 本体が欠落する。健全な版は対象に含めない。



986
987
988
989
990
991
992
993
994
995
996
997
# File 'lib/vivlio_starter/cli/doctor.rb', line 986

def broken_vivliostyle_chrome_entries
  base = vivliostyle_browsers_cache_dir
  return [] unless Dir.exist?(base)

  entries = Dir.glob(File.join(base, '**', '*.zip'))
  Dir.glob(File.join(base, 'chrome', '*')).each do |version_dir|
    next unless File.directory?(version_dir)

    entries << version_dir unless chrome_framework_present?(version_dir)
  end
  entries.uniq
end

.capture_command(cmd) ⇒ Object



582
583
584
585
586
# File 'lib/vivlio_starter/cli/doctor.rb', line 582

def capture_command(cmd)
  `#{cmd}`
rescue StandardError
  ''
end

.chrome_framework_present?(version_dir) ⇒ Boolean

バージョンディレクトリ配下に Chrome の Framework 本体があるか(= 展開が完了しているか)。

Returns:

  • (Boolean)


1005
1006
1007
1008
# File 'lib/vivlio_starter/cli/doctor.rb', line 1005

def chrome_framework_present?(version_dir)
  pattern = File.join(version_dir, '**', 'Frameworks', '*Framework.framework', 'Versions', '*', '*Framework')
  !Dir.glob(pattern).empty?
end

.clean_waifu2x_bins(bin_dir, bundle_dir) ⇒ Object



1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
# File 'lib/vivlio_starter/cli/doctor.rb', line 1241

def clean_waifu2x_bins(bin_dir, bundle_dir)
  targets = []
  targets.concat(Dir.glob(File.join(bin_dir, 'waifu2x*')))
  targets << File.join(bin_dir, 'waifu2x-ncnn-vulkan.cmd')
  targets << File.join(bin_dir, 'waifu2x-ncnn-vulkan.exe')
  targets << bundle_dir
  targets.uniq.each do |path|
    next unless path.start_with?(bin_dir)

    FileUtils.rm_rf(path)
  end
end

.cli_tool_ok?(cmd) ⇒ Boolean

CLI ツールが存在し、かつ実際に起動できるか(--version 実起動で確認)。 ツールごとの正しいバージョン確認フラグ(VERSION_ARGS)を使う。 これにより「バイナリは在るが dylib 切れ/ダングリング symlink で起動失敗」も 「見つかりません」として拾える(presence だけの command_exists? では素通りしていた)。

Returns:

  • (Boolean)


940
# File 'lib/vivlio_starter/cli/doctor.rb', line 940

def cli_tool_ok?(cmd) = command_runnable?(cmd, version_arg: VERSION_ARGS.fetch(cmd, '--version'))

.command_exists?(cmd) ⇒ Boolean

Returns:

  • (Boolean)


905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
# File 'lib/vivlio_starter/cli/doctor.rb', line 905

def command_exists?(cmd)
  return false if cmd.nil? || cmd.strip.empty?

  candidate = cmd.strip
  return file_executable?(candidate) if candidate.include?(File::SEPARATOR) || candidate.include?('\\')

  pathext = windows_platform? ? ENV.fetch('PATHEXT', '').split(';').map(&:downcase) : ['']
  ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |path|
    pathext.any? do |ext|
      extname = ext.empty? || candidate.downcase.end_with?(ext) ? candidate : "#{candidate}#{ext.downcase}"
      resolved = File.join(path, extname)
      file_executable?(resolved)
    end
  end
end

.command_runnable?(cmd, version_arg: '--version') ⇒ Boolean

コマンドが存在し、かつ実際に起動できるかを検証する。 presence チェック(command_exists?)は「ファイルが在り実行ビットが立つ」だけを見るため、 Homebrew cask のラッパーが削除済みアプリ本体を exec する等の「在るのに動かない」壊れ方を 見抜けない(例: /opt/homebrew/bin/inkscape が欠落した Inkscape.app を指し exit 126)。 version_arg の実起動で終了ステータスまで確認し、壊れたラッパーを MISSING として扱う。

Returns:

  • (Boolean)


926
927
928
929
930
931
932
933
934
# File 'lib/vivlio_starter/cli/doctor.rb', line 926

def command_runnable?(cmd, version_arg: '--version')
  return false unless command_exists?(cmd)

  require 'open3'
  _out, _err, status = Open3.capture3(cmd, version_arg)
  status.success?
rescue StandardError
  false
end

.confirm_config_restore?(options) ⇒ Boolean

復元の最終確認。--yes または非対話(パイプ実行・CI)では自動で進める。 破損ファイルは必ず .bak へ退避するため、非対話でも非破壊(spec §3.2)

Returns:

  • (Boolean)


682
683
684
685
686
687
688
689
# File 'lib/vivlio_starter/cli/doctor.rb', line 682

def confirm_config_restore?(options)
  return true if options[:yes] || !$stdin.tty?

  return true if Common.confirm?('設定ファイルを初期状態から復元しますか?(破損ファイルはバックアップを取得します)')

  Common.log_always('設定ファイルの復元をスキップしました。')
  false
end

.create_kindlepreviewer_wrapper!(app_bin, bin_dir) ⇒ String?

アプリ内 CLI(app_bin)を引数透過で呼ぶ kindlepreviewer ラッパーを bin_dir に作成する。 既存の手動セットアップと同形の sh ラッパーを生成し、実行権限を付与する。

Returns:

  • (String, nil)

    作成したラッパーのパス(失敗時 nil)



855
856
857
858
859
860
861
862
863
864
# File 'lib/vivlio_starter/cli/doctor.rb', line 855

def create_kindlepreviewer_wrapper!(app_bin, bin_dir)
  wrapper = File.join(bin_dir, 'kindlepreviewer')
  File.write(wrapper, %(#!/bin/sh\n"#{app_bin}" "$@"\n))
  FileUtils.chmod('+x', wrapper)
  Common.log_always("kindlepreviewer ラッパーを作成しました: #{wrapper}")
  wrapper
rescue StandardError => e
  Common.log_warn("kindlepreviewer ラッパー作成に失敗: #{e}")
  nil
end

.describe_missing(keys) ⇒ Object

不足しているツールの表示名マッピングを返します。 ※ img2pdfは依存排除されたため削除されています。



877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
# File 'lib/vivlio_starter/cli/doctor.rb', line 877

def describe_missing(keys)
  return [] unless keys

  label_map = {
    'xcode-command-line-tools' => 'Xcode Command Line Tools',
    'node' => 'node',
    'vivliostyle' => 'Vivliostyle CLI',
    'textlint' => 'textlint',
    'qpdf' => 'qpdf',
    'pdfinfo' => 'pdfinfo (poppler)',
    'pdftoppm' => 'pdftoppm (poppler)',
    'gs' => 'Ghostscript',
    'imagemagick' => 'ImageMagick',
    'inkscape' => 'Inkscape',
    'vips' => 'vips (libvips)',
    'tesseract' => 'Tesseract OCR',
    'tesseract-lang' => 'Tesseract 日本語学習データ',
    'waifu2x' => 'waifu2x-ncnn-vulkan',
    'ssl-certificates' => 'Google Fonts 用 SSL 証明書',
    'mecab' => 'MeCab (索引機能用)',
    'rouge' => 'Rouge (コードブロック言語推定用)',
    'mathjax' => '数式SVG化 (mathjax-full)',
    'mermaid' => 'mermaid 図化 (mmdc・@mermaid-js/mermaid-cli)',
    'kindlepreviewer' => 'Kindle Previewer 3 (kindlepreviewer・targets: kindle 用)'
  }
  keys.uniq.map { |key| label_map[key] || key }
end

.detect_os_family(host_os) ⇒ Object



1074
1075
1076
1077
1078
1079
1080
1081
# File 'lib/vivlio_starter/cli/doctor.rb', line 1074

def detect_os_family(host_os)
  case host_os
  when /mswin|mingw|cygwin/i then :windows
  when /darwin/i then :macos
  when /linux/i then :linux
  else :unknown
  end
end

.detect_payload_root(extracted_dir) ⇒ Object



1218
1219
1220
1221
1222
1223
1224
1225
1226
# File 'lib/vivlio_starter/cli/doctor.rb', line 1218

def detect_payload_root(extracted_dir)
  entries = Dir.children(extracted_dir)
  return extracted_dir if entries.empty?

  first = File.join(extracted_dir, entries.first)
  File.directory?(first) ? first : extracted_dir
rescue StandardError
  nil
end

.diagnose_config_files!(options) ⇒ Object

config/ 配下を診断し、--fix 時は scaffold から復元する。 無関係なディレクトリに config/ を生成しないため、書籍プロジェクトの 痕跡(config/ または vivliostyle.config.js)が無い場所では何もしない。



622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
# File 'lib/vivlio_starter/cli/doctor.rb', line 622

def diagnose_config_files!(options)
  return unless book_project_dir?

  # --- Phase: 検出 ---
  # 必須 YAML は破損(YAML 解析不能)まで判定し、その他は欠落のみを見る
  broken = {}
  Common::REQUIRED_YAML_FILES.each do |path|
    case Guards::ConfigValidityCheck.diagnose(path)
    in [:ok, _] then next
    in [:missing, _]
      broken[path] = :missing
      Common.log_error("設定ファイルが見つかりません: #{path}")
    in [:corrupt, detail]
      broken[path] = :corrupt
      Common.log_error("設定ファイルが不正です: #{path}(YAML 解析に失敗)", detail:)
    end
  end

  missing_files = missing_optional_config_files
  missing_dirs = missing_config_dirs
  missing_files.each { Common.log_warn("設定ファイルが見つかりません: config/#{it}") }
  missing_dirs.each  { Common.log_warn("設定ディレクトリが見つかりません: config/#{it}/") }

  if broken.empty? && missing_files.empty? && missing_dirs.empty?
    Common.log_always('✅ config/ 設定ファイル: OK')
    return
  end

  unless options[:fix]
    Common.log_always('        修復するには vs doctor --fix を実行してください(破損ファイルはバックアップを取得します)')
    return
  end

  return unless confirm_config_restore?(options)

  # --- Phase: 復元 ---
  FileUtils.mkdir_p(Common::CONFIG_DIR)
  broken.each { |path, status| restore_required_yaml!(path, corrupt: status == :corrupt) }
  missing_files.each { restore_scaffold_file!(it) }
  missing_dirs.each  { restore_scaffold_dir!(it) }
rescue StandardError => e
  Common.log_warn("設定ファイルの診断・復元に失敗しました: #{e.class}: #{e.message}")
end

.download_asset(url, destination) ⇒ Object



1170
1171
1172
1173
1174
1175
1176
1177
1178
# File 'lib/vivlio_starter/cli/doctor.rb', line 1170

def download_asset(url, destination)
  URI.parse(url).open('User-Agent' => 'vivlio-starter') do |data|
    File.open(destination, 'wb') { |f| IO.copy_stream(data, f) }
  end
  true
rescue StandardError => e
  Common.log_warn("waifu2x アセットのダウンロードに失敗しました: #{e.class}: #{e.message}")
  false
end

.ensure_zsh_path(bin_dir) ⇒ Object



1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
# File 'lib/vivlio_starter/cli/doctor.rb', line 1254

def ensure_zsh_path(bin_dir)
  zshrc = File.join(Dir.home, '.zshrc')
  export_line = %(export PATH="#{bin_dir}:$PATH")

  contents = File.exist?(zshrc) ? File.read(zshrc) : ''
  return true if contents.include?(bin_dir)

  FileUtils.mkdir_p(File.dirname(zshrc)) unless File.directory?(File.dirname(zshrc))
  File.open(zshrc, 'a', encoding: 'utf-8') do |file|
    file.puts "\n# Added by vs doctor"
    file.puts export_line
  end
  true
rescue StandardError => e
  Common.log_warn("PATH 追記に失敗しました: #{e.class}: #{e.message}")
  false
end

.execute_doctor(command = nil) ⇒ void

This method returns an undefined value.

環境診断を実行し、不足ツールを報告・インストールする

オプション:

- :fix [Boolean] 不足ツールを自動インストール(macOS + Homebrew のみ)
- :yes [Boolean] 確認プロンプトをスキップ
- :verbose [Boolean] 詳細ログを出力

戻り値:

- 「必要ツールがすべて揃っているか」の Boolean
(vs upgrade のツール更新後の再診断が終了コード判定に使う。
  doctor コマンド自身は戻り値を終了コードへ反映しない)

Parameters:

  • command (Hash, Object, nil) (defaults to: nil)

    コマンドコンテキスト

    • Hash: { options: { fix: true, yes: true, verbose: false } }
    • Object: #options で Hash を返すオブジェクト


165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'lib/vivlio_starter/cli/doctor.rb', line 165

def execute_doctor(command = nil)
  options = extract_options(command)
  ENV['VERBOSE'] = '1' if options[:verbose]

  missing = []
  os = RbConfig::CONFIG['host_os']
  is_macos = os =~ /darwin/i

  Common.log_always('🔎 環境診断を開始します…')

  # --- Phase: 設定ファイル診断・復元(書籍プロジェクト内のみ)---
  diagnose_config_files!(options)

  # --- Phase: 外部ツール診断 ---
  # macOS では Xcode Command Line Tools が多くのビルドツールの前提条件
  if is_macos
    clt_ok = system('xcode-select -p >/dev/null 2>&1')
    if clt_ok
      Common.log_always('✅ Xcode Command Line Tools: OK')
    else
      Common.log_error('Xcode Command Line Tools: 見つかりません')
      missing << 'xcode-command-line-tools'
    end
  end

  # コマンド存在チェック定義
  # ※ img2pdfはJPEGからPDFへの結合に独自実装 JpegToPdf を使用するため依存排除されました。
  checks = {
    'node' => 'node',
    'textlint' => 'textlint',
    'vivliostyle' => 'vivliostyle',
    'qpdf' => 'qpdf',
    'pdfinfo' => 'pdfinfo',
    'pdftoppm' => 'pdftoppm',
    'gs' => 'gs', # Ghostscript
    'imagemagick' => nil,
    # inkscape はここには含めない。カバー SVG ラスタライズの主経路は rsvg-convert で、
    # inkscape は ImageMagick の SVG フォールバックでしか使われない任意ツールのため、
    # kindlepreviewer と同様にループ外で個別に診断する(ハードエラーにしない)。
    'vips' => 'vips',
    'tesseract' => 'tesseract',
    'tesseract-lang' => nil,
    'waifu2x' => nil,
    'mecab' => 'mecab', # 索引機能の読み自動推測用
    'rouge' => nil, # コードブロック言語推定用
    'mathjax' => nil, # 数式の SVG 化用(mathjax-full・npm パッケージ)
    'mermaid' => nil, # ```mermaid の図化用(@mermaid-js/mermaid-cli・mmdc)
    # EPUB 扉絵/節絵・図解注釈(showcase)の合成画像ラスタライズ用(librsvg)
    'rsvg-convert' => 'rsvg-convert'
  }

  plugin_installed = pdf_plugin_installed?
  ocr_optional_missing = []

  checks.each do |label, cmd|
    ok = case label
         when 'imagemagick'
           cli_tool_ok?('magick') || cli_tool_ok?('convert')
         when 'tesseract-lang'
           tesseract_language_available?('jpn')
         when 'waifu2x'
           waifu2x_available?
         when 'rouge'
           rouge_gem_available?
         when 'mathjax'
           mathjax_full_available?
         when 'mermaid'
           mmdc_available?
         else
           cli_tool_ok?(cmd)
         end

    if ok
      Common.log_always("#{label}: OK")
    elsif OCR_OPTIONAL_TOOLS.include?(label) && !plugin_installed
      # Enhanced Mode 専用ツールはプラグイン未導入の利用者にとってノイズのため
      # エラーにせず、後段でまとめて 🟡 注記を出す(spec §5.1)
      ocr_optional_missing << label
    else
      Common.log_error("#{label}: 見つかりません")
      missing << label
    end
  end

  # inkscape は任意ツール(カバー SVG ラスタライズの主経路は rsvg-convert。inkscape は
  # ImageMagick の SVG フォールバックでしか使われない)。存在+起動可能なら ✅、
  # 壊れ/不在は --fix(macOS) で復旧を試み、それ以外は 🟡 案内(ハードエラーにしない)。
  # command_runnable? を使うのは、半壊ラッパー(在るのに exit 126)まで見抜くため。
  inkscape_ok = command_runnable?('inkscape')
  Common.log_always('✅ inkscape: OK') if inkscape_ok

  # kindlepreviewer(Kindle Previewer 3)は targets: kindle 専用の任意ツール。
  # 存在すれば ✅、無ければ後段で 🟡 案内(ハードエラーにはしない)。
  # シムだけ残って .app 本体が消えた inkscape 型の半壊を見抜くため機能チェックする。
  kindle_previewer_present = kindlepreviewer_functional?
  Common.log_always('✅ kindlepreviewer (Kindle Previewer 3): OK') if kindle_previewer_present

  if is_macos
    if ssl_certificate_configured?
      Common.log_always('✅ Google Fonts 用 SSL 証明書: OK')
    else
      Common.log_error('Google Fonts 用 SSL 証明書: 未設定 (Google Fonts のダウンロードに必要)')
      missing << 'ssl-certificates'
    end
  end

  # Vivliostyle の headless Chrome キャッシュの健全性(中断ビルド等で壊れた残骸を掃除)
  # ※ missing.empty? の早期 return より前に実行し、他ツールが揃っていても修復できるようにする
  handle_vivliostyle_chrome(options)

  report_ocr_optional_tools(ocr_optional_missing)
  # --fix 時は OCR ツールも従来どおり先回りインストールする(spec §5.1)
  missing.concat(ocr_optional_missing) if options[:fix]

  # kindlepreviewer が不足の場合、--fix(macOS) ならインストール対象に積み(後段で導入)、
  # それ以外は 🟡 案内に留める(OCR ツールと同じ「任意ツールは fix 時だけ missing に積む」方式)。
  unless kindle_previewer_present
    if options[:fix] && is_macos
      missing << KINDLEPREVIEWER_COMMAND
    else
      report_kindle_previewer_optional(is_macos)
    end
  end

  # inkscape も同方式(任意ツール)。--fix(macOS) なら復旧を試み、それ以外は 🟡 案内。
  unless inkscape_ok
    if options[:fix] && is_macos
      missing << 'inkscape'
    else
      report_inkscape_optional(is_macos)
    end
  end

  os_family = detect_os_family(os)
  waifu2x_install_root = nil
  if options[:fix] && missing.include?('waifu2x')
    if os_family != :macos
      Common.log_warn('waifu2x の自動インストールは現在 macOS のみ対応しています。Linux / Windows では手動セットアップを行ってください。')
    elsif install_waifu2x_macos! do |paths|
            waifu2x_install_root = paths[:install]
          end
      missing.delete('waifu2x') if waifu2x_available?
    else
      Common.log_warn('waifu2x の自動インストールに失敗しました。手動セットアップを確認してください。')
    end
  end

  if missing.empty?
    Common.log_result('すべての必要ツールが見つかりました', status: :success)
    return true
  end

  # 「不足あり」も結末なので log_result で締める(❗ = 実行は成立したが要対応)
  Common.log_result("不足しているツール: #{describe_missing(missing).join(', ')}#{missing.size} 件)",
                    status: :warning)

  unless options[:fix]
    Common.log_always('ヒント: macOS の場合は `vs doctor --fix` で自動インストールを試行できます')
    if missing.include?('xcode-command-line-tools')
      Common.log_always('  Xcode Command Line Tools は手動でも `xcode-select --install` で導入できます')
    end
    return false
  end

  # --fix: 自動インストール試行
  unless is_macos
    Common.log_always('自動インストールは macOS(Homebrew) のみ対応です。手動でインストールしてください。')
    return false
  end

  # 先に CLT を処理(GUI 承認が必要)
  if missing.include?('xcode-command-line-tools')
    proceed = options[:yes]
    if !proceed && $stdin.tty?
      proceed = Common.confirm?('Xcode Command Line Tools をインストールしますか?')
    end
    if proceed
      Common.log_always('Xcode Command Line Tools のインストーラを起動します…')
      system('xcode-select --install >/dev/null 2>&1 || true')
      # ポーリングで最大 5 分間待機(5 秒間隔)
      waited = 0
      until system('xcode-select -p >/dev/null 2>&1') || waited >= 300
        sleep 5
        waited += 5
      end
      if system('xcode-select -p >/dev/null 2>&1')
        Common.log_always('✅ Xcode Command Line Tools が確認できました')
        missing.delete('xcode-command-line-tools')
      else
        Common.log_warn('インストールの確認ができませんでした。インストーラ完了後に再実行してください。')
      end
    else
      Common.log_always('Xcode Command Line Tools の自動インストールをスキップします。必要に応じて `xcode-select --install` を実行してください。')
    end
  end

  unless system('which brew >/dev/null 2>&1')
    Common.log_always('Homebrew が見つかりません。自動インストールを試みます。')
    proceed = options[:yes]
    if !proceed && $stdin.tty?
      proceed = Common.confirm?('Homebrew をインストールしますか?')
    end
    if proceed
      begin
        # 公式インストーラ実行(要ネットワーク)
        cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
        system(cmd)
      rescue StandardError => e
        Common.log_warn("Homebrew のインストールでエラー: #{e}")
      end
      # PATH 調整(Apple Silicon / Intel を想定)
      brew_bins = ['/opt/homebrew/bin', '/usr/local/bin']
      brew_bin = brew_bins.find { |p| File.exist?(File.join(p, 'brew')) }
      ENV['PATH'] = [brew_bin, ENV.fetch('PATH', nil)].compact.join(':') if brew_bin
    else
      Common.log_always('Homebrew をインストールしないため、自動インストール処理を中止します。手動で https://brew.sh/ を参照してください。')
      return false
    end
    unless system('which brew >/dev/null 2>&1')
      Common.log_always('Homebrew コマンドが見つかりませんでした。シェルの再起動や PATH 設定を確認してください。')
      return false
    end
  end

  Common.log_always('🛠 Homebrew による不足ツールのインストールを実行します…')
  begin
    # Node.js(node@20 を優先)
    if missing.include?('node')
      Common.log_always('node をインストールします(node@20 優先)…')
      ok = system('brew install node@20')
      ok ||= system('brew install node')
      Common.log_always('node の Homebrew インストールに失敗しました。手動インストールをご検討ください。') unless ok
    end

    # qpdf / poppler(pdfinfo, pdftoppm) / ghostscript / imagemagick / librsvg /
    # vips / tesseract(+lang) / mecab(+ipadic)。
    # formula 名の正典は ToolUpgrader::TOOLS(vs upgrade と共用し、二重管理しない)
    ToolUpgrader.brew_install_packages(missing).each { system("brew install #{it}") }

    # Inkscape(任意・カバー SVG フォールバック用)。半壊 cask も復旧できるよう force 対応。
    install_inkscape_macos! if missing.include?('inkscape')

    # Rouge(コードブロック言語推定用)
    if missing.include?('rouge')
      Common.log_always('Rouge(コードブロック言語推定用)をインストールします…')
      system('gem install rouge')
    end

    # mathjax-full(数式の SVG 化用・npm パッケージ)
    if missing.include?('mathjax')
      if system('which npm >/dev/null 2>&1')
        Common.log_always('数式の SVG 化用 mathjax-full をインストールします…')
        system('npm install --loglevel=error -g mathjax-full')
      else
        Common.log_always('npm が見つかりません。node のインストール後に `npm install -g mathjax-full` を実行してください。')
      end
    end

    # @mermaid-js/mermaid-cli(```mermaid の図化用・mmdc・npm パッケージ)
    if missing.include?('mermaid')
      if system('which npm >/dev/null 2>&1')
        Common.log_always('mermaid の図化用 @mermaid-js/mermaid-cli をインストールします…')
        system('npm install --loglevel=error -g @mermaid-js/mermaid-cli')
      else
        Common.log_always('npm が見つかりません。node のインストール後に `npm install -g @mermaid-js/mermaid-cli` を実行してください。')
      end
    end

    # Kindle Previewer 3(kindlepreviewer): cask 導入+アプリ内 CLI への PATH ラッパー作成
    install_kindlepreviewer_macos! if missing.include?(KINDLEPREVIEWER_COMMAND)

    install_ssl_certificates! if missing.include?('ssl-certificates')
  rescue StandardError => e
    Common.log_warn("brew 実行でエラー: #{e}")
  end

  # Vivliostyle CLI(npm -g)
  begin
    if missing.include?('vivliostyle')
      if system('which npm >/dev/null 2>&1')
        Common.log_always('Vivliostyle CLI(@vivliostyle/cli) をグローバルインストールします…')
        system('npm install --loglevel=error -g @vivliostyle/cli')
      else
        Common.log_always('npm が見つかりません。node のインストール後に `npm install -g @vivliostyle/cli` を実行してください。')
      end
    end
  rescue StandardError => e
    Common.log_warn("npm 実行でエラー: #{e}")
  end

  # textlint と推奨ルール
  begin
    if missing.include?('textlint')
      if system('which npm >/dev/null 2>&1')
        Common.log_always('textlint と推奨 Textlint ルールをグローバルインストールします…')
        packages = TEXTLINT_NPM_PACKAGES.map { |pkg| Shellwords.escape(pkg) }.join(' ')
        system("npm install --loglevel=error -g #{packages}")
      else
        Common.log_always('npm が見つかりません。node のインストール後に `npm install -g textlint textlint-rule-preset-ja-technical-writing ...` を実行してください。')
      end
    end
  rescue StandardError => e
    Common.log_warn("npm 実行でエラー: #{e}")
  end

  # 再診断
  Common.log_always('🔁 インストール後の再診断…')
  still_missing = []
  checks.each do |label, cmd|
    ok = case label
         when 'imagemagick'
           cli_tool_ok?('magick') || cli_tool_ok?('convert')
         when 'tesseract-lang'
           tesseract_language_available?('jpn')
         when 'waifu2x'
           waifu2x_available? || (waifu2x_install_root && waifu2x_present_at?(waifu2x_install_root, os_family))
         when 'rouge'
           rouge_gem_available?
         when 'mathjax'
           mathjax_full_available?
         when 'mermaid'
           mmdc_available?
         else
           cli_tool_ok?(cmd)
         end
    still_missing << label unless ok
  end
  still_missing << 'ssl-certificates' if is_macos && !ssl_certificate_configured?
  # プラグイン未導入の利用者には OCR ツールの不足を ❗ として残さない(spec §5.1)
  still_missing.reject! { OCR_OPTIONAL_TOOLS.include?(it) } unless plugin_installed

  # inkscape は任意ツール。--fix で導入を試みてもなお壊れている場合はハード ❗ ではなく
  # 🟡 で補足する(主経路は rsvg-convert なのでカバー生成自体は可能)。
  if missing.include?('inkscape') && !command_runnable?('inkscape')
    report_inkscape_optional(is_macos, install_failed: true)
  end

  if still_missing.empty?
    Common.log_always('✅ すべてのツールがインストールされました')
    true
  else
    Common.log_always("❗ まだ見つからないツールがあります: #{describe_missing(still_missing).join(', ')}。手動でのセットアップをご確認ください。")
    false
  end
end

.extract_archive(archive_path, destination, os_family = :macos) ⇒ Object



1191
1192
1193
1194
1195
1196
1197
# File 'lib/vivlio_starter/cli/doctor.rb', line 1191

def extract_archive(archive_path, destination, os_family = :macos)
  if os_family == :windows
    extract_with_powershell(archive_path, destination)
  else
    extract_with_unzip(archive_path, destination)
  end
end

.extract_options(command_or_ctx) ⇒ Object



512
513
514
515
516
517
518
519
520
521
522
523
524
525
# File 'lib/vivlio_starter/cli/doctor.rb', line 512

def extract_options(command_or_ctx)
  source =
    if command_or_ctx.nil?
      {}
    elsif command_or_ctx.is_a?(Hash)
      command_or_ctx[:options] || command_or_ctx
    elsif command_or_ctx.respond_to?(:options)
      command_or_ctx.options || {}
    else
      command_or_ctx
    end

  symbolize_option_keys(source || {})
end

.extract_with_powershell(archive_path, destination) ⇒ Object



1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
# File 'lib/vivlio_starter/cli/doctor.rb', line 1207

def extract_with_powershell(archive_path, destination)
  ps = %(powershell -NoLogo -NoProfile -Command "Expand-Archive -Force -LiteralPath '#{archive_path.gsub("'",
                                                                                                         "''")}' -DestinationPath '#{destination.gsub(
                                                                                                           "'", "''"
                                                                                                         )}'")
  system(ps)
rescue Errno::ENOENT
  Common.log_warn('PowerShell が見つかりません。手動で解凍してください。')
  false
end

.extract_with_unzip(archive_path, destination) ⇒ Object



1199
1200
1201
1202
1203
1204
1205
# File 'lib/vivlio_starter/cli/doctor.rb', line 1199

def extract_with_unzip(archive_path, destination)
  cmd = ['unzip', '-qq', archive_path, '-d', destination]
  system(*cmd)
rescue Errno::ENOENT
  Common.log_warn('unzip コマンドが見つかりません。手動で解凍してください。')
  false
end

.fetch_waifu2x_releaseObject



1161
1162
1163
1164
1165
1166
1167
1168
# File 'lib/vivlio_starter/cli/doctor.rb', line 1161

def fetch_waifu2x_release
  uri = URI.parse('https://api.github.com/repos/nihui/waifu2x-ncnn-vulkan/releases/tags/20250915')
  response = uri.open('User-Agent' => 'vivlio-starter')
  JSON.parse(response.read)
rescue StandardError => e
  Common.log_warn("waifu2x リリース情報の取得に失敗しました: #{e.class}: #{e.message}")
  nil
end

.file_executable?(path) ⇒ Boolean

Returns:

  • (Boolean)


1272
1273
1274
1275
1276
# File 'lib/vivlio_starter/cli/doctor.rb', line 1272

def file_executable?(path)
  return false unless File.exist?(path)

  windows_platform? || File.executable?(path)
end

.handle_vivliostyle_chrome(options) ⇒ Object

Vivliostyle が PDF レンダリングに使う headless Chrome のキャッシュを点検し、 中断したダウンロード/展開で壊れた残骸があれば(--fix 時に)掃除する。 ビルドを Ctrl+C で中断すると不完全な Chrome が残り、起動失敗 →「PDFの生成に失敗」 (本文欠落)になるため、著者がキャッシュを手で消さずに済むよう doctor が面倒を見る。 掃除後は次回ビルドで自動的に正しい Chrome が再取得される。



964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
# File 'lib/vivlio_starter/cli/doctor.rb', line 964

def handle_vivliostyle_chrome(options)
  broken = broken_vivliostyle_chrome_entries
  if broken.empty?
    Common.log_info('Vivliostyle の Chrome キャッシュは正常です')
    return
  end

  if options[:fix]
    broken.each { |path| FileUtils.rm_rf(path) }
    Common.log_success(
      "不完全な Vivliostyle Chrome を削除しました(次回ビルド時に自動再取得されます・#{broken.size}件)"
    )
  else
    Common.log_warn(
      'Vivliostyle の Chrome が不完全です(ビルド中断などで破損)。`vs doctor --fix` で修復できます。'
    )
  end
end

.homebrew_bin_dirObject

Homebrew の bin ディレクトリ(PATH 上)を返す。特定できなければ nil。



867
868
869
870
871
872
873
# File 'lib/vivlio_starter/cli/doctor.rb', line 867

def homebrew_bin_dir
  prefix = `brew --prefix 2>/dev/null`.strip
  return nil if prefix.empty?

  bin = File.join(prefix, 'bin')
  Dir.exist?(bin) ? bin : nil
end

.included(base) ⇒ Object

後方互換用の空フック



147
# File 'lib/vivlio_starter/cli/doctor.rb', line 147

def included(base); end

.install_inkscape_macos!Boolean

inkscape を macOS へ導入/復旧する(任意ツール)。 通常の brew install --cask inkscape を先に試し、失敗(半壊 cask のアップグレード扱いで purge に失敗する等)した場合は brew reinstall --cask --force inkscape で復旧する。

Returns:

  • (Boolean)

    導入/復旧に成功したか



810
811
812
813
814
815
816
817
818
819
820
821
822
823
# File 'lib/vivlio_starter/cli/doctor.rb', line 810

def install_inkscape_macos!
  unless system('which brew >/dev/null 2>&1')
    Common.log_warn('Homebrew が見つからないため inkscape を導入できません。')
    return false
  end

  Common.log_always('Inkscape を導入します(Homebrew cask)…')
  return true if system('brew install --cask inkscape')

  # 半壊 cask(記録は在るのに /Applications/Inkscape.app が無い等)は通常インストールが
  # アップグレード扱いになり purge に失敗する。--force 再インストールで上書き復旧する。
  Common.log_warn('通常インストールに失敗しました。壊れた cask を --force で再インストールします…')
  system('brew reinstall --cask --force inkscape')
end

.install_kindlepreviewer_macos!Object

Kindle Previewer 3(kindlepreviewer)を macOS へ導入する。 cask でアプリ本体(Pkg・管理者パスワードを求められることがある)を入れた後、 単体では PATH に乗らない CLI を呼ぶラッパーを Homebrew の bin へ作成する (アプリ内 "Kindle Previewer 3" 実行ファイルを引数透過で呼ぶ定石を自動化)。



829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
# File 'lib/vivlio_starter/cli/doctor.rb', line 829

def install_kindlepreviewer_macos!
  unless system('which brew >/dev/null 2>&1')
    Common.log_warn('Homebrew が見つからないため kindlepreviewer を導入できません。')
    return false
  end

  Common.log_always('Kindle Previewer 3(kindlepreviewer)を導入します(Homebrew cask)…')
  system('brew install --cask kindle-previewer')

  unless File.exist?(KINDLE_PREVIEWER_APP_BIN)
    Common.log_warn("Kindle Previewer 3 の実行ファイルが見つかりません: #{KINDLE_PREVIEWER_APP_BIN}")
    return false
  end

  bin_dir = homebrew_bin_dir
  unless bin_dir
    Common.log_warn('Homebrew の bin ディレクトリを特定できず、kindlepreviewer ラッパーを作成できません。')
    return false
  end

  !create_kindlepreviewer_wrapper!(KINDLE_PREVIEWER_APP_BIN, bin_dir).nil?
end

.install_ssl_certificates!Object



553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
# File 'lib/vivlio_starter/cli/doctor.rb', line 553

def install_ssl_certificates!
  Common.log_always('Google Fonts 用に ca-certificates / openssl@3 を設定します…')
  system('brew update >/dev/null 2>&1')
  system('brew install openssl@3') unless system('brew list --versions openssl@3 >/dev/null 2>&1')
  system('brew reinstall ca-certificates')

  openssl_prefix = capture_command('brew --prefix openssl@3').strip
  if openssl_prefix.empty?
    openssl_prefix = File.join(capture_command('brew --prefix').strip, 'opt',
                               'openssl@3')
  end

  cert_file = File.join(openssl_prefix, 'etc', 'openssl@3', 'cert.pem')
  cert_dir  = File.join(openssl_prefix, 'etc', 'openssl@3', 'certs')

  if File.file?(cert_file)
    ENV['SSL_CERT_FILE'] = cert_file
    ENV['SSL_CERT_DIR'] = cert_dir if Dir.exist?(cert_dir)

    persist_env('SSL_CERT_FILE', cert_file)
    persist_env('SSL_CERT_DIR', cert_dir) if Dir.exist?(cert_dir)

    Common.log_always("✅ SSL_CERT_FILE を #{cert_file} に設定しました")
    Common.log_always("✅ SSL_CERT_DIR を #{cert_dir} に設定しました") if Dir.exist?(cert_dir)
  else
    Common.log_warn("証明書ファイルが見つかりませんでした。#{openssl_prefix} に openssl@3 が存在するか確認してください。")
  end
end

.install_waifu2x_macos!Object



1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
# File 'lib/vivlio_starter/cli/doctor.rb', line 1083

def install_waifu2x_macos!
  paths = waifu2x_paths(:macos)
  unless paths
    Common.log_warn('macOS 用のインストール先を決定できませんでした')
    return false
  end

  FileUtils.mkdir_p(paths[:bin])
  FileUtils.mkdir_p(paths[:bundle])

  release = fetch_waifu2x_release
  return false unless release

  asset = Array(release['assets']).find { |a| a['name'].to_s.include?('macos') }
  unless asset
    Common.log_warn('macOS 用 waifu2x アセットが見つかりませんでした')
    return false
  end

  Dir.mktmpdir('waifu2x-install') do |tmpdir|
    archive_path = File.join(tmpdir, asset['name'])
    return false unless download_asset(asset['browser_download_url'], archive_path)

    return false unless verify_asset_digest(asset['digest'], archive_path)

    extracted_dir = File.join(tmpdir, 'extracted')
    FileUtils.mkdir_p(extracted_dir)
    return false unless extract_archive(archive_path, extracted_dir)

    payload_root = detect_payload_root(extracted_dir)
    return false unless payload_root

    # 既存の waifu2x 一式を削除
    clean_waifu2x_bins(paths[:bin], paths[:bundle])
    FileUtils.rm_rf(paths[:bundle])
    FileUtils.mkdir_p(paths[:bundle])

    Dir.children(payload_root).each do |child|
      src = File.join(payload_root, child)
      dst = File.join(paths[:bundle], child)
      FileUtils.cp_r(src, dst, preserve: true, remove_destination: true)
    end

    binary_path = locate_waifu2x_binary(paths[:bundle], :macos)
    unless binary_path
      Common.log_warn('waifu2x 実行ファイルが見つかりませんでした')
      return false
    end

    FileUtils.chmod(0o755, binary_path)

    Common.log_always("✅ waifu2x を #{paths[:bundle]} に配置しました")
    Common.log_always("   実行ファイル: #{binary_path}")
    unless path_included?(paths[:bin])
      if ensure_zsh_path(paths[:bin])
        Common.log_always('💡 ~/.zshrc に PATH を追記しました。新しいシェルで有効になります')
      else
        Common.log_always(path_hint_message(paths[:bin], :macos))
      end
    end
    yield(paths) if block_given?
    return true
  end
rescue StandardError => e
  Common.log_warn("waifu2x 自動インストールで例外: #{e.class}: #{e.message}")
  false
end

.kindlepreviewer_functional?Boolean

kindlepreviewer(Kindle Previewer 3)が実際に使えるかを機能チェックする。 kindlepreviewer は GUI アプリ(.app)を呼ぶラッパーシムなので、inkscape 同様 「シムだけ残って .app 本体が消えた」半壊がある。--version は GUI を起動しかねないため 実起動はせず、シムの存在に加えてシムが呼ぶ .app 実体の存在まで確認する。

Returns:

  • (Boolean)


946
947
948
949
950
# File 'lib/vivlio_starter/cli/doctor.rb', line 946

def kindlepreviewer_functional?
  return false unless command_exists?(KINDLEPREVIEWER_COMMAND)

  File.exist?(KINDLE_PREVIEWER_APP_BIN)
end

.locate_waifu2x_binary(install_root, os_family) ⇒ Object



1228
1229
1230
1231
# File 'lib/vivlio_starter/cli/doctor.rb', line 1228

def locate_waifu2x_binary(install_root, os_family)
  pattern = os_family == :windows ? 'waifu2x-ncnn-vulkan.exe' : 'waifu2x-ncnn-vulkan'
  Dir.glob(File.join(install_root, '**', pattern)).find { |path| File.file?(path) }
end

.mathjax_full_available?Boolean

mathjax-full(数式 SVG 化用の npm パッケージ)が解決できるか。 数式は前処理で Node 上の MathJax を「SVG 生成器」として呼び出すため、 node の存在に加え mathjax-full がローカル/グローバルの node_modules にあるかを見る。 未導入時は数式が SVG 化されず、Vivliostyle の MathJax 経路(PDF のみ)へ縮退する。

Returns:

  • (Boolean)


1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
# File 'lib/vivlio_starter/cli/doctor.rb', line 1014

def mathjax_full_available?
  return false unless command_exists?('node')

  local = File.join(Dir.pwd, 'node_modules', 'mathjax-full')
  return true if File.directory?(local)

  global = capture_command('npm root -g 2>/dev/null').to_s.strip
  !global.empty? && File.directory?(File.join(global, 'mathjax-full'))
rescue StandardError
  false
end

.missing_config_dirsObject



675
676
677
678
# File 'lib/vivlio_starter/cli/doctor.rb', line 675

def missing_config_dirs
  CONFIG_DIR_ENTRIES.reject { Dir.exist?(File.join(Common::CONFIG_DIR, it)) }
                    .select { Dir.exist?(File.join(SCAFFOLD_CONFIG_DIR, it)) }
end

.missing_optional_config_filesObject



670
671
672
673
# File 'lib/vivlio_starter/cli/doctor.rb', line 670

def missing_optional_config_files
  OPTIONAL_CONFIG_FILES.reject { File.file?(File.join(Common::CONFIG_DIR, it)) }
                       .select { File.file?(File.join(SCAFFOLD_CONFIG_DIR, it)) }
end

.mmdc_available?Boolean

mmdc(@mermaid-js/mermaid-cli)が解決でき起動できるか。

プロジェクト直下の node_modules/.bin を優先し、無ければ PATH 上の mmdc を確認する
(MermaidRenderer#resolve_mmdc_command と同じ解決順)。未導入時は図化されず
```mermaid はコードブロックのまま残る(ビルドは止まらない)。

Returns:

  • (Boolean)


1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
# File 'lib/vivlio_starter/cli/doctor.rb', line 1031

def mmdc_available?
  return false unless command_exists?('node')

  local = File.join(Dir.pwd, 'node_modules', '.bin', 'mmdc')
  return true if File.executable?(local)

  command_runnable?('mmdc')
rescue StandardError
  false
end

.path_hint_message(bin_dir, os_family) ⇒ Object



1287
1288
1289
1290
1291
1292
1293
1294
# File 'lib/vivlio_starter/cli/doctor.rb', line 1287

def path_hint_message(bin_dir, os_family)
  display_path = case os_family
                 when :windows then '%LOCALAPPDATA%\\vs\\bin'
                 when :macos, :linux then '$HOME/.local/bin'
                 else bin_dir
                 end
  "💡 PATH に #{display_path} を追加すると waifu2x-ncnn-vulkan が利用可能になります"
end

.path_included?(dir) ⇒ Boolean

Returns:

  • (Boolean)


1278
1279
1280
1281
1282
1283
1284
1285
# File 'lib/vivlio_starter/cli/doctor.rb', line 1278

def path_included?(dir)
  normalized = File.expand_path(dir)
  ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |entry|
    next if entry.nil? || entry.empty?

    File.expand_path(entry) == normalized
  end
end

.pdf_plugin_installed?Boolean

Enhanced Mode プラグインの導入有無(OCR ツールの診断ラベル出し分け用)。 provider.rb と異なり require はせず、インストール済み gemspec の有無のみを見る (doctor は判定だけが目的で、プラグイン本体や HexaPDF のロードは不要なため)

Returns:

  • (Boolean)


753
754
755
756
757
# File 'lib/vivlio_starter/cli/doctor.rb', line 753

def pdf_plugin_installed?
  Gem.path.any? { Dir.glob(File.join(it, 'specifications', "#{PDF_PLUGIN_GEM_NAME}-*.gemspec")).any? }
rescue StandardError
  false
end

.persist_env(key, value) ⇒ Object



588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
# File 'lib/vivlio_starter/cli/doctor.rb', line 588

def persist_env(key, value)
  return if value.nil? || value.empty?

  line = %(export #{key}="#{value}")
  profiles = %w[~/.zshrc ~/.bash_profile ~/.bashrc]
  profiles.each do |path|
    expanded = File.expand_path(path)
    begin
      if File.exist?(expanded)
        next if File.read(expanded, encoding: 'utf-8').include?(line)

        File.open(expanded, 'a', encoding: 'utf-8') do |f|
          f.puts unless File.read(expanded, encoding: 'utf-8').end_with?("\n")
          f.puts(line)
        end
      else
        FileUtils.mkdir_p(File.dirname(expanded))
        File.write(expanded, "#{line}\n", mode: 'a', encoding: 'utf-8')
      end
    rescue StandardError => e
      Common.log_warn("環境変数 #{key} の永続化に失敗しました (#{expanded}): #{e.class}: #{e.message}")
    end
  end
end

.report_inkscape_optional(is_macos, install_failed: false) ⇒ Object

inkscape 不在/破損時の 🟡 案内(任意ツール)。 カバー SVG のラスタライズ主経路は rsvg-convert なので、無くてもカバー生成は通る。 半壊 cask(記録は在るのに app 本体が消え、ラッパーが exit 126)の復旧には 通常の brew install ではなく --force 再インストールが要る点を明示する。

Parameters:

  • is_macos (Boolean)
  • install_failed (Boolean) (defaults to: false)

    --fix で導入を試みた後の案内か(見出しを変える)



789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
# File 'lib/vivlio_starter/cli/doctor.rb', line 789

def report_inkscape_optional(is_macos, install_failed: false)
  heading = if install_failed
              '任意ツール inkscape の導入に失敗しました(主経路は rsvg-convert なのでカバー生成は可能):'
            else
              '任意ツール inkscape(ImageMagick の SVG フォールバック用・主経路は rsvg-convert):'
            end
  detail = if is_macos
             "macOS では次で導入/復旧できます:\n" \
               '  brew reinstall --cask --force inkscape   # 半壊 cask(app 本体欠落)の復旧\n' \
               '  brew install --cask inkscape             # 未導入からの新規インストール'
           else
             'https://inkscape.org/ から導入し、inkscape に PATH を通してください。'
           end
  Common.log_warn(heading, detail:)
end

.report_kindle_previewer_optional(is_macos) ⇒ Object

kindlepreviewer 未導入時の案内(targets: kindle の KPF 変換時のみ必要・不足はエラーにしない)



772
773
774
775
776
777
778
779
780
# File 'lib/vivlio_starter/cli/doctor.rb', line 772

def report_kindle_previewer_optional(is_macos)
  detail = if is_macos
             'macOS では vs doctor --fix で自動導入できます(Homebrew cask kindle-previewer + PATH ラッパー作成)。'
           else
             'Amazon KDP のサイトから Kindle Previewer 3 を導入し、kindlepreviewer に PATH を通してください。'
           end
  Common.log_warn('任意ツール kindlepreviewer(Kindle Previewer 3・targets: kindle の KPF 変換時のみ必要):',
                  detail:)
end

.report_ocr_optional_tools(labels) ⇒ Object

プラグイン未導入時の OCR ツール案内(不足でもエラー扱いにしない / spec §5.2)



760
761
762
763
764
765
766
767
768
769
# File 'lib/vivlio_starter/cli/doctor.rb', line 760

def report_ocr_optional_tools(labels)
  return if labels.empty?

  lines = []
  lines << '- tesseract / tesseract-lang(OCR エンジン)' if labels.intersect?(%w[tesseract tesseract-lang])
  lines << '- vips(画像処理)' if labels.include?('vips')
  lines << "gem install #{PDF_PLUGIN_GEM_NAME} 後、vs doctor --fix でまとめて導入できます"
  Common.log_warn('任意ツール(pdf:read Enhanced Mode 用・vivlio-starter-pdf 利用時に必要):',
                  detail: lines.join("\n"))
end

.restore_required_yaml!(path, corrupt:) ⇒ Object

必須 YAML 1 件を復元する。破損時は必ず .bak へ退避した上で(spec §3.2)、 サルベージ(機能 D)→ 失敗なら素の scaffold 復元の順で試みる。



693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
# File 'lib/vivlio_starter/cli/doctor.rb', line 693

def restore_required_yaml!(path, corrupt:)
  scaffold_path = File.join(SCAFFOLD_CONFIG_DIR, File.basename(path))
  return Common.log_warn("scaffold に同名ファイルが無いため復元できません: #{path}") unless File.file?(scaffold_path)

  backup_path = nil
  salvaged = nil
  if corrupt
    corrupt_content = File.read(path, encoding: 'utf-8')
    backup_path = backup_corrupt_file!(path)
    # サルベージは best-effort。失敗は握りつぶして素の scaffold 復元へ進む(spec §3D.1)
    salvaged = begin
      ConfigSalvager.salvage(path, corrupt_content, scaffold_path)
    rescue StandardError => e
      Common.log_debug("サルベージに失敗したため初期状態から復元します: #{e.class}: #{e.message}")
      nil
    end
  end

  if salvaged
    File.write(path, salvaged.content, encoding: 'utf-8')
    Common.log_always("#{salvaged.summary}")
    salvaged.notes.each { Common.log_always("        #{it}") }
  else
    # book.yml はテンプレートのため、素の復元でもプレースホルダを既定値へ展開する
    content = if File.basename(path) == 'book.yml'
                ConfigSalvager.render_book_yml(scaffold_path)
              else
                File.read(scaffold_path, encoding: 'utf-8')
              end
    File.write(path, content, encoding: 'utf-8')
    Common.log_always("#{path} を初期状態から復元しました")
  end
  Common.log_always("        以前の設定は #{backup_path} から書き戻せます") if backup_path
end

.restore_scaffold_dir!(basename) ⇒ Object



741
742
743
744
# File 'lib/vivlio_starter/cli/doctor.rb', line 741

def restore_scaffold_dir!(basename)
  FileUtils.cp_r(File.join(SCAFFOLD_CONFIG_DIR, basename), File.join(Common::CONFIG_DIR, basename))
  Common.log_always("✅ config/#{basename}/ を初期状態から復元しました")
end

.restore_scaffold_file!(basename) ⇒ Object



736
737
738
739
# File 'lib/vivlio_starter/cli/doctor.rb', line 736

def restore_scaffold_file!(basename)
  FileUtils.cp(File.join(SCAFFOLD_CONFIG_DIR, basename), File.join(Common::CONFIG_DIR, basename))
  Common.log_always("✅ config/#{basename} を初期状態から復元しました")
end

.rouge_gem_available?Boolean

Returns:

  • (Boolean)


952
953
954
955
956
957
# File 'lib/vivlio_starter/cli/doctor.rb', line 952

def rouge_gem_available?
  require 'rouge'
  true
rescue LoadError
  false
end

.ssl_certificate_configured?Boolean

Returns:

  • (Boolean)


546
547
548
549
550
551
# File 'lib/vivlio_starter/cli/doctor.rb', line 546

def ssl_certificate_configured?
  test_cmd = "ruby -ropen-uri -e 'URI.open(\"https://fonts.googleapis.com/css2?family=Roboto&display=swap\") { |r| exit(r.status.first == \"200\" ? 0 : 1) }'"
  system(test_cmd)
rescue StandardError
  false
end

.symbolize_option_keys(hash) ⇒ Object



528
529
530
531
532
533
534
535
# File 'lib/vivlio_starter/cli/doctor.rb', line 528

def symbolize_option_keys(hash)
  return {} unless hash.respond_to?(:each_with_object)

  hash.each_with_object({}) do |(key, value), result|
    sym_key = key.is_a?(String) ? key.to_sym : key
    result[sym_key || key] = value
  end
end

.tesseract_language_available?(language) ⇒ Boolean

Returns:

  • (Boolean)


1042
1043
1044
1045
1046
1047
1048
1049
# File 'lib/vivlio_starter/cli/doctor.rb', line 1042

def tesseract_language_available?(language)
  return false unless command_exists?('tesseract')

  output = capture_command('tesseract --list-langs 2>/dev/null')
  output.lines.map(&:strip).include?(language.to_s)
rescue StandardError
  false
end

.verify_asset_digest(digest_field, file_path) ⇒ Object



1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
# File 'lib/vivlio_starter/cli/doctor.rb', line 1180

def verify_asset_digest(digest_field, file_path)
  return true unless digest_field.to_s.start_with?('sha256:')

  expected = digest_field.split(':', 2).last
  actual = Digest::SHA256.file(file_path).hexdigest
  return true if actual.casecmp?(expected)

  Common.log_warn('ダウンロードした waifu2x アセットの SHA256 が一致しません')
  false
end

.vivliostyle_browsers_cache_dirObject

vivliostyle が Chrome を保存するキャッシュディレクトリ(macOS)。



1000
1001
1002
# File 'lib/vivlio_starter/cli/doctor.rb', line 1000

def vivliostyle_browsers_cache_dir
  File.join(Dir.home, 'Library', 'Caches', 'vivliostyle', 'browsers')
end

.waifu2x_available?Boolean

Returns:

  • (Boolean)


1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
# File 'lib/vivlio_starter/cli/doctor.rb', line 1051

def waifu2x_available?
  os_family = detect_os_family(RbConfig::CONFIG['host_os'])
  paths = waifu2x_paths(os_family)

  candidates = [ENV.fetch('WAIFU2X_BIN', nil),
                'waifu2x-ncnn-vulkan',
                'waifu2x-ncnn-vulkan.exe']

  if paths
    %w[waifu2x-ncnn-vulkan waifu2x-ncnn-vulkan.exe].each do |name|
      candidates << File.join(paths[:bin], name)
      candidates << File.join(paths[:bundle], name)
    end
    candidates << paths[:binary]
  end

  candidates.compact.any? { |cmd| command_exists?(cmd) }
end

.waifu2x_paths(os_family = :macos) ⇒ Object



1151
1152
1153
1154
1155
1156
1157
1158
1159
# File 'lib/vivlio_starter/cli/doctor.rb', line 1151

def waifu2x_paths(os_family = :macos)
  return nil unless os_family == :macos

  base_dir = File.join(Dir.home, '.local')
  bin_dir = File.join(base_dir, 'bin')
  bundle_dir = File.join(bin_dir, 'waifu2x')
  binary_path = File.join(bundle_dir, 'waifu2x-ncnn-vulkan')
  { install: bundle_dir, bin: bin_dir, bundle: bundle_dir, binary: binary_path }
end

.waifu2x_present_at?(install_root, os_family) ⇒ Boolean

Returns:

  • (Boolean)


1233
1234
1235
1236
1237
1238
1239
# File 'lib/vivlio_starter/cli/doctor.rb', line 1233

def waifu2x_present_at?(install_root, os_family)
  return false unless install_root && File.directory?(install_root)

  binary = locate_waifu2x_binary(install_root, os_family)
  models = Dir.glob(File.join(install_root, 'models-*')).any? { |path| File.directory?(path) }
  binary && models
end

.windows_platform?Boolean

Returns:

  • (Boolean)


1070
1071
1072
# File 'lib/vivlio_starter/cli/doctor.rb', line 1070

def windows_platform?
  RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/i
end