@saasak/tool-env 1.1.0 → 1.2.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/bin/index.js CHANGED
@@ -1,65 +1,72 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import minimist from 'minimist';
4
- import fs from 'fs-extra';
5
- import path from 'path';
6
- import { execa } from 'execa';
7
-
8
- import { encrypt, isEncrypted } from '../src/utils-crypto.js';
9
- import { buildEnv } from '../src/utils-env.js';
10
- import { findMonorepoPackages } from '../src/utils-pkg.js';
11
- import { resolveTarget, verifyCanDecrypt, getOutputName } from '../src/core.js';
12
-
13
- const args = minimist(process.argv.slice(2), { '--': true });
3
+ import minimist from "minimist";
4
+ import fs from "fs-extra";
5
+ import path from "path";
6
+ import { execa } from "execa";
7
+
8
+ import { encrypt, isEncrypted } from "../src/utils-crypto.js";
9
+ import { buildEnv } from "../src/utils-env.js";
10
+ import { findMonorepoPackages } from "../src/utils-pkg.js";
11
+ import {
12
+ resolveTarget,
13
+ resolveSecret,
14
+ verifyCanDecrypt,
15
+ getOutputName,
16
+ load,
17
+ } from "../src/core.js";
18
+
19
+ const args = minimist(process.argv.slice(2), { "--": true });
14
20
  const __root = process.cwd();
15
21
 
16
- const command = args._[0] || 'write';
22
+ const command = args._[0] || "write";
17
23
 
18
- const secret = args.secret
19
- ? (args.secret === 'stdin' ? fs.readFileSync(0, 'utf8').trim() : fs.readFileSync(args.secret, 'utf8').trim())
20
- : process.env.WRENV_SECRET || process.env.TARGET_SECRET || '';
24
+ const secret = resolveSecret(args.secret);
21
25
 
22
- const envPath = args.env || '.env.json';
26
+ const envPath = args.env || ".env.json";
23
27
  const envFile = path.resolve(__root, envPath);
24
28
 
25
29
  // Check env file exists for commands that need it
26
- const commandsRequiringEnvFile = ['write', 'show', 'encrypt', 'besafe', 'add'];
30
+ const commandsRequiringEnvFile = ["write", "show", "encrypt", "besafe", "add"];
27
31
  if (commandsRequiringEnvFile.includes(command) && !fs.existsSync(envFile)) {
28
- console.error('No env file found.');
32
+ console.error("No env file found.");
29
33
  process.exit(1);
30
34
  }
31
35
 
32
36
  async function showCommand() {
33
37
  if (!secret) {
34
- console.log('Secret not provided. Use --secret flag or WRENV_SECRET env variable.');
38
+ console.log(
39
+ "Secret not provided. Use --secret flag or WRENV_SECRET env variable.",
40
+ );
35
41
  }
36
42
 
37
43
  const target = resolveTarget(args.target);
38
44
 
39
- const envContent = fs.readFileSync(envFile, 'utf8');
45
+ const envContent = fs.readFileSync(envFile, "utf8");
40
46
  const env = JSON.parse(envContent);
41
47
 
42
- const rootPkgPath = path.resolve(__root, 'package.json');
43
- const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
48
+ const rootPkgPath = path.resolve(__root, "package.json");
49
+ const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
44
50
  const rootPkg = JSON.parse(rootPkgContent);
45
51
 
46
- const scope = rootPkg.name.split('/')[0];
52
+ const scope = rootPkg.name.split("/")[0];
47
53
  const [_, ...foundPackages] = findMonorepoPackages(__root, scope);
48
54
 
49
55
  const deps = [
50
56
  ...foundPackages.filter(Boolean).map((pkg) => ({
51
- name: pkg.name.replace(`${scope}/`, ''),
57
+ name: pkg.name.replace(`${scope}/`, ""),
52
58
  dir: pkg.dir,
53
59
  })),
54
60
  {
55
- name: 'root',
61
+ name: "root",
56
62
  dir: __root,
57
63
  },
58
64
  ];
59
65
 
60
66
  const depsName = deps.map((dep) => dep.name);
61
67
 
62
- const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : ['dev'];
68
+ const allowedEnvs =
69
+ env && env.envs && Array.isArray(env.envs) ? env.envs : ["dev"];
63
70
  const targetEnv = allowedEnvs.find((env) => env === target);
64
71
  if (!targetEnv) {
65
72
  console.error(`Target env "${target}" is not allowed.`);
@@ -71,46 +78,50 @@ async function showCommand() {
71
78
  for await (const dep of deps) {
72
79
  const pkgEnv = Object.entries(allEnvs[dep.name] || {})
73
80
  .map(([key, value]) => `${key}=${value}`)
74
- .join('\n')
75
- console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~~');
76
- console.log(`=== ENV for ${dep.name} in ${dep.dir} with target ${targetEnv}`);
81
+ .join("\n");
82
+ console.log("~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
83
+ console.log(
84
+ `=== ENV for ${dep.name} in ${dep.dir} with target ${targetEnv}`,
85
+ );
77
86
  console.log(pkgEnv);
78
87
  }
79
-
80
88
  }
81
89
 
82
90
  async function writeCommand() {
83
91
  if (!secret) {
84
- console.log('Secret not provided. Use --secret flag or WRENV_SECRET env variable.');
92
+ console.log(
93
+ "Secret not provided. Use --secret flag or WRENV_SECRET env variable.",
94
+ );
85
95
  }
86
96
 
87
97
  const target = resolveTarget(args.target);
88
98
 
89
- const envContent = fs.readFileSync(envFile, 'utf8');
99
+ const envContent = fs.readFileSync(envFile, "utf8");
90
100
  const env = JSON.parse(envContent);
91
101
 
92
- const rootPkgPath = path.resolve(__root, 'package.json');
93
- const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
102
+ const rootPkgPath = path.resolve(__root, "package.json");
103
+ const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
94
104
  const rootPkg = JSON.parse(rootPkgContent);
95
- const scope = rootPkg.name.split('/')[0];
105
+ const scope = rootPkg.name.split("/")[0];
96
106
 
97
107
  const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
98
108
  const deps = [
99
109
  ...foundPackages.map((pkg) => ({
100
- name: pkg.name.replace(`${scope}/`, ''),
110
+ name: pkg.name.replace(`${scope}/`, ""),
101
111
  dir: pkg.dir,
102
112
  })),
103
113
  {
104
- name: 'root',
114
+ name: "root",
105
115
  dir: __root,
106
116
  },
107
117
  ];
108
118
  const depsName = deps.map((dep) => dep.name);
109
119
 
110
- const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : ['dev'];
120
+ const allowedEnvs =
121
+ env && env.envs && Array.isArray(env.envs) ? env.envs : ["dev"];
111
122
 
112
123
  // Handle 'all' target: write all environments with suffixed filenames
113
- const isAllTarget = target === 'all';
124
+ const isAllTarget = target === "all";
114
125
  const targetEnvs = isAllTarget ? allowedEnvs : [target];
115
126
 
116
127
  for (const targetEnv of targetEnvs) {
@@ -124,9 +135,11 @@ async function writeCommand() {
124
135
  for await (const dep of deps) {
125
136
  const pkgEnv = Object.entries(allEnvs[dep.name] || {})
126
137
  .map(([key, value]) => `${key}=${value}`)
127
- .join('\n')
138
+ .join("\n");
128
139
  // Use suffixed filename (.env.{outputName}) for 'all', plain .env otherwise
129
- const filename = isAllTarget ? `.env.${getOutputName(targetEnv)}` : '.env';
140
+ const filename = isAllTarget
141
+ ? `.env.${getOutputName(targetEnv)}`
142
+ : ".env";
130
143
  console.log(`Writing ${filename} for ${dep.name} in ${dep.dir}`);
131
144
  await fs.writeFile(path.join(dep.dir, filename), pkgEnv);
132
145
  }
@@ -134,27 +147,35 @@ async function writeCommand() {
134
147
  }
135
148
 
136
149
  async function encryptCommand() {
137
- const envContent = fs.readFileSync(envFile, 'utf8');
150
+ const envContent = fs.readFileSync(envFile, "utf8");
138
151
  const env = JSON.parse(envContent);
139
152
 
140
153
  if (!secret) {
141
- console.error('Cannot encrypt env file: secret not provided. Doing nothing');
154
+ console.error(
155
+ "Cannot encrypt env file: secret not provided. Doing nothing",
156
+ );
142
157
  return;
143
158
  }
144
159
 
145
160
  // Verify we can decrypt all existing encrypted values before encrypting new ones
146
161
  const verification = verifyCanDecrypt(env, secret);
147
162
  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.');
163
+ console.error(
164
+ `Cannot encrypt: the provided secret cannot decrypt existing encrypted value at "${verification.path}".`,
165
+ );
166
+ console.error(
167
+ "This would result in a file with mixed encryption passwords.",
168
+ );
169
+ console.error(
170
+ "Please provide the correct secret that was used for existing encrypted values.",
171
+ );
151
172
  process.exit(1);
152
173
  }
153
174
 
154
175
  if (env.variables) {
155
176
  for (const [varName, varValue] of Object.entries(env.variables)) {
156
177
  for (const [envKey, envVal] of Object.entries(varValue)) {
157
- if (typeof envVal === 'string' && !isEncrypted(envVal)) {
178
+ if (typeof envVal === "string" && !isEncrypted(envVal)) {
158
179
  env.variables[varName][envKey] = encrypt(envVal, secret);
159
180
  }
160
181
  }
@@ -165,7 +186,7 @@ async function encryptCommand() {
165
186
  for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
166
187
  for (const [varName, varValue] of Object.entries(pkgOverrides)) {
167
188
  for (const [envKey, envVal] of Object.entries(varValue)) {
168
- if (typeof envVal === 'string' && !isEncrypted(envVal)) {
189
+ if (typeof envVal === "string" && !isEncrypted(envVal)) {
169
190
  env.overrides[pkgName][varName][envKey] = encrypt(envVal, secret);
170
191
  }
171
192
  }
@@ -178,7 +199,11 @@ async function encryptCommand() {
178
199
 
179
200
  async function besafeCommand() {
180
201
  // 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 });
202
+ const { stdout: gitRoot, exitCode: gitCheck } = await execa(
203
+ "git",
204
+ ["rev-parse", "--show-toplevel"],
205
+ { cwd: __root, reject: false },
206
+ );
182
207
  if (gitCheck !== 0) {
183
208
  return;
184
209
  }
@@ -186,9 +211,21 @@ async function besafeCommand() {
186
211
  const relativeEnvPath = path.relative(gitRoot, envFile);
187
212
 
188
213
  // 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 });
214
+ const { stdout: staged } = await execa(
215
+ "git",
216
+ ["diff", "--cached", "--name-only", "--", relativeEnvPath],
217
+ { cwd: gitRoot, reject: false },
218
+ );
219
+ const { stdout: unstaged } = await execa(
220
+ "git",
221
+ ["diff", "--name-only", "--", relativeEnvPath],
222
+ { cwd: gitRoot, reject: false },
223
+ );
224
+ const { stdout: untracked } = await execa(
225
+ "git",
226
+ ["ls-files", "--others", "--exclude-standard", "--", relativeEnvPath],
227
+ { cwd: gitRoot, reject: false },
228
+ );
192
229
 
193
230
  if (!staged?.trim() && !unstaged?.trim() && !untracked?.trim()) {
194
231
  return;
@@ -197,24 +234,28 @@ async function besafeCommand() {
197
234
  await encryptCommand();
198
235
 
199
236
  if (!secret) {
200
- console.error('Warning: potential security risk: secret not provided. Variables WERE NOT encrypted.');
237
+ console.error(
238
+ "Warning: potential security risk: secret not provided. Variables WERE NOT encrypted.",
239
+ );
201
240
  }
202
241
 
203
- await execa('git', ['add', relativeEnvPath], { cwd: gitRoot });
242
+ await execa("git", ["add", relativeEnvPath], { cwd: gitRoot });
204
243
  }
205
244
 
206
245
  async function addCommand() {
207
246
  const varName = args._[1];
208
247
  if (!varName) {
209
- console.error('Variable name is required.');
248
+ console.error("Variable name is required.");
210
249
  process.exit(1);
211
250
  }
212
251
 
213
252
  if (!secret) {
214
- console.error('Warning: potential security risk: secret not provided. Variable WILL NOT be encrypted.');
253
+ console.error(
254
+ "Warning: potential security risk: secret not provided. Variable WILL NOT be encrypted.",
255
+ );
215
256
  }
216
257
 
217
- const envContent = fs.readFileSync(envFile, 'utf8');
258
+ const envContent = fs.readFileSync(envFile, "utf8");
218
259
  const env = JSON.parse(envContent);
219
260
 
220
261
  if (!env.variables) {
@@ -226,10 +267,10 @@ async function addCommand() {
226
267
  }
227
268
 
228
269
  for (const arg of args._.slice(2)) {
229
- if (!arg.startsWith('+')) continue;
230
- const [key, ...valueParts] = arg.slice(1).split('=');
231
- const value = valueParts.join('=');
232
- const envKey = key === 'fallback' ? '@@' : key;
270
+ if (!arg.startsWith("+")) continue;
271
+ const [key, ...valueParts] = arg.slice(1).split("=");
272
+ const value = valueParts.join("=");
273
+ const envKey = key === "fallback" ? "@@" : key;
233
274
  env.variables[varName][envKey] = secret ? encrypt(value, secret) : value;
234
275
  }
235
276
 
@@ -237,40 +278,23 @@ async function addCommand() {
237
278
  }
238
279
 
239
280
  async function runCommand() {
240
- const commandParts = args['--'] || [];
281
+ const commandParts = args["--"] || [];
241
282
  const [cmd, ...cmdArgs] = commandParts;
242
283
 
243
284
  if (!cmd) {
244
- console.error('Usage: wrenv run [options] -- <command> [args...]');
285
+ console.error("Usage: wrenv run [options] -- <command> [args...]");
245
286
  process.exit(1);
246
287
  }
247
288
 
248
- // Load environment variables
289
+ // Load environment variables (without applying to current process.env)
249
290
  let envVars = {};
250
291
  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,
292
+ envVars = load({
293
+ secret: resolveSecret(args.secret),
294
+ targetEnv: args.target,
262
295
  envPath: args.env,
296
+ applyToProcess: false,
263
297
  });
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
298
  } catch (error) {
275
299
  console.error(`wrenv: ${error.message}`);
276
300
  process.exit(1);
@@ -278,48 +302,66 @@ async function runCommand() {
278
302
 
279
303
  // Use execa for cross-platform compatibility with minimal overhead
280
304
  const subprocess = execa(cmd, cmdArgs, {
281
- stdio: 'inherit',
305
+ stdio: "inherit",
282
306
  env: { ...process.env, ...envVars },
283
307
  reject: false,
284
308
  });
285
309
 
286
310
  // Forward signals to child process
287
311
  const forwardSignal = (signal) => subprocess.kill(signal);
288
- process.on('SIGTERM', () => forwardSignal('SIGTERM'));
289
- process.on('SIGINT', () => forwardSignal('SIGINT'));
290
- process.on('SIGHUP', () => forwardSignal('SIGHUP'));
312
+ process.on("SIGTERM", () => forwardSignal("SIGTERM"));
313
+ process.on("SIGINT", () => forwardSignal("SIGINT"));
314
+ process.on("SIGHUP", () => forwardSignal("SIGHUP"));
291
315
 
292
316
  const result = await subprocess;
293
317
  process.exit(result.exitCode ?? 0);
294
318
  }
295
319
 
296
320
  function helpCommand() {
297
- console.log('Usage: wrenv [command] [options]\n');
298
- console.log('Commands:');
299
- console.log(' write Write .env files for all packages (default)');
300
- console.log(' show Show environment variables without writing files');
301
- console.log(' run Run a command with injected environment variables');
302
- console.log(' encrypt Encrypt all unencrypted variables in .env.json');
303
- console.log(' besafe Encrypt and stage .env.json if it has changes');
304
- console.log(' add <var> Add a new variable with environment-specific values');
305
- console.log(' help Show this help message\n');
306
- console.log('Options:');
307
- console.log(' --secret <path|stdin> Path to secret file or "stdin" to read from stdin');
308
- console.log(' --target <env> Target environment (dev, staging, prod, all)');
309
- console.log(' --env <path> Path to env file (default: .env.json)\n');
310
- console.log('Environment Variables:');
311
- console.log(' WRENV_SECRET Secret for encryption/decryption');
312
- console.log(' WRENV_TARGET Target environment');
313
- console.log(' TARGET_SECRET Alias for WRENV_SECRET');
314
- console.log(' TARGET_ENV Alias for WRENV_TARGET\n');
315
- console.log('Examples:');
316
- console.log(' wrenv --secret ~/.big-secret write --target dev');
317
- console.log(' wrenv --secret stdin write < ~/.big-secret');
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');
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');
321
+ console.log("Usage: wrenv [command] [options]\n");
322
+ console.log("Commands:");
323
+ console.log(" write Write .env files for all packages (default)");
324
+ console.log(
325
+ " show Show environment variables without writing files",
326
+ );
327
+ console.log(
328
+ " run Run a command with injected environment variables",
329
+ );
330
+ console.log(
331
+ " encrypt Encrypt all unencrypted variables in .env.json",
332
+ );
333
+ console.log(" besafe Encrypt and stage .env.json if it has changes");
334
+ console.log(
335
+ " add <var> Add a new variable with environment-specific values",
336
+ );
337
+ console.log(" help Show this help message\n");
338
+ console.log("Options:");
339
+ console.log(
340
+ ' --secret <path|stdin> Path to secret file or "stdin" to read from stdin',
341
+ );
342
+ console.log(
343
+ " --target <env> Target environment (dev, staging, prod, all)",
344
+ );
345
+ console.log(
346
+ " --env <path> Path to env file (default: .env.json)\n",
347
+ );
348
+ console.log("Environment Variables:");
349
+ console.log(" WRENV_SECRET Secret for encryption/decryption");
350
+ console.log(" WRENV_TARGET Target environment");
351
+ console.log(" TARGET_SECRET Alias for WRENV_SECRET");
352
+ console.log(" TARGET_ENV Alias for WRENV_TARGET\n");
353
+ console.log("Examples:");
354
+ console.log(" wrenv --secret ~/.big-secret write --target dev");
355
+ console.log(" wrenv --secret stdin write < ~/.big-secret");
356
+ console.log(" wrenv show --target prod");
357
+ console.log(" wrenv run --target prod -- node server.js");
358
+ console.log(" wrenv run -- npm test --coverage");
359
+ console.log(
360
+ " wrenv add NEW_VAR +fallback=value +dev=dev_value +production=prod_value",
361
+ );
362
+ console.log(
363
+ " wrenv write --target all # writes .env.development, .env.staging, .env.production",
364
+ );
323
365
  }
324
366
 
325
367
  const commands = {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saasak/tool-env",
3
3
  "license": "MIT",
4
- "version": "1.1.0",
4
+ "version": "1.2.0",
5
5
  "author": "dev@saasak.studio",
6
6
  "description": "A small util to manage environment variables for your monorepo",
7
7
  "keywords": [
@@ -11,6 +11,7 @@
11
11
  ],
12
12
  "type": "module",
13
13
  "main": "src/index.js",
14
+ "types": "src/index.d.ts",
14
15
  "files": [
15
16
  "bin",
16
17
  "src"
package/src/core.js CHANGED
@@ -1,8 +1,8 @@
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';
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
6
 
7
7
  /**
8
8
  * @typedef {Object.<string, string>} EnvRecord
@@ -34,7 +34,19 @@ import { findMonorepoPackages } from './utils-pkg.js';
34
34
  */
35
35
 
36
36
  /** @type {string} */
37
- const DEFAULT_ENV = 'dev';
37
+ const DEFAULT_ENV = "dev";
38
+
39
+ /**
40
+ * Environment variables that must never be injected by .env.json content.
41
+ * These control wrenv's own behavior and must not be overridden.
42
+ * @type {Set<string>}
43
+ */
44
+ export const RESERVED_ENV_VARS = new Set([
45
+ "WRENV_TARGET",
46
+ "WRENV_SECRET",
47
+ "TARGET_ENV",
48
+ "TARGET_SECRET",
49
+ ]);
38
50
 
39
51
  /**
40
52
  * Mapping of environment name aliases to canonical names
@@ -42,14 +54,14 @@ const DEFAULT_ENV = 'dev';
42
54
  * @type {Object.<string, string>}
43
55
  */
44
56
  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",
57
+ development: "dev",
58
+ dev: "dev",
59
+ staging: "preprod",
60
+ pp: "preprod",
61
+ preprod: "preprod",
62
+ prod: "production",
63
+ production: "production",
64
+ all: "all",
53
65
  };
54
66
 
55
67
  /**
@@ -58,9 +70,9 @@ const targets = {
58
70
  * @type {Object.<string, string>}
59
71
  */
60
72
  const outputNames = {
61
- "dev": "development",
62
- "preprod": "staging",
63
- "production": "production",
73
+ dev: "development",
74
+ preprod: "staging",
75
+ production: "production",
64
76
  };
65
77
 
66
78
  /**
@@ -104,39 +116,52 @@ export function resolveTarget(targetParam) {
104
116
  * @returns {string[]} - Resolved target sliases environment
105
117
  */
106
118
  export function resolveTargetAliases(allowedEnvs) {
107
- const allowedAliases = allowedEnvs.flatMap(
108
- (env => Object.entries(targets)
119
+ const allowedAliases = allowedEnvs.flatMap((env) =>
120
+ Object.entries(targets)
109
121
  .find(([_, val]) => val === env)
110
- .map(([alias]) => alias)
111
- )
112
- )
122
+ .map(([alias]) => alias),
123
+ );
113
124
 
114
125
  return Array.from(new Set(allowedAliases));
115
126
  }
116
127
 
117
-
118
128
  /**
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
129
+ * Resolve secret with unified fallback chain:
130
+ * param (stdin | file:// | file path | literal) -> WRENV_SECRET -> TARGET_SECRET -> null
131
+ *
132
+ * If reading from stdin or a file:// path fails, falls back to env vars.
133
+ * A bare string that is not a readable file is treated as a literal secret.
134
+ *
135
+ * @param {string} [secretParam] - Secret source: "stdin", "file://path", file path, or literal string
136
+ * @returns {string} - Resolved secret or empty string if not provided
124
137
  */
125
138
  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}`);
139
+ if (secretParam) {
140
+ if (secretParam === "stdin") {
141
+ try {
142
+ const value = fs.readFileSync(0, "utf8").trim();
143
+ if (value) return value;
144
+ } catch (_) {
145
+ // fall through to env vars
146
+ }
147
+ } else if (secretParam.startsWith("file://")) {
148
+ try {
149
+ const value = fs.readFileSync(secretParam.slice(7), "utf8").trim();
150
+ if (value) return value;
151
+ } catch (_) {
152
+ // fall through to env vars
153
+ }
154
+ } else {
155
+ try {
156
+ return fs.readFileSync(secretParam, "utf8").trim();
157
+ } catch (_) {
158
+ // Not a readable file path — treat as literal secret string
159
+ return secretParam;
160
+ }
136
161
  }
137
162
  }
138
163
 
139
- return secretParam;
164
+ return process.env.WRENV_SECRET || process.env.TARGET_SECRET || "";
140
165
  }
141
166
 
142
167
  /**
@@ -148,18 +173,18 @@ export function resolveSecret(secretParam) {
148
173
  export function parseEnvFile(content) {
149
174
  /** @type {EnvRecord} */
150
175
  const result = {};
151
- const lines = content.split('\n');
176
+ const lines = content.split("\n");
152
177
 
153
178
  for (const line of lines) {
154
179
  const trimmed = line.trim();
155
180
 
156
181
  // Skip empty lines and comments
157
- if (!trimmed || trimmed.startsWith('#')) {
182
+ if (!trimmed || trimmed.startsWith("#")) {
158
183
  continue;
159
184
  }
160
185
 
161
186
  // Find the first = sign
162
- const eqIndex = trimmed.indexOf('=');
187
+ const eqIndex = trimmed.indexOf("=");
163
188
  if (eqIndex === -1) {
164
189
  continue;
165
190
  }
@@ -168,8 +193,10 @@ export function parseEnvFile(content) {
168
193
  let value = trimmed.slice(eqIndex + 1).trim();
169
194
 
170
195
  // Remove surrounding quotes if present
171
- if ((value.startsWith('"') && value.endsWith('"')) ||
172
- (value.startsWith("'") && value.endsWith("'"))) {
196
+ if (
197
+ (value.startsWith('"') && value.endsWith('"')) ||
198
+ (value.startsWith("'") && value.endsWith("'"))
199
+ ) {
173
200
  value = value.slice(1, -1);
174
201
  }
175
202
 
@@ -190,14 +217,15 @@ export function parseEnvFile(content) {
190
217
  function findNearestPackageName(cwd, scope) {
191
218
  let currentDir = cwd;
192
219
 
193
- while (currentDir !== path.dirname(currentDir)) { // Stop at filesystem root
194
- const pkgPath = path.join(currentDir, 'package.json');
220
+ while (currentDir !== path.dirname(currentDir)) {
221
+ // Stop at filesystem root
222
+ const pkgPath = path.join(currentDir, "package.json");
195
223
  if (fs.existsSync(pkgPath)) {
196
224
  try {
197
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
225
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
198
226
  if (pkg.name) {
199
227
  // Return name without scope prefix
200
- return pkg.name.replace(`${scope}/`, '');
228
+ return pkg.name.replace(`${scope}/`, "");
201
229
  }
202
230
  } catch (e) {
203
231
  // Continue traversing upward
@@ -206,7 +234,7 @@ function findNearestPackageName(cwd, scope) {
206
234
  currentDir = path.dirname(currentDir);
207
235
  }
208
236
 
209
- return 'root'; // fallback
237
+ return "root"; // fallback
210
238
  }
211
239
 
212
240
  /**
@@ -215,14 +243,14 @@ function findNearestPackageName(cwd, scope) {
215
243
  * @returns {EnvRecord} - Local overrides or empty object
216
244
  */
217
245
  export function loadLocalOverrides(cwd = process.cwd()) {
218
- const localEnvPath = path.join(cwd, '.env.local');
246
+ const localEnvPath = path.join(cwd, ".env.local");
219
247
 
220
248
  if (!fs.existsSync(localEnvPath)) {
221
249
  return {};
222
250
  }
223
251
 
224
252
  try {
225
- const content = fs.readFileSync(localEnvPath, 'utf8');
253
+ const content = fs.readFileSync(localEnvPath, "utf8");
226
254
  return parseEnvFile(content);
227
255
  } catch (error) {
228
256
  return {};
@@ -260,8 +288,8 @@ export function loadEnvJson(options = {}) {
260
288
  const {
261
289
  secret: secretParam,
262
290
  targetEnv: targetParam,
263
- envPath = '.env.json',
264
- cwd = process.cwd()
291
+ envPath = ".env.json",
292
+ cwd = process.cwd(),
265
293
  } = options;
266
294
 
267
295
  const secret = resolveSecret(secretParam);
@@ -274,32 +302,35 @@ export function loadEnvJson(options = {}) {
274
302
  }
275
303
 
276
304
  const envFile = path.join(monorepoRoot, envPath);
277
- const envContent = fs.readFileSync(envFile, 'utf8');
305
+ const envContent = fs.readFileSync(envFile, "utf8");
278
306
  const env = JSON.parse(envContent);
279
307
 
280
- const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
308
+ const allowedEnvs =
309
+ env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
281
310
  const resolvedTarget = allowedEnvs.find((e) => e === targetEnv);
282
311
 
283
312
  if (!resolvedTarget) {
284
- throw new Error(`Target env "${targetEnv}" is not allowed. Allowed: ${allowedEnvs.join(', ')}`);
313
+ throw new Error(
314
+ `Target env "${targetEnv}" is not allowed. Allowed: ${allowedEnvs.join(", ")}`,
315
+ );
285
316
  }
286
317
 
287
318
  // Get scope from monorepo root package.json
288
- const rootPkgPath = path.join(monorepoRoot, 'package.json');
289
- let scope = '';
319
+ const rootPkgPath = path.join(monorepoRoot, "package.json");
320
+ let scope = "";
290
321
  /** @type {string[]} */
291
- let depsName = ['root'];
322
+ let depsName = ["root"];
292
323
 
293
324
  if (fs.existsSync(rootPkgPath)) {
294
325
  try {
295
- const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
326
+ const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
296
327
  const rootPkg = JSON.parse(rootPkgContent);
297
- scope = rootPkg.name.split('/')[0];
328
+ scope = rootPkg.name.split("/")[0];
298
329
 
299
330
  const foundPackages = findMonorepoPackages(monorepoRoot, scope);
300
331
  depsName = [
301
- ...foundPackages.map((pkg) => pkg.name.replace(`${scope}/`, '')),
302
- 'root'
332
+ ...foundPackages.map((pkg) => pkg.name.replace(`${scope}/`, "")),
333
+ "root",
303
334
  ];
304
335
  } catch (error) {
305
336
  // Not a monorepo or error parsing, use default
@@ -339,7 +370,7 @@ export function load(options = {}) {
339
370
  secret,
340
371
  envPath,
341
372
  cwd = process.cwd(),
342
- applyToProcess = true
373
+ applyToProcess = true,
343
374
  } = options;
344
375
 
345
376
  // Resolve target first to determine if we're in dev mode
@@ -350,13 +381,12 @@ export function load(options = {}) {
350
381
  secret,
351
382
  targetEnv: resolvedTarget,
352
383
  envPath,
353
- cwd
384
+ cwd,
354
385
  });
355
386
 
356
387
  // Load .env.local overrides only in dev mode (security: prevent local overrides in production)
357
- const localOverrides = resolvedTarget === 'dev'
358
- ? loadLocalOverrides(cwd)
359
- : {};
388
+ const localOverrides =
389
+ resolvedTarget === "dev" ? loadLocalOverrides(cwd) : {};
360
390
 
361
391
  // Merge: env.json values, then .env.local overrides (local takes precedence)
362
392
  // Note: .env.local only overrides env.json values, not existing process.env
@@ -369,6 +399,15 @@ export function load(options = {}) {
369
399
  }
370
400
  }
371
401
 
402
+ // - Reserved vars (WRENV_*, TARGET_*) are never injected
403
+ // - NODE_ENV is only injected if not already set
404
+ for (const key of RESERVED_ENV_VARS) {
405
+ delete finalConfig[key];
406
+ }
407
+ if (process.env.NODE_ENV) {
408
+ delete finalConfig.NODE_ENV;
409
+ }
410
+
372
411
  // Apply to process.env if requested
373
412
  if (applyToProcess) {
374
413
  for (const [key, value] of Object.entries(finalConfig)) {
@@ -397,15 +436,18 @@ export function verifyCanDecrypt(env, secret) {
397
436
  const encryptedValues = [];
398
437
 
399
438
  if (!secret) {
400
- return { success: false, path: null, error: 'No secret provided' };
439
+ return { success: false, path: null, error: "No secret provided" };
401
440
  }
402
441
 
403
442
  // Collect all encrypted values from variables
404
443
  if (env.variables) {
405
444
  for (const [varName, varValue] of Object.entries(env.variables)) {
406
445
  for (const [envKey, envVal] of Object.entries(varValue)) {
407
- if (typeof envVal === 'string' && isEncrypted(envVal)) {
408
- encryptedValues.push({ path: `variables.${varName}.${envKey}`, value: envVal });
446
+ if (typeof envVal === "string" && isEncrypted(envVal)) {
447
+ encryptedValues.push({
448
+ path: `variables.${varName}.${envKey}`,
449
+ value: envVal,
450
+ });
409
451
  }
410
452
  }
411
453
  }
@@ -416,8 +458,11 @@ export function verifyCanDecrypt(env, secret) {
416
458
  for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
417
459
  for (const [varName, varValue] of Object.entries(pkgOverrides)) {
418
460
  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 });
461
+ if (typeof envVal === "string" && isEncrypted(envVal)) {
462
+ encryptedValues.push({
463
+ path: `overrides.${pkgName}.${varName}.${envKey}`,
464
+ value: envVal,
465
+ });
421
466
  }
422
467
  }
423
468
  }
@@ -437,7 +482,7 @@ export function verifyCanDecrypt(env, secret) {
437
482
  return {
438
483
  success: false,
439
484
  path,
440
- error: error.message
485
+ error: error.message,
441
486
  };
442
487
  }
443
488
  }
package/src/index.d.ts ADDED
@@ -0,0 +1,82 @@
1
+ /** Key-value pairs of environment variable name to value */
2
+ export type EnvRecord = Record<string, string>;
3
+
4
+ export interface LoadOptions {
5
+ /** Target environment (e.g. 'dev', 'preprod', 'production') */
6
+ targetEnv?: string;
7
+ /** Secret for decryption: "stdin", "file://path", file path, or literal string */
8
+ secret?: string;
9
+ /** Path to env.json file (default: '.env.json') */
10
+ envPath?: string;
11
+ /** Working directory (default: process.cwd()) */
12
+ cwd?: string;
13
+ /** Whether to apply resolved vars to process.env (default: true) */
14
+ applyToProcess?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Load environment variables from .env.json with optional .env.local overrides.
19
+ * By default, applies resolved variables to process.env.
20
+ */
21
+ export function load(options?: LoadOptions): EnvRecord;
22
+
23
+ /**
24
+ * Load environment configuration from .env.json without .env.local overrides.
25
+ */
26
+ export function loadEnvJson(options?: {
27
+ secret?: string;
28
+ targetEnv?: string;
29
+ envPath?: string;
30
+ cwd?: string;
31
+ }): EnvRecord;
32
+
33
+ /** Load .env.local overrides if the file exists */
34
+ export function loadLocalOverrides(cwd?: string): EnvRecord;
35
+
36
+ /** Parse .env file content into key-value pairs */
37
+ export function parseEnvFile(content: string): EnvRecord;
38
+
39
+ /**
40
+ * Resolve target environment with fallback chain:
41
+ * param -> WRENV_TARGET -> TARGET_ENV -> NODE_ENV (mapped) -> 'dev'
42
+ */
43
+ export function resolveTarget(targetParam?: string): string;
44
+
45
+ /**
46
+ * Resolve secret with fallback chain:
47
+ * param (stdin | file:// | file path | literal) -> WRENV_SECRET -> TARGET_SECRET -> ''
48
+ */
49
+ export function resolveSecret(secretParam?: string): string;
50
+
51
+ /** Get the conventional output file name for an internal environment name */
52
+ export function getOutputName(internalName: string): string;
53
+
54
+ /** Verify that all encrypted values can be decrypted with the provided secret */
55
+ export function verifyCanDecrypt(env: object, secret: string): {
56
+ success: boolean;
57
+ path?: string | null;
58
+ error?: string;
59
+ };
60
+
61
+ /** Encrypt a plaintext value with the given secret */
62
+ export function encrypt(value: string, secret: string): string;
63
+
64
+ /** Decrypt an encrypted value with the given secret */
65
+ export function decrypt(value: string, secret: string): string;
66
+
67
+ /** Check if a value is encrypted */
68
+ export function isEncrypted(value: string): boolean;
69
+
70
+ /** Build environment variables for all packages from an env config */
71
+ export function buildEnv(
72
+ secret: string,
73
+ target: string,
74
+ env: object,
75
+ names: string[],
76
+ ): Record<string, EnvRecord>;
77
+
78
+ /** Find all packages in a monorepo */
79
+ export function findMonorepoPackages(
80
+ root: string,
81
+ scope: string,
82
+ ): Array<{ name: string; dir: string }>;
package/src/utils-env.js CHANGED
@@ -1,11 +1,9 @@
1
- import { decrypt, isEncrypted } from './utils-crypto.js';
1
+ import { decrypt, isEncrypted } from "./utils-crypto.js";
2
2
 
3
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
4
+ * Environment-specific values for a single variable.
5
+ * Keys are environment names (e.g. 'dev', 'production') or '@@' for the fallback.
6
+ * @typedef {Object.<string, string>} EnvVariableValue
9
7
  */
10
8
 
11
9
  /**
@@ -47,13 +45,16 @@ export function buildEnv(secret, target, env, names) {
47
45
 
48
46
  // For each variable, extract the value for the target env
49
47
  /** @type {EnvRecord} */
50
- const variables = Object.entries(variablesForAllTargets).reduce((acc, entry) => {
51
- const [key, value] = entry;
52
- return {
53
- ...acc,
54
- [key]: extractValue(secret, value[target] || value['@@'] || '')
55
- };
56
- }, {});
48
+ const variables = Object.entries(variablesForAllTargets).reduce(
49
+ (acc, entry) => {
50
+ const [key, value] = entry;
51
+ return {
52
+ ...acc,
53
+ [key]: extractValue(secret, value[target] || value["@@"] || ""),
54
+ };
55
+ },
56
+ {},
57
+ );
57
58
 
58
59
  // For each package, get all variables
59
60
  // compute the overrides and merge them
@@ -64,7 +65,7 @@ export function buildEnv(secret, target, env, names) {
64
65
  const allVars = names.reduce((acc, name) => {
65
66
  acc[name] = {
66
67
  ...variables,
67
- ...parseOverrides(secret, target, overrides[name], variables)
68
+ ...parseOverrides(secret, target, overrides[name], variables),
68
69
  };
69
70
 
70
71
  return acc;
@@ -77,7 +78,7 @@ export function buildEnv(secret, target, env, names) {
77
78
  * Parse package-specific overrides for a target environment
78
79
  * @param {string} secret - Secret for decrypting encrypted values
79
80
  * @param {string} target - Target environment
80
- * @param {Object.<string, EnvVariableValue>} [overrides] - Package overrides
81
+ * @param {Object.<string, EnvVariableValue>} overrides - Package overrides (may be undefined)
81
82
  * @param {EnvRecord} vars - Base variables for reference substitution
82
83
  * @returns {EnvRecord} - Parsed override values
83
84
  */
@@ -86,12 +87,16 @@ function parseOverrides(secret, target, overrides, vars) {
86
87
 
87
88
  return Object.entries(overrides).reduce((acc, entry) => {
88
89
  const [key, val] = entry;
89
- const value = extractValue(secret, val[target] || val['@@'] || '');
90
+ const value = extractValue(secret, val[target] || val["@@"] || "");
90
91
 
91
92
  if (value === null) return acc;
92
93
 
93
94
  acc[key] = Array.isArray(value)
94
- ? value.map(v => Object.prototype.hasOwnProperty.call(vars, v) ? vars[v] : v).join('')
95
+ ? value
96
+ .map((v) =>
97
+ Object.prototype.hasOwnProperty.call(vars, v) ? vars[v] : v,
98
+ )
99
+ .join("")
95
100
  : value;
96
101
  return acc;
97
102
  }, {});
@@ -105,6 +110,6 @@ function parseOverrides(secret, target, overrides, vars) {
105
110
  */
106
111
  function extractValue(secret, value) {
107
112
  if (!isEncrypted(value)) return value;
108
- if (!secret) return '';
113
+ if (!secret) return "";
109
114
  return decrypt(value, secret);
110
115
  }