@saasak/tool-env 1.0.1 → 1.1.0
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 +25 -4
- package/bin/index.js +124 -46
- package/package.json +9 -2
- package/src/core.js +446 -0
- package/src/index.js +42 -0
- package/src/utils-crypto.js +25 -6
- package/src/utils-env.js +58 -6
- package/src/utils-pkg.js +72 -55
package/README.md
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
## TODOs
|
|
16
16
|
|
|
17
|
-
[] Handle encryption
|
|
18
|
-
[]
|
|
19
|
-
[]
|
|
20
|
-
[]
|
|
17
|
+
- [x] Handle encryption
|
|
18
|
+
- [ ] Fix not scoped package detection (if one package is not scope, it fails with pkg.name something)
|
|
19
|
+
- [ ] Handle variable composition everywhere (not just in overrides)
|
|
20
|
+
- [x] Create runtime library to read vars (even encrypted)
|
|
21
|
+
- [x] Handle env.local files
|
|
21
22
|
|
|
22
23
|
## Open questions
|
|
23
24
|
|
|
@@ -46,6 +47,26 @@ or
|
|
|
46
47
|
WRENV_TARGET=staging bun run env:update
|
|
47
48
|
```
|
|
48
49
|
|
|
50
|
+
### Writing all environments
|
|
51
|
+
|
|
52
|
+
Use `--target all` to write environment files for all configured environments at once.
|
|
53
|
+
This creates suffixed files using conventional names (`.env.development`, `.env.staging`, `.env.production`) instead of a single `.env` file.
|
|
54
|
+
|
|
55
|
+
Internal names are mapped to conventional output names:
|
|
56
|
+
- `dev` → `.env.development`
|
|
57
|
+
- `preprod` → `.env.staging`
|
|
58
|
+
- `production` → `.env.production`
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
wrenv --secret ~/.big-secret write --target all
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
This is useful for:
|
|
65
|
+
- CI/CD pipelines that need all environment configurations
|
|
66
|
+
- Docker builds that copy environment-specific files
|
|
67
|
+
- Pre-generating all env files for deployment
|
|
68
|
+
- Compatibility with Next.js, Vite, and other frameworks that use conventional env file names
|
|
69
|
+
|
|
49
70
|
you can also pass the secret via an env variable (Even though it is not really encouraged)
|
|
50
71
|
```bash
|
|
51
72
|
WRENV_SECRET=super-secret WRENV_TARGET=prod npm run env:update
|
package/bin/index.js
CHANGED
|
@@ -1,46 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
1
3
|
import minimist from 'minimist';
|
|
2
4
|
import fs from 'fs-extra';
|
|
3
5
|
import path from 'path';
|
|
4
6
|
import { execa } from 'execa';
|
|
5
7
|
|
|
8
|
+
import { encrypt, isEncrypted } from '../src/utils-crypto.js';
|
|
6
9
|
import { buildEnv } from '../src/utils-env.js';
|
|
7
10
|
import { findMonorepoPackages } from '../src/utils-pkg.js';
|
|
8
|
-
import {
|
|
11
|
+
import { resolveTarget, verifyCanDecrypt, getOutputName } from '../src/core.js';
|
|
9
12
|
|
|
10
|
-
const args = minimist(process.argv.slice(2));
|
|
13
|
+
const args = minimist(process.argv.slice(2), { '--': true });
|
|
11
14
|
const __root = process.cwd();
|
|
12
15
|
|
|
13
16
|
const command = args._[0] || 'write';
|
|
14
17
|
|
|
15
18
|
const secret = args.secret
|
|
16
|
-
? (args.secret === 'stdin' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(args.secret, 'utf8'))
|
|
19
|
+
? (args.secret === 'stdin' ? fs.readFileSync(0, 'utf8').trim() : fs.readFileSync(args.secret, 'utf8').trim())
|
|
17
20
|
: process.env.WRENV_SECRET || process.env.TARGET_SECRET || '';
|
|
18
21
|
|
|
19
22
|
const envPath = args.env || '.env.json';
|
|
20
23
|
const envFile = path.resolve(__root, envPath);
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
// Check env file exists for commands that need it
|
|
26
|
+
const commandsRequiringEnvFile = ['write', 'show', 'encrypt', 'besafe', 'add'];
|
|
27
|
+
if (commandsRequiringEnvFile.includes(command) && !fs.existsSync(envFile)) {
|
|
23
28
|
console.error('No env file found.');
|
|
24
29
|
process.exit(1);
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
const DEFAULT_ENV = 'dev';
|
|
28
|
-
const targets = {
|
|
29
|
-
"development": "dev",
|
|
30
|
-
"dev": "dev",
|
|
31
|
-
"staging": "preprod",
|
|
32
|
-
"pp": "preprod",
|
|
33
|
-
"preprod": "preprod",
|
|
34
|
-
"prod": "production",
|
|
35
|
-
"production": "production",
|
|
36
|
-
}
|
|
37
|
-
|
|
38
32
|
async function showCommand() {
|
|
39
33
|
if (!secret) {
|
|
40
34
|
console.log('Secret not provided. Use --secret flag or WRENV_SECRET env variable.');
|
|
41
35
|
}
|
|
42
36
|
|
|
43
|
-
const target =
|
|
37
|
+
const target = resolveTarget(args.target);
|
|
44
38
|
|
|
45
39
|
const envContent = fs.readFileSync(envFile, 'utf8');
|
|
46
40
|
const env = JSON.parse(envContent);
|
|
@@ -48,11 +42,12 @@ async function showCommand() {
|
|
|
48
42
|
const rootPkgPath = path.resolve(__root, 'package.json');
|
|
49
43
|
const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
|
|
50
44
|
const rootPkg = JSON.parse(rootPkgContent);
|
|
45
|
+
|
|
51
46
|
const scope = rootPkg.name.split('/')[0];
|
|
47
|
+
const [_, ...foundPackages] = findMonorepoPackages(__root, scope);
|
|
52
48
|
|
|
53
|
-
const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
|
|
54
49
|
const deps = [
|
|
55
|
-
...foundPackages.map((pkg) => ({
|
|
50
|
+
...foundPackages.filter(Boolean).map((pkg) => ({
|
|
56
51
|
name: pkg.name.replace(`${scope}/`, ''),
|
|
57
52
|
dir: pkg.dir,
|
|
58
53
|
})),
|
|
@@ -61,9 +56,10 @@ async function showCommand() {
|
|
|
61
56
|
dir: __root,
|
|
62
57
|
},
|
|
63
58
|
];
|
|
59
|
+
|
|
64
60
|
const depsName = deps.map((dep) => dep.name);
|
|
65
61
|
|
|
66
|
-
const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [
|
|
62
|
+
const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : ['dev'];
|
|
67
63
|
const targetEnv = allowedEnvs.find((env) => env === target);
|
|
68
64
|
if (!targetEnv) {
|
|
69
65
|
console.error(`Target env "${target}" is not allowed.`);
|
|
@@ -88,7 +84,7 @@ async function writeCommand() {
|
|
|
88
84
|
console.log('Secret not provided. Use --secret flag or WRENV_SECRET env variable.');
|
|
89
85
|
}
|
|
90
86
|
|
|
91
|
-
const target =
|
|
87
|
+
const target = resolveTarget(args.target);
|
|
92
88
|
|
|
93
89
|
const envContent = fs.readFileSync(envFile, 'utf8');
|
|
94
90
|
const env = JSON.parse(envContent);
|
|
@@ -111,21 +107,29 @@ async function writeCommand() {
|
|
|
111
107
|
];
|
|
112
108
|
const depsName = deps.map((dep) => dep.name);
|
|
113
109
|
|
|
114
|
-
const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [
|
|
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
|
-
}
|
|
110
|
+
const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : ['dev'];
|
|
120
111
|
|
|
121
|
-
|
|
112
|
+
// Handle 'all' target: write all environments with suffixed filenames
|
|
113
|
+
const isAllTarget = target === 'all';
|
|
114
|
+
const targetEnvs = isAllTarget ? allowedEnvs : [target];
|
|
122
115
|
|
|
123
|
-
for
|
|
124
|
-
|
|
125
|
-
.
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
|
|
116
|
+
for (const targetEnv of targetEnvs) {
|
|
117
|
+
if (!allowedEnvs.includes(targetEnv)) {
|
|
118
|
+
console.error(`Target env "${targetEnv}" is not allowed.`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const allEnvs = buildEnv(secret, targetEnv, env, depsName);
|
|
123
|
+
|
|
124
|
+
for await (const dep of deps) {
|
|
125
|
+
const pkgEnv = Object.entries(allEnvs[dep.name] || {})
|
|
126
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
127
|
+
.join('\n')
|
|
128
|
+
// Use suffixed filename (.env.{outputName}) for 'all', plain .env otherwise
|
|
129
|
+
const filename = isAllTarget ? `.env.${getOutputName(targetEnv)}` : '.env';
|
|
130
|
+
console.log(`Writing ${filename} for ${dep.name} in ${dep.dir}`);
|
|
131
|
+
await fs.writeFile(path.join(dep.dir, filename), pkgEnv);
|
|
132
|
+
}
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -138,6 +142,15 @@ async function encryptCommand() {
|
|
|
138
142
|
return;
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
// Verify we can decrypt all existing encrypted values before encrypting new ones
|
|
146
|
+
const verification = verifyCanDecrypt(env, secret);
|
|
147
|
+
if (!verification.success) {
|
|
148
|
+
console.error(`Cannot encrypt: the provided secret cannot decrypt existing encrypted value at "${verification.path}".`);
|
|
149
|
+
console.error('This would result in a file with mixed encryption passwords.');
|
|
150
|
+
console.error('Please provide the correct secret that was used for existing encrypted values.');
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
if (env.variables) {
|
|
142
155
|
for (const [varName, varValue] of Object.entries(env.variables)) {
|
|
143
156
|
for (const [envKey, envVal] of Object.entries(varValue)) {
|
|
@@ -164,27 +177,30 @@ async function encryptCommand() {
|
|
|
164
177
|
}
|
|
165
178
|
|
|
166
179
|
async function besafeCommand() {
|
|
167
|
-
|
|
180
|
+
// Find git root to ensure we're in a repository and use correct base path
|
|
181
|
+
const { stdout: gitRoot, exitCode: gitCheck } = await execa('git', ['rev-parse', '--show-toplevel'], { cwd: __root, reject: false });
|
|
182
|
+
if (gitCheck !== 0) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
168
185
|
|
|
169
|
-
|
|
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 });
|
|
186
|
+
const relativeEnvPath = path.relative(gitRoot, envFile);
|
|
173
187
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
188
|
+
// Check if file has any changes (staged, unstaged, or untracked)
|
|
189
|
+
const { stdout: staged } = await execa('git', ['diff', '--cached', '--name-only', '--', relativeEnvPath], { cwd: gitRoot, reject: false });
|
|
190
|
+
const { stdout: unstaged } = await execa('git', ['diff', '--name-only', '--', relativeEnvPath], { cwd: gitRoot, reject: false });
|
|
191
|
+
const { stdout: untracked } = await execa('git', ['ls-files', '--others', '--exclude-standard', '--', relativeEnvPath], { cwd: gitRoot, reject: false });
|
|
192
|
+
|
|
193
|
+
if (!staged?.trim() && !unstaged?.trim() && !untracked?.trim()) {
|
|
178
194
|
return;
|
|
179
195
|
}
|
|
180
196
|
|
|
181
|
-
await
|
|
197
|
+
await encryptCommand();
|
|
182
198
|
|
|
183
199
|
if (!secret) {
|
|
184
|
-
console.error('Warning: potential security risk: secret not provided. Variables WERE NOT
|
|
200
|
+
console.error('Warning: potential security risk: secret not provided. Variables WERE NOT encrypted.');
|
|
185
201
|
}
|
|
186
202
|
|
|
187
|
-
await execa('git', ['add', relativeEnvPath], { cwd:
|
|
203
|
+
await execa('git', ['add', relativeEnvPath], { cwd: gitRoot });
|
|
188
204
|
}
|
|
189
205
|
|
|
190
206
|
async function addCommand() {
|
|
@@ -220,18 +236,76 @@ async function addCommand() {
|
|
|
220
236
|
await fs.writeFile(envFile, JSON.stringify(env, null, 2));
|
|
221
237
|
}
|
|
222
238
|
|
|
239
|
+
async function runCommand() {
|
|
240
|
+
const commandParts = args['--'] || [];
|
|
241
|
+
const [cmd, ...cmdArgs] = commandParts;
|
|
242
|
+
|
|
243
|
+
if (!cmd) {
|
|
244
|
+
console.error('Usage: wrenv run [options] -- <command> [args...]');
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Load environment variables
|
|
249
|
+
let envVars = {};
|
|
250
|
+
try {
|
|
251
|
+
const { loadEnvJson, loadLocalOverrides, resolveTarget, resolveSecret } = await import('../src/core.js');
|
|
252
|
+
|
|
253
|
+
const resolvedSecret = args.secret
|
|
254
|
+
? (args.secret === 'stdin' ? fs.readFileSync(0, 'utf8').trim() : fs.readFileSync(args.secret, 'utf8').trim())
|
|
255
|
+
: process.env.WRENV_SECRET || process.env.TARGET_SECRET || null;
|
|
256
|
+
|
|
257
|
+
const target = resolveTarget(args.target);
|
|
258
|
+
|
|
259
|
+
envVars = loadEnvJson({
|
|
260
|
+
secret: resolvedSecret,
|
|
261
|
+
targetEnv: target,
|
|
262
|
+
envPath: args.env,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Apply .env.local overrides only in dev mode (security: prevent local overrides in production)
|
|
266
|
+
if (target === 'dev') {
|
|
267
|
+
const localOverrides = loadLocalOverrides();
|
|
268
|
+
for (const [key, value] of Object.entries(localOverrides)) {
|
|
269
|
+
if (Object.hasOwn(envVars, key)) {
|
|
270
|
+
envVars[key] = value;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.error(`wrenv: ${error.message}`);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Use execa for cross-platform compatibility with minimal overhead
|
|
280
|
+
const subprocess = execa(cmd, cmdArgs, {
|
|
281
|
+
stdio: 'inherit',
|
|
282
|
+
env: { ...process.env, ...envVars },
|
|
283
|
+
reject: false,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Forward signals to child process
|
|
287
|
+
const forwardSignal = (signal) => subprocess.kill(signal);
|
|
288
|
+
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
|
|
289
|
+
process.on('SIGINT', () => forwardSignal('SIGINT'));
|
|
290
|
+
process.on('SIGHUP', () => forwardSignal('SIGHUP'));
|
|
291
|
+
|
|
292
|
+
const result = await subprocess;
|
|
293
|
+
process.exit(result.exitCode ?? 0);
|
|
294
|
+
}
|
|
295
|
+
|
|
223
296
|
function helpCommand() {
|
|
224
297
|
console.log('Usage: wrenv [command] [options]\n');
|
|
225
298
|
console.log('Commands:');
|
|
226
299
|
console.log(' write Write .env files for all packages (default)');
|
|
227
300
|
console.log(' show Show environment variables without writing files');
|
|
301
|
+
console.log(' run Run a command with injected environment variables');
|
|
228
302
|
console.log(' encrypt Encrypt all unencrypted variables in .env.json');
|
|
229
303
|
console.log(' besafe Encrypt and stage .env.json if it has changes');
|
|
230
304
|
console.log(' add <var> Add a new variable with environment-specific values');
|
|
231
305
|
console.log(' help Show this help message\n');
|
|
232
306
|
console.log('Options:');
|
|
233
307
|
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,
|
|
308
|
+
console.log(' --target <env> Target environment (dev, staging, prod, all)');
|
|
235
309
|
console.log(' --env <path> Path to env file (default: .env.json)\n');
|
|
236
310
|
console.log('Environment Variables:');
|
|
237
311
|
console.log(' WRENV_SECRET Secret for encryption/decryption');
|
|
@@ -242,7 +316,10 @@ function helpCommand() {
|
|
|
242
316
|
console.log(' wrenv --secret ~/.big-secret write --target dev');
|
|
243
317
|
console.log(' wrenv --secret stdin write < ~/.big-secret');
|
|
244
318
|
console.log(' wrenv show --target prod');
|
|
319
|
+
console.log(' wrenv run --target prod -- node server.js');
|
|
320
|
+
console.log(' wrenv run -- npm test --coverage');
|
|
245
321
|
console.log(' wrenv add NEW_VAR +fallback=value +dev=dev_value +production=prod_value');
|
|
322
|
+
console.log(' wrenv write --target all # writes .env.development, .env.staging, .env.production');
|
|
246
323
|
}
|
|
247
324
|
|
|
248
325
|
const commands = {
|
|
@@ -251,6 +328,7 @@ const commands = {
|
|
|
251
328
|
besafe: besafeCommand,
|
|
252
329
|
encrypt: encryptCommand,
|
|
253
330
|
add: addCommand,
|
|
331
|
+
run: runCommand,
|
|
254
332
|
help: helpCommand,
|
|
255
333
|
};
|
|
256
334
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saasak/tool-env",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"author": "dev@saasak.studio",
|
|
6
6
|
"description": "A small util to manage environment variables for your monorepo",
|
|
7
7
|
"keywords": [
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"env"
|
|
11
11
|
],
|
|
12
12
|
"type": "module",
|
|
13
|
-
"main": "
|
|
13
|
+
"main": "src/index.js",
|
|
14
14
|
"files": [
|
|
15
15
|
"bin",
|
|
16
16
|
"src"
|
|
@@ -22,5 +22,12 @@
|
|
|
22
22
|
"execa": "8.0.1",
|
|
23
23
|
"fs-extra": "11.2.0",
|
|
24
24
|
"minimist": "1.2.8"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"vitest": "^1.6.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest"
|
|
25
32
|
}
|
|
26
33
|
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { decrypt, isEncrypted } from './utils-crypto.js';
|
|
4
|
+
import { buildEnv } from './utils-env.js';
|
|
5
|
+
import { findMonorepoPackages } from './utils-pkg.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object.<string, string>} EnvRecord
|
|
9
|
+
* Key-value pairs of environment variable name to value
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} LoadOptions
|
|
14
|
+
* @property {string} [targetEnv] - Target environment (e.g. 'dev', 'production')
|
|
15
|
+
* @property {string} [secret] - Secret for decryption (or file://path to read from file)
|
|
16
|
+
* @property {string} [envPath] - Path to env.json file (default: '.env.json')
|
|
17
|
+
* @property {string} [cwd] - Working directory (default: process.cwd())
|
|
18
|
+
* @property {boolean} [applyToProcess] - Whether to apply to process.env (default: true)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} LoadEnvJsonOptions
|
|
23
|
+
* @property {string} [secret] - Secret for decryption (or file://path)
|
|
24
|
+
* @property {string} [targetEnv] - Target environment
|
|
25
|
+
* @property {string} [envPath] - Path to env.json file (default: '.env.json')
|
|
26
|
+
* @property {string} [cwd] - Working directory (default: process.cwd())
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} VerifyResult
|
|
31
|
+
* @property {boolean} success - Whether verification passed
|
|
32
|
+
* @property {string|null} [path] - Path to the failed encrypted value
|
|
33
|
+
* @property {string} [error] - Error message if verification failed
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** @type {string} */
|
|
37
|
+
const DEFAULT_ENV = 'dev';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mapping of environment name aliases to canonical names
|
|
41
|
+
* Note: 'all' is a special target that writes all environments (write command only)
|
|
42
|
+
* @type {Object.<string, string>}
|
|
43
|
+
*/
|
|
44
|
+
const targets = {
|
|
45
|
+
"development": "dev",
|
|
46
|
+
"dev": "dev",
|
|
47
|
+
"staging": "preprod",
|
|
48
|
+
"pp": "preprod",
|
|
49
|
+
"preprod": "preprod",
|
|
50
|
+
"prod": "production",
|
|
51
|
+
"production": "production",
|
|
52
|
+
"all": "all",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mapping of internal environment names to conventional output file names
|
|
57
|
+
* Used when writing .env files with 'all' target
|
|
58
|
+
* @type {Object.<string, string>}
|
|
59
|
+
*/
|
|
60
|
+
const outputNames = {
|
|
61
|
+
"dev": "development",
|
|
62
|
+
"preprod": "staging",
|
|
63
|
+
"production": "production",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the conventional output file name for an internal environment name
|
|
68
|
+
* Falls back to the internal name if no mapping exists
|
|
69
|
+
* @param {string} internalName - Internal environment name (e.g. 'dev', 'preprod')
|
|
70
|
+
* @returns {string} - Conventional output name (e.g. 'development', 'staging')
|
|
71
|
+
*/
|
|
72
|
+
export function getOutputName(internalName) {
|
|
73
|
+
return outputNames[internalName] || internalName;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve target environment with fallback chain:
|
|
78
|
+
* param -> WRENV_TARGET -> TARGET_ENV -> NODE_ENV (mapped) -> 'dev'
|
|
79
|
+
* @param {string} [targetParam] - Explicit target environment parameter
|
|
80
|
+
* @returns {string} - Resolved target environment
|
|
81
|
+
*/
|
|
82
|
+
export function resolveTarget(targetParam) {
|
|
83
|
+
if (targetParam) {
|
|
84
|
+
return targets[targetParam] || targetParam;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const envTarget = process.env.WRENV_TARGET || process.env.TARGET_ENV;
|
|
88
|
+
if (envTarget) {
|
|
89
|
+
return targets[envTarget] || envTarget;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const nodeEnv = process.env.NODE_ENV;
|
|
93
|
+
if (nodeEnv && targets[nodeEnv]) {
|
|
94
|
+
return targets[nodeEnv];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return DEFAULT_ENV;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve target environment aliases:
|
|
102
|
+
* param -> allowedEnvs
|
|
103
|
+
* @param {string[]} [allowedEnvs] - 4rray of allowed environments to resolve against (e.g. ['dev', 'preprod', 'production'])
|
|
104
|
+
* @returns {string[]} - Resolved target sliases environment
|
|
105
|
+
*/
|
|
106
|
+
export function resolveTargetAliases(allowedEnvs) {
|
|
107
|
+
const allowedAliases = allowedEnvs.flatMap(
|
|
108
|
+
(env => Object.entries(targets)
|
|
109
|
+
.find(([_, val]) => val === env)
|
|
110
|
+
.map(([alias]) => alias)
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return Array.from(new Set(allowedAliases));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve secret with support for file:// prefix
|
|
120
|
+
* Falls back to WRENV_SECRET or TARGET_SECRET environment variables
|
|
121
|
+
* @param {string} [secretParam] - Secret string or file path (with file:// prefix)
|
|
122
|
+
* @returns {string|null} - Resolved secret or null if not provided
|
|
123
|
+
* @throws {Error} - If file:// path cannot be read
|
|
124
|
+
*/
|
|
125
|
+
export function resolveSecret(secretParam) {
|
|
126
|
+
if (!secretParam) {
|
|
127
|
+
return process.env.WRENV_SECRET || process.env.TARGET_SECRET || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (secretParam.startsWith('file://')) {
|
|
131
|
+
const filePath = secretParam.slice(7);
|
|
132
|
+
try {
|
|
133
|
+
return fs.readFileSync(filePath, 'utf8').trim();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
throw new Error(`Failed to read secret from file: ${filePath}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return secretParam;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse .env file content into key-value pairs
|
|
144
|
+
* Supports standard KEY=value format with basic quote handling
|
|
145
|
+
* @param {string} content - Raw .env file content
|
|
146
|
+
* @returns {EnvRecord} - Parsed key-value pairs
|
|
147
|
+
*/
|
|
148
|
+
export function parseEnvFile(content) {
|
|
149
|
+
/** @type {EnvRecord} */
|
|
150
|
+
const result = {};
|
|
151
|
+
const lines = content.split('\n');
|
|
152
|
+
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
const trimmed = line.trim();
|
|
155
|
+
|
|
156
|
+
// Skip empty lines and comments
|
|
157
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Find the first = sign
|
|
162
|
+
const eqIndex = trimmed.indexOf('=');
|
|
163
|
+
if (eqIndex === -1) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
168
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
169
|
+
|
|
170
|
+
// Remove surrounding quotes if present
|
|
171
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
172
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
173
|
+
value = value.slice(1, -1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (key) {
|
|
177
|
+
result[key] = value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Find the nearest package.json traversing upward from cwd and return its name (without scope)
|
|
186
|
+
* @param {string} cwd - Starting directory
|
|
187
|
+
* @param {string} scope - Package scope (e.g. '@saasak')
|
|
188
|
+
* @returns {string} - Package name without scope, or 'root' as fallback
|
|
189
|
+
*/
|
|
190
|
+
function findNearestPackageName(cwd, scope) {
|
|
191
|
+
let currentDir = cwd;
|
|
192
|
+
|
|
193
|
+
while (currentDir !== path.dirname(currentDir)) { // Stop at filesystem root
|
|
194
|
+
const pkgPath = path.join(currentDir, 'package.json');
|
|
195
|
+
if (fs.existsSync(pkgPath)) {
|
|
196
|
+
try {
|
|
197
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
198
|
+
if (pkg.name) {
|
|
199
|
+
// Return name without scope prefix
|
|
200
|
+
return pkg.name.replace(`${scope}/`, '');
|
|
201
|
+
}
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// Continue traversing upward
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
currentDir = path.dirname(currentDir);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return 'root'; // fallback
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Load .env.local file if it exists
|
|
214
|
+
* @param {string} [cwd] - Working directory (default: process.cwd())
|
|
215
|
+
* @returns {EnvRecord} - Local overrides or empty object
|
|
216
|
+
*/
|
|
217
|
+
export function loadLocalOverrides(cwd = process.cwd()) {
|
|
218
|
+
const localEnvPath = path.join(cwd, '.env.local');
|
|
219
|
+
|
|
220
|
+
if (!fs.existsSync(localEnvPath)) {
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const content = fs.readFileSync(localEnvPath, 'utf8');
|
|
226
|
+
return parseEnvFile(content);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Find the monorepo root by looking for the env.json file, traversing upward from cwd
|
|
234
|
+
* @param {string} cwd - Starting directory
|
|
235
|
+
* @param {string} envPath - Name of the env file (e.g. '.env.json')
|
|
236
|
+
* @returns {string|null} - Path to monorepo root, or null if not found
|
|
237
|
+
*/
|
|
238
|
+
function findMonorepoRoot(cwd, envPath) {
|
|
239
|
+
let currentDir = cwd;
|
|
240
|
+
|
|
241
|
+
while (currentDir !== path.dirname(currentDir)) {
|
|
242
|
+
const envFilePath = path.join(currentDir, envPath);
|
|
243
|
+
if (fs.existsSync(envFilePath)) {
|
|
244
|
+
return currentDir;
|
|
245
|
+
}
|
|
246
|
+
currentDir = path.dirname(currentDir);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Load environment configuration from env.json file
|
|
254
|
+
* Automatically detects the current package and returns its specific env vars
|
|
255
|
+
* @param {LoadEnvJsonOptions} [options] - Options object
|
|
256
|
+
* @returns {EnvRecord} - Decrypted environment variables for the current package
|
|
257
|
+
* @throws {Error} - If env file not found or target env not allowed
|
|
258
|
+
*/
|
|
259
|
+
export function loadEnvJson(options = {}) {
|
|
260
|
+
const {
|
|
261
|
+
secret: secretParam,
|
|
262
|
+
targetEnv: targetParam,
|
|
263
|
+
envPath = '.env.json',
|
|
264
|
+
cwd = process.cwd()
|
|
265
|
+
} = options;
|
|
266
|
+
|
|
267
|
+
const secret = resolveSecret(secretParam);
|
|
268
|
+
const targetEnv = resolveTarget(targetParam);
|
|
269
|
+
|
|
270
|
+
// Find the monorepo root (where env.json lives)
|
|
271
|
+
const monorepoRoot = findMonorepoRoot(cwd, envPath);
|
|
272
|
+
if (!monorepoRoot) {
|
|
273
|
+
throw new Error(`No env file found (searched upward from ${cwd})`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const envFile = path.join(monorepoRoot, envPath);
|
|
277
|
+
const envContent = fs.readFileSync(envFile, 'utf8');
|
|
278
|
+
const env = JSON.parse(envContent);
|
|
279
|
+
|
|
280
|
+
const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
|
|
281
|
+
const resolvedTarget = allowedEnvs.find((e) => e === targetEnv);
|
|
282
|
+
|
|
283
|
+
if (!resolvedTarget) {
|
|
284
|
+
throw new Error(`Target env "${targetEnv}" is not allowed. Allowed: ${allowedEnvs.join(', ')}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Get scope from monorepo root package.json
|
|
288
|
+
const rootPkgPath = path.join(monorepoRoot, 'package.json');
|
|
289
|
+
let scope = '';
|
|
290
|
+
/** @type {string[]} */
|
|
291
|
+
let depsName = ['root'];
|
|
292
|
+
|
|
293
|
+
if (fs.existsSync(rootPkgPath)) {
|
|
294
|
+
try {
|
|
295
|
+
const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
|
|
296
|
+
const rootPkg = JSON.parse(rootPkgContent);
|
|
297
|
+
scope = rootPkg.name.split('/')[0];
|
|
298
|
+
|
|
299
|
+
const foundPackages = findMonorepoPackages(monorepoRoot, scope);
|
|
300
|
+
depsName = [
|
|
301
|
+
...foundPackages.map((pkg) => pkg.name.replace(`${scope}/`, '')),
|
|
302
|
+
'root'
|
|
303
|
+
];
|
|
304
|
+
} catch (error) {
|
|
305
|
+
// Not a monorepo or error parsing, use default
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const allEnvs = buildEnv(secret, resolvedTarget, env, depsName);
|
|
310
|
+
|
|
311
|
+
// Find the current package name from nearest package.json
|
|
312
|
+
const currentPackageName = findNearestPackageName(cwd, scope);
|
|
313
|
+
|
|
314
|
+
// Return env for current package (with fallback to root)
|
|
315
|
+
return allEnvs[currentPackageName] || allEnvs.root || {};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Main load function - loads env.json and applies .env.local overrides
|
|
320
|
+
* Applies values to process.env and returns the final config object
|
|
321
|
+
* @param {LoadOptions} [options] - Options object
|
|
322
|
+
* @returns {EnvRecord} - Final environment configuration
|
|
323
|
+
* @throws {Error} - If env file not found or target env not allowed
|
|
324
|
+
* @example
|
|
325
|
+
* // Basic usage - loads from cwd, applies to process.env
|
|
326
|
+
* const config = load();
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* // With options
|
|
330
|
+
* const config = load({
|
|
331
|
+
* targetEnv: 'production',
|
|
332
|
+
* secret: 'file://./secret.txt',
|
|
333
|
+
* applyToProcess: false
|
|
334
|
+
* });
|
|
335
|
+
*/
|
|
336
|
+
export function load(options = {}) {
|
|
337
|
+
const {
|
|
338
|
+
targetEnv,
|
|
339
|
+
secret,
|
|
340
|
+
envPath,
|
|
341
|
+
cwd = process.cwd(),
|
|
342
|
+
applyToProcess = true
|
|
343
|
+
} = options;
|
|
344
|
+
|
|
345
|
+
// Resolve target first to determine if we're in dev mode
|
|
346
|
+
const resolvedTarget = resolveTarget(targetEnv);
|
|
347
|
+
|
|
348
|
+
// Load env.json config
|
|
349
|
+
const envConfig = loadEnvJson({
|
|
350
|
+
secret,
|
|
351
|
+
targetEnv: resolvedTarget,
|
|
352
|
+
envPath,
|
|
353
|
+
cwd
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Load .env.local overrides only in dev mode (security: prevent local overrides in production)
|
|
357
|
+
const localOverrides = resolvedTarget === 'dev'
|
|
358
|
+
? loadLocalOverrides(cwd)
|
|
359
|
+
: {};
|
|
360
|
+
|
|
361
|
+
// Merge: env.json values, then .env.local overrides (local takes precedence)
|
|
362
|
+
// Note: .env.local only overrides env.json values, not existing process.env
|
|
363
|
+
/** @type {EnvRecord} */
|
|
364
|
+
const finalConfig = { ...envConfig };
|
|
365
|
+
|
|
366
|
+
for (const [key, value] of Object.entries(localOverrides)) {
|
|
367
|
+
if (Object.prototype.hasOwnProperty.call(envConfig, key)) {
|
|
368
|
+
finalConfig[key] = value;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Apply to process.env if requested
|
|
373
|
+
if (applyToProcess) {
|
|
374
|
+
for (const [key, value] of Object.entries(finalConfig)) {
|
|
375
|
+
process.env[key] = value;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return finalConfig;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @typedef {Object} EncryptedValueInfo
|
|
384
|
+
* @property {string} path - Path to the encrypted value (e.g. 'variables.API_KEY.dev')
|
|
385
|
+
* @property {string} value - The encrypted value
|
|
386
|
+
*/
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Verify that all encrypted values in the env can be decrypted with the provided secret
|
|
390
|
+
* This prevents mixed-encryption scenarios where different passwords are used
|
|
391
|
+
* @param {Object} env - The env configuration object (parsed env.json)
|
|
392
|
+
* @param {string} secret - The secret to use for decryption (already resolved, not file:// path)
|
|
393
|
+
* @returns {VerifyResult} - Verification result with success status
|
|
394
|
+
*/
|
|
395
|
+
export function verifyCanDecrypt(env, secret) {
|
|
396
|
+
/** @type {EncryptedValueInfo[]} */
|
|
397
|
+
const encryptedValues = [];
|
|
398
|
+
|
|
399
|
+
if (!secret) {
|
|
400
|
+
return { success: false, path: null, error: 'No secret provided' };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Collect all encrypted values from variables
|
|
404
|
+
if (env.variables) {
|
|
405
|
+
for (const [varName, varValue] of Object.entries(env.variables)) {
|
|
406
|
+
for (const [envKey, envVal] of Object.entries(varValue)) {
|
|
407
|
+
if (typeof envVal === 'string' && isEncrypted(envVal)) {
|
|
408
|
+
encryptedValues.push({ path: `variables.${varName}.${envKey}`, value: envVal });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Collect all encrypted values from overrides
|
|
415
|
+
if (env.overrides) {
|
|
416
|
+
for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
|
|
417
|
+
for (const [varName, varValue] of Object.entries(pkgOverrides)) {
|
|
418
|
+
for (const [envKey, envVal] of Object.entries(varValue)) {
|
|
419
|
+
if (typeof envVal === 'string' && isEncrypted(envVal)) {
|
|
420
|
+
encryptedValues.push({ path: `overrides.${pkgName}.${varName}.${envKey}`, value: envVal });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// If no encrypted values exist, verification passes (first-time encryption)
|
|
428
|
+
if (encryptedValues.length === 0) {
|
|
429
|
+
return { success: true };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Try to decrypt each encrypted value
|
|
433
|
+
for (const { path, value } of encryptedValues) {
|
|
434
|
+
try {
|
|
435
|
+
decrypt(value, secret);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
return {
|
|
438
|
+
success: false,
|
|
439
|
+
path,
|
|
440
|
+
error: error.message
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return { success: true };
|
|
446
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @saasak/tool-env - Environment variable management for monorepos
|
|
3
|
+
*
|
|
4
|
+
* Main module exports for programmatic usage.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* // Basic usage - load env vars and apply to process.env
|
|
8
|
+
* import { load } from '@saasak/tool-env';
|
|
9
|
+
* const config = load();
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // Load with specific options
|
|
13
|
+
* import { load } from '@saasak/tool-env';
|
|
14
|
+
* const config = load({
|
|
15
|
+
* targetEnv: 'production',
|
|
16
|
+
* secret: 'file://./secret.txt',
|
|
17
|
+
* applyToProcess: false
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* @module @saasak/tool-env
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Core functions for loading and resolving environment configuration
|
|
24
|
+
export {
|
|
25
|
+
load,
|
|
26
|
+
loadEnvJson,
|
|
27
|
+
loadLocalOverrides,
|
|
28
|
+
parseEnvFile,
|
|
29
|
+
resolveTarget,
|
|
30
|
+
resolveSecret,
|
|
31
|
+
verifyCanDecrypt,
|
|
32
|
+
getOutputName
|
|
33
|
+
} from './core.js';
|
|
34
|
+
|
|
35
|
+
// Re-export crypto utilities for encryption/decryption
|
|
36
|
+
export { encrypt, decrypt, isEncrypted } from './utils-crypto.js';
|
|
37
|
+
|
|
38
|
+
// Re-export env building utilities
|
|
39
|
+
export { buildEnv } from './utils-env.js';
|
|
40
|
+
|
|
41
|
+
// Re-export package utilities
|
|
42
|
+
export { findMonorepoPackages } from './utils-pkg.js';
|
package/src/utils-crypto.js
CHANGED
|
@@ -3,18 +3,29 @@ import crypto from 'crypto';
|
|
|
3
3
|
// Constants for AES-256-GCM encryption
|
|
4
4
|
// GCM provides authenticated encryption (confidentiality + integrity)
|
|
5
5
|
// Using 256-bit keys for strong security
|
|
6
|
+
|
|
7
|
+
/** @type {string} */
|
|
6
8
|
const ENCRYPTION_PREFIX = '$enc$';
|
|
9
|
+
/** @type {string} */
|
|
7
10
|
const ALGORITHM = 'aes-256-gcm';
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
11
|
+
/** @type {number} 128 bits */
|
|
12
|
+
const IV_LENGTH = 16;
|
|
13
|
+
/** @type {number} 512 bits */
|
|
14
|
+
const SALT_LENGTH = 64;
|
|
15
|
+
/** @type {number} 128 bits */
|
|
16
|
+
const TAG_LENGTH = 16;
|
|
17
|
+
/** @type {number} 256 bits */
|
|
18
|
+
const KEY_LENGTH = 32;
|
|
19
|
+
/** @type {number} PBKDF2 iterations */
|
|
20
|
+
const ITERATIONS = 100000;
|
|
13
21
|
|
|
14
22
|
/**
|
|
15
23
|
* Derive a 256-bit key from a secret using PBKDF2
|
|
16
24
|
* This ensures consistent key generation from variable-length secrets
|
|
17
25
|
* and adds computational cost to prevent brute-force attacks
|
|
26
|
+
* @param {string} secret - The secret passphrase
|
|
27
|
+
* @param {Buffer} salt - Random salt for key derivation
|
|
28
|
+
* @returns {Buffer} - Derived 256-bit key
|
|
18
29
|
*/
|
|
19
30
|
function deriveKey(secret, salt) {
|
|
20
31
|
return crypto.pbkdf2Sync(secret, salt, ITERATIONS, KEY_LENGTH, 'sha256');
|
|
@@ -24,6 +35,9 @@ function deriveKey(secret, salt) {
|
|
|
24
35
|
* Encrypt a string value using AES-256-GCM
|
|
25
36
|
* Returns a string with format: $enc$<base64(salt+iv+ciphertext+tag)>
|
|
26
37
|
* Each encryption uses a unique salt and IV, so the same plaintext produces different ciphertexts
|
|
38
|
+
* @param {string} plaintext - The string to encrypt
|
|
39
|
+
* @param {string} secret - The secret passphrase for encryption
|
|
40
|
+
* @returns {string} - Encrypted string with $enc$ prefix, or original value if empty
|
|
27
41
|
*/
|
|
28
42
|
export function encrypt(plaintext, secret) {
|
|
29
43
|
if (!plaintext) return plaintext;
|
|
@@ -50,6 +64,10 @@ export function encrypt(plaintext, secret) {
|
|
|
50
64
|
* Decrypt an encrypted string
|
|
51
65
|
* Strips the prefix and decodes the base64 payload
|
|
52
66
|
* GCM authentication tag ensures the data hasn't been tampered with
|
|
67
|
+
* @param {string} encrypted - The encrypted string (with $enc$ prefix)
|
|
68
|
+
* @param {string} secret - The secret passphrase for decryption
|
|
69
|
+
* @returns {string} - Decrypted plaintext, or original value if not encrypted
|
|
70
|
+
* @throws {Error} - If decryption fails (wrong secret or corrupted data)
|
|
53
71
|
*/
|
|
54
72
|
export function decrypt(encrypted, secret) {
|
|
55
73
|
if (!encrypted || !isEncrypted(encrypted)) {
|
|
@@ -83,8 +101,9 @@ export function decrypt(encrypted, secret) {
|
|
|
83
101
|
/**
|
|
84
102
|
* Check if a string is encrypted by looking for the prefix
|
|
85
103
|
* This allows the system to detect encrypted values without attempting decryption
|
|
104
|
+
* @param {unknown} value - The value to check
|
|
105
|
+
* @returns {boolean} - True if the value is an encrypted string
|
|
86
106
|
*/
|
|
87
107
|
export function isEncrypted(value) {
|
|
88
108
|
return typeof value === 'string' && value.startsWith(ENCRYPTION_PREFIX);
|
|
89
109
|
}
|
|
90
|
-
|
package/src/utils-env.js
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
import { decrypt, isEncrypted } from './utils-crypto.js';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} EnvVariableValue
|
|
5
|
+
* @property {string} [@@] - Fallback value for all environments
|
|
6
|
+
* @property {string} [dev] - Value for dev environment
|
|
7
|
+
* @property {string} [preprod] - Value for preprod environment
|
|
8
|
+
* @property {string} [production] - Value for production environment
|
|
9
|
+
*/
|
|
6
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object.<string, EnvVariableValue>} EnvVariables
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object.<string, Object.<string, EnvVariableValue>>} EnvOverrides
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} EnvConfig
|
|
21
|
+
* @property {string[]} [envs] - List of allowed environment names
|
|
22
|
+
* @property {EnvVariables} variables - Environment variables by name
|
|
23
|
+
* @property {EnvOverrides} [overrides] - Package-specific overrides
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object.<string, string>} EnvRecord
|
|
28
|
+
* Key-value pairs of environment variable name to value
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object.<string, EnvRecord>} AllEnvs
|
|
33
|
+
* Environment variables grouped by package name
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build environment variables for all packages
|
|
38
|
+
* @param {string} secret - Secret for decrypting encrypted values
|
|
39
|
+
* @param {string} target - Target environment (e.g. 'dev', 'production')
|
|
40
|
+
* @param {EnvConfig} env - The env.json configuration object
|
|
41
|
+
* @param {string[]} names - List of package names to build env for
|
|
42
|
+
* @returns {AllEnvs} - Environment variables grouped by package name
|
|
43
|
+
*/
|
|
7
44
|
export function buildEnv(secret, target, env, names) {
|
|
8
45
|
const variablesForAllTargets = env.variables;
|
|
9
46
|
const overrides = env.overrides || {};
|
|
10
47
|
|
|
11
48
|
// For each variable, extract the value for the target env
|
|
49
|
+
/** @type {EnvRecord} */
|
|
12
50
|
const variables = Object.entries(variablesForAllTargets).reduce((acc, entry) => {
|
|
13
51
|
const [key, value] = entry;
|
|
14
52
|
return {
|
|
@@ -22,6 +60,7 @@ export function buildEnv(secret, target, env, names) {
|
|
|
22
60
|
// allVars is an object with the package name as key
|
|
23
61
|
// and the variables as value like so
|
|
24
62
|
// => { [name]: { [variable]: value } }
|
|
63
|
+
/** @type {AllEnvs} */
|
|
25
64
|
const allVars = names.reduce((acc, name) => {
|
|
26
65
|
acc[name] = {
|
|
27
66
|
...variables,
|
|
@@ -34,8 +73,15 @@ export function buildEnv(secret, target, env, names) {
|
|
|
34
73
|
return allVars;
|
|
35
74
|
}
|
|
36
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Parse package-specific overrides for a target environment
|
|
78
|
+
* @param {string} secret - Secret for decrypting encrypted values
|
|
79
|
+
* @param {string} target - Target environment
|
|
80
|
+
* @param {Object.<string, EnvVariableValue>} [overrides] - Package overrides
|
|
81
|
+
* @param {EnvRecord} vars - Base variables for reference substitution
|
|
82
|
+
* @returns {EnvRecord} - Parsed override values
|
|
83
|
+
*/
|
|
37
84
|
function parseOverrides(secret, target, overrides, vars) {
|
|
38
|
-
if (!secret) return {};
|
|
39
85
|
if (!overrides) return {};
|
|
40
86
|
|
|
41
87
|
return Object.entries(overrides).reduce((acc, entry) => {
|
|
@@ -45,12 +91,18 @@ function parseOverrides(secret, target, overrides, vars) {
|
|
|
45
91
|
if (value === null) return acc;
|
|
46
92
|
|
|
47
93
|
acc[key] = Array.isArray(value)
|
|
48
|
-
? value.map(v => vars[v]
|
|
49
|
-
: value
|
|
94
|
+
? value.map(v => Object.prototype.hasOwnProperty.call(vars, v) ? vars[v] : v).join('')
|
|
95
|
+
: value;
|
|
50
96
|
return acc;
|
|
51
97
|
}, {});
|
|
52
98
|
}
|
|
53
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Extract and decrypt a value if needed
|
|
102
|
+
* @param {string} secret - Secret for decryption
|
|
103
|
+
* @param {string} value - Value to extract (may be encrypted)
|
|
104
|
+
* @returns {string} - Decrypted/plain value, or empty string if no secret
|
|
105
|
+
*/
|
|
54
106
|
function extractValue(secret, value) {
|
|
55
107
|
if (!isEncrypted(value)) return value;
|
|
56
108
|
if (!secret) return '';
|
package/src/utils-pkg.js
CHANGED
|
@@ -1,68 +1,85 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} PackageInfo
|
|
6
|
+
* @property {string} name - Package name (e.g. '@scope/package-name')
|
|
7
|
+
* @property {string} dir - Absolute path to the package directory
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Try to read and parse a package.json, returning PackageInfo if valid and matches scope
|
|
12
|
+
* @param {string} dir - Directory containing package.json
|
|
13
|
+
* @param {string|null} scope - Package scope to filter by
|
|
14
|
+
* @returns {PackageInfo|null}
|
|
15
|
+
*/
|
|
16
|
+
function readPackageInfo(dir, scope) {
|
|
17
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
21
|
+
|
|
22
|
+
const pkg = fs.readJsonSync(pkgPath);
|
|
23
|
+
|
|
24
|
+
if (scope && !pkg.name?.startsWith(`${scope}/`)) {
|
|
21
25
|
return null;
|
|
22
26
|
}
|
|
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
27
|
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
}
|
|
28
|
+
return { name: pkg.name, dir };
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
53
31
|
}
|
|
32
|
+
}
|
|
54
33
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Get valid subdirectories (excluding node_modules and symlinks)
|
|
36
|
+
* @param {string} dir - Directory to list
|
|
37
|
+
* @returns {string[]} - Array of absolute paths to subdirectories
|
|
38
|
+
*/
|
|
39
|
+
function getSubdirectories(dir) {
|
|
40
|
+
try {
|
|
41
|
+
return fs
|
|
42
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
43
|
+
.filter(
|
|
44
|
+
(entry) =>
|
|
45
|
+
entry.isDirectory() &&
|
|
46
|
+
!entry.isSymbolicLink() &&
|
|
47
|
+
entry.name !== 'node_modules'
|
|
48
|
+
)
|
|
49
|
+
.map((entry) => path.join(dir, entry.name));
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
59
54
|
|
|
60
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Recursively collect packages from a directory tree
|
|
57
|
+
* @param {string} dir - Directory to search
|
|
58
|
+
* @param {string|null} scope - Package scope to filter by
|
|
59
|
+
* @param {number} depth - Remaining depth to traverse
|
|
60
|
+
* @returns {PackageInfo[]}
|
|
61
|
+
*/
|
|
62
|
+
function collectPackages(dir, scope, depth) {
|
|
63
|
+
if (depth < 0) return [];
|
|
64
|
+
|
|
65
|
+
return getSubdirectories(dir).flatMap((subdir) => {
|
|
66
|
+
const pkg = readPackageInfo(subdir, scope);
|
|
67
|
+
const nested = collectPackages(subdir, scope, depth - 1);
|
|
68
|
+
return pkg ? [pkg, ...nested] : nested;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Find all packages in a monorepo by traversing the directory tree
|
|
74
|
+
* @param {string} rootDir - Root directory to start searching from
|
|
75
|
+
* @param {string|null} [scope=null] - Package scope to filter by (e.g. '@saasak')
|
|
76
|
+
* @param {number} [maxDepth=4] - Maximum directory depth to traverse
|
|
77
|
+
* @returns {PackageInfo[]} - Array of found packages (root package first, no nulls)
|
|
78
|
+
*/
|
|
79
|
+
export function findMonorepoPackages(rootDir, scope = null, maxDepth = 4) {
|
|
80
|
+
const rootPkg = readPackageInfo(rootDir, scope);
|
|
63
81
|
|
|
64
|
-
|
|
65
|
-
await traverse(rootDir, 0);
|
|
82
|
+
if (!rootPkg) return [];
|
|
66
83
|
|
|
67
|
-
return
|
|
84
|
+
return [rootPkg, ...collectPackages(rootDir, scope, maxDepth)];
|
|
68
85
|
}
|