docktor_rails 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3e9a0546a0a1bfb8849cd3c5f8cd2767f5a49ac7c3e8077fb1be9f964e494fc9
4
+ data.tar.gz: '0099dfd0a1ad6f689a02c321a75e3e6e95a619abd17186becc0630ddae2c5ce4'
5
+ SHA512:
6
+ metadata.gz: 69695f70efc8e1aee5a28ee5696e14244430c3fc251ff414097d400508bc2f1e940f526b9ed7d14aa82d8f97d9d3ceae185dbf1d6960cb9aa05c891ec2c9557a
7
+ data.tar.gz: f8c1911b3b59b0b3bec51d9033f3bb6d1f6264fb9cca7deb2c013ed34e07d4ea15b567b5c78c780dd62335f30168f58d3f18f1f5f47dfe113daea2c455c3778a
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-06-11
4
+
5
+ - Initial release
6
+ - Added preflight checks for Compose file presence, build path validity, env_file presence, entrypoint sanity/exec, CRLF in shell scripts, db/redis healthcheck warnings, platform pin risk, and Rails master key availability.
7
+ - Added `--root` for scanning a target directory.
8
+ - Added `--format json` output with context (host OS/arch, root).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 FAllS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # DocktorRails
2
+ DocktorRails is a tiny preflight checker for Rails Docker/Compose dev environments.
3
+ It catches common cross-machine issues early (CRLF shell scripts, missing compose-referenced files, build path mistakes, platform pinning risk) and provides CI-friendly JSON output.
4
+
5
+ ## Installation
6
+ Add it to your Rails app:
7
+
8
+ ```bash
9
+ bundle add docktor_rails
10
+ ```
11
+
12
+ ## Usage
13
+ Run in your project root:
14
+
15
+ ```bash
16
+ bundle exec docktor_rails diagnose
17
+ ```
18
+
19
+ Scan another directory (useful in mono-repos):
20
+
21
+ ```bash
22
+ bundle exec docktor_rails diagnose --root ../some_app
23
+ ```
24
+
25
+ CI/automation (JSON):
26
+
27
+ ```bash
28
+ bundle exec docktor_rails diagnose --format json --output preflight.json
29
+ ```
30
+
31
+ Options:
32
+ - `--verbose`: show passing checks
33
+ - `--quiet`: only show errors/warnings + summary (good for CI logs)
34
+
35
+ ## Exit codes
36
+ - `0`: no failing checks
37
+ - `1`: one or more failing checks
38
+ - `2`: tool/internal error
39
+
40
+ ## GitHub Actions example
41
+
42
+ ```yaml
43
+ name: Preflight
44
+ on:
45
+ pull_request:
46
+ push:
47
+ branches: [ main ]
48
+
49
+ jobs:
50
+ preflight:
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@v4
54
+ - uses: ruby/setup-ruby@v1
55
+ with:
56
+ ruby-version: "3.3"
57
+ bundler-cache: true
58
+ - run: bundle exec docktor_rails diagnose --format json --output preflight.json
59
+ - if: always()
60
+ uses: actions/upload-artifact@v4
61
+ with:
62
+ name: docktor-rails-preflight
63
+ path: preflight.json
64
+ ```
65
+
66
+ ## Contributing
67
+ Issues and pull requests are welcome.
68
+
69
+ ## License
70
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: %i[spec]
9
+
10
+ # Optional lint task (not required for build/publish)
11
+ begin
12
+ require "rubocop/rake_task"
13
+ RuboCop::RakeTask.new
14
+ task lint: :rubocop
15
+ rescue LoadError
16
+ # rubocop not installed
17
+ end
data/exe/docktor_rails ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "docktor_rails"
5
+
6
+ DocktorRails::CLI.start(ARGV)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../result"
4
+
5
+ module DocktorRails
6
+ module Checks
7
+ class BaseCheck
8
+ def id
9
+ raise NotImplementedError
10
+ end
11
+
12
+ def name
13
+ id
14
+ end
15
+
16
+ def run(_ctx)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ private
21
+
22
+ def pass(message, files: nil, hint: nil)
23
+ Result.new(id: id, status: :pass, message: message, files: files, hint: hint)
24
+ end
25
+
26
+ def warn(message, files: nil, hint: nil)
27
+ Result.new(id: id, status: :warn, message: message, files: files, hint: hint)
28
+ end
29
+
30
+ def fail(message, files: nil, hint: nil)
31
+ Result.new(id: id, status: :fail, message: message, files: files, hint: hint)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_check"
4
+ require_relative "../compose"
5
+
6
+ module DocktorRails
7
+ module Checks
8
+ class ComposeBuildPaths < BaseCheck
9
+ def id
10
+ "compose.build_paths"
11
+ end
12
+
13
+ def run(ctx)
14
+ root = ctx.fetch(:root)
15
+ compose_path = Compose.find_file(root)
16
+ return pass("No compose file found (skipping build path check)") unless compose_path
17
+
18
+ doc = Compose.load_file(compose_path)
19
+ services = Compose.services(doc)
20
+
21
+ problems = []
22
+
23
+ services.each do |svc_name, svc|
24
+ next unless svc.is_a?(Hash)
25
+
26
+ build = svc["build"]
27
+ next if build.nil?
28
+
29
+ context, dockerfile = extract_build(build)
30
+ context ||= "."
31
+
32
+ context_abs = File.expand_path(context, root)
33
+ unless Dir.exist?(context_abs)
34
+ problems << "#{svc_name}: build.context missing (#{context})"
35
+ next
36
+ end
37
+
38
+ dockerfile ||= "Dockerfile"
39
+ dockerfile_abs = File.expand_path(dockerfile, context_abs)
40
+ unless File.file?(dockerfile_abs)
41
+ problems << "#{svc_name}: build.dockerfile missing (#{File.join(context, dockerfile)})"
42
+ end
43
+ end
44
+
45
+ if problems.empty?
46
+ pass("Compose build paths look valid")
47
+ else
48
+ fail("Compose build path issues detected", files: problems)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def extract_build(build)
55
+ case build
56
+ when String
57
+ [build, nil]
58
+ when Hash
59
+ [build["context"], build["dockerfile"]]
60
+ else
61
+ [nil, nil]
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_check"
4
+ require_relative "../compose"
5
+
6
+ module DocktorRails
7
+ module Checks
8
+ class ComposeEntrypointExec < BaseCheck
9
+ def id
10
+ "compose.entrypoint_exec"
11
+ end
12
+
13
+ def run(ctx)
14
+ root = ctx.fetch(:root)
15
+ platform = ctx[:platform]
16
+
17
+ if platform&.windows?
18
+ return pass("Windows host: skipping executable/shebang checks")
19
+ end
20
+
21
+ compose_path = Compose.find_file(root)
22
+ return pass("No compose file found (skipping entrypoint exec check)") unless compose_path
23
+
24
+ doc = Compose.load_file(compose_path)
25
+ services = Compose.services(doc)
26
+
27
+ entrypoint_files = services.flat_map do |_svc_name, svc|
28
+ next [] unless svc.is_a?(Hash)
29
+
30
+ ep = svc["entrypoint"]
31
+ ep = normalize_entrypoint(ep)
32
+ next [] unless ep
33
+
34
+ file = entrypoint_file_candidate(ep)
35
+ file ? [file] : []
36
+ end
37
+
38
+ entrypoint_files.uniq!
39
+ return pass("No entrypoint file paths detected") if entrypoint_files.empty?
40
+
41
+ problems = []
42
+
43
+ entrypoint_files.each do |file|
44
+ abs = File.expand_path(file, root)
45
+ next unless File.file?(abs) # existence handled elsewhere
46
+
47
+ problems << "#{file}: not executable" unless File.executable?(abs)
48
+
49
+ first = File.open(abs, "rb", &:readline) rescue ""
50
+ problems << "#{file}: missing shebang" unless first.start_with?("#!")
51
+ end
52
+
53
+ if problems.empty?
54
+ pass("Entrypoint executable/shebang OK")
55
+ else
56
+ fail("Entrypoint executable/shebang issues detected", files: problems)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def normalize_entrypoint(value)
63
+ case value
64
+ when Array
65
+ value.first
66
+ when String
67
+ value.strip
68
+ else
69
+ nil
70
+ end
71
+ end
72
+
73
+ def entrypoint_file_candidate(entrypoint)
74
+ first = entrypoint.split(/\s+/).first
75
+ return nil if first.nil? || first.empty?
76
+
77
+ looks_like_path = first.include?("/") || first.end_with?(".sh") || first.start_with?(".")
78
+ looks_like_path ? first : nil
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_check"
4
+ require_relative "../compose"
5
+
6
+ module DocktorRails
7
+ module Checks
8
+ class ComposeEntrypointSanity < BaseCheck
9
+ def id
10
+ "compose.entrypoint_sanity"
11
+ end
12
+
13
+ def run(ctx)
14
+ root = ctx.fetch(:root)
15
+ compose_path = Compose.find_file(root)
16
+ return pass("No compose file found (skipping entrypoint check)") unless compose_path
17
+
18
+ doc = Compose.load_file(compose_path)
19
+ services = Compose.services(doc)
20
+
21
+ entrypoints = services.flat_map do |svc_name, svc|
22
+ next [] unless svc.is_a?(Hash)
23
+
24
+ ep = svc["entrypoint"]
25
+ ep = normalize_entrypoint(ep)
26
+ next [] unless ep
27
+
28
+ [{ service: svc_name, entrypoint: ep }]
29
+ end
30
+
31
+ return pass("No compose entrypoints referenced") if entrypoints.empty?
32
+
33
+ failures = []
34
+ entrypoints.each do |ep|
35
+ file = entrypoint_file_candidate(ep.fetch(:entrypoint))
36
+ next unless file
37
+
38
+ abs = File.expand_path(file, root)
39
+ unless abs.start_with?(File.expand_path(root) + File::SEPARATOR)
40
+ failures << { file: file, message: "entrypoint points outside repo root" }
41
+ next
42
+ end
43
+
44
+ unless File.file?(abs)
45
+ failures << { file: file, message: "file not found" }
46
+ next
47
+ end
48
+
49
+ if File.binread(abs).include?("\r\n")
50
+ failures << { file: file, message: "CRLF line endings" }
51
+ end
52
+ end
53
+
54
+ if failures.empty?
55
+ pass("Entrypoint files look sane")
56
+ else
57
+ files = failures.map { |f| f.fetch(:file) }
58
+ fail("Compose entrypoint issues detected", files: files, hint: "Ensure entrypoint scripts exist and use LF line endings (not CRLF).")
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def normalize_entrypoint(value)
65
+ case value
66
+ when Array
67
+ value.first
68
+ when String
69
+ value.strip
70
+ else
71
+ nil
72
+ end
73
+ end
74
+
75
+ def entrypoint_file_candidate(entrypoint)
76
+ return nil if entrypoint.nil? || entrypoint.empty?
77
+
78
+ # Strings like "bash -lc ..." are commands, not file paths.
79
+ first = entrypoint.split(/\s+/).first
80
+ return nil if first.nil? || first.empty?
81
+
82
+ looks_like_path = first.include?("/") || first.end_with?(".sh") || first.start_with?(".")
83
+ looks_like_path ? first : nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_check"
4
+ require_relative "../compose"
5
+
6
+ module DocktorRails
7
+ module Checks
8
+ class ComposeEnvFilePresent < BaseCheck
9
+ def id
10
+ "compose.env_file_present"
11
+ end
12
+
13
+ def run(ctx)
14
+ root = ctx.fetch(:root)
15
+ compose_path = Compose.find_file(root)
16
+ return pass("No compose file found (skipping env_file check)") unless compose_path
17
+
18
+ doc = Compose.load_file(compose_path)
19
+ services = Compose.services(doc)
20
+
21
+ missing = []
22
+ services.each do |_name, svc|
23
+ next unless svc.is_a?(Hash)
24
+
25
+ env_file = svc["env_file"]
26
+ files = case env_file
27
+ when String then [env_file]
28
+ when Array then env_file
29
+ else []
30
+ end
31
+
32
+ files.each do |f|
33
+ next unless f.is_a?(String)
34
+ path = File.expand_path(f, root)
35
+ missing << f unless File.file?(path)
36
+ end
37
+ end
38
+
39
+ if missing.empty?
40
+ pass("All compose env_file entries exist")
41
+ else
42
+ fail("Missing compose env_file files", files: missing.uniq.sort)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_check"
4
+
5
+ module DocktorRails
6
+ module Checks
7
+ class ComposeFilePresent < BaseCheck
8
+ CANDIDATES = ["compose.yml", "docker-compose.yml"].freeze
9
+
10
+ def id
11
+ "compose.file_present"
12
+ end
13
+
14
+ def run(ctx)
15
+ root = ctx.fetch(:root)
16
+ found = CANDIDATES.map { |f| File.join(root, f) }.find { |p| File.file?(p) }
17
+
18
+ if found
19
+ pass("Compose file present", files: [relative_to_root(found, root)])
20
+ else
21
+ fail("No compose.yml or docker-compose.yml found", hint: "Add a compose.yml (or docker-compose.yml) at the repo root.")
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def relative_to_root(path, root)
28
+ path.delete_prefix(root + File::SEPARATOR)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_check"
4
+ require_relative "../compose"
5
+
6
+ module DocktorRails
7
+ module Checks
8
+ class ComposeHealthchecks < BaseCheck
9
+ def id
10
+ "compose.healthchecks"
11
+ end
12
+
13
+ def run(ctx)
14
+ root = ctx.fetch(:root)
15
+ compose_path = Compose.find_file(root)
16
+ return pass("No compose file found (skipping healthcheck check)") unless compose_path
17
+
18
+ doc = Compose.load_file(compose_path)
19
+ services = Compose.services(doc)
20
+
21
+ targets = %w[db redis].select { |name| services.key?(name) }
22
+ return pass("No db/redis services detected") if targets.empty?
23
+
24
+ missing = targets.reject do |name|
25
+ svc = services[name]
26
+ svc.is_a?(Hash) && svc.key?("healthcheck") && !svc["healthcheck"].nil?
27
+ end
28
+
29
+ if missing.empty?
30
+ pass("Healthchecks present for db/redis")
31
+ else
32
+ warn("Missing healthchecks for: #{missing.join(", ")}", hint: "Add compose healthchecks so readiness is deterministic across machines.")
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_check"
4
+ require_relative "../compose"
5
+
6
+ module DocktorRails
7
+ module Checks
8
+ class ComposePlatformRisk < BaseCheck
9
+ def id
10
+ "compose.platform_risk"
11
+ end
12
+
13
+ def run(ctx)
14
+ root = ctx.fetch(:root)
15
+ platform = ctx[:platform]
16
+ compose_path = Compose.find_file(root)
17
+ return pass("No compose file found (skipping platform check)") unless compose_path
18
+
19
+ return pass("Unknown host architecture (skipping platform risk)") if platform.nil? || platform.arch == :unknown
20
+
21
+ doc = Compose.load_file(compose_path)
22
+ services = Compose.services(doc)
23
+
24
+ pins = services.filter_map do |name, svc|
25
+ next unless svc.is_a?(Hash)
26
+ p = svc["platform"]
27
+ next unless p.is_a?(String)
28
+ [name, p]
29
+ end
30
+
31
+ return pass("No compose platform pins") if pins.empty?
32
+
33
+ risky = pins.select { |_name, p| mismatch?(platform, p) }
34
+
35
+ if risky.empty?
36
+ pass("Compose platform pins match host")
37
+ else
38
+ warn("Compose platform pins may not match this host", files: risky.map { |n, p| "#{n}: #{p}" })
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def mismatch?(platform, pin)
45
+ pin = pin.downcase
46
+ return platform.arm64? && pin.include?("amd64")
47
+ return platform.amd64? && (pin.include?("arm64") || pin.include?("aarch64"))
48
+
49
+ false
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_check"
4
+ require_relative "../dotenv"
5
+
6
+ module DocktorRails
7
+ module Checks
8
+ class RailsMasterKeyRequired < BaseCheck
9
+ def id
10
+ "env.rails_master_key"
11
+ end
12
+
13
+ def run(ctx)
14
+ root = ctx.fetch(:root)
15
+
16
+ encrypted_files = encrypted_credentials_files(root)
17
+ return pass("No encrypted credentials detected") if encrypted_files.empty?
18
+
19
+ # Satisfy via file (common for local dev).
20
+ master_key_path = File.join(root, "config", "master.key")
21
+ if File.file?(master_key_path) && !File.read(master_key_path).strip.empty?
22
+ return pass("master.key present", files: ["config/master.key"])
23
+ end
24
+
25
+ # Or via environment variable (common for CI/prod).
26
+ if ENV.key?("RAILS_MASTER_KEY") && !ENV.fetch("RAILS_MASTER_KEY").to_s.empty?
27
+ return pass("RAILS_MASTER_KEY is set in ENV")
28
+ end
29
+
30
+ # Or via dotenv file commonly used for docker/compose flows.
31
+ dotenv_path = File.join(root, ".env.docker")
32
+ dotenv = Dotenv.parse_file(dotenv_path)
33
+ if dotenv.key?("RAILS_MASTER_KEY") && !dotenv.fetch("RAILS_MASTER_KEY").to_s.empty?
34
+ return pass("RAILS_MASTER_KEY present in .env.docker", files: [".env.docker"])
35
+ end
36
+
37
+ fail(
38
+ "Missing master key for encrypted credentials",
39
+ files: encrypted_files,
40
+ hint: "Provide config/master.key (local), or set RAILS_MASTER_KEY (CI/prod), or add it to .env.docker."
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ def encrypted_credentials_files(root)
47
+ files = []
48
+
49
+ default = File.join(root, "config", "credentials.yml.enc")
50
+ files << "config/credentials.yml.enc" if File.file?(default)
51
+
52
+ # Rails also supports per-environment credentials in config/credentials/*.yml.enc
53
+ Dir.glob(File.join(root, "config", "credentials", "*.yml.enc")).each do |path|
54
+ rel = path.delete_prefix(root + File::SEPARATOR)
55
+ files << rel
56
+ end
57
+
58
+ files.uniq
59
+ end
60
+ end
61
+ end
62
+ end