@saasak/tool-env 1.0.0 → 1.0.1

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/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.1",
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
+ }