@saasak/tool-env 1.3.0 → 1.5.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
@@ -5,8 +5,8 @@ import fs from "fs-extra";
5
5
  import path from "path";
6
6
  import { execa } from "execa";
7
7
 
8
- import { encrypt, isEncrypted } from "../src/utils-crypto.js";
9
- import { buildEnv } from "../src/utils-env.js";
8
+ import { encrypt, decrypt, isEncrypted } from "../src/utils-crypto.js";
9
+ import { buildEnv, walkEnvValues } from "../src/utils-env.js";
10
10
  import { findMonorepoPackages } from "../src/utils-pkg.js";
11
11
  import {
12
12
  resolveTarget,
@@ -14,121 +14,148 @@ import {
14
14
  verifyCanDecrypt,
15
15
  getOutputName,
16
16
  load,
17
+ findMonorepoRoot,
17
18
  } from "../src/core.js";
18
19
 
19
- const args = minimist(process.argv.slice(2), { "--": true });
20
- const __root = process.cwd();
20
+ const args = minimist(process.argv.slice(2), {
21
+ "--": true,
22
+ boolean: ["strict", "expose", "help", "h"],
23
+ string: ["format", "package"],
24
+ });
25
+ const __cwd = process.cwd();
26
+ const envPath = args.env || ".env.json";
27
+ const __root = findMonorepoRoot(__cwd, envPath) || __cwd;
21
28
 
22
- const command = args._[0] || "write";
29
+ let command = args._[0] || "help";
30
+ if (args.help || args.h) command = "help";
23
31
 
24
32
  const secret = resolveSecret(args.secret ? `file://${args.secret}` : '');
25
33
 
26
- const envPath = args.env || ".env.json";
27
34
  const envFile = path.resolve(__root, envPath);
28
35
 
29
36
  // Check env file exists for commands that need it
30
- const commandsRequiringEnvFile = ["write", "show", "encrypt", "besafe", "add"];
37
+ const commandsRequiringEnvFile = ["write", "show", "encrypt", "decrypt", "besafe", "add", "get"];
31
38
  if (commandsRequiringEnvFile.includes(command) && !fs.existsSync(envFile)) {
32
39
  console.error("No env file found.");
33
40
  process.exit(1);
34
41
  }
35
42
 
36
- async function showCommand() {
37
- if (!secret) {
38
- console.log(
39
- "Secret not provided. Use --secret flag or WRENV_SECRET env variable.",
40
- );
43
+ if (args.strict && commandsRequiringEnvFile.includes(command)) {
44
+ const env = JSON.parse(fs.readFileSync(envFile, "utf8"));
45
+ const result = verifyCanDecrypt(env, secret);
46
+ if (!result.success) {
47
+ console.error(`Strict mode: ${result.error}`);
48
+ process.exit(1);
41
49
  }
50
+ }
42
51
 
52
+ function loadWorkspace() {
43
53
  const target = resolveTarget(args.target);
44
-
45
- const envContent = fs.readFileSync(envFile, "utf8");
46
- const env = JSON.parse(envContent);
47
-
48
- const rootPkgPath = path.resolve(__root, "package.json");
49
- const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
50
- const rootPkg = JSON.parse(rootPkgContent);
51
-
54
+ const env = JSON.parse(fs.readFileSync(envFile, "utf8"));
55
+ const rootPkg = JSON.parse(
56
+ fs.readFileSync(path.resolve(__root, "package.json"), "utf8"),
57
+ );
52
58
  const scope = rootPkg.name.split("/")[0];
53
59
  const [_, ...foundPackages] = findMonorepoPackages(__root, scope);
54
-
55
60
  const deps = [
56
61
  ...foundPackages.filter(Boolean).map((pkg) => ({
57
62
  name: pkg.name.replace(`${scope}/`, ""),
58
63
  dir: pkg.dir,
59
64
  })),
60
- {
61
- name: "root",
62
- dir: __root,
63
- },
65
+ { name: "root", dir: __root },
64
66
  ];
67
+ const depsName = deps.map((d) => d.name);
68
+ const allowedEnvs =
69
+ env?.envs && Array.isArray(env.envs) ? env.envs : ["dev"];
70
+ return { env, deps, depsName, allowedEnvs, target };
71
+ }
65
72
 
66
- const depsName = deps.map((dep) => dep.name);
73
+ function detectCurrentPackage(deps) {
74
+ for (const dep of deps) {
75
+ if (dep.name === "root") continue;
76
+ const depDir = path.resolve(dep.dir);
77
+ if (__cwd === depDir || __cwd.startsWith(depDir + path.sep)) {
78
+ return dep.name;
79
+ }
80
+ }
81
+ return null;
82
+ }
67
83
 
68
- const allowedEnvs =
69
- env && env.envs && Array.isArray(env.envs) ? env.envs : ["dev"];
70
- const targetEnv = allowedEnvs.find((env) => env === target);
71
- if (!targetEnv) {
84
+ function validateTarget(target, allowedEnvs) {
85
+ if (!allowedEnvs.includes(target)) {
72
86
  console.error(`Target env "${target}" is not allowed.`);
73
87
  process.exit(1);
74
88
  }
89
+ return target;
90
+ }
75
91
 
76
- const allEnvs = buildEnv(secret, targetEnv, env, depsName);
92
+ function printJson(allEnvs, deps, isSinglePackage) {
93
+ if (isSinglePackage) {
94
+ console.log(JSON.stringify(allEnvs[deps[0].name] || {}, null, 2));
95
+ } else {
96
+ const out = {};
97
+ for (const dep of deps) out[dep.name] = allEnvs[dep.name] || {};
98
+ console.log(JSON.stringify(out, null, 2));
99
+ }
100
+ }
77
101
 
78
- for await (const dep of deps) {
102
+ function printKeyValue(allEnvs, deps, targetEnv) {
103
+ for (const dep of deps) {
79
104
  const pkgEnv = Object.entries(allEnvs[dep.name] || {})
80
105
  .map(([key, value]) => `${key}=${value}`)
81
106
  .join("\n");
82
- console.log("~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
107
+ console.log("# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
83
108
  console.log(
84
- `=== ENV for ${dep.name} in ${dep.dir} with target ${targetEnv}`,
109
+ `# ===> ${dep.name} : in ${dep.dir} with target ${targetEnv}`,
85
110
  );
111
+ console.log("# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
86
112
  console.log(pkgEnv);
87
113
  }
88
114
  }
89
115
 
90
- async function writeCommand() {
116
+ async function showCommand() {
91
117
  if (!secret) {
92
- console.log(
118
+ console.error(
93
119
  "Secret not provided. Use --secret flag or WRENV_SECRET env variable.",
94
120
  );
95
121
  }
96
122
 
97
- const target = resolveTarget(args.target);
123
+ const { env, deps, depsName, allowedEnvs, target } = loadWorkspace();
124
+ const targetEnv = validateTarget(target, allowedEnvs);
98
125
 
99
- const envContent = fs.readFileSync(envFile, "utf8");
100
- const env = JSON.parse(envContent);
126
+ const selectedPkg = args.package || detectCurrentPackage(deps);
127
+ const filteredDeps = selectedPkg
128
+ ? deps.filter((d) => d.name === selectedPkg)
129
+ : deps;
130
+ if (selectedPkg && filteredDeps.length === 0) {
131
+ console.error(`Package "${selectedPkg}" not found.`);
132
+ process.exit(1);
133
+ }
101
134
 
102
- const rootPkgPath = path.resolve(__root, "package.json");
103
- const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
104
- const rootPkg = JSON.parse(rootPkgContent);
105
- const scope = rootPkg.name.split("/")[0];
135
+ const allEnvs = buildEnv(secret, targetEnv, env, depsName);
106
136
 
107
- const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
108
- const deps = [
109
- ...foundPackages.map((pkg) => ({
110
- name: pkg.name.replace(`${scope}/`, ""),
111
- dir: pkg.dir,
112
- })),
113
- {
114
- name: "root",
115
- dir: __root,
116
- },
117
- ];
118
- const depsName = deps.map((dep) => dep.name);
137
+ if (args.format === "json") {
138
+ printJson(allEnvs, filteredDeps, !!selectedPkg);
139
+ } else {
140
+ printKeyValue(allEnvs, filteredDeps, targetEnv);
141
+ }
142
+ }
119
143
 
120
- const allowedEnvs =
121
- env && env.envs && Array.isArray(env.envs) ? env.envs : ["dev"];
144
+ async function writeCommand() {
145
+ if (!secret) {
146
+ console.log(
147
+ "Secret not provided. Use --secret flag or WRENV_SECRET env variable.",
148
+ );
149
+ }
150
+
151
+ const { env, deps, depsName, allowedEnvs, target } = loadWorkspace();
122
152
 
123
153
  // Handle 'all' target: write all environments with suffixed filenames
124
154
  const isAllTarget = target === "all";
125
155
  const targetEnvs = isAllTarget ? allowedEnvs : [target];
126
156
 
127
157
  for (const targetEnv of targetEnvs) {
128
- if (!allowedEnvs.includes(targetEnv)) {
129
- console.error(`Target env "${targetEnv}" is not allowed.`);
130
- process.exit(1);
131
- }
158
+ validateTarget(targetEnv, allowedEnvs);
132
159
 
133
160
  const allEnvs = buildEnv(secret, targetEnv, env, depsName);
134
161
 
@@ -147,8 +174,7 @@ async function writeCommand() {
147
174
  }
148
175
 
149
176
  async function encryptCommand() {
150
- const envContent = fs.readFileSync(envFile, "utf8");
151
- const env = JSON.parse(envContent);
177
+ const env = JSON.parse(fs.readFileSync(envFile, "utf8"));
152
178
 
153
179
  if (!secret) {
154
180
  console.error(
@@ -172,31 +198,65 @@ async function encryptCommand() {
172
198
  process.exit(1);
173
199
  }
174
200
 
175
- if (env.variables) {
176
- for (const [varName, varValue] of Object.entries(env.variables)) {
177
- for (const [envKey, envVal] of Object.entries(varValue)) {
178
- if (typeof envVal === "string" && !isEncrypted(envVal)) {
179
- env.variables[varName][envKey] = encrypt(envVal, secret);
180
- }
181
- }
182
- }
201
+ walkEnvValues(
202
+ env,
203
+ (v) => typeof v === "string" && !isEncrypted(v),
204
+ (v) => encrypt(v, secret),
205
+ );
206
+
207
+ await fs.writeFile(envFile, JSON.stringify(env, null, 2));
208
+ }
209
+
210
+ async function decryptCommand() {
211
+ const env = JSON.parse(fs.readFileSync(envFile, "utf8"));
212
+
213
+ if (!secret) {
214
+ console.error(
215
+ "Cannot decrypt env file: secret not provided. Doing nothing",
216
+ );
217
+ return;
183
218
  }
184
219
 
185
- if (env.overrides) {
186
- for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
187
- for (const [varName, varValue] of Object.entries(pkgOverrides)) {
188
- for (const [envKey, envVal] of Object.entries(varValue)) {
189
- if (typeof envVal === "string" && !isEncrypted(envVal)) {
190
- env.overrides[pkgName][varName][envKey] = encrypt(envVal, secret);
191
- }
192
- }
193
- }
194
- }
220
+ const verification = verifyCanDecrypt(env, secret);
221
+ if (!verification.success) {
222
+ console.error(
223
+ `Cannot decrypt: the provided secret cannot decrypt encrypted value at "${verification.path}".`,
224
+ );
225
+ process.exit(1);
195
226
  }
196
227
 
228
+ walkEnvValues(env, isEncrypted, (v) => decrypt(v, secret));
229
+
197
230
  await fs.writeFile(envFile, JSON.stringify(env, null, 2));
198
231
  }
199
232
 
233
+ async function getCommand() {
234
+ const varName = args._[1];
235
+ if (!varName) {
236
+ console.error("Variable name is required.");
237
+ process.exit(1);
238
+ }
239
+
240
+ const { env, deps, depsName, allowedEnvs, target } = loadWorkspace();
241
+ const targetEnv = validateTarget(target, allowedEnvs);
242
+
243
+ const selectedPkg = args.package || detectCurrentPackage(deps) || "root";
244
+ if (!depsName.includes(selectedPkg)) {
245
+ console.error(`Package "${selectedPkg}" not found.`);
246
+ process.exit(1);
247
+ }
248
+
249
+ const allEnvs = buildEnv(secret, targetEnv, env, depsName);
250
+ const pkgEnv = allEnvs[selectedPkg] || {};
251
+
252
+ if (!Object.prototype.hasOwnProperty.call(pkgEnv, varName)) {
253
+ console.error(`Variable "${varName}" not found.`);
254
+ process.exit(1);
255
+ }
256
+
257
+ process.stdout.write(pkgEnv[varName]);
258
+ }
259
+
200
260
  async function besafeCommand() {
201
261
  // Find git root to ensure we're in a repository and use correct base path
202
262
  const { stdout: gitRoot, exitCode: gitCheck } = await execa(
@@ -295,7 +355,8 @@ async function runCommand() {
295
355
  targetEnv: args.target,
296
356
  envPath: args.env,
297
357
  applyToProcess: false,
298
- expose
358
+ expose,
359
+ strict: !!args.strict
299
360
  });
300
361
  } catch (error) {
301
362
  console.error(`wrenv: ${error.message}`);
@@ -322,7 +383,7 @@ async function runCommand() {
322
383
  function helpCommand() {
323
384
  console.log("Usage: wrenv [command] [options]\n");
324
385
  console.log("Commands:");
325
- console.log(" write Write .env files for all packages (default)");
386
+ console.log(" write Write .env files for all packages");
326
387
  console.log(
327
388
  " show Show environment variables without writing files",
328
389
  );
@@ -332,10 +393,16 @@ function helpCommand() {
332
393
  console.log(
333
394
  " encrypt Encrypt all unencrypted variables in .env.json",
334
395
  );
396
+ console.log(
397
+ " decrypt Decrypt all encrypted variables in .env.json",
398
+ );
335
399
  console.log(" besafe Encrypt and stage .env.json if it has changes");
336
400
  console.log(
337
401
  " add <var> Add a new variable with environment-specific values",
338
402
  );
403
+ console.log(
404
+ " get <var> Get the value of a specific variable",
405
+ );
339
406
  console.log(" help Show this help message\n");
340
407
  console.log("Options:");
341
408
  console.log(
@@ -345,7 +412,16 @@ function helpCommand() {
345
412
  " --target <env> Target environment (dev, staging, prod, all)",
346
413
  );
347
414
  console.log(
348
- " --env <path> Path to env file (default: .env.json)\n",
415
+ " --env <path> Path to env file (default: .env.json)",
416
+ );
417
+ console.log(
418
+ " --strict Error if encrypted values cannot be decrypted",
419
+ );
420
+ console.log(
421
+ " --format <fmt> Output format for show (json)",
422
+ );
423
+ console.log(
424
+ " --package <name> Show/get variables for a specific package\n",
349
425
  );
350
426
  console.log("Environment Variables:");
351
427
  console.log(" WRENV_SECRET Secret for encryption/decryption");
@@ -356,6 +432,8 @@ function helpCommand() {
356
432
  console.log(" wrenv --secret ~/.big-secret write --target dev");
357
433
  console.log(" wrenv --secret stdin write < ~/.big-secret");
358
434
  console.log(" wrenv show --target prod");
435
+ console.log(" wrenv show --format json --package root");
436
+ console.log(" wrenv get API_URL --target prod --package pkg-a");
359
437
  console.log(" wrenv run --target prod -- node server.js");
360
438
  console.log(" wrenv run -- npm test --coverage");
361
439
  console.log(
@@ -371,7 +449,9 @@ const commands = {
371
449
  show: showCommand,
372
450
  besafe: besafeCommand,
373
451
  encrypt: encryptCommand,
452
+ decrypt: decryptCommand,
374
453
  add: addCommand,
454
+ get: getCommand,
375
455
  run: runCommand,
376
456
  help: helpCommand,
377
457
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saasak/tool-env",
3
3
  "license": "MIT",
4
- "version": "1.3.0",
4
+ "version": "1.5.0",
5
5
  "author": "dev@saasak.studio",
6
6
  "description": "A small util to manage environment variables for your monorepo",
7
7
  "keywords": [
package/src/core.js CHANGED
@@ -17,6 +17,7 @@ import { findMonorepoPackages } from "./utils-pkg.js";
17
17
  * @property {string} [cwd] - Working directory (default: process.cwd())
18
18
  * @property {boolean} [applyToProcess] - Whether to apply to process.env (default: true)
19
19
  * @property {boolean} [expose] - wether to expose the secret or not
20
+ * @property {boolean} [strict] - Error if encrypted values cannot be decrypted
20
21
  */
21
22
 
22
23
  /**
@@ -26,6 +27,7 @@ import { findMonorepoPackages } from "./utils-pkg.js";
26
27
  * @property {string} [envPath] - Path to env.json file (default: '.env.json')
27
28
  * @property {string} [cwd] - Working directory (default: process.cwd())
28
29
  * @property {boolean} [expose] - wether to expose the secret or not
30
+ * @property {boolean} [strict] - Error if encrypted values cannot be decrypted
29
31
  */
30
32
 
31
33
  /**
@@ -263,7 +265,7 @@ export function loadLocalOverrides(cwd = process.cwd()) {
263
265
  * @param {string} envPath - Name of the env file (e.g. '.env.json')
264
266
  * @returns {string|null} - Path to monorepo root, or null if not found
265
267
  */
266
- function findMonorepoRoot(cwd, envPath) {
268
+ export function findMonorepoRoot(cwd, envPath) {
267
269
  let currentDir = cwd;
268
270
 
269
271
  while (currentDir !== path.dirname(currentDir)) {
@@ -290,7 +292,8 @@ export function loadEnvJson(options = {}) {
290
292
  secret: secretParam,
291
293
  envPath = ".env.json",
292
294
  cwd = process.cwd(),
293
- expose = false
295
+ expose = false,
296
+ strict = false
294
297
  } = options;
295
298
 
296
299
  const secret = resolveSecret(secretParam);
@@ -306,6 +309,13 @@ export function loadEnvJson(options = {}) {
306
309
  const envContent = fs.readFileSync(envFile, "utf8");
307
310
  const env = JSON.parse(envContent);
308
311
 
312
+ if (strict) {
313
+ const result = verifyCanDecrypt(env, secret);
314
+ if (!result.success) {
315
+ throw new Error(`Strict mode: ${result.error}`);
316
+ }
317
+ }
318
+
309
319
  const allowedEnvs =
310
320
  env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
311
321
  const resolvedTarget = allowedEnvs.find((e) => e === targetEnv);
@@ -328,7 +338,7 @@ export function loadEnvJson(options = {}) {
328
338
  const rootPkg = JSON.parse(rootPkgContent);
329
339
  scope = rootPkg.name.split("/")[0];
330
340
 
331
- const foundPackages = findMonorepoPackages(monorepoRoot, scope);
341
+ const [, ...foundPackages] = findMonorepoPackages(monorepoRoot, scope);
332
342
  depsName = [
333
343
  ...foundPackages.map((pkg) => pkg.name.replace(`${scope}/`, "")),
334
344
  "root",
@@ -378,7 +388,8 @@ export function load(options = {}) {
378
388
  envPath,
379
389
  cwd = process.cwd(),
380
390
  applyToProcess = true,
381
- expose = false
391
+ expose = false,
392
+ strict = false
382
393
  } = options;
383
394
 
384
395
  // Resolve target first to determine if we're in dev mode
@@ -390,7 +401,8 @@ export function load(options = {}) {
390
401
  targetEnv: resolvedTarget,
391
402
  envPath,
392
403
  cwd,
393
- expose
404
+ expose,
405
+ strict
394
406
  });
395
407
 
396
408
  // Load .env.local overrides only in dev mode (security: prevent local overrides in production)
@@ -446,10 +458,6 @@ export function verifyCanDecrypt(env, secret) {
446
458
  /** @type {EncryptedValueInfo[]} */
447
459
  const encryptedValues = [];
448
460
 
449
- if (!secret) {
450
- return { success: false, path: null, error: "No secret provided" };
451
- }
452
-
453
461
  // Collect all encrypted values from variables
454
462
  if (env.variables) {
455
463
  for (const [varName, varValue] of Object.entries(env.variables)) {
@@ -485,6 +493,10 @@ export function verifyCanDecrypt(env, secret) {
485
493
  return { success: true };
486
494
  }
487
495
 
496
+ if (!secret) {
497
+ return { success: false, path: null, error: "No secret provided" };
498
+ }
499
+
488
500
  // Try to decrypt each encrypted value
489
501
  for (const { path, value } of encryptedValues) {
490
502
  try {
package/src/index.d.ts CHANGED
@@ -12,6 +12,10 @@ export interface LoadOptions {
12
12
  cwd?: string;
13
13
  /** Whether to apply resolved vars to process.env (default: true) */
14
14
  applyToProcess?: boolean;
15
+ /** Whether to expose WRENV_TARGET and WRENV_SECRET in the output (default: false) */
16
+ expose?: boolean;
17
+ /** Error if encrypted values cannot be decrypted (default: false) */
18
+ strict?: boolean;
15
19
  }
16
20
 
17
21
  /**
@@ -28,6 +32,10 @@ export function loadEnvJson(options?: {
28
32
  targetEnv?: string;
29
33
  envPath?: string;
30
34
  cwd?: string;
35
+ /** Whether to expose WRENV_TARGET and WRENV_SECRET in the output (default: false) */
36
+ expose?: boolean;
37
+ /** Error if encrypted values cannot be decrypted (default: false) */
38
+ strict?: boolean;
31
39
  }): EnvRecord;
32
40
 
33
41
  /** Load .env.local overrides if the file exists */
package/src/utils-env.js CHANGED
@@ -1,5 +1,23 @@
1
1
  import { decrypt, isEncrypted } from "./utils-crypto.js";
2
2
 
3
+ /**
4
+ * Walk all string values in env.variables and env.overrides,
5
+ * applying transform when predicate matches. Mutates env in place.
6
+ * @param {EnvConfig} env
7
+ * @param {(value: string) => boolean} predicate
8
+ * @param {(value: string) => string} transform
9
+ */
10
+ export function walkEnvValues(env, predicate, transform) {
11
+ for (const varValue of Object.values(env.variables || {}))
12
+ for (const [envKey, envVal] of Object.entries(varValue))
13
+ if (predicate(envVal)) varValue[envKey] = transform(envVal);
14
+
15
+ for (const pkgOverrides of Object.values(env.overrides || {}))
16
+ for (const varValue of Object.values(pkgOverrides))
17
+ for (const [envKey, envVal] of Object.entries(varValue))
18
+ if (predicate(envVal)) varValue[envKey] = transform(envVal);
19
+ }
20
+
3
21
  /**
4
22
  * Environment-specific values for a single variable.
5
23
  * Keys are environment names (e.g. 'dev', 'production') or '@@' for the fallback.