@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 +25 -4
- package/bin/index.js +218 -103
- package/package.json +10 -2
- package/src/core.js +491 -0
- package/src/index.d.ts +82 -0
- package/src/index.js +42 -0
- package/src/utils-crypto.js +25 -6
- package/src/utils-env.js +74 -17
- package/src/utils-pkg.js +72 -55
package/src/core.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { decrypt, isEncrypted } from "./utils-crypto.js";
|
|
4
|
+
import { buildEnv } from "./utils-env.js";
|
|
5
|
+
import { findMonorepoPackages } from "./utils-pkg.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object.<string, string>} EnvRecord
|
|
9
|
+
* Key-value pairs of environment variable name to value
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} LoadOptions
|
|
14
|
+
* @property {string} [targetEnv] - Target environment (e.g. 'dev', 'production')
|
|
15
|
+
* @property {string} [secret] - Secret for decryption (or file://path to read from file)
|
|
16
|
+
* @property {string} [envPath] - Path to env.json file (default: '.env.json')
|
|
17
|
+
* @property {string} [cwd] - Working directory (default: process.cwd())
|
|
18
|
+
* @property {boolean} [applyToProcess] - Whether to apply to process.env (default: true)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} LoadEnvJsonOptions
|
|
23
|
+
* @property {string} [secret] - Secret for decryption (or file://path)
|
|
24
|
+
* @property {string} [targetEnv] - Target environment
|
|
25
|
+
* @property {string} [envPath] - Path to env.json file (default: '.env.json')
|
|
26
|
+
* @property {string} [cwd] - Working directory (default: process.cwd())
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} VerifyResult
|
|
31
|
+
* @property {boolean} success - Whether verification passed
|
|
32
|
+
* @property {string|null} [path] - Path to the failed encrypted value
|
|
33
|
+
* @property {string} [error] - Error message if verification failed
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** @type {string} */
|
|
37
|
+
const DEFAULT_ENV = "dev";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Environment variables that must never be injected by .env.json content.
|
|
41
|
+
* These control wrenv's own behavior and must not be overridden.
|
|
42
|
+
* @type {Set<string>}
|
|
43
|
+
*/
|
|
44
|
+
export const RESERVED_ENV_VARS = new Set([
|
|
45
|
+
"WRENV_TARGET",
|
|
46
|
+
"WRENV_SECRET",
|
|
47
|
+
"TARGET_ENV",
|
|
48
|
+
"TARGET_SECRET",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Mapping of environment name aliases to canonical names
|
|
53
|
+
* Note: 'all' is a special target that writes all environments (write command only)
|
|
54
|
+
* @type {Object.<string, string>}
|
|
55
|
+
*/
|
|
56
|
+
const targets = {
|
|
57
|
+
development: "dev",
|
|
58
|
+
dev: "dev",
|
|
59
|
+
staging: "preprod",
|
|
60
|
+
pp: "preprod",
|
|
61
|
+
preprod: "preprod",
|
|
62
|
+
prod: "production",
|
|
63
|
+
production: "production",
|
|
64
|
+
all: "all",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Mapping of internal environment names to conventional output file names
|
|
69
|
+
* Used when writing .env files with 'all' target
|
|
70
|
+
* @type {Object.<string, string>}
|
|
71
|
+
*/
|
|
72
|
+
const outputNames = {
|
|
73
|
+
dev: "development",
|
|
74
|
+
preprod: "staging",
|
|
75
|
+
production: "production",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the conventional output file name for an internal environment name
|
|
80
|
+
* Falls back to the internal name if no mapping exists
|
|
81
|
+
* @param {string} internalName - Internal environment name (e.g. 'dev', 'preprod')
|
|
82
|
+
* @returns {string} - Conventional output name (e.g. 'development', 'staging')
|
|
83
|
+
*/
|
|
84
|
+
export function getOutputName(internalName) {
|
|
85
|
+
return outputNames[internalName] || internalName;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve target environment with fallback chain:
|
|
90
|
+
* param -> WRENV_TARGET -> TARGET_ENV -> NODE_ENV (mapped) -> 'dev'
|
|
91
|
+
* @param {string} [targetParam] - Explicit target environment parameter
|
|
92
|
+
* @returns {string} - Resolved target environment
|
|
93
|
+
*/
|
|
94
|
+
export function resolveTarget(targetParam) {
|
|
95
|
+
if (targetParam) {
|
|
96
|
+
return targets[targetParam] || targetParam;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const envTarget = process.env.WRENV_TARGET || process.env.TARGET_ENV;
|
|
100
|
+
if (envTarget) {
|
|
101
|
+
return targets[envTarget] || envTarget;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const nodeEnv = process.env.NODE_ENV;
|
|
105
|
+
if (nodeEnv && targets[nodeEnv]) {
|
|
106
|
+
return targets[nodeEnv];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return DEFAULT_ENV;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve target environment aliases:
|
|
114
|
+
* param -> allowedEnvs
|
|
115
|
+
* @param {string[]} [allowedEnvs] - 4rray of allowed environments to resolve against (e.g. ['dev', 'preprod', 'production'])
|
|
116
|
+
* @returns {string[]} - Resolved target sliases environment
|
|
117
|
+
*/
|
|
118
|
+
export function resolveTargetAliases(allowedEnvs) {
|
|
119
|
+
const allowedAliases = allowedEnvs.flatMap((env) =>
|
|
120
|
+
Object.entries(targets)
|
|
121
|
+
.find(([_, val]) => val === env)
|
|
122
|
+
.map(([alias]) => alias),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return Array.from(new Set(allowedAliases));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve secret with unified fallback chain:
|
|
130
|
+
* param (stdin | file:// | file path | literal) -> WRENV_SECRET -> TARGET_SECRET -> null
|
|
131
|
+
*
|
|
132
|
+
* If reading from stdin or a file:// path fails, falls back to env vars.
|
|
133
|
+
* A bare string that is not a readable file is treated as a literal secret.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} [secretParam] - Secret source: "stdin", "file://path", file path, or literal string
|
|
136
|
+
* @returns {string} - Resolved secret or empty string if not provided
|
|
137
|
+
*/
|
|
138
|
+
export function resolveSecret(secretParam) {
|
|
139
|
+
if (secretParam) {
|
|
140
|
+
if (secretParam === "stdin") {
|
|
141
|
+
try {
|
|
142
|
+
const value = fs.readFileSync(0, "utf8").trim();
|
|
143
|
+
if (value) return value;
|
|
144
|
+
} catch (_) {
|
|
145
|
+
// fall through to env vars
|
|
146
|
+
}
|
|
147
|
+
} else if (secretParam.startsWith("file://")) {
|
|
148
|
+
try {
|
|
149
|
+
const value = fs.readFileSync(secretParam.slice(7), "utf8").trim();
|
|
150
|
+
if (value) return value;
|
|
151
|
+
} catch (_) {
|
|
152
|
+
// fall through to env vars
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
try {
|
|
156
|
+
return fs.readFileSync(secretParam, "utf8").trim();
|
|
157
|
+
} catch (_) {
|
|
158
|
+
// Not a readable file path — treat as literal secret string
|
|
159
|
+
return secretParam;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return process.env.WRENV_SECRET || process.env.TARGET_SECRET || "";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse .env file content into key-value pairs
|
|
169
|
+
* Supports standard KEY=value format with basic quote handling
|
|
170
|
+
* @param {string} content - Raw .env file content
|
|
171
|
+
* @returns {EnvRecord} - Parsed key-value pairs
|
|
172
|
+
*/
|
|
173
|
+
export function parseEnvFile(content) {
|
|
174
|
+
/** @type {EnvRecord} */
|
|
175
|
+
const result = {};
|
|
176
|
+
const lines = content.split("\n");
|
|
177
|
+
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
const trimmed = line.trim();
|
|
180
|
+
|
|
181
|
+
// Skip empty lines and comments
|
|
182
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Find the first = sign
|
|
187
|
+
const eqIndex = trimmed.indexOf("=");
|
|
188
|
+
if (eqIndex === -1) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
193
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
194
|
+
|
|
195
|
+
// Remove surrounding quotes if present
|
|
196
|
+
if (
|
|
197
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
198
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
199
|
+
) {
|
|
200
|
+
value = value.slice(1, -1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (key) {
|
|
204
|
+
result[key] = value;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Find the nearest package.json traversing upward from cwd and return its name (without scope)
|
|
213
|
+
* @param {string} cwd - Starting directory
|
|
214
|
+
* @param {string} scope - Package scope (e.g. '@saasak')
|
|
215
|
+
* @returns {string} - Package name without scope, or 'root' as fallback
|
|
216
|
+
*/
|
|
217
|
+
function findNearestPackageName(cwd, scope) {
|
|
218
|
+
let currentDir = cwd;
|
|
219
|
+
|
|
220
|
+
while (currentDir !== path.dirname(currentDir)) {
|
|
221
|
+
// Stop at filesystem root
|
|
222
|
+
const pkgPath = path.join(currentDir, "package.json");
|
|
223
|
+
if (fs.existsSync(pkgPath)) {
|
|
224
|
+
try {
|
|
225
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
226
|
+
if (pkg.name) {
|
|
227
|
+
// Return name without scope prefix
|
|
228
|
+
return pkg.name.replace(`${scope}/`, "");
|
|
229
|
+
}
|
|
230
|
+
} catch (e) {
|
|
231
|
+
// Continue traversing upward
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
currentDir = path.dirname(currentDir);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return "root"; // fallback
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Load .env.local file if it exists
|
|
242
|
+
* @param {string} [cwd] - Working directory (default: process.cwd())
|
|
243
|
+
* @returns {EnvRecord} - Local overrides or empty object
|
|
244
|
+
*/
|
|
245
|
+
export function loadLocalOverrides(cwd = process.cwd()) {
|
|
246
|
+
const localEnvPath = path.join(cwd, ".env.local");
|
|
247
|
+
|
|
248
|
+
if (!fs.existsSync(localEnvPath)) {
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const content = fs.readFileSync(localEnvPath, "utf8");
|
|
254
|
+
return parseEnvFile(content);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Find the monorepo root by looking for the env.json file, traversing upward from cwd
|
|
262
|
+
* @param {string} cwd - Starting directory
|
|
263
|
+
* @param {string} envPath - Name of the env file (e.g. '.env.json')
|
|
264
|
+
* @returns {string|null} - Path to monorepo root, or null if not found
|
|
265
|
+
*/
|
|
266
|
+
function findMonorepoRoot(cwd, envPath) {
|
|
267
|
+
let currentDir = cwd;
|
|
268
|
+
|
|
269
|
+
while (currentDir !== path.dirname(currentDir)) {
|
|
270
|
+
const envFilePath = path.join(currentDir, envPath);
|
|
271
|
+
if (fs.existsSync(envFilePath)) {
|
|
272
|
+
return currentDir;
|
|
273
|
+
}
|
|
274
|
+
currentDir = path.dirname(currentDir);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Load environment configuration from env.json file
|
|
282
|
+
* Automatically detects the current package and returns its specific env vars
|
|
283
|
+
* @param {LoadEnvJsonOptions} [options] - Options object
|
|
284
|
+
* @returns {EnvRecord} - Decrypted environment variables for the current package
|
|
285
|
+
* @throws {Error} - If env file not found or target env not allowed
|
|
286
|
+
*/
|
|
287
|
+
export function loadEnvJson(options = {}) {
|
|
288
|
+
const {
|
|
289
|
+
secret: secretParam,
|
|
290
|
+
targetEnv: targetParam,
|
|
291
|
+
envPath = ".env.json",
|
|
292
|
+
cwd = process.cwd(),
|
|
293
|
+
} = options;
|
|
294
|
+
|
|
295
|
+
const secret = resolveSecret(secretParam);
|
|
296
|
+
const targetEnv = resolveTarget(targetParam);
|
|
297
|
+
|
|
298
|
+
// Find the monorepo root (where env.json lives)
|
|
299
|
+
const monorepoRoot = findMonorepoRoot(cwd, envPath);
|
|
300
|
+
if (!monorepoRoot) {
|
|
301
|
+
throw new Error(`No env file found (searched upward from ${cwd})`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const envFile = path.join(monorepoRoot, envPath);
|
|
305
|
+
const envContent = fs.readFileSync(envFile, "utf8");
|
|
306
|
+
const env = JSON.parse(envContent);
|
|
307
|
+
|
|
308
|
+
const allowedEnvs =
|
|
309
|
+
env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
|
|
310
|
+
const resolvedTarget = allowedEnvs.find((e) => e === targetEnv);
|
|
311
|
+
|
|
312
|
+
if (!resolvedTarget) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Target env "${targetEnv}" is not allowed. Allowed: ${allowedEnvs.join(", ")}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Get scope from monorepo root package.json
|
|
319
|
+
const rootPkgPath = path.join(monorepoRoot, "package.json");
|
|
320
|
+
let scope = "";
|
|
321
|
+
/** @type {string[]} */
|
|
322
|
+
let depsName = ["root"];
|
|
323
|
+
|
|
324
|
+
if (fs.existsSync(rootPkgPath)) {
|
|
325
|
+
try {
|
|
326
|
+
const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
|
|
327
|
+
const rootPkg = JSON.parse(rootPkgContent);
|
|
328
|
+
scope = rootPkg.name.split("/")[0];
|
|
329
|
+
|
|
330
|
+
const foundPackages = findMonorepoPackages(monorepoRoot, scope);
|
|
331
|
+
depsName = [
|
|
332
|
+
...foundPackages.map((pkg) => pkg.name.replace(`${scope}/`, "")),
|
|
333
|
+
"root",
|
|
334
|
+
];
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// Not a monorepo or error parsing, use default
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const allEnvs = buildEnv(secret, resolvedTarget, env, depsName);
|
|
341
|
+
|
|
342
|
+
// Find the current package name from nearest package.json
|
|
343
|
+
const currentPackageName = findNearestPackageName(cwd, scope);
|
|
344
|
+
|
|
345
|
+
// Return env for current package (with fallback to root)
|
|
346
|
+
return allEnvs[currentPackageName] || allEnvs.root || {};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Main load function - loads env.json and applies .env.local overrides
|
|
351
|
+
* Applies values to process.env and returns the final config object
|
|
352
|
+
* @param {LoadOptions} [options] - Options object
|
|
353
|
+
* @returns {EnvRecord} - Final environment configuration
|
|
354
|
+
* @throws {Error} - If env file not found or target env not allowed
|
|
355
|
+
* @example
|
|
356
|
+
* // Basic usage - loads from cwd, applies to process.env
|
|
357
|
+
* const config = load();
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* // With options
|
|
361
|
+
* const config = load({
|
|
362
|
+
* targetEnv: 'production',
|
|
363
|
+
* secret: 'file://./secret.txt',
|
|
364
|
+
* applyToProcess: false
|
|
365
|
+
* });
|
|
366
|
+
*/
|
|
367
|
+
export function load(options = {}) {
|
|
368
|
+
const {
|
|
369
|
+
targetEnv,
|
|
370
|
+
secret,
|
|
371
|
+
envPath,
|
|
372
|
+
cwd = process.cwd(),
|
|
373
|
+
applyToProcess = true,
|
|
374
|
+
} = options;
|
|
375
|
+
|
|
376
|
+
// Resolve target first to determine if we're in dev mode
|
|
377
|
+
const resolvedTarget = resolveTarget(targetEnv);
|
|
378
|
+
|
|
379
|
+
// Load env.json config
|
|
380
|
+
const envConfig = loadEnvJson({
|
|
381
|
+
secret,
|
|
382
|
+
targetEnv: resolvedTarget,
|
|
383
|
+
envPath,
|
|
384
|
+
cwd,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Load .env.local overrides only in dev mode (security: prevent local overrides in production)
|
|
388
|
+
const localOverrides =
|
|
389
|
+
resolvedTarget === "dev" ? loadLocalOverrides(cwd) : {};
|
|
390
|
+
|
|
391
|
+
// Merge: env.json values, then .env.local overrides (local takes precedence)
|
|
392
|
+
// Note: .env.local only overrides env.json values, not existing process.env
|
|
393
|
+
/** @type {EnvRecord} */
|
|
394
|
+
const finalConfig = { ...envConfig };
|
|
395
|
+
|
|
396
|
+
for (const [key, value] of Object.entries(localOverrides)) {
|
|
397
|
+
if (Object.prototype.hasOwnProperty.call(envConfig, key)) {
|
|
398
|
+
finalConfig[key] = value;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// - Reserved vars (WRENV_*, TARGET_*) are never injected
|
|
403
|
+
// - NODE_ENV is only injected if not already set
|
|
404
|
+
for (const key of RESERVED_ENV_VARS) {
|
|
405
|
+
delete finalConfig[key];
|
|
406
|
+
}
|
|
407
|
+
if (process.env.NODE_ENV) {
|
|
408
|
+
delete finalConfig.NODE_ENV;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Apply to process.env if requested
|
|
412
|
+
if (applyToProcess) {
|
|
413
|
+
for (const [key, value] of Object.entries(finalConfig)) {
|
|
414
|
+
process.env[key] = value;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return finalConfig;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @typedef {Object} EncryptedValueInfo
|
|
423
|
+
* @property {string} path - Path to the encrypted value (e.g. 'variables.API_KEY.dev')
|
|
424
|
+
* @property {string} value - The encrypted value
|
|
425
|
+
*/
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Verify that all encrypted values in the env can be decrypted with the provided secret
|
|
429
|
+
* This prevents mixed-encryption scenarios where different passwords are used
|
|
430
|
+
* @param {Object} env - The env configuration object (parsed env.json)
|
|
431
|
+
* @param {string} secret - The secret to use for decryption (already resolved, not file:// path)
|
|
432
|
+
* @returns {VerifyResult} - Verification result with success status
|
|
433
|
+
*/
|
|
434
|
+
export function verifyCanDecrypt(env, secret) {
|
|
435
|
+
/** @type {EncryptedValueInfo[]} */
|
|
436
|
+
const encryptedValues = [];
|
|
437
|
+
|
|
438
|
+
if (!secret) {
|
|
439
|
+
return { success: false, path: null, error: "No secret provided" };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Collect all encrypted values from variables
|
|
443
|
+
if (env.variables) {
|
|
444
|
+
for (const [varName, varValue] of Object.entries(env.variables)) {
|
|
445
|
+
for (const [envKey, envVal] of Object.entries(varValue)) {
|
|
446
|
+
if (typeof envVal === "string" && isEncrypted(envVal)) {
|
|
447
|
+
encryptedValues.push({
|
|
448
|
+
path: `variables.${varName}.${envKey}`,
|
|
449
|
+
value: envVal,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Collect all encrypted values from overrides
|
|
457
|
+
if (env.overrides) {
|
|
458
|
+
for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
|
|
459
|
+
for (const [varName, varValue] of Object.entries(pkgOverrides)) {
|
|
460
|
+
for (const [envKey, envVal] of Object.entries(varValue)) {
|
|
461
|
+
if (typeof envVal === "string" && isEncrypted(envVal)) {
|
|
462
|
+
encryptedValues.push({
|
|
463
|
+
path: `overrides.${pkgName}.${varName}.${envKey}`,
|
|
464
|
+
value: envVal,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// If no encrypted values exist, verification passes (first-time encryption)
|
|
473
|
+
if (encryptedValues.length === 0) {
|
|
474
|
+
return { success: true };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Try to decrypt each encrypted value
|
|
478
|
+
for (const { path, value } of encryptedValues) {
|
|
479
|
+
try {
|
|
480
|
+
decrypt(value, secret);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
return {
|
|
483
|
+
success: false,
|
|
484
|
+
path,
|
|
485
|
+
error: error.message,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return { success: true };
|
|
491
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Key-value pairs of environment variable name to value */
|
|
2
|
+
export type EnvRecord = Record<string, string>;
|
|
3
|
+
|
|
4
|
+
export interface LoadOptions {
|
|
5
|
+
/** Target environment (e.g. 'dev', 'preprod', 'production') */
|
|
6
|
+
targetEnv?: string;
|
|
7
|
+
/** Secret for decryption: "stdin", "file://path", file path, or literal string */
|
|
8
|
+
secret?: string;
|
|
9
|
+
/** Path to env.json file (default: '.env.json') */
|
|
10
|
+
envPath?: string;
|
|
11
|
+
/** Working directory (default: process.cwd()) */
|
|
12
|
+
cwd?: string;
|
|
13
|
+
/** Whether to apply resolved vars to process.env (default: true) */
|
|
14
|
+
applyToProcess?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Load environment variables from .env.json with optional .env.local overrides.
|
|
19
|
+
* By default, applies resolved variables to process.env.
|
|
20
|
+
*/
|
|
21
|
+
export function load(options?: LoadOptions): EnvRecord;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load environment configuration from .env.json without .env.local overrides.
|
|
25
|
+
*/
|
|
26
|
+
export function loadEnvJson(options?: {
|
|
27
|
+
secret?: string;
|
|
28
|
+
targetEnv?: string;
|
|
29
|
+
envPath?: string;
|
|
30
|
+
cwd?: string;
|
|
31
|
+
}): EnvRecord;
|
|
32
|
+
|
|
33
|
+
/** Load .env.local overrides if the file exists */
|
|
34
|
+
export function loadLocalOverrides(cwd?: string): EnvRecord;
|
|
35
|
+
|
|
36
|
+
/** Parse .env file content into key-value pairs */
|
|
37
|
+
export function parseEnvFile(content: string): EnvRecord;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve target environment with fallback chain:
|
|
41
|
+
* param -> WRENV_TARGET -> TARGET_ENV -> NODE_ENV (mapped) -> 'dev'
|
|
42
|
+
*/
|
|
43
|
+
export function resolveTarget(targetParam?: string): string;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve secret with fallback chain:
|
|
47
|
+
* param (stdin | file:// | file path | literal) -> WRENV_SECRET -> TARGET_SECRET -> ''
|
|
48
|
+
*/
|
|
49
|
+
export function resolveSecret(secretParam?: string): string;
|
|
50
|
+
|
|
51
|
+
/** Get the conventional output file name for an internal environment name */
|
|
52
|
+
export function getOutputName(internalName: string): string;
|
|
53
|
+
|
|
54
|
+
/** Verify that all encrypted values can be decrypted with the provided secret */
|
|
55
|
+
export function verifyCanDecrypt(env: object, secret: string): {
|
|
56
|
+
success: boolean;
|
|
57
|
+
path?: string | null;
|
|
58
|
+
error?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Encrypt a plaintext value with the given secret */
|
|
62
|
+
export function encrypt(value: string, secret: string): string;
|
|
63
|
+
|
|
64
|
+
/** Decrypt an encrypted value with the given secret */
|
|
65
|
+
export function decrypt(value: string, secret: string): string;
|
|
66
|
+
|
|
67
|
+
/** Check if a value is encrypted */
|
|
68
|
+
export function isEncrypted(value: string): boolean;
|
|
69
|
+
|
|
70
|
+
/** Build environment variables for all packages from an env config */
|
|
71
|
+
export function buildEnv(
|
|
72
|
+
secret: string,
|
|
73
|
+
target: string,
|
|
74
|
+
env: object,
|
|
75
|
+
names: string[],
|
|
76
|
+
): Record<string, EnvRecord>;
|
|
77
|
+
|
|
78
|
+
/** Find all packages in a monorepo */
|
|
79
|
+
export function findMonorepoPackages(
|
|
80
|
+
root: string,
|
|
81
|
+
scope: string,
|
|
82
|
+
): Array<{ name: string; dir: string }>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @saasak/tool-env - Environment variable management for monorepos
|
|
3
|
+
*
|
|
4
|
+
* Main module exports for programmatic usage.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* // Basic usage - load env vars and apply to process.env
|
|
8
|
+
* import { load } from '@saasak/tool-env';
|
|
9
|
+
* const config = load();
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // Load with specific options
|
|
13
|
+
* import { load } from '@saasak/tool-env';
|
|
14
|
+
* const config = load({
|
|
15
|
+
* targetEnv: 'production',
|
|
16
|
+
* secret: 'file://./secret.txt',
|
|
17
|
+
* applyToProcess: false
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* @module @saasak/tool-env
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Core functions for loading and resolving environment configuration
|
|
24
|
+
export {
|
|
25
|
+
load,
|
|
26
|
+
loadEnvJson,
|
|
27
|
+
loadLocalOverrides,
|
|
28
|
+
parseEnvFile,
|
|
29
|
+
resolveTarget,
|
|
30
|
+
resolveSecret,
|
|
31
|
+
verifyCanDecrypt,
|
|
32
|
+
getOutputName
|
|
33
|
+
} from './core.js';
|
|
34
|
+
|
|
35
|
+
// Re-export crypto utilities for encryption/decryption
|
|
36
|
+
export { encrypt, decrypt, isEncrypted } from './utils-crypto.js';
|
|
37
|
+
|
|
38
|
+
// Re-export env building utilities
|
|
39
|
+
export { buildEnv } from './utils-env.js';
|
|
40
|
+
|
|
41
|
+
// Re-export package utilities
|
|
42
|
+
export { findMonorepoPackages } from './utils-pkg.js';
|
package/src/utils-crypto.js
CHANGED
|
@@ -3,18 +3,29 @@ import crypto from 'crypto';
|
|
|
3
3
|
// Constants for AES-256-GCM encryption
|
|
4
4
|
// GCM provides authenticated encryption (confidentiality + integrity)
|
|
5
5
|
// Using 256-bit keys for strong security
|
|
6
|
+
|
|
7
|
+
/** @type {string} */
|
|
6
8
|
const ENCRYPTION_PREFIX = '$enc$';
|
|
9
|
+
/** @type {string} */
|
|
7
10
|
const ALGORITHM = 'aes-256-gcm';
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
11
|
+
/** @type {number} 128 bits */
|
|
12
|
+
const IV_LENGTH = 16;
|
|
13
|
+
/** @type {number} 512 bits */
|
|
14
|
+
const SALT_LENGTH = 64;
|
|
15
|
+
/** @type {number} 128 bits */
|
|
16
|
+
const TAG_LENGTH = 16;
|
|
17
|
+
/** @type {number} 256 bits */
|
|
18
|
+
const KEY_LENGTH = 32;
|
|
19
|
+
/** @type {number} PBKDF2 iterations */
|
|
20
|
+
const ITERATIONS = 100000;
|
|
13
21
|
|
|
14
22
|
/**
|
|
15
23
|
* Derive a 256-bit key from a secret using PBKDF2
|
|
16
24
|
* This ensures consistent key generation from variable-length secrets
|
|
17
25
|
* and adds computational cost to prevent brute-force attacks
|
|
26
|
+
* @param {string} secret - The secret passphrase
|
|
27
|
+
* @param {Buffer} salt - Random salt for key derivation
|
|
28
|
+
* @returns {Buffer} - Derived 256-bit key
|
|
18
29
|
*/
|
|
19
30
|
function deriveKey(secret, salt) {
|
|
20
31
|
return crypto.pbkdf2Sync(secret, salt, ITERATIONS, KEY_LENGTH, 'sha256');
|
|
@@ -24,6 +35,9 @@ function deriveKey(secret, salt) {
|
|
|
24
35
|
* Encrypt a string value using AES-256-GCM
|
|
25
36
|
* Returns a string with format: $enc$<base64(salt+iv+ciphertext+tag)>
|
|
26
37
|
* Each encryption uses a unique salt and IV, so the same plaintext produces different ciphertexts
|
|
38
|
+
* @param {string} plaintext - The string to encrypt
|
|
39
|
+
* @param {string} secret - The secret passphrase for encryption
|
|
40
|
+
* @returns {string} - Encrypted string with $enc$ prefix, or original value if empty
|
|
27
41
|
*/
|
|
28
42
|
export function encrypt(plaintext, secret) {
|
|
29
43
|
if (!plaintext) return plaintext;
|
|
@@ -50,6 +64,10 @@ export function encrypt(plaintext, secret) {
|
|
|
50
64
|
* Decrypt an encrypted string
|
|
51
65
|
* Strips the prefix and decodes the base64 payload
|
|
52
66
|
* GCM authentication tag ensures the data hasn't been tampered with
|
|
67
|
+
* @param {string} encrypted - The encrypted string (with $enc$ prefix)
|
|
68
|
+
* @param {string} secret - The secret passphrase for decryption
|
|
69
|
+
* @returns {string} - Decrypted plaintext, or original value if not encrypted
|
|
70
|
+
* @throws {Error} - If decryption fails (wrong secret or corrupted data)
|
|
53
71
|
*/
|
|
54
72
|
export function decrypt(encrypted, secret) {
|
|
55
73
|
if (!encrypted || !isEncrypted(encrypted)) {
|
|
@@ -83,8 +101,9 @@ export function decrypt(encrypted, secret) {
|
|
|
83
101
|
/**
|
|
84
102
|
* Check if a string is encrypted by looking for the prefix
|
|
85
103
|
* This allows the system to detect encrypted values without attempting decryption
|
|
104
|
+
* @param {unknown} value - The value to check
|
|
105
|
+
* @returns {boolean} - True if the value is an encrypted string
|
|
86
106
|
*/
|
|
87
107
|
export function isEncrypted(value) {
|
|
88
108
|
return typeof value === 'string' && value.startsWith(ENCRYPTION_PREFIX);
|
|
89
109
|
}
|
|
90
|
-
|