@saasak/tool-env 0.0.3 → 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/README.md +77 -0
- package/bin/index.js +235 -168
- package/package.json +3 -2
- package/src/utils-crypto.js +90 -0
- package/src/utils-env.js +58 -0
- package/src/utils-pkg.js +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Env management
|
|
2
|
+
|
|
3
|
+
## Warnings
|
|
4
|
+
|
|
5
|
+
- Make sure to have the git hook setup OR TO add any new variable viw the dedicated subcommand
|
|
6
|
+
- Obviously do not commit your password file
|
|
7
|
+
- In variable composition, the "static" parts are NOT encrypted (static part should never contain sensitive information)
|
|
8
|
+
|
|
9
|
+
## Goals
|
|
10
|
+
|
|
11
|
+
- Centralize env variables definitions
|
|
12
|
+
- Allow explicit variables composition
|
|
13
|
+
- Good DX (setup and forget)
|
|
14
|
+
|
|
15
|
+
## TODOs
|
|
16
|
+
|
|
17
|
+
[] Handle encryption
|
|
18
|
+
[] Handle variable composition everywhere (not just in overrides)
|
|
19
|
+
[] Create runtime library to read vars (even encrypted)
|
|
20
|
+
[] Handle env.local files
|
|
21
|
+
|
|
22
|
+
## Open questions
|
|
23
|
+
|
|
24
|
+
- Should we write all the env with the next.js convention ?
|
|
25
|
+
- How to handle adding a var easily in an encrypted context ?
|
|
26
|
+
- Should we split BUILD / RUN variables ?
|
|
27
|
+
|
|
28
|
+
## Examples
|
|
29
|
+
|
|
30
|
+
in package.json (root)
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"scripts": {
|
|
34
|
+
"postinstall": "wrenv --secret ~/.big-secret write --target dev",
|
|
35
|
+
"env:update": "wrenv --secret ~/.big-secret write"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
and then later
|
|
41
|
+
```bash
|
|
42
|
+
pnpm run env:update --target prod
|
|
43
|
+
```
|
|
44
|
+
or
|
|
45
|
+
```bash
|
|
46
|
+
WRENV_TARGET=staging bun run env:update
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
you can also pass the secret via an env variable (Even though it is not really encouraged)
|
|
50
|
+
```bash
|
|
51
|
+
WRENV_SECRET=super-secret WRENV_TARGET=prod npm run env:update
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
or via stdin
|
|
55
|
+
```bash
|
|
56
|
+
cat ~/.big-secret | wrenv --secret=stdin write --target=dev
|
|
57
|
+
wrenv --secret stdin --target=dev < ~/.big-secret
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Git hooks
|
|
61
|
+
|
|
62
|
+
Wrenv provide a git hook (to be configured independently with the solution of your choosing) to encrypt all added variables
|
|
63
|
+
So in `.git/hooks/pre-commit` you can add
|
|
64
|
+
```bash
|
|
65
|
+
bun run wrenv --secret ~/.big-secret besafe
|
|
66
|
+
```
|
|
67
|
+
This will run on the .env.json file and make sure all variables are encrypted.
|
|
68
|
+
This way you can add new variables and make sure they don't leak, offering minimum friction.
|
|
69
|
+
|
|
70
|
+
/!\ You must make sure to add this hook OR to always add var via the dedicated subcommand `wrenv add`
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## Add a variable
|
|
74
|
+
|
|
75
|
+
To add a new variable use the `add` subcommand
|
|
76
|
+
```bash
|
|
77
|
+
wrenv --secret=~/.big-secret add NEW_VAR +fallback=@@_VALUE +dev=DEV_VALUE +production=PROD_VALUE
|
package/bin/index.js
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import minimist from 'minimist';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {
|
|
4
|
+
import { execa } from 'execa';
|
|
5
|
+
|
|
6
|
+
import { buildEnv } from '../src/utils-env.js';
|
|
7
|
+
import { findMonorepoPackages } from '../src/utils-pkg.js';
|
|
8
|
+
import { encrypt, isEncrypted } from '../src/utils-crypto.js';
|
|
5
9
|
|
|
6
10
|
const args = minimist(process.argv.slice(2));
|
|
7
11
|
const __root = process.cwd();
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
const command = args._[0] || 'write';
|
|
14
|
+
|
|
15
|
+
const secret = args.secret
|
|
16
|
+
? (args.secret === 'stdin' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(args.secret, 'utf8'))
|
|
17
|
+
: process.env.WRENV_SECRET || process.env.TARGET_SECRET || '';
|
|
18
|
+
|
|
19
|
+
const envPath = args.env || '.env.json';
|
|
20
|
+
const envFile = path.resolve(__root, envPath);
|
|
21
|
+
|
|
22
|
+
if (!fs.existsSync(envFile)) {
|
|
23
|
+
console.error('No env file found.');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
const DEFAULT_ENV = 'dev';
|
|
11
28
|
const targets = {
|
|
12
29
|
"development": "dev",
|
|
@@ -17,187 +34,237 @@ const targets = {
|
|
|
17
34
|
"prod": "production",
|
|
18
35
|
"production": "production",
|
|
19
36
|
}
|
|
20
|
-
const target = targets[args.target] || DEFAULT_ENV;
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
async function showCommand() {
|
|
39
|
+
if (!secret) {
|
|
40
|
+
console.log('Secret not provided. Use --secret flag or WRENV_SECRET env variable.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const target = targets[args.target || process.env.WRENV_TARGET || process.env.TARGET_ENV] || DEFAULT_ENV;
|
|
44
|
+
|
|
45
|
+
const envContent = fs.readFileSync(envFile, 'utf8');
|
|
46
|
+
const env = JSON.parse(envContent);
|
|
47
|
+
|
|
48
|
+
const rootPkgPath = path.resolve(__root, 'package.json');
|
|
49
|
+
const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
|
|
50
|
+
const rootPkg = JSON.parse(rootPkgContent);
|
|
51
|
+
const scope = rootPkg.name.split('/')[0];
|
|
52
|
+
|
|
53
|
+
const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
|
|
54
|
+
const deps = [
|
|
55
|
+
...foundPackages.map((pkg) => ({
|
|
56
|
+
name: pkg.name.replace(`${scope}/`, ''),
|
|
57
|
+
dir: pkg.dir,
|
|
58
|
+
})),
|
|
59
|
+
{
|
|
60
|
+
name: 'root',
|
|
61
|
+
dir: __root,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
const depsName = deps.map((dep) => dep.name);
|
|
65
|
+
|
|
66
|
+
const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
|
|
67
|
+
const targetEnv = allowedEnvs.find((env) => env === target);
|
|
68
|
+
if (!targetEnv) {
|
|
69
|
+
console.error(`Target env "${target}" is not allowed.`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const allEnvs = buildEnv(secret, targetEnv, env, depsName);
|
|
74
|
+
|
|
75
|
+
for await (const dep of deps) {
|
|
76
|
+
const pkgEnv = Object.entries(allEnvs[dep.name] || {})
|
|
77
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
78
|
+
.join('\n')
|
|
79
|
+
console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~~');
|
|
80
|
+
console.log(`=== ENV for ${dep.name} in ${dep.dir} with target ${targetEnv}`);
|
|
81
|
+
console.log(pkgEnv);
|
|
82
|
+
}
|
|
27
83
|
|
|
28
|
-
if (!envFile) {
|
|
29
|
-
console.error('No env file found.');
|
|
30
|
-
process.exit(1);
|
|
31
84
|
}
|
|
32
85
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
name
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
async function writeCommand() {
|
|
87
|
+
if (!secret) {
|
|
88
|
+
console.log('Secret not provided. Use --secret flag or WRENV_SECRET env variable.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const target = targets[args.target || process.env.WRENV_TARGET || process.env.TARGET_ENV] || DEFAULT_ENV;
|
|
92
|
+
|
|
93
|
+
const envContent = fs.readFileSync(envFile, 'utf8');
|
|
94
|
+
const env = JSON.parse(envContent);
|
|
95
|
+
|
|
96
|
+
const rootPkgPath = path.resolve(__root, 'package.json');
|
|
97
|
+
const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
|
|
98
|
+
const rootPkg = JSON.parse(rootPkgContent);
|
|
99
|
+
const scope = rootPkg.name.split('/')[0];
|
|
100
|
+
|
|
101
|
+
const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
|
|
102
|
+
const deps = [
|
|
103
|
+
...foundPackages.map((pkg) => ({
|
|
104
|
+
name: pkg.name.replace(`${scope}/`, ''),
|
|
105
|
+
dir: pkg.dir,
|
|
106
|
+
})),
|
|
107
|
+
{
|
|
108
|
+
name: 'root',
|
|
109
|
+
dir: __root,
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const depsName = deps.map((dep) => dep.name);
|
|
113
|
+
|
|
114
|
+
const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
|
|
115
|
+
const targetEnv = allowedEnvs.find((env) => env === target);
|
|
116
|
+
if (!targetEnv) {
|
|
117
|
+
console.error(`Target env "${target}" is not allowed.`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const allEnvs = buildEnv(secret, targetEnv, env, depsName);
|
|
122
|
+
|
|
123
|
+
for await (const dep of deps) {
|
|
124
|
+
const pkgEnv = Object.entries(allEnvs[dep.name] || {})
|
|
125
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
126
|
+
.join('\n')
|
|
127
|
+
console.log(`Writing env file for ${dep.name} in ${dep.dir} with target ${targetEnv}`);
|
|
128
|
+
await fs.writeFile(path.join(dep.dir, '.env'), pkgEnv);
|
|
129
|
+
}
|
|
71
130
|
}
|
|
72
131
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
132
|
+
async function encryptCommand() {
|
|
133
|
+
const envContent = fs.readFileSync(envFile, 'utf8');
|
|
134
|
+
const env = JSON.parse(envContent);
|
|
135
|
+
|
|
136
|
+
if (!secret) {
|
|
137
|
+
console.error('Cannot encrypt env file: secret not provided. Doing nothing');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (env.variables) {
|
|
142
|
+
for (const [varName, varValue] of Object.entries(env.variables)) {
|
|
143
|
+
for (const [envKey, envVal] of Object.entries(varValue)) {
|
|
144
|
+
if (typeof envVal === 'string' && !isEncrypted(envVal)) {
|
|
145
|
+
env.variables[varName][envKey] = encrypt(envVal, secret);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (env.overrides) {
|
|
152
|
+
for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
|
|
153
|
+
for (const [varName, varValue] of Object.entries(pkgOverrides)) {
|
|
154
|
+
for (const [envKey, envVal] of Object.entries(varValue)) {
|
|
155
|
+
if (typeof envVal === 'string' && !isEncrypted(envVal)) {
|
|
156
|
+
env.overrides[pkgName][varName][envKey] = encrypt(envVal, secret);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await fs.writeFile(envFile, JSON.stringify(env, null, 2));
|
|
86
164
|
}
|
|
87
165
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// ========================================
|
|
91
|
-
|
|
92
|
-
function buildEnv(target, env, names) {
|
|
93
|
-
const variablesForAllTargets = env.variables;
|
|
94
|
-
const overrides = env.overrides || {};
|
|
95
|
-
const sections = env.sections || {};
|
|
96
|
-
|
|
97
|
-
// For each variables, extract the value for the target env
|
|
98
|
-
const variables = Object.entries(variablesForAllTargets).reduce((acc, entry) => {
|
|
99
|
-
const [key, value] = entry;
|
|
100
|
-
return {
|
|
101
|
-
...acc,
|
|
102
|
-
[key]: value[target] || value['@@'] || ''
|
|
103
|
-
};
|
|
104
|
-
}, {});
|
|
105
|
-
|
|
106
|
-
// For each package, get all variables
|
|
107
|
-
// compute the overrides and merge them
|
|
108
|
-
// allVars is an object with the package name as key
|
|
109
|
-
// and the variables as value like so
|
|
110
|
-
// => { [name]: { [variable]: value } }
|
|
111
|
-
const allVars = names.reduce((acc, name) => {
|
|
112
|
-
acc[name] = {
|
|
113
|
-
...variables,
|
|
114
|
-
...parseOverrides(target, overrides[name], variables)
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
return acc;
|
|
118
|
-
}, {});
|
|
119
|
-
|
|
120
|
-
// For each package, get all dependencies of variables
|
|
121
|
-
// For instance and for clarity, backoffice can include all vars
|
|
122
|
-
// from the api, and the api can include all vars from the core
|
|
123
|
-
const allDeps = names.reduce((acc, name) => ({
|
|
124
|
-
...acc,
|
|
125
|
-
[name]: parseSectionDeps(name, sections).reverse()
|
|
126
|
-
}), {})
|
|
127
|
-
|
|
128
|
-
// For each package, get all variable names from the dependencies
|
|
129
|
-
// and from the package itself and merge them
|
|
130
|
-
const allSections = Object.entries(allDeps).reduce((acc, entry) => {
|
|
131
|
-
const [key, deps] = entry;
|
|
132
|
-
|
|
133
|
-
// For all deps, get the variable names
|
|
134
|
-
const depVars = deps.filter(Boolean).reduce((_acc, dep) => {
|
|
135
|
-
const vars = parseSectionVars(dep, sections)
|
|
136
|
-
return [..._acc, ...vars];
|
|
137
|
-
}, []);
|
|
138
|
-
|
|
139
|
-
// For the current section, get the variable names
|
|
140
|
-
const nameVars = parseSectionVars(key, sections)
|
|
141
|
-
|
|
142
|
-
// Merge all variables names, and for each variable name,
|
|
143
|
-
// get the value from the allVars object
|
|
144
|
-
return {
|
|
145
|
-
...acc,
|
|
146
|
-
[key]: [...depVars, ...nameVars].reduce((_acc, variable) => {
|
|
147
|
-
if (!allVars[key] || allVars[key][variable] === null) return _acc;
|
|
148
|
-
|
|
149
|
-
return { ..._acc, [variable]: allVars[key][variable] };
|
|
150
|
-
}, {})
|
|
151
|
-
};
|
|
152
|
-
}, {});
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return allSections;
|
|
156
|
-
}
|
|
166
|
+
async function besafeCommand() {
|
|
167
|
+
const relativeEnvPath = path.relative(__root, envFile);
|
|
157
168
|
|
|
158
|
-
|
|
159
|
-
|
|
169
|
+
try {
|
|
170
|
+
const { stdout: stagedDiff } = await execa('git', ['diff', '--cached', '--name-only', '--', relativeEnvPath], { cwd: __root, reject: false });
|
|
171
|
+
const { stdout: unstagedDiff } = await execa('git', ['diff', '--name-only', '--', relativeEnvPath], { cwd: __root, reject: false });
|
|
172
|
+
const { stdout: untracked } = await execa('git', ['ls-files', '--others', '--exclude-standard', '--', relativeEnvPath], { cwd: __root, reject: false });
|
|
160
173
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
if (!stagedDiff?.trim() && !unstagedDiff?.trim() && !untracked?.trim()) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
164
180
|
|
|
165
|
-
|
|
181
|
+
await encrypt();
|
|
166
182
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
if (!secret) {
|
|
184
|
+
console.error('Warning: potential security risk: secret not provided. Variables WERE NOT be encrypted.');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await execa('git', ['add', relativeEnvPath], { cwd: __root });
|
|
172
188
|
}
|
|
173
189
|
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
190
|
+
async function addCommand() {
|
|
191
|
+
const varName = args._[1];
|
|
192
|
+
if (!varName) {
|
|
193
|
+
console.error('Variable name is required.');
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!secret) {
|
|
198
|
+
console.error('Warning: potential security risk: secret not provided. Variable WILL NOT be encrypted.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const envContent = fs.readFileSync(envFile, 'utf8');
|
|
202
|
+
const env = JSON.parse(envContent);
|
|
203
|
+
|
|
204
|
+
if (!env.variables) {
|
|
205
|
+
env.variables = {};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!env.variables[varName]) {
|
|
209
|
+
env.variables[varName] = {};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const arg of args._.slice(2)) {
|
|
213
|
+
if (!arg.startsWith('+')) continue;
|
|
214
|
+
const [key, ...valueParts] = arg.slice(1).split('=');
|
|
215
|
+
const value = valueParts.join('=');
|
|
216
|
+
const envKey = key === 'fallback' ? '@@' : key;
|
|
217
|
+
env.variables[varName][envKey] = secret ? encrypt(value, secret) : value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await fs.writeFile(envFile, JSON.stringify(env, null, 2));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function helpCommand() {
|
|
224
|
+
console.log('Usage: wrenv [command] [options]\n');
|
|
225
|
+
console.log('Commands:');
|
|
226
|
+
console.log(' write Write .env files for all packages (default)');
|
|
227
|
+
console.log(' show Show environment variables without writing files');
|
|
228
|
+
console.log(' encrypt Encrypt all unencrypted variables in .env.json');
|
|
229
|
+
console.log(' besafe Encrypt and stage .env.json if it has changes');
|
|
230
|
+
console.log(' add <var> Add a new variable with environment-specific values');
|
|
231
|
+
console.log(' help Show this help message\n');
|
|
232
|
+
console.log('Options:');
|
|
233
|
+
console.log(' --secret <path|stdin> Path to secret file or "stdin" to read from stdin');
|
|
234
|
+
console.log(' --target <env> Target environment (dev, staging, prod, etc.)');
|
|
235
|
+
console.log(' --env <path> Path to env file (default: .env.json)\n');
|
|
236
|
+
console.log('Environment Variables:');
|
|
237
|
+
console.log(' WRENV_SECRET Secret for encryption/decryption');
|
|
238
|
+
console.log(' WRENV_TARGET Target environment');
|
|
239
|
+
console.log(' TARGET_SECRET Alias for WRENV_SECRET');
|
|
240
|
+
console.log(' TARGET_ENV Alias for WRENV_TARGET\n');
|
|
241
|
+
console.log('Examples:');
|
|
242
|
+
console.log(' wrenv --secret ~/.big-secret write --target dev');
|
|
243
|
+
console.log(' wrenv --secret stdin write < ~/.big-secret');
|
|
244
|
+
console.log(' wrenv show --target prod');
|
|
245
|
+
console.log(' wrenv add NEW_VAR +fallback=value +dev=dev_value +production=prod_value');
|
|
196
246
|
}
|
|
197
247
|
|
|
198
|
-
|
|
199
|
-
|
|
248
|
+
const commands = {
|
|
249
|
+
write: writeCommand,
|
|
250
|
+
show: showCommand,
|
|
251
|
+
besafe: besafeCommand,
|
|
252
|
+
encrypt: encryptCommand,
|
|
253
|
+
add: addCommand,
|
|
254
|
+
help: helpCommand,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const commandFunction = commands[command];
|
|
258
|
+
|
|
259
|
+
if (!commandFunction) {
|
|
260
|
+
console.error(`Unknown command: ${command}\n`);
|
|
261
|
+
helpCommand();
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
200
264
|
|
|
201
|
-
|
|
202
|
-
|
|
265
|
+
try {
|
|
266
|
+
await commandFunction();
|
|
267
|
+
} catch (error) {
|
|
268
|
+
console.error(error.message);
|
|
269
|
+
process.exit(1);
|
|
203
270
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saasak/tool-env",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "1.0.1",
|
|
5
5
|
"author": "dev@saasak.studio",
|
|
6
6
|
"description": "A small util to manage environment variables for your monorepo",
|
|
7
7
|
"keywords": [
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"type": "module",
|
|
13
13
|
"main": "bin/index.js",
|
|
14
14
|
"files": [
|
|
15
|
-
"bin"
|
|
15
|
+
"bin",
|
|
16
|
+
"src"
|
|
16
17
|
],
|
|
17
18
|
"bin": {
|
|
18
19
|
"wrenv": "bin/index.js"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
// Constants for AES-256-GCM encryption
|
|
4
|
+
// GCM provides authenticated encryption (confidentiality + integrity)
|
|
5
|
+
// Using 256-bit keys for strong security
|
|
6
|
+
const ENCRYPTION_PREFIX = '$enc$';
|
|
7
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
8
|
+
const IV_LENGTH = 16; // 128 bits
|
|
9
|
+
const SALT_LENGTH = 64; // 512 bits
|
|
10
|
+
const TAG_LENGTH = 16; // 128 bits
|
|
11
|
+
const KEY_LENGTH = 32; // 256 bits
|
|
12
|
+
const ITERATIONS = 100000; // PBKDF2 iterations
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Derive a 256-bit key from a secret using PBKDF2
|
|
16
|
+
* This ensures consistent key generation from variable-length secrets
|
|
17
|
+
* and adds computational cost to prevent brute-force attacks
|
|
18
|
+
*/
|
|
19
|
+
function deriveKey(secret, salt) {
|
|
20
|
+
return crypto.pbkdf2Sync(secret, salt, ITERATIONS, KEY_LENGTH, 'sha256');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Encrypt a string value using AES-256-GCM
|
|
25
|
+
* Returns a string with format: $enc$<base64(salt+iv+ciphertext+tag)>
|
|
26
|
+
* Each encryption uses a unique salt and IV, so the same plaintext produces different ciphertexts
|
|
27
|
+
*/
|
|
28
|
+
export function encrypt(plaintext, secret) {
|
|
29
|
+
if (!plaintext) return plaintext;
|
|
30
|
+
|
|
31
|
+
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
32
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
33
|
+
const key = deriveKey(secret, salt);
|
|
34
|
+
|
|
35
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
36
|
+
|
|
37
|
+
let encrypted = cipher.update(plaintext, 'utf8');
|
|
38
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
39
|
+
|
|
40
|
+
const tag = cipher.getAuthTag();
|
|
41
|
+
|
|
42
|
+
// Combine: salt + iv + encrypted + tag, then base64 encode
|
|
43
|
+
const combined = Buffer.concat([salt, iv, encrypted, tag]);
|
|
44
|
+
const encoded = combined.toString('base64');
|
|
45
|
+
|
|
46
|
+
return `${ENCRYPTION_PREFIX}${encoded}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decrypt an encrypted string
|
|
51
|
+
* Strips the prefix and decodes the base64 payload
|
|
52
|
+
* GCM authentication tag ensures the data hasn't been tampered with
|
|
53
|
+
*/
|
|
54
|
+
export function decrypt(encrypted, secret) {
|
|
55
|
+
if (!encrypted || !isEncrypted(encrypted)) {
|
|
56
|
+
return encrypted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Remove prefix and decode
|
|
60
|
+
const encoded = encrypted.slice(ENCRYPTION_PREFIX.length);
|
|
61
|
+
const combined = Buffer.from(encoded, 'base64');
|
|
62
|
+
|
|
63
|
+
// Extract components
|
|
64
|
+
const salt = combined.slice(0, SALT_LENGTH);
|
|
65
|
+
const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
|
|
66
|
+
const tag = combined.slice(-TAG_LENGTH);
|
|
67
|
+
const encryptedData = combined.slice(
|
|
68
|
+
SALT_LENGTH + IV_LENGTH,
|
|
69
|
+
-TAG_LENGTH
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const key = deriveKey(secret, salt);
|
|
73
|
+
|
|
74
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
75
|
+
decipher.setAuthTag(tag);
|
|
76
|
+
|
|
77
|
+
let decrypted = decipher.update(encryptedData);
|
|
78
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
79
|
+
|
|
80
|
+
return decrypted.toString('utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a string is encrypted by looking for the prefix
|
|
85
|
+
* This allows the system to detect encrypted values without attempting decryption
|
|
86
|
+
*/
|
|
87
|
+
export function isEncrypted(value) {
|
|
88
|
+
return typeof value === 'string' && value.startsWith(ENCRYPTION_PREFIX);
|
|
89
|
+
}
|
|
90
|
+
|
package/src/utils-env.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { decrypt, isEncrypted } from './utils-crypto.js';
|
|
2
|
+
|
|
3
|
+
// ========================================
|
|
4
|
+
// Helpers for core logic
|
|
5
|
+
// ========================================
|
|
6
|
+
|
|
7
|
+
export function buildEnv(secret, target, env, names) {
|
|
8
|
+
const variablesForAllTargets = env.variables;
|
|
9
|
+
const overrides = env.overrides || {};
|
|
10
|
+
|
|
11
|
+
// For each variable, extract the value for the target env
|
|
12
|
+
const variables = Object.entries(variablesForAllTargets).reduce((acc, entry) => {
|
|
13
|
+
const [key, value] = entry;
|
|
14
|
+
return {
|
|
15
|
+
...acc,
|
|
16
|
+
[key]: extractValue(secret, value[target] || value['@@'] || '')
|
|
17
|
+
};
|
|
18
|
+
}, {});
|
|
19
|
+
|
|
20
|
+
// For each package, get all variables
|
|
21
|
+
// compute the overrides and merge them
|
|
22
|
+
// allVars is an object with the package name as key
|
|
23
|
+
// and the variables as value like so
|
|
24
|
+
// => { [name]: { [variable]: value } }
|
|
25
|
+
const allVars = names.reduce((acc, name) => {
|
|
26
|
+
acc[name] = {
|
|
27
|
+
...variables,
|
|
28
|
+
...parseOverrides(secret, target, overrides[name], variables)
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return acc;
|
|
32
|
+
}, {});
|
|
33
|
+
|
|
34
|
+
return allVars;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseOverrides(secret, target, overrides, vars) {
|
|
38
|
+
if (!secret) return {};
|
|
39
|
+
if (!overrides) return {};
|
|
40
|
+
|
|
41
|
+
return Object.entries(overrides).reduce((acc, entry) => {
|
|
42
|
+
const [key, val] = entry;
|
|
43
|
+
const value = extractValue(secret, val[target] || val['@@'] || '');
|
|
44
|
+
|
|
45
|
+
if (value === null) return acc;
|
|
46
|
+
|
|
47
|
+
acc[key] = Array.isArray(value)
|
|
48
|
+
? value.map(v => vars[v] || v).join('')
|
|
49
|
+
: value
|
|
50
|
+
return acc;
|
|
51
|
+
}, {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractValue(secret, value) {
|
|
55
|
+
if (!isEncrypted(value)) return value;
|
|
56
|
+
if (!secret) return '';
|
|
57
|
+
return decrypt(value, secret);
|
|
58
|
+
}
|
package/src/utils-pkg.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
|
|
4
|
+
export async function findMonorepoPackages(rootDir, scope = null, maxDepth = 4) {
|
|
5
|
+
const packages = [];
|
|
6
|
+
|
|
7
|
+
async function processPackage(pkgPath, pkgDir) {
|
|
8
|
+
try {
|
|
9
|
+
const pkgContent = await fs.readFile(pkgPath, 'utf8');
|
|
10
|
+
const pkg = JSON.parse(pkgContent);
|
|
11
|
+
|
|
12
|
+
if (scope && !pkg.name?.startsWith(`${scope}/`)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
name: pkg.name,
|
|
18
|
+
dir: pkgDir,
|
|
19
|
+
};
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function traverse(dir, currentDepth = 0) {
|
|
26
|
+
if (currentDepth > maxDepth) return;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const fullPath = path.join(dir, entry.name);
|
|
33
|
+
|
|
34
|
+
if (entry.name === 'node_modules' || entry.isSymbolicLink()) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!entry.isDirectory()) continue;
|
|
39
|
+
|
|
40
|
+
const pkgPath = path.join(fullPath, 'package.json');
|
|
41
|
+
const exists = await fs.pathExists(pkgPath);
|
|
42
|
+
|
|
43
|
+
if (exists) {
|
|
44
|
+
const pkg = await processPackage(pkgPath, fullPath);
|
|
45
|
+
packages.push(pkg);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await traverse(fullPath, currentDepth + 1);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rootPkgPath = path.join(rootDir, 'package.json');
|
|
56
|
+
const exists = await fs.pathExists(rootPkgPath);
|
|
57
|
+
|
|
58
|
+
if (!exists) return [];
|
|
59
|
+
|
|
60
|
+
const pkg = await processPackage(rootPkgPath, rootDir);
|
|
61
|
+
|
|
62
|
+
if (!pkg) return [];
|
|
63
|
+
|
|
64
|
+
packages.push(pkg);
|
|
65
|
+
await traverse(rootDir, 0);
|
|
66
|
+
|
|
67
|
+
return packages;
|
|
68
|
+
}
|