envspec 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28f13d06ae0882152222605d7b631f76f181c63fd91544ace2e53c33cc718ffa
4
- data.tar.gz: 616d302174e177c0cce5864a4a12533710ea4823c528496a0e88a9f1466462be
3
+ metadata.gz: 876d54d300e49212e28fe8ff57ed3b05fc96ef168a2b668ac71ba6f2c9421945
4
+ data.tar.gz: d7d25d84e2fc60d898070bd13ce0cfebe891612035975695e16c31b10eedd319
5
5
  SHA512:
6
- metadata.gz: 9b57dba76d1e5862c97548c1c0b040e1147ce3abac04e3034d9ae313f46c27fbbdb9a3105d776a7da2d5d066412a05373dfc19c769698222c25406f9d9b651d0
7
- data.tar.gz: a19532006f8fc6822fa3a8b4ed7a17f4ba6485547233819e76c762df9a22b79b09a6ab52adf36401d760bc0ae0265939ad9e848d967ccb0f349b0572ca0db65d
6
+ metadata.gz: 7a4582455ef7d02a5d190d8bb16173ddda9e67aacbe701fe29b16879df6e404dad3d6106ff792913527677253511c1c0428e3cfb20dccc41b348f36125a06234
7
+ data.tar.gz: af136637aa5cd6f4e35129dbfe787ce59ec245901b5980b0a6b4df06a8790885ce1e59426ace7ececb9f095bca1d94aafc00cf85b7386a07ccae2054830bfea3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 — 2026-05-04
4
+
5
+ - Split CLI into one file per subcommand under `lib/envspec/cli/`:
6
+ `command.rb` (base class), `lint.rb`, `check.rb`, `init.rb`. Adding a new
7
+ subcommand is now: write a class inheriting from `EnvSpec::CLI::Command`,
8
+ register it in `EnvSpec::CLI::COMMANDS`.
9
+ - Aligned formatting in global help.
10
+
11
+ ## 0.1.1 — 2026-05-04
12
+
13
+ - Refactor CLI into `EnvSpec::CLI` with proper subcommand structure (per-subcommand `OptionParser` with banners, options, and `-h` help). Still pure stdlib.
14
+ - `envspec help <command>` and `envspec <command> -h` now show subcommand-specific usage.
15
+ - Better exit codes (2 for usage errors, 1 for parse/validation errors, 0 for success).
16
+
3
17
  ## 0.1.0 — 2026-05-04
4
18
 
5
19
  Initial release.
data/exe/envspec CHANGED
@@ -1,106 +1,3 @@
1
1
  #!/usr/bin/env ruby
2
- require "optparse"
3
2
  require "envspec"
4
-
5
- def die(msg, code = 1)
6
- warn "envspec: #{msg}"
7
- exit code
8
- end
9
-
10
- def usage
11
- <<~USAGE
12
- Usage: envspec <command> [options]
13
-
14
- Commands:
15
- lint <file> Parse and validate syntax of an env.spec file
16
- check <file> [--env=NAME] Check current ENV against the spec
17
- (presence + type validation)
18
- init [--force] [--output=PATH]
19
- Scan repo for env var usages and generate env.spec
20
-
21
- Options:
22
- -h, --help Show this help
23
- -v, --version Show version
24
-
25
- USAGE
26
- end
27
-
28
- cmd = ARGV.shift
29
-
30
- case cmd
31
- when nil, "-h", "--help", "help"
32
- puts usage
33
- exit 0
34
-
35
- when "-v", "--version", "version"
36
- puts EnvSpec::VERSION
37
- exit 0
38
-
39
- when "lint"
40
- file = ARGV.shift or die "lint: missing file argument\n\n#{usage}"
41
- die "file not found: #{file}" unless File.exist?(file)
42
- begin
43
- EnvSpec.parse_file(file)
44
- puts "✓ #{file} is valid"
45
- rescue EnvSpec::ParseError => e
46
- die e.message
47
- end
48
-
49
- when "check"
50
- file = nil
51
- env = "*"
52
- strict = false
53
-
54
- parser = OptionParser.new do |o|
55
- o.on("--env=NAME") { |v| env = v }
56
- o.on("--strict") { strict = true }
57
- end
58
- rest = parser.parse(ARGV)
59
- file = rest.shift or die "check: missing file argument\n\n#{usage}"
60
- die "file not found: #{file}" unless File.exist?(file)
61
-
62
- spec = begin
63
- EnvSpec.parse_file(file)
64
- rescue EnvSpec::ParseError => e
65
- die e.message
66
- end
67
-
68
- begin
69
- spec.validate!(ENV.to_h, env: env, strict: strict)
70
- scope_label = env == "*" ? "shared" : env
71
- puts "✓ ENV satisfies #{file} (env: #{scope_label})"
72
- rescue EnvSpec::ValidationError => e
73
- die e.message
74
- end
75
-
76
- when "init"
77
- force = false
78
- output = "env.spec"
79
- root = Dir.pwd
80
-
81
- parser = OptionParser.new do |o|
82
- o.on("--force") { force = true }
83
- o.on("--output=PATH") { |v| output = v }
84
- o.on("--root=PATH") { |v| root = v }
85
- end
86
- parser.parse(ARGV)
87
-
88
- result = EnvSpec::Init.run(root: root, force: force, output: output)
89
-
90
- unless result[:ok]
91
- die "#{output} already exists (use --force to overwrite)" if result[:reason] == :exists
92
- die "init failed: #{result[:reason]}"
93
- end
94
-
95
- puts "✓ Scanned in #{format('%.2fs', result[:elapsed])}"
96
- puts "✓ Found #{result[:keys]} env vars across #{result[:files]} files"
97
- puts "✓ Wrote #{result[:path]}"
98
- puts ""
99
- puts "Next steps:"
100
- puts " 1. Edit #{output} — review heuristic suggestions, classify secrets"
101
- puts " 2. Run `envspec lint #{output}` to validate"
102
- puts " 3. Run `envspec check #{output}` to test against your current ENV"
103
-
104
- else
105
- die "unknown command: #{cmd}\n\n#{usage}"
106
- end
3
+ exit EnvSpec::CLI.run(ARGV)
@@ -0,0 +1,33 @@
1
+ require_relative "command"
2
+
3
+ class EnvSpec
4
+ class CLI
5
+ class Check < Command
6
+ name "check"
7
+ summary "Check current ENV against an env.spec"
8
+ banner "Usage: envspec check <file> [options]"
9
+ description <<~DESC
10
+ Validates the current process ENV against an env.spec file.
11
+ Checks presence of required keys and that values match declared types.
12
+ DESC
13
+
14
+ def default_opts
15
+ { env: "*", strict: false }
16
+ end
17
+
18
+ def configure(o, opts)
19
+ o.on("--env=NAME", "Env scope to validate against (default: shared/*)") { |v| opts[:env] = v }
20
+ o.on("--strict", "Treat unknown env vars as problems") { opts[:strict] = true }
21
+ end
22
+
23
+ def call(rest, opts)
24
+ file = require_file(rest.shift)
25
+ spec = EnvSpec.parse_file(file)
26
+ spec.validate!(ENV.to_h, env: opts[:env], strict: opts[:strict])
27
+ label = opts[:env] == "*" ? "shared" : opts[:env]
28
+ puts "✓ ENV satisfies #{file} (env: #{label})"
29
+ 0
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,97 @@
1
+ require "optparse"
2
+
3
+ class EnvSpec
4
+ class CLI
5
+ # Base class for a single CLI subcommand.
6
+ #
7
+ # Subclasses declare:
8
+ # - name (string, e.g. "lint")
9
+ # - summary (one-line description for global help)
10
+ # - configure(parser, opts) — define options on the OptionParser
11
+ # - call(argv, opts) — execute; return exit code
12
+ class Command
13
+ class << self
14
+ def name(value = nil)
15
+ @name = value if value
16
+ @name
17
+ end
18
+
19
+ def summary(value = nil)
20
+ @summary = value if value
21
+ @summary
22
+ end
23
+
24
+ def banner(value = nil)
25
+ @banner = value if value
26
+ @banner
27
+ end
28
+
29
+ def description(value = nil)
30
+ @description = value if value
31
+ @description
32
+ end
33
+ end
34
+
35
+ def initialize
36
+ @opts = default_opts
37
+ end
38
+
39
+ def default_opts
40
+ {}
41
+ end
42
+
43
+ def parser
44
+ @parser ||= build_parser
45
+ end
46
+
47
+ def build_parser
48
+ OptionParser.new do |o|
49
+ o.banner = self.class.banner || "Usage: envspec #{self.class.name}"
50
+ if self.class.description
51
+ o.separator ""
52
+ self.class.description.each_line { |ln| o.separator ln.chomp }
53
+ end
54
+ o.separator ""
55
+ o.separator "Options:"
56
+ configure(o, @opts)
57
+ o.on("-h", "--help", "Show help for this command") { puts o; exit 0 }
58
+ end
59
+ end
60
+
61
+ def configure(_parser, _opts)
62
+ # subclass override
63
+ end
64
+
65
+ def help
66
+ parser.help
67
+ end
68
+
69
+ # Subclasses override `call(argv, opts)`. The dispatcher invokes `run(argv)`.
70
+ def run(argv)
71
+ rest = parser.parse(argv)
72
+ call(rest, @opts)
73
+ end
74
+
75
+ def call(_rest, _opts)
76
+ raise NotImplementedError
77
+ end
78
+
79
+ protected
80
+
81
+ def die(msg, code = 1)
82
+ warn "envspec: #{msg}"
83
+ code
84
+ end
85
+
86
+ def require_file(file)
87
+ return file if file && File.exist?(file)
88
+ if file.nil?
89
+ warn parser.help
90
+ exit 2
91
+ end
92
+ warn "envspec: file not found: #{file}"
93
+ exit 1
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,46 @@
1
+ require_relative "command"
2
+
3
+ class EnvSpec
4
+ class CLI
5
+ class Init < Command
6
+ name "init"
7
+ summary "Scan repo for env vars and generate env.spec"
8
+ banner "Usage: envspec init [options]"
9
+ description <<~DESC
10
+ Scans the current directory tree for env var usages across
11
+ Ruby/Python/JS/Go/Shell sources and writes a starter env.spec.
12
+ DESC
13
+
14
+ def default_opts
15
+ { force: false, output: "env.spec", root: Dir.pwd }
16
+ end
17
+
18
+ def configure(o, opts)
19
+ o.on("--force", "Overwrite an existing env.spec") { opts[:force] = true }
20
+ o.on("--output=PATH", "Output file (default: env.spec)") { |v| opts[:output] = v }
21
+ o.on("--root=PATH", "Directory to scan (default: cwd)") { |v| opts[:root] = v }
22
+ end
23
+
24
+ def call(_rest, opts)
25
+ result = EnvSpec::Init.run(root: opts[:root], force: opts[:force], output: opts[:output])
26
+
27
+ unless result[:ok]
28
+ if result[:reason] == :exists
29
+ return die("#{opts[:output]} already exists (use --force to overwrite)", 1)
30
+ end
31
+ return die("init failed: #{result[:reason]}", 1)
32
+ end
33
+
34
+ puts "✓ Scanned in #{format('%.2fs', result[:elapsed])}"
35
+ puts "✓ Found #{result[:keys]} env vars across #{result[:files]} files"
36
+ puts "✓ Wrote #{result[:path]}"
37
+ puts ""
38
+ puts "Next steps:"
39
+ puts " 1. Edit #{opts[:output]} — review heuristic suggestions, classify secrets"
40
+ puts " 2. Run `envspec lint #{opts[:output]}` to validate"
41
+ puts " 3. Run `envspec check #{opts[:output]}` to test against your current ENV"
42
+ 0
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "command"
2
+
3
+ class EnvSpec
4
+ class CLI
5
+ class Lint < Command
6
+ name "lint"
7
+ summary "Parse and validate syntax of an env.spec file"
8
+ banner "Usage: envspec lint <file>"
9
+ description "Parses an env.spec file and reports any syntax errors."
10
+
11
+ def call(rest, _opts)
12
+ file = require_file(rest.shift)
13
+ EnvSpec.parse_file(file)
14
+ puts "✓ #{file} is valid"
15
+ 0
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,80 @@
1
+ require_relative "cli/command"
2
+ require_relative "cli/lint"
3
+ require_relative "cli/check"
4
+ require_relative "cli/init"
5
+
6
+ class EnvSpec
7
+ # Dispatcher for `envspec` subcommands.
8
+ #
9
+ # Each subcommand is a class under EnvSpec::CLI inheriting from
10
+ # EnvSpec::CLI::Command. Register new commands by adding them to COMMANDS
11
+ # below and creating a file under lib/envspec/cli/.
12
+ class CLI
13
+ COMMANDS = {
14
+ "lint" => Lint,
15
+ "check" => Check,
16
+ "init" => Init,
17
+ }.freeze
18
+
19
+ def self.run(argv)
20
+ new.run(argv)
21
+ end
22
+
23
+ def run(argv)
24
+ argv = argv.dup
25
+ cmd = argv.shift
26
+
27
+ case cmd
28
+ when nil, "help", "-h", "--help"
29
+ print_help(argv.first)
30
+ return 0
31
+ when "version", "-v", "--version"
32
+ puts EnvSpec::VERSION
33
+ return 0
34
+ end
35
+
36
+ klass = COMMANDS[cmd]
37
+ unless klass
38
+ warn "envspec: unknown command '#{cmd}'"
39
+ warn ""
40
+ print_help
41
+ return 2
42
+ end
43
+
44
+ klass.new.run(argv)
45
+ rescue OptionParser::ParseError => e
46
+ warn "envspec: #{e.message}"
47
+ 2
48
+ rescue EnvSpec::ParseError, EnvSpec::ValidationError => e
49
+ warn "envspec: #{e.message}"
50
+ 1
51
+ end
52
+
53
+ private
54
+
55
+ def print_help(topic = nil)
56
+ if topic && (klass = COMMANDS[topic])
57
+ puts klass.new.help
58
+ return
59
+ end
60
+ puts global_help
61
+ end
62
+
63
+ def global_help
64
+ rows = COMMANDS.map { |n, k| [n, k.summary] }
65
+ rows << ["help", "Show help for a command"]
66
+ rows << ["version", "Show version"]
67
+ width = rows.map { |r| r[0].length }.max
68
+
69
+ lines = []
70
+ lines << "Usage: envspec <command> [options]"
71
+ lines << ""
72
+ lines << "Commands:"
73
+ rows.each { |name, summary| lines << format(" %-#{width}s %s", name, summary) }
74
+ lines << ""
75
+ lines << "Run `envspec help <command>` (or `envspec <command> -h`) for command-specific options."
76
+ lines << "Docs: https://github.com/repleadfy/envspec"
77
+ lines.join("\n")
78
+ end
79
+ end
80
+ end
@@ -1,3 +1,3 @@
1
1
  class EnvSpec
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.1.2".freeze
3
3
  end
data/lib/envspec.rb CHANGED
@@ -4,3 +4,4 @@ require_relative "envspec/entry"
4
4
  require_relative "envspec/parser"
5
5
  require_relative "envspec/validator"
6
6
  require_relative "envspec/init"
7
+ require_relative "envspec/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: envspec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leadfy
@@ -67,6 +67,11 @@ files:
67
67
  - README.md
68
68
  - exe/envspec
69
69
  - lib/envspec.rb
70
+ - lib/envspec/cli.rb
71
+ - lib/envspec/cli/check.rb
72
+ - lib/envspec/cli/command.rb
73
+ - lib/envspec/cli/init.rb
74
+ - lib/envspec/cli/lint.rb
70
75
  - lib/envspec/entry.rb
71
76
  - lib/envspec/errors.rb
72
77
  - lib/envspec/heuristics.rb