@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 CHANGED
@@ -14,10 +14,11 @@
14
14
 
15
15
  ## TODOs
16
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
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 { encrypt, isEncrypted } from '../src/utils-crypto.js';
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
- if (!fs.existsSync(envFile)) {
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 = targets[args.target || process.env.WRENV_TARGET || process.env.TARGET_ENV] || DEFAULT_ENV;
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 : [DEFAULT_ENV];
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 = targets[args.target || process.env.WRENV_TARGET || process.env.TARGET_ENV] || DEFAULT_ENV;
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 : [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
- }
110
+ const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : ['dev'];
120
111
 
121
- const allEnvs = buildEnv(secret, targetEnv, env, depsName);
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 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);
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
- const relativeEnvPath = path.relative(__root, envFile);
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
- 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 });
186
+ const relativeEnvPath = path.relative(gitRoot, envFile);
173
187
 
174
- if (!stagedDiff?.trim() && !unstagedDiff?.trim() && !untracked?.trim()) {
175
- return;
176
- }
177
- } catch (error) {
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 encrypt();
197
+ await encryptCommand();
182
198
 
183
199
  if (!secret) {
184
- console.error('Warning: potential security risk: secret not provided. Variables WERE NOT be encrypted.');
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: __root });
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, etc.)');
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.1",
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": "bin/index.js",
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';
@@ -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
- 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
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
- // Helpers for core logic
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] || v).join('')
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
- 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) {
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
- 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
- }
28
+ return { name: pkg.name, dir };
29
+ } catch {
30
+ return null;
53
31
  }
32
+ }
54
33
 
55
- const rootPkgPath = path.join(rootDir, 'package.json');
56
- const exists = await fs.pathExists(rootPkgPath);
57
-
58
- if (!exists) return [];
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
- const pkg = await processPackage(rootPkgPath, rootDir);
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
- if (!pkg) return [];
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
- packages.push(pkg);
65
- await traverse(rootDir, 0);
82
+ if (!rootPkg) return [];
66
83
 
67
- return packages;
84
+ return [rootPkg, ...collectPackages(rootDir, scope, maxDepth)];
68
85
  }