envspec 0.1.1 → 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 +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/envspec/cli/check.rb +33 -0
- data/lib/envspec/cli/command.rb +97 -0
- data/lib/envspec/cli/init.rb +46 -0
- data/lib/envspec/cli/lint.rb +19 -0
- data/lib/envspec/cli.rb +41 -140
- data/lib/envspec/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 876d54d300e49212e28fe8ff57ed3b05fc96ef168a2b668ac71ba6f2c9421945
|
|
4
|
+
data.tar.gz: d7d25d84e2fc60d898070bd13ce0cfebe891612035975695e16c31b10eedd319
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a4582455ef7d02a5d190d8bb16173ddda9e67aacbe701fe29b16879df6e404dad3d6106ff792913527677253511c1c0428e3cfb20dccc41b348f36125a06234
|
|
7
|
+
data.tar.gz: af136637aa5cd6f4e35129dbfe787ce59ec245901b5980b0a6b4df06a8790885ce1e59426ace7ececb9f095bca1d94aafc00cf85b7386a07ccae2054830bfea3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.1.1 — 2026-05-04
|
|
4
12
|
|
|
5
13
|
- Refactor CLI into `EnvSpec::CLI` with proper subcommand structure (per-subcommand `OptionParser` with banners, options, and `-h` help). Still pure stdlib.
|
|
@@ -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
|
data/lib/envspec/cli.rb
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
require_relative "cli/command"
|
|
2
|
+
require_relative "cli/lint"
|
|
3
|
+
require_relative "cli/check"
|
|
4
|
+
require_relative "cli/init"
|
|
2
5
|
|
|
3
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/.
|
|
4
12
|
class CLI
|
|
5
|
-
|
|
13
|
+
COMMANDS = {
|
|
14
|
+
"lint" => Lint,
|
|
15
|
+
"check" => Check,
|
|
16
|
+
"init" => Init,
|
|
17
|
+
}.freeze
|
|
6
18
|
|
|
7
19
|
def self.run(argv)
|
|
8
20
|
new.run(argv)
|
|
@@ -15,22 +27,21 @@ class EnvSpec
|
|
|
15
27
|
case cmd
|
|
16
28
|
when nil, "help", "-h", "--help"
|
|
17
29
|
print_help(argv.first)
|
|
18
|
-
0
|
|
30
|
+
return 0
|
|
19
31
|
when "version", "-v", "--version"
|
|
20
32
|
puts EnvSpec::VERSION
|
|
21
|
-
0
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
when "init"
|
|
27
|
-
cmd_init(argv)
|
|
28
|
-
else
|
|
33
|
+
return 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
klass = COMMANDS[cmd]
|
|
37
|
+
unless klass
|
|
29
38
|
warn "envspec: unknown command '#{cmd}'"
|
|
30
39
|
warn ""
|
|
31
40
|
print_help
|
|
32
|
-
2
|
|
41
|
+
return 2
|
|
33
42
|
end
|
|
43
|
+
|
|
44
|
+
klass.new.run(argv)
|
|
34
45
|
rescue OptionParser::ParseError => e
|
|
35
46
|
warn "envspec: #{e.message}"
|
|
36
47
|
2
|
|
@@ -41,139 +52,29 @@ class EnvSpec
|
|
|
41
52
|
|
|
42
53
|
private
|
|
43
54
|
|
|
44
|
-
# ── help ──────────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
55
|
def print_help(topic = nil)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
when "init" then puts init_parser({}).help
|
|
51
|
-
else puts global_help
|
|
56
|
+
if topic && (klass = COMMANDS[topic])
|
|
57
|
+
puts klass.new.help
|
|
58
|
+
return
|
|
52
59
|
end
|
|
60
|
+
puts global_help
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
def global_help
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
# ── lint ──────────────────────────────────────────────────────────
|
|
72
|
-
|
|
73
|
-
def lint_parser
|
|
74
|
-
OptionParser.new do |o|
|
|
75
|
-
o.banner = "Usage: envspec lint <file>"
|
|
76
|
-
o.separator ""
|
|
77
|
-
o.separator "Parses an env.spec file and reports any syntax errors."
|
|
78
|
-
o.separator ""
|
|
79
|
-
o.separator "Options:"
|
|
80
|
-
o.on("-h", "--help") { puts o; exit 0 }
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def cmd_lint(argv)
|
|
85
|
-
rest = lint_parser.parse(argv)
|
|
86
|
-
file = rest.shift or (warn lint_parser.help; return 2)
|
|
87
|
-
die_unless_file(file)
|
|
88
|
-
|
|
89
|
-
EnvSpec.parse_file(file)
|
|
90
|
-
puts "✓ #{file} is valid"
|
|
91
|
-
0
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# ── check ─────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
def check_parser(opts)
|
|
97
|
-
OptionParser.new do |o|
|
|
98
|
-
o.banner = "Usage: envspec check <file> [options]"
|
|
99
|
-
o.separator ""
|
|
100
|
-
o.separator "Validates the current process ENV against an env.spec file."
|
|
101
|
-
o.separator "Checks presence of required keys and that values match declared types."
|
|
102
|
-
o.separator ""
|
|
103
|
-
o.separator "Options:"
|
|
104
|
-
o.on("--env=NAME", "Env scope to validate against (default: shared/*)") do |v|
|
|
105
|
-
opts[:env] = v
|
|
106
|
-
end
|
|
107
|
-
o.on("--strict", "Treat unknown env vars as problems") do
|
|
108
|
-
opts[:strict] = true
|
|
109
|
-
end
|
|
110
|
-
o.on("-h", "--help") { puts o; exit 0 }
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def cmd_check(argv)
|
|
115
|
-
opts = { env: "*", strict: false }
|
|
116
|
-
rest = check_parser(opts).parse(argv)
|
|
117
|
-
file = rest.shift or (warn check_parser(opts).help; return 2)
|
|
118
|
-
die_unless_file(file)
|
|
119
|
-
|
|
120
|
-
spec = EnvSpec.parse_file(file)
|
|
121
|
-
spec.validate!(ENV.to_h, env: opts[:env], strict: opts[:strict])
|
|
122
|
-
|
|
123
|
-
label = opts[:env] == "*" ? "shared" : opts[:env]
|
|
124
|
-
puts "✓ ENV satisfies #{file} (env: #{label})"
|
|
125
|
-
0
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# ── init ──────────────────────────────────────────────────────────
|
|
129
|
-
|
|
130
|
-
def init_parser(opts)
|
|
131
|
-
OptionParser.new do |o|
|
|
132
|
-
o.banner = "Usage: envspec init [options]"
|
|
133
|
-
o.separator ""
|
|
134
|
-
o.separator "Scans the current directory tree for env var usages across"
|
|
135
|
-
o.separator "Ruby/Python/JS/Go/Shell sources and writes a starter env.spec."
|
|
136
|
-
o.separator ""
|
|
137
|
-
o.separator "Options:"
|
|
138
|
-
o.on("--force", "Overwrite an existing env.spec") { opts[:force] = true }
|
|
139
|
-
o.on("--output=PATH", "Output file (default: env.spec)") { |v| opts[:output] = v }
|
|
140
|
-
o.on("--root=PATH", "Directory to scan (default: cwd)") { |v| opts[:root] = v }
|
|
141
|
-
o.on("-h", "--help") { puts o; exit 0 }
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def cmd_init(argv)
|
|
146
|
-
opts = { force: false, output: "env.spec", root: Dir.pwd }
|
|
147
|
-
init_parser(opts).parse(argv)
|
|
148
|
-
|
|
149
|
-
result = EnvSpec::Init.run(root: opts[:root], force: opts[:force], output: opts[:output])
|
|
150
|
-
|
|
151
|
-
unless result[:ok]
|
|
152
|
-
if result[:reason] == :exists
|
|
153
|
-
warn "envspec: #{opts[:output]} already exists (use --force to overwrite)"
|
|
154
|
-
return 1
|
|
155
|
-
end
|
|
156
|
-
warn "envspec: init failed: #{result[:reason]}"
|
|
157
|
-
return 1
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
puts "✓ Scanned in #{format('%.2fs', result[:elapsed])}"
|
|
161
|
-
puts "✓ Found #{result[:keys]} env vars across #{result[:files]} files"
|
|
162
|
-
puts "✓ Wrote #{result[:path]}"
|
|
163
|
-
puts ""
|
|
164
|
-
puts "Next steps:"
|
|
165
|
-
puts " 1. Edit #{opts[:output]} — review heuristic suggestions, classify secrets"
|
|
166
|
-
puts " 2. Run `envspec lint #{opts[:output]}` to validate"
|
|
167
|
-
puts " 3. Run `envspec check #{opts[:output]}` to test against your current ENV"
|
|
168
|
-
0
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# ── helpers ───────────────────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
def die_unless_file(file)
|
|
174
|
-
return if File.exist?(file)
|
|
175
|
-
warn "envspec: file not found: #{file}"
|
|
176
|
-
exit 1
|
|
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")
|
|
177
78
|
end
|
|
178
79
|
end
|
|
179
80
|
end
|
data/lib/envspec/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leadfy
|
|
@@ -68,6 +68,10 @@ files:
|
|
|
68
68
|
- exe/envspec
|
|
69
69
|
- lib/envspec.rb
|
|
70
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
|
|
71
75
|
- lib/envspec/entry.rb
|
|
72
76
|
- lib/envspec/errors.rb
|
|
73
77
|
- lib/envspec/heuristics.rb
|