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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -2
  3. package/dist/cli.mjs +152 -0
  4. 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
- # Temporary Holding Version
1
+ # sops-env
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }