sops-env 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/cli.mjs +152 -0
- package/package.json +57 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ilya Semenov
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# sops-env
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Print the `env` section of a [SOPS](https://github.com/getsops/sops)-encrypted file as `KEY=VALUE` lines.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 22 or later, or Bun
|
|
8
|
+
- [`sops`](https://github.com/getsops/sops) in `PATH`, with access to the decryption key
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
Keep the environment variables under the `env` key of a SOPS file; other keys are ignored.
|
|
13
|
+
Name the variables with ASCII letters, digits, and `_`, and do not start a name with a digit.
|
|
14
|
+
An encrypted file `environment.sops.yaml` looks like this:
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
env:
|
|
18
|
+
NODE_ENV: ENC[AES256_GCM,data:66pExK+Z2weH0g==,iv:…,tag:…,type:str]
|
|
19
|
+
PORT: ENC[AES256_GCM,data:8hoeUQ==,iv:…,tag:…,type:int]
|
|
20
|
+
SECRET_KEY: ENC[AES256_GCM,data:4+CiTc2C,iv:…,tag:…,type:str]
|
|
21
|
+
sops:
|
|
22
|
+
# Encrypted data keys and metadata
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Print the decrypted variables as lines of an environment file:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx sops-env environment.sops.yaml
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
NODE_ENV=production
|
|
33
|
+
PORT=3000
|
|
34
|
+
SECRET_KEY=s3cr3t
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Values are printed as is, without quoting or escaping, so the command fails when a value spans several lines.
|
|
38
|
+
|
|
39
|
+
### Set variables in Dokku
|
|
40
|
+
|
|
41
|
+
Pass `--base64` to print every value base64-encoded, as `dokku config:set --encoded` expects; multi-line values are allowed:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
ssh dokku@example.com config:set --encoded my-app $(npx sops-env --base64 environment.sops.yaml)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The unquoted substitution splits the output into one argument per variable.
|
|
48
|
+
A failed command inside it does not stop the script, so in a script assign the output to a variable first and stop on failure, instead of running `config:set` without variables:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
config=$(npx sops-env --base64 environment.sops.yaml) || exit
|
|
52
|
+
ssh dokku@example.com config:set --encoded my-app $config
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Values
|
|
56
|
+
|
|
57
|
+
- Strings, numbers, and booleans are printed as text, such as `PORT=3000` and `DEBUG=true`.
|
|
58
|
+
- Quote a value to keep its exact text: sops rewrites unquoted numbers and dates, such as `1.50` to `1.5`, `0x10` to `16`, and `2024-01-01` to `2024-01-01T00:00:00Z`.
|
|
59
|
+
- `null`, lists, and maps are rejected: quote a value, such as `EMPTY: ''`, to print it as text.
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { constants } from "node:os";
|
|
5
|
+
// src/error.ts
|
|
6
|
+
/** Error that the CLI reports to the user as a message without a stack trace. */
|
|
7
|
+
var SopsEnvError = class extends Error {
|
|
8
|
+
exitCode;
|
|
9
|
+
constructor(message, exitCode = 1) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
this.name = "SopsEnvError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
// src/env.ts
|
|
16
|
+
/**
|
|
17
|
+
* Formats the `env` section of a decrypted document as `KEY=VALUE` lines.
|
|
18
|
+
*
|
|
19
|
+
* Names are written as is and must be shell identifiers, so that no name makes a line ambiguous.
|
|
20
|
+
* Without `base64`, values are written as is, without quoting, so multi-line values are rejected.
|
|
21
|
+
*/
|
|
22
|
+
function formatEnv(document, { base64 = false } = {}) {
|
|
23
|
+
const env = isRecord(document) ? document.env : void 0;
|
|
24
|
+
if (!isRecord(env)) throw new SopsEnvError("The env section is missing or is not a mapping. Put the variables under the env key of a YAML or JSON file; dotenv files are not supported.");
|
|
25
|
+
const invalidNames = Object.keys(env).filter((key) => !isValidName(key));
|
|
26
|
+
if (invalidNames.length) throw new SopsEnvError(`Unsupported variable names: ${invalidNames.map(quoteName).join(", ")}. A name must consist of ASCII letters, digits, and _, and must not start with a digit.`);
|
|
27
|
+
const entries = Object.entries(env).map(([key, value]) => [key, toEnvValue(key, value)]);
|
|
28
|
+
if (!base64) {
|
|
29
|
+
const multiline = entries.filter(([, value]) => value.includes("\n")).map(([key]) => key);
|
|
30
|
+
if (multiline.length) throw new SopsEnvError(`Multi-line values are not supported: ${multiline.join(", ")}`);
|
|
31
|
+
}
|
|
32
|
+
return entries.map(([key, value]) => `${key}=${base64 ? Buffer.from(value).toString("base64") : value}`).join("\n");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Checks that a variable name is a shell identifier: ASCII letters, digits, and `_`, not starting with a digit.
|
|
36
|
+
*
|
|
37
|
+
* Shells and Dokku accept only such names, and they cannot make a `KEY=VALUE` line ambiguous,
|
|
38
|
+
* unlike names with `=`, line breaks, whitespace, or a leading `#`.
|
|
39
|
+
*/
|
|
40
|
+
function isValidName(name) {
|
|
41
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Quotes a variable name for an error message.
|
|
45
|
+
*
|
|
46
|
+
* Escapes non-space whitespace, control characters, and format characters left literal by `JSON.stringify`,
|
|
47
|
+
* as `\uXXXX` per UTF-16 code unit, so that a character beyond U+FFFF becomes a surrogate pair.
|
|
48
|
+
*/
|
|
49
|
+
function quoteName(name) {
|
|
50
|
+
return JSON.stringify(name).replace(/[^\S ]|[\p{Cc}\p{Cf}]/gu, (char) => char.split("").map((unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`).join(""));
|
|
51
|
+
}
|
|
52
|
+
function isRecord(value) {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
/** Converts a scalar value to its string form; rejects null and collections. */
|
|
56
|
+
function toEnvValue(key, value) {
|
|
57
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
58
|
+
throw new SopsEnvError(`Unsupported value of ${key}: expected a string, number, or boolean.`);
|
|
59
|
+
}
|
|
60
|
+
// src/sops.ts
|
|
61
|
+
/**
|
|
62
|
+
* Decrypts a SOPS file with the `sops` CLI and returns the parsed document.
|
|
63
|
+
*
|
|
64
|
+
* Numbers are returned as their JSON source text, so that integers beyond the double precision keep every digit.
|
|
65
|
+
* Throws a `SopsEnvError` with the exit code that a shell would report for sops:
|
|
66
|
+
* its own exit code, 128 plus the signal number when a signal kills it, or 127 when it is missing.
|
|
67
|
+
* Only a missing sops gets a message; stderr of sops is inherited.
|
|
68
|
+
*/
|
|
69
|
+
function decryptFile(path) {
|
|
70
|
+
const result = spawnSync("sops", [
|
|
71
|
+
"decrypt",
|
|
72
|
+
"--output-type",
|
|
73
|
+
"json",
|
|
74
|
+
path
|
|
75
|
+
], {
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
maxBuffer: Number.POSITIVE_INFINITY,
|
|
78
|
+
stdio: [
|
|
79
|
+
"ignore",
|
|
80
|
+
"pipe",
|
|
81
|
+
"inherit"
|
|
82
|
+
]
|
|
83
|
+
});
|
|
84
|
+
if (result.error) {
|
|
85
|
+
if (result.error.code === "ENOENT") throw new SopsEnvError("sops is not installed or not in PATH.", 127);
|
|
86
|
+
throw result.error;
|
|
87
|
+
}
|
|
88
|
+
if (result.signal) throw new SopsEnvError("", 128 + constants.signals[result.signal]);
|
|
89
|
+
if (result.status !== 0) throw new SopsEnvError("", result.status ?? 1);
|
|
90
|
+
return parseJson(result.stdout);
|
|
91
|
+
}
|
|
92
|
+
/** Parses JSON, returning numbers as their source text. */
|
|
93
|
+
function parseJson(text) {
|
|
94
|
+
return JSON.parse(text, (_key, value, context) => typeof value === "number" && context?.source !== void 0 ? context.source : value);
|
|
95
|
+
}
|
|
96
|
+
// src/cli.ts
|
|
97
|
+
const usage = "Usage: sops-env [--base64] <environment.sops.yaml>";
|
|
98
|
+
const help = `${usage}
|
|
99
|
+
|
|
100
|
+
Print the env section of a SOPS-encrypted file as KEY=VALUE lines.
|
|
101
|
+
Multi-line values are rejected.
|
|
102
|
+
|
|
103
|
+
Options:
|
|
104
|
+
--base64 Encode values in base64; allows multi-line values
|
|
105
|
+
-h, --help Show this help
|
|
106
|
+
`;
|
|
107
|
+
const options = {
|
|
108
|
+
base64: { type: "boolean" },
|
|
109
|
+
help: {
|
|
110
|
+
type: "boolean",
|
|
111
|
+
short: "h"
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
function main(args) {
|
|
115
|
+
let parsed;
|
|
116
|
+
try {
|
|
117
|
+
parsed = parseArgs({
|
|
118
|
+
args,
|
|
119
|
+
allowPositionals: true,
|
|
120
|
+
options
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new SopsEnvError(`${error.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? `Unknown option '${findUnknownOption(args)}'.` : error.message}\n${usage}`);
|
|
124
|
+
}
|
|
125
|
+
const { values, positionals } = parsed;
|
|
126
|
+
if (values.help) {
|
|
127
|
+
process.stdout.write(help);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (positionals.length !== 1) throw new SopsEnvError(usage);
|
|
131
|
+
const output = formatEnv(decryptFile(positionals[0]), { base64: values.base64 });
|
|
132
|
+
if (output) process.stdout.write(`${output}\n`);
|
|
133
|
+
}
|
|
134
|
+
/** Returns the first option in `args` that the CLI does not define, as the user wrote it. */
|
|
135
|
+
function findUnknownOption(args) {
|
|
136
|
+
const { tokens } = parseArgs({
|
|
137
|
+
args,
|
|
138
|
+
allowPositionals: true,
|
|
139
|
+
options,
|
|
140
|
+
strict: false,
|
|
141
|
+
tokens: true
|
|
142
|
+
});
|
|
143
|
+
for (const token of tokens) if (token.kind === "option" && !Object.hasOwn(options, token.name)) return token.rawName;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
main(process.argv.slice(2));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!(error instanceof SopsEnvError)) throw error;
|
|
149
|
+
if (error.message) process.stderr.write(`${error.message}\n`);
|
|
150
|
+
process.exitCode = error.exitCode;
|
|
151
|
+
}
|
|
152
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sops-env",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Print the env section of a SOPS-encrypted file as KEY=VALUE lines, optionally with base64-encoded values.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"dokku",
|
|
8
|
+
"dotenv",
|
|
9
|
+
"env",
|
|
10
|
+
"secrets",
|
|
11
|
+
"sops"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/IlyaSemenov/sops-env#readme",
|
|
14
|
+
"bugs": "https://github.com/IlyaSemenov/sops-env/issues",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Ilya Semenov",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/IlyaSemenov/sops-env.git"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"sops-env": "./dist/cli.mjs"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"README.md",
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsdown",
|
|
34
|
+
"lint": "oxlint --fix && oxfmt",
|
|
35
|
+
"lint:check": "oxlint && oxfmt --check",
|
|
36
|
+
"prepare": "lefthook install",
|
|
37
|
+
"prepublishOnly": "bun run build",
|
|
38
|
+
"release": "changesets-local",
|
|
39
|
+
"release:check": "bun run build && bun run types && bun run test",
|
|
40
|
+
"test": "bun test",
|
|
41
|
+
"types": "bun check && bun check -p tests"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@changesets/cli": "^3.0.3",
|
|
45
|
+
"@ilyasemenov/oxc-config": "^0.1.3",
|
|
46
|
+
"@tsconfig/bun": "^1.0.12",
|
|
47
|
+
"@types/bun": "^1.4.4",
|
|
48
|
+
"changesets-local": "^1.6.0",
|
|
49
|
+
"oxfmt": "^0.78.0",
|
|
50
|
+
"oxlint": "^1.93.0",
|
|
51
|
+
"publint": "^0.3.25",
|
|
52
|
+
"tsdown": "^0.23.7",
|
|
53
|
+
"typescript": "^7.0.2"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22"
|
|
57
|
+
},
|
|
58
|
+
"packageManager": "bun@1.4.4"
|
|
59
|
+
}
|