@saasak/tool-env 1.4.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,24 +14,27 @@ import {
14
14
  verifyCanDecrypt,
15
15
  getOutputName,
16
16
  load,
17
+ findMonorepoRoot,
17
18
  } from "../src/core.js";
18
19
 
19
20
  const args = minimist(process.argv.slice(2), {
20
21
  "--": true,
21
22
  boolean: ["strict", "expose", "help", "h"],
23
+ string: ["format", "package"],
22
24
  });
23
- const __root = process.cwd();
25
+ const __cwd = process.cwd();
26
+ const envPath = args.env || ".env.json";
27
+ const __root = findMonorepoRoot(__cwd, envPath) || __cwd;
24
28
 
25
29
  let command = args._[0] || "help";
26
30
  if (args.help || args.h) command = "help";
27
31
 
28
32
  const secret = resolveSecret(args.secret ? `file://${args.secret}` : '');
29
33
 
30
- const envPath = args.env || ".env.json";
31
34
  const envFile = path.resolve(__root, envPath);
32
35
 
33
36
  // Check env file exists for commands that need it
34
- const commandsRequiringEnvFile = ["write", "show", "encrypt", "besafe", "add"];
37
+ const commandsRequiringEnvFile = ["write", "show", "encrypt", "decrypt", "besafe", "add", "get"];
35
38
  if (commandsRequiringEnvFile.includes(command) && !fs.existsSync(envFile)) {
36
39
  console.error("No env file found.");
37
40
  process.exit(1);
@@ -46,102 +49,113 @@ if (args.strict && commandsRequiringEnvFile.includes(command)) {
46
49
  }
47
50
  }
48
51
 
49
- async function showCommand() {
50
- if (!secret) {
51
- console.log(
52
- "Secret not provided. Use --secret flag or WRENV_SECRET env variable.",
53
- );
54
- }
55
-
52
+ function loadWorkspace() {
56
53
  const target = resolveTarget(args.target);
57
-
58
- const envContent = fs.readFileSync(envFile, "utf8");
59
- const env = JSON.parse(envContent);
60
-
61
- const rootPkgPath = path.resolve(__root, "package.json");
62
- const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
63
- const rootPkg = JSON.parse(rootPkgContent);
64
-
54
+ const env = JSON.parse(fs.readFileSync(envFile, "utf8"));
55
+ const rootPkg = JSON.parse(
56
+ fs.readFileSync(path.resolve(__root, "package.json"), "utf8"),
57
+ );
65
58
  const scope = rootPkg.name.split("/")[0];
66
59
  const [_, ...foundPackages] = findMonorepoPackages(__root, scope);
67
-
68
60
  const deps = [
69
61
  ...foundPackages.filter(Boolean).map((pkg) => ({
70
62
  name: pkg.name.replace(`${scope}/`, ""),
71
63
  dir: pkg.dir,
72
64
  })),
73
- {
74
- name: "root",
75
- dir: __root,
76
- },
65
+ { name: "root", dir: __root },
77
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
+ }
78
72
 
79
- 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
+ }
80
83
 
81
- const allowedEnvs =
82
- env && env.envs && Array.isArray(env.envs) ? env.envs : ["dev"];
83
- const targetEnv = allowedEnvs.find((env) => env === target);
84
- if (!targetEnv) {
84
+ function validateTarget(target, allowedEnvs) {
85
+ if (!allowedEnvs.includes(target)) {
85
86
  console.error(`Target env "${target}" is not allowed.`);
86
87
  process.exit(1);
87
88
  }
89
+ return target;
90
+ }
88
91
 
89
- 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
+ }
90
101
 
91
- for await (const dep of deps) {
102
+ function printKeyValue(allEnvs, deps, targetEnv) {
103
+ for (const dep of deps) {
92
104
  const pkgEnv = Object.entries(allEnvs[dep.name] || {})
93
105
  .map(([key, value]) => `${key}=${value}`)
94
106
  .join("\n");
95
- console.log("~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
107
+ console.log("# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
96
108
  console.log(
97
- `=== ENV for ${dep.name} in ${dep.dir} with target ${targetEnv}`,
109
+ `# ===> ${dep.name} : in ${dep.dir} with target ${targetEnv}`,
98
110
  );
111
+ console.log("# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
99
112
  console.log(pkgEnv);
100
113
  }
101
114
  }
102
115
 
103
- async function writeCommand() {
116
+ async function showCommand() {
104
117
  if (!secret) {
105
- console.log(
118
+ console.error(
106
119
  "Secret not provided. Use --secret flag or WRENV_SECRET env variable.",
107
120
  );
108
121
  }
109
122
 
110
- const target = resolveTarget(args.target);
123
+ const { env, deps, depsName, allowedEnvs, target } = loadWorkspace();
124
+ const targetEnv = validateTarget(target, allowedEnvs);
111
125
 
112
- const envContent = fs.readFileSync(envFile, "utf8");
113
- 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
+ }
114
134
 
115
- const rootPkgPath = path.resolve(__root, "package.json");
116
- const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
117
- const rootPkg = JSON.parse(rootPkgContent);
118
- const scope = rootPkg.name.split("/")[0];
135
+ const allEnvs = buildEnv(secret, targetEnv, env, depsName);
119
136
 
120
- const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
121
- const deps = [
122
- ...foundPackages.map((pkg) => ({
123
- name: pkg.name.replace(`${scope}/`, ""),
124
- dir: pkg.dir,
125
- })),
126
- {
127
- name: "root",
128
- dir: __root,
129
- },
130
- ];
131
- 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
+ }
132
143
 
133
- const allowedEnvs =
134
- 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();
135
152
 
136
153
  // Handle 'all' target: write all environments with suffixed filenames
137
154
  const isAllTarget = target === "all";
138
155
  const targetEnvs = isAllTarget ? allowedEnvs : [target];
139
156
 
140
157
  for (const targetEnv of targetEnvs) {
141
- if (!allowedEnvs.includes(targetEnv)) {
142
- console.error(`Target env "${targetEnv}" is not allowed.`);
143
- process.exit(1);
144
- }
158
+ validateTarget(targetEnv, allowedEnvs);
145
159
 
146
160
  const allEnvs = buildEnv(secret, targetEnv, env, depsName);
147
161
 
@@ -160,8 +174,7 @@ async function writeCommand() {
160
174
  }
161
175
 
162
176
  async function encryptCommand() {
163
- const envContent = fs.readFileSync(envFile, "utf8");
164
- const env = JSON.parse(envContent);
177
+ const env = JSON.parse(fs.readFileSync(envFile, "utf8"));
165
178
 
166
179
  if (!secret) {
167
180
  console.error(
@@ -185,31 +198,65 @@ async function encryptCommand() {
185
198
  process.exit(1);
186
199
  }
187
200
 
188
- if (env.variables) {
189
- for (const [varName, varValue] of Object.entries(env.variables)) {
190
- for (const [envKey, envVal] of Object.entries(varValue)) {
191
- if (typeof envVal === "string" && !isEncrypted(envVal)) {
192
- env.variables[varName][envKey] = encrypt(envVal, secret);
193
- }
194
- }
195
- }
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;
196
218
  }
197
219
 
198
- if (env.overrides) {
199
- for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
200
- for (const [varName, varValue] of Object.entries(pkgOverrides)) {
201
- for (const [envKey, envVal] of Object.entries(varValue)) {
202
- if (typeof envVal === "string" && !isEncrypted(envVal)) {
203
- env.overrides[pkgName][varName][envKey] = encrypt(envVal, secret);
204
- }
205
- }
206
- }
207
- }
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);
208
226
  }
209
227
 
228
+ walkEnvValues(env, isEncrypted, (v) => decrypt(v, secret));
229
+
210
230
  await fs.writeFile(envFile, JSON.stringify(env, null, 2));
211
231
  }
212
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
+
213
260
  async function besafeCommand() {
214
261
  // Find git root to ensure we're in a repository and use correct base path
215
262
  const { stdout: gitRoot, exitCode: gitCheck } = await execa(
@@ -346,10 +393,16 @@ function helpCommand() {
346
393
  console.log(
347
394
  " encrypt Encrypt all unencrypted variables in .env.json",
348
395
  );
396
+ console.log(
397
+ " decrypt Decrypt all encrypted variables in .env.json",
398
+ );
349
399
  console.log(" besafe Encrypt and stage .env.json if it has changes");
350
400
  console.log(
351
401
  " add <var> Add a new variable with environment-specific values",
352
402
  );
403
+ console.log(
404
+ " get <var> Get the value of a specific variable",
405
+ );
353
406
  console.log(" help Show this help message\n");
354
407
  console.log("Options:");
355
408
  console.log(
@@ -362,7 +415,13 @@ function helpCommand() {
362
415
  " --env <path> Path to env file (default: .env.json)",
363
416
  );
364
417
  console.log(
365
- " --strict Error if encrypted values cannot be decrypted\n",
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",
366
425
  );
367
426
  console.log("Environment Variables:");
368
427
  console.log(" WRENV_SECRET Secret for encryption/decryption");
@@ -373,6 +432,8 @@ function helpCommand() {
373
432
  console.log(" wrenv --secret ~/.big-secret write --target dev");
374
433
  console.log(" wrenv --secret stdin write < ~/.big-secret");
375
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");
376
437
  console.log(" wrenv run --target prod -- node server.js");
377
438
  console.log(" wrenv run -- npm test --coverage");
378
439
  console.log(
@@ -388,7 +449,9 @@ const commands = {
388
449
  show: showCommand,
389
450
  besafe: besafeCommand,
390
451
  encrypt: encryptCommand,
452
+ decrypt: decryptCommand,
391
453
  add: addCommand,
454
+ get: getCommand,
392
455
  run: runCommand,
393
456
  help: helpCommand,
394
457
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saasak/tool-env",
3
3
  "license": "MIT",
4
- "version": "1.4.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
@@ -265,7 +265,7 @@ export function loadLocalOverrides(cwd = process.cwd()) {
265
265
  * @param {string} envPath - Name of the env file (e.g. '.env.json')
266
266
  * @returns {string|null} - Path to monorepo root, or null if not found
267
267
  */
268
- function findMonorepoRoot(cwd, envPath) {
268
+ export function findMonorepoRoot(cwd, envPath) {
269
269
  let currentDir = cwd;
270
270
 
271
271
  while (currentDir !== path.dirname(currentDir)) {
@@ -338,7 +338,7 @@ export function loadEnvJson(options = {}) {
338
338
  const rootPkg = JSON.parse(rootPkgContent);
339
339
  scope = rootPkg.name.split("/")[0];
340
340
 
341
- const foundPackages = findMonorepoPackages(monorepoRoot, scope);
341
+ const [, ...foundPackages] = findMonorepoPackages(monorepoRoot, scope);
342
342
  depsName = [
343
343
  ...foundPackages.map((pkg) => pkg.name.replace(`${scope}/`, "")),
344
344
  "root",
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.