├── .tool-versions ├── fixtures ├── check │ └── cases │ │ ├── fs_fail │ │ ├── fsd_fail │ │ ├── fsd_pass │ │ ├── fsf_fail │ │ ├── fsf_pass │ │ ├── vgt_fail │ │ ├── vgte_fail │ │ ├── vlt_fail │ │ ├── vlte_fail │ │ ├── lc_fail │ │ ├── uc_fail │ │ ├── alnum_fail │ │ ├── fs_pass │ │ ├── nre_fail │ │ ├── n_pass │ │ ├── b64_fail │ │ ├── i_fail │ │ ├── sf_fail │ │ ├── f_fail │ │ ├── f_pass │ │ ├── hex_fail │ │ ├── gt_fail │ │ ├── gt_pass │ │ ├── gte_fail │ │ ├── lc_pass │ │ ├── lt_fail │ │ ├── lt_pass │ │ ├── uc_pass │ │ ├── ipv4_pass │ │ ├── ipv6_fail │ │ ├── json_pass │ │ ├── si_pass │ │ ├── v_pass │ │ ├── nre_pass │ │ ├── ip_fail │ │ ├── lte_fail │ │ ├── alnum_pass │ │ ├── i_pass │ │ ├── lte_pass │ │ ├── json_fail │ │ ├── len_pass │ │ ├── sn_fail │ │ ├── sn_pass │ │ ├── sf_pass │ │ ├── si_fail │ │ ├── gte_pass │ │ ├── ipv6_pass │ │ ├── v_fail │ │ ├── re_fail │ │ ├── hexcol_fail │ │ ├── len_fail │ │ ├── b64_pass │ │ ├── n_fail │ │ ├── hexcol_pass │ │ ├── ipv4_fail │ │ ├── hex_pass │ │ ├── re_pass │ │ ├── ip_pass │ │ ├── uuid_pass │ │ ├── uuid_fail │ │ ├── vgt_pass │ │ ├── vgte_pass │ │ ├── vlt_pass │ │ └── vlte_pass └── input │ ├── test.invalid │ ├── test.toml │ ├── test.yaml │ ├── test_normalized.yaml │ └── test.json ├── .gitignore ├── assets └── mugshot.png ├── .ameba.yml ├── src ├── envcat │ ├── version.cr │ ├── format │ │ ├── export.cr │ │ ├── j2_unsafe.cr │ │ ├── none.cr │ │ ├── yaml.cr │ │ ├── json.cr │ │ ├── kv.cr │ │ ├── etf.cr │ │ └── j2.cr │ ├── format.cr │ ├── env.cr │ ├── check.cr │ └── cli.cr └── envcat.cr ├── .editorconfig ├── spec ├── envcat │ ├── cli │ │ ├── version_spec.cr │ │ ├── format_spec.cr │ │ ├── format │ │ │ ├── kv_spec.cr │ │ │ ├── json_spec.cr │ │ │ ├── none_spec.cr │ │ │ ├── yaml_spec.cr │ │ │ ├── export_spec.cr │ │ │ ├── j2_unsafe_spec.cr │ │ │ └── j2_spec.cr │ │ ├── set_spec.cr │ │ ├── help_spec.cr │ │ ├── input_spec.cr │ │ └── check_spec.cr │ └── check_spec.cr └── spec_helper.cr ├── shard.lock ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── crystal_action.rb │ ├── release_notes.sh │ └── ci.yml ├── shard.yml ├── LICENSE ├── Makefile ├── docs └── templates │ └── README.md.j2 └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | crystal 1.11.2 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/fs_fail: -------------------------------------------------------------------------------- 1 | nope 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/fsd_fail: -------------------------------------------------------------------------------- 1 | shard.yml 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/fsd_pass: -------------------------------------------------------------------------------- 1 | fixtures 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/fsf_fail: -------------------------------------------------------------------------------- 1 | fixtures 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/fsf_pass: -------------------------------------------------------------------------------- 1 | shard.yml 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/vgt_fail: -------------------------------------------------------------------------------- 1 | 0.0.0 0.0.1 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/vgte_fail: -------------------------------------------------------------------------------- 1 | 0.0.0 0.0.1 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/vlt_fail: -------------------------------------------------------------------------------- 1 | 0.0.1 0.0.0 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/vlte_fail: -------------------------------------------------------------------------------- 1 | 0.0.1 0.0.0 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/lc_fail: -------------------------------------------------------------------------------- 1 | A 2 | Abc 3 | abC 4 | -------------------------------------------------------------------------------- /fixtures/check/cases/uc_fail: -------------------------------------------------------------------------------- 1 | a 2 | aBC 3 | abC 4 | -------------------------------------------------------------------------------- /fixtures/input/test.invalid: -------------------------------------------------------------------------------- 1 | parse me, i dare you! 2 | -------------------------------------------------------------------------------- /fixtures/check/cases/alnum_fail: -------------------------------------------------------------------------------- 1 | . 2 | a/Bc 3 | "abc" 4 | -------------------------------------------------------------------------------- /fixtures/check/cases/fs_pass: -------------------------------------------------------------------------------- 1 | fixtures 2 | shard.yml 3 | -------------------------------------------------------------------------------- /fixtures/check/cases/nre_fail: -------------------------------------------------------------------------------- 1 | hello ^h 2 | 1x3 ^\dx\d$ 3 | -------------------------------------------------------------------------------- /fixtures/check/cases/n_pass: -------------------------------------------------------------------------------- 1 | 0 2 | 1 3 | 0.1 4 | .1 5 | .0 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/b64_fail: -------------------------------------------------------------------------------- 1 | xxx 2 | abc123 3 | Y29ycnVwdGVCg= 4 | -------------------------------------------------------------------------------- /fixtures/check/cases/i_fail: -------------------------------------------------------------------------------- 1 | .0 2 | 0.0 3 | 1.0 4 | -0 5 | -1 6 | derp 7 | -------------------------------------------------------------------------------- /fixtures/check/cases/sf_fail: -------------------------------------------------------------------------------- 1 | -.1x 2 | -.x 3 | -1.x 4 | 0.x 5 | derp 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/f_fail: -------------------------------------------------------------------------------- 1 | -.01 2 | -.0 3 | -0.1 4 | -0 5 | -1 6 | derp 7 | -------------------------------------------------------------------------------- /fixtures/check/cases/f_pass: -------------------------------------------------------------------------------- 1 | .0 2 | .1 3 | 0.0 4 | 0.1 5 | 1.0 6 | 0 7 | 1 8 | -------------------------------------------------------------------------------- /fixtures/check/cases/hex_fail: -------------------------------------------------------------------------------- 1 | x 2 | 0xKeK 3 | 0.1 4 | -a 5 | -A 6 | -0xAbC 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /bin/ 3 | /build/ 4 | /.shards/ 5 | *.dwarf 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /fixtures/check/cases/gt_fail: -------------------------------------------------------------------------------- 1 | 0 0.01 2 | 0 1 3 | 0.1 0.2 4 | -0.1 0 5 | -1 0 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/gt_pass: -------------------------------------------------------------------------------- 1 | 0.01 0 2 | 1 0 3 | 0.2 0.1 4 | 0 -0.1 5 | 0 -1 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/gte_fail: -------------------------------------------------------------------------------- 1 | -0.01 0 2 | -1 0 3 | 0.1 0.2 4 | 0 0.1 5 | 0 1 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/lc_pass: -------------------------------------------------------------------------------- 1 | a 2 | abc 3 | 123 4 | !@#$%^&*()_+=-/.,\';[]`~ 5 | -------------------------------------------------------------------------------- /fixtures/check/cases/lt_fail: -------------------------------------------------------------------------------- 1 | 0.01 0 2 | 1 0 3 | 0.2 0.1 4 | 0 -0.1 5 | 0 -1 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/lt_pass: -------------------------------------------------------------------------------- 1 | 0 0.01 2 | 0 1 3 | 0.1 0.2 4 | -0.1 0 5 | -1 0 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/uc_pass: -------------------------------------------------------------------------------- 1 | A 2 | ABC 3 | 123 4 | !@#$%^&*()_+=-/.,\';[]`~ 5 | -------------------------------------------------------------------------------- /assets/mugshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/busyloop/envcat/HEAD/assets/mugshot.png -------------------------------------------------------------------------------- /fixtures/check/cases/ipv4_pass: -------------------------------------------------------------------------------- 1 | 0.0.0.0 2 | 127.0.0.1 3 | 10.0.0.1 4 | 142.251.143.78 5 | -------------------------------------------------------------------------------- /fixtures/check/cases/ipv6_fail: -------------------------------------------------------------------------------- 1 | 0.0.0.0 2 | 127.0.0.1 3 | 10.0.0.1 4 | 142.251.143.78 5 | -------------------------------------------------------------------------------- /fixtures/check/cases/json_pass: -------------------------------------------------------------------------------- 1 | {} 2 | {"a":42} 3 | {"a":"b"} 4 | {"a":1,"b":"c"} 5 | -------------------------------------------------------------------------------- /fixtures/check/cases/si_pass: -------------------------------------------------------------------------------- 1 | -1 2 | 0 3 | 1 4 | 4294967296 5 | 18446744073709551615 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/v_pass: -------------------------------------------------------------------------------- 1 | 0.0.0 2 | 0.0.1 3 | 1.0.0 4 | 4.5.6-release 5 | 2023.1.1 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/nre_pass: -------------------------------------------------------------------------------- 1 | hello .all. 2 | hello ^.ell$ 3 | hello ^hall 4 | 123 ^\d\d$ 5 | -------------------------------------------------------------------------------- /fixtures/check/cases/ip_fail: -------------------------------------------------------------------------------- 1 | 1 2 | 1.2 3 | 1.2.3 4 | 1.2.3.4/8 5 | 1.2.3.4/16 6 | 1.2.3.4/32 7 | -------------------------------------------------------------------------------- /fixtures/check/cases/lte_fail: -------------------------------------------------------------------------------- 1 | 0 -0.01 2 | 0 -1 3 | 0.2 0.1 4 | 0.1 0 5 | 1 0 6 | a b 7 | b a 8 | -------------------------------------------------------------------------------- /fixtures/check/cases/alnum_pass: -------------------------------------------------------------------------------- 1 | a 2 | abc 3 | abc123 4 | 123abc 5 | A 6 | ABC 7 | ABc123 8 | 123aBc 9 | -------------------------------------------------------------------------------- /fixtures/check/cases/i_pass: -------------------------------------------------------------------------------- 1 | 0 2 | 1 3 | 9999999999999999999999999999999999999999999999999999999999 4 | -------------------------------------------------------------------------------- /fixtures/check/cases/lte_pass: -------------------------------------------------------------------------------- 1 | 0 0.01 2 | 0 1 3 | 0.1 0.2 4 | -0.1 0 5 | -1 0 6 | 0 0 7 | 0.1 0.1 8 | 1 1 9 | -------------------------------------------------------------------------------- /fixtures/check/cases/json_fail: -------------------------------------------------------------------------------- 1 | { 2 | {{ 3 | } 4 | }} 5 | {{} 6 | {}} 7 | {a:1} 8 | {a:"1"} 9 | {"a":1 10 | -------------------------------------------------------------------------------- /fixtures/check/cases/len_pass: -------------------------------------------------------------------------------- 1 | x 0 1 2 | x 1 1 3 | x -1 1 4 | xx -2 2 5 | x 1 2 6 | xx 2 2 7 | xx 2 4 8 | xxx 2 4 9 | -------------------------------------------------------------------------------- /fixtures/check/cases/sn_fail: -------------------------------------------------------------------------------- 1 | x 2 | -x 3 | a 4 | -a 5 | ff 6 | 0.23a 7 | 0.2a3 8 | 0.2a 9 | 0,123 10 | 0xff 11 | -------------------------------------------------------------------------------- /fixtures/check/cases/sn_pass: -------------------------------------------------------------------------------- 1 | -.01 2 | -.0 3 | .0 4 | -1 5 | -0 6 | 0 7 | 1 8 | -0.1 9 | -0.0 10 | 0.0 11 | 0.1 12 | -------------------------------------------------------------------------------- /.ameba.yml: -------------------------------------------------------------------------------- 1 | Naming/BlockParameterName: 2 | Description: Disallows non-descriptive block parameter names 3 | Enabled: false 4 | -------------------------------------------------------------------------------- /fixtures/check/cases/sf_pass: -------------------------------------------------------------------------------- 1 | -1 2 | -0.1 3 | 0 4 | 0.0 5 | 0.00 6 | 0.01 7 | 0.1 8 | 1 9 | 1.0 10 | .0 11 | .1 12 | -.1 13 | -------------------------------------------------------------------------------- /fixtures/check/cases/si_fail: -------------------------------------------------------------------------------- 1 | 0.0 2 | -0.1 3 | 1.0 4 | -.1x 5 | -.x 6 | -1.x 7 | 0.x 8 | derp 9 | .0 10 | .1 11 | -.1 12 | -------------------------------------------------------------------------------- /src/envcat/version.cr: -------------------------------------------------------------------------------- 1 | module Envcat 2 | VERSION = {{ `grep "^version" shard.yml | cut -d ' ' -f 2`.chomp.stringify }} 3 | end 4 | -------------------------------------------------------------------------------- /fixtures/check/cases/gte_pass: -------------------------------------------------------------------------------- 1 | 0.01 0 2 | 1 0 3 | 0.2 0.1 4 | 0 -0.1 5 | 0 -1 6 | -1 -1 7 | -0.1 -0.1 8 | 0 0 9 | 0.1 0.1 10 | 1 1 11 | -------------------------------------------------------------------------------- /fixtures/check/cases/ipv6_pass: -------------------------------------------------------------------------------- 1 | 2001:4860:4860::8888 2 | 2001:db8:3333:4444:5555:6666:7777:8888 3 | 2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF 4 | FE80::1 5 | -------------------------------------------------------------------------------- /fixtures/check/cases/v_fail: -------------------------------------------------------------------------------- 1 | v1.0.0 2 | v1.0 3 | v1 4 | 0 5 | 0.0 6 | 1 7 | 1.0 8 | 1.0. 9 | 1,2,3 10 | 2022 11 | 1.2.3pre 12 | 2023.01.01 13 | -------------------------------------------------------------------------------- /fixtures/check/cases/re_fail: -------------------------------------------------------------------------------- 1 | hello ^o 2 | 1x3 ^\d+$ 3 | a:c a[:]b 4 | bonk ^(foo|bar|batz)$ 5 | foo ^((?!foo|bar|batz).)*$ 6 | bar ^((?!foo|bar|batz).)*$ 7 | -------------------------------------------------------------------------------- /fixtures/check/cases/hexcol_fail: -------------------------------------------------------------------------------- 1 | ff 2 | #ff 3 | fffff 4 | #fffff 5 | x 6 | 0xfff 7 | 0.1 8 | 0 9 | 1 10 | 11 11 | 255,255,255 12 | '#fff' 13 | "#fff" 14 | -------------------------------------------------------------------------------- /fixtures/check/cases/len_fail: -------------------------------------------------------------------------------- 1 | xx 0 1 2 | x -1 0 3 | x 0 0 4 | xx -1 1 5 | xxx -1 2 6 | xxx 0 2 7 | xxx 1 2 8 | x 1 0 9 | x 2 0 10 | x 2 1 11 | xx 10 2 12 | -------------------------------------------------------------------------------- /fixtures/check/cases/b64_pass: -------------------------------------------------------------------------------- 1 | aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1KNHQ0cE1aQlhaZyZ0PTM3MzdzCg== 2 | aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1KNHQ0cE1aQlhaZyZ0PTQwNTNzCg== 3 | -------------------------------------------------------------------------------- /fixtures/check/cases/n_fail: -------------------------------------------------------------------------------- 1 | x 2 | -x 3 | a 4 | -a 5 | ff 6 | 0.23a 7 | 0.2a3 8 | 0.2a 9 | 0,123 10 | 0xff 11 | -.01 12 | -0.1 13 | -.0 14 | -1 15 | -0 16 | -0.0 17 | - 18 | -------------------------------------------------------------------------------- /fixtures/check/cases/hexcol_pass: -------------------------------------------------------------------------------- 1 | fff 2 | FFF 3 | ffffff 4 | FfFfFf 5 | 000 6 | 255 7 | #000 8 | #fff 9 | #ffffff 10 | ffff 11 | #ffff 12 | abc 13 | #abc 14 | AbC 15 | #AbC 16 | -------------------------------------------------------------------------------- /fixtures/check/cases/ipv4_fail: -------------------------------------------------------------------------------- 1 | 1.2.3.4/8 2 | 1.2.3.4/16 3 | 1.2.3.4/32 4 | 2001:4860:4860::8888 5 | 2001:db8:3333:4444:5555:6666:7777:8888 6 | 2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF 7 | FE80::1 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /fixtures/check/cases/hex_pass: -------------------------------------------------------------------------------- 1 | 0 2 | 1 3 | a 4 | b 5 | c 6 | d 7 | e 8 | f 9 | A 10 | B 11 | C 12 | D 13 | E 14 | F 15 | 0a 16 | 0A 17 | a0 18 | A0 19 | 0xd1ce 20 | 0xAbC 21 | badf00d 22 | d3adfAce 23 | -------------------------------------------------------------------------------- /fixtures/check/cases/re_pass: -------------------------------------------------------------------------------- 1 | hello ^he 2 | hello ^.ell.$ 3 | hello lo$ 4 | 123 ^\d+$ 5 | a:b a[:]b 6 | abc a[:b]c 7 | foo ^(foo|bar|batz)$ 8 | bar ^(foo|bar|batz)$ 9 | bonk ^((?!foo|bar|batz).)*$ 10 | -------------------------------------------------------------------------------- /fixtures/check/cases/ip_pass: -------------------------------------------------------------------------------- 1 | 0.0.0.0 2 | 127.0.0.1 3 | 10.0.0.1 4 | 142.251.143.78 5 | 2001:4860:4860::8888 6 | 2001:db8:3333:4444:5555:6666:7777:8888 7 | 2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF 8 | FE80::1 9 | ::1 10 | -------------------------------------------------------------------------------- /fixtures/check/cases/uuid_pass: -------------------------------------------------------------------------------- 1 | 00000000-0000-1000-8000-000000000000 2 | 00000000-0000-0000-0000-000000000000 3 | abc00000-0000-1000-8000-000000000000 4 | AbC00000-0000-1000-8000-000000000000 5 | ABC00000-0000-1000-8000-000000000000 6 | -------------------------------------------------------------------------------- /fixtures/check/cases/uuid_fail: -------------------------------------------------------------------------------- 1 | 0 2 | 00000000 3 | 00000000- 4 | 00000000-0000 5 | 00000000-0000- 6 | 00000000-0000-1000 7 | 00000000-0000-1000-8000 8 | 00000000-0000-1000-8000-00000000000 9 | 00000000-0000-1000-8000-0000000000000 10 | 11 | -------------------------------------------------------------------------------- /src/envcat/format/export.cr: -------------------------------------------------------------------------------- 1 | require "./kv" 2 | 3 | require "json" 4 | 5 | module Envcat 6 | class Format::Export < Format::Kv 7 | @@prefix = "export " 8 | 9 | def self.description : String 10 | "Shell export format" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /src/envcat/format/j2_unsafe.cr: -------------------------------------------------------------------------------- 1 | require "./j2" 2 | 3 | module Envcat 4 | class Format::J2Unsafe < Format::J2 5 | @@strict = false 6 | 7 | def self.description : String 8 | "Render j2 template from stdin (renders undefined vars as empty string)" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /fixtures/check/cases/vgt_pass: -------------------------------------------------------------------------------- 1 | 0.0.1 0.0.0 2 | 1.0.2 1.0.1 3 | 1.2.0 1.0.1 4 | 2.6.10 2.6.1 5 | 1.0.0-rc.1 1.0.0-beta.11 6 | 1.0.0-beta.11 1.0.0-beta 7 | 1.0.0-beta 1.0.0-alpha.beta 8 | 1.0.0-alpha.beta 1.0.0-alpha.1 9 | 1.0.0-alpha.1 1.0.0-alpha 10 | 1.0.0-rc.1 1.0.0-alpha 11 | 1.0.0 1.0.0-rc.1 12 | -------------------------------------------------------------------------------- /fixtures/check/cases/vgte_pass: -------------------------------------------------------------------------------- 1 | 0.0.1 0.0.1 2 | 1.0.2 1.0.1 3 | 1.2.0 1.0.1 4 | 2.6.10 2.6.1 5 | 1.0.0-rc.1 1.0.0-beta.11 6 | 1.0.0-beta.11 1.0.0-beta 7 | 1.0.0-beta 1.0.0-alpha.beta 8 | 1.0.0-alpha.beta 1.0.0-alpha.1 9 | 1.0.0-alpha.1 1.0.0-alpha 10 | 1.0.0-rc.1 1.0.0-alpha 11 | 1.0.0 1.0.0-rc.1 12 | -------------------------------------------------------------------------------- /fixtures/check/cases/vlt_pass: -------------------------------------------------------------------------------- 1 | 0.0.0 0.0.1 2 | 1.0.1 1.0.2 3 | 1.0.1 1.2.0 4 | 2.6.1 2.6.10 5 | 1.0.0-beta.11 1.0.0-rc.1 6 | 1.0.0-beta 1.0.0-beta.11 7 | 1.0.0-alpha.beta 1.0.0-beta 8 | 1.0.0-alpha.1 1.0.0-alpha.beta 9 | 1.0.0-alpha 1.0.0-alpha.1 10 | 1.0.0-alpha 1.0.0-rc.1 11 | 1.0.0-rc.1 1.0.0 12 | -------------------------------------------------------------------------------- /fixtures/check/cases/vlte_pass: -------------------------------------------------------------------------------- 1 | 0.0.1 0.0.1 2 | 1.0.1 1.0.2 3 | 1.0.1 1.2.0 4 | 2.6.1 2.6.10 5 | 1.0.0-beta.11 1.0.0-rc.1 6 | 1.0.0-beta 1.0.0-beta.11 7 | 1.0.0-alpha.beta 1.0.0-beta 8 | 1.0.0-alpha.1 1.0.0-alpha.beta 9 | 1.0.0-alpha 1.0.0-alpha.1 10 | 1.0.0-alpha 1.0.0-rc.1 11 | 1.0.0-rc.1 1.0.0 12 | -------------------------------------------------------------------------------- /src/envcat.cr: -------------------------------------------------------------------------------- 1 | require "./envcat/cli" 2 | 3 | module Envcat 4 | class StdinAlreadyClaimedError < Exception; end 5 | 6 | @@stdin_claimed = false 7 | 8 | def self.claim_stdin! 9 | raise StdinAlreadyClaimedError.new if @@stdin_claimed 10 | @@stdin_claimed = true 11 | end 12 | end 13 | 14 | {% unless @top_level.constant("BUILD_ENV") == :spec %} 15 | Envcat::Cli.invoke 16 | {% end %} 17 | -------------------------------------------------------------------------------- /spec/envcat/cli/version_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | require "../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "(no arguments)" do 6 | it "prints help and exits with code 3" do 7 | expect_output(nil, /Usage:.*SPEC/) { |o, e, i| 8 | expect_raises(Exit, "3") { 9 | Envcat::Cli.invoke(%w[], o, e, i) 10 | } 11 | } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/envcat/format/none.cr: -------------------------------------------------------------------------------- 1 | require "../format" 2 | 3 | require "yaml" 4 | 5 | module Envcat 6 | class Format::None < Format::Formatter 7 | def self.description : String 8 | "No format" 9 | end 10 | 11 | def self.from_string(value : String) 12 | raise InvalidModeError.new "can not be used as input format" 13 | end 14 | 15 | def write(env) 16 | # 🦗 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/envcat/format/yaml.cr: -------------------------------------------------------------------------------- 1 | require "../format" 2 | 3 | require "yaml" 4 | 5 | module Envcat 6 | class Format::YAML < Format::Formatter 7 | def self.description : String 8 | "YAML format" 9 | end 10 | 11 | def self.from_string(value : String) 12 | raise InvalidModeError.new "can not be used as input format" 13 | end 14 | 15 | def write(env) 16 | return if env.empty? 17 | env.to_yaml(@io) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /src/envcat/format/json.cr: -------------------------------------------------------------------------------- 1 | require "../format" 2 | 3 | require "json" 4 | 5 | module Envcat 6 | class Format::JSON < Format::Formatter 7 | def self.description : String 8 | "JSON format" 9 | end 10 | 11 | def self.from_string(value : String) 12 | raise InvalidModeError.new "can not be used as input format" 13 | end 14 | 15 | def write(env : Env) 16 | return if env.empty? 17 | env.to_json(@io) 18 | @io.puts 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/envcat/cli/format_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | require "../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "when I/O error occurs" do 6 | it "reports I/O error with exit code 7" do 7 | expect_output(/^$/, /^Error: Closed stream\n$/) { |o, e, i| 8 | o.close 9 | expect_raises(Exit, "7") { 10 | ENV["FOO"] = "1" 11 | Envcat::Cli.invoke(%w[-f export FOO], o, e, i) 12 | } 13 | } 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | ameba: 4 | git: https://github.com/crystal-ameba/ameba.git 5 | version: 1.6.1 6 | 7 | crinja: 8 | git: https://github.com/straight-shoota/crinja.git 9 | version: 0.8.0+git.commit.ca17c3d698b2d1d7ccc702079e93e31788caabb2 10 | 11 | toka: 12 | git: https://github.com/papierkorb/toka.git 13 | version: 0.1.2+git.commit.3c160b77369e3491954b782601247f668ccff071 14 | 15 | toml: 16 | git: https://github.com/crystal-community/toml.cr.git 17 | version: 0.7.0+git.commit.db53c77b6973369c8d5575d20ee91dec971a6fee 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps / commands to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Envcat version** 20 | Put the output of `envcat --version` here. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /src/envcat/format/kv.cr: -------------------------------------------------------------------------------- 1 | require "../format" 2 | require "json" 3 | 4 | module Envcat 5 | class Format::Kv < Format::Formatter 6 | @@prefix : String? 7 | 8 | def self.description : String 9 | "Shell format" 10 | end 11 | 12 | def self.from_string(value : String) 13 | raise InvalidModeError.new "can not be used as input format" 14 | end 15 | 16 | def write(env) 17 | env.each do |k, v| 18 | @io.print @@prefix if @@prefix 19 | @io.print k 20 | @io.print '=' 21 | @io.puts Process.quote_posix(v) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | ::BUILD_ENV = :spec 2 | 3 | require "../src/envcat" 4 | 5 | ENV.clear 6 | 7 | class Exit < Exception; end 8 | 9 | macro exit(code) 10 | {% if @type.name.starts_with? "Envcat" %} 11 | raise Exit.new(({{code}}).to_s) 12 | {% end %} 13 | Process.exit {{code}} 14 | end 15 | 16 | require "spec" 17 | 18 | def expect_output(stdout_re : Regex? = nil, stderr_re : Regex? = nil, stdin_data = "", &block : (IO, IO, IO) ->) 19 | i = IO::Memory.new(stdin_data) 20 | o = IO::Memory.new 21 | e = IO::Memory.new 22 | block.call(o, e, i) 23 | 24 | o.to_s.should match(stdout_re) if stdout_re 25 | e.to_s.should match(stderr_re) if stderr_re 26 | end 27 | -------------------------------------------------------------------------------- /spec/envcat/cli/format/kv_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../../spec_helper" 2 | require "../../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "-f kv FOO BAR" do 6 | it "outputs nothing if selected vars are empty" do 7 | expect_output(/^$/, /^$/) { |o, e, i| 8 | ENV.delete("FOO") 9 | ENV.delete("BAR") 10 | Envcat::Cli.invoke(%w[-f kv FOO BAR], o, e, i) 11 | } 12 | end 13 | 14 | it "writes kv to stdout if a selected var has a value" do 15 | expect_output(/BAR=1\n/, /^$/) { |o, e, i| 16 | ENV.delete("FOO") 17 | ENV["BAR"] = "1" 18 | Envcat::Cli.invoke(%w[-f kv FOO BAR], o, e, i) 19 | } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /spec/envcat/cli/format/json_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../../spec_helper" 2 | require "../../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "-f json FOO BAR" do 6 | it "outputs nothing if selected vars are empty" do 7 | expect_output(/^$/, /^$/) { |o, e, i| 8 | ENV.delete("FOO") 9 | ENV.delete("BAR") 10 | Envcat::Cli.invoke(%w[-f json FOO BAR], o, e, i) 11 | } 12 | end 13 | 14 | it "writes json to stdout if a selected var has a value" do 15 | expect_output(/{"BAR":"1"}/, /^$/) { |o, e, i| 16 | ENV.delete("FOO") 17 | ENV["BAR"] = "1" 18 | Envcat::Cli.invoke(%w[-f json FOO BAR], o, e, i) 19 | } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/envcat/cli/format/none_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../../spec_helper" 2 | require "../../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "-f none FOO BAR" do 6 | it "outputs nothing if selected vars are empty" do 7 | expect_output(/^$/, /^$/) { |o, e, i| 8 | ENV.delete("FOO") 9 | ENV.delete("BAR") 10 | Envcat::Cli.invoke(%w[-f none FOO BAR], o, e, i) 11 | } 12 | end 13 | 14 | it "outputs nothing stdout even if a selected var has a value" do 15 | expect_output(/^$/, /^$/) { |o, e, i| 16 | ENV.delete("FOO") 17 | ENV["BAR"] = "1" 18 | Envcat::Cli.invoke(%w[-f none FOO BAR], o, e, i) 19 | } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/envcat/cli/format/yaml_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../../spec_helper" 2 | require "../../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "-f yaml FOO BAR" do 6 | it "outputs nothing if selected vars are empty" do 7 | expect_output(/^$/, /^$/) { |o, e, i| 8 | ENV.delete("FOO") 9 | ENV.delete("BAR") 10 | Envcat::Cli.invoke(%w[-f yaml FOO BAR], o, e, i) 11 | } 12 | end 13 | 14 | it "writes yaml to stdout if a selected var has a value" do 15 | expect_output(/---\nBAR: \"1\"\n/, /^$/) { |o, e, i| 16 | ENV.delete("FOO") 17 | ENV["BAR"] = "1" 18 | Envcat::Cli.invoke(%w[-f yaml FOO BAR], o, e, i) 19 | } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/envcat/cli/format/export_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../../spec_helper" 2 | require "../../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "-f export FOO BAR" do 6 | it "outputs nothing if selected vars are empty" do 7 | expect_output(/^$/, /^$/) { |o, e, i| 8 | ENV.delete("FOO") 9 | ENV.delete("BAR") 10 | Envcat::Cli.invoke(%w[-f export FOO BAR], o, e, i) 11 | } 12 | end 13 | 14 | it "writes export to stdout if a selected var has a value" do 15 | expect_output(/export BAR=1\n/, /^$/) { |o, e, i| 16 | ENV.delete("FOO") 17 | ENV["BAR"] = "1" 18 | Envcat::Cli.invoke(%w[-f export FOO BAR], o, e, i) 19 | } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.github/workflows/crystal_action.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # crystal_action.rb v1.0.0 4 | # (c)2023 moe@busyloop.net - MIT License 5 | 6 | require 'json' 7 | require 'yaml' 8 | 9 | SHARD_YML = YAML.load_file('shard.yml') 10 | 11 | ENTRIES={} 12 | ENTRIES['platform'] = SHARD_YML['support_matrix']['platforms'] 13 | ENTRIES['crystal'] = SHARD_YML['support_matrix']['crystal_versions'] # + %w[latest] 14 | 15 | def product_hash(hsh) 16 | keys = hsh.keys 17 | attrs = keys.map { |key| hsh[key] } 18 | product = attrs[0].product(*attrs[1..-1]) 19 | product.map{ |p| Hash[keys.zip p] } 20 | end 21 | 22 | print "::set-output name=matrix_json::" 23 | puts product_hash(ENTRIES).to_json 24 | 25 | print "::set-output name=crystal_version::" 26 | puts SHARD_YML['crystal'] 27 | -------------------------------------------------------------------------------- /fixtures/input/test.toml: -------------------------------------------------------------------------------- 1 | name = "John Doe" 2 | age = 30 3 | city = "New York" 4 | hobbies = ["Reading", "Hiking", "Cooking"] 5 | matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 6 | colors = ["Red", "Green", "Blue"] 7 | description = "This is a multi-line\ndescription in YAML.\nIt can span multiple lines.\n" 8 | 9 | [[people]] 10 | name = "Alice" 11 | age = 25 12 | 13 | [[people]] 14 | name = "Bob" 15 | age = 28 16 | 17 | [address] 18 | street = "123 Main St" 19 | city = "Springfield" 20 | zip = 12345 21 | 22 | [employee] 23 | name = "Jane Smith" 24 | department = "HR" 25 | projects = ["Project A", "Project B"] 26 | skills = ["Skill 1", "Skill 2"] 27 | [employee.contact] 28 | email = "jane@example.com" 29 | phone = "555-123-4567" 30 | 31 | [person] 32 | first_name = "Mary" 33 | last_name = "Johnson" 34 | -------------------------------------------------------------------------------- /spec/envcat/cli/set_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | require "../../../src/envcat/cli" 3 | require "digest/sha256" 4 | 5 | describe Envcat::Cli do 6 | describe "-i json:fixtures/input/test.json -s AGE=42" do 7 | it "overwrites value from json" do 8 | expect_output(nil, nil) { |o, e, i| 9 | Envcat::Cli.invoke(%w[-f kv -i json:fixtures/input/test.json -s AGE=42 AGE], o, e, i) 10 | o.to_s.should eq("AGE=42\n") 11 | } 12 | end 13 | end 14 | 15 | describe "-s AGE=42 -i json:fixtures/input/test.json" do 16 | it "overwrites value from json" do 17 | expect_output(nil, nil) { |o, e, i| 18 | Envcat::Cli.invoke(%w[-f kv -s AGE=42 -i json:fixtures/input/test.json AGE], o, e, i) 19 | o.to_s.should eq("AGE=42\n") 20 | } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/envcat/cli/format/j2_unsafe_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../../spec_helper" 2 | require "../../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "-f j2 FOO BAR" do 6 | it "fails if template is malformed" do 7 | tpl = "{{FOO} " 8 | expect_output(/^$/, /^Malformed template:/, tpl) { |o, e, i| 9 | expect_raises(Exit, "3") { 10 | ENV["FOO"] = "1" 11 | ENV.delete("BAR") 12 | Envcat::Cli.invoke(%w[-f j2_unsafe FOO BAR], o, e, i) 13 | } 14 | } 15 | end 16 | 17 | it "renders undefined vars as empty string" do 18 | tpl = "{{FOO}} {{BAR}}" 19 | expect_output(/^ 2$/, /^$/, tpl) { |o, e, i| 20 | ENV.delete("FOO") 21 | ENV["BAR"] = "2" 22 | Envcat::Cli.invoke(%w[-f j2_unsafe FOO BAR], o, e, i) 23 | } 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: envcat 2 | version: 1.1.1 3 | 4 | authors: 5 | - moe 6 | 7 | targets: 8 | envcat: 9 | main: src/envcat/cli.cr 10 | 11 | crystal: &crystal_version 1.11.2 12 | 13 | license: MIT 14 | 15 | support_matrix: 16 | crystal_versions: 17 | - *crystal_version 18 | 19 | platforms: 20 | - ubuntu-latest 21 | - macos-latest 22 | - buildjet-2vcpu-ubuntu-2204-arm 23 | 24 | dependencies: 25 | crinja: 26 | github: straight-shoota/crinja 27 | # version: 0.8.0 28 | commit: ca17c3d698b2d1d7ccc702079e93e31788caabb2 29 | 30 | toka: 31 | github: Papierkorb/toka 32 | # version: 0.1.2 33 | commit: 3c160b77369e3491954b782601247f668ccff071 34 | 35 | toml: 36 | github: crystal-community/toml.cr 37 | # version: 0.7.0 38 | commit: db53c77b6973369c8d5575d20ee91dec971a6fee 39 | 40 | development_dependencies: 41 | ameba: 42 | github: crystal-ameba/ameba 43 | 44 | -------------------------------------------------------------------------------- /fixtures/input/test.yaml: -------------------------------------------------------------------------------- 1 | # Key-Value Pair 2 | name: John Doe 3 | age: 30 4 | city: New York 5 | 6 | # Array 7 | hobbies: 8 | - Reading 9 | - Hiking 10 | - Cooking 11 | 12 | # Array of Arrays 13 | matrix: 14 | - [1, 2, 3] 15 | - [4, 5, 6] 16 | - [7, 8, 9] 17 | 18 | # Hash of Arrays 19 | people: 20 | - name: Alice 21 | age: 25 22 | - name: Bob 23 | age: 28 24 | 25 | # Nested Hash 26 | address: 27 | street: 123 Main St 28 | city: Springfield 29 | zip: 12345 30 | 31 | # Mixed Nesting 32 | employee: 33 | name: Jane Smith 34 | department: HR 35 | contact: 36 | email: jane@example.com 37 | phone: 555-123-4567 38 | projects: 39 | - Project A 40 | - Project B 41 | skills: 42 | - Skill 1 43 | - Skill 2 44 | 45 | # Inline Arrays and Hashes 46 | colors: ["Red", "Green", "Blue"] 47 | person: {first_name: "Mary", last_name: "Johnson"} 48 | 49 | # Multi-line Strings 50 | description: | 51 | This is a multi-line 52 | description in YAML. 53 | It can span multiple lines. 54 | -------------------------------------------------------------------------------- /fixtures/input/test_normalized.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | NAME: John Doe 3 | AGE: "30" 4 | CITY: New York 5 | HOBBIES_0: Reading 6 | HOBBIES_1: Hiking 7 | HOBBIES_2: Cooking 8 | MATRIX_0_0: "1" 9 | MATRIX_0_1: "2" 10 | MATRIX_0_2: "3" 11 | MATRIX_1_0: "4" 12 | MATRIX_1_1: "5" 13 | MATRIX_1_2: "6" 14 | MATRIX_2_0: "7" 15 | MATRIX_2_1: "8" 16 | MATRIX_2_2: "9" 17 | PEOPLE_0_NAME: Alice 18 | PEOPLE_0_AGE: "25" 19 | PEOPLE_1_NAME: Bob 20 | PEOPLE_1_AGE: "28" 21 | ADDRESS_STREET: 123 Main St 22 | ADDRESS_CITY: Springfield 23 | ADDRESS_ZIP: "12345" 24 | EMPLOYEE_NAME: Jane Smith 25 | EMPLOYEE_DEPARTMENT: HR 26 | EMPLOYEE_CONTACT_EMAIL: jane@example.com 27 | EMPLOYEE_CONTACT_PHONE: 555-123-4567 28 | EMPLOYEE_PROJECTS_0: Project A 29 | EMPLOYEE_PROJECTS_1: Project B 30 | EMPLOYEE_SKILLS_0: Skill 1 31 | EMPLOYEE_SKILLS_1: Skill 2 32 | COLORS_0: Red 33 | COLORS_1: Green 34 | COLORS_2: Blue 35 | PERSON_FIRST_NAME: Mary 36 | PERSON_LAST_NAME: Johnson 37 | DESCRIPTION: 'This is a multi-line 38 | 39 | description in YAML. 40 | 41 | It can span multiple lines. 42 | 43 | ' 44 | -------------------------------------------------------------------------------- /src/envcat/format.cr: -------------------------------------------------------------------------------- 1 | module Envcat 2 | class Format 3 | class UnknownFormatIdError < Exception; end 4 | 5 | class MalformedInputError < Exception; end 6 | 7 | class InvalidModeError < Exception; end 8 | 9 | DEFAULT = "json" 10 | REGISTRY = {} of String => Formatter.class 11 | 12 | def self.[](format_id : String) 13 | raise UnknownFormatIdError.new(format_id) unless REGISTRY.has_key?(format_id) 14 | REGISTRY[format_id] 15 | end 16 | 17 | def self.keys 18 | REGISTRY.keys 19 | end 20 | 21 | def self.has_format?(format_id) 22 | REGISTRY.has_key? format_id 23 | end 24 | end 25 | end 26 | 27 | module Envcat 28 | abstract class Format::Formatter 29 | module ClassMethods 30 | abstract def description : String 31 | abstract def from_string(value : String) 32 | end 33 | 34 | def initialize(@io : IO, @io_in : IO) 35 | end 36 | 37 | abstract def write(env : Env) 38 | 39 | macro inherited 40 | extend ClassMethods 41 | Format::REGISTRY[self.name.split("::").last.underscore.downcase] = self 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 moe 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. 22 | -------------------------------------------------------------------------------- /src/envcat/format/etf.cr: -------------------------------------------------------------------------------- 1 | require "../format" 2 | 3 | require "json" 4 | require "base64" 5 | require "compress/gzip" 6 | 7 | module Envcat 8 | class Format::ETF < Format::Formatter 9 | def self.description : String 10 | "Envcat Transport Format" 11 | end 12 | 13 | def write(env : Env) 14 | return if env.empty? 15 | 16 | payload = IO::Memory.new(env.to_json) 17 | zipped = IO::Memory.new 18 | Compress::Gzip::Writer.open(zipped, level: Compress::Deflate::BEST_COMPRESSION) do |gzip| 19 | IO.copy(payload, gzip) 20 | end 21 | 22 | @io.puts Base64.urlsafe_encode(zipped.to_s, padding: false) 23 | end 24 | 25 | def self.from_string(value : String) 26 | payload = IO::Memory.new(Base64.decode_string(value)) 27 | unzipped = IO::Memory.new 28 | Compress::Gzip::Reader.open(payload) do |gzip| 29 | IO.copy(gzip, unzipped) 30 | end 31 | 32 | unzipped.rewind 33 | Hash(String, String).from_json(unzipped) 34 | rescue ex : ::JSON::ParseException | Compress::Gzip::Error | IO::Error | Base64::Error 35 | raise Format::MalformedInputError.new(cause: ex.is_a?(IO::EOFError) ? nil : ex) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/envcat/cli/help_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | require "../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "--help" do 6 | it "prints help and exits with code 0" do 7 | expect_output(nil, /Usage:.*SPEC/) { |o, e, i| 8 | expect_raises(Exit, "0") { 9 | Envcat::Cli.invoke(%w[--help], o, e, i) 10 | } 11 | } 12 | end 13 | end 14 | 15 | describe "-h" do 16 | it "prints help and exits with code 0" do 17 | expect_output(nil, /Usage:.*SPEC/) { |o, e, i| 18 | expect_raises(Exit, "0") { 19 | Envcat::Cli.invoke(%w[-h], o, e, i) 20 | } 21 | } 22 | end 23 | end 24 | 25 | describe "(invalid arguments)" do 26 | it "prints help and exits with code 3" do 27 | expect_output(nil, /Usage:.*SPEC/) { |o, e, i| 28 | expect_raises(Exit, "3") { 29 | Envcat::Cli.invoke(%w[--port -x], o, e, i) 30 | } 31 | } 32 | end 33 | end 34 | 35 | describe "(no arguments)" do 36 | it "prints help and exits with code 3" do 37 | expect_output(nil, /Usage:.*SPEC/) { |o, e, i| 38 | expect_raises(Exit, "3") { 39 | Envcat::Cli.invoke(%w[], o, e, i) 40 | } 41 | } 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /fixtures/input/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "John Doe", 3 | "age": 30, 4 | "city": "New York", 5 | "hobbies": [ 6 | "Reading", 7 | "Hiking", 8 | "Cooking" 9 | ], 10 | "matrix": [ 11 | [ 12 | 1, 13 | 2, 14 | 3 15 | ], 16 | [ 17 | 4, 18 | 5, 19 | 6 20 | ], 21 | [ 22 | 7, 23 | 8, 24 | 9 25 | ] 26 | ], 27 | "people": [ 28 | { 29 | "name": "Alice", 30 | "age": 25 31 | }, 32 | { 33 | "name": "Bob", 34 | "age": 28 35 | } 36 | ], 37 | "address": { 38 | "street": "123 Main St", 39 | "city": "Springfield", 40 | "zip": 12345 41 | }, 42 | "employee": { 43 | "name": "Jane Smith", 44 | "department": "HR", 45 | "contact": { 46 | "email": "jane@example.com", 47 | "phone": "555-123-4567" 48 | }, 49 | "projects": [ 50 | "Project A", 51 | "Project B" 52 | ], 53 | "skills": [ 54 | "Skill 1", 55 | "Skill 2" 56 | ] 57 | }, 58 | "colors": [ 59 | "Red", 60 | "Green", 61 | "Blue" 62 | ], 63 | "person": { 64 | "first_name": "Mary", 65 | "last_name": "Johnson" 66 | }, 67 | "description": "This is a multi-line\ndescription in YAML.\nIt can span multiple lines.\n" 68 | } 69 | -------------------------------------------------------------------------------- /spec/envcat/check_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/envcat/check" 3 | 4 | {% for case_path in `find fixtures/check/cases -type f`.lines.map(&.chomp).map(&.split("_")[0..-2].join("_")).sort.uniq %} 5 | \{% for var in `cat {{case_path.id}}_fail`.lines.map(&.chomp).map(&.split(" ")) %} 6 | {% case_id = case_path.split("/").last.split("_").first %} 7 | describe Envcat::Check do 8 | describe "#invalid?" do 9 | it "fails for {{case_path.id}}_fail: {{case_id.id}} #{\{{var.join(":")}}}" do 10 | Envcat::Check.invalid?({ "v" => \{{var[0]}} }, "v", {{case_id}}, \{{var[1..-1]}} of String).should be_a String 11 | rescue Envcat::Check::ArgumentError 12 | # Testcase failed successfully 13 | end 14 | end 15 | end 16 | \{% end %} 17 | 18 | \{% for var in `cat {{case_path.id}}_pass`.lines.map(&.chomp).map(&.split(" ")) %} 19 | describe Envcat::Check do 20 | describe "#invalid?" do 21 | it "passes for {{case_path.id}}_pass: {{case_id.id}} #{\{{var.join(":")}}}" do 22 | Envcat::Check.invalid?({ "v" => \{{var[0]}} }, "v", {{case_id}}, \{{var[1..-1]}} of String).should eq false 23 | end 24 | end 25 | end 26 | \{% end %} 27 | {% end %} 28 | -------------------------------------------------------------------------------- /.github/workflows/release_notes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | VERSION=$(git describe --tags | cut -c 2-) 6 | 7 | cat <build/$(EXE_BASENAME).$(UNAME) 40 | rm -f build/*.dwarf 41 | 42 | release_linux: 43 | $(MAKE) release UNAME=linux-x86_64 44 | 45 | ci: 46 | shards install --without-development 47 | 48 | init: 49 | @mkdir -p build 50 | 51 | tag: 52 | git tag v$(VERSION) 53 | 54 | version: 55 | @echo $(VERSION) 56 | 57 | prepare_alpine: 58 | ifeq ($(shell [[ ! -z "$(ALPINE_VERSION)" ]] && echo true),true) 59 | apk add yaml-static libxml2-static xz-static 60 | endif 61 | 62 | # Static linux release build inside alpine 63 | build/$(EXE_BASENAME).linux-x86_64: $(SRC_FILES) | init prepare_alpine 64 | ifeq ($(shell [[ -z "$(ALPINE_VERSION)" && "$(BUILD_MODE)" == "RELEASE" ]] && echo true),true) 65 | time docker run --rm -it -w /src -v `pwd`:/src --entrypoint make $(DOCKER_IMAGE) $@ BUILD_MODE=RELEASE 66 | else 67 | $(CRYSTAL) build $(CRYSTAL_ARGS_$(or $(BUILD_MODE),LOCAL)_$(MAKE_UNAME)) -o $@ ${EXE_SRC} 68 | @ldd $@ 2>/dev/null && { echo "ERROR: Compiler did not produce a static executable - see http://bit.ly/3jnS5yV"; exit 1; } || true 69 | endif 70 | 71 | build/$(EXE_BASENAME).%: $(SRC_FILES) | init prepare_alpine 72 | time $(CRYSTAL) build $(CRYSTAL_ARGS_$(or $(BUILD_MODE),LOCAL)_$(MAKE_UNAME)) -o $@ ${EXE_SRC} 73 | 74 | README.md: docs/templates/README.md.j2 build/$(EXE_BASENAME).$(UNAME) 75 | HELP_SCREEN=$$(build/$(EXE_BASENAME).$(UNAME) --help 2>&1 | tac | tail -n +3 | tac | tail -n +2 | sed 's/\x1B\[[0-9;]\{1,\}[A-Za-z]//g') build/$(EXE_BASENAME).$(UNAME) -f j2 HELP_SCREEN VERSION <$^ >$@ 76 | 77 | README: README.md 78 | readme: README.md 79 | -------------------------------------------------------------------------------- /spec/envcat/cli/check_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | require "../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "-c FOO" do 6 | it "fails if FOO is undefined" do 7 | expect_output(nil, /FOO must be defined/) { |o, e, i| 8 | expect_raises(Exit, "1") { 9 | ENV.delete("FOO") 10 | Envcat::Cli.invoke(%w[-c FOO], o, e, i) 11 | } 12 | } 13 | end 14 | 15 | it "succeeds if FOO is defined" do 16 | expect_output(nil, /^$/) { |o, e, i| 17 | ENV["FOO"] = "bar" 18 | Envcat::Cli.invoke(%w[-c FOO], o, e, i) 19 | } 20 | end 21 | end 22 | 23 | describe "-c FOO:gte:1" do 24 | it "fails if FOO is undefined" do 25 | expect_output(nil, /FOO must be >= 1/) { |o, e, i| 26 | expect_raises(Exit, "1") { 27 | ENV.delete("FOO") 28 | Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) 29 | } 30 | } 31 | end 32 | 33 | it "fails if FOO is < 1" do 34 | expect_output(nil, /FOO must be >= 1/) { |o, e, i| 35 | expect_raises(Exit, "1") { 36 | ENV["FOO"] = "0.5" 37 | Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) 38 | } 39 | } 40 | end 41 | 42 | it "succeeds if FOO is >= 1" do 43 | expect_output(nil, /^$/) { |o, e, i| 44 | ENV["FOO"] = "1" 45 | Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) 46 | } 47 | 48 | expect_output(nil, /^$/) { |o, e, i| 49 | ENV["FOO"] = "1.1" 50 | Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) 51 | } 52 | 53 | expect_output(nil, /^$/) { |o, e, i| 54 | ENV["FOO"] = "2" 55 | Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) 56 | } 57 | end 58 | end 59 | 60 | describe "-c FOO:?gte:1" do 61 | it "fails if FOO is < 1" do 62 | expect_output(nil, /FOO must be >= 1/) { |o, e, i| 63 | expect_raises(Exit, "1") { 64 | ENV["FOO"] = "0.5" 65 | Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) 66 | } 67 | } 68 | end 69 | 70 | it "succeeds if FOO is undefined" do 71 | expect_output(/^$/, /^$/) { |o, e, i| 72 | ENV.delete("FOO") 73 | Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) 74 | } 75 | end 76 | 77 | it "succeeds if FOO is >= 1" do 78 | expect_output(/^$/, /^$/) { |o, e, i| 79 | ENV["FOO"] = "1" 80 | Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) 81 | } 82 | 83 | expect_output(/^$/, /^$/) { |o, e, i| 84 | ENV["FOO"] = "1.1" 85 | Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) 86 | } 87 | 88 | expect_output(/^$/, /^$/) { |o, e, i| 89 | ENV["FOO"] = "2" 90 | Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) 91 | } 92 | end 93 | end 94 | 95 | describe "-f json -c FOO" do 96 | it "applies checks before invoking a formatter" do 97 | expect_output(/^$/, /FOO must be defined/) { |o, e, i| 98 | expect_raises(Exit, "1") { 99 | ENV.delete("FOO") 100 | Envcat::Cli.invoke(%w[-c FOO], o, e, i) 101 | } 102 | } 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /src/envcat/env.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "yaml" 3 | require "toml" 4 | 5 | module Envcat 6 | class ParseException < Exception; end 7 | 8 | class Env 9 | @@source_cache = {} of String => String 10 | @kv = {} of String => String 11 | 12 | forward_missing_to @kv 13 | 14 | def initialize(@globs : Array(String) = [] of String) 15 | end 16 | 17 | def merge(hash, globs = @globs) 18 | globs.each do |glob| 19 | glob, format_id = glob.split(":", 2) rescue [glob, nil] 20 | hash.keys.each do |key| 21 | if File.match?(glob, key) 22 | if format_id 23 | Format[format_id].from_string(hash[key]).each do |k, v| 24 | hash[k] = v 25 | end 26 | else 27 | @kv[key] = hash[key] 28 | end 29 | end 30 | rescue ex : Format::MalformedInputError 31 | raise Format::MalformedInputError.new("#{key} is not in #{format_id} format", cause: ex.cause) 32 | rescue ex : Format::InvalidModeError 33 | raise Format::InvalidModeError.new("#{key} #{ex.message}", cause: ex.cause) 34 | end 35 | end 36 | end 37 | 38 | def read_and_cache(path) 39 | @@source_cache[path] ||= File.read(path) 40 | end 41 | 42 | def merge_json(path) 43 | merge(self.class.flatten_hash(JSON.parse(read_and_cache(path)).as_h)) 44 | rescue ex : JSON::ParseException | TypeCastError 45 | raise ParseException.new("#{path} is not valid JSON", cause: ex) 46 | end 47 | 48 | def merge_yaml(path) 49 | merge(self.class.flatten_hash(YAML.parse(read_and_cache(path)).as_h)) 50 | rescue ex : YAML::ParseException | TypeCastError 51 | raise ParseException.new("#{path} is not valid YAML", cause: ex) 52 | end 53 | 54 | def merge_toml(path) 55 | toml = TOML.parse(read_and_cache(path)) 56 | toml.each do |k, v| 57 | if value = v.as_h? 58 | merge(self.class.flatten_hash(value, [k])) 59 | else 60 | hash = {} of String => TOML::Any 61 | hash[k] = v 62 | merge(self.class.flatten_hash(hash)) 63 | end 64 | end 65 | rescue ex : TOML::ParseException | TypeCastError 66 | raise ParseException.new("#{path} is not valid TOML", cause: ex) 67 | end 68 | 69 | def self.flatten_hash(hash, prefix = [] of String, output = {} of String => String, &keymaker : Array(String) -> String) 70 | hash.each do |k, v| 71 | path = prefix + [k.to_s] 72 | if child = v.as_h? 73 | flatten_hash(child, path, output, &keymaker) 74 | elsif child = v.as_a? 75 | flatten_array(child, path, output, &keymaker) 76 | else 77 | output[keymaker.call(path)] = v.to_s 78 | end 79 | end 80 | output 81 | end 82 | 83 | def self.flatten_array(array, prefix = [] of String, output = {} of String => String, &keymaker : Array(String) -> String) 84 | array.each_with_index do |v, i| 85 | path = prefix + [i.to_s] 86 | if child = v.as_h? 87 | flatten_hash(child, path, output, &keymaker) 88 | elsif child = v.as_a? 89 | flatten_array(child, path, output, &keymaker) 90 | else 91 | output[keymaker.call(path)] = v.to_s 92 | end 93 | end 94 | output 95 | end 96 | 97 | def self.flatten_hash(hash, prefix = [] of String, output = {} of String => String) 98 | flatten_hash(hash, prefix, output) do |path| 99 | path.map(&.upcase).map(&.gsub(/[^A-Z0-9]/, '_')).join('_') 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/envcat/cli/format/j2_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../../spec_helper" 2 | require "../../../../src/envcat/cli" 3 | 4 | describe Envcat::Cli do 5 | describe "-f j2 FOO BAR" do 6 | it "fails if template is malformed" do 7 | tpl = "{{FOO} " 8 | expect_output(/^$/, /^Malformed template:/, tpl) { |o, e, i| 9 | expect_raises(Exit, "3") { 10 | ENV["FOO"] = "1" 11 | ENV.delete("BAR") 12 | Envcat::Cli.invoke(%w[-f j2 FOO BAR], o, e, i) 13 | } 14 | } 15 | end 16 | 17 | it "fails cleanly on 'empty expression' template error" do 18 | tpl = "{{}}" 19 | expect_output(/^$/, /^Malformed template:/, tpl) { |o, e, i| 20 | expect_raises(Exit, "3") { 21 | Envcat::Cli.invoke(%w[-f j2], o, e, i) 22 | } 23 | } 24 | end 25 | 26 | it "fails if any referenced var is undefined" do 27 | tpl = "{{FOO}} {{BAR}}" 28 | expect_output(/^$/, /Undefined variable: BAR/, tpl) { |o, e, i| 29 | expect_raises(Exit, "5") { 30 | ENV["FOO"] = "1" 31 | ENV.delete("BAR") 32 | Envcat::Cli.invoke(%w[-f j2 FOO BAR], o, e, i) 33 | } 34 | } 35 | end 36 | 37 | it "fails and reports the first undefined var when any is undefined" do 38 | tpl = "{{FOO}} {{BAR}} {{BATZ}}" 39 | expect_output(/^$/, /Undefined variable: BAR/, tpl) { |o, e, i| 40 | expect_raises(Exit, "5") { 41 | ENV["FOO"] = "1" 42 | ENV["BAR"] = "2" 43 | ENV["BATZ"] = "3" 44 | Envcat::Cli.invoke(%w[-f j2 FOO], o, e, i) 45 | } 46 | } 47 | 48 | expect_output(/^$/, /Undefined variable: FOO/, tpl) { |o, e, i| 49 | expect_raises(Exit, "5") { 50 | ENV["FOO"] = "1" 51 | ENV["BAR"] = "2" 52 | ENV["BATZ"] = "3" 53 | Envcat::Cli.invoke(%w[-f j2 BAR], o, e, i) 54 | } 55 | } 56 | 57 | expect_output(/^$/, /Undefined variable: FOO/, tpl) { |o, e, i| 58 | expect_raises(Exit, "5") { 59 | ENV["FOO"] = "1" 60 | ENV["BAR"] = "2" 61 | ENV["BATZ"] = "3" 62 | Envcat::Cli.invoke(%w[-f j2 BATZ], o, e, i) 63 | } 64 | } 65 | 66 | expect_output(/^$/, /Undefined variable: BATZ/, tpl) { |o, e, i| 67 | expect_raises(Exit, "5") { 68 | ENV["FOO"] = "1" 69 | ENV["BAR"] = "2" 70 | ENV["BATZ"] = "3" 71 | Envcat::Cli.invoke(%w[-f j2 FOO BAR], o, e, i) 72 | } 73 | } 74 | expect_output(/^$/, /Undefined variable: BAR/, tpl) { |o, e, i| 75 | expect_raises(Exit, "5") { 76 | ENV["FOO"] = "1" 77 | ENV["BAR"] = "2" 78 | ENV["BATZ"] = "3" 79 | Envcat::Cli.invoke(%w[-f j2 FOO BATZ], o, e, i) 80 | } 81 | } 82 | 83 | expect_output(/^$/, /Undefined variable: FOO/, tpl) { |o, e, i| 84 | expect_raises(Exit, "5") { 85 | ENV["FOO"] = "1" 86 | ENV["BAR"] = "2" 87 | ENV["BATZ"] = "3" 88 | Envcat::Cli.invoke(%w[-f j2 BATZ BAR], o, e, i) 89 | } 90 | } 91 | end 92 | 93 | it "renders to stdout if all referenced vars have a value or a default" do 94 | tpl = "{{FOO}} {{BAR | default('2')}}" 95 | expect_output(/^1 2$/, /^$/, tpl) { |o, e, i| 96 | ENV["FOO"] = "1" 97 | ENV.delete("BAR") 98 | Envcat::Cli.invoke(%w[-f j2 FOO BAR], o, e, i) 99 | } 100 | end 101 | 102 | it "renders to stdout if referenced vars are made available with wildcard" do 103 | tpl = "{{FOO}} {{BAR | default('2')}}" 104 | expect_output(/^1 2$/, /^$/, tpl) { |o, e, i| 105 | ENV["FOO"] = "1" 106 | ENV.delete("BAR") 107 | Envcat::Cli.invoke(%w[-f j2 *], o, e, i) 108 | } 109 | end 110 | end 111 | 112 | describe "-f json -c FOO BAR" do 113 | it "applies checks before invoking a formatter" do 114 | tpl = "{{FOO} " 115 | expect_output(nil, /FOO must be defined/, tpl) { |o, e, i| 116 | expect_raises(Exit, "1") { 117 | ENV.delete("FOO") 118 | Envcat::Cli.invoke(%w[-c FOO], o, e, i) 119 | } 120 | } 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - 'master' 8 | 9 | jobs: 10 | prepare: 11 | name: Prepare 12 | runs-on: ubuntu-latest 13 | outputs: 14 | trusted: ${{ steps.contains_tag.outputs.retval }} 15 | matrix_json: ${{ steps.crystal_action.outputs.matrix_json }} 16 | crystal_version: ${{ steps.crystal_action.outputs.crystal_version }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | 23 | # It's too slow. Will be re-added when this fix is released: 24 | # https://github.com/crystal-ameba/github-action/commit/6d139121f94294e33921104408ef98bdd33407c3 25 | # - name: Lint 26 | # uses: crystal-ameba/github-action@v0.6.0 27 | # env: 28 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Determine if tag is on trusted branch 31 | uses: rickstaa/action-contains-tag@0f592a0dd54a67d9af4545f6b6687ee01853d9a7 32 | id: contains_tag 33 | with: 34 | frail: false 35 | reference: "master" 36 | tag: "${{ github.ref }}" 37 | 38 | - name: Crystal Action 39 | id: crystal_action 40 | run: .github/workflows/crystal_action.rb 41 | 42 | build: 43 | name: Test & Build 44 | needs: prepare 45 | strategy: 46 | fail-fast: true 47 | matrix: 48 | include: ${{ fromJson(needs.prepare.outputs.matrix_json) }} 49 | runs-on: ${{ matrix.platform }} 50 | permissions: 51 | contents: write 52 | 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v3 56 | 57 | - if: matrix.platform != 'ubuntu-latest' && !endsWith(matrix.platform, '-arm') 58 | name: Install Crystal 59 | uses: crystal-lang/install-crystal@v1 60 | with: 61 | crystal: ${{ matrix.crystal }} 62 | 63 | - if: matrix.platform != 'ubuntu-latest' && !endsWith(matrix.platform, '-arm') 64 | name: Test & Build (on runner host) 65 | run: | 66 | make clean ci release 67 | 68 | - if: matrix.platform == 'ubuntu-latest' || endsWith(matrix.platform, '-arm') 69 | name: Test & Build (in alpine) 70 | uses: addnab/docker-run-action@v3 71 | with: 72 | image: 84codes/crystal:${{ matrix.crystal }}-alpine 73 | options: -v ${{ github.workspace }}:/workspace 74 | run: | 75 | cd /workspace && make clean ci release 76 | 77 | - if: startsWith(github.ref, 'refs/tags/') && matrix.crystal == needs.prepare.outputs.crystal_version && needs.prepare.outputs.trusted == 'true' 78 | name: Compute checksum 79 | run: | 80 | shasum -a 256 build/* >checksums.txt 81 | 82 | - if: startsWith(github.ref, 'refs/tags/') && matrix.crystal == needs.prepare.outputs.crystal_version && needs.prepare.outputs.trusted == 'true' 83 | name: Upload artifacts 84 | uses: actions/upload-artifact@v3 85 | with: 86 | name: sha256-${{ matrix.platform }} 87 | path: checksums.txt 88 | 89 | - if: startsWith(github.ref, 'refs/tags/') && matrix.crystal == needs.prepare.outputs.crystal_version && needs.prepare.outputs.trusted == 'true' 90 | name: Draft release 91 | uses: ncipollo/release-action@v1 92 | with: 93 | artifacts: "build/*" 94 | allowUpdates: true 95 | draft: true 96 | updateOnlyUnreleased: false 97 | generateReleaseNotes: false 98 | omitBody: true 99 | 100 | release: 101 | name: Release 102 | needs: [prepare, build] 103 | permissions: 104 | contents: write 105 | runs-on: ubuntu-latest 106 | 107 | if: startsWith(github.ref, 'refs/tags/') && needs.prepare.outputs.trusted == 'true' 108 | steps: 109 | - name: Checkout 110 | uses: actions/checkout@v3 111 | 112 | - name: Download artifacts 113 | uses: actions/download-artifact@v3 114 | 115 | - name: Generate Release Notes 116 | run: .github/workflows/release_notes.sh >release.txt 117 | 118 | - name: Finish Release 119 | uses: ncipollo/release-action@v1 120 | with: 121 | allowUpdates: true 122 | draft: false 123 | updateOnlyUnreleased: false 124 | generateReleaseNotes: false 125 | bodyFile: release.txt 126 | omitBody: false 127 | 128 | -------------------------------------------------------------------------------- /src/envcat/check.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "socket" 3 | require "semantic_version" 4 | 5 | module Envcat 6 | class Check 7 | class UnknownConstraintIdError < Exception; end 8 | 9 | class ConstraintViolationError < Exception; end 10 | 11 | class ArgumentError < Exception; end 12 | 13 | RX_ALNUM = /^[a-zA-Z0-9]+$/ 14 | RX_HEX = /^(0x|0h)?[0-9A-F]+$/i 15 | RX_HEXCOLOR = /^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$/i 16 | RX_INT = /^[-]?\d+$/ 17 | RX_NUM = /^[-]?([0-9]*[.])?[0-9]+$/ 18 | RX_UUID = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i 19 | RX_UFLOAT = /^([0-9]*[.])?[0-9]+$/ 20 | RX_UINT = /^\d+$/ 21 | 22 | CONSTRAINTS = { 23 | presence: ->(v : String?, _a : Array(String)) { v && !v.empty? || "must be defined" }, 24 | alnum: ->(v : String?, _a : Array(String)) { v && RX_ALNUM.match(v) || "must be alphanumeric" }, 25 | b64: ->(v : String?, _a : Array(String)) { v && (v.size % 4 === 0) && /^[a-zA-Z0-9+\/]+={0,2}$/ =~ v || "must be base64" }, 26 | f: ->(v : String?, _a : Array(String)) { v && RX_UFLOAT.match(v) || "must be an unsigned float" }, 27 | fs: ->(v : String?, _a : Array(String)) { v && File.exists?(v) || "must be a path to an existing file or directory" }, 28 | fsd: ->(v : String?, _a : Array(String)) { v && File.directory?(v) || "must be a path to an existing directory" }, 29 | fsf: ->(v : String?, _a : Array(String)) { v && File.file?(v) || "must be a path to an existing file" }, 30 | gt: ->(v : String?, a : Array(String)) { v && v.to_f > a[0].to_f || "must be > #{a[0]}" }, 31 | gte: ->(v : String?, a : Array(String)) { v && v.to_f >= a[0].to_f || "must be >= #{a[0]}" }, 32 | hex: ->(v : String?, _a : Array(String)) { v && RX_HEX.match(v) || "must be a hex number" }, 33 | hexcol: ->(v : String?, _a : Array(String)) { v && RX_HEXCOLOR.match(v) || "must be a hex color" }, 34 | i: ->(v : String?, _a : Array(String)) { v && RX_UINT.match(v) || "must be an unsigned integer" }, 35 | ip: ->(v : String?, _a : Array(String)) { v && Socket::IPAddress.valid?(v) || "must be an ip address" }, 36 | ipv4: ->(v : String?, _a : Array(String)) { v && Socket::IPAddress.valid_v4?(v) || "must be an ipv4 address" }, 37 | ipv6: ->(v : String?, _a : Array(String)) { v && Socket::IPAddress.valid_v6?(v) || "must be an ipv6 address" }, 38 | json: ->(v : String?, _a : Array(String)) { v && JSON.parse(v) || raise "" rescue "must be JSON" }, 39 | lc: ->(v : String?, _a : Array(String)) { v && v.downcase === v || "must be all lowercase" }, 40 | len: ->(v : String?, a : Array(String)) { v && v.size >= a[0].to_i && v.size <= a[1].to_i || "must be #{a[0]}-#{a[1]} characters" }, 41 | lt: ->(v : String?, a : Array(String)) { v && v.to_f < a[0].to_f || "must be < #{a[0]}" }, 42 | lte: ->(v : String?, a : Array(String)) { v && v.to_f <= a[0].to_f || "must be <= #{a[0]}" }, 43 | n: ->(v : String?, _a : Array(String)) { v && RX_UFLOAT.match(v) || "must be an unsigned float or integer" }, 44 | nre: ->(v : String?, a : Array(String)) { v && !Regex.new(a.join(":")).match(v) || "must not match PCRE regex: #{a.same?(DUMMY_ARGS) ? a[0] : a.join(":")}" }, 45 | port: ->(v : String?, _a : Array(String)) { v && RX_INT.match(v) && v.to_i >= 0 && v.to_i <= 65535 || "must be a port number (0-65535)" }, 46 | re: ->(v : String?, a : Array(String)) { v && Regex.new(a.join(":")).match(v) || "must match PCRE regex: #{a.same?(DUMMY_ARGS) ? a[0] : a.join(":")}" }, 47 | sf: ->(v : String?, _a : Array(String)) { v && RX_NUM.match(v) || "must be a float" }, 48 | si: ->(v : String?, _a : Array(String)) { v && RX_INT.match(v) || "must be an integer" }, 49 | sn: ->(v : String?, _a : Array(String)) { v && RX_NUM.match(v) || "must be a float or integer" }, 50 | uc: ->(v : String?, _a : Array(String)) { v && v.upcase === v || "must be all uppercase" }, 51 | uuid: ->(v : String?, _a : Array(String)) { v && RX_UUID.match(v) || "must be a UUID" }, 52 | v: ->(v : String?, _a : Array(String)) { v && SemanticVersion.parse(v) || raise "" rescue "must be a semantic version" }, 53 | vgt: ->(v : String?, a : Array(String)) { v && SemanticVersion.parse(v) > SemanticVersion.parse(a[0]) || raise "" rescue "must be a semantic version > #{a[0]}" }, 54 | vgte: ->(v : String?, a : Array(String)) { v && SemanticVersion.parse(v) >= SemanticVersion.parse(a[0]) || raise "" rescue "must be a semantic version >= #{a[0]}" }, 55 | vlt: ->(v : String?, a : Array(String)) { v && SemanticVersion.parse(v) < SemanticVersion.parse(a[0]) || raise "" rescue "must be a semantic version < #{a[0]}" }, 56 | vlte: ->(v : String?, a : Array(String)) { v && SemanticVersion.parse(v) <= SemanticVersion.parse(a[0]) || raise "" rescue "must be a semantic version <= #{a[0]}" }, 57 | } 58 | 59 | DUMMY_ARGS = %w[X Y ..] 60 | EXCLUDE_FROM_HELP = %i[presence] 61 | 62 | def self.invalid?(env, var_name, constraint_id, args : Array(String), permit_undefined = false) 63 | raise UnknownConstraintIdError.new("Unknown check type '#{constraint_id}'\nMust be one of: #{(CONSTRAINTS.keys.to_a - EXCLUDE_FROM_HELP).join(" ")}") unless CONSTRAINTS.has_key? constraint_id 64 | value = env[var_name]? 65 | return false if value.nil? && permit_undefined 66 | result = CONSTRAINTS[constraint_id].call(value, args) 67 | result.is_a?(String) ? "#{var_name} #{result}" : false 68 | rescue ex : ::ArgumentError 69 | raise Check::ArgumentError.new(ex.message, cause: ex) 70 | rescue ex : IndexError 71 | raise Check::ArgumentError.new("Argument missing", cause: ex) 72 | end 73 | 74 | def self.help_for(io, constraint_id) 75 | return if EXCLUDE_FROM_HELP.includes? constraint_id 76 | sample_error = CONSTRAINTS[constraint_id].call(nil, DUMMY_ARGS).to_s 77 | 78 | text = String.build do |s| 79 | s << " " 80 | s << constraint_id 81 | DUMMY_ARGS.each do |x| 82 | next unless sample_error.includes? x 83 | s << ":" 84 | s << x 85 | end 86 | s << " " * (20 - s.bytesize) 87 | s << sample_error 88 | end 89 | 90 | io.puts text 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /src/envcat/cli.cr: -------------------------------------------------------------------------------- 1 | require "./env" 2 | require "./format/*" 3 | require "./check" 4 | require "./version" 5 | 6 | require "toka" 7 | 8 | class Envcat::Cli 9 | E_OK = 0 10 | E_INVALID = 1 11 | E_SYNTAX = 3 12 | E_UNDEFINED = 5 13 | E_IO = 7 14 | E_PARSE = 11 15 | E_BUG = 255 16 | 17 | HELP_FOOTER = <<-EOF 18 | See https://github.com/busyloop/envcat for documentation and usage examples. 19 | EOF 20 | 21 | class InvalidFlagArgumentError < Exception; end 22 | 23 | class ValidationErrors < Exception 24 | getter errors 25 | 26 | def initialize(@errors : Array(String)) 27 | end 28 | end 29 | 30 | Toka.mapping({ 31 | input: { 32 | type: Array(String), 33 | default: ["env"], 34 | value_name: "SOURCE", 35 | description: "env|json:PATH|yaml:PATH|toml:PATH (default: env)", 36 | }, 37 | set: { 38 | type: Array(String), 39 | value_name: "KEY=VALUE", 40 | description: "KEY=VALUE", 41 | }, 42 | format: { 43 | type: String, 44 | default: Envcat::Cli.default_format, 45 | value_name: "FORMAT", 46 | description: Format.keys.sort!.join("|") + " (default: #{Envcat::Cli.default_format})", 47 | }, 48 | check: { 49 | type: Array(String), 50 | description: "Check VAR against SPEC. Omit SPEC to check only for presence.", 51 | value_name: "VAR[:SPEC]", 52 | short: ["c"], 53 | }, 54 | help: { 55 | type: Bool?, 56 | description: "Show this help", 57 | }, 58 | version: { 59 | type: Bool?, 60 | short: false, 61 | description: "Print version and exit", 62 | }, 63 | }, { 64 | banner: "\nUsage: envcat [-i ..] [-s ..] [-c ..] [-f #{Format.keys.join("|")}] [GLOB[:etf] ..]\n\n", 65 | help: false, 66 | }) 67 | 68 | def self.default_format 69 | PROGRAM_NAME.try &.includes?("envtpl") ? "j2" : Format::DEFAULT 70 | end 71 | 72 | def self.help 73 | String.build(4096) do |s| 74 | s.puts "SOURCE" 75 | s.puts " env - Shell environment" 76 | s.puts " json:PATH - JSON file at PATH" 77 | s.puts " yaml:PATH - YAML file at PATH" 78 | s.puts " toml:PATH - TOML file at PATH" 79 | s.puts 80 | 81 | s.puts "FORMAT" 82 | 83 | Format.keys.sort!.each do |fmt_id| 84 | s.printf " %-16s %s\n", fmt_id, Format[fmt_id].description 85 | end 86 | 87 | s.puts 88 | s.puts "SPEC" 89 | 90 | Envcat::Check::CONSTRAINTS.keys.each do |cid| 91 | Check.help_for(s, cid) 92 | end 93 | 94 | s.puts 95 | s.puts " Prefix ? to skip check when VAR is undefined." 96 | 97 | s.puts 98 | s.puts HELP_FOOTER 99 | s.puts 100 | end 101 | end 102 | 103 | def self.be_helpful(opts, io) 104 | if opts.help || (opts.format == Format::DEFAULT && opts.check.empty? && opts.positional_options.empty?) 105 | io.puts Toka::HelpPageRenderer.new(self) 106 | io.puts help 107 | exit opts.help ? E_OK : E_SYNTAX 108 | end 109 | end 110 | 111 | def self.process_version_flag(opts, io) 112 | if opts.version 113 | io.puts "envcat #{Envcat::VERSION} #{{{env("UNAME") || "unknown-unknown"}}}" 114 | exit E_OK 115 | end 116 | end 117 | 118 | def self.process_check_flags(opts, env) 119 | validation_errors = [] of String 120 | opts.check.try &.each do |vspec| 121 | args = vspec.split(':') 122 | var_name = args.shift 123 | constraint_id = args.shift rescue "presence" 124 | if constraint_id.starts_with? '?' 125 | permit_undefined = true 126 | constraint_id = constraint_id.lchop('?') 127 | else 128 | permit_undefined = false 129 | end 130 | error = Check.invalid? env, var_name, constraint_id, args, permit_undefined 131 | validation_errors << error if error.is_a?(String) 132 | rescue ex : Check::UnknownConstraintIdError | Check::ArgumentError 133 | raise InvalidFlagArgumentError.new("-c #{vspec}", cause: ex) 134 | end 135 | raise ValidationErrors.new(validation_errors) unless validation_errors.empty? 136 | end 137 | 138 | def self.process_input_flags(opts) 139 | envs = {full: Envcat::Env.new(["*"]), filtered: Envcat::Env.new(opts.positional_options)} 140 | opts.input.try &.each do |ispec| 141 | parts = ispec.split(':') 142 | format = parts.shift.try &.downcase 143 | path = parts.shift? 144 | 145 | if format == "env" 146 | envs.values.map(&.merge(ENV)) 147 | elsif %w[json yaml toml].includes? format 148 | raise InvalidFlagArgumentError.new("Path is required in -i #{format}:") if path.nil? || path.empty? 149 | path = "/dev/stdin" if path == "-" 150 | Envcat.claim_stdin! if path == "/dev/stdin" 151 | 152 | case format 153 | when "json" 154 | envs.values.map(&.merge_json(path)) 155 | when "yaml" 156 | envs.values.map(&.merge_yaml(path)) 157 | when "toml" 158 | envs.values.map(&.merge_toml(path)) 159 | end 160 | else 161 | raise InvalidFlagArgumentError.new("-i #{ispec}", cause: InvalidFlagArgumentError.new("Unknown input type: '#{ispec}'")) 162 | end 163 | end 164 | envs 165 | end 166 | 167 | def self.process_set_flags(opts, envs) 168 | opts.set.try &.each do |sspec| 169 | key, value = sspec.split('=') 170 | envs.values.map(&.[key] = value) 171 | end 172 | end 173 | 174 | def self.process_format_flag(opts, env, io_out, io_err, io_in) 175 | Envcat::Format[opts.format].new(io_out, io_in).write(env) 176 | end 177 | 178 | def self.check_format_flag(opts) 179 | Format[opts.format] 180 | end 181 | 182 | def self.invoke(argv = ARGV, io_out = STDOUT, io_err = STDERR, io_in = STDIN) 183 | opts = new(argv) 184 | 185 | process_version_flag(opts, io_out) 186 | be_helpful(opts, io_err) 187 | check_format_flag(opts) # fail-fast on bad syntax 188 | envs = process_input_flags(opts) 189 | process_set_flags(opts, envs) 190 | process_check_flags(opts, envs[:full]) 191 | process_format_flag(opts, envs[:filtered], io_out, io_err, io_in) 192 | rescue ex : Toka::MissingOptionError | Toka::MissingValueError | Toka::ConversionError | Toka::UnknownOptionError 193 | io_err.puts Toka::HelpPageRenderer.new(Envcat::Cli) 194 | io_err.puts "Syntax error: #{ex}" 195 | exit E_SYNTAX 196 | rescue ex : ValidationErrors 197 | io_err.puts ex.errors.join("\n") 198 | exit E_INVALID 199 | rescue ex : IO::Error 200 | io_err.print "Error: " 201 | io_err.puts ex 202 | exit E_IO 203 | rescue ex : Format::J2::UndefinedVariableError 204 | if ex.message.try &.includes? ',' 205 | io_err.puts "Undefined variables: #{ex.message}" 206 | else 207 | io_err.puts "Undefined variable: #{ex.message}" 208 | end 209 | exit E_UNDEFINED 210 | rescue ex : InvalidFlagArgumentError 211 | io_err.puts "Invalid flag: #{ex.message}" 212 | if cause = ex.cause 213 | io_err.puts "Reason: #{cause.message}" 214 | end 215 | exit E_SYNTAX 216 | rescue ex : Format::MalformedInputError | Format::InvalidModeError 217 | io_err.puts "Malformed input: #{ex.message}" 218 | if cause = ex.cause 219 | io_err.puts "Reason: #{cause.message}" 220 | end 221 | exit E_INVALID 222 | rescue ex : StdinAlreadyClaimedError 223 | io_err.puts "Error: Can read from stdin only once." 224 | exit E_SYNTAX 225 | rescue ex : Format::UnknownFormatIdError 226 | io_err.puts "Unknown format: #{ex.message}" 227 | exit E_SYNTAX 228 | rescue ex : Crinja::TemplateSyntaxError | Crinja::FeatureLibrary::UnknownFeatureError | Crinja::TypeError | Format::J2::RenderError 229 | io_err.puts "Malformed template: #{ex.message}" 230 | exit E_SYNTAX 231 | rescue ex : ParseException 232 | io_err.puts "Malformed input: #{ex.message}" 233 | if cause = ex.cause 234 | io_err.puts "Cause: #{cause.message}" 235 | end 236 | exit E_PARSE 237 | rescue ex : Exception 238 | {% if @top_level.constant("BUILD_ENV") == :spec %} 239 | raise ex 240 | {% else %} 241 | STDERR.puts 242 | STDOUT.puts "BUG: #{ex.class} #{ex.message}" 243 | STDERR.puts "🚨 Please report to: https://github.com/busyloop/envcat/issues/new 🚨" 244 | STDERR.puts 245 | 3.times do 246 | STDOUT.print("\a") 247 | sleep 0.42 248 | end 249 | STDERR.puts "Include the following in your report:" 250 | STDERR.puts "--" 251 | STDERR.puts "VERSION: #{Envcat::VERSION}" 252 | STDERR.puts "ARGV: #{ARGV}" 253 | STDERR.puts "TRACE: #{ex.class} #{ex.message}\n#{ex.backtrace.join("\n")}" 254 | STDERR.puts "--" 255 | STDERR.puts 256 | exit E_BUG 257 | {% end %} 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /docs/templates/README.md.j2: -------------------------------------------------------------------------------- 1 | {#- 2 | # Run `make README.md` to compile this template and overwrite README.md 3 | -#} 4 | 7 | 8 | # envcat 9 | 10 | [![Build](https://github.com/busyloop/envcat/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/busyloop/envcat/actions/workflows/ci.yml?query=branch%3Amaster) [![GitHub](https://img.shields.io/github/license/busyloop/envcat)](https://en.wikipedia.org/wiki/MIT_License) [![GitHub release](https://img.shields.io/github/release/busyloop/envcat.svg)](https://github.com/busyloop/envcat/releases) 11 | 12 | 🐟 13 | 14 | **Your Shell Environment Swiss Army Knife.** 🇨🇭 15 | 16 | ### Features 17 | 18 | * Print environment variables in JSON, YAML or other formats 19 | * Validate your environment variables 20 | * Populate a template with env-variables from stdin to stdout 21 | 22 | Hint: envcat loves templating config-files in a Docker or Kubernetes environment. 23 | 24 |
25 | 26 | ## Installation 27 | 28 | #### Download static executable 29 | 30 | | OS | Arch | Version | | 31 | | ------------ | ------- | --------------------- | ---- | 32 | | macOS (Darwin) | x86_64 | {{VERSION}} (latest) | [Download](https://github.com/busyloop/envcat/releases/latest) | 33 | | Linux | x86_64 | {{VERSION}} (latest) | [Download](https://github.com/busyloop/envcat/releases/latest) | 34 | | Linux | aarch64 | {{VERSION}} (latest) | [Download](https://github.com/busyloop/envcat/releases/latest) | 35 | 36 | #### macOS :beer: 37 | 38 | `brew install busyloop/tap/envcat` 39 | 40 | #### Dockerfile 41 | 42 | See the [download page](https://github.com/busyloop/envcat/releases/latest) for an example Dockerfile. :whale: 43 | 44 | {%- raw -%} 45 | ## Usage 46 | 47 | ```bash 48 | # Print 49 | envcat '*' # Print all env vars in JSON-format 50 | envcat -f yaml SHELL HOME # Print $SHELL and $HOME in YAML-format 51 | 52 | # Validate 53 | envcat -c ADDR:ipv4 # Exit 1 if $ADDR is undefined or not an IPv4 address 54 | envcat -c ADDR:?ipv4 # Exit 1 if $ADDR is defined and not an IPv4 address 55 | 56 | # Template 57 | echo "{{HOME}}" | envcat -f j2 '*' # Read j2 template from stdin and render it to stdout 58 | echo "{{HOME}}" | envcat -f j2 'H*' # Same, but only vars starting with H available in the template 59 | 60 | # All of the above combined 61 | echo "{{BIND}}:{{PORT | default('443')}} {{NAME}}" | envcat -f j2 -c PORT:?port -c BIND:ipv4 PORT BIND NAME 62 | ``` 63 | 64 | :bulb: See `envcat --help` for full syntax reference. 65 | 66 | 67 | ## Templating 68 | 69 | With `-f j2`, or when called by the name `envtpl`, envcat renders a jinja2 template from _stdin_ to _stdout_. 70 | Environment variables are available as `{{VAR}}`. 71 | 72 | envcat will abort with code 5 if your template references an undefined variable, 73 | so make sure to provide defaults where appropriate: `{{VAR | default('xxx')}}`. 74 | 75 | 76 | #### Examples 77 | 78 | 79 | ```bash 80 | export FOO=a,b,c 81 | export BAR=41 82 | unset NOPE 83 | 84 | echo "{{FOO}}" | envcat -f j2 FOO # => a,b,c 85 | echo "{{NOPE | default('empty')}}" | envcat -f j2 NOPE # => empty 86 | echo "{% for x in FOO | split(',') %}{{x}}{% endfor %}" | envcat -f j2 FOO # => abc 87 | echo "{% if FOO == 'd,e,f' %}A{% else %}B{% endif %}" | envtpl FOO # => B 88 | echo "{% if BAR | int + 1 == 42 %}yes{% endif %}" | envtpl BAR # => yes 89 | ``` 90 | 91 | 92 | ## Template syntax 93 | 94 | Envcat supports most jinja2 syntax and [builtin filters](https://jinja.palletsprojects.com/en/2.11.x/templates/#list-of-builtin-filters). 95 | 96 | On top it provides the following additional filters: 97 | 98 | #### b64encode, b64encode_urlsafe 99 | 100 | ```bash 101 | export FOO="hello? world?" 102 | 103 | # b64encode, b64encode_urlsafe 104 | echo "{{FOO | b64encode}}" | envtpl FOO # => aGVsbG8/IHdvcmxkPw== 105 | echo "{{FOO | b64encode_urlsafe}}" | envtpl FOO # => aGVsbG8_IHdvcmxkPw== 106 | ``` 107 | 108 | #### b64decode 109 | 110 | ```bash 111 | export B64_REGULAR="aGVsbG8/IHdvcmxkPw==" 112 | export B64_URLSAFE="aGVsbG8_IHdvcmxkPw==" 113 | 114 | echo "{{B64_REGULAR | b64decode}}" | envtpl 'B*' # => hello? world? 115 | echo "{{B64_URLSAFE | b64decode}}" | envtpl 'B*' # => hello? world? 116 | ``` 117 | 118 | #### split 119 | 120 | 121 | ```bash 122 | export FOO=a,b,c 123 | 124 | echo "{% for x in FOO | split(',') %}{{x}}..{% endfor %}" | envtpl FOO # => a..b..c.. 125 | ``` 126 | 127 | **Note:** 128 | Envcat uses a [Crystal implementation of the jinja2 template engine](https://straight-shoota.github.io/crinja/). 129 | Python expressions are **not** supported. 130 | 131 | ## Layering data from multiple sources 132 | 133 | By default envcat reads variables only from your shell environment. 134 | With `-i` you can additionally source data from YAML, JSON or TOML files. 135 | With `-s` you can override variables directly on the command line. 136 | 137 | Both flags can be given multiple times. 138 | 139 | **Examples:** 140 | 141 | ```bash 142 | # Override vars with YAML file 143 | $ export FOO=from_env 144 | $ echo "foo: from_file" >demo.yaml 145 | $ envcat -i env -i yaml:demo.yaml FOO 146 | {"FOO":"from_file"} 147 | 148 | # Override a var with `-s` 149 | $ envcat -i env -i yaml:demo.yaml -s FOO=from_arg FOO 150 | {"FOO":"from_arg"} 151 | 152 | # Layer data from foo.yaml, the environment, 153 | # JSON from stdin and lastly override FOO 154 | $ envcat -i yaml:foo.yaml -i env -i json:- -s FOO=bar [..] 155 | ``` 156 | 157 | ### Input normalization 158 | 159 | envcat flattens the structure of data sourced via `-i` as follows. 160 | 161 | Given the following YAML: 162 | 163 | ```yaml 164 | # demo.yaml 165 | employee: 166 | name: Jane Smith 167 | department: HR 168 | contact: 169 | email: jane@example.com 170 | phone: 555-123-4567 171 | projects: 172 | - Project A 173 | - Project B 174 | skills: 175 | - Skill 1 176 | - Skill 2 177 | ``` 178 | 179 | `envcat -f yaml -i yaml:demo.yaml '*'` produces the following output: 180 | 181 | ```yaml 182 | EMPLOYEE_NAME: Jane Smith 183 | EMPLOYEE_DEPARTMENT: HR 184 | EMPLOYEE_CONTACT_EMAIL: jane@example.com 185 | EMPLOYEE_CONTACT_PHONE: 555-123-4567 186 | EMPLOYEE_PROJECTS_0: Project A 187 | EMPLOYEE_PROJECTS_1: Project B 188 | EMPLOYEE_SKILLS_0: Skill 1 189 | EMPLOYEE_SKILLS_1: Skill 2 190 | ``` 191 | 192 | 193 | ## Checks 194 | 195 | With `-c VAR[:SPEC]` envcat checks that $VAR meets a constraint defined by SPEC. 196 | 197 | This flag can be given multiple times. 198 | envcat aborts with code 1 if any check fails. 199 | 200 | You can prefix a SPEC with `?` to skip it when $VAR is undefined: 201 | 202 | ```bash 203 | unset FOO 204 | envcat -c FOO:i # => Abort because FOO is undefined 205 | envcat -c FOO:?i # => Success because FOO is undefined (check skipped) 206 | 207 | export FOO=x 208 | envcat -c FOO:i # => Abort because FOO is not an unsigned integer 209 | envcat -c FOO:?i # => Abort because FOO is not an unsigned integer 210 | 211 | export FOO=1 212 | envcat -c FOO:i # => Success because FOO is an unsigned integer 213 | envcat -c FOO:?i # => Success because FOO is an unsigned integer 214 | ``` 215 | 216 | For a full list of available SPEC constraints see below. 217 | 218 | 219 | ## Synopsis 220 | 221 | ``` 222 | {% endraw %}{{ HELP_SCREEN }}{% raw %} 223 | ``` 224 | 225 | ## Advanced: Envcat Transport Format 🚚 226 | 227 | Sometimes it can be helpful to pack multiple env vars 228 | into a single string, to be unpacked elsewhere. 229 | You can do this with envcat by using the `etf` format: 230 | 231 | ```bash 232 | $ export A=1 B=2 C=3 233 | 234 | # Export to ETF format (url-safe base64) 235 | $ envcat -f etf A B C 236 | H4sIAPPtsmMA_6tWclSyUjJU0lFyAtJGQNoZSBsr1QIActF58hkAAAA 237 | 238 | # Import from ETF format 239 | # The :etf suffix tells envcat to unpack $VARS_ETF from etf format. 240 | # The unpacked vars override any existing env vars by the same name. 241 | $ export VARS_ETF=H4sIAPPtsmMA_6tWclSyUjJU0lFyAtJGQNoZSBsr1QIActF58hkAAAA 242 | $ envcat -f export VARS_ETF:etf A B C 243 | export A=1 244 | export B=2 245 | export C=3 246 | ``` 247 | 248 | You can also layer multiple ETF bundles: 249 | 250 | 251 | ```bash 252 | $ export BUNDLE_A=$(A=xxx envcat -f etf A) 253 | $ export BUNDLE_B=$(A=hello B=world envcat -f etf A B) 254 | 255 | $ envcat -f export BUNDLE_A:etf A B 256 | export A=xxx 257 | 258 | $ envcat -f export BUNDLE_A:etf BUNDLE_B:etf A B 259 | export A=hello 260 | export B=world 261 | ``` 262 | 263 | ## Exit codes 264 | 265 | | Code | | 266 | | ----- | ------------------------------------------------------------------------------------- | 267 | | 0 | Success | 268 | | 1 | Invalid value (`--check` constraint violation) | 269 | | 3 | Syntax error (invalid argument or template) | 270 | | 5 | Undefined variable access (e.g. your template contains `{{FOO}}` but $FOO is not set) | 271 | | 7 | I/O Error | 272 | | 11 | Parsing error | 273 | | 255 | Bug (unhandled exception) | 274 | 275 | ## Contributing 276 | 277 | 1. Fork it () 278 | 2. Create your feature branch (`git checkout -b my-new-feature`) 279 | 3. Commit your changes (`git commit -am 'Add some feature'`) 280 | 4. Push to the branch (`git push origin my-new-feature`) 281 | 5. Create a new Pull Request 282 | {% endraw %} 283 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | # envcat 6 | 7 | [![Build](https://github.com/busyloop/envcat/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/busyloop/envcat/actions/workflows/ci.yml?query=branch%3Amaster) [![GitHub](https://img.shields.io/github/license/busyloop/envcat)](https://en.wikipedia.org/wiki/MIT_License) [![GitHub release](https://img.shields.io/github/release/busyloop/envcat.svg)](https://github.com/busyloop/envcat/releases) 8 | 9 | 🐟 10 | 11 | **Your Shell Environment Swiss Army Knife.** 🇨🇭 12 | 13 | ### Features 14 | 15 | * Print environment variables in JSON, YAML or other formats 16 | * Validate your environment variables 17 | * Populate a template with env-variables from stdin to stdout 18 | 19 | Hint: envcat loves templating config-files in a Docker or Kubernetes environment. 20 | 21 |
22 | 23 | ## Installation 24 | 25 | #### Download static executable 26 | 27 | | OS | Arch | Version | | 28 | | ------------ | ------- | --------------------- | ---- | 29 | | macOS (Darwin) | x86_64 | 1.1.1 (latest) | [Download](https://github.com/busyloop/envcat/releases/latest) | 30 | | Linux | x86_64 | 1.1.1 (latest) | [Download](https://github.com/busyloop/envcat/releases/latest) | 31 | | Linux | aarch64 | 1.1.1 (latest) | [Download](https://github.com/busyloop/envcat/releases/latest) | 32 | 33 | #### macOS :beer: 34 | 35 | `brew install busyloop/tap/envcat` 36 | 37 | #### Dockerfile 38 | 39 | See the [download page](https://github.com/busyloop/envcat/releases/latest) for an example Dockerfile. :whale: 40 | 41 | 42 | ## Usage 43 | 44 | ```bash 45 | # Print 46 | envcat '*' # Print all env vars in JSON-format 47 | envcat -f yaml SHELL HOME # Print $SHELL and $HOME in YAML-format 48 | 49 | # Validate 50 | envcat -c ADDR:ipv4 # Exit 1 if $ADDR is undefined or not an IPv4 address 51 | envcat -c ADDR:?ipv4 # Exit 1 if $ADDR is defined and not an IPv4 address 52 | 53 | # Template 54 | echo "{{HOME}}" | envcat -f j2 '*' # Read j2 template from stdin and render it to stdout 55 | echo "{{HOME}}" | envcat -f j2 'H*' # Same, but only vars starting with H available in the template 56 | 57 | # All of the above combined 58 | echo "{{BIND}}:{{PORT | default('443')}} {{NAME}}" | envcat -f j2 -c PORT:?port -c BIND:ipv4 PORT BIND NAME 59 | ``` 60 | 61 | :bulb: See `envcat --help` for full syntax reference. 62 | 63 | 64 | ## Templating 65 | 66 | With `-f j2`, or when called by the name `envtpl`, envcat renders a jinja2 template from _stdin_ to _stdout_. 67 | Environment variables are available as `{{VAR}}`. 68 | 69 | envcat will abort with code 5 if your template references an undefined variable, 70 | so make sure to provide defaults where appropriate: `{{VAR | default('xxx')}}`. 71 | 72 | 73 | #### Examples 74 | 75 | 76 | ```bash 77 | export FOO=a,b,c 78 | export BAR=41 79 | unset NOPE 80 | 81 | echo "{{FOO}}" | envcat -f j2 FOO # => a,b,c 82 | echo "{{NOPE | default('empty')}}" | envcat -f j2 NOPE # => empty 83 | echo "{% for x in FOO | split(',') %}{{x}}{% endfor %}" | envcat -f j2 FOO # => abc 84 | echo "{% if FOO == 'd,e,f' %}A{% else %}B{% endif %}" | envtpl FOO # => B 85 | echo "{% if BAR | int + 1 == 42 %}yes{% endif %}" | envtpl BAR # => yes 86 | ``` 87 | 88 | 89 | ## Template syntax 90 | 91 | Envcat supports most jinja2 syntax and [builtin filters](https://jinja.palletsprojects.com/en/2.11.x/templates/#list-of-builtin-filters). 92 | 93 | On top it provides the following additional filters: 94 | 95 | #### b64encode, b64encode_urlsafe 96 | 97 | ```bash 98 | export FOO="hello? world?" 99 | 100 | # b64encode, b64encode_urlsafe 101 | echo "{{FOO | b64encode}}" | envtpl FOO # => aGVsbG8/IHdvcmxkPw== 102 | echo "{{FOO | b64encode_urlsafe}}" | envtpl FOO # => aGVsbG8_IHdvcmxkPw== 103 | ``` 104 | 105 | #### b64decode 106 | 107 | ```bash 108 | export B64_REGULAR="aGVsbG8/IHdvcmxkPw==" 109 | export B64_URLSAFE="aGVsbG8_IHdvcmxkPw==" 110 | 111 | echo "{{B64_REGULAR | b64decode}}" | envtpl 'B*' # => hello? world? 112 | echo "{{B64_URLSAFE | b64decode}}" | envtpl 'B*' # => hello? world? 113 | ``` 114 | 115 | #### split 116 | 117 | 118 | ```bash 119 | export FOO=a,b,c 120 | 121 | echo "{% for x in FOO | split(',') %}{{x}}..{% endfor %}" | envtpl FOO # => a..b..c.. 122 | ``` 123 | 124 | **Note:** 125 | Envcat uses a [Crystal implementation of the jinja2 template engine](https://straight-shoota.github.io/crinja/). 126 | Python expressions are **not** supported. 127 | 128 | ## Layering data from multiple sources 129 | 130 | By default envcat reads variables only from your shell environment. 131 | With `-i` you can additionally source data from YAML, JSON or TOML files. 132 | With `-s` you can override variables directly on the command line. 133 | 134 | Both flags can be given multiple times. 135 | 136 | **Examples:** 137 | 138 | ```bash 139 | # Override vars with YAML file 140 | $ export FOO=from_env 141 | $ echo "foo: from_file" >demo.yaml 142 | $ envcat -i env -i yaml:demo.yaml FOO 143 | {"FOO":"from_file"} 144 | 145 | # Override a var with `-s` 146 | $ envcat -i env -i yaml:demo.yaml -s FOO=from_arg FOO 147 | {"FOO":"from_arg"} 148 | 149 | # Layer data from foo.yaml, the environment, 150 | # JSON from stdin and lastly override FOO 151 | $ envcat -i yaml:foo.yaml -i env -i json:- -s FOO=bar [..] 152 | ``` 153 | 154 | ### Input normalization 155 | 156 | envcat flattens the structure of data sourced via `-i` as follows. 157 | 158 | Given the following YAML: 159 | 160 | ```yaml 161 | # demo.yaml 162 | employee: 163 | name: Jane Smith 164 | department: HR 165 | contact: 166 | email: jane@example.com 167 | phone: 555-123-4567 168 | projects: 169 | - Project A 170 | - Project B 171 | skills: 172 | - Skill 1 173 | - Skill 2 174 | ``` 175 | 176 | `envcat -f yaml -i yaml:demo.yaml '*'` produces the following output: 177 | 178 | ```yaml 179 | EMPLOYEE_NAME: Jane Smith 180 | EMPLOYEE_DEPARTMENT: HR 181 | EMPLOYEE_CONTACT_EMAIL: jane@example.com 182 | EMPLOYEE_CONTACT_PHONE: 555-123-4567 183 | EMPLOYEE_PROJECTS_0: Project A 184 | EMPLOYEE_PROJECTS_1: Project B 185 | EMPLOYEE_SKILLS_0: Skill 1 186 | EMPLOYEE_SKILLS_1: Skill 2 187 | ``` 188 | 189 | 190 | ## Checks 191 | 192 | With `-c VAR[:SPEC]` envcat checks that $VAR meets a constraint defined by SPEC. 193 | 194 | This flag can be given multiple times. 195 | envcat aborts with code 1 if any check fails. 196 | 197 | You can prefix a SPEC with `?` to skip it when $VAR is undefined: 198 | 199 | ```bash 200 | unset FOO 201 | envcat -c FOO:i # => Abort because FOO is undefined 202 | envcat -c FOO:?i # => Success because FOO is undefined (check skipped) 203 | 204 | export FOO=x 205 | envcat -c FOO:i # => Abort because FOO is not an unsigned integer 206 | envcat -c FOO:?i # => Abort because FOO is not an unsigned integer 207 | 208 | export FOO=1 209 | envcat -c FOO:i # => Success because FOO is an unsigned integer 210 | envcat -c FOO:?i # => Success because FOO is an unsigned integer 211 | ``` 212 | 213 | For a full list of available SPEC constraints see below. 214 | 215 | 216 | ## Synopsis 217 | 218 | ``` 219 | Usage: envcat [-i ..] [-s ..] [-c ..] [-f etf|kv|export|j2|j2_unsafe|json|none|yaml] [GLOB[:etf] ..] 220 | 221 | -i, --input=SOURCE env|json:PATH|yaml:PATH|toml:PATH (default: env) 222 | -s, --set=KEY=VALUE KEY=VALUE 223 | -f, --format=FORMAT etf|export|j2|j2_unsafe|json|kv|none|yaml (default: json) 224 | -c, --check=VAR[:SPEC] Check VAR against SPEC. Omit SPEC to check only for presence. 225 | -h, --help Show this help 226 | --version Print version and exit 227 | 228 | SOURCE 229 | env - Shell environment 230 | json:PATH - JSON file at PATH 231 | yaml:PATH - YAML file at PATH 232 | toml:PATH - TOML file at PATH 233 | 234 | FORMAT 235 | etf Envcat Transport Format 236 | export Shell export format 237 | j2 Render j2 template from stdin (aborts with code 5 if template references an undefined var) 238 | j2_unsafe Render j2 template from stdin (renders undefined vars as empty string) 239 | json JSON format 240 | kv Shell format 241 | none No format 242 | yaml YAML format 243 | 244 | SPEC 245 | alnum must be alphanumeric 246 | b64 must be base64 247 | f must be an unsigned float 248 | fs must be a path to an existing file or directory 249 | fsd must be a path to an existing directory 250 | fsf must be a path to an existing file 251 | gt:X must be > X 252 | gte:X must be >= X 253 | hex must be a hex number 254 | hexcol must be a hex color 255 | i must be an unsigned integer 256 | ip must be an ip address 257 | ipv4 must be an ipv4 address 258 | ipv6 must be an ipv6 address 259 | json must be JSON 260 | lc must be all lowercase 261 | len:X:Y must be X-Y characters 262 | lt:X must be < X 263 | lte:X must be <= X 264 | n must be an unsigned float or integer 265 | nre:X must not match PCRE regex: X 266 | port must be a port number (0-65535) 267 | re:X must match PCRE regex: X 268 | sf must be a float 269 | si must be an integer 270 | sn must be a float or integer 271 | uc must be all uppercase 272 | uuid must be a UUID 273 | v must be a semantic version 274 | vgt:X must be a semantic version > X 275 | vgte:X must be a semantic version >= X 276 | vlt:X must be a semantic version < X 277 | vlte:X must be a semantic version <= X 278 | 279 | Prefix ? to skip check when VAR is undefined. 280 | ``` 281 | 282 | ## Advanced: Envcat Transport Format 🚚 283 | 284 | Sometimes it can be helpful to pack multiple env vars 285 | into a single string, to be unpacked elsewhere. 286 | You can do this with envcat by using the `etf` format: 287 | 288 | ```bash 289 | $ export A=1 B=2 C=3 290 | 291 | # Export to ETF format (url-safe base64) 292 | $ envcat -f etf A B C 293 | H4sIAPPtsmMA_6tWclSyUjJU0lFyAtJGQNoZSBsr1QIActF58hkAAAA 294 | 295 | # Import from ETF format 296 | # The :etf suffix tells envcat to unpack $VARS_ETF from etf format. 297 | # The unpacked vars override any existing env vars by the same name. 298 | $ export VARS_ETF=H4sIAPPtsmMA_6tWclSyUjJU0lFyAtJGQNoZSBsr1QIActF58hkAAAA 299 | $ envcat -f export VARS_ETF:etf A B C 300 | export A=1 301 | export B=2 302 | export C=3 303 | ``` 304 | 305 | You can also layer multiple ETF bundles: 306 | 307 | 308 | ```bash 309 | $ export BUNDLE_A=$(A=xxx envcat -f etf A) 310 | $ export BUNDLE_B=$(A=hello B=world envcat -f etf A B) 311 | 312 | $ envcat -f export BUNDLE_A:etf A B 313 | export A=xxx 314 | 315 | $ envcat -f export BUNDLE_A:etf BUNDLE_B:etf A B 316 | export A=hello 317 | export B=world 318 | ``` 319 | 320 | ## Exit codes 321 | 322 | | Code | | 323 | | ----- | ------------------------------------------------------------------------------------- | 324 | | 0 | Success | 325 | | 1 | Invalid value (`--check` constraint violation) | 326 | | 3 | Syntax error (invalid argument or template) | 327 | | 5 | Undefined variable access (e.g. your template contains `{{FOO}}` but $FOO is not set) | 328 | | 7 | I/O Error | 329 | | 11 | Parsing error | 330 | | 255 | Bug (unhandled exception) | 331 | 332 | ## Contributing 333 | 334 | 1. Fork it () 335 | 2. Create your feature branch (`git checkout -b my-new-feature`) 336 | 3. Commit your changes (`git commit -am 'Add some feature'`) 337 | 4. Push to the branch (`git push origin my-new-feature`) 338 | 5. Create a new Pull Request 339 | 340 | --------------------------------------------------------------------------------