├── .eslintrc.js ├── .github └── workflows │ ├── branches.yml │ ├── codeql.yml │ └── master.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg ├── post-merge ├── post-rewrite ├── pre-commit └── pre-push ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin ├── dev ├── run └── run.cmd ├── commitlint.config.js ├── example-with-base-config-containing-secrets ├── base.yml ├── development.yml └── schema.json ├── example-with-base-config ├── base.yml ├── config.d.ts ├── development.yml ├── schema.json └── staging.yml ├── example ├── development.yaml ├── invalid.yml ├── no-secrets.yml ├── pgp │ └── example-keypair.pgp ├── schema.json ├── unencrypted-with-nested-secrets.yml └── unencrypted.yml ├── jest.config.js ├── lint-staged.config.js ├── nodemon.json ├── package.json ├── prettier.config.mjs ├── scriptlint.config.js ├── scripts ├── end-to-end-tests.sh ├── healthcheck.mjs ├── post-merge-or-rebase-githook.sh └── postinstall.mjs ├── src ├── cli │ ├── check-encryption.test.ts │ ├── check-encryption.ts │ ├── decrypt.test.ts │ ├── decrypt.ts │ ├── encrypt.test.ts │ ├── encrypt.ts │ ├── generate-types.test.ts │ ├── generate-types.ts │ ├── validate.test.ts │ └── validate.ts ├── core │ ├── generate-types.test.ts │ ├── get-config.test.ts │ ├── get-schema.test.ts │ ├── index.test.ts │ ├── index.ts │ └── validate.test.ts ├── e2e │ ├── load-commonjs.js │ ├── load-es6.ts │ └── validate.ts ├── fixtures │ └── index.ts ├── global.d.ts ├── integration-tests │ ├── load-commonjs.js │ ├── load-es6.ts │ └── validate.ts ├── match-all.d.ts ├── options.ts ├── types.ts └── utils │ ├── format-ajv-errors.ts │ ├── generate-types-from-schema.test.ts │ ├── generate-types-from-schema.ts │ ├── has-secrets.test.ts │ ├── has-secrets.ts │ ├── hydrate-config.test.ts │ ├── hydrate-config.ts │ ├── load-files.test.ts │ ├── load-files.ts │ ├── pascal-case.test.ts │ ├── pascal-case.ts │ ├── sops.test.ts │ ├── sops.ts │ ├── substitute-with-env.test.ts │ └── substitute-with-env.ts ├── tsconfig.build.json ├── tsconfig.eslint.json ├── tsconfig.json └── yarn.lock /.github/workflows/branches.yml: -------------------------------------------------------------------------------- 1 | name: CI [branches] 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'master' 7 | tags-ignore: 8 | - '**' 9 | 10 | jobs: 11 | lint: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest] 16 | node-version: [16.x, 18.x] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - name: Yarn install with caching 24 | uses: bahmutov/npm-install@v1 25 | 26 | - run: yarn lint 27 | 28 | test: 29 | runs-on: ${{ matrix.os }} 30 | strategy: 31 | matrix: 32 | os: [ubuntu-latest] 33 | node-version: [16.x, 18.x] 34 | steps: 35 | - uses: actions/checkout@v2 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v2 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | - name: Yarn install with caching 41 | uses: bahmutov/npm-install@v1 42 | 43 | - run: yarn test --coverage 44 | 45 | - name: Coveralls GitHub Action 46 | uses: coverallsapp/github-action@v1.1.1 47 | with: 48 | github-token: ${{ secrets.GITHUB_TOKEN }} 49 | 50 | e2e: 51 | runs-on: ${{ matrix.os }} 52 | strategy: 53 | matrix: 54 | os: [ubuntu-latest] 55 | node-version: [16.x, 18.x] 56 | steps: 57 | - uses: actions/checkout@v2 58 | - name: Use Node.js ${{ matrix.node-version }} 59 | uses: actions/setup-node@v2 60 | with: 61 | node-version: ${{ matrix.node-version }} 62 | - name: Yarn install with caching 63 | uses: bahmutov/npm-install@v1 64 | 65 | - run: ./scripts/end-to-end-tests.sh 66 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "15 8 * * 2" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: CI + Release [master] 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags-ignore: 8 | - '**' 9 | paths-ignore: 10 | - 'package.json' 11 | - 'CHANGELOG.md' 12 | - 'oclif.manifest.json' 13 | 14 | env: 15 | GIT_USER_NAME: brickblock-bot 16 | GIT_USER_EMAIL: bot@brickblock.io 17 | 18 | jobs: 19 | lint: 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | matrix: 23 | os: [ubuntu-latest] 24 | node-version: [16.x, 18.x] 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v2 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - name: Yarn install with caching 32 | uses: bahmutov/npm-install@v1 33 | - run: yarn lint 34 | 35 | test: 36 | runs-on: ${{ matrix.os }} 37 | strategy: 38 | matrix: 39 | os: [ubuntu-latest] 40 | node-version: [16.x, 18.x] 41 | steps: 42 | - uses: actions/checkout@v2 43 | - name: Use Node.js ${{ matrix.node-version }} 44 | uses: actions/setup-node@v2 45 | with: 46 | node-version: ${{ matrix.node-version }} 47 | - name: Yarn install with caching 48 | uses: bahmutov/npm-install@v1 49 | 50 | - run: yarn test --coverage 51 | 52 | - name: Coveralls GitHub Action 53 | uses: coverallsapp/github-action@v1.1.1 54 | with: 55 | github-token: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | e2e: 58 | runs-on: ${{ matrix.os }} 59 | strategy: 60 | matrix: 61 | os: [ubuntu-latest] 62 | node-version: [16.x, 18.x] 63 | steps: 64 | - uses: actions/checkout@v2 65 | - name: Use Node.js ${{ matrix.node-version }} 66 | uses: actions/setup-node@v2 67 | with: 68 | node-version: ${{ matrix.node-version }} 69 | - name: Yarn install with caching 70 | uses: bahmutov/npm-install@v1 71 | 72 | - run: ./scripts/end-to-end-tests.sh 73 | 74 | build: 75 | runs-on: ${{ matrix.os }} 76 | strategy: 77 | matrix: 78 | os: [ubuntu-latest] 79 | node-version: [16.x, 18.x] 80 | steps: 81 | - uses: actions/checkout@v2 82 | - name: Use Node.js ${{ matrix.node-version }} 83 | uses: actions/setup-node@v2 84 | with: 85 | node-version: ${{ matrix.node-version }} 86 | - name: Yarn install with caching 87 | uses: bahmutov/npm-install@v1 88 | - run: yarn build 89 | 90 | release: 91 | needs: [lint, test, build] 92 | runs-on: ubuntu-latest 93 | steps: 94 | - uses: actions/checkout@v2 95 | with: 96 | # The default GITHUB_TOKEN that's auto-generated by GitHub for CI pipelines 97 | # does not allow us to push the auto-generated CHANGELOG.md and package.json 98 | # back to the master branch. Hence, using our brickblock-bot user for this. 99 | token: ${{ secrets.BRICKBLOCK_BOT_TOKEN }} 100 | # This is necessary for 'standard-version' to generate the right CHANGELOG 101 | # See: https://github.com/conventional-changelog/conventional-changelog/issues/622 102 | fetch-depth: 0 103 | 104 | - name: Use Node.js 16.x 105 | uses: actions/setup-node@v2 106 | with: 107 | node-version: 16.x 108 | registry-url: https://registry.npmjs.org 109 | scope: '@strong-config' 110 | 111 | - name: Yarn install with caching 112 | uses: bahmutov/npm-install@v1 113 | 114 | - name: Configure git for pushing back auto-generated CHANGELOG 115 | run: | 116 | git config user.name "${GIT_USER_NAME}" 117 | git config user.email "${GIT_USER_EMAIL}" 118 | git config push.followTags true 119 | git config push.default current 120 | 121 | - run: yarn release 122 | - run: git push --no-verify 123 | - name: Post changelog to Slack 124 | run: npx @brickblock/slack-changelog-notifier --projectName $GITHUB_REPOSITORY 125 | env: 126 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 127 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | 3 | # Build output 4 | lib 5 | dist 6 | build 7 | 8 | # Test dir generated by end-to-end tests 9 | blackbox 10 | 11 | # This file gets bundled with the npm package but it interferes with local development so we don't want to have it locally 12 | oclif.manifest.json 13 | 14 | # Default output for generated types when strongConfig.load() is called 15 | strong-config.d.ts 16 | 17 | # Auto-generated example types file 18 | example/config.d.ts 19 | 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # Runtime data 28 | pids 29 | *.pid 30 | *.seed 31 | *.pid.lock 32 | 33 | # Directory for instrumented libs generated by jscoverage/JSCover 34 | lib-cov 35 | 36 | # Coverage directory used by tools like istanbul 37 | coverage 38 | 39 | # Dependencies 40 | node_modules/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # editor configs 64 | .vscode -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # shellcheck source=./_/husky.sh 4 | . "$(dirname "$0")/_/husky.sh" 5 | 6 | npx commitlint --edit "$1" -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | $(pwd)/scripts/post-merge-or-rebase-githook.sh -------------------------------------------------------------------------------- /.husky/post-rewrite: -------------------------------------------------------------------------------- 1 | $(pwd)/scripts/post-merge-or-rebase-githook.sh -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint-staged -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | yarn report:health -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Strong Config 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 💪 Strong Config 4 | 5 | https://strong-config.dev 6 | 7 | ![Continuous Integration](https://github.com/strong-config/node/workflows/Continuous%20Integration%20Checks/badge.svg) 8 | [![Coverage Status](https://coveralls.io/repos/github/strong-config/node/badge.svg?branch=master)](https://coveralls.io/github/strong-config/node?branch=master) 9 | 10 | ## Have you ever... 11 | 12 | ❓ ...struggled with config drift between local, staging, prod? 13 | 14 | ❓ ...forgot to update the production config after updating the development config? 15 | 16 | ❓ ...forgot to tell your teammates to update their local `.env` files after you made a change? 17 | 18 | ❓ ...worried about leaking secrets by accidentally pushing your `.env` files to GitHub? 19 | 20 | ❓ ...wished you could nest config values in your `.env` just like in a JavaScript object? 21 | 22 | ❓...had a CI build fail due to environment variable issues? 23 | 24 | ## Strong Config is here to help! 25 | 26 | ✅ **Commit your configs to version-control** safely and easily, for all your environments 27 | 28 | ✅ **Define your config in JSON or YAML** instead of `.env` files 29 | 30 | ✅ **Nest your values** for clearly structured config files 31 | 32 | ✅ **Validate your config against a [JSON Schema](https://json-schema.org/)** to catch config errors early 33 | 34 | ✅ **Encrypt your secrets with strong cryptography**. Fully encrypted at rest and only decrypted in-memory at runtime. 35 | 36 | ✅ **Safeguard your config through git hooks**. Ensure config is both valid and encrypted before committing and pushing. 37 | 38 | ✅ **Easy integration with the most popular cloud key management services** [AWS KMS](https://aws.amazon.com/kms/), [Google Cloud KMS](https://cloud.google.com/kms/), and [Azure Key Vault](https://azure.microsoft.com/en-us/services/key-vault/). Powered by [Mozilla's SOPS](https://github.com/mozilla/sops). 39 | 40 | ✅ **Enforce environment-specific permissions** via your KMS. Decide who can encrypt and decrypt configs for which environments. For example, you could allow _all_ engineers to decrypt your staging config, but restrict the production config to fewer people. 41 | 42 | ✅ **Auto-generate TypeScript types for your config** (requires a [JSON Schema](https://json-schema.org)) 43 | 44 |
45 | 46 | ## Example config before encryption 47 | 48 | ```yaml 49 | # A top-level config value which will be available to your application as `config.logger` 50 | logger: 51 | # A nested value which will be available as `config.logger.level` 52 | level: DEBUG 53 | 54 | auth: 55 | apiClientId: non-secret-client-id 56 | # A secret. Every key with a 'Secret' suffix will be encrypted by Strong Config (e.g. 'encryptMeSecret') 57 | apiSecret: top-secret-api-credential 58 | 59 | # A dynamic value that will be substituted at runtime with the value of the environment variable $SHELL 60 | shell: ${SHELL} 61 | ``` 62 | 63 | ## Example config after encryption 64 | 65 | ```yaml 66 | logger: 67 | # This value remains as is because it doesn't have a 'Secret' suffix 68 | level: DEBUG 69 | 70 | auth: 71 | apiClientId: non-secret-client-id 72 | # This is now encrypted and safe to commit into version control :) 73 | apiSecret: ENC[AES256_GCM,data:aeQ+hlVIah7WyJoVR/Jbkb6GLH7ihsV0D81+U++pkiWD0zeoRL/Oe9Q3Tz6j/TNvKKVDnohIMyw3UVjELOuSY+A==,iv:nVRZWogV4B7o=,tag:KrE2jssfP4uCvqq+pc/JyQ==,type:str] 74 | 75 | # Also still the same value which will be substituted only at runtime 76 | shell: ${SHELL} 77 | 78 | # The below section is auto-generated by sops and contains important metadata to 79 | # decrypt the config at runtime. Do not manually edit or delete this section. 80 | sops: 81 | gcp_kms: 82 | - resource_id: projects/my-project/locations/europe-west2/keyRings/my-project-key-ring/cryptoKeys/my-strong-config-key 83 | created_at: '2020-01-07T10:11:12Z' 84 | enc: AiAAmdAgj1dw1XdD2MsVpvmA4Deo867hmcX2B3NDhe9BCF2axuZ18hJJFK9oBlE1BrD70djwqi+L8T+NRNVnGUP+1//w8cJATAfJ8W/cQZFcdFTqjezC+VYv9xYI8i1bRna4xfFo/INIJtFDR38ZH1nrQg== 85 | lastmodified: '2020-01-07T10:11:12Z' 86 | mac: ENC[AES256_GCM,data:ABcd1EF2gh3IJKl4MNOpQr5stuvWXYz6sBCDEfGhIjK=,iv:A1AaAAAaa111a1Aa111AA/aaaAaaAAaa+aAaAaAAAaA=,tag:AAaaA1a1aaaAa/aa11AaaA==,type:str] 87 | encrypted_suffix: Secret 88 | version: 3.5.0 89 | ``` 90 | 91 |
92 | 93 | ## Quickstart 94 | 95 | For the full documentation, check https://strong-config.dev. Here's a short teaser: 96 | 97 | 1. **Install `@strong-config/node` and the SOPS binary.** 98 | 99 | ```sh 100 | npm install @strong-config/node 101 | # or 102 | yarn add @strong-config/node 103 | ``` 104 | 105 | **Sidenote: The Sops Binary** 106 | After package installation, Strong Config automatically runs a `postinstall` script that checks for availability of the sops binary on your system. If it can't find the sops binary, it will try to download it to `node_modules/.bin/sops` which is always part of `$PATH` when you `yarn run` or `npm run` scripts. 107 | Alternatively, you can also install sops globally via `brew install sops` (macOS). For other systems check the [official sops releases on GitHub](https://github.com/mozilla/sops/releases). 108 | 109 | 1. **Create a config file** 110 | 111 | ```sh 112 | # By default, strong-config uses the ./config folder. 113 | # You can configure this to be a different folder via the options 114 | mkdir config 115 | 116 | # We'll use YAML here, but this could also JSON 117 | echo "myFirstConfig: strong" > config/development.yml 118 | echo "myFirstSecret: a development secret" >> config/development.yml 119 | ``` 120 | 121 | 1. **Load config in your application code** 122 | 123 | ```js 124 | /* src/config.js */ 125 | 126 | const StrongConfig = require('@strong-config/node') 127 | 128 | // Instantiate StrongConfig, then decrypt and load config file 129 | const config = new StrongConfig().getConfig() 130 | 131 | // This will print "{ myFirstConfig: 'strong' }" to the console 132 | console.log(config) 133 | 134 | /* 135 | * OPTIONAL (but recommended) 136 | * Call `new StrongConfig()` just once in your application, then export the memoized config for other files to use. 137 | * If you call `new StrongConfig()` again from another file, it would still work, but would re-instantiate a new 138 | * StrongConfig instance and load the config file from disk again which is slower than loading it from memory. 139 | */ 140 | module.exports = config 141 | ``` 142 | 143 | 1. **Run your app** 144 | 145 | `strong-config` relies on the `NODE_ENV` environment variable to determine which config file 146 | to load. For example, setting `NODE_ENV=development` will load `./config/development.yaml` 147 | 148 | ```sh 149 | # Set the environment variable 150 | NODE_ENV=development yarn start # or `NODE_ENV=development npm start 151 | ``` 152 | 153 | If you used our example code from the previous step, the config should now be 154 | printed to the terminal 💪. 155 | 156 | 1. **Check [the Strong Config website](https://strong-config.dev) for more documentation** 157 | 158 | Check out the full documentation on https://strong-config.dev to learn how to: 159 | 160 | - Encrypt your config 161 | - Validate your config against a schema 162 | - Generate TypeScript types for your config 163 | 164 | ...and more :) 165 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core') 4 | 5 | const path = require('path') 6 | const project = path.join(__dirname, '..', 'tsconfig.build.json') 7 | 8 | // In dev mode -> use ts-node and dev plugins 9 | process.env.NODE_ENV = 'development' 10 | 11 | require('ts-node').register({ project }) 12 | 13 | // In dev mode, always show stack traces 14 | oclif.settings.debug = true 15 | 16 | // Start the CLI 17 | oclif.run().then(oclif.flush).catch(oclif.Errors.handle) 18 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core') 4 | 5 | oclif 6 | .run() 7 | .then(require('@oclif/core/flush')) 8 | .catch(require('@oclif/core/handle')) 9 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'header-max-length': [1, 'always', 120], 5 | 'scope-case': [0, 'always', 'PascalCase'], 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /example-with-base-config-containing-secrets/base.yml: -------------------------------------------------------------------------------- 1 | name: example-config-extending-from-base-config 2 | someSharedField: i am the same across all environments 3 | someSecret: ❌ I AM NOT ALLOWED IN HERE ❌ 4 | -------------------------------------------------------------------------------- /example-with-base-config-containing-secrets/development.yml: -------------------------------------------------------------------------------- 1 | someDifferentField: i am different in each environment 2 | -------------------------------------------------------------------------------- /example-with-base-config-containing-secrets/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "Example Config", 5 | "required": ["name", "someSharedField", "someDifferentField"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "name": { 9 | "title": "Name", 10 | "type": "string" 11 | }, 12 | "someSharedField": { 13 | "title": "A shared field that's defined in the base config", 14 | "type": "string", 15 | "examples": ["some shared value"] 16 | }, 17 | "someDifferentField": { 18 | "title": "A config-specific field that's different per environment", 19 | "type": "string", 20 | "examples": ["many change", "such different"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example-with-base-config/base.yml: -------------------------------------------------------------------------------- 1 | name: example-config-extending-from-base-config 2 | someSharedField: i am the same across all environments 3 | -------------------------------------------------------------------------------- /example-with-base-config/config.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export type Name = string 9 | export type ASharedFieldThatSDefinedInTheBaseConfig = string 10 | export type AConfigSpecificFieldThatSDifferentPerEnvironment = string 11 | 12 | export interface ExampleConfig { 13 | name: Name 14 | someSharedField: ASharedFieldThatSDefinedInTheBaseConfig 15 | someDifferentField: AConfigSpecificFieldThatSDifferentPerEnvironment 16 | } 17 | 18 | export interface Config extends ExampleConfig { 19 | runtimeEnv: string 20 | } -------------------------------------------------------------------------------- /example-with-base-config/development.yml: -------------------------------------------------------------------------------- 1 | someDifferentField: i am different in each environment 2 | -------------------------------------------------------------------------------- /example-with-base-config/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "Example Config", 5 | "required": ["name", "someSharedField", "someDifferentField"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "name": { 9 | "title": "Name", 10 | "type": "string" 11 | }, 12 | "someSharedField": { 13 | "title": "A shared field that's defined in the base config", 14 | "type": "string", 15 | "examples": ["some shared value"] 16 | }, 17 | "someDifferentField": { 18 | "title": "A config-specific field that's different per environment", 19 | "type": "string", 20 | "examples": ["many change", "such different"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example-with-base-config/staging.yml: -------------------------------------------------------------------------------- 1 | someDifferentField: super different 2 | -------------------------------------------------------------------------------- /example/development.yaml: -------------------------------------------------------------------------------- 1 | name: example-project 2 | someField: 3 | optionalField: 123 4 | requiredField: crucial string 5 | someArray: 6 | - joe 7 | - freeman 8 | someSecret: ENC[AES256_GCM,data:CxljzBH8Xh06,iv:L5VSXtgaD7RXTt7HW0Pra6vDSBTbP8/dkyruXmMKtgU=,tag:tVwtqvz1QLXskFKXT4na7A==,type:str] 9 | sops: 10 | kms: [] 11 | gcp_kms: [] 12 | azure_kv: [] 13 | lastmodified: '2019-11-15T15:12:21Z' 14 | mac: ENC[AES256_GCM,data:XF5VwIKse+f8FsjDRiTkyKUdRpx0xdPtx1Rzb/kU8sp5D9NXDHHs5pUJs61E6SlANCI2L0CW+3riO3dC3nfOzRIf8w/9l0Jm6qVERhS97CPfDz0frlKXLITKxRtl1yBD9yjCRpv6NZT+zFQn9l+afQ/+pCpwyLwLjxWUqrvFhpc=,iv:5QHpdF/LAMlOVbhDGUXvUtB9Z6n1GbaSlUgrvk8ktvU=,tag:BxKcFUAvfcQWQ+TXn6uwWQ==,type:str] 15 | pgp: 16 | - created_at: '2019-11-15T15:12:21Z' 17 | enc: | 18 | -----BEGIN PGP MESSAGE----- 19 | 20 | hQEMA4yR0WhX562aAQf+KMbfjs0Uv5MCeyu8JVhvqooNJ9qLWprLu12qbjp2T0an 21 | lq0PxVonhWr4okVE+vlWUqHF2GoeIv7qiv8B2PfsANLBHli5E1pBlUQC9X1gSZpL 22 | 3JQlS+khetIdzZXXh+QaEgKwsMPlGnB9u39Bo5k5+FydKtuf37ojvKcpLfKWSnPm 23 | b+g0E7U2kKTAzIQSQnNdHqZcblMc9gzfbmhniAvJkBKMAebQq+IodDKJyY+L/7Nx 24 | Jzsa3PXkMnpu95BpWWYuwnK/WSG+Of/mQg263M5B0rpy/raZGAKULQekTKrS+P43 25 | 0ONA+zPD0lHlGBFUIpDmv8sUn2apiqMYy4H3+xvTsdJeAQs5c0LmLujN/QVqjFsd 26 | Lf8jlBPs+ggGzb1h7dURDun2qFsTWAhlVczUBlQdu2wAGutuvv+TOJTnfytBzSJD 27 | 0J+TjFPSdCcn1lMSQ6FT2BEwgpKemLKhyyOVHnKiWQ== 28 | =V/vV 29 | -----END PGP MESSAGE----- 30 | fp: 2E9644A658379349EFB77E895351CE7FC0AC6E94 31 | encrypted_suffix: Secret 32 | version: 3.4.0 33 | -------------------------------------------------------------------------------- /example/invalid.yml: -------------------------------------------------------------------------------- 1 | name: invalid-config-for-schema 2 | someInvalidField: this won't work! 3 | -------------------------------------------------------------------------------- /example/no-secrets.yml: -------------------------------------------------------------------------------- 1 | no: secrets 2 | means: no encryption 3 | is: necessary 4 | -------------------------------------------------------------------------------- /example/pgp/example-keypair.pgp: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mQGNBGMDskQBDADG0+YBt0cL2fnZdhb+TCemn91zWJmaFt7d7ruBtuKuW1uFYy70 4 | niThE7mnhGwjLtMfNi86kQh9c/9Ld5c1IRYirM/gnKUadzb4tubNbB6V9Et1JzUv 5 | UQtK0i/YWZwX1T/t1ean+Wohruf3ZBuS7cZSQWBfBnGyWJw4NAOf9jyHvmfXlqIE 6 | /1txrXCyRaL2Lwof3f+K2dVljMfLNBe5B6FjQno+uFLH0KIjnEXr49f+RYMM8WMw 7 | OCtRpkCg0x9K7Vg74vOpd557rRSE07BAcZ2n+i24FGJ15nZH3L6JrvqiNM/sLZCo 8 | J44/lL0EXib9N47C5HF+eLTD+ldcDe2V+OfbN2KCyTOKfxoIfOqqtY1eBlJcKc+z 9 | y6T9fo5nLuxqD9u5da98W8f+Ma7D0Ljd3rM6Lng1zX9Rx3zw4ldCYYySAeTmiQhV 10 | gx+PEpwkDO6y+cN+h2PEbWIgD25SmxmPlavPxrrYgKpk8oLvN9iE0z3+Q8dz5X5i 11 | 7kNMXraO8UCLIJMAEQEAAbQJVGVzdCBVc2VyiQHUBBMBCAA+FiEE4mP3dDJ2+5wg 12 | kHjfWZcamcNL9sEFAmMDskQCGwMFCQPCZwAFCwkIBwIGFQoJCAsCBBYCAwECHgEC 13 | F4AACgkQWZcamcNL9sFzHAwApx7qdkx/82B9G1OfL7xo7Dt/Iw1DU0rcYH2VtKnZ 14 | iSi1st5+kGG33QoA6fgYOO6typJd+ratDqTif5uapjx6fZ7og/RsNDhSALOXHfuX 15 | 4kfTPVtLubdtVZIsLp8LxDh5q9XYlfh/iFinZimDd7YSXToiIRSPp6nbaVsY+nzd 16 | 3TtDWWxKrBNwnrEcZt/szQ8w7JOMCbsjz5nSV8Kilpgrx3g6DFfXhvfhdWpZ++rz 17 | x4fcVCeYa6ekbS4UORiESu58Iuw62d+GSgCqZ+O+j9nfQFJLc7W6c4/40RsaJx+J 18 | kfQkkhxi5Xc/QIENJtKA5VKa7Y347Eerz7uxI954LB7EAPBfifXppA2G4pkm0lsK 19 | xypGUnIXSBc1Y8oVNu1V82bBLgIjsUpYUy7VD72v+S3N8foCnhbfNdu3fNKJ12+7 20 | fngxozadQLUukzk3eG7GqeuGjVBUbzytErwLo2/f0NSki/pU9ibKgx4Hb3E7izyS 21 | e9PaV+Zo07fDcjAVyD/Duk2quQGNBGMDskQBDACrhJAImhWzntc9+TLswDFAFZN9 22 | MUb0lDKIsRKsxtw54986XAm68vcXVLdMHxBomf3NJhKUtKqip15S/Ox0S6dh2Xz1 23 | ILFtFaWGcMZ33QSuvMfRjvoMjxpYUvoTf4ZuFD46I7bdAKQBZgBAk8eB7ZZDu+Gi 24 | a6HiTPCao2+naGxdmxu/AiRFPCCXSNpcOua8bPavtUvm0Uqjg9mBm3s5PeqbBZ/o 25 | YYCsTo/AHMfPhFVQ15SAffadlNQonsVHbHaaSz4EroP4GbQi5qeJiFzb+3xtXdcV 26 | Te0c8eZtxpyNEyNBiRNPL9XOYjuY8KrvdiDgH3pE2pYSActYKLDSyvJVrxew2ZIt 27 | D1cWCIyZkqPmZ0ENYfNMrvI7KKxsrfQ/jzZt4BY0iFfgsK3plwRJOZIkwhjdBeiI 28 | j23pZpWRIvituc0v0MyJnWgFVh/Rd8HJxfQy6YUdgixSztx+ZLUY4rAOhKnSCIB6 29 | J60L3v3GEArh4XdR0TJIQ57t7EEL5WUxnDgfwK0AEQEAAYkBtgQYAQgAIBYhBOJj 30 | 93QydvucIJB431mXGpnDS/bBBQJjA7JEAhsMAAoJEFmXGpnDS/bBnsQL/A1SOGXU 31 | yOl0GRP7hoxzequFZzNw2Tow1XrbKQpgkEyL6iAXYjgb6n0gUdgDZm46W6pQ9F7Q 32 | StmPWQZcm3W3mS6WeCLEGX1/rKvZ2sCeXR/RUdhngYrJ3wCT4IX3LR3kxBX+H8t6 33 | H564rfH8FqZT55IsKawuCbzB+kekxsfM6AwF5/Ofw8e505pfFOw4yVpnnP5W6qTW 34 | CUjvb5s5uxEJ3Wgxb7ttKKYVroic0AVC/3g/wU07cF1wrNPdMsAITgpNkRlt7+gg 35 | aMdfQFtncj6a1YlMrq1Orq4qsCc+ZTCgtXMXF3szVCjTRdunNxuq0VdW4OWMFX8U 36 | PJyQKQZP/LS2DopgNEnUbC9LXiMhThYujqMzrIxtqD2Am6gqwKREZcQxIBIwAZmw 37 | 7tBOl15tYebdQhNdrt2WOaJcd8Ni83Rtrgf4tH4vESX1ruKQ+h5NZ5skz+soEbeE 38 | D9MtkWfl/+nws0hYWq80b4Q45BYSwo1o3lqCKZmF3ToUHNVY7HRihpY+hg== 39 | =qNWZ 40 | -----END PGP PUBLIC KEY BLOCK----- 41 | -----BEGIN PGP PRIVATE KEY BLOCK----- 42 | 43 | lQVYBGMDskQBDADG0+YBt0cL2fnZdhb+TCemn91zWJmaFt7d7ruBtuKuW1uFYy70 44 | niThE7mnhGwjLtMfNi86kQh9c/9Ld5c1IRYirM/gnKUadzb4tubNbB6V9Et1JzUv 45 | UQtK0i/YWZwX1T/t1ean+Wohruf3ZBuS7cZSQWBfBnGyWJw4NAOf9jyHvmfXlqIE 46 | /1txrXCyRaL2Lwof3f+K2dVljMfLNBe5B6FjQno+uFLH0KIjnEXr49f+RYMM8WMw 47 | OCtRpkCg0x9K7Vg74vOpd557rRSE07BAcZ2n+i24FGJ15nZH3L6JrvqiNM/sLZCo 48 | J44/lL0EXib9N47C5HF+eLTD+ldcDe2V+OfbN2KCyTOKfxoIfOqqtY1eBlJcKc+z 49 | y6T9fo5nLuxqD9u5da98W8f+Ma7D0Ljd3rM6Lng1zX9Rx3zw4ldCYYySAeTmiQhV 50 | gx+PEpwkDO6y+cN+h2PEbWIgD25SmxmPlavPxrrYgKpk8oLvN9iE0z3+Q8dz5X5i 51 | 7kNMXraO8UCLIJMAEQEAAQAL/Ah2fXxGNk5xVTql1Z22VRu5Az6FH2iZH5xXrIB9 52 | bdGZDuCzE40S7CPuaIESWF35AMB72G/IO5HHba7jJLr8sQoBzAlV8YsaVusoMdO9 53 | jeG5F7shU5izfOUO5D1ztvqmt4VijOJKcfOEE9iKWMgcucvHf5gb2JwMPH4B7MOS 54 | wgnPF3FsNnI7AkPo63qTDzgmUWqA0v8wfW5Imzpxea8E/aARdM2Vn+RkY3pbjPhY 55 | 5tkqUUUsQxoK0gE0L90Ij3TrfDo6dwh+m8zf6O1iOAM7ULIgmMY5MI4h3JZ0mXJ5 56 | EjwUo6Erw4AseNIXq7L/8fCMm8POlzfcb3THhOvgAe4OY8/vliEwZ+wK3umH9MZK 57 | aObA6jMDKS1j9OG9gpgHCVbmUWW2mldPl2cJM87ZBCKkTaeRd+oxvOYYtlGa5Nfi 58 | 3Vm7QXozSJaD3VUnJOrChFZa8IUvBmFDvDDFbzbyNE1JcTnMbES/JIs4gOj9e3ia 59 | xdfjICjoyv2MIQRZkCsABFntHQYA14UmC1JoymMoc6TioAxBWVgT9SMBgsu5Zzdc 60 | 20pc6jfxTDz9J5wqydpScGMoHWMtdE79dyj5LV0FrdXCkhH3GA+5aNqihYr11o5n 61 | BvLllPtx+JM2XnOK4RYOWEMDg/aaT92zKsNAjl0ugHiSyzKoFtqUXmLmoZmpuekW 62 | w8rwc4fhIdBqOn4VzqXlW5K9DzFAC7PYuqmLfjgQQ2u2VwP7t07Kahar0abvfbz8 63 | LqtOVgYHnaTlATqyFmikl4r11LE/BgDsLCFRdMumk/R7t7EW+2pmSKGaA/umHWEH 64 | odt0/M1VtfS2qc2813f3FSccBTAcZSsrN/r8GeEDC9dD8/+xUr1W9tTaAkmUOts+ 65 | p3/99L7lpYSTvGRUdcP21lJOXtJIX280RhLPmx//Tp5zogb+TduFDeo0af3KgfFJ 66 | fgGDP+imD6GHn1qIjgXZhtEhdP/04ore7jJ/LXkRvyQGJyaymrnwZWUa0TZLP9Ep 67 | OAIAU3yyegora/iQilr5nQDMkhTBZ60F/i1IxYtskdqI+2kMvJ7aOYc9qaNAEooW 68 | +/epf736rU+vx/90VEKRx37jdgV71ZU0tdydLixmo5zcry7Rrr6PoVZgYTmvgp36 69 | qfAvOrPFVqobM68nWG7vx9wOabgD+/uFZjYEaBf1zoQNn0vkoTZodjf1j1N4hMZL 70 | 3sXh3l+FP5jRTLNEN9HTfMTjC/NTBYPjkWidYP9eNC/lcI8/DUknUF1yn0jnfC04 71 | Spk8yDfpq5BQgcb6g0yjCWVNWHMBCLg+/uGbtAlUZXN0IFVzZXKJAdQEEwEIAD4W 72 | IQTiY/d0Mnb7nCCQeN9ZlxqZw0v2wQUCYwOyRAIbAwUJA8JnAAULCQgHAgYVCgkI 73 | CwIEFgIDAQIeAQIXgAAKCRBZlxqZw0v2wXMcDACnHup2TH/zYH0bU58vvGjsO38j 74 | DUNTStxgfZW0qdmJKLWy3n6QYbfdCgDp+Bg47q3Kkl36tq0OpOJ/m5qmPHp9nuiD 75 | 9Gw0OFIAs5cd+5fiR9M9W0u5t21VkiwunwvEOHmr1diV+H+IWKdmKYN3thJdOiIh 76 | FI+nqdtpWxj6fN3dO0NZbEqsE3CesRxm3+zNDzDsk4wJuyPPmdJXwqKWmCvHeDoM 77 | V9eG9+F1aln76vPHh9xUJ5hrp6RtLhQ5GIRK7nwi7DrZ34ZKAKpn476P2d9AUktz 78 | tbpzj/jRGxonH4mR9CSSHGLldz9AgQ0m0oDlUprtjfjsR6vPu7Ej3ngsHsQA8F+J 79 | 9emkDYbimSbSWwrHKkZSchdIFzVjyhU27VXzZsEuAiOxSlhTLtUPva/5Lc3x+gKe 80 | Ft8127d80onXb7t+eDGjNp1AtS6TOTd4bsap64aNUFRvPK0SvAujb9/Q1KSL+lT2 81 | JsqDHgdvcTuLPJJ709pX5mjTt8NyMBXIP8O6TaqdBVgEYwOyRAEMAKuEkAiaFbOe 82 | 1z35MuzAMUAVk30xRvSUMoixEqzG3Dnj3zpcCbry9xdUt0wfEGiZ/c0mEpS0qqKn 83 | XlL87HRLp2HZfPUgsW0VpYZwxnfdBK68x9GO+gyPGlhS+hN/hm4UPjojtt0ApAFm 84 | AECTx4HtlkO74aJroeJM8Jqjb6dobF2bG78CJEU8IJdI2lw65rxs9q+1S+bRSqOD 85 | 2YGbezk96psFn+hhgKxOj8Acx8+EVVDXlIB99p2U1CiexUdsdppLPgSug/gZtCLm 86 | p4mIXNv7fG1d1xVN7Rzx5m3GnI0TI0GJE08v1c5iO5jwqu92IOAfekTalhIBy1go 87 | sNLK8lWvF7DZki0PVxYIjJmSo+ZnQQ1h80yu8jsorGyt9D+PNm3gFjSIV+CwremX 88 | BEk5kiTCGN0F6IiPbelmlZEi+K25zS/QzImdaAVWH9F3wcnF9DLphR2CLFLO3H5k 89 | tRjisA6EqdIIgHonrQve/cYQCuHhd1HRMkhDnu3sQQvlZTGcOB/ArQARAQABAAv8 90 | Cuxj4fBc7nYbc4p3PaXAh+TNN7LCN+8SSCtYKvcf7R3qncRhKENeDOHWf1DBJ5rw 91 | aOGCWxnQfjsWN5x9EKuwICa3ncL0byPq8/ojDFzf9fhMP408C+hh6fsgNcqwNzrZ 92 | 0F5tldCly/hWOXFP9Pc6foYs3hO9w8muTyLr3ae5sxJ0UhYNqk0khDizE3RQNUg5 93 | Q8J88C58cu0sQTleP9cw0FOri04JDFrqg8vcG9wWijyt7ABZtaUrXTTouojusBqX 94 | i0rUox4m38z4D/A92Q5DN5lFIiTe4xKUzGCgiSDPaWPLei8AD0818jk/W7o5TjsC 95 | kKCq8bkf2BvB1UPTLB0LPt0ZJg/ANcreKMkr2bnaw9OEmXfltbhcRKPGjeIpNjQW 96 | 61A0m+WmGoiuEu/7gz8PAIU8rETN+UzZR10WOI8IaEGB9aZzayOFQjcrlyubb9Ab 97 | MDh/s917+jQUKcNLtSKI0gYR4Djt/eSCMzYmpFpIrWeLr/WfD/+1EUqjWLphS5H/ 98 | BgDCkByCp/eL6fv2p4gBW7yxhF7Rc7eDWJAwTNlRRN74h0D8Ta/sQWwMbXJ85ah2 99 | ewh4a5LyxauM9EfI7fewA60OVEkbB2cUWcMCg2pgS3XYfsTUdwluQevcqpBHRKGl 100 | c1doRcfQ6QdEZCytSygH818ruUKaAVl1SM05BdTqr4Ua1xORUSFww/gyz2Maq3xM 101 | jh0AaHc0qz4hf7mqPTTGn/CsbaCl7GGZTey5oA1/dMyXTBu5NooegIrNGTtyCgzm 102 | M5sGAOGtjY+V8emvjOwhyZ8sg+5Rf1WBmXyZlHTQbGOE4KfMEe3Q05WGdP4U/LWR 103 | Nu4/p2BEJStavHf879R42BliYtIDa0kIpYs4eeWb02Cmuio6gYcPwUiWm/IiszmJ 104 | vVqRAcVaK8s7pQoWMUCkQ58OpCDvGeeiw/IT30psjW6rUUER6n+BUMwaUts4O0sl 105 | jZjBjIv4tDn717WXR47cGgyo/xMro+CH6KikquHpPzI4WMOQMbrLWYxyhrNu0u1k 106 | 9sGVVwX/dV1tKS6GoCGCFUXXC/Z9cBGOXE0+agzABxCWt2Z4/B5Q01S30VTxolUR 107 | P+Ra/SyAQqEt/Gg8xz34qs1Y61RRyww4Tewuj4dO+CO/WkC4VK97pI7I+v50+fyW 108 | 4LbpInHLwm+8DKEQG/4HD5nI8jy884z5ukyIMa13b9H2ERtrKw6RHL4usJxaMZw4 109 | NMppzL5swskHZxN5fxJBYMQ1hFEOLF5Tdal3gs7uyVX4SsgSvaAZTCi0osESKgYp 110 | kAxeDRvW2rGJAbYEGAEIACAWIQTiY/d0Mnb7nCCQeN9ZlxqZw0v2wQUCYwOyRAIb 111 | DAAKCRBZlxqZw0v2wZ7EC/wNUjhl1MjpdBkT+4aMc3qrhWczcNk6MNV62ykKYJBM 112 | i+ogF2I4G+p9IFHYA2ZuOluqUPRe0ErZj1kGXJt1t5kulngixBl9f6yr2drAnl0f 113 | 0VHYZ4GKyd8Ak+CF9y0d5MQV/h/Leh+euK3x/BamU+eSLCmsLgm8wfpHpMbHzOgM 114 | Befzn8PHudOaXxTsOMlaZ5z+Vuqk1glI72+bObsRCd1oMW+7bSimFa6InNAFQv94 115 | P8FNO3BdcKzT3TLACE4KTZEZbe/oIGjHX0BbZ3I+mtWJTK6tTq6uKrAnPmUwoLVz 116 | Fxd7M1Qo00XbpzcbqtFXVuDljBV/FDyckCkGT/y0tg6KYDRJ1GwvS14jIU4WLo6j 117 | M6yMbag9gJuoKsCkRGXEMSASMAGZsO7QTpdebWHm3UITXa7dljmiXHfDYvN0ba4H 118 | +LR+LxEl9a7ikPoeTWebJM/rKBG3hA/TLZFn5f/p8LNIWFqvNG+EOOQWEsKNaN5a 119 | gimZhd06FBzVWOx0YoaWPoY= 120 | =kgLB 121 | -----END PGP PRIVATE KEY BLOCK----- -------------------------------------------------------------------------------- /example/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "Example Config", 5 | "required": ["name", "someField"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "name": { 9 | "title": "Name", 10 | "type": "string" 11 | }, 12 | "someField": { 13 | "title": "Some Field", 14 | "type": "object", 15 | "required": ["requiredField"], 16 | "additionalProperties": false, 17 | "properties": { 18 | "requiredField": { 19 | "title": "A required field", 20 | "type": "string", 21 | "examples": ["crucial string", "much needed string"] 22 | }, 23 | "optionalField": { 24 | "title": "An optional field", 25 | "type": "integer", 26 | "examples": [123, 234, 345] 27 | } 28 | } 29 | }, 30 | "someArray": { 31 | "title": "Some Array", 32 | "type": "array", 33 | "items": { 34 | "type": "string" 35 | } 36 | }, 37 | "someSecret": { 38 | "title": "Some Secret", 39 | "type": "string", 40 | "examples": ["cantSeeMe", "myLittleSecret"] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /example/unencrypted-with-nested-secrets.yml: -------------------------------------------------------------------------------- 1 | application: 2 | url: https://myhost.com:8443/myapp/ 3 | username: myname 4 | very: 5 | deeply: 6 | nestedSecret: 123 7 | -------------------------------------------------------------------------------- /example/unencrypted.yml: -------------------------------------------------------------------------------- 1 | name: example-project 2 | someField: 3 | optionalField: 123 4 | requiredField: crucial string 5 | someArray: 6 | - joe 7 | - freeman 8 | someSecret: cantSeeMe 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | collectCoverageFrom: [ 4 | 'src/**/*.ts', 5 | '!src/cli/index.ts', 6 | '!src/integration-tests/**/*', 7 | '!src/e2e/**/*', 8 | ], 9 | coverageThreshold: { 10 | global: { 11 | branches: 95, 12 | functions: 100, 13 | lines: 95, 14 | statements: 95, 15 | }, 16 | }, 17 | preset: 'ts-jest', 18 | setupFilesAfterEnv: ['jest-extended/all'], 19 | testEnvironment: 'node', 20 | transform: { 21 | '^.+\\.ts$': [ 22 | 'ts-jest', 23 | { 24 | // Vastly improves jest performance as it skips type-checking 25 | // https://kulshekhar.github.io/ts-jest/docs/getting-started/options/isolatedModules/ 26 | isolatedModules: true, 27 | }, 28 | ], 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.ts': ['eslint --fix'], 3 | '*.json': ['eslint --fix'], 4 | 'package.json': ['npx scriptlint'], 5 | '*.{yaml,yml}': ['eslint --fix'], 6 | 'example/*[^invalid].{yaml,yml}': [ 7 | 'strong-config validate --config-root example', 8 | ], 9 | '*.md': ['markdownlint --ignore CHANGELOG.md'], 10 | 'example/development.yaml': ['strong-config check example/development.yaml'], 11 | } 12 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["**/*.test.ts"], 3 | "watch": ["src", "example"], 4 | "ext": "ts,js,json,yaml,yml" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@strong-config/node", 3 | "version": "2.0.1", 4 | "description": "Simple & Secure Config Management for Node.js", 5 | "repository": "git@github.com:strong-config/node.git", 6 | "homepage": "https://strong-config.dev", 7 | "bugs": "https://github.com/strong-config/node/issues", 8 | "author": "Brickblock Engineering ", 9 | "license": "MIT", 10 | "main": "lib/core/index.js", 11 | "types": "lib/core/index.d.ts", 12 | "files": [ 13 | "/bin", 14 | "/lib", 15 | "/scripts/postinstall.mjs", 16 | "oclif.manifest.json" 17 | ], 18 | "bin": { 19 | "strong-config": "./bin/run" 20 | }, 21 | "engines": { 22 | "node": ">= 16" 23 | }, 24 | "oclif": { 25 | "commands": "./lib/cli", 26 | "bin": "strong-config", 27 | "plugins": [ 28 | "@oclif/plugin-autocomplete", 29 | "@oclif/plugin-help", 30 | "@oclif/plugin-not-found", 31 | "@oclif/plugin-warn-if-update-available" 32 | ], 33 | "warn-if-update-available": { 34 | "timeoutInDays": 7, 35 | "message": "<%= config.name %> update available from <%= chalk.greenBright(config.version) %> to <%= chalk.greenBright(latest) %>." 36 | } 37 | }, 38 | "scripts": { 39 | "build": "tsc -p tsconfig.build.json", 40 | "build:clean": "rimraf lib", 41 | "dev": "tsc --watch -p tsconfig.build.json", 42 | "dev:importkey": "gpg --import --quiet example/pgp/example-keypair.pgp", 43 | "format": "prettier --write src/*.ts", 44 | "lint": "yarn lint:ts && yarn lint:scripts && yarn lint:json && yarn lint:deps && yarn lint:yaml && yarn lint:markdown", 45 | "lint:deps": "npx npm-check -i '\"{leasot,lint-staged,@commitlint/*,@oclif/plugin-autocomplete,@oclif/plugin-help,@oclif/plugin-not-found,@oclif/plugin-warn-if-update-available,@strong-config/node,execa,ora,tslib,chalk}\"'", 46 | "lint:json": "eslint . --fix --ext .json", 47 | "lint:markdown": "markdownlint **/*.md --ignore node_modules --ignore CHANGELOG.md --ignore blackbox", 48 | "lint:scripts": "npx scriptlint --fix", 49 | "lint:ts": "eslint . --fix --ext .ts", 50 | "lint:yaml": "eslint . --ext .yml,.yaml", 51 | "postinstall": "node scripts/postinstall.mjs; [ -d \".husky\" ] && husky install || true", 52 | "postpack": "rimraf oclif.manifest.json", 53 | "prepack": "yarn build:clean && yarn build && npx oclif manifest", 54 | "release": "npx standard-version", 55 | "report:health": "node scripts/healthcheck.mjs", 56 | "report:todo": "leasot '**/*.ts' --ignore 'node_modules/**/*','lib/**/*','.git/**/*','blackbox' || true", 57 | "start": "yarn dev", 58 | "test": "jest", 59 | "test:cli": "yarn test:cli:check && yarn test:cli:decrypt && yarn test:cli:encrypt && yarn test:cli:validate && yarn test:cli:generateTypes", 60 | "test:cli:check": "./bin/run check example/development.yaml --config-root example", 61 | "test:cli:decrypt": "yarn dev:importkey && ./bin/run decrypt example/development.yaml tmp.yml && cat tmp.yml && rimraf tmp.yml", 62 | "test:cli:encrypt": "yarn dev:importkey && ./bin/run encrypt -p pgp -k E263F7743276FB9C209078DF59971A99C34BF6C1 example/unencrypted.yml tmp.yml && cat tmp.yml && rimraf tmp.yml", 63 | "test:cli:generateTypes": "rimraf example/config.d.ts && ./bin/run generate-types --config-root example && cat example/config.d.ts", 64 | "test:cli:validate": "./bin/run validate example/development.yaml --config-root example", 65 | "test:core": "yarn test:core:load:es6 && yarn test:core:load:commonjs && yarn test:core:validate", 66 | "test:core:load": "yarn test:core:load:es6", 67 | "test:core:load:commonjs": "yarn build && yarn dev:importkey && cross-env NODE_ENV=development time node src/integration-tests/load-commonjs.js", 68 | "test:core:load:es6": "yarn dev:importkey && cross-env NODE_ENV=development time ts-node --transpile-only src/integration-tests/load-es6.ts", 69 | "test:core:validate": "cross-env NODE_ENV=development time ts-node --transpile-only src/integration-tests/validate.ts", 70 | "test:watch": "jest --watch" 71 | }, 72 | "dependencies": { 73 | "@oclif/core": "^2.11.8", 74 | "@oclif/plugin-autocomplete": "^2.3.6", 75 | "@oclif/plugin-help": "^5.2.17", 76 | "@oclif/plugin-not-found": "^2.3.1", 77 | "@oclif/plugin-warn-if-update-available": "^2.0.4", 78 | "ajv": "^8.12.0", 79 | "debug": "^4.3.1", 80 | "execa": "^5", 81 | "fast-glob": "^3.2.5", 82 | "fs-extra": "^11.1.1", 83 | "js-yaml": "^4.0.0", 84 | "json-schema-to-typescript": "^13.0.2", 85 | "match-all": "^1.2.6", 86 | "node-fetch": "^3.1.1", 87 | "ora": "^5", 88 | "ramda": "^0.29.0", 89 | "tslib": "^2.6.2", 90 | "which": "^3.0.1" 91 | }, 92 | "devDependencies": { 93 | "@commitlint/config-conventional": "^17.0.3", 94 | "@types/debug": "^4.1.5", 95 | "@types/fs-extra": "^11.0.1", 96 | "@types/jest": "^29.5.4", 97 | "@types/js-yaml": "^4.0.0", 98 | "@types/lodash": "^4.14.168", 99 | "@types/node": "^20.5.3", 100 | "@types/node-fetch": "^2.5.8", 101 | "@types/ramda": "^0.29.3", 102 | "@types/shelljs": "^0.8.8", 103 | "@types/std-mocks": "^1.0.0", 104 | "@types/which": "^3.0.0", 105 | "@typescript-eslint/eslint-plugin": "^6.4.1", 106 | "@typescript-eslint/parser": "^6.4.1", 107 | "chalk": "^4.1.2", 108 | "cross-env": "^7.0.2", 109 | "eslint": "^8.22.0", 110 | "eslint-config-prettier": "^9.0.0", 111 | "eslint-plugin-import": "^2.24.2", 112 | "eslint-plugin-jest": "^27.2.3", 113 | "eslint-plugin-jest-formatting": "^3.0.0", 114 | "eslint-plugin-json": "^3.1.0", 115 | "eslint-plugin-prettier": "^5.0.0", 116 | "eslint-plugin-promise": "^6.0.0", 117 | "eslint-plugin-security": "^1.4.0", 118 | "eslint-plugin-sonarjs": "^0.21.0", 119 | "eslint-plugin-tsdoc": "^0.2.11", 120 | "eslint-plugin-unicorn": "^48.0.1", 121 | "eslint-plugin-yaml": "^0.5.0", 122 | "husky": "^8.0.1", 123 | "jest": "^29.6.3", 124 | "jest-extended": "^4.0.1", 125 | "json-schema": "^0.4.0", 126 | "leasot": "^13.2.0", 127 | "lint-staged": "^14.0.1", 128 | "markdownlint-cli": "^0.35.0", 129 | "prettier": "^3.0.2", 130 | "rimraf": "^5.0.1", 131 | "shelljs": "^0.8.4", 132 | "stdout-stderr": "^0.1.13", 133 | "ts-jest": "^29.1.1", 134 | "ts-node": "^10.9.1", 135 | "typescript": "^5.1.6" 136 | }, 137 | "volta": { 138 | "node": "16.20.2" 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | proseWrap: "always", 3 | semi: false, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | endOfLine: 'lf', 7 | } 8 | 9 | -------------------------------------------------------------------------------- /scriptlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | strict: true, 3 | rules: { 4 | 'no-unix-double-ampersand': false, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /scripts/end-to-end-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # flags inspired by https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/ 3 | set -euxo pipefail 4 | 5 | # Clean up leftovers from previous e2e test runs 6 | rm -rf blackbox 7 | rm -f strong-config-node-*.tgz 8 | 9 | # Create tarball with strong-config package 10 | yarn pack --prod 11 | 12 | # Create empty dir to bootstrap test project 13 | mkdir -p blackbox 14 | cd blackbox 15 | 16 | # Create package.json for test project 17 | yarn init --yes 18 | 19 | # Clear yarn cache because it seems to sometimes cache a previous tarball even when we specify a specific file to install from 20 | yarn cache clean 21 | 22 | # Install strong-config from tarball, ts-node & typescript for transpiling test files 23 | yarn add file:$(ls ../strong-config*.tgz) ts-node typescript 24 | 25 | # Copy in example configs to test against 26 | cp -r ../example config 27 | cp -r ../example-with-base-config config-with-base-config 28 | cp -r ../example-with-base-config config-with-base-config-containing-secrets 29 | 30 | # Copy in e2e test files 31 | cp -r ../src/e2e e2e 32 | 33 | # Import GPG key into local keychain 34 | echo "Importing dummy GPG Key for encryption tests" 35 | gpg --import --quiet config/pgp/example-keypair.pgp 36 | 37 | # Run CLI commands 38 | echo "CLI Tests" 39 | 40 | # Decrypt with local GPG key 41 | yarn strong-config decrypt config/development.yaml tmp.yml && cat tmp.yml && rm tmp.yml 42 | 43 | # Encrypt with local GPG key 44 | yarn strong-config encrypt -p pgp -k E263F7743276FB9C209078DF59971A99C34BF6C1 config/unencrypted.yml tmp.yml && cat tmp.yml && rm tmp.yml 45 | 46 | # Check single config 47 | yarn strong-config check config/development.yaml 48 | 49 | # Check multiple configs 50 | yarn strong-config check config/development.yaml config/no-secrets.yml 51 | 52 | # Validate single config 53 | yarn strong-config validate config/development.yaml config/unencrypted.yml 54 | 55 | # Validate single config with base config 56 | yarn strong-config validate config-with-base-config/development.yml --config-root config-with-base-config 57 | 58 | # Validate multiple configs 59 | yarn strong-config validate config/development.yaml 60 | 61 | # Validate multiple configs with base config 62 | yarn strong-config validate config-with-base-config/development.yml config-with-base-config/staging.yml --config-root config-with-base-config 63 | 64 | # Generate TypeScript Definitions 65 | yarn strong-config generate-types && cat config/config.d.ts 66 | 67 | # Generate TypeScript Definitions into a different folder 68 | mkdir @types 69 | yarn strong-config generate-types --types-path @types && cat @types/config.d.ts 70 | 71 | # Test core strong-config package 72 | echo "Core Tests" 73 | NODE_ENV=development yarn ts-node --transpile-only e2e/load-es6.ts 74 | NODE_ENV=development yarn ts-node --transpile-only e2e/validate.ts 75 | 76 | # The PATH-assignment is necessary so the 'sops' binary is available which sits in node_modules/.bin 77 | # This hack is not necessary for the previous commands because all 'yarn xxx' commands add ./node_modules/.bin 78 | # to the $PATH automatically already 79 | NODE_ENV=development PATH="$(yarn bin):$PATH" node e2e/load-commonjs.js -------------------------------------------------------------------------------- /scripts/healthcheck.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment */ 3 | import fs from 'node:fs' 4 | import path from 'path' 5 | import chalk from 'chalk' 6 | import ora from 'ora' 7 | import shelljs from 'shelljs' 8 | 9 | async function run({ command, options = { silent: true } }) { 10 | return new Promise((resolve, reject) => { 11 | shelljs.exec(command, options, function (exitCode, stdout, stderr) { 12 | exitCode === 0 ? resolve(stdout.trim()) : reject(stderr) 13 | }) 14 | }) 15 | } 16 | 17 | async function runLinters() { 18 | const spinner = ora('Running linters...').start() 19 | 20 | try { 21 | await run({ command: 'yarn lint' }) 22 | spinner.succeed(chalk.bold('Linters')) 23 | } catch (error) { 24 | spinner.fail(chalk.bold('Linters')) 25 | 26 | // Need to re-run this to show the user what the error was 27 | await run({ command: 'yarn lint', options: { silent: false } }) 28 | throw error 29 | } 30 | } 31 | 32 | async function runUnitTests() { 33 | const spinner = ora('Running unit tests...').start() 34 | 35 | try { 36 | await run({ command: 'yarn test --coverage' }) 37 | spinner.succeed(chalk.bold('Tests')) 38 | } catch (error) { 39 | spinner.fail(chalk.bold('Tests')) 40 | throw error 41 | } 42 | } 43 | 44 | async function runBuild() { 45 | const spinner = ora().start() 46 | 47 | try { 48 | spinner.text = 'Cleaning previous build files...' 49 | await run({ command: 'yarn build:clean' }) 50 | 51 | spinner.text = 'Building...' 52 | await run({ command: 'yarn build' }) 53 | 54 | spinner.text = 'Checking generated TypeScript declarations...' 55 | 56 | if (!fs.existsSync(path.resolve('./lib/core/index.d.ts'))) { 57 | throw new Error( 58 | "Couldn't find TypeScript declaration files in build output.\nMake sure that `declaration: true` is set in `tsconfig.json`" 59 | ) 60 | } 61 | 62 | spinner.text = 'Checking generated sourcemaps...' 63 | 64 | if (!fs.existsSync(path.resolve('./lib/core/index.d.ts.map'))) { 65 | throw new Error( 66 | "Couldn't find sourcemaps for TypeScript declaration files in build output.\nMake sure that `declarationMap: true` is set in `tsconfig.json`" 67 | ) 68 | } 69 | 70 | spinner.text = 'Checking that tests are excluded from build output...' 71 | 72 | if (fs.existsSync(path.resolve('./lib/core/index.test.ts'))) { 73 | throw new Error( 74 | "Detected test files in build files. Make sure to exclude `src/**/*.test.ts` from the build output via TypeScript's `exclude` option" 75 | ) 76 | } 77 | 78 | spinner.succeed(chalk.bold('Build')) 79 | } catch (error) { 80 | spinner.fail(chalk.bold('Build')) 81 | throw error 82 | } 83 | } 84 | 85 | async function runIntegrationTests() { 86 | const spinner = ora('Running Integration Tests...').start() 87 | 88 | try { 89 | spinner.text = 'Running Integration Tests: Core Library' 90 | await run({ command: 'yarn test:core' }) 91 | 92 | spinner.text = 'Running Integration Tests: CLI' 93 | await run({ command: 'yarn test:cli' }) 94 | 95 | spinner.succeed(chalk.bold('Integration Tests')) 96 | } catch (error) { 97 | spinner.fail(chalk.bold('Integration Tests')) 98 | throw error 99 | } 100 | } 101 | 102 | async function runEndToEndTests() { 103 | const spinner = ora('Running End-to-End Tests...').start() 104 | 105 | try { 106 | await run({ command: './scripts/end-to-end-tests.sh' }) 107 | 108 | spinner.succeed(chalk.bold('End-to-End Tests')) 109 | } catch (error) { 110 | spinner.fail(chalk.bold('End-to-End Tests')) 111 | throw error 112 | } 113 | } 114 | 115 | async function runReleaseScripts() { 116 | const spinner = ora('Running Release Scripts...').start() 117 | 118 | try { 119 | spinner.text = 'Running Release Scripts: yarn release --dry-run' 120 | await run({ command: 'yarn release --dry-run' }) 121 | 122 | spinner.text = 'Running Release Scripts: yarn prepack' 123 | await run({ command: 'yarn prepack' }) 124 | 125 | spinner.text = 126 | 'Removing generated manifest again to not interfere with local development' 127 | await run({ command: 'rimraf oclif.manifest.json' }) 128 | 129 | spinner.succeed(chalk.bold('Release Scripts')) 130 | } catch (error) { 131 | spinner.fail(chalk.bold('Release Scripts')) 132 | throw error 133 | } 134 | } 135 | 136 | async function printTodos() { 137 | const spinner = ora('️Searching for open TODOs and FIXMEs...').start() 138 | 139 | let todos 140 | 141 | try { 142 | todos = await run({ command: 'yarn report:todo' }) 143 | } catch (error) { 144 | spinner.fail(chalk.bold('Todos')) 145 | throw error 146 | } 147 | 148 | if (/No todos\/fixmes found/g.test(todos)) { 149 | spinner.succeed(chalk.bold('No TODOs or FIXMEs')) 150 | } else { 151 | spinner.info(`${chalk.bold('Todos:')}\n\n${todos}`) 152 | } 153 | } 154 | 155 | async function main() { 156 | ora('Checking overall project health...\n').info() 157 | 158 | await runLinters() 159 | await runUnitTests() 160 | await runBuild() 161 | await runIntegrationTests() 162 | await runEndToEndTests() 163 | await runReleaseScripts() 164 | await printTodos() 165 | } 166 | 167 | main() 168 | .then(() => { 169 | // eslint-disable-next-line no-undef 170 | console.log(`\n${chalk.bold('💪 Project looks healthy 💪')}\n`) 171 | 172 | return true 173 | }) 174 | // eslint-disable-next-line unicorn/prefer-top-level-await 175 | .catch((error) => { 176 | // eslint-disable-next-line no-undef 177 | console.error( 178 | `\n${chalk.bold('❌ Project not healthy ❌')}\n\n${chalk.red(error)}` 179 | ) 180 | // eslint-disable-next-line no-undef 181 | process.exit(1) 182 | }) 183 | -------------------------------------------------------------------------------- /scripts/post-merge-or-rebase-githook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # MIT © Sindre Sorhus - sindresorhus.com 3 | 4 | changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)" 5 | 6 | check_run() { 7 | echo "$changed_files" | grep --quiet "$1" && eval "$2" 8 | } 9 | 10 | # Usage 11 | # In this example it's used to run `yarn` if yarn.lock changed 12 | check_run yarn.lock "yarn install" || true 13 | -------------------------------------------------------------------------------- /scripts/postinstall.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-process-exit */ 2 | /* 3 | * EXPLAINER 4 | * This script contains logic to NOT run in the context of this repo, otherwise 5 | * it would run everytime we 'yarn add' something in development. In the context 6 | * of other applications, installing the package via 'yarn add @strong-config/node' 7 | * will run this script to install the sops binary. Unless sops is already installed, 8 | * then it will opt out and exit early with code 0. 9 | */ 10 | 11 | // eslint-disable-next-line no-restricted-imports 12 | import fetch from 'node-fetch' 13 | import ora from 'ora' 14 | import os from 'os' 15 | import path from 'path' 16 | import { promisify } from 'util' 17 | import which from 'which' 18 | import { pipeline } from 'stream' 19 | 20 | /* 21 | * We're using the async implementations from 'fs-extra' for the spinner to work properly. 22 | * When using the standard sync implementations (e.g. chmodSync) then the spinner freezes. 23 | */ 24 | import fsExtra from 'fs-extra' 25 | const { chmod, copyFile, createWriteStream, pathExists } = fsExtra 26 | 27 | const streamPipeline = promisify(pipeline) 28 | 29 | const REPO = 'mozilla/sops' 30 | const VERSION = '3.5.0' 31 | const BINARY = 'sops' 32 | const DEST_PATH = `${process.env.INIT_CWD}/node_modules/.bin/${BINARY}` 33 | 34 | const checkSops = async () => { 35 | // If sops is available in the runtime environment already, skip downloading and exit early 36 | if (which.sync('sops', { nothrow: true }) || (await pathExists(DEST_PATH))) { 37 | process.exit(0) 38 | } 39 | } 40 | 41 | const getFileExtension = () => { 42 | const type = os.type() 43 | const arch = os.arch() 44 | 45 | console.log(`Detected OS '${type} [${arch}]'`) 46 | 47 | switch (type) { 48 | case 'Darwin': 49 | return 'darwin' 50 | case 'Windows_NT': 51 | return 'exe' 52 | case 'Linux': 53 | return 'linux' 54 | default: 55 | console.error(`Unsupported OS: ${type} :: ${arch}`) 56 | process.exit(1) 57 | } 58 | } 59 | 60 | const downloadBinary = async (repo, binary, version) => { 61 | const extension = getFileExtension() 62 | const url = `https://github.com/${repo}/releases/download/v${version}/${binary}-v${version}.${extension}` 63 | const spinner = ora(`Downloading '${binary}' binary from ${url}...`).start() 64 | 65 | try { 66 | const response = await fetch(url) 67 | 68 | if (!response.ok) { 69 | throw new Error( 70 | `Binary download failed with unexpected response: ${response.statusText}` 71 | ) 72 | } 73 | 74 | const downloadPath = `./node_modules/.bin/${ 75 | // eslint-disable-next-line @typescript-eslint/restrict-plus-operands 76 | extension === 'exe' ? binary + '.exe' : binary 77 | }` 78 | await streamPipeline(response.body, createWriteStream(downloadPath)) 79 | 80 | spinner.succeed(`✅ Downloaded binary to ${downloadPath}`) 81 | 82 | return downloadPath 83 | } catch (error) { 84 | spinner.fail('Failed to download binary') 85 | console.error(error) 86 | process.exit(1) 87 | } 88 | } 89 | 90 | const makeBinaryExecutable = async (_path) => { 91 | const spinner = ora(`Making ${BINARY} binary executable...`).start() 92 | 93 | try { 94 | await chmod(_path, 0o755) 95 | spinner.succeed(`✅ ${BINARY} binary is executable`) 96 | } catch (error) { 97 | spinner.fail('Failed to make binary executable') 98 | console.error(error) 99 | } 100 | } 101 | 102 | const copyFileToConsumingPackagesBinaryPath = async (sourcePath) => { 103 | const destinationPath = `${process.env.INIT_CWD}/node_modules/.bin/${BINARY}` 104 | 105 | // This is the case when running 'postinstall' from the strong-config project itself 106 | if (path.resolve(sourcePath) === path.resolve(destinationPath)) { 107 | return 108 | } 109 | 110 | const spinner = ora( 111 | `Copying ${BINARY} binary to consuming package's ./node_modules/.bin folder...` 112 | ).start() 113 | 114 | try { 115 | if (!process.env.INIT_CWD) { 116 | spinner.fail( 117 | 'process.env.INIT_CWD is nil. This variable is usually available when running yarn or npm scripts 🤔.' 118 | ) 119 | process.exit(1) 120 | } else { 121 | await copyFile(sourcePath, destinationPath) 122 | spinner.succeed(`✅ ${BINARY} binary to ${destinationPath}`) 123 | 124 | return 125 | } 126 | } catch (error) { 127 | spinner.fail(`Failed to copy ${BINARY} binary to ${process.env.INIT_CWD}`) 128 | console.error(error) 129 | process.exit(1) 130 | } 131 | } 132 | 133 | async function main() { 134 | await checkSops() 135 | const downloadPath = await downloadBinary(REPO, BINARY, VERSION) 136 | await makeBinaryExecutable(downloadPath) 137 | await copyFileToConsumingPackagesBinaryPath(downloadPath) 138 | } 139 | 140 | main() 141 | -------------------------------------------------------------------------------- /src/cli/check-encryption.test.ts: -------------------------------------------------------------------------------- 1 | // This is safe to disable as we're in a test file 2 | 3 | import { stderr, stdout } from 'stdout-stderr' 4 | import * as readFiles from '../utils/load-files' 5 | import { CheckEncryption } from './check-encryption' 6 | 7 | describe('strong-config check-encryption', () => { 8 | const encryptedConfigPath = 'example/development.yaml' 9 | const invalidConfigPath = 'example/invalid.yml' 10 | const unencryptedConfigPath = 'example/unencrypted.yml' 11 | const unencryptedWithNestedSecretsConfigPath = 12 | 'example/unencrypted-with-nested-secrets.yml' 13 | const noSecretsConfigPath = 'example/no-secrets.yml' 14 | 15 | const allConfigFiles = [ 16 | encryptedConfigPath, 17 | invalidConfigPath, 18 | noSecretsConfigPath, 19 | unencryptedConfigPath, 20 | unencryptedWithNestedSecretsConfigPath, 21 | ] 22 | 23 | const configRoot = 'example' 24 | 25 | beforeAll(() => { 26 | jest.spyOn(process, 'exit').mockImplementation() 27 | }) 28 | 29 | beforeEach(() => { 30 | jest.clearAllMocks() 31 | // Mocking stdout/stderr also for tests that don't check it to not pollute the jest output with status messages 32 | stdout.start() 33 | stderr.start() 34 | }) 35 | 36 | afterEach(() => { 37 | stdout.stop() 38 | stderr.stop() 39 | }) 40 | 41 | afterAll(() => { 42 | jest.restoreAllMocks() 43 | }) 44 | 45 | describe('for ONE file', () => { 46 | it('should read the config file', async () => { 47 | jest.spyOn(readFiles, 'loadConfigFromPath') 48 | await CheckEncryption.run([ 49 | encryptedConfigPath, 50 | '--config-root', 51 | configRoot, 52 | ]) 53 | 54 | expect(readFiles.loadConfigFromPath).toHaveBeenCalledWith( 55 | encryptedConfigPath, 56 | configRoot 57 | ) 58 | }) 59 | 60 | describe('given a path to an ENCRYPTED config file', () => { 61 | it('should exit with code 0 and success message', async () => { 62 | await CheckEncryption.run([encryptedConfigPath]) 63 | stderr.stop() 64 | 65 | expect(stderr.output).toMatch( 66 | new RegExp(`Checking ${encryptedConfigPath} for encryption`) 67 | ) 68 | expect(stderr.output).toMatch( 69 | new RegExp(`✔.*${encryptedConfigPath}.*safely encrypted`) 70 | ) 71 | // eslint-disable-next-line @typescript-eslint/unbound-method 72 | expect(process.exit).toHaveBeenCalledWith(0) 73 | }) 74 | }) 75 | 76 | describe('given a path to an UNENCRYPTED config file that contains secrets', () => { 77 | it('should exit with code 1 and error message', async () => { 78 | await CheckEncryption.run([unencryptedConfigPath]) 79 | stderr.stop() 80 | 81 | expect(stderr.output).toMatch( 82 | new RegExp(`✖.*${unencryptedConfigPath}.*NOT encrypted`) 83 | ) 84 | // eslint-disable-next-line @typescript-eslint/unbound-method 85 | expect(process.exit).toHaveBeenCalledWith(0) 86 | }) 87 | 88 | it('should also fail for nested secrets', async () => { 89 | await CheckEncryption.run([unencryptedWithNestedSecretsConfigPath]) 90 | stderr.stop() 91 | 92 | expect(stderr.output).toMatch( 93 | new RegExp( 94 | `✖.*${unencryptedWithNestedSecretsConfigPath}.*NOT encrypted` 95 | ) 96 | ) 97 | // eslint-disable-next-line @typescript-eslint/unbound-method 98 | expect(process.exit).toHaveBeenCalledWith(0) 99 | }) 100 | }) 101 | 102 | describe('given a path to an UNENCRYPTED config file that does NOT contain secrets', () => { 103 | it('should exit with code 0 because there is nothing that needs encryption', async () => { 104 | await CheckEncryption.run([noSecretsConfigPath]) 105 | stderr.stop() 106 | 107 | expect(stderr.output).toMatch( 108 | new RegExp(`Checking ${noSecretsConfigPath} for encryption`) 109 | ) 110 | expect(stderr.output).toMatch( 111 | new RegExp(`✔ No secrets found in ${noSecretsConfigPath}`) 112 | ) 113 | 114 | // eslint-disable-next-line @typescript-eslint/unbound-method 115 | expect(process.exit).toHaveBeenCalledWith(0) 116 | }) 117 | }) 118 | 119 | describe('given an invalid path', () => { 120 | it('should exit with code 1 and error message', async () => { 121 | const invalidPath = 'non/existant/path' 122 | await CheckEncryption.run([invalidPath]) 123 | stderr.stop() 124 | 125 | expect(stderr.output).toMatch( 126 | new RegExp(`✖.*${invalidPath}.*doesn't exist`) 127 | ) 128 | // eslint-disable-next-line @typescript-eslint/unbound-method 129 | expect(process.exit).toHaveBeenCalledWith(1) 130 | }) 131 | }) 132 | }) 133 | 134 | describe('for MULTIPLE (but not all) files', () => { 135 | it('should validate all config files passed as arguments', async () => { 136 | const checkOneConfigFileSpy = jest.spyOn( 137 | CheckEncryption.prototype, 138 | 'checkOneConfigFile' 139 | ) 140 | await CheckEncryption.run([ 141 | '--config-root', 142 | configRoot, 143 | encryptedConfigPath, 144 | unencryptedConfigPath, 145 | ]) 146 | 147 | expect(checkOneConfigFileSpy).toHaveBeenCalledTimes(2) 148 | expect(checkOneConfigFileSpy).toHaveBeenNthCalledWith( 149 | 1, 150 | encryptedConfigPath, 151 | configRoot 152 | ) 153 | 154 | expect(checkOneConfigFileSpy).toHaveBeenNthCalledWith( 155 | 2, 156 | unencryptedConfigPath, 157 | configRoot 158 | ) 159 | }) 160 | 161 | it('should exit with code 1 and error message when not all config files are valid', async () => { 162 | await CheckEncryption.run([ 163 | '--config-root', 164 | configRoot, 165 | encryptedConfigPath, 166 | unencryptedConfigPath, 167 | ]) 168 | stderr.stop() 169 | 170 | expect(stderr.output).toContain( 171 | `Secrets in ${unencryptedConfigPath} are NOT encrypted` 172 | ) 173 | // eslint-disable-next-line @typescript-eslint/unbound-method 174 | expect(process.exit).toHaveBeenCalledWith(1) 175 | }) 176 | }) 177 | 178 | describe('for ALL files', () => { 179 | describe('given a folder with multiple config files', () => { 180 | it('should find all *.yaml and *.yml files in the configRoot dir', async () => { 181 | const checkOneConfigFileSpy = jest.spyOn( 182 | CheckEncryption.prototype, 183 | 'checkOneConfigFile' 184 | ) 185 | await CheckEncryption.run(['--config-root', configRoot]) 186 | 187 | expect(checkOneConfigFileSpy).toHaveBeenCalledTimes( 188 | allConfigFiles.length 189 | ) 190 | expect(checkOneConfigFileSpy).toHaveBeenNthCalledWith( 191 | 1, 192 | allConfigFiles[0], 193 | configRoot 194 | ) 195 | expect(checkOneConfigFileSpy).toHaveBeenNthCalledWith( 196 | 2, 197 | allConfigFiles[1], 198 | configRoot 199 | ) 200 | expect(checkOneConfigFileSpy).toHaveBeenNthCalledWith( 201 | 3, 202 | allConfigFiles[2], 203 | configRoot 204 | ) 205 | }) 206 | 207 | it('should exit with code 1 and error message when not all config files are encrypted', async () => { 208 | await CheckEncryption.run(['--config-root', './example']) 209 | stderr.stop() 210 | 211 | expect(stderr.output).toMatch( 212 | '✖ Secrets in example/unencrypted.yml are NOT encrypted' 213 | ) 214 | expect(stderr.output).toMatch('✖ Not all secrets are encrypted') 215 | // eslint-disable-next-line @typescript-eslint/unbound-method 216 | expect(process.exit).toHaveBeenCalledWith(1) 217 | }) 218 | }) 219 | 220 | describe('given a folder with NO config files', () => { 221 | it('should exit with code 1 and error message', async () => { 222 | const invalidConfigRoot = './i/dont/exist' 223 | await CheckEncryption.run(['--config-root', invalidConfigRoot]) 224 | stderr.stop() 225 | 226 | expect(stderr.output).toMatch( 227 | new RegExp(`✖.*Found no config files in.*${invalidConfigRoot}`) 228 | ) 229 | // eslint-disable-next-line @typescript-eslint/unbound-method 230 | expect(process.exit).toHaveBeenCalledWith(1) 231 | }) 232 | }) 233 | }) 234 | }) 235 | -------------------------------------------------------------------------------- /src/cli/check-encryption.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import path from 'path' 3 | import { Args, Command, Flags } from '@oclif/core' 4 | import fastGlob from 'fast-glob' 5 | import ora from 'ora' 6 | import { defaultOptions } from '../options' 7 | import type { EncryptedConfig, JSONObject } from '../types' 8 | import { loadConfigFromPath } from '../utils/load-files' 9 | import { hasSecrets } from '../utils/has-secrets' 10 | 11 | export class CheckEncryption extends Command { 12 | static description = 13 | 'check that config file(s) with secrets are safely encrypted' 14 | 15 | // Allows passing multiple args to this command 16 | static strict = false 17 | 18 | static flags = { 19 | help: Flags.help({ 20 | char: 'h', 21 | description: 'show help', 22 | }), 23 | 'config-root': Flags.string({ 24 | char: 'c', 25 | description: 'your config folder containing your config files', 26 | default: defaultOptions.configRoot, 27 | }), 28 | } 29 | 30 | static args = { 31 | config_file: Args.string({ 32 | description: 33 | '[optional] path to a specific config file to check. if omitted, all config files will be checked', 34 | required: false, 35 | }), 36 | } 37 | 38 | static usage = 'check-encryption [CONFIG_FILE]' 39 | 40 | static examples = [ 41 | '<%= config.bin %> <%= command.id %>', 42 | '<%= config.bin %> <%= command.id %> --config-root ./deeply/nested/config/folder', 43 | '<%= config.bin %> <%= command.id %> config/production.yml', 44 | '$ check', 45 | ] 46 | 47 | static aliases = ['check'] 48 | 49 | async checkAllConfigFiles(): Promise { 50 | const spinner = ora('Checking all config files for encryption...').start() 51 | const { flags } = await this.parse(CheckEncryption) 52 | 53 | const globPattern = `${flags['config-root']}/**/*.{yml,yaml}` 54 | const configFiles = await fastGlob(globPattern) 55 | 56 | if (configFiles.length === 0) { 57 | spinner.fail(`Found no config files in '${globPattern}'`) 58 | process.exit(1) 59 | } 60 | 61 | const checkResultsPerFile = configFiles.map((configPath) => 62 | this.checkOneConfigFile(configPath, flags['config-root']) 63 | ) 64 | 65 | if (checkResultsPerFile.includes(false)) { 66 | spinner.fail('Not all secrets are encrypted') 67 | process.exit(1) 68 | } 69 | 70 | spinner.succeed('Secrets in all config files are safely encrypted 💪') 71 | } 72 | 73 | checkOneConfigFile(rawPath: string, configRoot: string): boolean { 74 | const configPath = path.normalize(rawPath) 75 | const spinner = ora(`Checking ${configPath} for encryption...`).start() 76 | 77 | let configFile 78 | 79 | try { 80 | configFile = loadConfigFromPath(configPath, configRoot) 81 | } catch { 82 | spinner.fail( 83 | `${configPath} doesn't exist.\nPlease either provide a valid path to a config file or don't pass any arguments to check all config files in '$ configRoot}'` 84 | ) 85 | 86 | return false 87 | } 88 | 89 | if (this.isEncrypted(configFile.contents)) { 90 | spinner.succeed(`Secrets in ${configPath} are safely encrypted 💪`) 91 | 92 | return true 93 | } else if (hasSecrets(configFile.contents)) { 94 | spinner.fail(`Secrets in ${configPath} are NOT encrypted 🚨`) 95 | 96 | return false 97 | } else { 98 | spinner.succeed( 99 | `No secrets found in ${configPath}, no encryption required.` 100 | ) 101 | 102 | return true 103 | } 104 | } 105 | 106 | isEncrypted(configObject: JSONObject | EncryptedConfig): boolean { 107 | return Object.keys(configObject).includes('sops') 108 | } 109 | 110 | async run(): Promise { 111 | const { argv, flags } = await this.parse(CheckEncryption) 112 | 113 | if (argv.length > 0) { 114 | for (const configPath of argv) { 115 | // We can ignore this because in practice oclif would fail if passed a non-string arg 116 | /* istanbul ignore next */ 117 | if (typeof configPath !== 'string') { 118 | throw new TypeError( 119 | `Received argument was not a string but: ${typeof configPath}` 120 | ) 121 | } 122 | 123 | this.checkOneConfigFile(configPath, flags['config-root']) || 124 | process.exit(1) 125 | } 126 | 127 | process.exit(0) 128 | } else { 129 | await this.checkAllConfigFiles() 130 | } 131 | 132 | process.exit(0) 133 | } 134 | } 135 | 136 | export default CheckEncryption 137 | -------------------------------------------------------------------------------- /src/cli/decrypt.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import { stderr, stdout } from 'stdout-stderr' 3 | import { runSopsWithOptions } from '../utils/sops' 4 | import { loadSchema } from '../utils/load-files' 5 | 6 | import { Decrypt } from './decrypt' 7 | import * as validateCommand from './validate' 8 | 9 | jest.mock('../utils/sops', () => { 10 | return { 11 | runSopsWithOptions: jest.fn(), 12 | getSopsOptions: jest.fn(() => ['--some', '--flags']), 13 | } 14 | }) 15 | 16 | const runSopsWithOptionsMock = runSopsWithOptions as jest.Mock< 17 | ReturnType 18 | > 19 | 20 | describe('strong-config decrypt', () => { 21 | const configRoot = 'example' 22 | const configFile = 'example/development.yaml' 23 | const schema = loadSchema(configRoot) 24 | const sopsError = new Error('some sops error') 25 | 26 | beforeAll(() => { 27 | jest.spyOn(console, 'error').mockImplementation() 28 | jest.spyOn(process, 'exit').mockImplementation() 29 | }) 30 | 31 | beforeEach(() => { 32 | jest.clearAllMocks() 33 | // Mocking stdout/stderr also for tests that don't check it to not pollute the jest output with status messages 34 | stdout.start() 35 | stderr.start() 36 | }) 37 | 38 | afterEach(() => { 39 | stderr.stop() 40 | stdout.stop() 41 | }) 42 | 43 | afterAll(() => { 44 | jest.restoreAllMocks() 45 | }) 46 | 47 | it('exits with code 0 when successful', async () => { 48 | await Decrypt.run([configFile]) 49 | 50 | expect(process.exit).toHaveBeenCalledWith(0) 51 | }) 52 | 53 | it('exits with code 1 when decryption fails', async () => { 54 | runSopsWithOptionsMock.mockImplementationOnce(() => { 55 | throw sopsError 56 | }) 57 | 58 | await Decrypt.run([configFile]) 59 | 60 | expect(process.exit).toHaveBeenCalledWith(1) 61 | }) 62 | 63 | it('decrypts by using sops', async () => { 64 | await Decrypt.run([configFile]) 65 | 66 | expect(runSopsWithOptionsMock).toHaveBeenCalledWith([ 67 | '--decrypt', 68 | '--some', 69 | '--flags', 70 | ]) 71 | }) 72 | 73 | describe('given a config-root with a schema file', () => { 74 | it('validates config against schema AFTER decrypting', async () => { 75 | jest.spyOn(validateCommand, 'validateOneConfigFile') 76 | 77 | await Decrypt.run([configFile, '--config-root', configRoot]) 78 | 79 | expect(validateCommand.validateOneConfigFile).toHaveBeenCalledWith( 80 | configFile, 81 | configRoot, 82 | schema 83 | ) 84 | 85 | expect(validateCommand.validateOneConfigFile).toHaveBeenCalledAfter( 86 | runSopsWithOptionsMock 87 | ) 88 | }) 89 | }) 90 | 91 | describe('when config file is already decrypted', () => { 92 | it('displays a useful error message', async () => { 93 | runSopsWithOptionsMock.mockImplementationOnce(() => { 94 | const error = new Error( 95 | 'original non-userfriendly error message from sops :: already decrypted' 96 | ) 97 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 98 | // @ts-ignore 99 | error.exitCode = 1 // this is sops' error-code 100 | throw error 101 | }) 102 | 103 | await Decrypt.run(['unencrypted.yml', '--config-root', configRoot]) 104 | 105 | expect(console.error).toHaveBeenCalledWith( 106 | expect.stringContaining('is already decrypted') 107 | ) 108 | }) 109 | }) 110 | 111 | describe("when config file can't be found", () => { 112 | it('displays a useful error message', async () => { 113 | runSopsWithOptionsMock.mockImplementationOnce(() => { 114 | const error = new Error( 115 | 'original non-userfriendly error message from sops :: file not found' 116 | ) 117 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 118 | // @ts-ignore 119 | error.exitCode = 100 // this is sops' error-code 120 | throw error 121 | }) 122 | 123 | await Decrypt.run(['non-existing-file.yml']) 124 | 125 | expect(console.error).toHaveBeenCalledWith( 126 | "🤔 Didn't find ./non-existing-file.yml. A typo, perhaps?" 127 | ) 128 | }) 129 | }) 130 | 131 | describe('when an unknown error happens', () => { 132 | it('displays the error', async () => { 133 | const error = new Error( 134 | 'original non-userfriendly error message from sops :: weird error that we are not explicitly handling yet' 135 | ) 136 | runSopsWithOptionsMock.mockImplementationOnce(() => { 137 | throw error 138 | }) 139 | 140 | await Decrypt.run(['example/development.yaml']) 141 | 142 | expect(console.error).toHaveBeenCalledWith(error) 143 | }) 144 | }) 145 | 146 | describe('cli output', () => { 147 | it('displays the decryption process', async () => { 148 | await Decrypt.run([configFile]) 149 | stderr.stop() 150 | 151 | expect(stderr.output).toMatch('Decrypting...') 152 | }) 153 | 154 | it('displays the decryption result', async () => { 155 | await Decrypt.run([configFile]) 156 | stderr.stop() 157 | 158 | expect(stderr.output).toMatch(`Successfully decrypted ${configFile}!`) 159 | }) 160 | 161 | it('displays decryption errors', async () => { 162 | runSopsWithOptionsMock.mockImplementationOnce(() => { 163 | throw sopsError 164 | }) 165 | 166 | await Decrypt.run([configFile]) 167 | stderr.stop() 168 | 169 | expect(stderr.output).toMatch('Failed to decrypt config file') 170 | }) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /src/cli/decrypt.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Args, Command, Flags } from '@oclif/core' 3 | import ora from 'ora' 4 | import { getSopsOptions, runSopsWithOptions } from '../utils/sops' 5 | import { loadSchema } from '../utils/load-files' 6 | import { defaultOptions } from '../options' 7 | import { validateOneConfigFile } from './validate' 8 | 9 | export class Decrypt extends Command { 10 | static description = 'decrypt a config file' 11 | 12 | static strict = true 13 | 14 | static args = { 15 | config_file: Args.string({ 16 | description: 17 | 'path to an encrypted config file, for example: `strong-config decrypt config/production.yml`', 18 | required: true, 19 | }), 20 | output_path: Args.string({ 21 | description: 22 | '[optional] output file of the decrypted config. If not specified, CONFIG_FILE is overwritten in-place.', 23 | required: false, 24 | }), 25 | } 26 | 27 | static flags = { 28 | help: Flags.help({ 29 | char: 'h', 30 | description: 'show help', 31 | }), 32 | 'config-root': Flags.string({ 33 | char: 'c', 34 | description: 35 | 'your config folder containing your config files and optional schema', 36 | default: defaultOptions.configRoot, 37 | }), 38 | } 39 | 40 | static usage = 'decrypt CONFIG_FILE [OUTPUT_PATH]' 41 | 42 | static examples = [ 43 | '<%= config.bin %> <%= command.id %>', 44 | '<%= config.bin %> <%= command.id %> config/development.yaml', 45 | '<%= config.bin %> <%= command.id %> config/production.yaml config/production.decrypted.yaml', 46 | ] 47 | 48 | async decrypt() { 49 | const { args, flags } = await this.parse(Decrypt) 50 | 51 | const spinner = ora('Decrypting...').start() 52 | 53 | const sopsOptions = ['--decrypt', ...getSopsOptions(args, flags)] 54 | 55 | try { 56 | runSopsWithOptions(sopsOptions) 57 | } catch (error) { 58 | spinner.fail('Failed to decrypt config file') 59 | 60 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 61 | // @ts-ignore - don't know how to make typescript happy here 62 | if (error.exitCode) { 63 | /* istanbul ignore else: no need to test the else path */ 64 | // @ts-ignore - don't know how to make typescript happy here 65 | if (error.exitCode === 1) { 66 | console.error( 67 | `🤔 It looks like ${args.config_file} is already decrypted` 68 | ) 69 | // @ts-ignore - don't know how to make typescript happy here 70 | } else if (error.exitCode === 100) { 71 | console.error( 72 | `🤔 Didn't find ./${args.config_file}. A typo, perhaps?` 73 | ) 74 | } 75 | } else { 76 | console.error(error) 77 | } 78 | /* eslint-enable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ 79 | 80 | process.exit(1) 81 | } 82 | 83 | spinner.succeed(`Successfully decrypted ${args.config_file}!`) 84 | } 85 | 86 | // All run() methods must return a Promise in oclif CLIs, regardless of whether they do something async or not. 87 | async run(): Promise { 88 | const { args, flags } = await this.parse(Decrypt) 89 | const configRoot = flags['config-root'] 90 | await this.decrypt() 91 | 92 | const schema = loadSchema(configRoot) 93 | 94 | if ( 95 | schema && 96 | !validateOneConfigFile(args.config_file, configRoot, schema) 97 | ) { 98 | ora( 99 | `Encryption failed because ./${args.config_file} failed validation against ./${configRoot}/schema.json` 100 | ).fail() 101 | process.exit(1) 102 | } 103 | 104 | process.exit(0) 105 | } 106 | } 107 | 108 | export default Decrypt 109 | -------------------------------------------------------------------------------- /src/cli/encrypt.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import { stderr, stdout } from 'stdout-stderr' 3 | import { runSopsWithOptions } from '../utils/sops' 4 | import { loadSchema } from '../utils/load-files' 5 | import { Encrypt } from './encrypt' 6 | import * as validateCommand from './validate' 7 | 8 | jest.mock('../utils/sops', () => { 9 | return { 10 | runSopsWithOptions: jest.fn(), 11 | getSopsOptions: jest.fn(() => ['--some', '--flags']), 12 | } 13 | }) 14 | 15 | const runSopsWithOptionsMock = runSopsWithOptions as jest.Mock< 16 | ReturnType 17 | > 18 | 19 | describe('strong-config encrypt', () => { 20 | const configRoot = 'example' 21 | const configFile = 'example/development.decrypted.yaml' 22 | const schema = loadSchema(configRoot) 23 | const sopsError = new Error('some sops error') 24 | const keyId = '2E9644A658379349EFB77E895351CE7FC0AC6E94' // => ./example/pgp/example-keypair.pgp 25 | const keyProvider = 'pgp' 26 | const requiredKeyFlags = ['-k', keyId, '-p', keyProvider] 27 | 28 | beforeAll(() => { 29 | jest.spyOn(console, 'error').mockImplementation() 30 | jest.spyOn(process, 'exit').mockImplementation() 31 | }) 32 | 33 | beforeEach(() => { 34 | jest.clearAllMocks() 35 | // Mocking stdout/stderr also for tests that don't check it to not pollute the jest output with status messages 36 | stdout.start() 37 | stderr.start() 38 | }) 39 | 40 | afterEach(() => { 41 | stdout.stop() 42 | stderr.stop() 43 | }) 44 | 45 | afterAll(() => { 46 | jest.restoreAllMocks() 47 | }) 48 | 49 | it('exits with code 0 when successful', async () => { 50 | await Encrypt.run([configFile, ...requiredKeyFlags]) 51 | 52 | expect(process.exit).toHaveBeenCalledWith(0) 53 | }) 54 | 55 | it('exits with code 1 when encryption fails', async () => { 56 | runSopsWithOptionsMock.mockImplementationOnce(() => { 57 | throw sopsError 58 | }) 59 | 60 | await Encrypt.run([configFile, ...requiredKeyFlags]) 61 | 62 | expect(process.exit).toHaveBeenCalledWith(1) 63 | }) 64 | 65 | it('encrypts by using sops', async () => { 66 | await Encrypt.run([configFile, ...requiredKeyFlags]) 67 | 68 | expect(runSopsWithOptions).toHaveBeenCalledWith([ 69 | '--encrypt', 70 | '--some', 71 | '--flags', 72 | ]) 73 | }) 74 | 75 | describe('given a config-root with a schema file', () => { 76 | it('validates config against schema BEFORE encrypting', async () => { 77 | jest.spyOn(validateCommand, 'validateOneConfigFile') 78 | 79 | await Encrypt.run([ 80 | configFile, 81 | ...requiredKeyFlags, 82 | '--config-root', 83 | configRoot, 84 | ]) 85 | 86 | expect(validateCommand.validateOneConfigFile).toHaveBeenCalledWith( 87 | configFile, 88 | configRoot, 89 | schema 90 | ) 91 | 92 | expect(validateCommand.validateOneConfigFile).toHaveBeenCalledBefore( 93 | runSopsWithOptionsMock 94 | ) 95 | }) 96 | }) 97 | 98 | describe('when config file is already encrypted', () => { 99 | it('displays a useful error message', async () => { 100 | runSopsWithOptionsMock.mockImplementationOnce(() => { 101 | const error = new Error( 102 | 'original non-userfriendly error message from sops :: already encrypted' 103 | ) 104 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 105 | // @ts-ignore 106 | error.exitCode = 203 // this is sops' error-code 107 | throw error 108 | }) 109 | 110 | await Encrypt.run([configFile, ...requiredKeyFlags]) 111 | 112 | expect(console.error).toHaveBeenCalledWith( 113 | expect.stringContaining('is already encrypted') 114 | ) 115 | }) 116 | }) 117 | 118 | describe('when using GCP as a KMS and an error happens on GCP-side', () => { 119 | it('displays a useful error message', async () => { 120 | runSopsWithOptionsMock.mockImplementationOnce(() => { 121 | const error = new Error( 122 | 'original non-userfriendly error message from sops :: some GCP problem' 123 | ) 124 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 125 | // @ts-ignore 126 | error.stderr = 127 | 'potentially long and complicated too read error string containing the word "GCP"' 128 | throw error 129 | }) 130 | 131 | await Encrypt.run([configFile, ...requiredKeyFlags]) 132 | 133 | expect(console.error).toHaveBeenCalledWith( 134 | expect.stringContaining('Google Cloud KMS Error') 135 | ) 136 | }) 137 | }) 138 | 139 | describe('cli output', () => { 140 | it('displays the encryption process', async () => { 141 | await Encrypt.run([configFile, ...requiredKeyFlags]) 142 | stderr.stop() 143 | 144 | expect(stderr.output).toMatch('Encrypting...') 145 | }) 146 | 147 | it('displays the encryption result', async () => { 148 | await Encrypt.run([configFile, ...requiredKeyFlags]) 149 | stderr.stop() 150 | 151 | expect(stderr.output).toMatch(`Successfully encrypted ${configFile}!`) 152 | }) 153 | 154 | it('displays encryption errors', async () => { 155 | runSopsWithOptionsMock.mockImplementationOnce(() => { 156 | throw sopsError 157 | }) 158 | 159 | await Encrypt.run([configFile, ...requiredKeyFlags]) 160 | stderr.stop() 161 | 162 | expect(stderr.output).toMatch('Failed to encrypt config file') 163 | }) 164 | }) 165 | }) 166 | -------------------------------------------------------------------------------- /src/cli/encrypt.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Args, Command, Flags } from '@oclif/core' 3 | import ora from 'ora' 4 | import { getSopsOptions, runSopsWithOptions } from '../utils/sops' 5 | import { defaultOptions } from '../options' 6 | import { loadSchema } from './../utils/load-files' 7 | import { validateOneConfigFile } from './validate' 8 | 9 | export class Encrypt extends Command { 10 | static description = 'encrypt a config file' 11 | 12 | static strict = true 13 | 14 | static args = { 15 | config_file: Args.string({ 16 | description: 17 | 'path to a decrypted config file, for example: `strong-config encrypt config/production.yml`', 18 | required: true, 19 | }), 20 | output_path: Args.string({ 21 | description: 22 | '[optionap] output file of the encrypted config. If not specified, CONFIG_FILE is overwritten in-place.', 23 | required: false, 24 | }), 25 | } 26 | 27 | static flags = { 28 | help: Flags.help({ 29 | char: 'h', 30 | description: 'show help', 31 | }), 32 | 'config-root': Flags.string({ 33 | char: 'c', 34 | description: 35 | 'your config folder containing your config files and optional schema', 36 | default: defaultOptions.configRoot, 37 | }), 38 | 'key-provider': Flags.string({ 39 | char: 'p', 40 | description: 'key provider to use to encrypt secrets', 41 | required: true, 42 | options: ['pgp', 'gcp', 'aws', 'azr'], 43 | dependsOn: ['key-id'], 44 | }), 45 | 'key-id': Flags.string({ 46 | char: 'k', 47 | description: 'reference to a unique key managed by the key provider', 48 | required: true, 49 | dependsOn: ['key-provider'], 50 | }), 51 | 'encrypted-key-suffix': Flags.string({ 52 | char: 'e', 53 | description: 'key suffix determining the values to be encrypted', 54 | required: false, 55 | default: 'Secret', 56 | exclusive: ['unencrypted-key-suffix'], 57 | }), 58 | 'unencrypted-key-suffix': Flags.string({ 59 | char: 'u', 60 | description: 'key suffix determining the values to be NOT encrypted', 61 | required: false, 62 | exclusive: ['encrypted-key-suffix'], 63 | }), 64 | } 65 | 66 | static usage = 67 | 'encrypt CONFIG_FILE [OUTPUT_PATH] --key-provider=KEY_PROVIDER --key-id=KEY_ID' 68 | 69 | static examples = [ 70 | '<%= config.bin %> <%= command.id %>', 71 | '<%= config.bin %> <%= command.id %> config/development.yaml --key-provider gcp --key-id ref/to/key', 72 | '<%= config.bin %> <%= command.id %> config/production.yaml -p aws -k ref/to/key --unencrypted-key-suffix "Plain"', 73 | ] 74 | 75 | async encrypt(): Promise { 76 | const { args, flags } = await this.parse(Encrypt) 77 | 78 | const spinner = ora('Encrypting...').start() 79 | 80 | const sopsOptions = ['--encrypt', ...getSopsOptions(args, flags)] 81 | 82 | try { 83 | runSopsWithOptions(sopsOptions) 84 | } catch (error) { 85 | spinner.fail('Failed to encrypt config file') 86 | 87 | if ( 88 | typeof error === 'object' && 89 | error !== null && 90 | /* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ 91 | // @ts-ignore - don't know how to make typescript happy here 92 | error.exitCode && 93 | // @ts-ignore - don't know how to make typescript happy here 94 | error.exitCode === 203 95 | ) { 96 | console.error( 97 | `🤔 It looks like ${args.config_file} is already encrypted!\n` 98 | ) 99 | } 100 | 101 | if ( 102 | // @ts-ignore - don't know how to make typescript happy here 103 | error.stderr && 104 | // @ts-ignore - don't know how to make typescript happy here 105 | typeof error.stderr === 'string' && 106 | // @ts-ignore - don't know how to make typescript happy here 107 | error.stderr.includes('GCP') 108 | ) { 109 | // @ts-ignore - don't know how to make typescript happy here 110 | console.error(`🌩 Google Cloud KMS Error:\n${error.stderr as string}`) 111 | } 112 | /* eslint-enable @typescript-eslint/ban-ts-comment */ 113 | 114 | console.error(error) 115 | process.exit(1) 116 | } 117 | 118 | spinner.succeed(`Successfully encrypted ${args.config_file}!`) 119 | } 120 | 121 | // All run() methods must return a Promise in oclif CLIs, regardless of whether they do something async or not. 122 | 123 | async run(): Promise { 124 | const { args, flags } = await this.parse(Encrypt) 125 | const configRoot = flags['config-root'] 126 | 127 | const schema = loadSchema(configRoot) 128 | 129 | if ( 130 | schema && 131 | !validateOneConfigFile(args.config_file, configRoot, schema) 132 | ) { 133 | ora( 134 | `Encryption failed because ./${args.config_file} failed validation against ./${configRoot}/schema.json` 135 | ).fail() 136 | process.exit(1) 137 | } 138 | 139 | await this.encrypt() 140 | 141 | process.exit(0) 142 | } 143 | } 144 | 145 | export default Encrypt 146 | -------------------------------------------------------------------------------- /src/cli/generate-types.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import fs from 'fs' 3 | import { stderr, stdout } from 'stdout-stderr' 4 | import * as generateTypesFromSchemaModule from '../utils/generate-types-from-schema' 5 | import * as readFiles from '../utils/load-files' 6 | import { GenerateTypes } from './generate-types' 7 | 8 | jest.mock('./validate') 9 | 10 | describe('strong-config generate-types', () => { 11 | beforeAll(() => { 12 | jest.spyOn(console, 'error').mockImplementation() 13 | jest.spyOn(fs, 'writeFileSync').mockImplementation() 14 | jest.spyOn(process, 'exit').mockImplementation() 15 | }) 16 | 17 | beforeEach(() => { 18 | jest.clearAllMocks() 19 | // Mocking stdout/stderr also for tests that don't check it to not pollute the jest output with status messages 20 | stdout.start() 21 | stderr.start() 22 | }) 23 | 24 | afterEach(() => { 25 | stdout.stop() 26 | stderr.stop() 27 | }) 28 | 29 | afterAll(() => { 30 | jest.restoreAllMocks() 31 | }) 32 | 33 | it('checks if a valid schema file exists', async () => { 34 | jest.spyOn(readFiles, 'loadSchema') 35 | const configRoot = '/i/dont/exist' 36 | 37 | await GenerateTypes.run(['--config-root', configRoot]) 38 | 39 | expect(readFiles.loadSchema).toHaveBeenCalledWith(configRoot) 40 | }) 41 | 42 | it('generates types', async () => { 43 | const configRoot = 'example' 44 | const typesPath = '@types' 45 | jest.spyOn(generateTypesFromSchemaModule, 'generateTypesFromSchema') 46 | 47 | await GenerateTypes.run([ 48 | '--config-root', 49 | configRoot, 50 | '--types-path', 51 | typesPath, 52 | ]) 53 | 54 | expect( 55 | generateTypesFromSchemaModule.generateTypesFromSchema 56 | ).toHaveBeenCalledWith(configRoot, typesPath) 57 | }) 58 | 59 | it('if --types-path is omitted, will default to --config-root', async () => { 60 | const configRoot = 'example' 61 | jest.spyOn(generateTypesFromSchemaModule, 'generateTypesFromSchema') 62 | 63 | await GenerateTypes.run(['--config-root', configRoot]) 64 | 65 | expect( 66 | generateTypesFromSchemaModule.generateTypesFromSchema 67 | ).toHaveBeenCalledWith(configRoot, configRoot) 68 | }) 69 | 70 | it('exits with code 0 when type generation was successful', async () => { 71 | const configRoot = 'example' 72 | 73 | await GenerateTypes.run(['--config-root', configRoot]) 74 | 75 | expect(process.exit).toHaveBeenCalledWith(0) 76 | }) 77 | 78 | describe('when given a config-root without schema', () => { 79 | it('fails and exits with code 1', async () => { 80 | await GenerateTypes.run(['--config-root', '/i/dont/exist']) 81 | 82 | expect(process.exit).toHaveBeenCalledWith(1) 83 | }) 84 | }) 85 | 86 | describe('when type generation fails for unknown reasons', () => { 87 | it('displays human-readable error message when type generation fails', async () => { 88 | jest 89 | .spyOn(generateTypesFromSchemaModule, 'generateTypesFromSchema') 90 | .mockImplementationOnce(() => { 91 | throw new Error('something went wrong during type generation ') 92 | }) 93 | 94 | await GenerateTypes.run(['--config-root', 'example']) 95 | stderr.stop() 96 | 97 | expect(stderr.output).toContain("Couldn't generate types from schema") 98 | }) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/cli/generate-types.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command, Flags } from '@oclif/core' 3 | import ora from 'ora' 4 | import { generateTypesFromSchema } from '../utils/generate-types-from-schema' 5 | import { loadSchema } from '../utils/load-files' 6 | import { defaultOptions } from '../options' 7 | 8 | export class GenerateTypes extends Command { 9 | static description = 'generate typescript types based on a JSON schema' 10 | 11 | static flags = { 12 | 'config-root': Flags.string({ 13 | char: 'c', 14 | description: 'your config folder containing your schema.json', 15 | default: defaultOptions.configRoot, 16 | }), 17 | help: Flags.help({ 18 | char: 'h', 19 | }), 20 | 'types-path': Flags.string({ 21 | char: 'p', 22 | description: 23 | 'the path to the folder into which the config.d.ts type declaration will be generated', 24 | }), 25 | } 26 | 27 | static examples = [ 28 | '<%= config.bin %> <%= command.id %>', 29 | '<%= config.bin %> <%= command.id %> --config-root ./some/sub/folder/config', 30 | '<%= config.bin %> <%= command.id %> -c ./some/sub/folder/config', 31 | ] 32 | 33 | async run(): Promise { 34 | const { flags } = await this.parse(GenerateTypes) 35 | const spinner = ora('Generating types...').start() 36 | 37 | if (loadSchema(flags['config-root'])) { 38 | try { 39 | await generateTypesFromSchema( 40 | flags['config-root'], 41 | flags['types-path'] || flags['config-root'] 42 | ) 43 | } catch (error) { 44 | spinner.fail("Couldn't generate types from schema") 45 | console.error(error) 46 | } 47 | 48 | spinner.succeed( 49 | `Successfully generated types to '${flags['config-root']}/config.d.ts' 💪` 50 | ) 51 | process.exit(0) 52 | } else { 53 | spinner.fail( 54 | "Didn't find schema file. Without a schema.json file inside your config directory we can't generate types." 55 | ) 56 | 57 | process.exit(1) 58 | } 59 | } 60 | } 61 | 62 | export default GenerateTypes 63 | -------------------------------------------------------------------------------- /src/cli/validate.test.ts: -------------------------------------------------------------------------------- 1 | // This is safe to disable as we're in a test file 2 | /* eslint-disable @typescript-eslint/unbound-method */ 3 | import { stderr, stdout } from 'stdout-stderr' 4 | import Ajv from 'ajv' 5 | import { formatAjvErrors } from '../utils/format-ajv-errors' 6 | import * as readFiles from '../utils/load-files' 7 | import * as sops from '../utils/sops' 8 | import { encryptedConfigFile, decryptedConfig } from '../fixtures' 9 | import { defaultOptions } from '../options' 10 | import { Validate } from './validate' 11 | import * as validateCommand from './validate' 12 | 13 | describe('strong-config validate', () => { 14 | const encryptedConfigPath = 'example/development.yaml' 15 | const invalidConfigPath = 'example/invalid.yml' 16 | const unencryptedConfigPath = 'example/unencrypted.yml' 17 | const unencryptedWithNestedSecretsConfigPath = 18 | 'example/unencrypted-with-nested-secrets.yml' 19 | const noSecretsConfigPath = 'example/no-secrets.yml' 20 | 21 | const allConfigFiles = [ 22 | encryptedConfigPath, 23 | invalidConfigPath, 24 | unencryptedConfigPath, 25 | unencryptedWithNestedSecretsConfigPath, 26 | noSecretsConfigPath, 27 | ] 28 | 29 | const someConfigFiles = [encryptedConfigPath, noSecretsConfigPath] 30 | 31 | const runtimeEnv = 'development' 32 | const configRoot = 'example' 33 | const schema = readFiles.loadSchema(configRoot) 34 | 35 | const loadConfigFromPath = jest.spyOn(readFiles, 'loadConfigFromPath') 36 | const loadSchema = jest.spyOn(readFiles, 'loadSchema') 37 | const decryptToObject = jest.spyOn(sops, 'decryptToObject') 38 | const ajvValidate = jest.spyOn(Ajv.prototype, 'validate') 39 | const processExit = jest.spyOn(process, 'exit') 40 | 41 | beforeAll(() => { 42 | // Otherwise the test process itself would be terminated 43 | processExit.mockImplementation() 44 | jest.spyOn(console, 'error').mockImplementation() 45 | }) 46 | 47 | beforeEach(() => { 48 | jest.clearAllMocks() 49 | loadConfigFromPath.mockReturnValue(encryptedConfigFile) 50 | decryptToObject.mockReturnValue(decryptedConfig) 51 | 52 | // Mocking stdout/stderr to not pollute the jest output with status messages 53 | stdout.start() 54 | stderr.start() 55 | }) 56 | 57 | afterEach(() => { 58 | stdout.stop() 59 | stderr.stop() 60 | }) 61 | 62 | afterAll(() => { 63 | jest.restoreAllMocks() 64 | }) 65 | 66 | describe('for ONE file', () => { 67 | it('validates a given config against its schema', async () => { 68 | await Validate.run([encryptedConfigPath, '--config-root', configRoot]) 69 | 70 | expect(loadConfigFromPath).toHaveBeenCalledWith( 71 | encryptedConfigPath, 72 | configRoot 73 | ) 74 | expect(sops.decryptToObject).toHaveBeenCalledWith( 75 | encryptedConfigFile.filePath, 76 | encryptedConfigFile.contents 77 | ) 78 | expect(loadSchema).toHaveBeenCalledWith(configRoot) 79 | expect(ajvValidate).toHaveBeenCalledWith(schema, decryptedConfig) 80 | expect(ajvValidate).toHaveReturnedWith(true) 81 | expect(process.exit).toHaveBeenCalledWith(0) 82 | }) 83 | 84 | it('accepts a relative path to a config file as input', async () => { 85 | const relativePaths = [ 86 | 'example/development.yaml', 87 | './example/development.yaml', 88 | '../node/example/development.yaml', 89 | ] 90 | 91 | for (const path of relativePaths) { 92 | ajvValidate.mockClear() 93 | processExit.mockClear() 94 | 95 | await Validate.run([path, '--config-root', configRoot]) 96 | 97 | expect(ajvValidate).toHaveBeenCalledWith(schema, decryptedConfig) 98 | expect(ajvValidate).toHaveReturnedWith(true) 99 | expect(process.exit).toHaveBeenCalledWith(0) 100 | } 101 | }) 102 | 103 | it('accepts an absolute path to a config file as input', async () => { 104 | const absolutePath = `${process.cwd()}/example/development.yaml` 105 | 106 | await Validate.run([absolutePath, '--config-root', configRoot]) 107 | 108 | expect(ajvValidate).toHaveBeenCalledWith(schema, decryptedConfig) 109 | expect(ajvValidate).toHaveReturnedWith(true) 110 | expect(process.exit).toHaveBeenCalledWith(0) 111 | }) 112 | 113 | it('accepts a runtimeEnv name as input', async () => { 114 | await Validate.run([runtimeEnv, '--config-root', configRoot]) 115 | 116 | expect(ajvValidate).toHaveBeenCalledWith(schema, decryptedConfig) 117 | expect(ajvValidate).toHaveReturnedWith(true) 118 | expect(process.exit).toHaveBeenCalledWith(0) 119 | }) 120 | 121 | it('displays the validation process', async () => { 122 | await Validate.run([encryptedConfigPath, '--config-root', configRoot]) 123 | stderr.stop() 124 | stdout.stop() 125 | 126 | expect(stderr.output).toMatch(`Validating ${encryptedConfigPath}...`) 127 | expect(stderr.output).toMatch(`Loading config: ${encryptedConfigPath}`) 128 | expect(stderr.output).toMatch(`Loading schema from: ${configRoot}`) 129 | expect(stderr.output).toMatch('Validating config against schema...') 130 | expect(stderr.output).toMatch(`${encryptedConfigPath} is valid!`) 131 | }) 132 | 133 | describe('error handling', () => { 134 | describe('with non-existing config files', () => { 135 | const nonExistingFile = 'i-dont-exist.yml' 136 | const originalError = new Error("Couldn't find config file") 137 | 138 | beforeEach(() => { 139 | loadConfigFromPath.mockImplementationOnce(() => { 140 | throw originalError 141 | }) 142 | }) 143 | 144 | it('fails', async () => { 145 | await Validate.run(['i-dont-exist.yml']) 146 | stderr.stop() 147 | 148 | expect(process.exit).toHaveBeenCalledWith(1) 149 | }) 150 | 151 | it('displays a human-readable error message', async () => { 152 | await Validate.run([nonExistingFile]) 153 | stderr.stop() 154 | 155 | expect(stderr.output).toContain( 156 | `${nonExistingFile} => Error during validation` 157 | ) 158 | expect(process.exit).toHaveBeenCalledWith(1) 159 | }) 160 | 161 | it('displays original error', async () => { 162 | await Validate.run([nonExistingFile]) 163 | stdout.stop() 164 | 165 | expect(console.error).toHaveBeenCalledWith(originalError, '\n') 166 | expect(process.exit).toHaveBeenCalledWith(1) 167 | }) 168 | }) 169 | 170 | describe("when schema can't be found in configRoot", () => { 171 | beforeEach(() => { 172 | // eslint-disable-next-line unicorn/no-useless-undefined 173 | loadSchema.mockReturnValue(undefined) 174 | }) 175 | 176 | afterAll(() => { 177 | loadSchema.mockRestore() 178 | }) 179 | 180 | it('fails', async () => { 181 | await Validate.run([encryptedConfigPath]) 182 | 183 | expect(process.exit).toHaveBeenCalledWith(1) 184 | }) 185 | 186 | it('displays human-readable error message', async () => { 187 | await Validate.run([encryptedConfigPath]) 188 | stderr.stop() 189 | 190 | expect(stderr.output).toMatch( 191 | `No schema found in your config root ./${defaultOptions.configRoot}/schema.json` 192 | ) 193 | }) 194 | 195 | it('displays original error', async () => { 196 | const ajvError = new Error('schema must be object or boolean') 197 | await Validate.run([encryptedConfigPath]) 198 | 199 | expect(console.error).toHaveBeenCalledWith(ajvError, '\n') 200 | }) 201 | }) 202 | 203 | describe('when validation fails', () => { 204 | const ajvErrors = 'data should NOT have additional properties' 205 | 206 | beforeEach(() => { 207 | ajvValidate.mockReturnValueOnce(false) 208 | jest.spyOn(Ajv.prototype, 'errorsText').mockReturnValueOnce(ajvErrors) 209 | }) 210 | 211 | it('displays detailed validation errors from ajv', async () => { 212 | await Validate.run([encryptedConfigPath]) 213 | stderr.stop() 214 | 215 | expect(stderr.output).toMatch( 216 | `${encryptedConfigPath} is invalid:\n${formatAjvErrors( 217 | ajvErrors 218 | )}\n` 219 | ) 220 | }) 221 | }) 222 | }) 223 | }) 224 | 225 | describe('for MULTIPLE (but not all) files', () => { 226 | it('should validate all config files passed as arguments', async () => { 227 | const validateOneConfigFileSpy = jest.spyOn( 228 | validateCommand, 229 | 'validateOneConfigFile' 230 | ) 231 | await Validate.run([ 232 | '--config-root', 233 | configRoot, 234 | encryptedConfigPath, 235 | unencryptedConfigPath, 236 | ]) 237 | 238 | expect(validateOneConfigFileSpy).toHaveBeenCalledTimes( 239 | someConfigFiles.length 240 | ) 241 | expect(validateOneConfigFileSpy).toHaveBeenNthCalledWith( 242 | 1, 243 | encryptedConfigPath, 244 | configRoot, 245 | schema 246 | ) 247 | 248 | expect(validateOneConfigFileSpy).toHaveBeenNthCalledWith( 249 | 2, 250 | unencryptedConfigPath, 251 | configRoot, 252 | schema 253 | ) 254 | }) 255 | 256 | it('should exit with code 1 and error message when not all config files are valid', async () => { 257 | const ajvErrors = 'data should NOT have additional properties' 258 | ajvValidate.mockReturnValueOnce(false) 259 | jest.spyOn(Ajv.prototype, 'errorsText').mockReturnValueOnce(ajvErrors) 260 | 261 | await Validate.run([ 262 | '--config-root', 263 | configRoot, 264 | invalidConfigPath, 265 | encryptedConfigPath, 266 | ]) 267 | stderr.stop() 268 | 269 | expect(stderr.output).toContain(`${invalidConfigPath} is invalid`) 270 | expect(process.exit).toHaveBeenCalledWith(1) 271 | }) 272 | }) 273 | 274 | describe('for ALL files', () => { 275 | describe('given a folder with multiple config files', () => { 276 | it('should find all *.yaml and *.yml files in the configRoot dir', async () => { 277 | const validateOneConfigFileSpy = jest.spyOn( 278 | validateCommand, 279 | 'validateOneConfigFile' 280 | ) 281 | await Validate.run(['--config-root', configRoot]) 282 | 283 | expect(validateOneConfigFileSpy).toHaveBeenCalledTimes( 284 | allConfigFiles.length 285 | ) 286 | 287 | expect(validateOneConfigFileSpy).toHaveBeenNthCalledWith( 288 | 1, 289 | allConfigFiles[0], 290 | configRoot, 291 | schema 292 | ) 293 | 294 | expect(validateOneConfigFileSpy).toHaveBeenNthCalledWith( 295 | 2, 296 | allConfigFiles[1], 297 | configRoot, 298 | schema 299 | ) 300 | }) 301 | 302 | it('should exit with code 1 and error message when not all config files are valid', async () => { 303 | const ajvErrors = 'data should NOT have additional properties' 304 | ajvValidate.mockReturnValueOnce(false) 305 | jest.spyOn(Ajv.prototype, 'errorsText').mockReturnValueOnce(ajvErrors) 306 | 307 | await Validate.run(['--config-root', 'example']) 308 | stderr.stop() 309 | 310 | expect(stderr.output).toMatch('✖ Validation failed') 311 | expect(process.exit).toHaveBeenCalledWith(1) 312 | }) 313 | }) 314 | 315 | describe('given a folder with NO config files', () => { 316 | it('should exit with code 1 and error message', async () => { 317 | const invalidConfigRoot = './i/dont/exist' 318 | await Validate.run(['--config-root', invalidConfigRoot]) 319 | stderr.stop() 320 | 321 | expect(stderr.output).toMatch( 322 | new RegExp(`✖.*Found no config files in.*${invalidConfigRoot}`) 323 | ) 324 | expect(process.exit).toHaveBeenCalledWith(1) 325 | }) 326 | }) 327 | }) 328 | }) 329 | -------------------------------------------------------------------------------- /src/cli/validate.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Args, Command, Flags } from '@oclif/core' 3 | import Ajv from 'ajv' 4 | import fastGlob from 'fast-glob' 5 | import ora from 'ora' 6 | import { formatAjvErrors } from '../utils/format-ajv-errors' 7 | import { defaultOptions } from '../options' 8 | import { loadSchema, loadConfigFromPath } from '../utils/load-files' 9 | import * as sops from '../utils/sops' 10 | import { DecryptedConfig, Schema } from '../types' 11 | 12 | export const validateOneConfigFile = ( 13 | configPath: string, 14 | configRoot: string, 15 | schema: Schema 16 | ): boolean => { 17 | const spinner = ora(`Validating ${configPath}...`).start() 18 | const ajv = new Ajv({ allErrors: true, useDefaults: true }) 19 | 20 | try { 21 | spinner.text = `Loading config: ${configPath}` 22 | spinner.render() 23 | const configFile = loadConfigFromPath(configPath, configRoot) 24 | 25 | spinner.text = `Decrypting config with sops...` 26 | spinner.render() 27 | const decryptedConfig: DecryptedConfig = sops.decryptToObject( 28 | configFile.filePath, 29 | configFile.contents 30 | ) 31 | 32 | spinner.text = 'Validating config against schema...' 33 | spinner.render() 34 | 35 | if (ajv.validate(schema, decryptedConfig)) { 36 | spinner.succeed(`${configPath} is valid!`) 37 | 38 | return true 39 | } else { 40 | spinner.fail( 41 | `${configPath} is invalid:\n${formatAjvErrors(ajv.errorsText())}\n` 42 | ) 43 | 44 | return false 45 | } 46 | } catch (error) { 47 | spinner.fail(`${configPath} => Error during validation:`) 48 | console.error(error, '\n') 49 | 50 | return false 51 | } 52 | } 53 | 54 | export class Validate extends Command { 55 | static description = 'validate config file(s) against a JSON schema' 56 | 57 | // Allows passing multiple args to this command 58 | static strict = false 59 | 60 | static flags = { 61 | 'config-root': Flags.string({ 62 | char: 'c', 63 | description: 'your config folder containing your config files and schema', 64 | default: defaultOptions.configRoot, 65 | }), 66 | help: Flags.help({ 67 | char: 'h', 68 | description: 'show help', 69 | }), 70 | } 71 | 72 | static args = { 73 | config_file: Args.string({ 74 | description: 75 | '[optional] path to a specific config file to validate. if omitted, all config files will be validated', 76 | required: false, 77 | }), 78 | } 79 | 80 | static usage = 'validate [CONFIG_FILE]' 81 | 82 | static examples = [ 83 | '<%= config.bin %> <%= command.id %>', 84 | '<%= config.bin %> <%= command.id %> config/development.yaml', 85 | '<%= config.bin %> <%= command.id %> --config-root ./nested/config-folder', 86 | ] 87 | 88 | async validateAllConfigFiles( 89 | configRoot: string, 90 | schema: Schema 91 | ): Promise { 92 | const spinner = ora( 93 | `Validating all config files in ${configRoot}...` 94 | ).start() 95 | 96 | const globPattern = `${configRoot}/**/*.{yml,yaml}` 97 | const configFiles = await fastGlob(globPattern) 98 | 99 | if (configFiles.length === 0) { 100 | spinner.fail(`Found no config files in '${globPattern}'`) 101 | process.exit(1) 102 | } 103 | 104 | const validationResults = configFiles.map((configPath) => 105 | validateOneConfigFile(configPath, configRoot, schema) 106 | ) 107 | 108 | if (validationResults.includes(false)) { 109 | console.log('\n') 110 | spinner.fail('Validation failed') 111 | 112 | return false 113 | } else { 114 | spinner.stop() 115 | 116 | return true 117 | } 118 | } 119 | 120 | async run(): Promise { 121 | const { argv, flags } = await this.parse(Validate) 122 | const configRoot = flags['config-root'] 123 | const spinner = ora(`Loading schema from: ${configRoot}`).start() 124 | const schema = loadSchema(configRoot) 125 | 126 | if (!schema) { 127 | spinner.fail( 128 | `No schema found in your config root ./${configRoot}/schema.json\nCan't validate config without a schema because there's nothing to validate against :)\nPlease create a 'schema.json' in your config folder\n` 129 | ) 130 | process.exit(1) 131 | } 132 | 133 | if (argv.length > 0) { 134 | for (const configPath of argv) { 135 | // We can ignore this because in practice oclif would fail if passed a non-string arg 136 | /* istanbul ignore next */ 137 | if (typeof configPath !== 'string') { 138 | throw new TypeError( 139 | `Received argument was not a string but: ${typeof configPath}` 140 | ) 141 | } 142 | 143 | validateOneConfigFile(configPath, configRoot, schema) || process.exit(1) 144 | } 145 | 146 | process.exit(0) 147 | } else { 148 | ;(await this.validateAllConfigFiles(configRoot, schema)) 149 | ? process.exit(0) 150 | : process.exit(1) 151 | } 152 | 153 | process.exit(0) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/core/generate-types.test.ts: -------------------------------------------------------------------------------- 1 | import * as readFiles from '../utils/load-files' 2 | import * as sops from '../utils/sops' 3 | import { 4 | validOptions, 5 | encryptedConfigFile, 6 | schema, 7 | decryptedConfig, 8 | } from '../fixtures' 9 | import { generateTypesFromSchemaCallback } from '../utils/generate-types-from-schema' 10 | 11 | // Needed for commonjs compatibility 12 | // eslint-disable-next-line @typescript-eslint/no-require-imports 13 | import StrongConfig = require('.') 14 | 15 | jest.mock('../utils/generate-types-from-schema') 16 | 17 | describe('Type Generation', () => { 18 | const originalEnv = process.env[validOptions.runtimeEnvName] 19 | jest.spyOn(console, 'info').mockReturnValue() 20 | jest.spyOn(readFiles, 'loadConfigForEnv').mockReturnValue(encryptedConfigFile) 21 | jest.spyOn(readFiles, 'loadSchema').mockReturnValue(schema) 22 | jest.spyOn(sops, 'decryptToObject').mockReturnValue(decryptedConfig) 23 | 24 | beforeEach(() => { 25 | jest.clearAllMocks() 26 | }) 27 | 28 | describe("when process.env[runtimeEnvName] is 'development'", () => { 29 | beforeEach(() => { 30 | jest.clearAllMocks() 31 | process.env[validOptions.runtimeEnvName] = 'development' 32 | }) 33 | 34 | afterAll(() => { 35 | process.env[validOptions.runtimeEnvName] = originalEnv 36 | }) 37 | 38 | describe('when options.generateTypes is TRUE', () => { 39 | it('generates types', () => { 40 | new StrongConfig({ 41 | ...validOptions, 42 | generateTypes: true, 43 | typesPath: '@types', 44 | }) 45 | 46 | expect(generateTypesFromSchemaCallback).toHaveBeenCalledWith( 47 | validOptions.configRoot, 48 | '@types', 49 | expect.any(Function) 50 | ) 51 | }) 52 | 53 | it('if options.typesPath is undefined, then use configRoot as the default output path for generated types', () => { 54 | new StrongConfig({ ...validOptions, generateTypes: true }) 55 | 56 | expect(generateTypesFromSchemaCallback).toHaveBeenCalledWith( 57 | validOptions.configRoot, 58 | validOptions.configRoot, 59 | expect.any(Function) 60 | ) 61 | }) 62 | }) 63 | 64 | describe('when options.generateTypes is FALSE', () => { 65 | it('skips generating types', () => { 66 | new StrongConfig({ ...validOptions, generateTypes: false }) 67 | 68 | expect(generateTypesFromSchemaCallback).toHaveBeenCalledTimes(0) 69 | }) 70 | }) 71 | }) 72 | 73 | describe("when process.env.NODE_ENV is anything other than 'development'", () => { 74 | beforeEach(() => { 75 | process.env[validOptions.runtimeEnvName] = 'production' 76 | }) 77 | 78 | afterAll(() => { 79 | process.env[validOptions.runtimeEnvName] = originalEnv 80 | }) 81 | 82 | it('skips generating types', () => { 83 | new StrongConfig(validOptions) 84 | 85 | expect(generateTypesFromSchemaCallback).not.toHaveBeenCalled() 86 | }) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/core/get-config.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import Ajv from 'ajv' 3 | import { optionsSchema, defaultOptions } from '../options' 4 | import * as readFiles from '../utils/load-files' 5 | import * as sops from '../utils/sops' 6 | import { 7 | validOptions, 8 | encryptedConfigFile, 9 | decryptedConfig, 10 | hydratedConfig, 11 | schema, 12 | } from '../fixtures' 13 | import * as hydrateConfigModule from './../utils/hydrate-config' 14 | 15 | // Needed for commonjs compatibility 16 | // eslint-disable-next-line @typescript-eslint/no-require-imports 17 | import StrongConfig = require('.') 18 | 19 | jest.mock('../utils/generate-types-from-schema') 20 | 21 | describe('StrongConfig.getConfig()', () => { 22 | const OLD_ENV = process.env 23 | const runtimeEnv = 'development' 24 | 25 | jest.spyOn(console, 'info').mockReturnValue() 26 | const loadConfigForEnv = jest.spyOn(readFiles, 'loadConfigForEnv') 27 | const loadSchema = jest.spyOn(readFiles, 'loadSchema') 28 | const decryptToObject = jest.spyOn(sops, 'decryptToObject') 29 | const hydrateConfig = jest.spyOn(hydrateConfigModule, 'hydrateConfig') 30 | 31 | beforeEach(() => { 32 | jest.clearAllMocks() 33 | process.env[defaultOptions.runtimeEnvName] = runtimeEnv 34 | loadConfigForEnv.mockReturnValue(encryptedConfigFile) 35 | decryptToObject.mockReturnValue(decryptedConfig) 36 | loadSchema.mockReturnValue(schema) 37 | hydrateConfig.mockReturnValue(hydratedConfig) 38 | }) 39 | 40 | afterAll(() => { 41 | process.env = OLD_ENV 42 | jest.restoreAllMocks() 43 | }) 44 | 45 | describe('upon first invocation, when config is not memoized yet', () => { 46 | it('loads the config file from disk, based on runtimeEnv', () => { 47 | new StrongConfig(validOptions) 48 | 49 | expect(loadConfigForEnv).toHaveBeenCalledWith( 50 | runtimeEnv, 51 | validOptions.configRoot, 52 | validOptions.baseConfig 53 | ) 54 | }) 55 | 56 | it('decrypts the loaded config file', () => { 57 | new StrongConfig(validOptions) 58 | 59 | expect(sops.decryptToObject).toHaveBeenCalledWith( 60 | encryptedConfigFile.filePath, 61 | encryptedConfigFile.contents 62 | ) 63 | }) 64 | 65 | it('hydrates the config object', () => { 66 | new StrongConfig(validOptions) 67 | 68 | expect(hydrateConfig).toHaveBeenCalledWith(decryptedConfig, runtimeEnv) 69 | }) 70 | 71 | describe('when a schema exists', () => { 72 | it('should validate the loaded config against the schema', () => { 73 | jest.spyOn(Ajv.prototype, 'validate').mockReturnValue(true) 74 | jest.spyOn(StrongConfig.prototype, 'validate') 75 | 76 | const sc = new StrongConfig(validOptions) 77 | 78 | expect(sc.validate).toHaveBeenCalledTimes(2) 79 | expect(sc.validate).toHaveBeenNthCalledWith( 80 | 1, 81 | validOptions, 82 | optionsSchema 83 | ) 84 | 85 | expect(sc.validate).toHaveBeenNthCalledWith(2, hydratedConfig, schema) 86 | }) 87 | 88 | it('should trigger type generation', () => { 89 | jest.spyOn(StrongConfig.prototype, 'generateTypes') 90 | 91 | const sc = new StrongConfig(validOptions) 92 | 93 | expect(sc.generateTypes).toHaveBeenCalledTimes(1) 94 | }) 95 | }) 96 | }) 97 | 98 | describe('after first invocation, when config is memoized', () => { 99 | it('should NOT load config file from disk and return memoized config directly', () => { 100 | const sc = new StrongConfig(validOptions) 101 | const firstLoadResult = sc.getConfig() 102 | 103 | expect(loadConfigForEnv).toHaveBeenCalled() 104 | expect(sops.decryptToObject).toHaveBeenCalled() 105 | expect(hydrateConfig).toHaveBeenCalled() 106 | 107 | jest.clearAllMocks() 108 | 109 | const secondLoadResult = sc.getConfig() 110 | 111 | expect(loadConfigForEnv).not.toHaveBeenCalled() 112 | expect(sops.decryptToObject).not.toHaveBeenCalled() 113 | expect(hydrateConfig).not.toHaveBeenCalled() 114 | 115 | expect(firstLoadResult).toStrictEqual(secondLoadResult) 116 | }) 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /src/core/get-schema.test.ts: -------------------------------------------------------------------------------- 1 | import * as readFiles from '../utils/load-files' 2 | import * as sops from '../utils/sops' 3 | import { 4 | validOptions, 5 | encryptedConfigFile, 6 | schema, 7 | decryptedConfig, 8 | } from '../fixtures' 9 | 10 | // Needed for commonjs-compatibility 11 | // eslint-disable-next-line @typescript-eslint/no-require-imports 12 | import StrongConfig = require('.') 13 | 14 | describe('StrongConfig.getSchema()', () => { 15 | jest.spyOn(console, 'info').mockReturnValue() 16 | jest.spyOn(readFiles, 'loadConfigForEnv').mockReturnValue(encryptedConfigFile) 17 | jest.spyOn(sops, 'decryptToObject').mockReturnValue(decryptedConfig) 18 | const loadSchema = jest.spyOn(readFiles, 'loadSchema') 19 | let sc: StrongConfig 20 | 21 | beforeEach(() => { 22 | jest.clearAllMocks() 23 | }) 24 | 25 | describe('when a schema exists', () => { 26 | beforeEach(() => { 27 | loadSchema.mockReturnValue(schema) 28 | }) 29 | 30 | it('loads the schema file from disk upon first invocation', () => { 31 | sc = new StrongConfig(validOptions) 32 | 33 | expect(loadSchema).toHaveBeenCalledWith(validOptions.configRoot) 34 | }) 35 | 36 | it('memoizes the schema on subsequent calls and does NOT hit the disk', () => { 37 | sc = new StrongConfig(validOptions) 38 | 39 | expect(loadSchema).toHaveBeenCalledWith(validOptions.configRoot) 40 | 41 | const memoizedSchema = sc.getSchema() 42 | 43 | expect(loadSchema).not.toHaveBeenCalledWith() 44 | expect(memoizedSchema).toStrictEqual(schema) 45 | }) 46 | }) 47 | 48 | describe('when NO schema exists', () => { 49 | beforeEach(() => { 50 | // eslint-disable-next-line unicorn/no-useless-undefined 51 | loadSchema.mockReturnValue(undefined) 52 | }) 53 | 54 | it('should return `null`', () => { 55 | jest.spyOn(StrongConfig.prototype, 'getSchema') 56 | sc = new StrongConfig(validOptions) 57 | 58 | expect(sc.getSchema()).toBeNull() 59 | }) 60 | 61 | it('should still memoize `null` as schema to avoid retrying to read a schema from disk on subsequent calls', () => { 62 | jest.spyOn(StrongConfig.prototype, 'getSchema') 63 | sc = new StrongConfig(validOptions) 64 | 65 | expect(loadSchema).toHaveBeenCalled() 66 | 67 | jest.clearAllMocks() 68 | 69 | sc.getSchema() 70 | 71 | expect(loadSchema).not.toHaveBeenCalled() 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/core/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/unbound-method */ 2 | import Ajv from 'ajv' 3 | import { optionsSchema, defaultOptions } from '../options' 4 | import * as readFiles from '../utils/load-files' 5 | import * as sops from '../utils/sops' 6 | import { 7 | schema, 8 | validOptions, 9 | encryptedConfigFile, 10 | hydratedConfig, 11 | decryptedConfig, 12 | } from '../fixtures' 13 | 14 | // Needed for commonjs-compatibility 15 | // eslint-disable-next-line @typescript-eslint/no-require-imports 16 | import StrongConfig = require('.') 17 | 18 | jest.mock('../utils/generate-types-from-schema') 19 | 20 | describe('StrongConfig.constructor()', () => { 21 | const loadConfigForEnv = jest.spyOn(readFiles, 'loadConfigForEnv') 22 | const loadSchema = jest.spyOn(readFiles, 'loadSchema') 23 | const decryptToObject = jest.spyOn(sops, 'decryptToObject') 24 | 25 | beforeAll(() => { 26 | jest.spyOn(console, 'info').mockImplementation() 27 | }) 28 | 29 | beforeEach(() => { 30 | jest.clearAllMocks() 31 | loadConfigForEnv.mockReturnValue(encryptedConfigFile) 32 | loadSchema.mockReturnValue(schema) 33 | decryptToObject.mockReturnValue(decryptedConfig) 34 | }) 35 | 36 | afterAll(() => { 37 | jest.restoreAllMocks() 38 | }) 39 | 40 | it('can be instantiated without any options', () => { 41 | expect(new StrongConfig()).toBeDefined() 42 | }) 43 | 44 | it('can be instantiated with options', () => { 45 | expect(new StrongConfig(validOptions)).toBeDefined() 46 | }) 47 | 48 | it('validates the passed options object against the optionsSchema', () => { 49 | jest.spyOn(StrongConfig.prototype, 'validate') 50 | 51 | const sc = new StrongConfig(validOptions) 52 | 53 | expect(sc.validate).toHaveBeenCalledWith(validOptions, optionsSchema) 54 | expect(sc.validate).toHaveReturnedWith(true) 55 | }) 56 | 57 | it('throws if runtimeEnv is not set', () => { 58 | const originalEnv = process.env[defaultOptions.runtimeEnvName] 59 | delete process.env[defaultOptions.runtimeEnvName] 60 | 61 | expect(() => new StrongConfig()).toThrow( 62 | `process.env.${defaultOptions.runtimeEnvName} needs to be set but was 'undefined'` 63 | ) 64 | 65 | process.env[defaultOptions.runtimeEnvName] = originalEnv 66 | }) 67 | 68 | /* eslint-disable no-console */ 69 | it('throws if the passed options object is invalid', () => { 70 | const invalidOptions = { i: 'am', super: 'invalid' } 71 | const originalConsoleError = console.error 72 | jest.spyOn(console, 'error').mockImplementation() 73 | const validate = jest.spyOn(StrongConfig.prototype, 'validate') 74 | 75 | expect( 76 | // @ts-ignore explicitly testing invalid options here 77 | () => new StrongConfig(invalidOptions) 78 | ).toThrow('config must NOT have additional properties') 79 | 80 | expect(validate).toHaveBeenCalledWith( 81 | { ...defaultOptions, ...invalidOptions }, 82 | optionsSchema 83 | ) 84 | 85 | expect(console.error).toHaveBeenCalledWith( 86 | expect.stringMatching("Invalid options passed to 'new StrongConfig") 87 | ) 88 | 89 | console.error = originalConsoleError 90 | }) 91 | /* eslint-enable no-console */ 92 | 93 | it('stores the validated options object', () => { 94 | const sc = new StrongConfig(validOptions) 95 | 96 | expect(sc.options).toStrictEqual(validOptions) 97 | }) 98 | 99 | describe('when a schema exists', () => { 100 | it('should validate the loaded config against the schema', () => { 101 | jest.spyOn(Ajv.prototype, 'validate').mockReturnValue(true) 102 | jest.spyOn(StrongConfig.prototype, 'validate') 103 | 104 | const sc = new StrongConfig(validOptions) 105 | 106 | expect(sc.validate).toHaveBeenCalledTimes(2) 107 | expect(sc.validate).toHaveBeenNthCalledWith( 108 | 1, 109 | validOptions, 110 | optionsSchema 111 | ) 112 | expect(sc.validate).toHaveBeenNthCalledWith(2, hydratedConfig, schema) 113 | }) 114 | }) 115 | 116 | describe('when NO schema exists', () => { 117 | it('should skip validation', () => { 118 | // eslint-disable-next-line unicorn/no-useless-undefined 119 | loadSchema.mockReturnValueOnce(undefined) 120 | jest.spyOn(StrongConfig.prototype, 'validate') 121 | 122 | const sc = new StrongConfig(validOptions) 123 | 124 | // The first call to StrongConfig.validate() happens in the constructor to validate the options 125 | expect(sc.validate).toHaveBeenCalledTimes(1) 126 | expect(sc.validate).toHaveBeenCalledWith(validOptions, optionsSchema) 127 | 128 | // The second time would have been the call to validate the actual config which should not happen without a schema 129 | expect(sc.validate).not.toHaveBeenCalledTimes(2) 130 | }) 131 | 132 | it('should skip type generation', () => { 133 | jest.spyOn(StrongConfig.prototype, 'getSchema').mockReturnValue(null) 134 | jest.spyOn(StrongConfig.prototype, 'generateTypes') 135 | const sc = new StrongConfig(validOptions) 136 | 137 | expect(sc.generateTypes).not.toHaveBeenCalled() 138 | }) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | import Debug from 'debug' 2 | import Ajv from 'ajv' 3 | import type { Options } from '../options' 4 | import { optionsSchema, defaultOptions } from '../options' 5 | import type { HydratedConfig, MemoizedConfig, Schema } from '../types' 6 | import { formatAjvErrors } from '../utils/format-ajv-errors' 7 | import { generateTypesFromSchemaCallback } from '../utils/generate-types-from-schema' 8 | import { hydrateConfig } from '../utils/hydrate-config' 9 | import { loadSchema, loadConfigForEnv } from '../utils/load-files' 10 | import * as sops from '../utils/sops' 11 | 12 | const debug = Debug('strong-config:main') 13 | 14 | /** 15 | * The main Strong Config interface 16 | * 17 | * @remarks 18 | * This class lets you interact with your application configuration in a simple, safe, 19 | * and reliable way. It finds & loads configuration files from the file system, decrypts 20 | * encrypted config values, and optionally validates config against a user-defined json-schema. 21 | * If a schema is defined, it will also auto-generate typescript types for your config 22 | * for a better development experience. 23 | * 24 | * This class should be instantiated just once per application in order to avoid unnecessary 25 | * repetitive file system lookups. 26 | * 27 | * @example 28 | * ```js 29 | * // In Node.js projects, import it like any other module: 30 | * const StrongConfig = require('strong-config/node') // (omitted at-sign in package name due to tsdoc incompatibility) 31 | * ``` 32 | * 33 | * @example 34 | * ```ts 35 | * // In TypeScript projects, you have to use 'import =' syntax (because we want to stay CommonJS-compatible): 36 | * import StrongConfig = require('strong-config/node') // (omitted at-sign in package name due to tsdoc incompatibility) 37 | * ``` 38 | * 39 | * @example 40 | * ```ts 41 | * // It's advisable to instantiate Strong Config only once to leverage memoization and avoid unnecessary disk lookups 42 | * 43 | * // ./src/config.ts 44 | * import StrongConfig = require('strong-config/node') // (omitted at-sign in package name due to tsdoc incompatibility) 45 | * const config = new StrongConfig().getConfig() 46 | * export default config 47 | * 48 | * // ./src/index.ts 49 | * import config from './config' // import memoized config instead of doing 'new StrongConfig()' again 50 | * console.log("Here is your memoized config:", config) 51 | * ``` 52 | * 53 | * @remarks 54 | * We use `export =` syntax for CommonJS compatibility 55 | * https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require 56 | */ 57 | export = class StrongConfig { 58 | public readonly options: Options 59 | 60 | // FIXME: Change this to 'private' after dropping CommonJS support 61 | public readonly runtimeEnv: string 62 | 63 | // FIXME: Change this to 'private' after dropping CommonJS support 64 | public config: MemoizedConfig 65 | 66 | // FIXME: Change this to 'private' after dropping CommonJS support 67 | public schema: Schema | null | undefined 68 | 69 | /** 70 | * Initializes StrongConfig and loads & memoizes config (plus schema, if existent) 71 | * 72 | * @param options - custom options to override defaults 73 | * 74 | * @remarks 75 | * The constructor also ensures that the options are valid, 76 | * and a runtime env has been set. 77 | */ 78 | constructor(userOptions?: Partial) { 79 | const options = userOptions 80 | ? { ...defaultOptions, ...userOptions } 81 | : defaultOptions 82 | 83 | if ( 84 | !process.env[options.runtimeEnvName] || 85 | typeof process.env[options.runtimeEnvName] !== 'string' 86 | ) { 87 | throw new Error( 88 | `[strong-config 💪]: process.env.${ 89 | options.runtimeEnvName 90 | } needs to be set but was '${ 91 | process.env[options.runtimeEnvName] || 'undefined' 92 | }'\nMake sure it's set when starting the app, for example: '${ 93 | options.runtimeEnvName 94 | }'=development yarn start'\n` 95 | ) 96 | } 97 | 98 | try { 99 | this.validate(options, optionsSchema) 100 | this.options = options 101 | } catch (error) { 102 | // eslint-disable-next-line no-console 103 | console.error( 104 | `Invalid options passed to 'new StrongConfig({ ${JSON.stringify( 105 | options, 106 | undefined, 107 | 2 108 | )}})'` 109 | ) 110 | 111 | throw error 112 | } 113 | 114 | // Typecasting to string is safe as we've determined it's a string in the previous if-statement 115 | this.runtimeEnv = process.env[this.options.runtimeEnvName] as string 116 | 117 | this.schema = this.getSchema() || null 118 | this.config = this.getConfig() 119 | 120 | if (this.schema) { 121 | debug('Loaded schema file: %O', this.schema) 122 | 123 | debug('Validating config against schema') 124 | this.validate(this.config, this.schema) 125 | debug('Config is valid') 126 | 127 | if ( 128 | this.options.generateTypes && 129 | process.env[this.options.runtimeEnvName] === 'development' 130 | ) { 131 | this.generateTypes() 132 | } 133 | } else { 134 | /* 135 | * Deliberately NOT using debug() here because we want to 136 | * always encourage users to define a schema for their config. 137 | */ 138 | // eslint-disable-next-line no-console 139 | console.info( 140 | `⚠️ No schema file found under '${this.options.configRoot}/schema.json'. We recommend creating a schema so Strong Config can ensure your config is valid.` 141 | ) 142 | } 143 | } 144 | 145 | /** 146 | * Returns the environment-specific decrypted config. 147 | * 148 | * @remarks 149 | * Upon first invocation: loads config file from disk, decrypts encrypted fields, hydrates config, then memoizes it. 150 | * Upon subsequent invocations, returns memoized config directly. 151 | * 152 | * If a schema.json exists in the config folder, then it will also 153 | * a) validate the loaded config object against the schema 154 | * b) generate config types based on the schema 155 | * 156 | * @returns The config 157 | */ 158 | public getConfig(): HydratedConfig { 159 | if (this.config) { 160 | debug('Returning memoized config') 161 | 162 | return this.config 163 | } 164 | 165 | debug('💰 Loading config from disk (expensive operation!)') 166 | const configFile = loadConfigForEnv( 167 | this.runtimeEnv, 168 | this.options.configRoot, 169 | this.options.baseConfig 170 | ) 171 | debug('Loaded config file: %O', configFile) 172 | 173 | const decryptedConfig = sops.decryptToObject( 174 | configFile.filePath, 175 | configFile.contents 176 | ) 177 | debug('Decrypted config: %O', decryptedConfig) 178 | 179 | const config = hydrateConfig(decryptedConfig, this.runtimeEnv) 180 | debug('Hydrated config: %O', config) 181 | 182 | return config 183 | } 184 | 185 | /** 186 | * Returns the schema against which to validate the config 187 | * 188 | * @remarks 189 | * If we can't find a schema file, we're explicitly setting it to 'null'. 190 | * We're doing this to distinguish between the first invocation (where this.schema 191 | * is 'undefined') and subsequent invocations (where this.schema is 'null). 192 | * 193 | * We only want to look for the schema once. 194 | * If we were to leave it 'undefined' then we would look for a schema on disk every time 195 | * getSchema() gets called, which is a waste of work if we already know there is no schema. 196 | * 197 | * @returns The config schema 198 | */ 199 | public getSchema(): Schema | null { 200 | if (this.schema) { 201 | debug('Returning memoized schema') 202 | 203 | return this.schema 204 | } 205 | 206 | if (this.schema === null) { 207 | debug( 208 | `getSchema() :: No schema found in ${this.options.configRoot}. Skip loading.` 209 | ) 210 | 211 | return this.schema 212 | } 213 | 214 | debug('💰 Loading schema from file (expensive operation!)') 215 | 216 | return loadSchema(this.options.configRoot) || null 217 | } 218 | 219 | /** 220 | * Validates config against a user-defined json-schema 221 | * 222 | * @remarks 223 | * Also used to validate the Strong Config options upon instantiation 224 | * 225 | * @param data - A config object to validate 226 | * @param schema - A config schema to validate against 227 | * 228 | * @throws an error if validation failed 229 | * 230 | * @returns true, if validation succeeded 231 | */ 232 | public validate(data: MemoizedConfig | Options, schema: Schema): true { 233 | const ajv = new Ajv({ allErrors: true, useDefaults: true }) 234 | 235 | if (!ajv.validate(schema, data)) { 236 | throw new Error( 237 | `Config validation failed ❌\n${formatAjvErrors(ajv.errorsText())}\n` 238 | ) 239 | } 240 | 241 | return true 242 | } 243 | 244 | /** 245 | * Generates a config.d.ts file in your config folder to strongly type your config object 246 | * 247 | * @example 248 | * ```ts 249 | * // Import the generated types from your config folder 250 | * import { Config } from "../config/config.d.ts" 251 | * // Then use them like this 252 | * const config = new StrongConfig().getConfig() as unknown as Config 253 | * ``` 254 | * The 'as unknown as Config' part is a slightly hacky way of convincing the TypeScript compiler 255 | * that the config variable is in fact of type 'Config'. See more details about this approach in 256 | * this blog article: https://mariusschulz.com/blog/the-unknown-type-in-typescript#using-type-assertions-with-unknown 257 | * 258 | * @remarks 259 | * Why a callback? 260 | * Because if we made this function asynchronous, it would mean that wherever strong-config 261 | * is used, the code from which it's called needs to be asynchronous, too. 262 | * For example, it would be "const strongConfig = await new StrongConfig()" which means also 263 | * that this code would need to be wrapped in an 'async' function. This is too cumbersome. 264 | * 265 | * Also, type generation is a dev-only feature that can safely fail without impacting 266 | * the core functionality of strong-config, so it's OK to not wait for it to finish. 267 | */ 268 | public generateTypes(): void { 269 | debug('Starting type generation...') 270 | generateTypesFromSchemaCallback( 271 | this.options.configRoot, 272 | this.options.typesPath || this.options.configRoot, 273 | /* istanbul ignore next: too difficult to test and too little value in testing it */ 274 | (error) => { 275 | if (error) { 276 | debug('Type generation failed') 277 | // eslint-disable-next-line no-console 278 | console.error('Failed to generate types from schema:', error) 279 | } else { 280 | debug('Type generation succeeded') 281 | } 282 | } 283 | ) 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/core/validate.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import Ajv from 'ajv' 3 | import { optionsSchema } from '../options' 4 | import { 5 | validOptions, 6 | invalidOptions, 7 | encryptedConfigFile, 8 | hydratedConfig, 9 | schema, 10 | decryptedConfig, 11 | } from '../fixtures' 12 | import * as readFiles from '../utils/load-files' 13 | import * as sops from '../utils/sops' 14 | 15 | // Needed for commonjs-compatibility 16 | // eslint-disable-next-line @typescript-eslint/no-require-imports 17 | import StrongConfig = require('.') 18 | 19 | jest.mock('../utils/generate-types-from-schema') 20 | 21 | describe('StrongConfig.validate(data, schema)', () => { 22 | const loadConfigForEnv = jest.spyOn(readFiles, 'loadConfigForEnv') 23 | const loadSchema = jest.spyOn(readFiles, 'loadSchema') 24 | const decryptToObject = jest.spyOn(sops, 'decryptToObject') 25 | const ajvValidate = jest.spyOn(Ajv.prototype, 'validate') 26 | let sc: StrongConfig 27 | 28 | beforeEach(() => { 29 | jest.clearAllMocks() 30 | loadConfigForEnv.mockReturnValue(encryptedConfigFile) 31 | loadSchema.mockReturnValue(schema) 32 | decryptToObject.mockReturnValue(decryptedConfig) 33 | sc = new StrongConfig() 34 | 35 | // Reset mock again because it was implicitly called through `new StrongConfig()` already 36 | ajvValidate.mockClear() 37 | }) 38 | 39 | it('can validate config objects', () => { 40 | sc.validate(hydratedConfig, schema) 41 | 42 | expect(ajvValidate).toHaveBeenCalledWith(schema, hydratedConfig) 43 | expect(ajvValidate).toHaveReturnedWith(true) 44 | 45 | ajvValidate.mockClear() 46 | 47 | const invalidConfig = { i: 'am', not: 'valid' } 48 | 49 | // @ts-ignore explicitly testing invalid input here 50 | expect(() => sc.validate(invalidConfig, schema)).toThrow( 51 | 'config must NOT have additional properties' 52 | ) 53 | expect(ajvValidate).toHaveBeenCalledWith(schema, invalidConfig) 54 | expect(ajvValidate).toHaveReturnedWith(false) 55 | }) 56 | 57 | it('can validate strong-config options', () => { 58 | sc.validate(validOptions, optionsSchema) 59 | 60 | expect(ajvValidate).toHaveBeenCalledWith(optionsSchema, validOptions) 61 | expect(ajvValidate).toHaveReturnedWith(true) 62 | 63 | ajvValidate.mockClear() 64 | 65 | // @ts-ignore explicitly testing invalid input here 66 | expect(() => sc.validate(invalidOptions, optionsSchema)).toThrow( 67 | 'config must NOT have additional properties' 68 | ) 69 | expect(ajvValidate).toHaveBeenCalledWith(optionsSchema, invalidOptions) 70 | expect(ajvValidate).toHaveReturnedWith(false) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/e2e/load-commonjs.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-module */ 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | /* eslint-disable @typescript-eslint/no-require-imports */ 4 | // We want to use the actual compiled JS here to make sure the final npm package works 5 | const { inspect } = require('util') 6 | const chalk = require('chalk') 7 | const StrongConfig = require('@strong-config/node') 8 | 9 | /* 10 | * LOAD WITHOUT BASE CONFIG 11 | */ 12 | console.log( 13 | chalk.bold('\nE2E Test: Load config without base config [commonjs]') 14 | ) 15 | const strongConfig = new StrongConfig() 16 | 17 | const config = strongConfig.getConfig() 18 | 19 | console.log( 20 | `\n✅ Loaded '${chalk.bold(process.env.NODE_ENV || 'undefined')}' config:\n` 21 | ) 22 | console.log(inspect(config, { colors: true, compact: false })) 23 | console.log('\n') 24 | 25 | /* 26 | * LOAD WITH BASE CONFIG 27 | */ 28 | console.log(chalk.bold('E2E Test: Load config with base config [commonjs]')) 29 | const strongConfigWithBaseConfig = new StrongConfig() 30 | 31 | const mergedConfig = strongConfigWithBaseConfig.getConfig() 32 | 33 | console.log( 34 | `\n✅ Loaded '${chalk.bold( 35 | process.env.NODE_ENV || 'undefined' 36 | )}' config extended from base config:\n` 37 | ) 38 | console.log(inspect(mergedConfig, { colors: true, compact: false })) 39 | console.log('\n') 40 | 41 | /* 42 | * ERROR CASES 43 | */ 44 | console.log(chalk.bold('E2E Test: Base config can not contain secrets [ES6]')) 45 | 46 | try { 47 | new StrongConfig({ 48 | configRoot: 'config-with-base-config-containing-secrets', 49 | }) 50 | } catch (error) { 51 | if ( 52 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 53 | error.message !== 54 | "Secret detected in example-with-base-config-containing-secrets/base.yml config. Base config files can not contain secrets because when using different encryption keys per environment, it's unclear which key should be used to encrypt the base config." 55 | ) { 56 | throw new Error( 57 | 'Instantiating strong-config with a base config that contains secrets should fail, but is has not 🤔' 58 | ) 59 | } 60 | } 61 | 62 | console.log(`\n✅ Failed as expected\n`) 63 | 64 | console.log( 65 | chalk.bold( 66 | 'Integration Test: Base config and runtimeEnv cannot have the same name [commonjs]' 67 | ) 68 | ) 69 | 70 | try { 71 | new StrongConfig({ 72 | configRoot: 'config-with-base-config', 73 | baseConfig: 'development.yml', 74 | }) 75 | } catch (error) { 76 | if ( 77 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 78 | error.message !== 79 | `Base config name 'development.yml' must be different from the environment-name 'development'. Base config and env-specific config can't be the same file.` 80 | ) { 81 | throw new Error( 82 | "Instantiating strong-config with a base config whose name is identical to the runtimeEnv (e.g. baseConfig: 'development.yml', process.env.NODE_ENV: 'development') should fail, but it hasn't 🤔" 83 | ) 84 | } 85 | } 86 | 87 | console.log(`\n✅ Failed as expected\n`) 88 | -------------------------------------------------------------------------------- /src/e2e/load-es6.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | import chalk from 'chalk' 3 | 4 | // Needed for commonjs compatibility 5 | /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/ban-ts-comment */ 6 | // @ts-ignore 7 | import StrongConfig = require('@strong-config/node') 8 | /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/ban-ts-comment */ 9 | 10 | /* 11 | * LOAD WITHOUT BASE CONFIG 12 | */ 13 | console.log(chalk.bold('\nE2E Test: Load config without base config [ES6]')) 14 | 15 | const strongConfig = new StrongConfig() 16 | 17 | const config = strongConfig.getConfig() 18 | 19 | console.log( 20 | `\nLoaded ${chalk.bold(process.env.NODE_ENV || 'undefined')} config:\n` 21 | ) 22 | console.log(inspect(config, { colors: true, compact: false })) 23 | console.log('\n') 24 | 25 | /* 26 | * LOAD WITH BASE CONFIG 27 | */ 28 | console.log(chalk.bold('E2E Test: Load config extended from base config [ES6]')) 29 | 30 | const strongConfigWithBaseConfig = new StrongConfig({ 31 | configRoot: 'config-with-base-config', 32 | }) 33 | 34 | const mergedConfig = strongConfigWithBaseConfig.getConfig() 35 | 36 | console.log( 37 | `\nLoaded ${chalk.bold( 38 | process.env.NODE_ENV || 'undefined' 39 | )} config extended from base config:\n` 40 | ) 41 | console.log(inspect(mergedConfig, { colors: true, compact: false })) 42 | console.log('\n') 43 | 44 | /* 45 | * ERROR CASES 46 | */ 47 | console.log(chalk.bold('E2E Test: Base config can not contain secrets [ES6]')) 48 | 49 | try { 50 | new StrongConfig({ 51 | configRoot: 'config-with-base-config-containing-secrets', 52 | }) 53 | } catch (error) { 54 | if ( 55 | error instanceof Error && 56 | error.message !== 57 | "Secret detected in example-with-base-config-containing-secrets/base.yml config. Base config files can not contain secrets because when using different encryption keys per environment, it's unclear which key should be used to encrypt the base config." 58 | ) { 59 | throw new Error( 60 | 'Instantiating strong-config with a base config that contains secrets should fail, but is has not 🤔' 61 | ) 62 | } 63 | } 64 | 65 | console.log(`\n✅ Failed as expected\n`) 66 | 67 | console.log( 68 | chalk.bold( 69 | 'Integration Test: Base config and runtimeEnv cannot have the same name [ES6]' 70 | ) 71 | ) 72 | 73 | try { 74 | new StrongConfig({ 75 | configRoot: 'config-with-base-config', 76 | baseConfig: 'development.yml', 77 | }) 78 | } catch (error) { 79 | if ( 80 | error.message !== 81 | `Base config name 'development.yml' must be different from the environment-name 'development'. Base config and env-specific config can't be the same file.` 82 | ) { 83 | throw new Error( 84 | "Instantiating strong-config with a base config whose name is identical to the runtimeEnv (e.g. baseConfig: 'development.yml', process.env.NODE_ENV: 'development') should fail, but it hasn't 🤔" 85 | ) 86 | } 87 | } 88 | 89 | console.log(`\n✅ Failed as expected\n`) 90 | -------------------------------------------------------------------------------- /src/e2e/validate.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import type { Schema } from '../types' 3 | 4 | // Needed for commonjs compatibility 5 | /* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ 6 | // @ts-ignore 7 | import StrongConfig = require('@strong-config/node') 8 | /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/ban-ts-comment */ 9 | 10 | console.log(chalk.bold('\nE2E Test: Validate config without base config')) 11 | 12 | const strongConfig = new StrongConfig() 13 | 14 | const config = strongConfig.getConfig() 15 | let schema = strongConfig.getSchema() as Schema 16 | 17 | let validationResult, validationError 18 | 19 | try { 20 | validationResult = strongConfig.validate(config, schema) 21 | } catch (error) { 22 | if (error instanceof Error) { 23 | validationResult = false 24 | validationError = error.message 25 | } else { 26 | throw error 27 | } 28 | } 29 | 30 | console.log('\nValidation result: ') 31 | 32 | if (validationResult) { 33 | console.log('✅', validationResult) 34 | } else { 35 | console.log('❌', validationResult) 36 | console.log(validationError) 37 | } 38 | 39 | console.log('') 40 | 41 | console.log(chalk.bold('E2E Test: Validate config with base config')) 42 | 43 | const strongConfigWithBaseConfig = new StrongConfig() 44 | 45 | const mergedConfig = strongConfigWithBaseConfig.getConfig() 46 | schema = strongConfigWithBaseConfig.getSchema() as Schema 47 | 48 | try { 49 | validationResult = strongConfigWithBaseConfig.validate(mergedConfig, schema) 50 | } catch (error) { 51 | if (error instanceof Error) { 52 | validationResult = false 53 | validationError = error.message 54 | } else { 55 | throw error 56 | } 57 | } 58 | 59 | console.log('\nValidation result: ') 60 | 61 | if (validationResult) { 62 | console.log('✅', validationResult) 63 | } else { 64 | console.log('❌', validationResult) 65 | console.log(validationError) 66 | } 67 | 68 | console.log('') 69 | -------------------------------------------------------------------------------- /src/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | import { compose, dissoc, set, lensProp } from 'ramda' 2 | import { defaultOptions } from '../options' 3 | import { 4 | EncryptedConfigFile, 5 | HydratedConfig, 6 | Schema, 7 | DecryptedConfig, 8 | } from '../types' 9 | 10 | const runtimeEnv = 'test' 11 | 12 | export const encryptedConfigFile: EncryptedConfigFile = { 13 | filePath: `/mocked/path/${runtimeEnv}.yaml`, 14 | contents: { 15 | name: 'example-project', 16 | someField: { optionalField: 123, requiredField: 'crucial string' }, 17 | someArray: ['joe', 'freeman'], 18 | someSecret: 19 | 'ENC[AES256_GCM,data:abcdeBH8Xh06,iv:L5VSXtgaD7RXTt7HW0Pra6vDSBTbP8/dkyruXmMKtgU=,tag:tVwtqvz1QLXskFKXT4na7A==,type:str]', 20 | sops: { 21 | kms: [], 22 | gcp_kms: [], 23 | azure_kv: [], 24 | pgp: { 25 | created_at: '2019-11-15T15:12:21Z', 26 | enc: 'PGP MESSAGE', 27 | fp: 'MOCK', 28 | }, 29 | encrypted_suffix: 'Secret', 30 | lastmodified: '', 31 | mac: '', 32 | version: '', 33 | }, 34 | }, 35 | } 36 | 37 | export const userOptions = { configRoot: 'example' } 38 | 39 | export const validOptions = { ...defaultOptions, ...userOptions } 40 | 41 | export const invalidOptions = { ...defaultOptions, invalid: 'option' } 42 | 43 | // No idea how to make ramda types happy here 🤷‍♂️ 44 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 45 | // @ts-ignore 46 | export const decryptedConfig: DecryptedConfig = compose( 47 | // @ts-ignore 48 | set(lensProp('someSecret'), 'decrypted secret'), 49 | dissoc('sops') 50 | // @ts-ignore 51 | )(encryptedConfigFile.contents) 52 | // eslint-enable @typescript-eslint/ban-ts-comment 53 | 54 | export const hydratedConfig: HydratedConfig = { ...decryptedConfig, runtimeEnv } 55 | 56 | export const schema: Schema = { 57 | type: 'object', 58 | title: 'Example Config', 59 | required: ['name', 'someField'], 60 | additionalProperties: false, 61 | properties: { 62 | runtimeEnv: { 63 | type: 'string', 64 | }, 65 | name: { 66 | title: 'Name', 67 | type: 'string', 68 | }, 69 | someField: { 70 | title: 'Some Field', 71 | type: 'object', 72 | required: ['requiredField'], 73 | additionalProperties: false, 74 | properties: { 75 | requiredField: { 76 | title: 'A required field', 77 | type: 'string', 78 | }, 79 | optionalField: { 80 | title: 'An optional field', 81 | type: 'integer', 82 | }, 83 | }, 84 | }, 85 | someArray: { 86 | title: 'Some Array', 87 | type: 'array', 88 | items: { 89 | type: 'string', 90 | }, 91 | }, 92 | someSecret: { 93 | title: 'Some Secret', 94 | type: 'string', 95 | }, 96 | }, 97 | } 98 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is for your IDE to learn about the additional matchers and offer them via IntelliSense 3 | * https://jest-extended.jestcommunity.dev/docs/getting-started/typescript 4 | */ 5 | // eslint-disable-next-line import/no-unassigned-import 6 | import 'jest-extended' 7 | -------------------------------------------------------------------------------- /src/integration-tests/load-commonjs.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { inspect } = require('util') 3 | const chalk = require('chalk') 4 | 5 | // We want to use the actual compiled JS here to make sure the final npm package works 6 | const StrongConfig = require('../../lib/core') 7 | 8 | /* 9 | * LOAD WITHOUT BASE CONFIG 10 | */ 11 | console.log( 12 | chalk.bold('\nIntegration Test: Load config without base config [commonjs]') 13 | ) 14 | 15 | const strongConfig = new StrongConfig({ 16 | configRoot: 'example', 17 | }) 18 | 19 | const config = strongConfig.getConfig() 20 | 21 | console.log( 22 | `\n✅ Loaded '${chalk.bold(process.env.NODE_ENV || 'undefined')}' config:\n` 23 | ) 24 | console.log(inspect(config, { colors: true, compact: false })) 25 | console.log('\n') 26 | 27 | /* 28 | * LOAD WITH BASE CONFIG 29 | */ 30 | console.log( 31 | chalk.bold( 32 | 'Integration Test: Load config extended from base config [commonjs]' 33 | ) 34 | ) 35 | const strongConfigWithBaseConfig = new StrongConfig({ 36 | configRoot: 'example-with-base-config', 37 | }) 38 | 39 | const mergedConfig = strongConfigWithBaseConfig.getConfig() 40 | 41 | console.log( 42 | `\n✅ Loaded '${chalk.bold( 43 | process.env.NODE_ENV || 'undefined' 44 | )}' config extended from base config:\n` 45 | ) 46 | console.log(inspect(mergedConfig, { colors: true, compact: false })) 47 | console.log('\n') 48 | 49 | /* 50 | * ERROR CASES 51 | */ 52 | console.log( 53 | chalk.bold('Integration Test: Base config can not contain secrets [commonjs]') 54 | ) 55 | 56 | try { 57 | new StrongConfig({ 58 | configRoot: 'example-with-base-config-containing-secrets', 59 | }) 60 | } catch (error) { 61 | if ( 62 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 63 | error.message !== 64 | "Secret detected in example-with-base-config-containing-secrets/base.yml config. Base config files can not contain secrets because when using different encryption keys per environment, it's unclear which key should be used to encrypt the base config." 65 | ) { 66 | throw new Error( 67 | 'Instantiating strong-config with a base config that contains secrets should fail, but is has not 🤔' 68 | ) 69 | } 70 | } 71 | 72 | console.log(`\n✅ Failed as expected\n`) 73 | 74 | console.log( 75 | chalk.bold( 76 | 'Integration Test: Base config and runtimeEnv cannot have the same name [commonjs]' 77 | ) 78 | ) 79 | 80 | try { 81 | new StrongConfig({ 82 | configRoot: 'example-with-base-config', 83 | baseConfig: 'development.yml', 84 | }) 85 | } catch (error) { 86 | if ( 87 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 88 | error.message !== 89 | `Base config name 'development.yml' must be different from the environment-name 'development'. Base config and env-specific config can't be the same file.` 90 | ) { 91 | throw new Error( 92 | "Instantiating strong-config with a base config whose name is identical to the runtimeEnv (e.g. baseConfig: 'development.yml', process.env.NODE_ENV: 'development') should fail, but it hasn't 🤔" 93 | ) 94 | } 95 | } 96 | 97 | console.log(`\n✅ Failed as expected\n`) 98 | -------------------------------------------------------------------------------- /src/integration-tests/load-es6.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | import chalk from 'chalk' 3 | 4 | // Needed for commonjs compatibility 5 | // eslint-disable-next-line @typescript-eslint/no-require-imports 6 | import StrongConfig = require('../core') 7 | 8 | /* 9 | * LOAD WITHOUT BASE CONFIG 10 | */ 11 | console.log( 12 | chalk.bold('\nIntegration Test: Load config without base config [ES6]') 13 | ) 14 | 15 | const strongConfig = new StrongConfig({ 16 | configRoot: 'example', 17 | }) 18 | 19 | const config = strongConfig.getConfig() 20 | 21 | console.log( 22 | `\n✅ Loaded '${chalk.bold(process.env.NODE_ENV || 'undefined')}' config:\n` 23 | ) 24 | console.log(inspect(config, { colors: true, compact: false })) 25 | console.log('\n') 26 | 27 | /* 28 | * LOAD WITH BASE CONFIG 29 | */ 30 | console.log( 31 | chalk.bold('Integration Test: Load config extended from base config [ES6]') 32 | ) 33 | const strongConfigWithBaseConfig = new StrongConfig({ 34 | configRoot: 'example-with-base-config', 35 | }) 36 | 37 | const mergedConfig = strongConfigWithBaseConfig.getConfig() 38 | 39 | console.log( 40 | `\n✅ Loaded '${chalk.bold( 41 | process.env.NODE_ENV || 'undefined' 42 | )}' config extended from base config:\n` 43 | ) 44 | console.log(inspect(mergedConfig, { colors: true, compact: false })) 45 | console.log('\n') 46 | 47 | /* 48 | * ERROR CASES 49 | */ 50 | console.log( 51 | chalk.bold('Integration Test: Base config can not contain secrets [ES6]') 52 | ) 53 | 54 | try { 55 | new StrongConfig({ 56 | configRoot: 'example-with-base-config-containing-secrets', 57 | }) 58 | } catch (error) { 59 | if ( 60 | error.message !== 61 | "Secret detected in example-with-base-config-containing-secrets/base.yml config. Base config files can not contain secrets because when using different encryption keys per environment, it's unclear which key should be used to encrypt the base config." 62 | ) { 63 | throw new Error( 64 | 'Instantiating strong-config with a base config that contains secrets should fail, but is has not 🤔' 65 | ) 66 | } 67 | } 68 | 69 | console.log(`\n✅ Failed as expected\n`) 70 | 71 | console.log( 72 | chalk.bold( 73 | 'Integration Test: Base config and runtimeEnv cannot have the same name [ES6]' 74 | ) 75 | ) 76 | 77 | try { 78 | new StrongConfig({ 79 | configRoot: 'example-with-base-config', 80 | baseConfig: 'development.yml', 81 | }) 82 | } catch (error) { 83 | if ( 84 | error.message !== 85 | `Base config name 'development.yml' must be different from the environment-name 'development'. Base config and env-specific config can't be the same file.` 86 | ) { 87 | throw new Error( 88 | "Instantiating strong-config with a base config whose name is identical to the runtimeEnv (e.g. baseConfig: 'development.yml', process.env.NODE_ENV: 'development') should fail, but it hasn't 🤔" 89 | ) 90 | } 91 | } 92 | 93 | console.log(`\n✅ Failed as expected\n`) 94 | -------------------------------------------------------------------------------- /src/integration-tests/validate.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import type { Schema } from '../types' 3 | 4 | // Needed for commonjs compatibility 5 | // eslint-disable-next-line @typescript-eslint/no-require-imports 6 | import StrongConfig = require('../core') 7 | 8 | console.log(chalk.bold('\nIntegration Test: Validation without base config')) 9 | 10 | const strongConfig = new StrongConfig({ 11 | configRoot: 'example', 12 | }) 13 | 14 | const config = strongConfig.getConfig() 15 | let schema = strongConfig.getSchema() as Schema 16 | 17 | let validationResult, validationError 18 | 19 | try { 20 | validationResult = strongConfig.validate(config, schema) 21 | } catch (error) { 22 | if (error instanceof Error) { 23 | validationResult = false 24 | validationError = error.message 25 | } else { 26 | throw error 27 | } 28 | } 29 | 30 | console.log('\nValidation result: ') 31 | 32 | if (validationResult) { 33 | console.log('✅', validationResult) 34 | } else { 35 | console.log('❌', validationResult) 36 | console.log(validationError) 37 | } 38 | 39 | console.log('') 40 | 41 | console.log(chalk.bold('Integration Test: Validation with base config')) 42 | 43 | const strongConfigWithBaseConfig = new StrongConfig({ 44 | configRoot: 'example-with-base-config', 45 | }) 46 | 47 | const mergedConfig = strongConfigWithBaseConfig.getConfig() 48 | schema = strongConfigWithBaseConfig.getSchema() as Schema 49 | 50 | try { 51 | validationResult = strongConfigWithBaseConfig.validate(mergedConfig, schema) 52 | } catch (error) { 53 | if (error instanceof Error) { 54 | validationResult = false 55 | validationError = error.message 56 | } else { 57 | throw error 58 | } 59 | } 60 | 61 | console.log('\nValidation result: ') 62 | 63 | if (validationResult) { 64 | console.log('✅', validationResult) 65 | } else { 66 | console.log('❌', validationResult) 67 | console.log(validationError) 68 | } 69 | 70 | console.log('') 71 | -------------------------------------------------------------------------------- /src/match-all.d.ts: -------------------------------------------------------------------------------- 1 | // NOTE: This file is needed for the String.prototype.matchAll() polyfill module we need to remain Node 10.x compatible 2 | declare module 'match-all' { 3 | export default function matchAll( 4 | s: string, 5 | r: RegExp 6 | ): { 7 | input: string 8 | regex: RegExp 9 | next: () => string | null 10 | toArray: () => string[] 11 | reset: () => void 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from './types' 2 | 3 | export interface Options { 4 | baseConfig: string 5 | configRoot: string 6 | runtimeEnvName: string 7 | generateTypes: boolean 8 | typesPath?: string 9 | } 10 | 11 | export const defaultOptions: Options = { 12 | baseConfig: 'base.yml', 13 | configRoot: 'config', 14 | runtimeEnvName: 'NODE_ENV', 15 | generateTypes: true, 16 | } 17 | 18 | export const optionsSchema: Schema = { 19 | type: 'object', 20 | title: 'Schema for strong-config options', 21 | required: ['configRoot', 'runtimeEnvName', 'generateTypes'], 22 | additionalProperties: false, 23 | properties: { 24 | configRoot: { 25 | title: 'Config root path', 26 | description: 'A path to a directory that contains all config files', 27 | examples: ['config', '../config', 'app/config/'], 28 | type: 'string', 29 | }, 30 | runtimeEnvName: { 31 | title: 'Runtime environment variable name', 32 | description: 33 | 'The value of this variable determines which config is loaded', 34 | examples: ['NODE_ENV', 'RUNTIME_ENVIRONMENT'], 35 | type: 'string', 36 | pattern: '^[a-zA-Z]\\w*$', 37 | }, 38 | generateTypes: { 39 | title: 'Generate TypeScript types', 40 | description: 41 | 'Boolean that governs whether to generate TypeScript types for the config or not', 42 | type: 'boolean', 43 | }, 44 | typesPath: { 45 | title: 'Types Directory', 46 | description: 47 | 'Path to output directory of the auto-generated config.d.ts type definition file', 48 | examples: ['config', '@types', 'src/types'], 49 | type: 'string', 50 | }, 51 | baseConfig: { 52 | title: 'Base config file name', 53 | description: 54 | 'The filename of the base config from which other configs extend', 55 | examples: ['base.yml', 'defaults.yaml', 'shared.json'], 56 | type: 'string', 57 | }, 58 | }, 59 | } 60 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema4 } from 'json-schema' 2 | 3 | type JSON = string | number | boolean | null | JSON[] | JSONObject 4 | 5 | export type JSONObject = { [key: string]: JSON } 6 | 7 | export type Schema = JSONSchema4 8 | 9 | type SopsMetadata = { 10 | kms: unknown 11 | gcp_kms: unknown 12 | azure_kv: unknown 13 | pgp: unknown 14 | lastmodified: string 15 | mac: string 16 | version: string 17 | encrypted_suffix?: string | null 18 | } 19 | 20 | export type BaseConfig = JSONObject 21 | 22 | export type EncryptedConfig = { sops?: SopsMetadata } & BaseConfig 23 | 24 | export type DecryptedConfig = Omit 25 | 26 | export type HydratedConfig = { runtimeEnv: string } & DecryptedConfig 27 | 28 | export type MemoizedConfig = HydratedConfig | undefined 29 | 30 | export const ConfigFileExtensions = ['json', 'yaml', 'yml'] 31 | 32 | export type EncryptedConfigFile = { 33 | contents: EncryptedConfig 34 | filePath: string 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/format-ajv-errors.ts: -------------------------------------------------------------------------------- 1 | export const formatAjvErrors = (errorText: string): string => 2 | ' - '.concat(errorText.replaceAll(/,\s/g, '\n - ').replaceAll('data', 'config')) 3 | 4 | export default formatAjvErrors 5 | -------------------------------------------------------------------------------- /src/utils/generate-types-from-schema.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import * as jsonSchemaToTypescript from 'json-schema-to-typescript' 3 | import { defaultOptions } from '../options' 4 | import { generateTypesFromSchema } from './generate-types-from-schema' 5 | 6 | describe('generateTypesFromSchema()', () => { 7 | const compiledTypes = ` 8 | export interface TheTopLevelInterface { 9 | name: string 10 | otherField: number 11 | }` 12 | 13 | let readFileSyncSpy: jest.SpyInstance 14 | 15 | beforeEach(() => { 16 | jest.spyOn(fs, 'writeFileSync').mockImplementation() 17 | jest 18 | .spyOn(jsonSchemaToTypescript, 'compileFromFile') 19 | .mockResolvedValue(compiledTypes) 20 | readFileSyncSpy = jest.spyOn(fs, 'readFileSync') 21 | jest.clearAllMocks() 22 | }) 23 | 24 | describe('given a configRoot with a valid schema.json file', () => { 25 | it('generates a TypeScript file with corresponding type definitions', async () => { 26 | const typesPath = '@types' 27 | readFileSyncSpy.mockReturnValueOnce( 28 | `{ "title": "the top-level-interface" }` 29 | ) 30 | 31 | await generateTypesFromSchema(defaultOptions.configRoot, typesPath) 32 | 33 | const expectedRootType = ` 34 | export interface Config extends TheTopLevelInterface { 35 | runtimeEnv: string 36 | }` 37 | const expectedTypes = compiledTypes.concat(expectedRootType) 38 | 39 | expect(fs.writeFileSync).toHaveBeenCalledWith( 40 | `${typesPath}/config.d.ts`, 41 | expectedTypes 42 | ) 43 | }) 44 | }) 45 | 46 | it('throws when top-level schema definition does not have a title field', async () => { 47 | readFileSyncSpy.mockReturnValueOnce( 48 | `{ "required": ["field"], "description": "This is a description" }` 49 | ) 50 | 51 | await expect(async () => 52 | generateTypesFromSchema(defaultOptions.configRoot, '@types') 53 | ).rejects.toThrow( 54 | "Expected top-level attribute 'title' in schema definition." 55 | ) 56 | }) 57 | 58 | it('throws when schema title is not a string', async () => { 59 | const title = true 60 | // We're explicitly testing an invalid value here 61 | 62 | readFileSyncSpy.mockReturnValueOnce(`{ "title": ${title} }`) 63 | 64 | await expect(async () => 65 | generateTypesFromSchema(defaultOptions.configRoot, '@types') 66 | ).rejects.toThrow( 67 | `'Title' attribute in schema definition must be a string, but is of type '${typeof title}'` 68 | ) 69 | }) 70 | 71 | it('throws when schema title is named "config" or "Config"', async () => { 72 | readFileSyncSpy.mockReturnValueOnce(`{ "title": "config" }`) 73 | 74 | await expect(async () => 75 | generateTypesFromSchema(defaultOptions.configRoot, '@types') 76 | ).rejects.toThrow( 77 | 'Title attribute of top-level schema definition must not be named Config or config' 78 | ) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /src/utils/generate-types-from-schema.ts: -------------------------------------------------------------------------------- 1 | import { callbackify } from 'util' 2 | import { readFileSync, writeFileSync } from 'fs' 3 | import { compileFromFile } from 'json-schema-to-typescript' 4 | import Debug from 'debug' 5 | import type { JSONObject } from '../types' 6 | import { pascalCase } from './pascal-case' 7 | 8 | const debug = Debug('strong-config:generate-types') 9 | 10 | export const generateTypesFromSchema = async ( 11 | configRoot: string, 12 | typesPath: string 13 | ): Promise => { 14 | const schemaPath = `${configRoot}/schema.json` 15 | 16 | const baseTypes = await compileFromFile(schemaPath, { 17 | style: { semi: false }, 18 | }) 19 | debug(`Compiled base types from ${schemaPath} : %s`, '\n'.concat(baseTypes)) 20 | 21 | const schemaString = readFileSync(schemaPath).toString() 22 | debug('Read schema file: %s', '\n'.concat(schemaString)) 23 | 24 | const parsedSchemaString = JSON.parse(schemaString) as JSONObject 25 | debug('Parsed schema string to JSON:\n%O\n', parsedSchemaString) 26 | 27 | const title = parsedSchemaString.title 28 | 29 | if (title === undefined) { 30 | throw new Error( 31 | "Expected top-level attribute 'title' in schema definition." 32 | ) 33 | } 34 | 35 | if (typeof title !== 'string') { 36 | throw new TypeError( 37 | `'Title' attribute in schema definition must be a string, but is of type '${typeof title}'` 38 | ) 39 | } 40 | 41 | if (title.toLowerCase() === 'config') { 42 | throw new Error( 43 | 'Title attribute of top-level schema definition must not be named Config or config' 44 | ) 45 | } 46 | 47 | const configInterfaceAsString = ` 48 | export interface Config extends ${pascalCase(title)} { 49 | runtimeEnv: string 50 | }` 51 | // eslint-disable-next-line unicorn/prefer-spread 52 | const exportedTypes = baseTypes.concat(configInterfaceAsString) 53 | 54 | writeFileSync(`${typesPath}/config.d.ts`, exportedTypes) 55 | debug( 56 | `Wrote generated types to file '${typesPath}/config.d.ts': %s`, 57 | '\n'.concat(exportedTypes) 58 | ) 59 | } 60 | 61 | export const generateTypesFromSchemaCallback = callbackify( 62 | generateTypesFromSchema 63 | ) 64 | -------------------------------------------------------------------------------- /src/utils/has-secrets.test.ts: -------------------------------------------------------------------------------- 1 | import { hasSecrets } from './has-secrets' 2 | 3 | describe('hasSecrets()', () => { 4 | it('should return true if a given config object contains secrets', () => { 5 | const config = { 6 | some: 'value', 7 | and: { a: { deeply: { nested: 'value' } } }, 8 | someSecret: 'i am not allowed in a base config 😞', 9 | } 10 | 11 | expect(hasSecrets(config)).toBe(true) 12 | }) 13 | 14 | it('should return true if a given config object contains deeply nested secrets', () => { 15 | const config = { 16 | some: 'value', 17 | and: { a: { deeply: { nestedSecret: 'value' } } }, 18 | } 19 | 20 | expect(hasSecrets(config)).toBe(true) 21 | }) 22 | 23 | it('should return false if a given config object does not contain secrets', () => { 24 | const config = { 25 | some: 'value', 26 | and: { a: { deeply: { nested: 'value' } } }, 27 | } 28 | 29 | expect(hasSecrets(config)).toBe(false) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/utils/has-secrets.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject } from '../types' 2 | 3 | export function hasSecrets(config: JSONObject): boolean { 4 | const recursiveSearchForSecrets = ( 5 | obj: Record, 6 | results: boolean[] = [] 7 | ): boolean[] => { 8 | const r = results 9 | 10 | for (const key of Object.keys(obj)) { 11 | const value = obj[key] as Record 12 | 13 | if (key.endsWith('Secret') && typeof value !== 'object') { 14 | r.push(true) 15 | } else if (typeof value === 'object') { 16 | recursiveSearchForSecrets(value, r) 17 | } 18 | } 19 | 20 | return r 21 | } 22 | 23 | return recursiveSearchForSecrets(config).includes(true) 24 | } 25 | 26 | export default hasSecrets 27 | -------------------------------------------------------------------------------- /src/utils/hydrate-config.test.ts: -------------------------------------------------------------------------------- 1 | import { hydrateConfig } from './hydrate-config' 2 | import * as substituteWithEnvModule from './substitute-with-env' 3 | 4 | describe('hydrateConfig()', () => { 5 | const runtimeEnv = 'test' 6 | const configMock = { field: 'value', replaceMe: '${AN_ENV_VAR}' } 7 | const configMockSubstituted = { field: 'value', replaceMe: 'ENV_VAR_VALUE' } 8 | const configMockHydrated = { ...configMockSubstituted, runtimeEnv } 9 | const substituteWithEnv = jest 10 | .spyOn(substituteWithEnvModule, 'substituteWithEnv') 11 | .mockReturnValue(configMockSubstituted) 12 | 13 | it('calls substituteWithEnv', () => { 14 | hydrateConfig(configMock, runtimeEnv) 15 | 16 | expect(substituteWithEnv).toHaveBeenCalledWith(configMock) 17 | }) 18 | 19 | it('adds runtimeEnv as top-level field', () => 20 | expect(hydrateConfig(configMock, runtimeEnv)).toStrictEqual( 21 | expect.objectContaining({ 22 | runtimeEnv, 23 | }) 24 | )) 25 | 26 | it('returns the expected result', () => { 27 | expect(hydrateConfig(configMock, runtimeEnv)).toStrictEqual( 28 | configMockHydrated 29 | ) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/utils/hydrate-config.ts: -------------------------------------------------------------------------------- 1 | import { DecryptedConfig, HydratedConfig } from '../types' 2 | import { substituteWithEnv } from './substitute-with-env' 3 | 4 | export type HydrateConfig = (decryptedConfig: DecryptedConfig) => HydratedConfig 5 | 6 | export function hydrateConfig( 7 | decryptedConfig: DecryptedConfig, 8 | runtimeEnv: string 9 | ): HydratedConfig { 10 | const configWithSubstitutedEnvVariables = substituteWithEnv(decryptedConfig) 11 | 12 | return { ...configWithSubstitutedEnvVariables, runtimeEnv } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/load-files.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import fastGlob from 'fast-glob' 3 | import yaml from 'js-yaml' 4 | import type { EncryptedConfigFile } from '../types' 5 | import { defaultOptions } from '../options' 6 | import * as loadFiles from './load-files' 7 | import { loadBaseConfig } from './load-files' 8 | 9 | const { loadConfigForEnv, loadConfigFromPath, loadSchema } = loadFiles 10 | 11 | describe('utils :: load-files', () => { 12 | const configRoot = 'config' 13 | 14 | describe('loadConfigForEnv()', () => { 15 | afterEach(() => { 16 | jest.restoreAllMocks() 17 | }) 18 | 19 | describe('without a base config', () => { 20 | beforeEach(() => { 21 | jest.spyOn(loadFiles, 'loadBaseConfig').mockImplementation(() => ({})) 22 | }) 23 | 24 | describe('given an invalid configRoot', () => { 25 | it('should throw', () => { 26 | expect(() => 27 | loadConfigForEnv('production', '/wrong/config/root') 28 | ).toThrow("Couldn't find config file") 29 | }) 30 | }) 31 | 32 | describe('given a config name for which multiple files exist (e.g. development.yaml AND development.json)', () => { 33 | it('should throw', () => { 34 | const multipleConfigFiles = [ 35 | 'config/development.yml', 36 | 'config/development.json', 37 | ] 38 | jest.spyOn(fastGlob, 'sync').mockReturnValueOnce(multipleConfigFiles) 39 | 40 | expect(() => loadConfigForEnv('development', './config')).toThrow( 41 | `Duplicate config files detected: ${multipleConfigFiles.join(',')}` 42 | ) 43 | }) 44 | }) 45 | 46 | describe('given a valid config name', () => { 47 | it('loads the config file', () => { 48 | const oneConfigFile = [`${configRoot}/development.yml`] 49 | const configFile: EncryptedConfigFile = { 50 | contents: { parsed: 'values' }, 51 | filePath: oneConfigFile[0], 52 | } 53 | jest.spyOn(fastGlob, 'sync').mockReturnValue(oneConfigFile) 54 | jest.spyOn(fs, 'existsSync').mockReturnValue(true) 55 | jest.spyOn(fs, 'readFileSync').mockReturnValue('mocked config file') 56 | jest.spyOn(yaml, 'load').mockReturnValue(configFile.contents) 57 | 58 | expect(loadConfigForEnv('development', configRoot)).toStrictEqual( 59 | configFile 60 | ) 61 | }) 62 | }) 63 | 64 | describe('given a runtimeEnv name that is identical to the base config name', () => { 65 | it('should throw because base config and env-specific config need to be different files', () => { 66 | const oneConfigFile = [`${configRoot}/development.yml`] 67 | const configFile: EncryptedConfigFile = { 68 | contents: { parsed: 'values' }, 69 | filePath: oneConfigFile[0], 70 | } 71 | jest.spyOn(fastGlob, 'sync').mockReturnValue(oneConfigFile) 72 | jest.spyOn(fs, 'existsSync').mockReturnValue(true) 73 | jest.spyOn(fs, 'readFileSync').mockReturnValue('mocked config file') 74 | jest.spyOn(yaml, 'load').mockReturnValue(configFile.contents) 75 | 76 | expect(() => { 77 | loadConfigForEnv('development', configRoot, 'development.yml') 78 | }).toThrow( 79 | "Base config name 'development.yml' must be different from the environment-name 'development'. Base config and env-specific config can't be the same file." 80 | ) 81 | }) 82 | }) 83 | }) 84 | 85 | describe('with a base config', () => { 86 | describe('given a valid config name', () => { 87 | it('loads the config file, extended from the base config', () => { 88 | const baseConfig = { base: 'config' } 89 | const oneConfigFile = [`${configRoot}/development.yml`] 90 | const configFile: EncryptedConfigFile = { 91 | contents: { parsed: 'values' }, 92 | filePath: oneConfigFile[0], 93 | } 94 | const mergedConfig = { 95 | contents: { ...baseConfig, ...configFile.contents }, 96 | filePath: oneConfigFile[0], 97 | } 98 | 99 | jest.spyOn(loadFiles, 'loadBaseConfig').mockImplementation(() => ({ 100 | base: 'config', 101 | })) 102 | jest.spyOn(fastGlob, 'sync').mockReturnValueOnce(oneConfigFile) 103 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) 104 | jest 105 | .spyOn(fs, 'readFileSync') 106 | .mockReturnValueOnce('mocked config file') 107 | jest.spyOn(yaml, 'load').mockReturnValueOnce(configFile.contents) 108 | 109 | expect(loadConfigForEnv('development', configRoot)).toStrictEqual( 110 | mergedConfig 111 | ) 112 | }) 113 | }) 114 | }) 115 | }) 116 | 117 | describe('loadConfigFromPath()', () => { 118 | afterEach(() => { 119 | jest.restoreAllMocks() 120 | }) 121 | 122 | describe('without a base config', () => { 123 | beforeEach(() => { 124 | jest.spyOn(loadFiles, 'loadBaseConfig').mockImplementation(() => ({})) 125 | }) 126 | 127 | describe('given an invalid path', () => { 128 | it('should return undefined', () => { 129 | expect(loadSchema('./an/invalid/path/prod.yml')).toBeUndefined() 130 | }) 131 | }) 132 | 133 | describe('given a valid path to a YAML config file', () => { 134 | const configFileContents = 'parsed: yaml' 135 | 136 | beforeEach(() => { 137 | jest.spyOn(yaml, 'load') 138 | jest.spyOn(JSON, 'parse') 139 | jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(configFileContents) 140 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) 141 | }) 142 | 143 | it('returns the expected config file', () => { 144 | const result = loadConfigFromPath('some/path/config.yaml', configRoot) 145 | 146 | expect(yaml.load).toHaveBeenCalledWith(configFileContents) 147 | expect(JSON.parse).not.toHaveBeenCalled() 148 | 149 | expect(result).toStrictEqual({ 150 | filePath: 'some/path/config.yaml', 151 | contents: { parsed: 'yaml' }, 152 | }) 153 | }) 154 | }) 155 | 156 | describe('given just a filename as configPath', () => { 157 | const configFileContents = 'parsed: yaml' 158 | 159 | beforeEach(() => { 160 | jest.spyOn(yaml, 'load') 161 | jest.spyOn(JSON, 'parse') 162 | jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(configFileContents) 163 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(false) 164 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) 165 | }) 166 | 167 | it('uses configRoot to compose the full config path and returns the expected config file', () => { 168 | const result = loadConfigFromPath('config.yaml', 'some/path') 169 | 170 | expect(yaml.load).toHaveBeenCalledWith(configFileContents) 171 | expect(JSON.parse).not.toHaveBeenCalled() 172 | 173 | expect(result).toStrictEqual({ 174 | filePath: 'some/path/config.yaml', 175 | contents: { parsed: 'yaml' }, 176 | }) 177 | }) 178 | }) 179 | 180 | describe('given a valid path to a JSON config file', () => { 181 | const configFileContents = `{ "parsed": "json" }` 182 | 183 | beforeEach(() => { 184 | jest.spyOn(yaml, 'load') 185 | jest.spyOn(JSON, 'parse') 186 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) 187 | jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(configFileContents) 188 | }) 189 | 190 | it('returns the expected config file', () => { 191 | const result = loadConfigFromPath('some/path/config.json', configRoot) 192 | 193 | expect(JSON.parse).toHaveBeenCalledWith(configFileContents) 194 | expect(yaml.load).not.toHaveBeenCalled() 195 | 196 | expect(result).toStrictEqual({ 197 | filePath: 'some/path/config.json', 198 | contents: { parsed: 'json' }, 199 | }) 200 | }) 201 | }) 202 | 203 | describe('given a valid path to a file that is neither JSON nor YAML', () => { 204 | it('should throw, because we only support JSON and YAML config files', () => { 205 | jest.spyOn(fs, 'existsSync').mockReturnValue(true) 206 | const configPath = 'some/path/config.xml' 207 | 208 | expect(() => loadConfigFromPath(configPath, configRoot)).toThrow( 209 | `Unsupported file: ${configPath}. Only JSON and YAML config files are supported.` 210 | ) 211 | }) 212 | }) 213 | }) 214 | 215 | describe('with a base config', () => { 216 | describe('given a valid path to a YAML config file', () => { 217 | it('returns the expected config file merged with the base config', () => { 218 | const baseConfig = { base: 'config' } 219 | const configFileContents = 'parsed: yaml' 220 | const mergedConfig = { base: 'config', parsed: 'yaml' } 221 | jest 222 | .spyOn(loadFiles, 'loadBaseConfig') 223 | .mockImplementation(() => baseConfig) 224 | jest.spyOn(yaml, 'load') 225 | jest.spyOn(fs, 'readFileSync').mockReturnValue(configFileContents) 226 | jest.spyOn(fs, 'existsSync').mockReturnValue(true) 227 | 228 | const result = loadConfigFromPath('some/path/config.yaml', configRoot) 229 | 230 | expect(yaml.load).toHaveBeenCalledWith(configFileContents) 231 | 232 | expect(result).toStrictEqual({ 233 | filePath: 'some/path/config.yaml', 234 | contents: mergedConfig, 235 | }) 236 | }) 237 | 238 | it('gives precedence to the env-specific config over the base config', () => { 239 | const baseConfig = { 240 | base: 'config', 241 | override: 'base value', 242 | deeply: { 243 | nested: { override: 'base value', noOverride: 'same-same' }, 244 | }, 245 | } 246 | const configFileContents = ` 247 | parsed: yaml 248 | override: env-specific value 249 | deeply: 250 | nested: 251 | override: env-specific value 252 | ` 253 | const mergedConfig = { 254 | base: 'config', 255 | parsed: 'yaml', 256 | override: 'env-specific value', 257 | deeply: { 258 | nested: { 259 | override: 'env-specific value', 260 | noOverride: 'same-same', 261 | }, 262 | }, 263 | } 264 | jest 265 | .spyOn(loadFiles, 'loadBaseConfig') 266 | .mockImplementation(() => baseConfig) 267 | jest.spyOn(fs, 'readFileSync').mockReturnValue(configFileContents) 268 | jest.spyOn(fs, 'existsSync').mockReturnValue(true) 269 | 270 | const result = loadConfigFromPath('some/path/config.yaml', configRoot) 271 | 272 | expect(result).toStrictEqual({ 273 | filePath: 'some/path/config.yaml', 274 | contents: mergedConfig, 275 | }) 276 | }) 277 | }) 278 | }) 279 | }) 280 | 281 | describe('loadBaseConfig()', () => { 282 | afterEach(() => { 283 | jest.restoreAllMocks() 284 | }) 285 | 286 | describe('given an existing base config file', () => { 287 | it('should load the base config', () => { 288 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) 289 | jest.spyOn(fs, 'readFileSync').mockReturnValueOnce('base: config') 290 | 291 | const baseConfig = { base: 'config' } 292 | const result = loadBaseConfig(configRoot, defaultOptions.baseConfig) 293 | 294 | expect(result).toStrictEqual(baseConfig) 295 | }) 296 | 297 | it('should allow customising the base config filename', () => { 298 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) 299 | jest.spyOn(fs, 'readFileSync').mockReturnValueOnce('base: config') 300 | 301 | const baseConfig = { base: 'config' } 302 | const result = loadBaseConfig(configRoot, 'my-custom-base.yaml') 303 | 304 | expect(result).toStrictEqual(baseConfig) 305 | }) 306 | }) 307 | 308 | describe('given a non-existing base config file', () => { 309 | it('should return an empty object', () => { 310 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(false) 311 | 312 | const result = loadBaseConfig(configRoot, defaultOptions.baseConfig) 313 | 314 | expect(result).toStrictEqual({}) 315 | }) 316 | }) 317 | 318 | describe('given a base config file with secrets', () => { 319 | it('should throw because base configs can not contain secrets because it is not clear which encryption key should be used to encrypt them', () => { 320 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) 321 | jest 322 | .spyOn(fs, 'readFileSync') 323 | .mockReturnValueOnce('baseSecret: you-can-not-encrypt-me') 324 | 325 | expect(() => { 326 | loadBaseConfig(configRoot, defaultOptions.baseConfig) 327 | }).toThrow( 328 | `Secret detected in ${configRoot}/${defaultOptions.baseConfig} config. Base config files can not contain secrets because when using different encryption keys per environment, it's unclear which key should be used to encrypt the base config.` 329 | ) 330 | }) 331 | }) 332 | }) 333 | 334 | describe('loadSchema()', () => { 335 | afterEach(() => { 336 | jest.restoreAllMocks() 337 | }) 338 | 339 | describe('given a config root WITHOUT an existing schema.json', () => { 340 | it('returns undefined when input is not a JSON file', () => { 341 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(false) 342 | 343 | expect(loadSchema('not-a-json-file.yaml')).toBeUndefined() 344 | }) 345 | }) 346 | 347 | describe('given a config root WITH an existing schema.json', () => { 348 | it('returns the parsed schema file and hydrates it with the runtimeEnv', () => { 349 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) 350 | jest 351 | .spyOn(fs, 'readFileSync') 352 | .mockReturnValueOnce(JSON.stringify({ mocked: 'schema' })) 353 | 354 | expect(loadSchema('config')).toStrictEqual({ 355 | mocked: 'schema', 356 | properties: { runtimeEnv: { type: 'string' } }, 357 | }) 358 | }) 359 | }) 360 | }) 361 | }) 362 | -------------------------------------------------------------------------------- /src/utils/load-files.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import fastGlob from 'fast-glob' 4 | import yaml from 'js-yaml' 5 | import { mergeDeepRight } from 'ramda' 6 | import { 7 | EncryptedConfigFile, 8 | Schema, 9 | ConfigFileExtensions, 10 | JSONObject, 11 | } from '../types' 12 | import { defaultOptions } from '../options' 13 | import { hasSecrets } from './has-secrets' 14 | 15 | export const loadConfigForEnv = ( 16 | runtimeEnv: string, 17 | configRootRaw: string, 18 | baseConfig: string = defaultOptions.baseConfig 19 | ): EncryptedConfigFile => { 20 | const configRoot = path.normalize(configRootRaw) 21 | const globPattern = `${configRoot}/${runtimeEnv}.{${ConfigFileExtensions.join( 22 | ',' 23 | )}}` 24 | const allFiles = fastGlob.sync(globPattern) 25 | 26 | const configFiles = allFiles.filter((filePath) => { 27 | const fileName = filePath.split('/').pop() as string 28 | 29 | return fileName !== 'schema.json' 30 | }) 31 | 32 | if (configFiles.length === 0) { 33 | throw new Error( 34 | `Couldn't find config file ${configRoot}/${runtimeEnv}.{${ConfigFileExtensions.join( 35 | ',' 36 | )}}` 37 | ) 38 | } else if (configFiles.length > 1) { 39 | throw new Error( 40 | `Duplicate config files detected: ${configFiles.join( 41 | ',' 42 | )}. There can be only one config file per environment.` 43 | ) 44 | } 45 | 46 | if (configFiles[0] === `${configRoot}/${baseConfig}`) { 47 | throw new Error( 48 | `Base config name '${baseConfig}' must be different from the environment-name '${runtimeEnv}'. Base config and env-specific config can't be the same file.` 49 | ) 50 | } 51 | 52 | return loadConfigFromPath(configFiles[0], configRoot, baseConfig) 53 | } 54 | 55 | /* 56 | * Cases to handle 57 | * 1. configPathRaw = config/development.yml 58 | * 2. configPathRaw = development.yml; configRootRaw = config 59 | * 3. configPathRaw = config/development.yml; configRootRaw = config 60 | */ 61 | export const loadConfigFromPath = ( 62 | configPathRaw: string, 63 | configRootRaw: string, 64 | baseConfigFileName: string = defaultOptions.baseConfig 65 | ): EncryptedConfigFile => { 66 | const configRoot = path.normalize(configRootRaw) 67 | 68 | // First, check if configPathRaw is a full path to the config file (if YES, we can ignore configRootRaw) 69 | let configPath = path.normalize(configPathRaw) 70 | 71 | // If configPathRaw was NOT a full path, retry loading the file from `${configRoot}/${configPathRaw}` 72 | if (!fs.existsSync(configPath)) { 73 | configPath = path.join(configRoot, configPathRaw) 74 | 75 | // If both prior approaches failed, throw error ❌ 76 | if (!fs.existsSync(configPath)) { 77 | throw new Error(`Couldn't find config file ${configPath}`) 78 | } 79 | } 80 | 81 | const baseConfig = loadBaseConfig(configRoot, baseConfigFileName) 82 | const fileExtension = configPath.split('.').pop() 83 | 84 | let fileAsString: string 85 | let parsedConfigFile: JSONObject 86 | 87 | if (fileExtension === 'json') { 88 | fileAsString = fs.readFileSync(configPath).toString() 89 | parsedConfigFile = JSON.parse(fileAsString) as JSONObject 90 | } else if (fileExtension === 'yml' || fileExtension === 'yaml') { 91 | fileAsString = fs.readFileSync(configPath).toString() 92 | parsedConfigFile = yaml.load(fileAsString) as JSONObject 93 | } else { 94 | throw new Error( 95 | `Unsupported file: ${configPath}. Only JSON and YAML config files are supported.` 96 | ) 97 | } 98 | 99 | /* 100 | * 1. Merge the environment-specific config (e.g. development.yaml) into the base config. 101 | * 2. If both baseConfig and env-specific config define the same key, the env-specific config should have precedence 102 | * 3. If baseConfig doesn't exist, it will be an empty object {} which is safe to overwrite anyways 103 | */ 104 | return { 105 | contents: mergeDeepRight(baseConfig, parsedConfigFile), 106 | filePath: configPath, 107 | } 108 | } 109 | 110 | export const loadBaseConfig = ( 111 | configRoot: string, 112 | baseConfigFileName: string 113 | ): JSONObject => { 114 | const baseConfigPath = `${configRoot}/${baseConfigFileName}` 115 | 116 | if (fs.existsSync(baseConfigPath)) { 117 | const baseConfigAsString = fs.readFileSync(baseConfigPath).toString() 118 | const baseConfig = yaml.load(baseConfigAsString) as JSONObject 119 | 120 | if (hasSecrets(baseConfig)) { 121 | throw new Error( 122 | `Secret detected in ${configRoot}/${baseConfigFileName} config. Base config files can not contain secrets because when using different encryption keys per environment, it's unclear which key should be used to encrypt the base config.` 123 | ) 124 | } 125 | 126 | return baseConfig 127 | } 128 | 129 | return {} 130 | } 131 | 132 | // Return type can be undefined because use of schemas is optional (contrary to config files) 133 | export const loadSchema = (configRootRaw: string): Schema | undefined => { 134 | const configRoot = path.normalize(configRootRaw) 135 | const schemaPath = `${configRoot}/schema.json` 136 | 137 | if (!fs.existsSync(schemaPath)) { 138 | return 139 | } 140 | 141 | const schemaAsString = fs.readFileSync(schemaPath).toString() 142 | const schema = JSON.parse(schemaAsString) as Schema 143 | 144 | /* 145 | * We auto-add the 'runtimeEnv' prop to the user's schema because we 146 | * hydrate every config object with a 'runtimeEnv' prop. 147 | * 148 | * If the user were to strictly define their schema via the json-schema 149 | * attribute 'additionalProperties: false', * then 'ajv.validate()' would 150 | * find an unexpected property 'runtimeEnv' in the config object and would 151 | * (rightfully) fail the schema validation. 152 | */ 153 | schema.properties 154 | ? (schema.properties['runtimeEnv'] = { type: 'string' }) 155 | : /* istanbul ignore next: an edge case for when the loaded schema is empty, not worth testing IMHO */ 156 | (schema['properties'] = { runtimeEnv: { type: 'string' } }) 157 | 158 | return schema 159 | } 160 | -------------------------------------------------------------------------------- /src/utils/pascal-case.test.ts: -------------------------------------------------------------------------------- 1 | import { pascalCase } from './pascal-case' 2 | 3 | describe('utils :: pascal-case', () => { 4 | describe('pascalCase()', () => { 5 | it.each([ 6 | ['name', 'Name'], 7 | ['some-name', 'SomeName'], 8 | ['some name', 'SomeName'], 9 | ['some name----asdf', 'SomeNameAsdf'], 10 | ['some name----asdf !@#($@^!)#% camelCase', 'SomeNameAsdfCamelCase'], 11 | ])('given %s, it correctly pascalCases to %s', (input, expectedOutput) => { 12 | expect(pascalCase(input)).toBe(expectedOutput) 13 | }) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/utils/pascal-case.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * json-schema-to-typescript uses a `toSafeString(string)` function to obtain a normalized string. 3 | * This pascalCase mimics this functionality, should address most cases, and reduces our dependency footprint. 4 | * => https://github.com/bcherny/json-schema-to-typescript/blob/f41945f19b68918e9c13885f345cb708e1d9898a/src/utils.ts#L163) 5 | */ 6 | export const pascalCase = (input: string): string => 7 | input 8 | .split(/[^\dA-Za-z]+/g) 9 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 10 | .join('') 11 | 12 | export default pascalCase 13 | -------------------------------------------------------------------------------- /src/utils/sops.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * We can't use the newest version of 'execa' yet as it's ESM-only which requires a 3 | * lot of setup work that we haven't had time to do yet. 4 | */ 5 | /* eslint-disable @typescript-eslint/unbound-method */ 6 | import fs from 'fs' 7 | import which from 'which' 8 | import execa from 'execa' 9 | import yaml from 'js-yaml' 10 | import { dissoc } from 'ramda' 11 | import type { DecryptedConfig, EncryptedConfig } from '../types' 12 | import { 13 | getSopsBinary, 14 | getSopsOptions, 15 | decryptInPlace, 16 | decryptToObject, 17 | runSopsWithOptions, 18 | sopsErrors, 19 | } from './sops' 20 | 21 | describe('utils :: sops', () => { 22 | const configFilePath = './config/development.yaml' 23 | const args = { config_file: './config/development.yaml' } 24 | 25 | const decryptedConfig: DecryptedConfig = { 26 | field: 'asdf', 27 | fieldSecret: 'PLAIN TEXT', 28 | } 29 | 30 | describe('getSopsBinary()', () => { 31 | it('should return `sops` if binary is available in $PATH', () => { 32 | jest.spyOn(which, 'sync').mockReturnValueOnce('sops') 33 | 34 | expect(getSopsBinary()).toBe('sops') 35 | }) 36 | 37 | it('should return `./sops` if binary is NOT available in $PATH but has been downloaded to current directory', () => { 38 | jest.spyOn(which, 'sync').mockReturnValueOnce('') 39 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) 40 | 41 | expect(getSopsBinary()).toBe('./sops') 42 | }) 43 | 44 | it('should throw if binary is neither available in $PATH nor in current directory', () => { 45 | jest.spyOn(which, 'sync').mockReturnValueOnce('') 46 | jest.spyOn(fs, 'existsSync').mockReturnValueOnce(false) 47 | 48 | expect(getSopsBinary).toThrow(sopsErrors['SOPS_NOT_FOUND']) 49 | }) 50 | }) 51 | 52 | describe('getSopsOptions()', () => { 53 | it('should pass the correct key-provider options', () => { 54 | const pgp = getSopsOptions(args, { 55 | 'key-provider': 'pgp', 56 | 'key-id': '123abc', 57 | }) 58 | const gcp = getSopsOptions(args, { 59 | 'key-provider': 'gcp', 60 | 'key-id': '123abc', 61 | }) 62 | const aws = getSopsOptions(args, { 63 | 'key-provider': 'aws', 64 | 'key-id': '123abc', 65 | }) 66 | const azr = getSopsOptions(args, { 67 | 'key-provider': 'azr', 68 | 'key-id': '123abc', 69 | }) 70 | 71 | expect(pgp).toContain('--pgp') 72 | expect(gcp).toContain('--gcp-kms') 73 | expect(aws).toContain('--kms') 74 | expect(azr).toContain('--azure-kv') 75 | 76 | expect(() => 77 | getSopsOptions({}, { 'key-provider': 'your-mama', 'key-id': '123abc' }) 78 | ).toThrow(sopsErrors['UNSUPPORTED_KEY_PROVIDER']) 79 | }) 80 | 81 | describe('given an output_path', () => { 82 | it('should pass an --output param to sops', () => { 83 | const output_path = './config/development.yml' 84 | const options = getSopsOptions({ ...args, output_path }, {}) 85 | 86 | expect(options).toContain('--output') 87 | expect(options).toContain(output_path) 88 | }) 89 | }) 90 | 91 | describe('given NO output_path', () => { 92 | it('should pass an --in-place param to sops', () => { 93 | const options = getSopsOptions(args, {}) 94 | 95 | expect(options).toContain('--in-place') 96 | }) 97 | }) 98 | 99 | describe('given an unencrypted-key-suffix and NO encrypted-key-suffix', () => { 100 | it('should pass the unencrypted-key-suffix to sops', () => { 101 | const unencryptedSuffix = 'NotASecret' 102 | const options = getSopsOptions(args, { 103 | 'key-provider': 'pgp', 104 | 'unencrypted-key-suffix': unencryptedSuffix, 105 | }) 106 | 107 | expect(options).toContain('--unencrypted-suffix') 108 | expect(options).toContain(unencryptedSuffix) 109 | expect(options).not.toContain('--encrypted-suffix') 110 | }) 111 | }) 112 | 113 | describe('given an encrypted-key-suffix and NO unencrypted-key-suffix', () => { 114 | it('should pass the encrypted-key-suffix to sops', () => { 115 | const encryptedSuffix = 'TopSecret' 116 | const options = getSopsOptions(args, { 117 | 'key-provider': 'pgp', 118 | 'encrypted-key-suffix': encryptedSuffix, 119 | }) 120 | 121 | expect(options).toContain('--encrypted-suffix') 122 | expect(options).toContain(encryptedSuffix) 123 | expect(options).not.toContain('--unencrypted-suffix') 124 | }) 125 | }) 126 | 127 | describe('given an encrypted-key-suffix AND an unencrypted-key-suffix', () => { 128 | it('should throw an error because these options are mutually exclusive', () => { 129 | expect(() => 130 | getSopsOptions(args, { 131 | 'key-provider': 'aws', 132 | 'encrypted-key-suffix': 'Secret', 133 | 'unencrypted-key-suffix': 'NotSoSecret', 134 | }) 135 | ).toThrow(sopsErrors['KEY_SUFFIX_CONFLICT']) 136 | }) 137 | }) 138 | 139 | describe('given a verbose flag', () => { 140 | it('runs sops in verbose mode', () => { 141 | const options = getSopsOptions(args, { 142 | 'key-provider': 'aws', 143 | verbose: true, 144 | }) 145 | 146 | expect(options).toContain('--verbose') 147 | }) 148 | }) 149 | 150 | describe('given an invalid config file', () => { 151 | it('should throw an error', () => { 152 | expect(() => 153 | getSopsOptions( 154 | { config_file: undefined }, 155 | { 156 | 'key-provider': 'aws', 157 | 'encrypted-key-suffix': 'Secret', 158 | } 159 | ) 160 | ).toThrow(sopsErrors['NO_CONFIG_FILE']) 161 | }) 162 | }) 163 | }) 164 | 165 | describe('runSopsWithOptions()', () => { 166 | it('should pass options to underlying sops binary', () => { 167 | jest.spyOn(which, 'sync').mockReturnValueOnce('sops') 168 | jest.spyOn(execa, 'sync') 169 | 170 | runSopsWithOptions(['--help']) 171 | 172 | expect(execa.sync).toHaveBeenCalledWith('sops', ['--help']) 173 | }) 174 | 175 | it('should handle decryption errors', () => { 176 | const execaSyncDecryptFail = { 177 | exitCode: 128, 178 | stdout: Buffer.from(''), 179 | stderr: Buffer.from(JSON.stringify(decryptedConfig)), 180 | command: 'sops', 181 | escapedCommand: 'sops', 182 | failed: true, 183 | timedOut: false, 184 | killed: false, 185 | } 186 | 187 | jest.spyOn(execa, 'sync').mockReturnValueOnce(execaSyncDecryptFail) 188 | 189 | expect(() => runSopsWithOptions(['decrypt'])).toThrow( 190 | sopsErrors['DECRYPTION_ERROR'] 191 | ) 192 | }) 193 | }) 194 | 195 | describe('decrypt', () => { 196 | const execaSyncSuccess = { 197 | exitCode: 0, 198 | stdout: Buffer.from(JSON.stringify(decryptedConfig)), 199 | stderr: Buffer.from(''), 200 | command: 'sops', 201 | escapedCommand: 'sops', 202 | failed: false, 203 | timedOut: false, 204 | killed: false, 205 | } 206 | 207 | const execaSyncFail = { 208 | exitCode: 1, 209 | stdout: Buffer.from(''), 210 | stderr: Buffer.from(JSON.stringify(decryptedConfig)), 211 | command: 'sops', 212 | escapedCommand: 'sops', 213 | failed: true, 214 | timedOut: false, 215 | killed: false, 216 | } 217 | 218 | describe('decryptToObject()', () => { 219 | const encryptedConfig: EncryptedConfig = { 220 | field: decryptedConfig.field, 221 | fieldSecret: 'ENC[some encrypted value]', 222 | sops: { 223 | kms: 'data', 224 | gcp_kms: 'data', 225 | azure_kv: 'data', 226 | pgp: 'data', 227 | lastmodified: 'timestamp', 228 | mac: 'data', 229 | version: 'version', 230 | }, 231 | } 232 | 233 | const encryptedConfigNoSops: EncryptedConfig = dissoc( 234 | 'sops', 235 | encryptedConfig 236 | ) 237 | 238 | it('returns the parsed config as-is when it does not contain SOPS metadata (nothing to decrypt)', () => { 239 | expect( 240 | decryptToObject(configFilePath, encryptedConfigNoSops) 241 | ).toStrictEqual(encryptedConfigNoSops) 242 | }) 243 | 244 | it('calls the sops binary to decrypt when SOPS metadata is present', () => { 245 | jest.spyOn(execa, 'sync').mockReturnValueOnce(execaSyncSuccess) 246 | decryptToObject(configFilePath, encryptedConfig) 247 | 248 | expect(execa.sync).toHaveBeenCalledWith('sops', [ 249 | '--decrypt', 250 | configFilePath, 251 | ]) 252 | }) 253 | 254 | it('throws when SOPS binary encounters an error while decrypting the config', () => { 255 | jest.spyOn(execa, 'sync').mockReturnValueOnce(execaSyncFail) 256 | 257 | expect(() => decryptToObject(configFilePath, encryptedConfig)).toThrow( 258 | Error 259 | ) 260 | }) 261 | 262 | /* eslint-disable @typescript-eslint/ban-ts-comment, unicorn/no-useless-undefined -- explicitly testing an error case here */ 263 | it('throws when `parsedConfig` parameter is nil', () => { 264 | // @ts-ignore 265 | expect(() => decryptToObject(configFilePath, undefined)).toThrow( 266 | 'Config is nil and can not be decrytped' 267 | ) 268 | 269 | // @ts-ignore 270 | expect(() => decryptToObject(configFilePath, null)).toThrow( 271 | 'Config is nil and can not be decrytped' 272 | ) 273 | }) 274 | /* eslint-enable @typescript-eslint/ban-ts-comment, unicorn/no-useless-undefined */ 275 | 276 | it('parses the output of SOPS to YAML', () => { 277 | jest.spyOn(execa, 'sync').mockReturnValueOnce(execaSyncSuccess) 278 | jest.spyOn(yaml, 'load') 279 | decryptToObject(configFilePath, encryptedConfig) 280 | 281 | expect(yaml.load).toHaveBeenCalledWith(JSON.stringify(decryptedConfig)) 282 | }) 283 | }) 284 | 285 | describe('decryptInPlace()', () => { 286 | it('calls sops with the "--in-place" flag', () => { 287 | // eslint-disable @typescript-eslint/unbound-method 288 | jest.spyOn(execa, 'sync').mockReturnValueOnce(execaSyncSuccess) 289 | decryptInPlace(configFilePath) 290 | 291 | expect(execa.sync).toHaveBeenCalledWith( 292 | 'sops', 293 | expect.arrayContaining(['--in-place']) 294 | ) 295 | }) 296 | }) 297 | }) 298 | }) 299 | -------------------------------------------------------------------------------- /src/utils/sops.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import execa from 'execa' 3 | import yaml from 'js-yaml' 4 | import which from 'which' 5 | import type { DecryptedConfig, EncryptedConfig } from '../types' 6 | 7 | export const sopsErrors = { 8 | SOPS_NOT_FOUND: `Couldn't find 'sops' binary. Please make sure it's available in your runtime environment`, 9 | DECRYPTION_ERROR: `Sops failed to retrieve the decryption key. This is often a permission error with your KMS provider.`, 10 | UNSUPPORTED_KEY_PROVIDER: 'Unsupported key provider:', 11 | KEY_SUFFIX_CONFLICT: `'--unencrypted-key-suffix' and '--encrypted-key-suffix' are mutually exclusive, pick one or the other`, 12 | NO_CONFIG_FILE: "Didn't receive a config file", 13 | } 14 | 15 | export function getSopsBinary(): string { 16 | if (which.sync('sops', { nothrow: true })) { 17 | return 'sops' 18 | } 19 | 20 | if (fs.existsSync('sops')) { 21 | return './sops' 22 | } 23 | 24 | throw new Error(sopsErrors['SOPS_NOT_FOUND']) 25 | } 26 | 27 | export const runSopsWithOptions = (options: string[]): string => { 28 | const sopsBinary = getSopsBinary() 29 | 30 | const sopsResult = execa.sync(sopsBinary, options) 31 | 32 | switch (sopsResult.exitCode) { 33 | case 0: { 34 | return sopsResult.stdout.toString() 35 | } 36 | case 128: { 37 | throw new Error(`${sopsErrors['DECRYPTION_ERROR']}\n${sopsResult.stdout}`) 38 | } 39 | default: { 40 | throw new Error(`Unexpected sops error:\n${sopsResult.stdout}`) 41 | } 42 | } 43 | } 44 | 45 | export const decryptToObject = ( 46 | filePath: string, 47 | config: EncryptedConfig 48 | ): DecryptedConfig => { 49 | if (!config) { 50 | throw new Error('Config is nil and can not be decrytped') 51 | } 52 | 53 | // If there's no SOPS metadata, config is already decrypted 54 | if (!Object.prototype.hasOwnProperty.call(config, 'sops')) { 55 | return config 56 | } 57 | 58 | const sopsResult = runSopsWithOptions(['--decrypt', filePath]) 59 | 60 | /* 61 | * We should be able to safely typecast the result because runSopsWithOptions() 62 | * should have already failed if it couldn't decrypt the config 63 | */ 64 | return yaml.load(sopsResult) as DecryptedConfig 65 | } 66 | 67 | export const decryptInPlace = (filePath: string): void => 68 | runSopsWithOptions(['--decrypt', '--in-place', filePath]) as unknown as void 69 | 70 | export const getSopsOptions = ( 71 | args: Record, 72 | flags: Record 73 | ): string[] => { 74 | const options: string[] = [] 75 | 76 | if (typeof args['output_path'] === 'string') { 77 | options.push('--output', args['output_path']) 78 | } else { 79 | options.push('--in-place') 80 | } 81 | 82 | if ( 83 | typeof flags['key-provider'] === 'string' && 84 | typeof flags['key-id'] === 'string' 85 | ) { 86 | switch (flags['key-provider']) { 87 | case 'pgp': { 88 | options.push('--pgp') 89 | break 90 | } 91 | case 'gcp': { 92 | options.push('--gcp-kms') 93 | break 94 | } 95 | case 'aws': { 96 | options.push('--kms') 97 | break 98 | } 99 | case 'azr': { 100 | options.push('--azure-kv') 101 | break 102 | } 103 | default: { 104 | // Should not be reached as we define possible options in Encrypt.flags (../cli/encrypt.ts) 105 | throw new Error( 106 | sopsErrors['UNSUPPORTED_KEY_PROVIDER'].concat( 107 | `\n${flags['key-provider']}` 108 | ) 109 | ) 110 | } 111 | } 112 | 113 | options.push(flags['key-id']) 114 | } 115 | 116 | if ( 117 | typeof flags['unencrypted-key-suffix'] === 'string' && 118 | !flags['encrypted-key-suffix'] 119 | ) { 120 | options.push('--unencrypted-suffix', flags['unencrypted-key-suffix']) 121 | } else if ( 122 | typeof flags['encrypted-key-suffix'] === 'string' && 123 | !flags['unencrypted-key-suffix'] 124 | ) { 125 | options.push('--encrypted-suffix', flags['encrypted-key-suffix']) 126 | } else if (flags['encrypted-key-suffix'] && flags['unencrypted-key-suffix']) { 127 | throw new Error(sopsErrors['KEY_SUFFIX_CONFLICT']) 128 | } 129 | 130 | if (flags.verbose) { 131 | options.push('--verbose') 132 | } 133 | 134 | if (typeof args['config_file'] === 'string') { 135 | options.push(args['config_file']) 136 | } else { 137 | throw new TypeError(sopsErrors['NO_CONFIG_FILE']) 138 | } 139 | 140 | return options 141 | } 142 | -------------------------------------------------------------------------------- /src/utils/substitute-with-env.test.ts: -------------------------------------------------------------------------------- 1 | import matchAll from 'match-all' 2 | import { substituteWithEnv } from './substitute-with-env' 3 | 4 | jest.mock('match-all', () => { 5 | const matchAllOriginal = jest.requireActual('match-all') 6 | 7 | return jest.fn().mockImplementation(matchAllOriginal) 8 | }) 9 | 10 | describe('substituteWithEnv()', () => { 11 | const OLD_PROCESS_ENV = process.env 12 | 13 | beforeAll(() => { 14 | process.env = { 15 | REPLACE_ME: 'REPLACED', 16 | SOME_ENV_VAR: 'an-env-var-value', 17 | '0INVALID': 'INVALID KEY', 18 | } 19 | }) 20 | 21 | afterAll(() => { 22 | process.env = OLD_PROCESS_ENV 23 | }) 24 | 25 | describe('given a string WITHOUT any substitutable values', () => { 26 | it('returns the same value it received as input', () => { 27 | const config = { field: 'value', otherField: 'other-value' } 28 | 29 | expect(substituteWithEnv(config)).toStrictEqual(config) 30 | }) 31 | }) 32 | 33 | describe('given a string WITH substitutable values', () => { 34 | it('substitutes all valid ${...} expressions with the respective env var value', () => { 35 | const config = { field: '${SOME_ENV_VAR}', otherField: '${REPLACE_ME}' } 36 | 37 | expect(substituteWithEnv(config)).toStrictEqual({ 38 | field: process.env.SOME_ENV_VAR, 39 | otherField: process.env.REPLACE_ME, 40 | }) 41 | }) 42 | 43 | it('throws when an environment variable is not set', () => { 44 | const config = { field: '${NOT_SET}' } 45 | 46 | expect(() => substituteWithEnv(config)).toThrow( 47 | /Environment variable "NOT_SET" is undefined/ 48 | ) 49 | }) 50 | 51 | it('throws when an environment variable starts with a string', () => { 52 | const config = { field: '${0INVALID}' } 53 | 54 | expect(() => substituteWithEnv(config)).toThrow( 55 | /Environment variable must not start with a digit/ 56 | ) 57 | }) 58 | 59 | it('throws when config value has empty substitution pattern ${}', () => { 60 | const config = { field: '${}', otherField: '${REPLACE_ME}' } 61 | 62 | expect(() => substituteWithEnv(config)).toThrow( 63 | "Config can't contain empty substitution template '${}'" 64 | ) 65 | }) 66 | 67 | it('throws when template ${...} contains invalid characters', () => { 68 | const config = { 69 | field: '${NO_$PEC!AL_CHARS_ALLOWED}', 70 | otherField: '${REPLACE_ME}', 71 | } 72 | 73 | expect(() => substituteWithEnv(config)).toThrow( 74 | `Env vars 'NO_$PEC!AL_CHARS_ALLOWED' contain unsupported characters. Env var names should only contain a-z, A-Z, 0-9, and _` 75 | ) 76 | }) 77 | }) 78 | 79 | it('uses a polyfill for String.prototype.matchAll in older Node versions', () => { 80 | // eslint-disable-next-line @typescript-eslint/unbound-method 81 | const originalImplementation = String.prototype.matchAll 82 | 83 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 84 | // @ts-ignore explicitly testing error case here 85 | String.prototype.matchAll = undefined 86 | 87 | const config = { field: 'value', otherField: 'other-value' } 88 | 89 | substituteWithEnv(config) 90 | 91 | expect(matchAll).toHaveBeenCalled() 92 | 93 | String.prototype.matchAll = originalImplementation 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/utils/substitute-with-env.ts: -------------------------------------------------------------------------------- 1 | import matchAll from 'match-all' 2 | import { DecryptedConfig } from '../types' 3 | 4 | export const substituteWithEnv = ( 5 | decryptedConfig: DecryptedConfig 6 | ): DecryptedConfig => { 7 | /* 8 | * This substitution pattern will replace the following types of expressions 9 | * in a config file with the respective env var value at runtime: 10 | * 11 | * PASS: ${AN_ENV_VAR} 12 | * PASS: ${an_env_var} 13 | * PASS: ${AN_ENV_VAR_1} 14 | * 15 | * 16 | * Following expressions will throw an error: 17 | * 18 | * FAIL: ${AN_ENV_VAR!@#} => no special chars allowed 19 | * FAIL: ${1AN_ENV_VAR} => no digit as first char allowed 20 | * FAIL: ${} => no empty env var name allowed 21 | */ 22 | const substitutionPattern = /\${(\w+)}/g 23 | const configAsString = JSON.stringify(decryptedConfig) 24 | 25 | if (configAsString.includes('${}')) { 26 | throw new TypeError( 27 | "Config can't contain empty substitution template '${}'" 28 | ) 29 | } 30 | 31 | /* istanbul ignore next: the if-branch does not get executed in Node 10 environments and will drop test coverage < 100% if not ignored */ 32 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 33 | // @ts-ignore because 'matchAll' is undefined in Node versions < 12.x 34 | const envVariablesWithIllegalCharacters = String.prototype.matchAll 35 | ? [...configAsString.matchAll(/\${(.*?)}/g)] 36 | .map((matches) => matches[1]) 37 | .filter((envVariableName) => /\W/.test(envVariableName)) 38 | : matchAll(configAsString, /\${(.*?)}/g) 39 | .toArray() 40 | .filter( 41 | /* istanbul ignore next: no need to test the same logic used above again */ 42 | (envVariableName) => /\W/.test(envVariableName) 43 | ) 44 | 45 | if (envVariablesWithIllegalCharacters.length > 0) { 46 | throw new TypeError( 47 | `Env vars '${envVariablesWithIllegalCharacters.join( 48 | ',' 49 | )}' contain unsupported characters. Env var names should only contain a-z, A-Z, 0-9, and _` 50 | ) 51 | } 52 | 53 | const substitutedConfig = configAsString.replaceAll( 54 | substitutionPattern, 55 | (_original: string, envVariable: string) => { 56 | if (!process.env[envVariable]) { 57 | throw new Error(`Environment variable "${envVariable}" is undefined`) 58 | } 59 | 60 | if (/^\d/.test(envVariable)) { 61 | throw new TypeError('Environment variable must not start with a digit') 62 | } 63 | 64 | return process.env[envVariable] as string 65 | } 66 | ) 67 | 68 | return JSON.parse(substitutedConfig) as DecryptedConfig 69 | } 70 | 71 | export default substituteWithEnv 72 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "src/**/*.test.ts", 5 | "src/e2e", 6 | "src/integration-tests", 7 | "src/fixtures", 8 | "example", 9 | "scripts" 10 | ], 11 | "compilerOptions": { 12 | "declaration": true, 13 | "declarationMap": true, 14 | "importHelpers": true, 15 | "outDir": "lib", 16 | "sourceMap": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", "scripts", "*.json", "example*/**/*.json", ".eslintrc.js"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. 4 | // https://www.typescriptlang.org/v2/en/tsconfig#esModuleInterop 5 | "esModuleInterop": true, 6 | 7 | // Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. 8 | // https://www.typescriptlang.org/v2/en/tsconfig#module 9 | "module": "commonjs", 10 | 11 | // When this option is set, TypeScript will issue an error if a program tries to include a file by a casing different from the casing on disk. 12 | // https://www.typescriptlang.org/v2/en/tsconfig#forceConsistentCasingInFileNames 13 | "forceConsistentCasingInFileNames": true, 14 | 15 | // Report error when not all code paths in function return a value. */ 16 | // https://www.typescriptlang.org/v2/en/tsconfig#noImplicitReturns 17 | "noImplicitReturns": true, 18 | 19 | // Report errors on unused locals. */ 20 | // https://www.typescriptlang.org/v2/en/tsconfig#noUnusedLocals 21 | "noUnusedLocals": true, 22 | 23 | // Report errors on unused parameters. */ 24 | // https://www.typescriptlang.org/v2/en/tsconfig#noUnusedParameters 25 | "noUnusedParameters": true, 26 | 27 | // Allows importing modules with a ‘.json’ extension, which is a common practice in node projects 28 | // https://www.typescriptlang.org/v2/en/tsconfig#resolveJsonModule 29 | "resolveJsonModule": true, 30 | 31 | // Enable all strict type-checking options. */ 32 | // https://www.typescriptlang.org/v2/en/tsconfig#strict 33 | "strict": true, 34 | 35 | // Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 36 | // https://www.typescriptlang.org/v2/en/tsconfig#target 37 | "target": "es6", 38 | 39 | // Specify library files to be included in the compilation: */ 40 | // https://www.typescriptlang.org/v2/en/tsconfig#lib 41 | "lib": ["es2021"] 42 | }, 43 | // Specifies an array of filenames or patterns to include in the program. These filenames are resolved relative to the directory containing the tsconfig.json file. 44 | // https://www.typescriptlang.org/v2/en/tsconfig#include 45 | "include": ["src", "scripts", "example"] 46 | } 47 | --------------------------------------------------------------------------------