diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0484602 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,125 @@ +name: CI + +on: + push: + pull_request: + branches: + - 'master' + +jobs: + prepare: + name: Prepare + runs-on: ubuntu-latest + outputs: + trusted: ${{ steps.contains_tag.outputs.retval }} + matrix_json: ${{ steps.crystal_action.outputs.matrix_json }} + crystal_version: ${{ steps.crystal_action.outputs.crystal_version }} + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Lint + uses: crystal-ameba/github-action@v0.6.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine if tag is on trusted branch + uses: rickstaa/action-contains-tag@0f592a0dd54a67d9af4545f6b6687ee01853d9a7 + id: contains_tag + with: + frail: false + reference: "master" + tag: "${{ github.ref }}" + + - name: Crystal Action + id: crystal_action + run: .github/workflows/crystal_action.rb + + build: + name: Test & Build + needs: prepare + strategy: + fail-fast: true + matrix: + include: ${{ fromJson(needs.prepare.outputs.matrix_json) }} + runs-on: ${{ matrix.platform }} + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - if: matrix.platform != 'ubuntu-latest' && !endsWith(matrix.platform, '-arm') + name: Install Crystal + uses: crystal-lang/install-crystal@v1 + with: + crystal: ${{ matrix.crystal }} + + - if: matrix.platform != 'ubuntu-latest' && !endsWith(matrix.platform, '-arm') + name: Test & Build (on runner host) + run: | + make clean ci release + + - if: matrix.platform == 'ubuntu-latest' || endsWith(matrix.platform, '-arm') + name: Test & Build (in alpine) + uses: addnab/docker-run-action@v3 + with: + image: 84codes/crystal:${{ matrix.crystal }}-alpine + options: -v ${{ github.workspace }}:/workspace + run: | + cd /workspace && make clean ci release + + - if: startsWith(github.ref, 'refs/tags/') && matrix.crystal == needs.prepare.outputs.crystal_version && needs.prepare.outputs.trusted == 'true' + name: Compute checksum + run: | + shasum -a 256 build/* >checksums.txt + + - if: startsWith(github.ref, 'refs/tags/') && matrix.crystal == needs.prepare.outputs.crystal_version && needs.prepare.outputs.trusted == 'true' + name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: sha256-${{ matrix.platform }} + path: checksums.txt + + - if: startsWith(github.ref, 'refs/tags/') && matrix.crystal == needs.prepare.outputs.crystal_version && needs.prepare.outputs.trusted == 'true' + name: Draft release + uses: ncipollo/release-action@v1 + with: + artifacts: "build/*" + allowUpdates: true + draft: true + updateOnlyUnreleased: false + generateReleaseNotes: false + omitBody: true + + release: + name: Release + needs: [prepare, build] + permissions: + contents: write + runs-on: ubuntu-latest + + if: startsWith(github.ref, 'refs/tags/') && needs.prepare.outputs.trusted == 'true' + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Download artifacts + uses: actions/download-artifact@v3 + + - name: Generate Release Notes + run: .github/workflows/release_notes.sh >release.txt + + - name: Finish Release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true + draft: false + updateOnlyUnreleased: false + generateReleaseNotes: false + bodyFile: release.txt + omitBody: false + diff --git a/.github/workflows/crystal_action.rb b/.github/workflows/crystal_action.rb new file mode 100755 index 0000000..1d7494f --- /dev/null +++ b/.github/workflows/crystal_action.rb @@ -0,0 +1,26 @@ +#!/usr/bin/env ruby + +# crystal_action.rb v1.0.0 +# (c)2023 moe@busyloop.net - MIT License + +require 'json' +require 'yaml' + +SHARD_YML = YAML.load_file('shard.yml') + +ENTRIES={} +ENTRIES['platform'] = SHARD_YML['support_matrix']['platforms'] +ENTRIES['crystal'] = SHARD_YML['support_matrix']['crystal_versions'] # + %w[latest] + +def product_hash(hsh) + keys = hsh.keys + attrs = keys.map { |key| hsh[key] } + product = attrs[0].product(*attrs[1..-1]) + product.map{ |p| Hash[keys.zip p] } +end + +print "::set-output name=matrix_json::" +puts product_hash(ENTRIES).to_json + +print "::set-output name=crystal_version::" +puts SHARD_YML['crystal'] diff --git a/.github/workflows/release_notes.sh b/.github/workflows/release_notes.sh new file mode 100755 index 0000000..b1aff71 --- /dev/null +++ b/.github/workflows/release_notes.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +set -e + +VERSION=$(git describe --tags | cut -c 2-) + +cat < + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..910a2c9 --- /dev/null +++ b/Makefile @@ -0,0 +1,78 @@ +export UNAME := $(shell uname -sm | sed 's/ /-/' | tr '[:upper:]' '[:lower:]') +export MAKE_UNAME := $(shell uname -sm | sed 's/ /_/' | tr '[:lower:]' '[:upper:]') +export VERSION := $(shell grep "^version" shard.yml | cut -d ' ' -f 2) + +SRC_FILES = $(shell find src) +CRYSTAL ?= crystal +CRYSTAL_SPEC_ARGS = --fail-fast + +CRYSTAL_ARGS_LOCAL_DARWIN_X86_64 = --progress +CRYSTAL_ARGS_LOCAL_LINUX_X86_64 = --progress +CRYSTAL_ARGS_LOCAL_LINUX_AARCH64 = --progress +CRYSTAL_ARGS_RELEASE_DARWIN_X86_64 = --release --no-debug +CRYSTAL_ARGS_RELEASE_LINUX_X86_64 = --static --release --no-debug +CRYSTAL_ARGS_RELEASE_LINUX_AARCH64 = --static --release --no-debug + +DOCKER_IMAGE = 84codes/crystal:1.6.2-alpine +ALPINE_VERSION = $(shell which apk) + +EXE_SRC = src/envcat.cr +EXE_BASENAME = envcat-$(VERSION) + +.PHONY: init release + +lint_and_test: lint test + +test: + $(CRYSTAL) spec $(CRYSTAL_SPEC_ARGS) + +lint: + bin/ameba + +clean: + rm -f build/* + +build: build/$(EXE_BASENAME).$(UNAME) + +release: test + $(MAKE) build/$(EXE_BASENAME).$(UNAME) BUILD_MODE=RELEASE + # mkdir -p build && date >build/$(EXE_BASENAME).$(UNAME) + rm -f build/*.dwarf + +release_linux: + $(MAKE) release UNAME=linux-x86_64 + +ci: + shards install --without-development + +init: + @mkdir -p build + +tag: + git tag v$(VERSION) + +version: + @echo $(VERSION) + +prepare_alpine: +ifeq ($(shell [[ ! -z "$(ALPINE_VERSION)" ]] && echo true),true) + apk add yaml-static +endif + +# Static linux release build inside alpine +build/$(EXE_BASENAME).linux-x86_64: $(SRC_FILES) | init prepare_alpine +ifeq ($(shell [[ -z "$(ALPINE_VERSION)" && "$(BUILD_MODE)" == "RELEASE" ]] && echo true),true) + time docker run --rm -it -w /src -v `pwd`:/src --entrypoint make $(DOCKER_IMAGE) $@ BUILD_MODE=RELEASE +else + $(CRYSTAL) build $(CRYSTAL_ARGS_$(or $(BUILD_MODE),LOCAL)_$(MAKE_UNAME)) -o $@ ${EXE_SRC} + @ldd $@ 2>/dev/null && { echo "ERROR: Compiler did not produce a static executable - see http://bit.ly/3jnS5yV"; exit 1; } || true +endif + +build/$(EXE_BASENAME).%: $(SRC_FILES) | init prepare_alpine + time $(CRYSTAL) build $(CRYSTAL_ARGS_$(or $(BUILD_MODE),LOCAL)_$(MAKE_UNAME)) -o $@ ${EXE_SRC} + +README.md: docs/templates/README.md.j2 build/$(EXE_BASENAME).$(UNAME) + 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 <$^ >$@ + +README: README.md +readme: README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..32ee496 --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ + + +# envcat + +[![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) + +🐟 + +**Your Shell Environment Swiss Army Knife.** 🇨🇭 + +## Features + +* Print environment variables in JSON, YAML or other formats +* Validate your environment variables +* Populate a template with env-variables from stdin to stdout + +Hint: envcat loves templating config-files in a Docker or Kubernetes environment. + +
+ +## Installation + +#### Download static executable + +| OS | Arch | Version | | +| ------------ | ------- | --------------------- | ---- | +| OSX (Darwin) | x86_64 | 1.0.0 (latest) | [Download](https://github.com/busyloop/envcat/releases/tag/v1.0.0) | +| Linux | x86_64 | 1.0.0 (latest) | [Download](https://github.com/busyloop/envcat/releases/tag/v1.0.0) | +| Linux | aarch64 | 1.0.0 (latest) | [Download](https://github.com/busyloop/envcat/releases/tag/v1.0.0) | + +#### Dockerfile + +See the [download page](https://github.com/busyloop/envcat/releases/tag/v1.0.0) for an example Dockerfile. :whale: + + +## Usage + +```bash +# Print +envcat '*' # Print all env vars in JSON-format +envcat -f yaml SHELL HOME # Print $SHELL and $HOME in YAML-format + +# Validate +envcat -c ADDR:ipv4 # Exit 1 if $ADDR is undefined or not an IPv4 address +envcat -c ADDR:?ipv4 # Exit 1 if $ADDR is defined and not an IPv4 address + +# Template +echo "{{HOME}}" | envcat -f j2 '*' # Read j2 template from stdin and render it to stdout +echo "{{HOME}}" | envcat -f j2 'H*' # Same, but only vars starting with H available in the template + +# All of the above combined +echo "{{BIND}}:{{PORT | default('443')}} {{NAME}}" | envcat -f j2 -c PORT:?port -c BIND:ipv4 PORT BIND NAME +``` + +:bulb: See `envcat --help` for full syntax reference. + + + +## Templating + +With `-f j2` envcat renders a jinja2 template from _stdin_ to _stdout_. +Environment variables are available as `{{VAR}}`. + +envcat will abort with code 5 if your template references an undefined variable, +so make sure to provide defaults where appropriate: `{{VARNAME | default('xxx')}}`. + + +#### Examples + + +```bash +export FOO=a,b,c +export BAR=41 +unset NOPE + +echo "{{FOO}}" | envcat -f j2 FOO # => a,b,c +echo "{{NOPE | default('empty')}}" | envcat -f j2 NOPE # => empty +echo "{% for x in FOO | split(',') %}{{x}}{% endfor %}" | envcat -f j2 FOO # => abc +echo "{% if FOO == 'd,e,f' %}A{% else %}B{% endif %}" | envcat -f j2 FOO # => B +echo "{% if BAR | int + 1 == 42 %}yes{% endif %}" | envcat -f j2 BAR # => yes +``` + +If you need more, please consult the [jinja2 documentation](https://jinja.palletsprojects.com/en/2.11.x/templates/). + +**Note:** +There are some [subtle differences](https://straight-shoota.github.io/crinja/#:~:text=Differences%20from%20Jinja2) between [the jinja2 library used in envcat](https://straight-shoota.github.io/crinja/) and the original Python jinja2. +But likely none that you will encounter in normal usage. + + +## Checks + +With `-c VAR[:SPEC]` envcat checks that $VAR meets a constraint defined by SPEC. + +This flag can be given multiple times. +envcat aborts with code 1 if any check fails. + +You can prefix a SPEC with `?` to skip it when $VAR is undefined: + +```bash +unset FOO +envcat -c FOO:i # => Abort because FOO is undefined +envcat -c FOO:?i # => Success because FOO is undefined (check skipped) + +export FOO=x +envcat -c FOO:i # => Abort because FOO is not an unsigned integer +envcat -c FOO:?i # => Abort because FOO is not an unsigned integer + +export FOO=1 +envcat -c FOO:i # => Success because FOO is an unsigned integer +envcat -c FOO:?i # => Success because FOO is an unsigned integer +``` + +For a full list of available SPEC constraints see below. + + +## Synopsis + +``` +Usage: envcat [-f etf|kv|export|j2|j2_unsafe|json|none|yaml] [-c ..] [GLOB[:etf] ..] + + -f, --format=FORMAT etf|export|j2|j2_unsafe|json|kv|none|yaml (default: json) + -c, --check=VAR[:SPEC] Check VAR against SPEC. Omit SPEC to check only for presence. + -h, --help Show this help + --version Print version and exit + +FORMAT + etf Envcat Transport Format + export Shell export format + j2 Render j2 template from stdin (aborts with code 5 if template references an undefined var) + j2_unsafe Render j2 template from stdin (renders undefined vars as empty string) + json JSON format + kv Shell format + none No format + yaml YAML format + +SPEC + alnum must be alphanumeric + b64 must be base64 + f must be an unsigned float + fs must be a path to an existing file or directory + fsd must be a path to an existing directory + fsf must be a path to an existing file + gt:X must be > X + gte:X must be >= X + hex must be a hex number + hexcol must be a hex color + i must be an unsigned integer + ip must be an ip address + ipv4 must be an ipv4 address + ipv6 must be an ipv6 address + json must be JSON + lc must be all lowercase + len:X:Y must be X-Y characters + lt:X must be < X + lte:X must be <= X + n must be an unsigned float or integer + nre:X must not match PCRE regex: X + port must be a port number (0-65535) + re:X must match PCRE regex: X + sf must be a float + si must be an integer + sn must be a float or integer + uc must be all uppercase + uuid must be a UUID + v must be a semantic version + vgt:X must be a semantic version > X + vgte:X must be a semantic version >= X + vlt:X must be a semantic version < X + vlte:X must be a semantic version <= X + + Prefix ? to skip check when VAR is undefined. +``` + +## Advanced: Envcat Transport Format 🚚 + +Sometimes it can be helpful to pack multiple env vars +into a single string, to be unpacked elsewhere. +You can do this with envcat by using the `etf` format: + +```bash +$ export A=1 B=2 C=3 + +# Export to ETF format (url-safe base64) +$ envcat -f etf A B C +H4sIAPPtsmMA_6tWclSyUjJU0lFyAtJGQNoZSBsr1QIActF58hkAAAA + +# Import from ETF format +# The :etf suffix tells envcat to unpack $VARS_ETF from etf format. +# The unpacked vars override any existing env vars by the same name. +$ export VARS_ETF=H4sIAPPtsmMA_6tWclSyUjJU0lFyAtJGQNoZSBsr1QIActF58hkAAAA +$ envcat -f json VARS_ETF:etf A B C +{"A":"1","B":"2","C":"3"} +``` + +## Exit codes + +| Code | | +| ----- | ------------------------------------------------------------------------------------- | +| 0 | Success | +| 1 | Invalid value (`--check` constraint violation) | +| 3 | Syntax error (invalid argument or template) | +| 5 | Undefined variable access (e.g. your template contains `{{FOO}}` but $FOO is not set) | + +## Contributing + +1. Fork it () +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +## Contributors + +- [moe](https://github.com/m-o-e) - creator and maintainer diff --git a/assets/mugshot.png b/assets/mugshot.png new file mode 100644 index 0000000..dc84d71 Binary files /dev/null and b/assets/mugshot.png differ diff --git a/fixtures/check/cases/alnum_fail b/fixtures/check/cases/alnum_fail new file mode 100644 index 0000000..06f1103 --- /dev/null +++ b/fixtures/check/cases/alnum_fail @@ -0,0 +1,3 @@ +. +a/Bc +"abc" diff --git a/fixtures/check/cases/alnum_pass b/fixtures/check/cases/alnum_pass new file mode 100644 index 0000000..3cd3beb --- /dev/null +++ b/fixtures/check/cases/alnum_pass @@ -0,0 +1,8 @@ +a +abc +abc123 +123abc +A +ABC +ABc123 +123aBc diff --git a/fixtures/check/cases/b64_fail b/fixtures/check/cases/b64_fail new file mode 100644 index 0000000..96bc9e7 --- /dev/null +++ b/fixtures/check/cases/b64_fail @@ -0,0 +1,3 @@ +xxx +abc123 +Y29ycnVwdGVCg= diff --git a/fixtures/check/cases/b64_pass b/fixtures/check/cases/b64_pass new file mode 100644 index 0000000..0d67e8f --- /dev/null +++ b/fixtures/check/cases/b64_pass @@ -0,0 +1,2 @@ +aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1KNHQ0cE1aQlhaZyZ0PTM3MzdzCg== +aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1KNHQ0cE1aQlhaZyZ0PTQwNTNzCg== diff --git a/fixtures/check/cases/f_fail b/fixtures/check/cases/f_fail new file mode 100644 index 0000000..054e869 --- /dev/null +++ b/fixtures/check/cases/f_fail @@ -0,0 +1,6 @@ +-.01 +-.0 +-0.1 +-0 +-1 +derp diff --git a/fixtures/check/cases/f_pass b/fixtures/check/cases/f_pass new file mode 100644 index 0000000..7e84c80 --- /dev/null +++ b/fixtures/check/cases/f_pass @@ -0,0 +1,7 @@ +.0 +.1 +0.0 +0.1 +1.0 +0 +1 diff --git a/fixtures/check/cases/fs_fail b/fixtures/check/cases/fs_fail new file mode 100644 index 0000000..1634764 --- /dev/null +++ b/fixtures/check/cases/fs_fail @@ -0,0 +1 @@ +nope diff --git a/fixtures/check/cases/fs_pass b/fixtures/check/cases/fs_pass new file mode 100644 index 0000000..bf78308 --- /dev/null +++ b/fixtures/check/cases/fs_pass @@ -0,0 +1,2 @@ +fixtures +shard.yml diff --git a/fixtures/check/cases/fsd_fail b/fixtures/check/cases/fsd_fail new file mode 100644 index 0000000..c1bdfff --- /dev/null +++ b/fixtures/check/cases/fsd_fail @@ -0,0 +1 @@ +shard.yml diff --git a/fixtures/check/cases/fsd_pass b/fixtures/check/cases/fsd_pass new file mode 100644 index 0000000..116caa1 --- /dev/null +++ b/fixtures/check/cases/fsd_pass @@ -0,0 +1 @@ +fixtures diff --git a/fixtures/check/cases/fsf_fail b/fixtures/check/cases/fsf_fail new file mode 100644 index 0000000..116caa1 --- /dev/null +++ b/fixtures/check/cases/fsf_fail @@ -0,0 +1 @@ +fixtures diff --git a/fixtures/check/cases/fsf_pass b/fixtures/check/cases/fsf_pass new file mode 100644 index 0000000..c1bdfff --- /dev/null +++ b/fixtures/check/cases/fsf_pass @@ -0,0 +1 @@ +shard.yml diff --git a/fixtures/check/cases/gt_fail b/fixtures/check/cases/gt_fail new file mode 100644 index 0000000..308c905 --- /dev/null +++ b/fixtures/check/cases/gt_fail @@ -0,0 +1,5 @@ +0 0.01 +0 1 +0.1 0.2 +-0.1 0 +-1 0 diff --git a/fixtures/check/cases/gt_pass b/fixtures/check/cases/gt_pass new file mode 100644 index 0000000..aeb0f3e --- /dev/null +++ b/fixtures/check/cases/gt_pass @@ -0,0 +1,5 @@ +0.01 0 +1 0 +0.2 0.1 +0 -0.1 +0 -1 diff --git a/fixtures/check/cases/gte_fail b/fixtures/check/cases/gte_fail new file mode 100644 index 0000000..0d20474 --- /dev/null +++ b/fixtures/check/cases/gte_fail @@ -0,0 +1,5 @@ +-0.01 0 +-1 0 +0.1 0.2 +0 0.1 +0 1 diff --git a/fixtures/check/cases/gte_pass b/fixtures/check/cases/gte_pass new file mode 100644 index 0000000..d6749fe --- /dev/null +++ b/fixtures/check/cases/gte_pass @@ -0,0 +1,10 @@ +0.01 0 +1 0 +0.2 0.1 +0 -0.1 +0 -1 +-1 -1 +-0.1 -0.1 +0 0 +0.1 0.1 +1 1 diff --git a/fixtures/check/cases/hex_fail b/fixtures/check/cases/hex_fail new file mode 100644 index 0000000..d13c9c3 --- /dev/null +++ b/fixtures/check/cases/hex_fail @@ -0,0 +1,6 @@ +x +0xKeK +0.1 +-a +-A +-0xAbC diff --git a/fixtures/check/cases/hex_pass b/fixtures/check/cases/hex_pass new file mode 100644 index 0000000..d3525aa --- /dev/null +++ b/fixtures/check/cases/hex_pass @@ -0,0 +1,22 @@ +0 +1 +a +b +c +d +e +f +A +B +C +D +E +F +0a +0A +a0 +A0 +0xd1ce +0xAbC +badf00d +d3adfAce diff --git a/fixtures/check/cases/hexcol_fail b/fixtures/check/cases/hexcol_fail new file mode 100644 index 0000000..1269714 --- /dev/null +++ b/fixtures/check/cases/hexcol_fail @@ -0,0 +1,13 @@ +ff +#ff +fffff +#fffff +x +0xfff +0.1 +0 +1 +11 +255,255,255 +'#fff' +"#fff" diff --git a/fixtures/check/cases/hexcol_pass b/fixtures/check/cases/hexcol_pass new file mode 100644 index 0000000..ff35ba1 --- /dev/null +++ b/fixtures/check/cases/hexcol_pass @@ -0,0 +1,15 @@ +fff +FFF +ffffff +FfFfFf +000 +255 +#000 +#fff +#ffffff +ffff +#ffff +abc +#abc +AbC +#AbC diff --git a/fixtures/check/cases/i_fail b/fixtures/check/cases/i_fail new file mode 100644 index 0000000..9570702 --- /dev/null +++ b/fixtures/check/cases/i_fail @@ -0,0 +1,6 @@ +.0 +0.0 +1.0 +-0 +-1 +derp diff --git a/fixtures/check/cases/i_pass b/fixtures/check/cases/i_pass new file mode 100644 index 0000000..735dc49 --- /dev/null +++ b/fixtures/check/cases/i_pass @@ -0,0 +1,3 @@ +0 +1 +9999999999999999999999999999999999999999999999999999999999 diff --git a/fixtures/check/cases/ip_fail b/fixtures/check/cases/ip_fail new file mode 100644 index 0000000..467b238 --- /dev/null +++ b/fixtures/check/cases/ip_fail @@ -0,0 +1,6 @@ +1 +1.2 +1.2.3 +1.2.3.4/8 +1.2.3.4/16 +1.2.3.4/32 diff --git a/fixtures/check/cases/ip_pass b/fixtures/check/cases/ip_pass new file mode 100644 index 0000000..1c577e1 --- /dev/null +++ b/fixtures/check/cases/ip_pass @@ -0,0 +1,9 @@ +0.0.0.0 +127.0.0.1 +10.0.0.1 +142.251.143.78 +2001:4860:4860::8888 +2001:db8:3333:4444:5555:6666:7777:8888 +2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF +FE80::1 +::1 diff --git a/fixtures/check/cases/ipv4_fail b/fixtures/check/cases/ipv4_fail new file mode 100644 index 0000000..62a3c4b --- /dev/null +++ b/fixtures/check/cases/ipv4_fail @@ -0,0 +1,7 @@ +1.2.3.4/8 +1.2.3.4/16 +1.2.3.4/32 +2001:4860:4860::8888 +2001:db8:3333:4444:5555:6666:7777:8888 +2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF +FE80::1 diff --git a/fixtures/check/cases/ipv4_pass b/fixtures/check/cases/ipv4_pass new file mode 100644 index 0000000..0112bd3 --- /dev/null +++ b/fixtures/check/cases/ipv4_pass @@ -0,0 +1,4 @@ +0.0.0.0 +127.0.0.1 +10.0.0.1 +142.251.143.78 diff --git a/fixtures/check/cases/ipv6_fail b/fixtures/check/cases/ipv6_fail new file mode 100644 index 0000000..0112bd3 --- /dev/null +++ b/fixtures/check/cases/ipv6_fail @@ -0,0 +1,4 @@ +0.0.0.0 +127.0.0.1 +10.0.0.1 +142.251.143.78 diff --git a/fixtures/check/cases/ipv6_pass b/fixtures/check/cases/ipv6_pass new file mode 100644 index 0000000..01bb95c --- /dev/null +++ b/fixtures/check/cases/ipv6_pass @@ -0,0 +1,4 @@ +2001:4860:4860::8888 +2001:db8:3333:4444:5555:6666:7777:8888 +2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF +FE80::1 diff --git a/fixtures/check/cases/json_fail b/fixtures/check/cases/json_fail new file mode 100644 index 0000000..c5ccee9 --- /dev/null +++ b/fixtures/check/cases/json_fail @@ -0,0 +1,9 @@ +{ +{{ +} +}} +{{} +{}} +{a:1} +{a:"1"} +{"a":1 diff --git a/fixtures/check/cases/json_pass b/fixtures/check/cases/json_pass new file mode 100644 index 0000000..811a91f --- /dev/null +++ b/fixtures/check/cases/json_pass @@ -0,0 +1,4 @@ +{} +{"a":42} +{"a":"b"} +{"a":1,"b":"c"} diff --git a/fixtures/check/cases/lc_fail b/fixtures/check/cases/lc_fail new file mode 100644 index 0000000..836e302 --- /dev/null +++ b/fixtures/check/cases/lc_fail @@ -0,0 +1,3 @@ +A +Abc +abC diff --git a/fixtures/check/cases/lc_pass b/fixtures/check/cases/lc_pass new file mode 100644 index 0000000..47989ab --- /dev/null +++ b/fixtures/check/cases/lc_pass @@ -0,0 +1,4 @@ +a +abc +123 +!@#$%^&*()_+=-/.,\';[]`~ diff --git a/fixtures/check/cases/len_fail b/fixtures/check/cases/len_fail new file mode 100644 index 0000000..050c6a4 --- /dev/null +++ b/fixtures/check/cases/len_fail @@ -0,0 +1,11 @@ +xx 0 1 +x -1 0 +x 0 0 +xx -1 1 +xxx -1 2 +xxx 0 2 +xxx 1 2 +x 1 0 +x 2 0 +x 2 1 +xx 10 2 diff --git a/fixtures/check/cases/len_pass b/fixtures/check/cases/len_pass new file mode 100644 index 0000000..ce3a75d --- /dev/null +++ b/fixtures/check/cases/len_pass @@ -0,0 +1,8 @@ +x 0 1 +x 1 1 +x -1 1 +xx -2 2 +x 1 2 +xx 2 2 +xx 2 4 +xxx 2 4 diff --git a/fixtures/check/cases/lt_fail b/fixtures/check/cases/lt_fail new file mode 100644 index 0000000..aeb0f3e --- /dev/null +++ b/fixtures/check/cases/lt_fail @@ -0,0 +1,5 @@ +0.01 0 +1 0 +0.2 0.1 +0 -0.1 +0 -1 diff --git a/fixtures/check/cases/lt_pass b/fixtures/check/cases/lt_pass new file mode 100644 index 0000000..308c905 --- /dev/null +++ b/fixtures/check/cases/lt_pass @@ -0,0 +1,5 @@ +0 0.01 +0 1 +0.1 0.2 +-0.1 0 +-1 0 diff --git a/fixtures/check/cases/lte_fail b/fixtures/check/cases/lte_fail new file mode 100644 index 0000000..14a5f21 --- /dev/null +++ b/fixtures/check/cases/lte_fail @@ -0,0 +1,7 @@ +0 -0.01 +0 -1 +0.2 0.1 +0.1 0 +1 0 +a b +b a diff --git a/fixtures/check/cases/lte_pass b/fixtures/check/cases/lte_pass new file mode 100644 index 0000000..c28f61c --- /dev/null +++ b/fixtures/check/cases/lte_pass @@ -0,0 +1,8 @@ +0 0.01 +0 1 +0.1 0.2 +-0.1 0 +-1 0 +0 0 +0.1 0.1 +1 1 diff --git a/fixtures/check/cases/n_fail b/fixtures/check/cases/n_fail new file mode 100644 index 0000000..13f9025 --- /dev/null +++ b/fixtures/check/cases/n_fail @@ -0,0 +1,17 @@ +x +-x +a +-a +ff +0.23a +0.2a3 +0.2a +0,123 +0xff +-.01 +-0.1 +-.0 +-1 +-0 +-0.0 +- diff --git a/fixtures/check/cases/n_pass b/fixtures/check/cases/n_pass new file mode 100644 index 0000000..b04147f --- /dev/null +++ b/fixtures/check/cases/n_pass @@ -0,0 +1,5 @@ +0 +1 +0.1 +.1 +.0 diff --git a/fixtures/check/cases/nre_fail b/fixtures/check/cases/nre_fail new file mode 100644 index 0000000..494d8aa --- /dev/null +++ b/fixtures/check/cases/nre_fail @@ -0,0 +1,2 @@ +hello ^h +1x3 ^\dx\d$ diff --git a/fixtures/check/cases/nre_pass b/fixtures/check/cases/nre_pass new file mode 100644 index 0000000..d9b6e57 --- /dev/null +++ b/fixtures/check/cases/nre_pass @@ -0,0 +1,4 @@ +hello .all. +hello ^.ell$ +hello ^hall +123 ^\d\d$ diff --git a/fixtures/check/cases/re_fail b/fixtures/check/cases/re_fail new file mode 100644 index 0000000..08c912c --- /dev/null +++ b/fixtures/check/cases/re_fail @@ -0,0 +1,6 @@ +hello ^o +1x3 ^\d+$ +a:c a[:]b +bonk ^(foo|bar|batz)$ +foo ^((?!foo|bar|batz).)*$ +bar ^((?!foo|bar|batz).)*$ diff --git a/fixtures/check/cases/re_pass b/fixtures/check/cases/re_pass new file mode 100644 index 0000000..d55f80b --- /dev/null +++ b/fixtures/check/cases/re_pass @@ -0,0 +1,9 @@ +hello ^he +hello ^.ell.$ +hello lo$ +123 ^\d+$ +a:b a[:]b +abc a[:b]c +foo ^(foo|bar|batz)$ +bar ^(foo|bar|batz)$ +bonk ^((?!foo|bar|batz).)*$ diff --git a/fixtures/check/cases/sf_fail b/fixtures/check/cases/sf_fail new file mode 100644 index 0000000..22d53c5 --- /dev/null +++ b/fixtures/check/cases/sf_fail @@ -0,0 +1,5 @@ +-.1x +-.x +-1.x +0.x +derp diff --git a/fixtures/check/cases/sf_pass b/fixtures/check/cases/sf_pass new file mode 100644 index 0000000..2048d82 --- /dev/null +++ b/fixtures/check/cases/sf_pass @@ -0,0 +1,12 @@ +-1 +-0.1 +0 +0.0 +0.00 +0.01 +0.1 +1 +1.0 +.0 +.1 +-.1 diff --git a/fixtures/check/cases/si_fail b/fixtures/check/cases/si_fail new file mode 100644 index 0000000..21f7aa2 --- /dev/null +++ b/fixtures/check/cases/si_fail @@ -0,0 +1,11 @@ +0.0 +-0.1 +1.0 +-.1x +-.x +-1.x +0.x +derp +.0 +.1 +-.1 diff --git a/fixtures/check/cases/si_pass b/fixtures/check/cases/si_pass new file mode 100644 index 0000000..5fb30b0 --- /dev/null +++ b/fixtures/check/cases/si_pass @@ -0,0 +1,5 @@ +-1 +0 +1 +4294967296 +18446744073709551615 diff --git a/fixtures/check/cases/sn_fail b/fixtures/check/cases/sn_fail new file mode 100644 index 0000000..b5cf6da --- /dev/null +++ b/fixtures/check/cases/sn_fail @@ -0,0 +1,10 @@ +x +-x +a +-a +ff +0.23a +0.2a3 +0.2a +0,123 +0xff diff --git a/fixtures/check/cases/sn_pass b/fixtures/check/cases/sn_pass new file mode 100644 index 0000000..6bba2d0 --- /dev/null +++ b/fixtures/check/cases/sn_pass @@ -0,0 +1,11 @@ +-.01 +-.0 +.0 +-1 +-0 +0 +1 +-0.1 +-0.0 +0.0 +0.1 diff --git a/fixtures/check/cases/uc_fail b/fixtures/check/cases/uc_fail new file mode 100644 index 0000000..da68b57 --- /dev/null +++ b/fixtures/check/cases/uc_fail @@ -0,0 +1,3 @@ +a +aBC +abC diff --git a/fixtures/check/cases/uc_pass b/fixtures/check/cases/uc_pass new file mode 100644 index 0000000..c01e466 --- /dev/null +++ b/fixtures/check/cases/uc_pass @@ -0,0 +1,4 @@ +A +ABC +123 +!@#$%^&*()_+=-/.,\';[]`~ diff --git a/fixtures/check/cases/uuid_fail b/fixtures/check/cases/uuid_fail new file mode 100644 index 0000000..574ccd7 --- /dev/null +++ b/fixtures/check/cases/uuid_fail @@ -0,0 +1,10 @@ +0 +00000000 +00000000- +00000000-0000 +00000000-0000- +00000000-0000-1000 +00000000-0000-1000-8000 +00000000-0000-1000-8000-00000000000 +00000000-0000-1000-8000-0000000000000 + diff --git a/fixtures/check/cases/uuid_pass b/fixtures/check/cases/uuid_pass new file mode 100644 index 0000000..de9e617 --- /dev/null +++ b/fixtures/check/cases/uuid_pass @@ -0,0 +1,5 @@ +00000000-0000-1000-8000-000000000000 +00000000-0000-0000-0000-000000000000 +abc00000-0000-1000-8000-000000000000 +AbC00000-0000-1000-8000-000000000000 +ABC00000-0000-1000-8000-000000000000 diff --git a/fixtures/check/cases/v_fail b/fixtures/check/cases/v_fail new file mode 100644 index 0000000..d06dc94 --- /dev/null +++ b/fixtures/check/cases/v_fail @@ -0,0 +1,12 @@ +v1.0.0 +v1.0 +v1 +0 +0.0 +1 +1.0 +1.0. +1,2,3 +2022 +1.2.3pre +2023.01.01 diff --git a/fixtures/check/cases/v_pass b/fixtures/check/cases/v_pass new file mode 100644 index 0000000..f2521a0 --- /dev/null +++ b/fixtures/check/cases/v_pass @@ -0,0 +1,5 @@ +0.0.0 +0.0.1 +1.0.0 +4.5.6-release +2023.1.1 diff --git a/fixtures/check/cases/vgt_fail b/fixtures/check/cases/vgt_fail new file mode 100644 index 0000000..0998914 --- /dev/null +++ b/fixtures/check/cases/vgt_fail @@ -0,0 +1 @@ +0.0.0 0.0.1 diff --git a/fixtures/check/cases/vgt_pass b/fixtures/check/cases/vgt_pass new file mode 100644 index 0000000..26403ea --- /dev/null +++ b/fixtures/check/cases/vgt_pass @@ -0,0 +1,11 @@ +0.0.1 0.0.0 +1.0.2 1.0.1 +1.2.0 1.0.1 +2.6.10 2.6.1 +1.0.0-rc.1 1.0.0-beta.11 +1.0.0-beta.11 1.0.0-beta +1.0.0-beta 1.0.0-alpha.beta +1.0.0-alpha.beta 1.0.0-alpha.1 +1.0.0-alpha.1 1.0.0-alpha +1.0.0-rc.1 1.0.0-alpha +1.0.0 1.0.0-rc.1 diff --git a/fixtures/check/cases/vgte_fail b/fixtures/check/cases/vgte_fail new file mode 100644 index 0000000..0998914 --- /dev/null +++ b/fixtures/check/cases/vgte_fail @@ -0,0 +1 @@ +0.0.0 0.0.1 diff --git a/fixtures/check/cases/vgte_pass b/fixtures/check/cases/vgte_pass new file mode 100644 index 0000000..965cad8 --- /dev/null +++ b/fixtures/check/cases/vgte_pass @@ -0,0 +1,11 @@ +0.0.1 0.0.1 +1.0.2 1.0.1 +1.2.0 1.0.1 +2.6.10 2.6.1 +1.0.0-rc.1 1.0.0-beta.11 +1.0.0-beta.11 1.0.0-beta +1.0.0-beta 1.0.0-alpha.beta +1.0.0-alpha.beta 1.0.0-alpha.1 +1.0.0-alpha.1 1.0.0-alpha +1.0.0-rc.1 1.0.0-alpha +1.0.0 1.0.0-rc.1 diff --git a/fixtures/check/cases/vlt_fail b/fixtures/check/cases/vlt_fail new file mode 100644 index 0000000..96fbb72 --- /dev/null +++ b/fixtures/check/cases/vlt_fail @@ -0,0 +1 @@ +0.0.1 0.0.0 diff --git a/fixtures/check/cases/vlt_pass b/fixtures/check/cases/vlt_pass new file mode 100644 index 0000000..7a7f62c --- /dev/null +++ b/fixtures/check/cases/vlt_pass @@ -0,0 +1,11 @@ +0.0.0 0.0.1 +1.0.1 1.0.2 +1.0.1 1.2.0 +2.6.1 2.6.10 +1.0.0-beta.11 1.0.0-rc.1 +1.0.0-beta 1.0.0-beta.11 +1.0.0-alpha.beta 1.0.0-beta +1.0.0-alpha.1 1.0.0-alpha.beta +1.0.0-alpha 1.0.0-alpha.1 +1.0.0-alpha 1.0.0-rc.1 +1.0.0-rc.1 1.0.0 diff --git a/fixtures/check/cases/vlte_fail b/fixtures/check/cases/vlte_fail new file mode 100644 index 0000000..96fbb72 --- /dev/null +++ b/fixtures/check/cases/vlte_fail @@ -0,0 +1 @@ +0.0.1 0.0.0 diff --git a/fixtures/check/cases/vlte_pass b/fixtures/check/cases/vlte_pass new file mode 100644 index 0000000..f8a329d --- /dev/null +++ b/fixtures/check/cases/vlte_pass @@ -0,0 +1,11 @@ +0.0.1 0.0.1 +1.0.1 1.0.2 +1.0.1 1.2.0 +2.6.1 2.6.10 +1.0.0-beta.11 1.0.0-rc.1 +1.0.0-beta 1.0.0-beta.11 +1.0.0-alpha.beta 1.0.0-beta +1.0.0-alpha.1 1.0.0-alpha.beta +1.0.0-alpha 1.0.0-alpha.1 +1.0.0-alpha 1.0.0-rc.1 +1.0.0-rc.1 1.0.0 diff --git a/shard.lock b/shard.lock new file mode 100644 index 0000000..c658baf --- /dev/null +++ b/shard.lock @@ -0,0 +1,14 @@ +version: 2.0 +shards: + ameba: + git: https://github.com/crystal-ameba/ameba.git + version: 1.3.1 + + crinja: + git: https://github.com/straight-shoota/crinja.git + version: 0.8.0+git.commit.ca17c3d698b2d1d7ccc702079e93e31788caabb2 + + toka: + git: https://github.com/papierkorb/toka.git + version: 0.1.2+git.commit.3c160b77369e3491954b782601247f668ccff071 + diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..ab1f5fb --- /dev/null +++ b/shard.yml @@ -0,0 +1,39 @@ +name: envcat +version: 1.0.0 + +authors: + - moe + +targets: + envcat: + main: src/envcat/cli.cr + +crystal: &crystal_version 1.6.2 + +license: MIT + +support_matrix: + crystal_versions: + - *crystal_version + + platforms: + - ubuntu-latest + - macos-latest + - buildjet-2vcpu-ubuntu-2204-arm + +dependencies: + crinja: + github: straight-shoota/crinja + # version: 0.8.0 + commit: ca17c3d698b2d1d7ccc702079e93e31788caabb2 + + toka: + github: Papierkorb/toka + # version: 0.1.2 + commit: 3c160b77369e3491954b782601247f668ccff071 + +development_dependencies: + ameba: + github: crystal-ameba/ameba + version: 1.3.1 + diff --git a/spec/envcat/check_spec.cr b/spec/envcat/check_spec.cr new file mode 100644 index 0000000..afe4ba9 --- /dev/null +++ b/spec/envcat/check_spec.cr @@ -0,0 +1,27 @@ +require "../spec_helper" +require "../../src/envcat/check" + +{% for case_path in `find fixtures/check/cases -type f`.lines.map(&.chomp).map(&.split("_")[0..-2].join("_")).sort.uniq %} + \{% for var in `cat {{case_path.id}}_fail`.lines.map(&.chomp).map(&.split(" ")) %} + {% case_id = case_path.split("/").last.split("_").first %} + describe Envcat::Check do + describe "#invalid?" do + it "fails for {{case_path.id}}_fail: {{case_id.id}} #{\{{var.join(":")}}}" do + Envcat::Check.invalid?({ "v" => \{{var[0]}} }, "v", {{case_id}}, \{{var[1..-1]}} of String).should be_a String + rescue Envcat::Check::ArgumentError + # Testcase failed successfully + end + end + end + \{% end %} + + \{% for var in `cat {{case_path.id}}_pass`.lines.map(&.chomp).map(&.split(" ")) %} + describe Envcat::Check do + describe "#invalid?" do + it "passes for {{case_path.id}}_pass: {{case_id.id}} #{\{{var.join(":")}}}" do + Envcat::Check.invalid?({ "v" => \{{var[0]}} }, "v", {{case_id}}, \{{var[1..-1]}} of String).should eq false + end + end + end + \{% end %} +{% end %} diff --git a/spec/envcat/cli/check_spec.cr b/spec/envcat/cli/check_spec.cr new file mode 100644 index 0000000..a2ced1a --- /dev/null +++ b/spec/envcat/cli/check_spec.cr @@ -0,0 +1,105 @@ +require "../../spec_helper" +require "../../../src/envcat/cli" + +describe Envcat::Cli do + describe "-c FOO" do + it "fails if FOO is undefined" do + expect_output(nil, /FOO must be defined/) { |o, e, i| + expect_raises(Exit, "1") { + ENV.delete("FOO") + Envcat::Cli.invoke(%w[-c FOO], o, e, i) + } + } + end + + it "succeeds if FOO is defined" do + expect_output(nil, /^$/) { |o, e, i| + ENV["FOO"] = "bar" + Envcat::Cli.invoke(%w[-c FOO], o, e, i) + } + end + end + + describe "-c FOO:gte:1" do + it "fails if FOO is undefined" do + expect_output(nil, /FOO must be >= 1/) { |o, e, i| + expect_raises(Exit, "1") { + ENV.delete("FOO") + Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) + } + } + end + + it "fails if FOO is < 1" do + expect_output(nil, /FOO must be >= 1/) { |o, e, i| + expect_raises(Exit, "1") { + ENV["FOO"] = "0.5" + Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) + } + } + end + + it "succeeds if FOO is >= 1" do + expect_output(nil, /^$/) { |o, e, i| + ENV["FOO"] = "1" + Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) + } + + expect_output(nil, /^$/) { |o, e, i| + ENV["FOO"] = "1.1" + Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) + } + + expect_output(nil, /^$/) { |o, e, i| + ENV["FOO"] = "2" + Envcat::Cli.invoke(%w[-c FOO:gte:1], o, e, i) + } + end + end + + describe "-c FOO:?gte:1" do + it "fails if FOO is < 1" do + expect_output(nil, /FOO must be >= 1/) { |o, e, i| + expect_raises(Exit, "1") { + ENV["FOO"] = "0.5" + Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) + } + } + end + + it "succeeds if FOO is undefined" do + expect_output(/^$/, /^$/) { |o, e, i| + ENV.delete("FOO") + Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) + } + end + + it "succeeds if FOO is >= 1" do + expect_output(/^$/, /^$/) { |o, e, i| + ENV["FOO"] = "1" + Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) + } + + expect_output(/^$/, /^$/) { |o, e, i| + ENV["FOO"] = "1.1" + Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) + } + + expect_output(/^$/, /^$/) { |o, e, i| + ENV["FOO"] = "2" + Envcat::Cli.invoke(%w[-c FOO:?gte:1], o, e, i) + } + end + end + + describe "-f json -c FOO" do + it "applies checks before invoking a formatter" do + expect_output(/^$/, /FOO must be defined/) { |o, e, i| + expect_raises(Exit, "1") { + ENV.delete("FOO") + Envcat::Cli.invoke(%w[-c FOO], o, e, i) + } + } + end + end +end diff --git a/spec/envcat/cli/format/export_spec.cr b/spec/envcat/cli/format/export_spec.cr new file mode 100644 index 0000000..91111f8 --- /dev/null +++ b/spec/envcat/cli/format/export_spec.cr @@ -0,0 +1,22 @@ +require "../../../spec_helper" +require "../../../../src/envcat/cli" + +describe Envcat::Cli do + describe "-f export FOO BAR" do + it "outputs nothing if selected vars are empty" do + expect_output(/^$/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f export FOO BAR], o, e, i) + } + end + + it "writes export to stdout if a selected var has a value" do + expect_output(/export BAR=1\n/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV["BAR"] = "1" + Envcat::Cli.invoke(%w[-f export FOO BAR], o, e, i) + } + end + end +end diff --git a/spec/envcat/cli/format/j2_spec.cr b/spec/envcat/cli/format/j2_spec.cr new file mode 100644 index 0000000..790497b --- /dev/null +++ b/spec/envcat/cli/format/j2_spec.cr @@ -0,0 +1,114 @@ +require "../../../spec_helper" +require "../../../../src/envcat/cli" + +describe Envcat::Cli do + describe "-f j2 FOO BAR" do + it "fails if template is malformed" do + tpl = "{{FOO} " + expect_output(/^$/, /^Malformed template:/, tpl) { |o, e, i| + expect_raises(Exit, "3") { + ENV["FOO"] = "1" + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f j2 FOO BAR], o, e, i) + } + } + end + + it "fails if any referenced var is undefined" do + tpl = "{{FOO}} {{BAR}}" + expect_output(/^$/, /Undefined variable: BAR/, tpl) { |o, e, i| + expect_raises(Exit, "5") { + ENV["FOO"] = "1" + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f j2 FOO BAR], o, e, i) + } + } + end + + it "fails and reports the first undefined var when any is undefined" do + tpl = "{{FOO}} {{BAR}} {{BATZ}}" + expect_output(/^$/, /Undefined variable: BAR/, tpl) { |o, e, i| + expect_raises(Exit, "5") { + ENV["FOO"] = "1" + ENV["BAR"] = "2" + ENV["BATZ"] = "3" + Envcat::Cli.invoke(%w[-f j2 FOO], o, e, i) + } + } + + expect_output(/^$/, /Undefined variable: FOO/, tpl) { |o, e, i| + expect_raises(Exit, "5") { + ENV["FOO"] = "1" + ENV["BAR"] = "2" + ENV["BATZ"] = "3" + Envcat::Cli.invoke(%w[-f j2 BAR], o, e, i) + } + } + + expect_output(/^$/, /Undefined variable: FOO/, tpl) { |o, e, i| + expect_raises(Exit, "5") { + ENV["FOO"] = "1" + ENV["BAR"] = "2" + ENV["BATZ"] = "3" + Envcat::Cli.invoke(%w[-f j2 BATZ], o, e, i) + } + } + + expect_output(/^$/, /Undefined variable: BATZ/, tpl) { |o, e, i| + expect_raises(Exit, "5") { + ENV["FOO"] = "1" + ENV["BAR"] = "2" + ENV["BATZ"] = "3" + Envcat::Cli.invoke(%w[-f j2 FOO BAR], o, e, i) + } + } + expect_output(/^$/, /Undefined variable: BAR/, tpl) { |o, e, i| + expect_raises(Exit, "5") { + ENV["FOO"] = "1" + ENV["BAR"] = "2" + ENV["BATZ"] = "3" + Envcat::Cli.invoke(%w[-f j2 FOO BATZ], o, e, i) + } + } + + expect_output(/^$/, /Undefined variable: FOO/, tpl) { |o, e, i| + expect_raises(Exit, "5") { + ENV["FOO"] = "1" + ENV["BAR"] = "2" + ENV["BATZ"] = "3" + Envcat::Cli.invoke(%w[-f j2 BATZ BAR], o, e, i) + } + } + end + + it "renders to stdout if all referenced vars have a value or a default" do + tpl = "{{FOO}} {{BAR | default('2')}}" + expect_output(/^1 2$/, /^$/, tpl) { |o, e, i| + ENV["FOO"] = "1" + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f j2 FOO BAR], o, e, i) + } + end + + it "renders to stdout if referenced vars are made available with wildcard" do + tpl = "{{FOO}} {{BAR | default('2')}}" + expect_output(/^1 2$/, /^$/, tpl) { |o, e, i| + ENV["FOO"] = "1" + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f j2 *], o, e, i) + } + end + end + + describe "-f json -c FOO BAR" do + it "applies checks before invoking a formatter" do + tpl = "{{FOO} " + expect_output(nil, /FOO must be defined/, tpl) { |o, e, i| + expect_raises(Exit, "1") { + ENV.delete("FOO") + Envcat::Cli.invoke(%w[-c FOO], o, e, i) + } + } + end + end +end diff --git a/spec/envcat/cli/format/j2_unsafe_spec.cr b/spec/envcat/cli/format/j2_unsafe_spec.cr new file mode 100644 index 0000000..9267720 --- /dev/null +++ b/spec/envcat/cli/format/j2_unsafe_spec.cr @@ -0,0 +1,26 @@ +require "../../../spec_helper" +require "../../../../src/envcat/cli" + +describe Envcat::Cli do + describe "-f j2 FOO BAR" do + it "fails if template is malformed" do + tpl = "{{FOO} " + expect_output(/^$/, /^Malformed template:/, tpl) { |o, e, i| + expect_raises(Exit, "3") { + ENV["FOO"] = "1" + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f j2_unsafe FOO BAR], o, e, i) + } + } + end + + it "renders undefined vars as empty string" do + tpl = "{{FOO}} {{BAR}}" + expect_output(/^ 2$/, /^$/, tpl) { |o, e, i| + ENV.delete("FOO") + ENV["BAR"] = "2" + Envcat::Cli.invoke(%w[-f j2_unsafe FOO BAR], o, e, i) + } + end + end +end diff --git a/spec/envcat/cli/format/json_spec.cr b/spec/envcat/cli/format/json_spec.cr new file mode 100644 index 0000000..cb05565 --- /dev/null +++ b/spec/envcat/cli/format/json_spec.cr @@ -0,0 +1,22 @@ +require "../../../spec_helper" +require "../../../../src/envcat/cli" + +describe Envcat::Cli do + describe "-f json FOO BAR" do + it "outputs nothing if selected vars are empty" do + expect_output(/^$/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f json FOO BAR], o, e, i) + } + end + + it "writes json to stdout if a selected var has a value" do + expect_output(/{"BAR":"1"}/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV["BAR"] = "1" + Envcat::Cli.invoke(%w[-f json FOO BAR], o, e, i) + } + end + end +end diff --git a/spec/envcat/cli/format/kv_spec.cr b/spec/envcat/cli/format/kv_spec.cr new file mode 100644 index 0000000..f295475 --- /dev/null +++ b/spec/envcat/cli/format/kv_spec.cr @@ -0,0 +1,22 @@ +require "../../../spec_helper" +require "../../../../src/envcat/cli" + +describe Envcat::Cli do + describe "-f kv FOO BAR" do + it "outputs nothing if selected vars are empty" do + expect_output(/^$/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f kv FOO BAR], o, e, i) + } + end + + it "writes kv to stdout if a selected var has a value" do + expect_output(/BAR=1\n/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV["BAR"] = "1" + Envcat::Cli.invoke(%w[-f kv FOO BAR], o, e, i) + } + end + end +end diff --git a/spec/envcat/cli/format/none_spec.cr b/spec/envcat/cli/format/none_spec.cr new file mode 100644 index 0000000..d644c00 --- /dev/null +++ b/spec/envcat/cli/format/none_spec.cr @@ -0,0 +1,22 @@ +require "../../../spec_helper" +require "../../../../src/envcat/cli" + +describe Envcat::Cli do + describe "-f none FOO BAR" do + it "outputs nothing if selected vars are empty" do + expect_output(/^$/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f none FOO BAR], o, e, i) + } + end + + it "outputs nothing stdout even if a selected var has a value" do + expect_output(/^$/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV["BAR"] = "1" + Envcat::Cli.invoke(%w[-f none FOO BAR], o, e, i) + } + end + end +end diff --git a/spec/envcat/cli/format/yaml_spec.cr b/spec/envcat/cli/format/yaml_spec.cr new file mode 100644 index 0000000..df86d66 --- /dev/null +++ b/spec/envcat/cli/format/yaml_spec.cr @@ -0,0 +1,22 @@ +require "../../../spec_helper" +require "../../../../src/envcat/cli" + +describe Envcat::Cli do + describe "-f yaml FOO BAR" do + it "outputs nothing if selected vars are empty" do + expect_output(/^$/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV.delete("BAR") + Envcat::Cli.invoke(%w[-f yaml FOO BAR], o, e, i) + } + end + + it "writes yaml to stdout if a selected var has a value" do + expect_output(/---\nBAR: \"1\"\n/, /^$/) { |o, e, i| + ENV.delete("FOO") + ENV["BAR"] = "1" + Envcat::Cli.invoke(%w[-f yaml FOO BAR], o, e, i) + } + end + end +end diff --git a/spec/envcat/cli/format_spec.cr b/spec/envcat/cli/format_spec.cr new file mode 100644 index 0000000..39aaaaa --- /dev/null +++ b/spec/envcat/cli/format_spec.cr @@ -0,0 +1,16 @@ +require "../../spec_helper" +require "../../../src/envcat/cli" + +describe Envcat::Cli do + describe "-f export FOO" do + it "reports I/O error with exit code 7" do + expect_output(/^$/, /^Error: Closed stream\n$/) { |o, e, i| + o.close + expect_raises(Exit, "7") { + ENV["FOO"] = "1" + Envcat::Cli.invoke(%w[-f export FOO], o, e, i) + } + } + end + end +end diff --git a/spec/envcat/cli/help_spec.cr b/spec/envcat/cli/help_spec.cr new file mode 100644 index 0000000..ca8dc28 --- /dev/null +++ b/spec/envcat/cli/help_spec.cr @@ -0,0 +1,44 @@ +require "../../spec_helper" +require "../../../src/envcat/cli" + +describe Envcat::Cli do + describe "--help" do + it "prints help and exits with code 0" do + expect_output(nil, /Usage:.*SPEC/) { |o, e, i| + expect_raises(Exit, "0") { + Envcat::Cli.invoke(%w[--help], o, e, i) + } + } + end + end + + describe "-h" do + it "prints help and exits with code 0" do + expect_output(nil, /Usage:.*SPEC/) { |o, e, i| + expect_raises(Exit, "0") { + Envcat::Cli.invoke(%w[-h], o, e, i) + } + } + end + end + + describe "(invalid arguments)" do + it "prints help and exits with code 3" do + expect_output(nil, /Usage:.*SPEC/) { |o, e, i| + expect_raises(Exit, "3") { + Envcat::Cli.invoke(%w[--port -x], o, e, i) + } + } + end + end + + describe "(no arguments)" do + it "prints help and exits with code 3" do + expect_output(nil, /Usage:.*SPEC/) { |o, e, i| + expect_raises(Exit, "3") { + Envcat::Cli.invoke(%w[], o, e, i) + } + } + end + end +end diff --git a/spec/envcat/cli/version_spec.cr b/spec/envcat/cli/version_spec.cr new file mode 100644 index 0000000..81f71f8 --- /dev/null +++ b/spec/envcat/cli/version_spec.cr @@ -0,0 +1,14 @@ +require "../../spec_helper" +require "../../../src/envcat/cli" + +describe Envcat::Cli do + describe "(no arguments)" do + it "prints help and exits with code 3" do + expect_output(nil, /Usage:.*SPEC/) { |o, e, i| + expect_raises(Exit, "3") { + Envcat::Cli.invoke(%w[], o, e, i) + } + } + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..14019c9 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,22 @@ +::BUILD_ENV = :spec + +class Exit < Exception; end + +macro exit(code) + {% if @type.name.starts_with? "Envcat" %} + raise Exit.new(({{code}}).to_s) + {% end %} + Process.exit {{code}} +end + +require "spec" + +def expect_output(stdout_re : Regex? = nil, stderr_re : Regex? = nil, stdin_data = "", &block : (IO, IO, IO) ->) + i = IO::Memory.new(stdin_data) + o = IO::Memory.new + e = IO::Memory.new + block.call(o, e, i) + + o.to_s.should match(stdout_re) if stdout_re + e.to_s.should match(stderr_re) if stderr_re +end diff --git a/src/envcat.cr b/src/envcat.cr new file mode 100644 index 0000000..2a5a13f --- /dev/null +++ b/src/envcat.cr @@ -0,0 +1,5 @@ +require "./envcat/cli" + +{% unless @top_level.constant("BUILD_ENV") == :spec %} + Envcat::Cli.invoke +{% end %} diff --git a/src/envcat/check.cr b/src/envcat/check.cr new file mode 100644 index 0000000..bc6f022 --- /dev/null +++ b/src/envcat/check.cr @@ -0,0 +1,93 @@ +require "json" +require "socket" +require "semantic_version" + +module Envcat + class Check + class UnknownConstraintIdError < Exception; end + + class ConstraintViolationError < Exception; end + + class ArgumentError < Exception; end + + RX_ALNUM = /^[a-zA-Z0-9]+$/ + RX_HEX = /^(0x|0h)?[0-9A-F]+$/i + RX_HEXCOLOR = /^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$/i + RX_INT = /^[-]?\d+$/ + RX_NUM = /^[-]?([0-9]*[.])?[0-9]+$/ + RX_UUID = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i + RX_UFLOAT = /^([0-9]*[.])?[0-9]+$/ + RX_UINT = /^\d+$/ + + CONSTRAINTS = { + presence: ->(v : String?, _a : Array(String)) { v && !v.empty? || "must be defined" }, + alnum: ->(v : String?, _a : Array(String)) { v && RX_ALNUM.match(v) || "must be alphanumeric" }, + b64: ->(v : String?, _a : Array(String)) { v && (v.size % 4 === 0) && /^[a-zA-Z0-9+\/]+={0,2}$/ =~ v || "must be base64" }, + f: ->(v : String?, _a : Array(String)) { v && RX_UFLOAT.match(v) || "must be an unsigned float" }, + fs: ->(v : String?, _a : Array(String)) { v && File.exists?(v) || "must be a path to an existing file or directory" }, + fsd: ->(v : String?, _a : Array(String)) { v && File.directory?(v) || "must be a path to an existing directory" }, + fsf: ->(v : String?, _a : Array(String)) { v && File.file?(v) || "must be a path to an existing file" }, + gt: ->(v : String?, a : Array(String)) { v && v.to_f > a[0].to_f || "must be > #{a[0]}" }, + gte: ->(v : String?, a : Array(String)) { v && v.to_f >= a[0].to_f || "must be >= #{a[0]}" }, + hex: ->(v : String?, _a : Array(String)) { v && RX_HEX.match(v) || "must be a hex number" }, + hexcol: ->(v : String?, _a : Array(String)) { v && RX_HEXCOLOR.match(v) || "must be a hex color" }, + i: ->(v : String?, _a : Array(String)) { v && RX_UINT.match(v) || "must be an unsigned integer" }, + ip: ->(v : String?, _a : Array(String)) { v && Socket::IPAddress.valid?(v) || "must be an ip address" }, + ipv4: ->(v : String?, _a : Array(String)) { v && Socket::IPAddress.valid_v4?(v) || "must be an ipv4 address" }, + ipv6: ->(v : String?, _a : Array(String)) { v && Socket::IPAddress.valid_v6?(v) || "must be an ipv6 address" }, + json: ->(v : String?, _a : Array(String)) { v && JSON.parse(v) || raise "" rescue "must be JSON" }, + lc: ->(v : String?, _a : Array(String)) { v && v.downcase === v || "must be all lowercase" }, + 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" }, + lt: ->(v : String?, a : Array(String)) { v && v.to_f < a[0].to_f || "must be < #{a[0]}" }, + lte: ->(v : String?, a : Array(String)) { v && v.to_f <= a[0].to_f || "must be <= #{a[0]}" }, + n: ->(v : String?, _a : Array(String)) { v && RX_UFLOAT.match(v) || "must be an unsigned float or integer" }, + 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(":")}" }, + 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)" }, + 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(":")}" }, + sf: ->(v : String?, _a : Array(String)) { v && RX_NUM.match(v) || "must be a float" }, + si: ->(v : String?, _a : Array(String)) { v && RX_INT.match(v) || "must be an integer" }, + sn: ->(v : String?, _a : Array(String)) { v && RX_NUM.match(v) || "must be a float or integer" }, + uc: ->(v : String?, _a : Array(String)) { v && v.upcase === v || "must be all uppercase" }, + uuid: ->(v : String?, _a : Array(String)) { v && RX_UUID.match(v) || "must be a UUID" }, + v: ->(v : String?, _a : Array(String)) { v && SemanticVersion.parse(v) || raise "" rescue "must be a semantic version" }, + vgt: ->(v : String?, a : Array(String)) { v && SemanticVersion.parse(v) > SemanticVersion.parse(a[0]) || raise "" rescue "must be a semantic version > #{a[0]}" }, + vgte: ->(v : String?, a : Array(String)) { v && SemanticVersion.parse(v) >= SemanticVersion.parse(a[0]) || raise "" rescue "must be a semantic version >= #{a[0]}" }, + vlt: ->(v : String?, a : Array(String)) { v && SemanticVersion.parse(v) < SemanticVersion.parse(a[0]) || raise "" rescue "must be a semantic version < #{a[0]}" }, + vlte: ->(v : String?, a : Array(String)) { v && SemanticVersion.parse(v) <= SemanticVersion.parse(a[0]) || raise "" rescue "must be a semantic version <= #{a[0]}" }, + } + + DUMMY_ARGS = %w[X Y ..] + EXCLUDE_FROM_HELP = %i[presence] + + def self.invalid?(env, var_name, constraint_id, args : Array(String), permit_undefined = false) + 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 + value = env[var_name]? + return false if value.nil? && permit_undefined + result = CONSTRAINTS[constraint_id].call(value, args) + result.is_a?(String) ? "#{var_name} #{result}" : false + rescue ex : ::ArgumentError + raise Check::ArgumentError.new(ex.message, cause: ex) + rescue ex : IndexError + raise Check::ArgumentError.new("Argument missing", cause: ex) + end + + def self.help_for(io, constraint_id) + return if EXCLUDE_FROM_HELP.includes? constraint_id + sample_error = CONSTRAINTS[constraint_id].call(nil, DUMMY_ARGS).to_s + + text = String.build do |s| + s << " " + s << constraint_id + DUMMY_ARGS.each do |x| + next unless sample_error.includes? x + s << ":" + s << x + end + s << " " * (20 - s.bytesize) + s << sample_error + end + + io.puts text + end + end +end diff --git a/src/envcat/cli.cr b/src/envcat/cli.cr new file mode 100644 index 0000000..77d88e2 --- /dev/null +++ b/src/envcat/cli.cr @@ -0,0 +1,190 @@ +require "./env" +require "./format/*" +require "./check" +require "./version" + +require "toka" + +class Envcat::Cli + E_OK = 0 + E_INVALID = 1 + E_SYNTAX = 3 + E_UNDEFINED = 5 + E_IO = 7 + E_BUG = 255 + + HELP_FOOTER = <<-EOF + See https://github.com/busyloop/envcat for documentation and usage examples. + EOF + + class InvalidFlagArgumentError < Exception; end + + class ValidationErrors < Exception + getter errors + + def initialize(@errors : Array(String)) + end + end + + Toka.mapping({ + format: { + type: String, + default: Format::DEFAULT, + value_name: "FORMAT", + description: Format.keys.sort!.join("|") + " (default: #{Format::DEFAULT})", + }, + check: { + type: Array(String), + description: "Check VAR against SPEC. Omit SPEC to check only for presence.", + value_name: "VAR[:SPEC]", + short: ["c"], + }, + help: { + type: Bool?, + description: "Show this help", + }, + version: { + type: Bool?, + short: false, + description: "Print version and exit", + }, + }, { + banner: "\nUsage: envcat [-f #{Format.keys.join("|")}] [-c ..] [GLOB[:etf] ..]\n\n", + help: false, + }) + + def self.help + String.build(4096) do |s| + s.puts "FORMAT" + + Format.keys.sort!.each do |fmt_id| + s.printf " %-16s %s\n", fmt_id, Format[fmt_id].description + end + + s.puts + s.puts "SPEC" + + Envcat::Check::CONSTRAINTS.keys.each do |cid| + Check.help_for(s, cid) + end + + s.puts + s.puts " Prefix ? to skip check when VAR is undefined." + + s.puts + s.puts HELP_FOOTER + s.puts + end + end + + def self.be_helpful(opts, io) + if opts.help || (opts.format == Format::DEFAULT && opts.check.empty? && opts.positional_options.empty?) + io.puts Toka::HelpPageRenderer.new(self) + io.puts help + exit opts.help ? E_OK : E_SYNTAX + end + end + + def self.process_version_flag(opts, io) + if opts.version + io.puts "envcat #{Envcat::VERSION} #{{{env("UNAME") || "unknown-unknown"}}}" + exit E_OK + end + end + + def self.process_check_flags(opts) + validation_errors = [] of String + opts.check.try &.each do |vspec| + args = vspec.split(':') + var_name = args.shift + constraint_id = args.shift rescue "presence" + if constraint_id.starts_with? '?' + permit_undefined = true + constraint_id = constraint_id.lchop('?') + else + permit_undefined = false + end + error = Check.invalid? ENV, var_name, constraint_id, args, permit_undefined + validation_errors << error if error.is_a?(String) + rescue ex : Check::UnknownConstraintIdError | Check::ArgumentError + raise InvalidFlagArgumentError.new("-c #{vspec}", cause: ex) + end + raise ValidationErrors.new(validation_errors) unless validation_errors.empty? + end + + def self.process_format_flag(opts, io_out, io_err, io_in) + env = Envcat::Env.new(ENV, opts.positional_options) + Envcat::Format[opts.format].new(io_out, io_in).write(env) + end + + def self.check_format_flag(opts) + Format[opts.format] + end + + def self.invoke(argv = ARGV, io_out = STDOUT, io_err = STDERR, io_in = STDIN) + opts = new(argv) + + process_version_flag(opts, io_out) + be_helpful(opts, io_err) + check_format_flag(opts) # fail-fast on bad syntax + process_check_flags(opts) + process_format_flag(opts, io_out, io_err, io_in) + rescue ex : Toka::MissingOptionError | Toka::MissingValueError | Toka::ConversionError | Toka::UnknownOptionError + io_err.puts Toka::HelpPageRenderer.new(Envcat::Cli) + io_err.puts "Syntax error: #{ex}" + exit E_SYNTAX + rescue ex : ValidationErrors + io_err.puts ex.errors.join("\n") + exit E_INVALID + rescue ex : IO::Error + io_err.print "Error: " + io_err.puts ex + exit E_IO + rescue ex : Format::J2::UndefinedVariableError + if ex.message.try &.includes? ',' + io_err.puts "Undefined variables: #{ex.message}" + else + io_err.puts "Undefined variable: #{ex.message}" + end + exit E_UNDEFINED + rescue ex : InvalidFlagArgumentError + io_err.puts "Invalid flag: #{ex.message}" + if cause = ex.cause + io_err.puts "Reason: #{cause.message}" + end + exit E_SYNTAX + rescue ex : Format::MalformedInputError | Format::InvalidModeError + io_err.puts "Malformed input: #{ex.message}" + if cause = ex.cause + io_err.puts "Reason: #{cause.message}" + end + exit E_INVALID + rescue ex : Format::UnknownFormatIdError + io_err.puts "Unknown format: #{ex.message}" + exit E_SYNTAX + rescue ex : Crinja::TemplateSyntaxError | Crinja::FeatureLibrary::UnknownFeatureError | Crinja::TypeError + io_err.puts "Malformed template: #{ex.message}" + exit E_SYNTAX + rescue ex : Exception + {% if @top_level.constant("BUILD_ENV") == :spec %} + raise ex + {% else %} + STDERR.puts + STDOUT.puts "BUG: #{ex.class} #{ex.message}" + STDERR.puts "🚨 Please report to: https://github.com/busyloop/envcat/issues/new 🚨" + STDERR.puts + 3.times do + STDOUT.print("\a") + sleep 0.42 + end + STDERR.puts "Include the following in your report:" + STDERR.puts "--" + STDERR.puts "VERSION: #{Envcat::VERSION}" + STDERR.puts "ARGV: #{ARGV}" + STDERR.puts "TRACE: #{ex.class} #{ex.message}\n#{ex.backtrace.join("\n")}" + STDERR.puts "--" + STDERR.puts + exit E_BUG + {% end %} + end +end diff --git a/src/envcat/env.cr b/src/envcat/env.cr new file mode 100644 index 0000000..519f22c --- /dev/null +++ b/src/envcat/env.cr @@ -0,0 +1,32 @@ +module Envcat + class Env + @kv = {} of String => String + + forward_missing_to @kv + + def initialize(env, globs : Array(String) = [] of String) + import(env, globs) + end + + def import(env, globs) + globs.each do |glob| + glob, format_id = glob.split(":", 2) rescue [glob, nil] + env.keys.each do |key| + if File.match?(glob, key) + if format_id + Format[format_id].from_string(env[key]).each do |k, v| + env[k] = v + end + else + @kv[key] = env[key] + end + end + rescue ex : Format::MalformedInputError + raise Format::MalformedInputError.new("#{key} is not in #{format_id} format", cause: ex.cause) + rescue ex : Format::InvalidModeError + raise Format::InvalidModeError.new("#{key} #{ex.message}", cause: ex.cause) + end + end + end + end +end diff --git a/src/envcat/format.cr b/src/envcat/format.cr new file mode 100644 index 0000000..611813e --- /dev/null +++ b/src/envcat/format.cr @@ -0,0 +1,44 @@ +module Envcat + class Format + class UnknownFormatIdError < Exception; end + + class MalformedInputError < Exception; end + + class InvalidModeError < Exception; end + + DEFAULT = "json" + REGISTRY = {} of String => Formatter.class + + def self.[](format_id : String) + raise UnknownFormatIdError.new(format_id) unless REGISTRY.has_key?(format_id) + REGISTRY[format_id] + end + + def self.keys + REGISTRY.keys + end + + def self.has_format?(format_id) + REGISTRY.has_key? format_id + end + end +end + +module Envcat + abstract class Format::Formatter + module ClassMethods + abstract def description : String + abstract def from_string(value : String) + end + + def initialize(@io : IO, @io_in : IO) + end + + abstract def write(env : Env) + + macro inherited + extend ClassMethods + Format::REGISTRY[self.name.split("::").last.underscore.downcase] = self + end + end +end diff --git a/src/envcat/format/etf.cr b/src/envcat/format/etf.cr new file mode 100644 index 0000000..589c132 --- /dev/null +++ b/src/envcat/format/etf.cr @@ -0,0 +1,38 @@ +require "../format" + +require "json" +require "base64" +require "compress/gzip" + +module Envcat + class Format::ETF < Format::Formatter + def self.description : String + "Envcat Transport Format" + end + + def write(env : Env) + return if env.empty? + + payload = IO::Memory.new(env.to_json) + zipped = IO::Memory.new + Compress::Gzip::Writer.open(zipped, level: Compress::Deflate::BEST_COMPRESSION) do |gzip| + IO.copy(payload, gzip) + end + + @io.puts Base64.urlsafe_encode(zipped.to_s, padding: false) + end + + def self.from_string(value : String) + payload = IO::Memory.new(Base64.decode_string(value)) + unzipped = IO::Memory.new + Compress::Gzip::Reader.open(payload) do |gzip| + IO.copy(gzip, unzipped) + end + + unzipped.rewind + Hash(String, String).from_json(unzipped) + rescue ex : ::JSON::ParseException | Compress::Gzip::Error | IO::Error | Base64::Error + raise Format::MalformedInputError.new(cause: ex.is_a?(IO::EOFError) ? nil : ex) + end + end +end diff --git a/src/envcat/format/export.cr b/src/envcat/format/export.cr new file mode 100644 index 0000000..c8d4a96 --- /dev/null +++ b/src/envcat/format/export.cr @@ -0,0 +1,13 @@ +require "./kv" + +require "json" + +module Envcat + class Format::Export < Format::Kv + @@prefix = "export " + + def self.description : String + "Shell export format" + end + end +end diff --git a/src/envcat/format/j2.cr b/src/envcat/format/j2.cr new file mode 100644 index 0000000..226fbf3 --- /dev/null +++ b/src/envcat/format/j2.cr @@ -0,0 +1,50 @@ +require "../format" +require "crinja" + +# 🐒 +class Crinja::Undefined + class_property strict = false + class_property tagged = [] of String + + def to_s(io) + raise Envcat::Format::J2::UndefinedVariableError.new(@@tagged.last) if @@strict + end +end + +# 🍌 +class Crinja::Util::ScopeMap(K, V) + def [](key : K) + val = previous_def + Crinja::Undefined.tagged << key if parent.nil? && Crinja::Undefined.strict && val.undefined? + val + end +end + +module Envcat + class Format::J2 < Format::Formatter + class UndefinedVariableError < Exception; end + + @@strict = true + + def self.description : String + "Render j2 template from stdin (aborts with code 5 if template references an undefined var)" + end + + def self.from_string(value : String) + raise InvalidModeError.new "can not be used as input format" + end + + def write(env) + Crinja::Undefined.strict = @@strict + + crinja = Crinja.new + + crinja.filters["split"] = Crinja.filter({on: nil}) { target.to_s.split(arguments["on"].to_s) } + crinja.filters["b64encode"] = Crinja.filter { Base64.strict_encode(target.to_s) } + crinja.filters["b64encode_urlsafe"] = Crinja.filter { Base64.urlsafe_encode(target.to_s) } + crinja.filters["b64decode"] = Crinja.filter { Base64.decode_string(target.to_s) } + + crinja.from_string(@io_in.gets_to_end).render(@io, env.as(Envcat::Env)) + end + end +end diff --git a/src/envcat/format/j2_unsafe.cr b/src/envcat/format/j2_unsafe.cr new file mode 100644 index 0000000..ce14482 --- /dev/null +++ b/src/envcat/format/j2_unsafe.cr @@ -0,0 +1,11 @@ +require "./j2" + +module Envcat + class Format::J2Unsafe < Format::J2 + @@strict = false + + def self.description : String + "Render j2 template from stdin (renders undefined vars as empty string)" + end + end +end diff --git a/src/envcat/format/json.cr b/src/envcat/format/json.cr new file mode 100644 index 0000000..bc70616 --- /dev/null +++ b/src/envcat/format/json.cr @@ -0,0 +1,21 @@ +require "../format" + +require "json" + +module Envcat + class Format::JSON < Format::Formatter + def self.description : String + "JSON format" + end + + def self.from_string(value : String) + raise InvalidModeError.new "can not be used as input format" + end + + def write(env : Env) + return if env.empty? + env.to_json(@io) + @io.puts + end + end +end diff --git a/src/envcat/format/kv.cr b/src/envcat/format/kv.cr new file mode 100644 index 0000000..642a81b --- /dev/null +++ b/src/envcat/format/kv.cr @@ -0,0 +1,25 @@ +require "../format" +require "json" + +module Envcat + class Format::Kv < Format::Formatter + @@prefix : String? + + def self.description : String + "Shell format" + end + + def self.from_string(value : String) + raise InvalidModeError.new "can not be used as input format" + end + + def write(env) + env.each do |k, v| + @io.print @@prefix if @@prefix + @io.print k + @io.print '=' + @io.puts Process.quote_posix(v) + end + end + end +end diff --git a/src/envcat/format/none.cr b/src/envcat/format/none.cr new file mode 100644 index 0000000..beccaa2 --- /dev/null +++ b/src/envcat/format/none.cr @@ -0,0 +1,19 @@ +require "../format" + +require "yaml" + +module Envcat + class Format::None < Format::Formatter + def self.description : String + "No format" + end + + def self.from_string(value : String) + raise InvalidModeError.new "can not be used as input format" + end + + def write(env) + # 🦗 + end + end +end diff --git a/src/envcat/format/yaml.cr b/src/envcat/format/yaml.cr new file mode 100644 index 0000000..b67fb7e --- /dev/null +++ b/src/envcat/format/yaml.cr @@ -0,0 +1,20 @@ +require "../format" + +require "yaml" + +module Envcat + class Format::YAML < Format::Formatter + def self.description : String + "YAML format" + end + + def self.from_string(value : String) + raise InvalidModeError.new "can not be used as input format" + end + + def write(env) + return if env.empty? + env.to_yaml(@io) + end + end +end diff --git a/src/envcat/version.cr b/src/envcat/version.cr new file mode 100644 index 0000000..98a58eb --- /dev/null +++ b/src/envcat/version.cr @@ -0,0 +1,3 @@ +module Envcat + VERSION = {{ `grep "^version" shard.yml | cut -d ' ' -f 2`.chomp.stringify }} +end