envspec 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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +74 -0
- data/exe/envspec +106 -0
- data/lib/envspec/entry.rb +3 -0
- data/lib/envspec/errors.rb +20 -0
- data/lib/envspec/heuristics.rb +38 -0
- data/lib/envspec/init.rb +103 -0
- data/lib/envspec/parser.rb +186 -0
- data/lib/envspec/scanner.rb +161 -0
- data/lib/envspec/validator.rb +70 -0
- data/lib/envspec/version.rb +3 -0
- data/lib/envspec.rb +6 -0
- metadata +101 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 28f13d06ae0882152222605d7b631f76f181c63fd91544ace2e53c33cc718ffa
|
|
4
|
+
data.tar.gz: 616d302174e177c0cce5864a4a12533710ea4823c528496a0e88a9f1466462be
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9b57dba76d1e5862c97548c1c0b040e1147ce3abac04e3034d9ae313f46c27fbbdb9a3105d776a7da2d5d066412a05373dfc19c769698222c25406f9d9b651d0
|
|
7
|
+
data.tar.gz: a19532006f8fc6822fa3a8b4ed7a17f4ba6485547233819e76c762df9a22b79b09a6ab52adf36401d760bc0ae0265939ad9e848d967ccb0f349b0572ca0db65d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-05-04
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Parser for `env.spec` syntax v5 (`[secrets]` modifier, `[env: …]` selector, scoped configmap/secrets, types `str | int | bool | dsn | enum`).
|
|
8
|
+
- Validator: presence + type-level checks (int/bool/enum/dsn).
|
|
9
|
+
- CLI: `envspec lint`, `envspec check`, `envspec init`.
|
|
10
|
+
- `init` scans Ruby/Python/JS/Go/Shell sources and generates a starter `env.spec` with heuristic-based suggestions.
|
|
11
|
+
- Zero runtime dependencies; Ruby ≥ 2.5.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leadfy
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# envspec
|
|
2
|
+
|
|
3
|
+
Declarative env var contract for apps. Pure-Ruby gem with **zero runtime dependencies**.
|
|
4
|
+
|
|
5
|
+
`envspec` parses `env.spec` files — a small DSL that describes which env vars an app expects, their types, optionality, and ConfigMap-vs-Secret classification. Use it as a CI lint, a Rails boot check, or a deploy-time contract.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install envspec
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Ruby ≥ 2.5. No native extensions, no other gems.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
# Generate env.spec from a scan of your repo
|
|
19
|
+
envspec init
|
|
20
|
+
|
|
21
|
+
# Validate the spec syntactically
|
|
22
|
+
envspec lint env.spec
|
|
23
|
+
|
|
24
|
+
# Check current ENV against the spec
|
|
25
|
+
envspec check env.spec --env=production
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## env.spec syntax
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
# shared (all envs)
|
|
32
|
+
APP_NAME
|
|
33
|
+
LOG_LEVEL: enum(debug, info, warn, error) = info
|
|
34
|
+
DEBUG?: bool
|
|
35
|
+
PORT: int = 3000
|
|
36
|
+
|
|
37
|
+
[secrets]
|
|
38
|
+
DB_PASS
|
|
39
|
+
OPENAI_API_KEY
|
|
40
|
+
|
|
41
|
+
[env: production]
|
|
42
|
+
DB_HOST
|
|
43
|
+
[secrets]
|
|
44
|
+
STRIPE_LIVE_KEY
|
|
45
|
+
|
|
46
|
+
[env: production, staging]
|
|
47
|
+
SENTRY_DSN: dsn
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `KEY` — required string in shared scope, ConfigMap
|
|
51
|
+
- `KEY?` — optional
|
|
52
|
+
- `KEY: type` — typed (`str | int | bool | dsn | enum(a,b,c)`)
|
|
53
|
+
- `KEY = default` — default value (configmap only; secrets cannot have defaults)
|
|
54
|
+
- `[secrets]` — modifier; subsequent keys become Secrets (scope unchanged)
|
|
55
|
+
- `[env: name]` / `[env: a, b]` — selector; subsequent keys scoped to those envs (also resets type back to ConfigMap — explicit beats implicit)
|
|
56
|
+
|
|
57
|
+
## Library API
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
require "envspec"
|
|
61
|
+
|
|
62
|
+
spec = EnvSpec.parse_file("env.spec")
|
|
63
|
+
|
|
64
|
+
spec.configmap # => { "*" => {...}, "production" => {...} }
|
|
65
|
+
spec.secrets # => { "*" => {...}, "production" => {...} }
|
|
66
|
+
spec.envs # => ["production", "staging"]
|
|
67
|
+
spec.configmap_for("production") # merged shared + production
|
|
68
|
+
|
|
69
|
+
spec.validate!(ENV.to_h, env: "production") # raises EnvSpec::ValidationError if anything missing or wrong type
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT.
|
data/exe/envspec
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
require "optparse"
|
|
3
|
+
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
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class EnvSpec
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class ParseError < Error
|
|
5
|
+
attr_reader :line_no
|
|
6
|
+
def initialize(msg, line_no = nil)
|
|
7
|
+
super(line_no ? "line #{line_no}: #{msg}" : msg)
|
|
8
|
+
@line_no = line_no
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class ValidationError < Error
|
|
13
|
+
attr_reader :problems
|
|
14
|
+
def initialize(problems)
|
|
15
|
+
@problems = Array(problems)
|
|
16
|
+
lines = ["env validation failed:"] + @problems.map { |p| " - #{p}" }
|
|
17
|
+
super(lines.join("\n"))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class EnvSpec
|
|
2
|
+
# Best-effort, conservative inference of envspec syntax from a key name and
|
|
3
|
+
# observed usage data. Output is ALWAYS suggestive — the generated env.spec
|
|
4
|
+
# file marks heuristic decisions as TODO comments so the dev reviews them.
|
|
5
|
+
module Heuristics
|
|
6
|
+
SECRET_NAME_RE = /(?:KEY|SECRET|TOKEN|PASSWORD|PASS|CREDENTIAL|PRIVATE)\b/.freeze
|
|
7
|
+
DSN_NAME_RE = /(?:_URL|_URI|_DSN|DATABASE_URL|REDIS_URL|MONGO_URL|AMQP_URL)\z/.freeze
|
|
8
|
+
INT_NAME_RE = /(?:PORT|TIMEOUT|INTERVAL|SIZE|COUNT|LIMIT|MAX_|MIN_)/.freeze
|
|
9
|
+
BOOL_NAME_RE = /\A(?:DEBUG|VERBOSE|ENABLE_|.*_ENABLED|IS_|HAS_|USE_)/.freeze
|
|
10
|
+
|
|
11
|
+
def self.infer(name, usages)
|
|
12
|
+
{
|
|
13
|
+
secret: secret?(name),
|
|
14
|
+
type: infer_type(name),
|
|
15
|
+
optional: usages.any? { |u| u[:optional] },
|
|
16
|
+
default: pick_default(usages),
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.secret?(name)
|
|
21
|
+
return true if name =~ SECRET_NAME_RE
|
|
22
|
+
return true if name =~ DSN_NAME_RE
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.infer_type(name)
|
|
27
|
+
return :dsn if name =~ DSN_NAME_RE
|
|
28
|
+
return :int if name =~ INT_NAME_RE
|
|
29
|
+
return :bool if name =~ BOOL_NAME_RE
|
|
30
|
+
:str
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.pick_default(usages)
|
|
34
|
+
defaults = usages.map { |u| u[:default] }.compact.uniq
|
|
35
|
+
defaults.first
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/envspec/init.rb
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require_relative "scanner"
|
|
2
|
+
require_relative "heuristics"
|
|
3
|
+
|
|
4
|
+
class EnvSpec
|
|
5
|
+
# Generates an initial env.spec by scanning a repo for env var usages.
|
|
6
|
+
module Init
|
|
7
|
+
HEADER = <<~HEADER
|
|
8
|
+
# env.spec — generated by `envspec init` %s
|
|
9
|
+
#
|
|
10
|
+
# This is a starting point. Review and adjust:
|
|
11
|
+
# 1. Move secrets (passwords, tokens, keys) under [secrets]
|
|
12
|
+
# 2. Add types: str (default) | int | bool | dsn | enum(a,b,c)
|
|
13
|
+
# 3. Mark optional vars with ?
|
|
14
|
+
# 4. Add defaults for configmap vars: KEY: type = value
|
|
15
|
+
# 5. Group per-env vars under [env: production], [env: staging], etc
|
|
16
|
+
#
|
|
17
|
+
# Discovered usages are listed as comments above each key.
|
|
18
|
+
# Confirm or remove before committing.
|
|
19
|
+
#
|
|
20
|
+
# Validate: envspec lint env.spec
|
|
21
|
+
# See docs: https://github.com/repleadfy/envspec
|
|
22
|
+
HEADER
|
|
23
|
+
|
|
24
|
+
def self.run(root: Dir.pwd, force: false, output: "env.spec")
|
|
25
|
+
out_path = File.expand_path(output, root)
|
|
26
|
+
if File.exist?(out_path) && !force
|
|
27
|
+
return { ok: false, reason: :exists, path: out_path }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
t0 = Time.now
|
|
31
|
+
results = Scanner.scan(root)
|
|
32
|
+
elapsed = Time.now - t0
|
|
33
|
+
|
|
34
|
+
content = render(results, root)
|
|
35
|
+
File.write(out_path, content)
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
ok: true,
|
|
39
|
+
path: out_path,
|
|
40
|
+
keys: results.size,
|
|
41
|
+
files: results.values.flatten.map { |u| u[:file] }.uniq.size,
|
|
42
|
+
elapsed: elapsed,
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.render(results, root)
|
|
47
|
+
out = []
|
|
48
|
+
out << format(HEADER, Time.now.strftime("%Y-%m-%d"))
|
|
49
|
+
out << ""
|
|
50
|
+
|
|
51
|
+
configmap_keys = []
|
|
52
|
+
secret_keys = []
|
|
53
|
+
|
|
54
|
+
results.keys.sort.each do |name|
|
|
55
|
+
usages = results[name]
|
|
56
|
+
inference = Heuristics.infer(name, usages)
|
|
57
|
+
block = render_key(name, usages, inference, root)
|
|
58
|
+
if inference[:secret]
|
|
59
|
+
secret_keys << block
|
|
60
|
+
else
|
|
61
|
+
configmap_keys << block
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
out.concat(configmap_keys) unless configmap_keys.empty?
|
|
66
|
+
|
|
67
|
+
unless secret_keys.empty?
|
|
68
|
+
out << ""
|
|
69
|
+
out << "[secrets]"
|
|
70
|
+
out.concat(secret_keys)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
out.join("\n").gsub(/\n{3,}/, "\n\n").strip + "\n"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.render_key(name, usages, inference, root)
|
|
77
|
+
lines = []
|
|
78
|
+
|
|
79
|
+
usages.first(3).each do |u|
|
|
80
|
+
rel = u[:file].sub(/\A#{Regexp.escape(root)}\/?/, "")
|
|
81
|
+
lines << "# Found in: #{rel}:#{u[:line]}"
|
|
82
|
+
end
|
|
83
|
+
lines << "# (and #{usages.size - 3} more usages)" if usages.size > 3
|
|
84
|
+
|
|
85
|
+
hints = []
|
|
86
|
+
hints << "name suggests #{inference[:type]}" if inference[:type] != :str
|
|
87
|
+
hints << "name suggests secret" if inference[:secret] && Heuristics::SECRET_NAME_RE.match?(name) && inference[:type] != :dsn
|
|
88
|
+
hints << "uses fetch with default" if inference[:optional] && inference[:default]
|
|
89
|
+
lines << "# Heuristic: #{hints.join(', ')}" unless hints.empty?
|
|
90
|
+
|
|
91
|
+
decl = name.dup
|
|
92
|
+
decl << "?" if inference[:optional]
|
|
93
|
+
|
|
94
|
+
type = inference[:type]
|
|
95
|
+
decl << ": #{type}" if type != :str || (inference[:default] && type == :str)
|
|
96
|
+
decl << " = #{inference[:default]}" if inference[:default] && !inference[:secret]
|
|
97
|
+
|
|
98
|
+
lines << decl
|
|
99
|
+
lines << ""
|
|
100
|
+
lines.join("\n")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Parses env.spec files -- declarative contract for ConfigMap and Secret keys
|
|
2
|
+
# an app expects.
|
|
3
|
+
#
|
|
4
|
+
# Syntax (v5):
|
|
5
|
+
# KEY # str, required, configmap, shared (all envs)
|
|
6
|
+
# KEY? # optional
|
|
7
|
+
# KEY: int # typed (str|int|bool|dsn|enum(a,b))
|
|
8
|
+
# KEY: str = default # with default (configmap only)
|
|
9
|
+
#
|
|
10
|
+
# [secrets] # MODIFIER: type flips to secret (scope unchanged)
|
|
11
|
+
# [env: production] # SELECTOR: scope -> production, type RESETS to configmap
|
|
12
|
+
# [env: production, staging]# multi-env scope
|
|
13
|
+
#
|
|
14
|
+
# Output:
|
|
15
|
+
# configmap = { "*" => { "KEY" => Entry }, "production" => {...} }
|
|
16
|
+
# secrets = { "*" => { "DB_PASS" => Entry }, "production" => {...} }
|
|
17
|
+
class EnvSpec
|
|
18
|
+
SHARED = "*".freeze
|
|
19
|
+
VALID_TYPES = %i[str int bool dsn enum].freeze
|
|
20
|
+
ENV_NAME_RE = /\A[A-Za-z0-9_-]+\z/.freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :configmap, :secrets
|
|
23
|
+
|
|
24
|
+
def self.parse(text)
|
|
25
|
+
new.tap { |spec| spec.send(:_parse, text) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.parse_file(path)
|
|
29
|
+
parse(File.read(path))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@configmap = {}
|
|
34
|
+
@secrets = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns array of env names declared in the spec (excluding "*").
|
|
38
|
+
def envs
|
|
39
|
+
(@configmap.keys + @secrets.keys).uniq.reject { |e| e == SHARED }.sort
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns merged keys (shared + given env) for configmap.
|
|
43
|
+
def configmap_for(env)
|
|
44
|
+
merge_scope(@configmap, env)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def secrets_for(env)
|
|
48
|
+
merge_scope(@secrets, env)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def merge_scope(bucket, env)
|
|
54
|
+
out = {}
|
|
55
|
+
out.merge!(bucket[SHARED]) if bucket[SHARED]
|
|
56
|
+
out.merge!(bucket[env]) if bucket[env]
|
|
57
|
+
out
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def _parse(text)
|
|
61
|
+
scope = [SHARED]
|
|
62
|
+
type = :configmap
|
|
63
|
+
|
|
64
|
+
text.each_line.with_index(1) do |raw, line_no|
|
|
65
|
+
line = raw.sub(/#.*/, "").strip
|
|
66
|
+
next if line.empty?
|
|
67
|
+
|
|
68
|
+
if line.start_with?("[") && line.end_with?("]")
|
|
69
|
+
inner = line[1..-2].strip
|
|
70
|
+
if inner == "secrets"
|
|
71
|
+
type = :secret
|
|
72
|
+
elsif inner.start_with?("env:")
|
|
73
|
+
envs = inner.sub(/\Aenv:/, "").split(",").map(&:strip).reject(&:empty?)
|
|
74
|
+
raise ParseError.new("empty [env: ...] selector", line_no) if envs.empty?
|
|
75
|
+
envs.each do |e|
|
|
76
|
+
unless ENV_NAME_RE.match?(e)
|
|
77
|
+
raise ParseError.new(
|
|
78
|
+
"invalid env name '#{e}' in [env: ...] (must match #{ENV_NAME_RE.source}; use commas to separate envs)",
|
|
79
|
+
line_no
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
scope = envs
|
|
84
|
+
type = :configmap
|
|
85
|
+
else
|
|
86
|
+
raise ParseError.new("unknown header [#{inner}] (only [secrets] and [env: ...] allowed)", line_no)
|
|
87
|
+
end
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
entry = parse_key_line(line, line_no)
|
|
92
|
+
if type == :secret && !entry[:default].nil?
|
|
93
|
+
raise ParseError.new("secret '#{entry[:name]}' cannot have a default value", line_no)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
store_entry(type, scope, entry, line_no)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_key_line(line, line_no)
|
|
101
|
+
m = line.match(/\A([A-Z][A-Z0-9_]*)(\?)?(?:\s*:\s*([a-z]+(?:\([^)]*\))?))?(?:\s*=\s*(.+))?\z/)
|
|
102
|
+
raise ParseError.new("invalid key line: #{line.inspect}", line_no) unless m
|
|
103
|
+
|
|
104
|
+
name = m[1]
|
|
105
|
+
optional = !m[2].nil?
|
|
106
|
+
type_str = m[3] || "str"
|
|
107
|
+
default = m[4] && m[4].strip
|
|
108
|
+
|
|
109
|
+
type, enum_values = parse_type(type_str, line_no)
|
|
110
|
+
{ name: name, type: type, optional: optional, default: default, enum_values: enum_values }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parse_type(type_str, line_no)
|
|
114
|
+
if type_str.start_with?("enum(")
|
|
115
|
+
unless (em = type_str.match(/\Aenum\((.+)\)\z/))
|
|
116
|
+
raise ParseError.new("malformed enum type: #{type_str}", line_no)
|
|
117
|
+
end
|
|
118
|
+
values = em[1].split(",").map(&:strip).reject(&:empty?)
|
|
119
|
+
raise ParseError.new("enum requires at least one value", line_no) if values.empty?
|
|
120
|
+
[:enum, values]
|
|
121
|
+
else
|
|
122
|
+
sym = type_str.to_sym
|
|
123
|
+
unless VALID_TYPES.include?(sym)
|
|
124
|
+
raise ParseError.new("unknown type '#{type_str}' (valid: #{VALID_TYPES.join(', ')})", line_no)
|
|
125
|
+
end
|
|
126
|
+
[sym, nil]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def store_entry(type, scope, e, line_no)
|
|
131
|
+
bucket = (type == :secret ? @secrets : @configmap)
|
|
132
|
+
|
|
133
|
+
scope.each do |env|
|
|
134
|
+
bucket[env] ||= {}
|
|
135
|
+
|
|
136
|
+
if (existing = find_overlapping(bucket, env, e[:name]))
|
|
137
|
+
raise ParseError.new(
|
|
138
|
+
"key '#{e[:name]}' defined in overlapping scopes ('#{existing[:env]}' line #{existing[:entry].line} and '#{env}' here)",
|
|
139
|
+
line_no
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
bucket[env][e[:name]] = Entry.new(
|
|
144
|
+
type: e[:type],
|
|
145
|
+
optional: e[:optional],
|
|
146
|
+
default: e[:default],
|
|
147
|
+
enum_values: e[:enum_values],
|
|
148
|
+
line: line_no
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
check_cross_env_type_consistency(bucket, e[:name], e[:type], e[:enum_values], line_no)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def find_overlapping(bucket, env, key)
|
|
156
|
+
bucket.each do |existing_env, keys|
|
|
157
|
+
next unless keys.key?(key)
|
|
158
|
+
next if existing_env == env
|
|
159
|
+
if existing_env == SHARED || env == SHARED
|
|
160
|
+
return { env: existing_env, entry: keys[key] }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
if bucket[env] && bucket[env].key?(key)
|
|
164
|
+
return { env: env, entry: bucket[env][key] }
|
|
165
|
+
end
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def check_cross_env_type_consistency(bucket, key, type, enum_values, line_no)
|
|
170
|
+
seen = nil
|
|
171
|
+
bucket.each do |env, keys|
|
|
172
|
+
next unless keys.key?(key)
|
|
173
|
+
e = keys[key]
|
|
174
|
+
if seen.nil?
|
|
175
|
+
seen = [env, e.type, e.enum_values]
|
|
176
|
+
else
|
|
177
|
+
if seen[1] != e.type || seen[2] != e.enum_values
|
|
178
|
+
raise ParseError.new(
|
|
179
|
+
"key '#{key}' has different types across envs ('#{seen[0]}' is #{seen[1]}, '#{env}' is #{e.type})",
|
|
180
|
+
line_no
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
require "find"
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
class EnvSpec
|
|
5
|
+
# Scans a directory tree for env var usages across multiple languages.
|
|
6
|
+
# Pure regex (no AST parser) to keep zero-deps requirement.
|
|
7
|
+
#
|
|
8
|
+
# Returns:
|
|
9
|
+
# { "OPENAI_API_KEY" => [{ file: "...", line: 9, default: nil, optional: true }, ...] }
|
|
10
|
+
module Scanner
|
|
11
|
+
SKIP_DIRS = %w[
|
|
12
|
+
.git node_modules vendor tmp log logs coverage
|
|
13
|
+
.bundle .yarn .pnpm-store dist build target
|
|
14
|
+
__pycache__ .venv venv .pytest_cache .next .nuxt .turbo
|
|
15
|
+
].to_set.freeze
|
|
16
|
+
|
|
17
|
+
SKIP_FILE_RE = /
|
|
18
|
+
\.lock\z |
|
|
19
|
+
\.min\.(js|css)\z |
|
|
20
|
+
\.svg\z |
|
|
21
|
+
\.png\z | \.jpg\z | \.jpeg\z | \.gif\z | \.ico\z | \.webp\z |
|
|
22
|
+
\.pdf\z | \.zip\z | \.tar\z | \.gz\z |
|
|
23
|
+
\.woff2?\z | \.ttf\z | \.eot\z |
|
|
24
|
+
\.map\z
|
|
25
|
+
/x.freeze
|
|
26
|
+
|
|
27
|
+
MAX_FILE_BYTES = 1_048_576 # 1 MB
|
|
28
|
+
|
|
29
|
+
EXT_LANG = {
|
|
30
|
+
".rb" => :ruby,
|
|
31
|
+
".rake" => :ruby,
|
|
32
|
+
".gemspec"=> :ruby,
|
|
33
|
+
".py" => :python,
|
|
34
|
+
".js" => :js,
|
|
35
|
+
".jsx" => :js,
|
|
36
|
+
".ts" => :js,
|
|
37
|
+
".tsx" => :js,
|
|
38
|
+
".mjs" => :js,
|
|
39
|
+
".cjs" => :js,
|
|
40
|
+
".go" => :go,
|
|
41
|
+
".sh" => :shell,
|
|
42
|
+
".bash" => :shell,
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
SPECIAL_FILES = {
|
|
46
|
+
"Dockerfile" => :shell,
|
|
47
|
+
"Rakefile" => :ruby,
|
|
48
|
+
"Gemfile" => :ruby,
|
|
49
|
+
"config.ru" => :ruby,
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
PATTERNS = {
|
|
53
|
+
ruby: [
|
|
54
|
+
/ENV\s*\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/,
|
|
55
|
+
/ENV\.fetch\s*\(\s*["']([A-Z][A-Z0-9_]*)["'](?:\s*,\s*([^)]+))?\s*\)/,
|
|
56
|
+
],
|
|
57
|
+
python: [
|
|
58
|
+
/os\.environ\s*\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/,
|
|
59
|
+
/os\.environ\.get\s*\(\s*["']([A-Z][A-Z0-9_]*)["'](?:\s*,\s*([^)]+))?\s*\)/,
|
|
60
|
+
/os\.getenv\s*\(\s*["']([A-Z][A-Z0-9_]*)["'](?:\s*,\s*([^)]+))?\s*\)/,
|
|
61
|
+
],
|
|
62
|
+
js: [
|
|
63
|
+
/process\.env\.([A-Z][A-Z0-9_]*)/,
|
|
64
|
+
/process\.env\s*\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/,
|
|
65
|
+
/import\.meta\.env\.([A-Z][A-Z0-9_]*)/,
|
|
66
|
+
],
|
|
67
|
+
go: [
|
|
68
|
+
/os\.Getenv\s*\(\s*["']([A-Z][A-Z0-9_]*)["']\s*\)/,
|
|
69
|
+
/os\.LookupEnv\s*\(\s*["']([A-Z][A-Z0-9_]*)["']\s*\)/,
|
|
70
|
+
],
|
|
71
|
+
shell: [
|
|
72
|
+
/\$\{([A-Z][A-Z0-9_]*)(?::[-=?+][^}]*)?\}/,
|
|
73
|
+
/\$([A-Z][A-Z0-9_]{2,})\b/, # require ≥ 3 chars to dodge $PATH-style false positives... actually $PATH is fine, but cuts $A
|
|
74
|
+
],
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
def self.scan(root, ignore_globs: [])
|
|
78
|
+
results = Hash.new { |h, k| h[k] = [] }
|
|
79
|
+
gitignore_globs = parse_gitignore(File.join(root, ".gitignore"))
|
|
80
|
+
all_globs = (ignore_globs + gitignore_globs).uniq
|
|
81
|
+
|
|
82
|
+
Find.find(root) do |path|
|
|
83
|
+
rel = path.sub(/\A#{Regexp.escape(root)}\/?/, "")
|
|
84
|
+
|
|
85
|
+
if File.directory?(path)
|
|
86
|
+
base = File.basename(path)
|
|
87
|
+
if SKIP_DIRS.include?(base) || ignored?(rel, all_globs, dir: true)
|
|
88
|
+
Find.prune
|
|
89
|
+
end
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
next if File.basename(path).start_with?(".") && File.basename(path) != ".envrc"
|
|
94
|
+
next if path =~ SKIP_FILE_RE
|
|
95
|
+
next if File.size(path) > MAX_FILE_BYTES rescue next
|
|
96
|
+
next if ignored?(rel, all_globs, dir: false)
|
|
97
|
+
|
|
98
|
+
lang = lang_for(path)
|
|
99
|
+
next unless lang
|
|
100
|
+
|
|
101
|
+
scan_file(path, lang, results)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
results
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.lang_for(path)
|
|
108
|
+
base = File.basename(path)
|
|
109
|
+
return SPECIAL_FILES[base] if SPECIAL_FILES.key?(base)
|
|
110
|
+
EXT_LANG[File.extname(path)]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.scan_file(path, lang, results)
|
|
114
|
+
content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
|
|
115
|
+
patterns = PATTERNS[lang] || []
|
|
116
|
+
|
|
117
|
+
content.each_line.with_index(1) do |line, line_no|
|
|
118
|
+
patterns.each do |re|
|
|
119
|
+
line.scan(re) do |captures|
|
|
120
|
+
name = captures[0]
|
|
121
|
+
default = captures[1] && captures[1].strip
|
|
122
|
+
results[name] << {
|
|
123
|
+
file: path,
|
|
124
|
+
line: line_no,
|
|
125
|
+
default: extract_default(default),
|
|
126
|
+
optional: !default.nil?,
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
rescue ArgumentError, Errno::ENOENT
|
|
132
|
+
# Skip unreadable / binary files
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.extract_default(raw)
|
|
136
|
+
return nil if raw.nil? || raw.empty?
|
|
137
|
+
# strip surrounding quotes if literal string
|
|
138
|
+
m = raw.match(/\A["']([^"']*)["']\z/)
|
|
139
|
+
m ? m[1] : nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Minimal .gitignore parser — supports literal patterns and globs (no
|
|
143
|
+
# full git semantics, just common cases).
|
|
144
|
+
def self.parse_gitignore(path)
|
|
145
|
+
return [] unless File.exist?(path)
|
|
146
|
+
File.readlines(path).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
|
|
147
|
+
rescue
|
|
148
|
+
[]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.ignored?(rel, globs, dir:)
|
|
152
|
+
globs.any? do |pat|
|
|
153
|
+
clean = pat.sub(/\A\//, "").sub(/\/\z/, "")
|
|
154
|
+
next false if clean.empty?
|
|
155
|
+
File.fnmatch?(clean, rel, File::FNM_PATHNAME) ||
|
|
156
|
+
File.fnmatch?(clean, File.basename(rel)) ||
|
|
157
|
+
rel.start_with?("#{clean}/")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
class EnvSpec
|
|
2
|
+
# Validates an env hash (or ENV) against a parsed spec.
|
|
3
|
+
#
|
|
4
|
+
# Checks:
|
|
5
|
+
# - required keys are present
|
|
6
|
+
# - typed values parse cleanly (int, bool, dsn, enum)
|
|
7
|
+
# - reports unknown keys as warnings (extras), not errors
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# spec.validate!(ENV.to_h, env: "production")
|
|
11
|
+
#
|
|
12
|
+
# Raises EnvSpec::ValidationError with a list of all problems found.
|
|
13
|
+
def validate!(env_hash, env: SHARED, strict: false)
|
|
14
|
+
problems = validate(env_hash, env: env, strict: strict)
|
|
15
|
+
raise ValidationError.new(problems) unless problems.empty?
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate(env_hash, env: SHARED, strict: false)
|
|
20
|
+
problems = []
|
|
21
|
+
|
|
22
|
+
keys = configmap_for(env).merge(secrets_for(env))
|
|
23
|
+
|
|
24
|
+
keys.each do |name, entry|
|
|
25
|
+
raw = env_hash[name]
|
|
26
|
+
if raw.nil? || raw.empty?
|
|
27
|
+
next if entry.optional || !entry.default.nil?
|
|
28
|
+
problems << "missing required env var '#{name}' (declared at line #{entry.line})"
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
err = type_problem(name, raw, entry)
|
|
33
|
+
problems << err if err
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if strict
|
|
37
|
+
known = keys.keys
|
|
38
|
+
env_hash.each_key do |k|
|
|
39
|
+
next unless k =~ /\A[A-Z][A-Z0-9_]*\z/
|
|
40
|
+
problems << "unknown env var '#{k}' (not declared in spec)" unless known.include?(k)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
problems
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def type_problem(name, raw, entry)
|
|
50
|
+
case entry.type
|
|
51
|
+
when :str, :dsn
|
|
52
|
+
nil
|
|
53
|
+
when :int
|
|
54
|
+
Integer(raw, 10)
|
|
55
|
+
nil
|
|
56
|
+
when :bool
|
|
57
|
+
unless %w[true false 1 0 yes no].include?(raw.downcase)
|
|
58
|
+
return "env var '#{name}' = #{raw.inspect} is not a valid bool (expected true/false/1/0/yes/no)"
|
|
59
|
+
end
|
|
60
|
+
nil
|
|
61
|
+
when :enum
|
|
62
|
+
unless entry.enum_values.include?(raw)
|
|
63
|
+
return "env var '#{name}' = #{raw.inspect} is not in enum(#{entry.enum_values.join(', ')})"
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
"env var '#{name}' = #{raw.inspect} is not a valid #{entry.type}"
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/envspec.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: envspec
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Leadfy
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: bundler
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
description: 'Parses env.spec files: a declarative contract describing which env vars
|
|
56
|
+
an app expects, their types, optionality, and ConfigMap-vs-Secret classification.
|
|
57
|
+
Pure stdlib, zero runtime dependencies.'
|
|
58
|
+
email:
|
|
59
|
+
- dev@leadfy.com
|
|
60
|
+
executables:
|
|
61
|
+
- envspec
|
|
62
|
+
extensions: []
|
|
63
|
+
extra_rdoc_files: []
|
|
64
|
+
files:
|
|
65
|
+
- CHANGELOG.md
|
|
66
|
+
- LICENSE.txt
|
|
67
|
+
- README.md
|
|
68
|
+
- exe/envspec
|
|
69
|
+
- lib/envspec.rb
|
|
70
|
+
- lib/envspec/entry.rb
|
|
71
|
+
- lib/envspec/errors.rb
|
|
72
|
+
- lib/envspec/heuristics.rb
|
|
73
|
+
- lib/envspec/init.rb
|
|
74
|
+
- lib/envspec/parser.rb
|
|
75
|
+
- lib/envspec/scanner.rb
|
|
76
|
+
- lib/envspec/validator.rb
|
|
77
|
+
- lib/envspec/version.rb
|
|
78
|
+
homepage: https://github.com/repleadfy/envspec
|
|
79
|
+
licenses:
|
|
80
|
+
- MIT
|
|
81
|
+
metadata: {}
|
|
82
|
+
post_install_message:
|
|
83
|
+
rdoc_options: []
|
|
84
|
+
require_paths:
|
|
85
|
+
- lib
|
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: 2.5.0
|
|
91
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
requirements: []
|
|
97
|
+
rubygems_version: 3.2.32
|
|
98
|
+
signing_key:
|
|
99
|
+
specification_version: 4
|
|
100
|
+
summary: Declarative env var contract for apps (parser + validator + scaffolder)
|
|
101
|
+
test_files: []
|