@saasak/tool-env 1.0.2 → 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/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,71 +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 { buildEnv } from '../src/utils-env.js';
9
- import { findMonorepoPackages } from '../src/utils-pkg.js';
10
- import { encrypt, isEncrypted } from '../src/utils-crypto.js';
11
-
12
- const args = minimist(process.argv.slice(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 {
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 });
13
20
  const __root = process.cwd();
14
21
 
15
- const command = args._[0] || 'write';
22
+ const command = args._[0] || "write";
16
23
 
17
- const secret = args.secret
18
- ? (args.secret === 'stdin' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(args.secret, 'utf8'))
19
- : process.env.WRENV_SECRET || process.env.TARGET_SECRET || '';
24
+ const secret = resolveSecret(args.secret);
20
25
 
21
- const envPath = args.env || '.env.json';
26
+ const envPath = args.env || ".env.json";
22
27
  const envFile = path.resolve(__root, envPath);
23
28
 
24
- if (!fs.existsSync(envFile)) {
25
- console.error('No env file found.');
29
+ // Check env file exists for commands that need it
30
+ const commandsRequiringEnvFile = ["write", "show", "encrypt", "besafe", "add"];
31
+ if (commandsRequiringEnvFile.includes(command) && !fs.existsSync(envFile)) {
32
+ console.error("No env file found.");
26
33
  process.exit(1);
27
34
  }
28
35
 
29
- const DEFAULT_ENV = 'dev';
30
- const targets = {
31
- "development": "dev",
32
- "dev": "dev",
33
- "staging": "preprod",
34
- "pp": "preprod",
35
- "preprod": "preprod",
36
- "prod": "production",
37
- "production": "production",
38
- }
39
-
40
36
  async function showCommand() {
41
37
  if (!secret) {
42
- 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
+ );
43
41
  }
44
42
 
45
- const target = targets[args.target || process.env.WRENV_TARGET || process.env.TARGET_ENV] || DEFAULT_ENV;
43
+ const target = resolveTarget(args.target);
46
44
 
47
- const envContent = fs.readFileSync(envFile, 'utf8');
45
+ const envContent = fs.readFileSync(envFile, "utf8");
48
46
  const env = JSON.parse(envContent);
49
47
 
50
- const rootPkgPath = path.resolve(__root, 'package.json');
51
- const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
48
+ const rootPkgPath = path.resolve(__root, "package.json");
49
+ const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
52
50
  const rootPkg = JSON.parse(rootPkgContent);
53
- const scope = rootPkg.name.split('/')[0];
54
51
 
55
- const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
52
+ const scope = rootPkg.name.split("/")[0];
53
+ const [_, ...foundPackages] = findMonorepoPackages(__root, scope);
54
+
56
55
  const deps = [
57
- ...foundPackages.map((pkg) => ({
58
- name: pkg.name.replace(`${scope}/`, ''),
56
+ ...foundPackages.filter(Boolean).map((pkg) => ({
57
+ name: pkg.name.replace(`${scope}/`, ""),
59
58
  dir: pkg.dir,
60
59
  })),
61
60
  {
62
- name: 'root',
61
+ name: "root",
63
62
  dir: __root,
64
63
  },
65
64
  ];
65
+
66
66
  const depsName = deps.map((dep) => dep.name);
67
67
 
68
- const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
68
+ const allowedEnvs =
69
+ env && env.envs && Array.isArray(env.envs) ? env.envs : ["dev"];
69
70
  const targetEnv = allowedEnvs.find((env) => env === target);
70
71
  if (!targetEnv) {
71
72
  console.error(`Target env "${target}" is not allowed.`);
@@ -77,73 +78,104 @@ async function showCommand() {
77
78
  for await (const dep of deps) {
78
79
  const pkgEnv = Object.entries(allEnvs[dep.name] || {})
79
80
  .map(([key, value]) => `${key}=${value}`)
80
- .join('\n')
81
- console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~~');
82
- 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
+ );
83
86
  console.log(pkgEnv);
84
87
  }
85
-
86
88
  }
87
89
 
88
90
  async function writeCommand() {
89
91
  if (!secret) {
90
- 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
+ );
91
95
  }
92
96
 
93
- const target = targets[args.target || process.env.WRENV_TARGET || process.env.TARGET_ENV] || DEFAULT_ENV;
97
+ const target = resolveTarget(args.target);
94
98
 
95
- const envContent = fs.readFileSync(envFile, 'utf8');
99
+ const envContent = fs.readFileSync(envFile, "utf8");
96
100
  const env = JSON.parse(envContent);
97
101
 
98
- const rootPkgPath = path.resolve(__root, 'package.json');
99
- const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
102
+ const rootPkgPath = path.resolve(__root, "package.json");
103
+ const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
100
104
  const rootPkg = JSON.parse(rootPkgContent);
101
- const scope = rootPkg.name.split('/')[0];
105
+ const scope = rootPkg.name.split("/")[0];
102
106
 
103
107
  const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
104
108
  const deps = [
105
109
  ...foundPackages.map((pkg) => ({
106
- name: pkg.name.replace(`${scope}/`, ''),
110
+ name: pkg.name.replace(`${scope}/`, ""),
107
111
  dir: pkg.dir,
108
112
  })),
109
113
  {
110
- name: 'root',
114
+ name: "root",
111
115
  dir: __root,
112
116
  },
113
117
  ];
114
118
  const depsName = deps.map((dep) => dep.name);
115
119
 
116
- const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
117
- const targetEnv = allowedEnvs.find((env) => env === target);
118
- if (!targetEnv) {
119
- console.error(`Target env "${target}" is not allowed.`);
120
- process.exit(1);
121
- }
120
+ const allowedEnvs =
121
+ env && env.envs && Array.isArray(env.envs) ? env.envs : ["dev"];
122
122
 
123
- const allEnvs = buildEnv(secret, targetEnv, env, depsName);
123
+ // Handle 'all' target: write all environments with suffixed filenames
124
+ const isAllTarget = target === "all";
125
+ const targetEnvs = isAllTarget ? allowedEnvs : [target];
124
126
 
125
- for await (const dep of deps) {
126
- const pkgEnv = Object.entries(allEnvs[dep.name] || {})
127
- .map(([key, value]) => `${key}=${value}`)
128
- .join('\n')
129
- console.log(`Writing env file for ${dep.name} in ${dep.dir} with target ${targetEnv}`);
130
- await fs.writeFile(path.join(dep.dir, '.env'), pkgEnv);
127
+ for (const targetEnv of targetEnvs) {
128
+ if (!allowedEnvs.includes(targetEnv)) {
129
+ console.error(`Target env "${targetEnv}" is not allowed.`);
130
+ process.exit(1);
131
+ }
132
+
133
+ const allEnvs = buildEnv(secret, targetEnv, env, depsName);
134
+
135
+ for await (const dep of deps) {
136
+ const pkgEnv = Object.entries(allEnvs[dep.name] || {})
137
+ .map(([key, value]) => `${key}=${value}`)
138
+ .join("\n");
139
+ // Use suffixed filename (.env.{outputName}) for 'all', plain .env otherwise
140
+ const filename = isAllTarget
141
+ ? `.env.${getOutputName(targetEnv)}`
142
+ : ".env";
143
+ console.log(`Writing ${filename} for ${dep.name} in ${dep.dir}`);
144
+ await fs.writeFile(path.join(dep.dir, filename), pkgEnv);
145
+ }
131
146
  }
132
147
  }
133
148
 
134
149
  async function encryptCommand() {
135
- const envContent = fs.readFileSync(envFile, 'utf8');
150
+ const envContent = fs.readFileSync(envFile, "utf8");
136
151
  const env = JSON.parse(envContent);
137
152
 
138
153
  if (!secret) {
139
- 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
+ );
140
157
  return;
141
158
  }
142
159
 
160
+ // Verify we can decrypt all existing encrypted values before encrypting new ones
161
+ const verification = verifyCanDecrypt(env, secret);
162
+ if (!verification.success) {
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
+ );
172
+ process.exit(1);
173
+ }
174
+
143
175
  if (env.variables) {
144
176
  for (const [varName, varValue] of Object.entries(env.variables)) {
145
177
  for (const [envKey, envVal] of Object.entries(varValue)) {
146
- if (typeof envVal === 'string' && !isEncrypted(envVal)) {
178
+ if (typeof envVal === "string" && !isEncrypted(envVal)) {
147
179
  env.variables[varName][envKey] = encrypt(envVal, secret);
148
180
  }
149
181
  }
@@ -154,7 +186,7 @@ async function encryptCommand() {
154
186
  for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
155
187
  for (const [varName, varValue] of Object.entries(pkgOverrides)) {
156
188
  for (const [envKey, envVal] of Object.entries(varValue)) {
157
- if (typeof envVal === 'string' && !isEncrypted(envVal)) {
189
+ if (typeof envVal === "string" && !isEncrypted(envVal)) {
158
190
  env.overrides[pkgName][varName][envKey] = encrypt(envVal, secret);
159
191
  }
160
192
  }
@@ -167,7 +199,11 @@ async function encryptCommand() {
167
199
 
168
200
  async function besafeCommand() {
169
201
  // Find git root to ensure we're in a repository and use correct base path
170
- 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
+ );
171
207
  if (gitCheck !== 0) {
172
208
  return;
173
209
  }
@@ -175,9 +211,21 @@ async function besafeCommand() {
175
211
  const relativeEnvPath = path.relative(gitRoot, envFile);
176
212
 
177
213
  // Check if file has any changes (staged, unstaged, or untracked)
178
- const { stdout: staged } = await execa('git', ['diff', '--cached', '--name-only', '--', relativeEnvPath], { cwd: gitRoot, reject: false });
179
- const { stdout: unstaged } = await execa('git', ['diff', '--name-only', '--', relativeEnvPath], { cwd: gitRoot, reject: false });
180
- 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
+ );
181
229
 
182
230
  if (!staged?.trim() && !unstaged?.trim() && !untracked?.trim()) {
183
231
  return;
@@ -186,24 +234,28 @@ async function besafeCommand() {
186
234
  await encryptCommand();
187
235
 
188
236
  if (!secret) {
189
- 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
+ );
190
240
  }
191
241
 
192
- await execa('git', ['add', relativeEnvPath], { cwd: gitRoot });
242
+ await execa("git", ["add", relativeEnvPath], { cwd: gitRoot });
193
243
  }
194
244
 
195
245
  async function addCommand() {
196
246
  const varName = args._[1];
197
247
  if (!varName) {
198
- console.error('Variable name is required.');
248
+ console.error("Variable name is required.");
199
249
  process.exit(1);
200
250
  }
201
251
 
202
252
  if (!secret) {
203
- 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
+ );
204
256
  }
205
257
 
206
- const envContent = fs.readFileSync(envFile, 'utf8');
258
+ const envContent = fs.readFileSync(envFile, "utf8");
207
259
  const env = JSON.parse(envContent);
208
260
 
209
261
  if (!env.variables) {
@@ -215,39 +267,101 @@ async function addCommand() {
215
267
  }
216
268
 
217
269
  for (const arg of args._.slice(2)) {
218
- if (!arg.startsWith('+')) continue;
219
- const [key, ...valueParts] = arg.slice(1).split('=');
220
- const value = valueParts.join('=');
221
- 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;
222
274
  env.variables[varName][envKey] = secret ? encrypt(value, secret) : value;
223
275
  }
224
276
 
225
277
  await fs.writeFile(envFile, JSON.stringify(env, null, 2));
226
278
  }
227
279
 
280
+ async function runCommand() {
281
+ const commandParts = args["--"] || [];
282
+ const [cmd, ...cmdArgs] = commandParts;
283
+
284
+ if (!cmd) {
285
+ console.error("Usage: wrenv run [options] -- <command> [args...]");
286
+ process.exit(1);
287
+ }
288
+
289
+ // Load environment variables (without applying to current process.env)
290
+ let envVars = {};
291
+ try {
292
+ envVars = load({
293
+ secret: resolveSecret(args.secret),
294
+ targetEnv: args.target,
295
+ envPath: args.env,
296
+ applyToProcess: false,
297
+ });
298
+ } catch (error) {
299
+ console.error(`wrenv: ${error.message}`);
300
+ process.exit(1);
301
+ }
302
+
303
+ // Use execa for cross-platform compatibility with minimal overhead
304
+ const subprocess = execa(cmd, cmdArgs, {
305
+ stdio: "inherit",
306
+ env: { ...process.env, ...envVars },
307
+ reject: false,
308
+ });
309
+
310
+ // Forward signals to child process
311
+ const forwardSignal = (signal) => subprocess.kill(signal);
312
+ process.on("SIGTERM", () => forwardSignal("SIGTERM"));
313
+ process.on("SIGINT", () => forwardSignal("SIGINT"));
314
+ process.on("SIGHUP", () => forwardSignal("SIGHUP"));
315
+
316
+ const result = await subprocess;
317
+ process.exit(result.exitCode ?? 0);
318
+ }
319
+
228
320
  function helpCommand() {
229
- console.log('Usage: wrenv [command] [options]\n');
230
- console.log('Commands:');
231
- console.log(' write Write .env files for all packages (default)');
232
- console.log(' show Show environment variables without writing files');
233
- console.log(' encrypt Encrypt all unencrypted variables in .env.json');
234
- console.log(' besafe Encrypt and stage .env.json if it has changes');
235
- console.log(' add <var> Add a new variable with environment-specific values');
236
- console.log(' help Show this help message\n');
237
- console.log('Options:');
238
- console.log(' --secret <path|stdin> Path to secret file or "stdin" to read from stdin');
239
- console.log(' --target <env> Target environment (dev, staging, prod, etc.)');
240
- console.log(' --env <path> Path to env file (default: .env.json)\n');
241
- console.log('Environment Variables:');
242
- console.log(' WRENV_SECRET Secret for encryption/decryption');
243
- console.log(' WRENV_TARGET Target environment');
244
- console.log(' TARGET_SECRET Alias for WRENV_SECRET');
245
- console.log(' TARGET_ENV Alias for WRENV_TARGET\n');
246
- console.log('Examples:');
247
- console.log(' wrenv --secret ~/.big-secret write --target dev');
248
- console.log(' wrenv --secret stdin write < ~/.big-secret');
249
- console.log(' wrenv show --target prod');
250
- console.log(' wrenv add NEW_VAR +fallback=value +dev=dev_value +production=prod_value');
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
+ );
251
365
  }
252
366
 
253
367
  const commands = {
@@ -256,6 +370,7 @@ const commands = {
256
370
  besafe: besafeCommand,
257
371
  encrypt: encryptCommand,
258
372
  add: addCommand,
373
+ run: runCommand,
259
374
  help: helpCommand,
260
375
  };
261
376
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saasak/tool-env",
3
3
  "license": "MIT",
4
- "version": "1.0.2",
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": [
@@ -10,7 +10,8 @@
10
10
  "env"
11
11
  ],
12
12
  "type": "module",
13
- "main": "bin/index.js",
13
+ "main": "src/index.js",
14
+ "types": "src/index.d.ts",
14
15
  "files": [
15
16
  "bin",
16
17
  "src"
@@ -22,5 +23,12 @@
22
23
  "execa": "8.0.1",
23
24
  "fs-extra": "11.2.0",
24
25
  "minimist": "1.2.8"
26
+ },
27
+ "devDependencies": {
28
+ "vitest": "^1.6.0"
29
+ },
30
+ "scripts": {
31
+ "test": "vitest run",
32
+ "test:watch": "vitest"
25
33
  }
26
34
  }