├── .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 | 
8 | [](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 |
--------------------------------------------------------------------------------