@saasak/tool-env 1.0.0 → 1.0.2

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,3 +1,5 @@
1
+ #!/usr/bin/env node
2
+
1
3
  import minimist from 'minimist';
2
4
  import fs from 'fs-extra';
3
5
  import path from 'path';
@@ -164,27 +166,30 @@ async function encryptCommand() {
164
166
  }
165
167
 
166
168
  async function besafeCommand() {
167
- const relativeEnvPath = path.relative(__root, envFile);
169
+ // 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 });
171
+ if (gitCheck !== 0) {
172
+ return;
173
+ }
168
174
 
169
- try {
170
- const { stdout: stagedDiff } = await execa('git', ['diff', '--cached', '--name-only', '--', relativeEnvPath], { cwd: __root, reject: false });
171
- const { stdout: unstagedDiff } = await execa('git', ['diff', '--name-only', '--', relativeEnvPath], { cwd: __root, reject: false });
172
- const { stdout: untracked } = await execa('git', ['ls-files', '--others', '--exclude-standard', '--', relativeEnvPath], { cwd: __root, reject: false });
175
+ const relativeEnvPath = path.relative(gitRoot, envFile);
173
176
 
174
- if (!stagedDiff?.trim() && !unstagedDiff?.trim() && !untracked?.trim()) {
175
- return;
176
- }
177
- } catch (error) {
177
+ // 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 });
181
+
182
+ if (!staged?.trim() && !unstaged?.trim() && !untracked?.trim()) {
178
183
  return;
179
184
  }
180
185
 
181
- await encrypt();
186
+ await encryptCommand();
182
187
 
183
188
  if (!secret) {
184
- console.error('Warning: potential security risk: secret not provided. Variables WERE NOT be encrypted.');
189
+ console.error('Warning: potential security risk: secret not provided. Variables WERE NOT encrypted.');
185
190
  }
186
191
 
187
- await execa('git', ['add', relativeEnvPath], { cwd: __root });
192
+ await execa('git', ['add', relativeEnvPath], { cwd: gitRoot });
188
193
  }
189
194
 
190
195
  async function addCommand() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saasak/tool-env",
3
3
  "license": "MIT",
4
- "version": "1.0.0",
4
+ "version": "1.0.2",
5
5
  "author": "dev@saasak.studio",
6
6
  "description": "A small util to manage environment variables for your monorepo",
7
7
  "keywords": [
@@ -12,7 +12,8 @@
12
12
  "type": "module",
13
13
  "main": "bin/index.js",
14
14
  "files": [
15
- "bin"
15
+ "bin",
16
+ "src"
16
17
  ],
17
18
  "bin": {
18
19
  "wrenv": "bin/index.js"
@@ -0,0 +1,90 @@
1
+ import crypto from 'crypto';
2
+
3
+ // Constants for AES-256-GCM encryption
4
+ // GCM provides authenticated encryption (confidentiality + integrity)
5
+ // Using 256-bit keys for strong security
6
+ const ENCRYPTION_PREFIX = '$enc$';
7
+ const ALGORITHM = 'aes-256-gcm';
8
+ const IV_LENGTH = 16; // 128 bits
9
+ const SALT_LENGTH = 64; // 512 bits
10
+ const TAG_LENGTH = 16; // 128 bits
11
+ const KEY_LENGTH = 32; // 256 bits
12
+ const ITERATIONS = 100000; // PBKDF2 iterations
13
+
14
+ /**
15
+ * Derive a 256-bit key from a secret using PBKDF2
16
+ * This ensures consistent key generation from variable-length secrets
17
+ * and adds computational cost to prevent brute-force attacks
18
+ */
19
+ function deriveKey(secret, salt) {
20
+ return crypto.pbkdf2Sync(secret, salt, ITERATIONS, KEY_LENGTH, 'sha256');
21
+ }
22
+
23
+ /**
24
+ * Encrypt a string value using AES-256-GCM
25
+ * Returns a string with format: $enc$<base64(salt+iv+ciphertext+tag)>
26
+ * Each encryption uses a unique salt and IV, so the same plaintext produces different ciphertexts
27
+ */
28
+ export function encrypt(plaintext, secret) {
29
+ if (!plaintext) return plaintext;
30
+
31
+ const salt = crypto.randomBytes(SALT_LENGTH);
32
+ const iv = crypto.randomBytes(IV_LENGTH);
33
+ const key = deriveKey(secret, salt);
34
+
35
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
36
+
37
+ let encrypted = cipher.update(plaintext, 'utf8');
38
+ encrypted = Buffer.concat([encrypted, cipher.final()]);
39
+
40
+ const tag = cipher.getAuthTag();
41
+
42
+ // Combine: salt + iv + encrypted + tag, then base64 encode
43
+ const combined = Buffer.concat([salt, iv, encrypted, tag]);
44
+ const encoded = combined.toString('base64');
45
+
46
+ return `${ENCRYPTION_PREFIX}${encoded}`;
47
+ }
48
+
49
+ /**
50
+ * Decrypt an encrypted string
51
+ * Strips the prefix and decodes the base64 payload
52
+ * GCM authentication tag ensures the data hasn't been tampered with
53
+ */
54
+ export function decrypt(encrypted, secret) {
55
+ if (!encrypted || !isEncrypted(encrypted)) {
56
+ return encrypted;
57
+ }
58
+
59
+ // Remove prefix and decode
60
+ const encoded = encrypted.slice(ENCRYPTION_PREFIX.length);
61
+ const combined = Buffer.from(encoded, 'base64');
62
+
63
+ // Extract components
64
+ const salt = combined.slice(0, SALT_LENGTH);
65
+ const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
66
+ const tag = combined.slice(-TAG_LENGTH);
67
+ const encryptedData = combined.slice(
68
+ SALT_LENGTH + IV_LENGTH,
69
+ -TAG_LENGTH
70
+ );
71
+
72
+ const key = deriveKey(secret, salt);
73
+
74
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
75
+ decipher.setAuthTag(tag);
76
+
77
+ let decrypted = decipher.update(encryptedData);
78
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
79
+
80
+ return decrypted.toString('utf8');
81
+ }
82
+
83
+ /**
84
+ * Check if a string is encrypted by looking for the prefix
85
+ * This allows the system to detect encrypted values without attempting decryption
86
+ */
87
+ export function isEncrypted(value) {
88
+ return typeof value === 'string' && value.startsWith(ENCRYPTION_PREFIX);
89
+ }
90
+
@@ -0,0 +1,58 @@
1
+ import { decrypt, isEncrypted } from './utils-crypto.js';
2
+
3
+ // ========================================
4
+ // Helpers for core logic
5
+ // ========================================
6
+
7
+ export function buildEnv(secret, target, env, names) {
8
+ const variablesForAllTargets = env.variables;
9
+ const overrides = env.overrides || {};
10
+
11
+ // For each variable, extract the value for the target env
12
+ const variables = Object.entries(variablesForAllTargets).reduce((acc, entry) => {
13
+ const [key, value] = entry;
14
+ return {
15
+ ...acc,
16
+ [key]: extractValue(secret, value[target] || value['@@'] || '')
17
+ };
18
+ }, {});
19
+
20
+ // For each package, get all variables
21
+ // compute the overrides and merge them
22
+ // allVars is an object with the package name as key
23
+ // and the variables as value like so
24
+ // => { [name]: { [variable]: value } }
25
+ const allVars = names.reduce((acc, name) => {
26
+ acc[name] = {
27
+ ...variables,
28
+ ...parseOverrides(secret, target, overrides[name], variables)
29
+ };
30
+
31
+ return acc;
32
+ }, {});
33
+
34
+ return allVars;
35
+ }
36
+
37
+ function parseOverrides(secret, target, overrides, vars) {
38
+ if (!secret) return {};
39
+ if (!overrides) return {};
40
+
41
+ return Object.entries(overrides).reduce((acc, entry) => {
42
+ const [key, val] = entry;
43
+ const value = extractValue(secret, val[target] || val['@@'] || '');
44
+
45
+ if (value === null) return acc;
46
+
47
+ acc[key] = Array.isArray(value)
48
+ ? value.map(v => vars[v] || v).join('')
49
+ : value
50
+ return acc;
51
+ }, {});
52
+ }
53
+
54
+ function extractValue(secret, value) {
55
+ if (!isEncrypted(value)) return value;
56
+ if (!secret) return '';
57
+ return decrypt(value, secret);
58
+ }
@@ -0,0 +1,68 @@
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+
4
+ export async function findMonorepoPackages(rootDir, scope = null, maxDepth = 4) {
5
+ const packages = [];
6
+
7
+ async function processPackage(pkgPath, pkgDir) {
8
+ try {
9
+ const pkgContent = await fs.readFile(pkgPath, 'utf8');
10
+ const pkg = JSON.parse(pkgContent);
11
+
12
+ if (scope && !pkg.name?.startsWith(`${scope}/`)) {
13
+ return null;
14
+ }
15
+
16
+ return {
17
+ name: pkg.name,
18
+ dir: pkgDir,
19
+ };
20
+ } catch (err) {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ async function traverse(dir, currentDepth = 0) {
26
+ if (currentDepth > maxDepth) return;
27
+
28
+ try {
29
+ const entries = await fs.readdir(dir, { withFileTypes: true });
30
+
31
+ for (const entry of entries) {
32
+ const fullPath = path.join(dir, entry.name);
33
+
34
+ if (entry.name === 'node_modules' || entry.isSymbolicLink()) {
35
+ continue;
36
+ }
37
+
38
+ if (!entry.isDirectory()) continue;
39
+
40
+ const pkgPath = path.join(fullPath, 'package.json');
41
+ const exists = await fs.pathExists(pkgPath);
42
+
43
+ if (exists) {
44
+ const pkg = await processPackage(pkgPath, fullPath);
45
+ packages.push(pkg);
46
+ }
47
+
48
+ await traverse(fullPath, currentDepth + 1);
49
+ }
50
+ } catch (err) {
51
+ return;
52
+ }
53
+ }
54
+
55
+ const rootPkgPath = path.join(rootDir, 'package.json');
56
+ const exists = await fs.pathExists(rootPkgPath);
57
+
58
+ if (!exists) return [];
59
+
60
+ const pkg = await processPackage(rootPkgPath, rootDir);
61
+
62
+ if (!pkg) return [];
63
+
64
+ packages.push(pkg);
65
+ await traverse(rootDir, 0);
66
+
67
+ return packages;
68
+ }