morphix-env 0.7.1 → 0.9.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/dist/index.js ADDED
@@ -0,0 +1,732 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ CompositionError: () => CompositionError,
34
+ StreamingRedactor: () => StreamingRedactor,
35
+ composeEnvironment: () => composeEnvironment,
36
+ loadConfigLayers: () => loadConfigLayers,
37
+ validateConfig: () => validateConfig
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/composition.ts
42
+ var import_os2 = require("os");
43
+
44
+ // src/config.ts
45
+ var import_fs = require("fs");
46
+ var import_path = require("path");
47
+ var import_os = require("os");
48
+ var CONFIG_FILE = "mx-env.config.json";
49
+ var GLOBAL_CONFIG_DIR = ".mx-env";
50
+ var GLOBAL_CONFIG_FILE = "config.json";
51
+ var GLOBAL_ENV_FILE = ".env";
52
+ function safeParseConfig(path) {
53
+ try {
54
+ const value = JSON.parse((0, import_fs.readFileSync)(path, "utf8"));
55
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
56
+ } catch (e) {
57
+ console.warn(`[morphix-env] Failed to parse configuration at ${path}: ${e?.name || "invalid JSON"}`);
58
+ return {};
59
+ }
60
+ }
61
+ function loadConfigAt(path) {
62
+ const absolutePath = (0, import_path.resolve)(path);
63
+ if (!(0, import_fs.existsSync)(absolutePath)) {
64
+ return { config: {}, path: absolutePath, baseDir: (0, import_path.dirname)(absolutePath), exists: false };
65
+ }
66
+ return {
67
+ config: safeParseConfig(absolutePath),
68
+ path: absolutePath,
69
+ baseDir: (0, import_path.dirname)(absolutePath),
70
+ exists: true
71
+ };
72
+ }
73
+ function loadConfigLayers(options = {}) {
74
+ const cwd = options.cwd || process.cwd();
75
+ const homeDir = options.homeDir || (0, import_os.homedir)();
76
+ const globalDir = (0, import_path.resolve)(homeDir, GLOBAL_CONFIG_DIR);
77
+ return {
78
+ global: loadConfigAt((0, import_path.resolve)(globalDir, GLOBAL_CONFIG_FILE)),
79
+ project: loadConfigAt((0, import_path.resolve)(cwd, CONFIG_FILE)),
80
+ globalEnvFile: (0, import_path.resolve)(globalDir, GLOBAL_ENV_FILE)
81
+ };
82
+ }
83
+ function normalizeProfile(input) {
84
+ if (Array.isArray(input)) return { sources: input };
85
+ return {
86
+ sources: Array.isArray(input?.sources) ? input.sources : [],
87
+ ...input?.extends ? { extends: input.extends } : {}
88
+ };
89
+ }
90
+ function expandHomePath(path, homeDir = (0, import_os.homedir)()) {
91
+ if (path === "~") return homeDir;
92
+ if (path.startsWith("~/")) return (0, import_path.resolve)(homeDir, path.slice(2));
93
+ return path;
94
+ }
95
+ function validateConfig(config, label = CONFIG_FILE) {
96
+ const errors = [];
97
+ const sources = config.sources || {};
98
+ for (const [name, source] of Object.entries(sources)) {
99
+ if (!name.trim()) errors.push(`${label}: source names cannot be empty`);
100
+ if (!source || typeof source !== "object" || !("provider" in source)) {
101
+ errors.push(`${label}: source "${name}" must declare a provider`);
102
+ continue;
103
+ }
104
+ switch (source.provider) {
105
+ case "infisical":
106
+ if (!Array.isArray(source.paths) || source.paths.length === 0) errors.push(`${label}: Infisical source "${name}" needs paths`);
107
+ if (source.optional) errors.push(`${label}: remote source "${name}" cannot be optional`);
108
+ break;
109
+ case "local":
110
+ case "dotenvx":
111
+ if (!Array.isArray(source.files) || source.files.length === 0) errors.push(`${label}: ${source.provider} source "${name}" needs files`);
112
+ break;
113
+ case "os-keychain":
114
+ if (!source.service) errors.push(`${label}: os-keychain source "${name}" needs service`);
115
+ if (source.optional) errors.push(`${label}: remote source "${name}" cannot be optional`);
116
+ break;
117
+ case "doppler":
118
+ if (!source.project || !source.config) errors.push(`${label}: Doppler source "${name}" needs project and config`);
119
+ if (source.optional) errors.push(`${label}: remote source "${name}" cannot be optional`);
120
+ break;
121
+ case "plugin":
122
+ if (typeof source.plugin !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source.plugin)) {
123
+ errors.push(`${label}: plugin source "${name}" needs a plugin name (letters, digits, ".", "_", "-")`);
124
+ }
125
+ if (!Array.isArray(source.keys) || !source.keys.length || source.keys.some((key) => typeof key !== "string" || !key)) {
126
+ errors.push(`${label}: plugin source "${name}" needs at least one key`);
127
+ }
128
+ break;
129
+ default:
130
+ errors.push(`${label}: source "${name}" has an unsupported provider`);
131
+ }
132
+ }
133
+ for (const [name, profileInput] of Object.entries(config.profiles || {})) {
134
+ const profile = normalizeProfile(profileInput);
135
+ if (!profile.sources.length) errors.push(`${label}: profile "${name}" needs sources`);
136
+ for (const sourceName of profile.sources) {
137
+ if (typeof sourceName !== "string" || !sourceName) errors.push(`${label}: profile "${name}" has an invalid source reference`);
138
+ }
139
+ }
140
+ return errors;
141
+ }
142
+
143
+ // src/sources.ts
144
+ var import_child_process2 = require("child_process");
145
+ var import_fs4 = require("fs");
146
+ var import_path4 = require("path");
147
+ var import_dotenv3 = require("dotenv");
148
+
149
+ // src/env.ts
150
+ var import_fs2 = require("fs");
151
+ var import_path2 = require("path");
152
+ var import_dotenv = require("dotenv");
153
+ function parseEnvFile(filePath) {
154
+ const absPath = (0, import_path2.resolve)(filePath);
155
+ if (!(0, import_fs2.existsSync)(absPath)) return {};
156
+ return (0, import_dotenv.parse)((0, import_fs2.readFileSync)(absPath, "utf8"));
157
+ }
158
+
159
+ // src/infisical.ts
160
+ var import_sdk = require("@infisical/sdk");
161
+ var import_child_process = require("child_process");
162
+ var import_fs3 = require("fs");
163
+ var import_path3 = require("path");
164
+ var import_dotenv2 = require("dotenv");
165
+ function applyPrefix(key, prefix) {
166
+ if (!prefix) return key;
167
+ return key.startsWith(prefix) ? key : prefix + key;
168
+ }
169
+ async function resolveInfisicalSecrets(config, envPrefix) {
170
+ const client = new import_sdk.InfisicalSDK({
171
+ ...config.siteUrl ? { siteUrl: config.siteUrl } : {}
172
+ });
173
+ await client.auth().universalAuth.login({
174
+ clientId: config.clientId,
175
+ clientSecret: config.clientSecret
176
+ });
177
+ const values = {};
178
+ for (const secretPath of config.paths) {
179
+ const result = await client.secrets().listSecrets({
180
+ environment: config.environment,
181
+ projectId: config.projectId,
182
+ secretPath,
183
+ expandSecretReferences: true,
184
+ viewSecretValue: true
185
+ });
186
+ for (const secret of result.secrets) {
187
+ const key = applyPrefix(secret.secretKey, envPrefix);
188
+ values[key] = secret.secretValue;
189
+ }
190
+ }
191
+ return { values, count: Object.keys(values).length };
192
+ }
193
+ function hasInfisicalCLI() {
194
+ try {
195
+ (0, import_child_process.execSync)("infisical --version", { stdio: "pipe" });
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+ function isInfisicalLoggedIn() {
202
+ try {
203
+ (0, import_child_process.execSync)("infisical user", { stdio: "pipe" });
204
+ return true;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+ function readInfisicalJson() {
210
+ const filePath = (0, import_path3.resolve)(".infisical.json");
211
+ if (!(0, import_fs3.existsSync)(filePath)) return {};
212
+ try {
213
+ return JSON.parse((0, import_fs3.readFileSync)(filePath, "utf8"));
214
+ } catch {
215
+ return {};
216
+ }
217
+ }
218
+ function fetchSecretsViaCLI(environment, paths, envPrefix) {
219
+ const values = {};
220
+ const errors = [];
221
+ for (const secretPath of paths) {
222
+ try {
223
+ const output = (0, import_child_process.execFileSync)(
224
+ "infisical",
225
+ ["export", `--path=${secretPath}`, `--env=${environment}`, "--format=dotenv"],
226
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
227
+ );
228
+ const vars = (0, import_dotenv2.parse)(output);
229
+ for (const [key, value] of Object.entries(vars)) {
230
+ values[applyPrefix(key, envPrefix)] = value;
231
+ }
232
+ } catch (e) {
233
+ const stderr = String(e?.stderr || e?.message || "");
234
+ const lower = stderr.toLowerCase();
235
+ let kind = "other";
236
+ if (lower.includes("login expired") || lower.includes("no login session") || lower.includes("not logged in") || lower.includes("please login") || lower.includes("user is not logged")) {
237
+ kind = "not-logged-in";
238
+ } else if (lower.includes("workspaceid") || lower.includes("project id") || lower.includes("infisical.json")) {
239
+ kind = "no-project";
240
+ }
241
+ errors.push({ kind, path: secretPath, stderr });
242
+ }
243
+ }
244
+ return { values, count: Object.keys(values).length, errors };
245
+ }
246
+
247
+ // src/sources.ts
248
+ var SourceError = class extends Error {
249
+ constructor(sourceName, provider, message) {
250
+ super(message);
251
+ this.sourceName = sourceName;
252
+ this.provider = provider;
253
+ this.name = "SourceError";
254
+ }
255
+ };
256
+ function resolveSourcePath(path, entry, homeDir) {
257
+ const expanded = expandHomePath(path, homeDir);
258
+ return (0, import_path4.isAbsolute)(expanded) ? expanded : (0, import_path4.resolve)(entry.baseDir, expanded);
259
+ }
260
+ function isPublicKey(key) {
261
+ return key.startsWith("NEXT_PUBLIC_") || key.startsWith("VITE_") || key.startsWith("EXPO_PUBLIC_");
262
+ }
263
+ function assertSafeGlobalFile(file, context, entry) {
264
+ if (!entry.globalImplicit || context.allowInsecureGlobal || process.platform === "win32") return;
265
+ const mode = (0, import_fs4.statSync)(file).mode;
266
+ if ((mode & 63) !== 0) {
267
+ throw new SourceError(
268
+ entry.name,
269
+ "local",
270
+ `[morphix-env] Global env file ${file} is readable by group or other users; set mode 0600 or pass --allow-insecure-global.`
271
+ );
272
+ }
273
+ }
274
+ function resolveLocal(entry, source, context) {
275
+ const values = {};
276
+ let found = false;
277
+ for (const rawFile of source.files) {
278
+ const file = resolveSourcePath(rawFile, entry, context.homeDir);
279
+ if (!(0, import_fs4.existsSync)(file)) continue;
280
+ found = true;
281
+ assertSafeGlobalFile(file, context, entry);
282
+ Object.assign(values, parseEnvFile(file));
283
+ }
284
+ if (!found) {
285
+ if (source.optional) {
286
+ return { name: entry.name, provider: source.provider, values: {}, availability: "missing-optional" };
287
+ }
288
+ throw new SourceError(entry.name, source.provider, `[morphix-env] Local source "${entry.name}" has no available env file.`);
289
+ }
290
+ return { name: entry.name, provider: source.provider, values, availability: "loaded" };
291
+ }
292
+ async function resolveInfisical(entry, source, context) {
293
+ if (context.noInfisical) {
294
+ return { name: entry.name, provider: source.provider, values: {}, availability: "skipped" };
295
+ }
296
+ const environment = source.env || context.envName || process.env.DEPLOY_ENV || process.env.INFISICAL_ENV || "prod";
297
+ const projectId = source.projectId || process.env.INFISICAL_PROJECT_ID || readInfisicalJson().workspaceId || "";
298
+ const clientId = process.env.INFISICAL_CLIENT_ID || "";
299
+ const clientSecret = process.env.INFISICAL_CLIENT_SECRET || "";
300
+ if (clientId && clientSecret && projectId) {
301
+ try {
302
+ const result2 = await resolveInfisicalSecrets({
303
+ clientId,
304
+ clientSecret,
305
+ projectId,
306
+ environment,
307
+ paths: source.paths,
308
+ siteUrl: source.siteUrl
309
+ }, source.envPrefix);
310
+ return { name: entry.name, provider: source.provider, values: result2.values, availability: "loaded" };
311
+ } catch {
312
+ throw new SourceError(entry.name, source.provider, `[morphix-env] Infisical source "${entry.name}" could not authenticate or load.`);
313
+ }
314
+ }
315
+ if (!hasInfisicalCLI()) {
316
+ if (entry.legacy) return { name: entry.name, provider: source.provider, values: {}, availability: "skipped" };
317
+ throw new SourceError(entry.name, source.provider, `[morphix-env] Infisical source "${entry.name}" needs Machine Identity credentials or an installed Infisical CLI.`);
318
+ }
319
+ if (!isInfisicalLoggedIn()) {
320
+ if (entry.legacy) return { name: entry.name, provider: source.provider, values: {}, availability: "skipped" };
321
+ throw new SourceError(entry.name, source.provider, `[morphix-env] Infisical source "${entry.name}" is not logged in; run infisical login.`);
322
+ }
323
+ const result = fetchSecretsViaCLI(environment, source.paths, source.envPrefix);
324
+ const firstError = result.errors[0];
325
+ if (firstError) {
326
+ if (entry.legacy) return { name: entry.name, provider: source.provider, values: {}, availability: "skipped" };
327
+ throw new SourceError(entry.name, source.provider, `[morphix-env] Infisical source "${entry.name}" could not load (${firstError.kind}).`);
328
+ }
329
+ return { name: entry.name, provider: source.provider, values: result.values, availability: "loaded" };
330
+ }
331
+ function ensureCommand(command, sourceName, provider) {
332
+ const localBinary = (0, import_path4.resolve)(process.cwd(), "node_modules", ".bin", command);
333
+ try {
334
+ if ((0, import_fs4.existsSync)(localBinary)) {
335
+ (0, import_child_process2.execFileSync)(localBinary, ["--version"], { stdio: ["ignore", "ignore", "ignore"] });
336
+ return localBinary;
337
+ }
338
+ (0, import_child_process2.execFileSync)(command, ["--version"], { stdio: ["ignore", "ignore", "ignore"] });
339
+ return command;
340
+ } catch {
341
+ try {
342
+ if ((0, import_fs4.existsSync)(localBinary)) {
343
+ (0, import_child_process2.execFileSync)(localBinary, ["--version"], { stdio: ["ignore", "ignore", "ignore"] });
344
+ return localBinary;
345
+ }
346
+ } catch {
347
+ }
348
+ throw new SourceError(sourceName, provider, `[morphix-env] ${provider} source "${sourceName}" requires the ${command} CLI.`);
349
+ }
350
+ }
351
+ function resolveDotenvx(entry, source, context) {
352
+ const files = source.files.map((file) => resolveSourcePath(file, entry, context.homeDir));
353
+ const existingFiles = files.filter(import_fs4.existsSync);
354
+ if (!existingFiles.length) {
355
+ if (source.optional) return { name: entry.name, provider: source.provider, values: {}, availability: "missing-optional" };
356
+ throw new SourceError(entry.name, source.provider, `[morphix-env] dotenvx source "${entry.name}" has no available env file.`);
357
+ }
358
+ if (existingFiles.length !== files.length) {
359
+ throw new SourceError(entry.name, source.provider, `[morphix-env] dotenvx source "${entry.name}" is missing a declared env file.`);
360
+ }
361
+ const command = ensureCommand("dotenvx", entry.name, source.provider);
362
+ const keys = [...new Set(existingFiles.flatMap((file) => Object.keys(parseEnvFile(file))))].filter((key) => !key.startsWith("DOTENV_PUBLIC_KEY"));
363
+ if (!keys.length) return { name: entry.name, provider: source.provider, values: {}, availability: "loaded" };
364
+ const helper = "const keys=JSON.parse(process.argv[1]);const output={};for(const key of keys){if(process.env[key]!==undefined)output[key]=process.env[key]}process.stdout.write(JSON.stringify(output))";
365
+ let stage = "run";
366
+ try {
367
+ const output = (0, import_child_process2.execFileSync)(
368
+ command,
369
+ ["run", "-f", existingFiles.join(","), "--", process.execPath, "-e", helper, JSON.stringify(keys)],
370
+ {
371
+ encoding: "utf8",
372
+ stdio: ["ignore", "pipe", "pipe"],
373
+ env: {
374
+ ...process.env,
375
+ ...context.environment,
376
+ // dotenvx's executable is a node script; direct API callers may
377
+ // provide an intentionally small environment, so retain a path to
378
+ // the current Node runtime for its shebang.
379
+ PATH: [(0, import_path4.dirname)(process.execPath), context.environment.PATH || process.env.PATH || ""].filter(Boolean).join(import_path4.delimiter)
380
+ },
381
+ // dotenvx locates .env.keys relative to its working directory.
382
+ cwd: (0, import_path4.dirname)(existingFiles[0])
383
+ }
384
+ );
385
+ stage = "parse-output";
386
+ const json = output.trim().split("\n").reverse().find((line) => line.trim().startsWith("{")) || "";
387
+ const parsed = JSON.parse(json);
388
+ const values = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string"));
389
+ return { name: entry.name, provider: source.provider, values, availability: "loaded" };
390
+ } catch (error) {
391
+ const category = typeof error?.code === "string" ? error.code : "failed";
392
+ throw new SourceError(entry.name, source.provider, `[morphix-env] dotenvx source "${entry.name}" could not decrypt or load (${stage}:${category}).`);
393
+ }
394
+ }
395
+ var keytarModule;
396
+ async function loadKeytar() {
397
+ if (keytarModule !== void 0) return keytarModule;
398
+ try {
399
+ const imported = await import("keytar");
400
+ keytarModule = imported.default ?? imported;
401
+ } catch {
402
+ keytarModule = null;
403
+ }
404
+ return keytarModule;
405
+ }
406
+ async function resolveKeychain(entry, source) {
407
+ if (source.platform === "macos" && process.platform !== "darwin") {
408
+ throw new SourceError(entry.name, source.provider, `[morphix-env] os-keychain source "${entry.name}" is only available on macOS.`);
409
+ }
410
+ if (process.platform !== "darwin") {
411
+ throw new SourceError(entry.name, source.provider, `[morphix-env] os-keychain source "${entry.name}" is currently supported on macOS only.`);
412
+ }
413
+ const keytar = await loadKeytar();
414
+ if (!keytar) {
415
+ throw new SourceError(
416
+ entry.name,
417
+ source.provider,
418
+ `[morphix-env] os-keychain source "${entry.name}" needs the optional native module "keytar", which is not usable on this machine. Reinstall morphix-env to rebuild it, or remove this source from the profile.`
419
+ );
420
+ }
421
+ try {
422
+ const credentials = await keytar.findCredentials(source.service);
423
+ return {
424
+ name: entry.name,
425
+ provider: source.provider,
426
+ values: Object.fromEntries(credentials.map(({ account, password }) => [account, password])),
427
+ availability: "loaded"
428
+ };
429
+ } catch {
430
+ throw new SourceError(entry.name, source.provider, `[morphix-env] os-keychain source "${entry.name}" could not be accessed.`);
431
+ }
432
+ }
433
+ function resolveDoppler(entry, source, context) {
434
+ const command = ensureCommand("doppler", entry.name, source.provider);
435
+ try {
436
+ const output = (0, import_child_process2.execFileSync)(
437
+ command,
438
+ ["secrets", "download", "--no-file", "--format", "dotenv", "--project", source.project, "--config", source.config],
439
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: context.environment }
440
+ );
441
+ return { name: entry.name, provider: source.provider, values: parseDotenvText(output), availability: "loaded" };
442
+ } catch {
443
+ throw new SourceError(entry.name, source.provider, `[morphix-env] Doppler source "${entry.name}" could not authenticate or load.`);
444
+ }
445
+ }
446
+ function parseDotenvText(text) {
447
+ return (0, import_dotenv3.parse)(text);
448
+ }
449
+ var PLUGIN_TIMEOUT_MS = 3e4;
450
+ function resolvePlugin(entry, source, context) {
451
+ const command = `mx-env-provider-${source.plugin}`;
452
+ const values = {};
453
+ for (const key of source.keys) {
454
+ let output;
455
+ try {
456
+ output = (0, import_child_process2.execFileSync)(command, [key], {
457
+ encoding: "utf8",
458
+ stdio: ["ignore", "pipe", "pipe"],
459
+ env: { ...process.env, ...context.environment },
460
+ timeout: PLUGIN_TIMEOUT_MS
461
+ });
462
+ } catch (error) {
463
+ if (error?.code === "ENOENT") {
464
+ if (source.optional) return { name: entry.name, provider: source.provider, values: {}, availability: "missing-optional" };
465
+ throw new SourceError(entry.name, source.provider, `[morphix-env] plugin source "${entry.name}" needs ${command} on PATH.`);
466
+ }
467
+ const reason = typeof error?.code === "string" ? error.code : `exit ${error?.status ?? "unknown"}`;
468
+ throw new SourceError(entry.name, source.provider, `[morphix-env] plugin source "${entry.name}" failed for key "${key}" (${reason}).`);
469
+ }
470
+ values[key] = output.endsWith("\r\n") ? output.slice(0, -2) : output.endsWith("\n") ? output.slice(0, -1) : output;
471
+ }
472
+ return { name: entry.name, provider: source.provider, values, availability: "loaded" };
473
+ }
474
+ async function loadSource(entry, context) {
475
+ switch (entry.source.provider) {
476
+ case "local":
477
+ return resolveLocal(entry, entry.source, context);
478
+ case "infisical":
479
+ return resolveInfisical(entry, entry.source, context);
480
+ case "dotenvx":
481
+ return resolveDotenvx(entry, entry.source, context);
482
+ case "os-keychain":
483
+ return resolveKeychain(entry, entry.source);
484
+ case "doppler":
485
+ return resolveDoppler(entry, entry.source, context);
486
+ case "plugin":
487
+ return resolvePlugin(entry, entry.source, context);
488
+ }
489
+ }
490
+
491
+ // src/composition.ts
492
+ var DEFAULT_ENV_FILE = ".env.local";
493
+ var GLOBAL_DOTENV_SOURCE = "__mx_global_dotenv";
494
+ var LEGACY_INFISICAL_SOURCE = "__mx_legacy_infisical";
495
+ var LEGACY_LOCAL_SOURCE = "__mx_legacy_local";
496
+ var CompositionError = class extends Error {
497
+ constructor(message) {
498
+ super(message);
499
+ this.name = "CompositionError";
500
+ }
501
+ };
502
+ function collectRedactionValues(environment, provenance) {
503
+ return [...new Set(Object.entries(provenance).filter(([key]) => !isPublicKey(key) && Boolean(environment[key])).map(([key]) => environment[key]).filter(Boolean))];
504
+ }
505
+ function declaredEntries(loaded) {
506
+ return new Map(Object.entries(loaded.config.sources || {}).map(([name, source]) => [name, {
507
+ name,
508
+ source,
509
+ baseDir: loaded.baseDir
510
+ }]));
511
+ }
512
+ function profileEntries(profileName, profiles, sources, visiting = /* @__PURE__ */ new Set()) {
513
+ if (visiting.has(profileName)) {
514
+ throw new CompositionError(`[morphix-env] Profile "${profileName}" has a circular extends chain.`);
515
+ }
516
+ const input = profiles.get(profileName);
517
+ if (!input) throw new CompositionError(`[morphix-env] Unknown profile "${profileName}".`);
518
+ visiting.add(profileName);
519
+ const profile = normalizeProfile(input);
520
+ const resolved = profile.extends ? profileEntries(profile.extends, profiles, sources, visiting) : [];
521
+ for (const sourceName of profile.sources) {
522
+ const source = sources.get(sourceName);
523
+ if (!source) throw new CompositionError(`[morphix-env] Profile "${profileName}" references unknown source "${sourceName}".`);
524
+ resolved.push(source);
525
+ }
526
+ visiting.delete(profileName);
527
+ return resolved;
528
+ }
529
+ function legacyEntries(project, envFilesOverride) {
530
+ const infisical = project.config.infisical;
531
+ const entries = [];
532
+ if (infisical) {
533
+ const infisicalSource = {
534
+ provider: "infisical",
535
+ paths: infisical.paths,
536
+ // The legacy project source sits above global baseline sources.
537
+ override: true,
538
+ ...infisical.projectId ? { projectId: infisical.projectId } : {},
539
+ ...infisical.siteUrl ? { siteUrl: infisical.siteUrl } : {},
540
+ ...infisical.envPrefix ? { envPrefix: infisical.envPrefix } : {}
541
+ };
542
+ entries.push({
543
+ name: LEGACY_INFISICAL_SOURCE,
544
+ source: infisicalSource,
545
+ baseDir: project.baseDir,
546
+ legacy: true
547
+ });
548
+ }
549
+ entries.push({
550
+ name: LEGACY_LOCAL_SOURCE,
551
+ source: {
552
+ provider: "local",
553
+ files: envFilesOverride?.length ? envFilesOverride : project.config.envFiles || [DEFAULT_ENV_FILE],
554
+ optional: true,
555
+ override: true
556
+ },
557
+ baseDir: project.baseDir,
558
+ legacy: true
559
+ });
560
+ return entries;
561
+ }
562
+ function configProblems(layers, includeGlobal) {
563
+ return [
564
+ ...includeGlobal ? validateConfig(layers.global.config, layers.global.path) : [],
565
+ ...validateConfig(layers.project.config, layers.project.path)
566
+ ];
567
+ }
568
+ function resolveEntries(layers, options) {
569
+ const includeGlobal = !options.noGlobal;
570
+ const errors = configProblems(layers, includeGlobal);
571
+ if (errors.length) throw new CompositionError(`[morphix-env] Invalid configuration: ${errors.join("; ")}`);
572
+ const globalSources = includeGlobal ? declaredEntries(layers.global) : /* @__PURE__ */ new Map();
573
+ const projectSources = declaredEntries(layers.project);
574
+ const sources = new Map([...globalSources, ...projectSources]);
575
+ const profiles = new Map([
576
+ ...includeGlobal ? Object.entries(layers.global.config.profiles || {}) : [],
577
+ ...Object.entries(layers.project.config.profiles || {})
578
+ ]);
579
+ const globalDotenv = includeGlobal ? [{
580
+ name: GLOBAL_DOTENV_SOURCE,
581
+ source: { provider: "local", files: [layers.globalEnvFile], optional: true },
582
+ baseDir: layers.global.baseDir,
583
+ globalImplicit: true
584
+ }] : [];
585
+ const explicitProfile = options.profile || null;
586
+ const projectDefault = layers.project.config.defaultProfile;
587
+ const globalDefault = includeGlobal ? layers.global.config.defaultProfile : void 0;
588
+ const legacy = legacyEntries(layers.project, options.envFiles);
589
+ const cliLocalOverride = options.envFiles?.length ? [{
590
+ name: "__mx_cli_local_override",
591
+ source: { provider: "local", files: options.envFiles, optional: true, override: true },
592
+ baseDir: layers.project.baseDir
593
+ }] : [];
594
+ if (explicitProfile) {
595
+ return { name: explicitProfile, entries: [...globalDotenv, ...profileEntries(explicitProfile, profiles, sources), ...cliLocalOverride] };
596
+ }
597
+ if (projectDefault) {
598
+ return { name: projectDefault, entries: [...globalDotenv, ...profileEntries(projectDefault, profiles, sources), ...cliLocalOverride] };
599
+ }
600
+ if (globalDefault) {
601
+ const baseline = profileEntries(globalDefault, profiles, sources);
602
+ return {
603
+ name: `${globalDefault}+legacy`,
604
+ entries: [...globalDotenv, ...baseline, ...legacy]
605
+ };
606
+ }
607
+ return { name: "legacy", entries: [...globalDotenv, ...legacy] };
608
+ }
609
+ async function composeEnvironment(layers, options = {}) {
610
+ const { name: profileName, entries } = resolveEntries(layers, options);
611
+ const environment = { ...options.baseEnvironment || process.env };
612
+ const owner = /* @__PURE__ */ new Map();
613
+ const sourceStatuses = [];
614
+ const homeDir = options.homeDir || (0, import_os2.homedir)();
615
+ for (let precedence = 0; precedence < entries.length; precedence++) {
616
+ const entry = entries[precedence];
617
+ const loaded = await loadSource(entry, {
618
+ homeDir,
619
+ envName: options.envName,
620
+ noInfisical: options.noInfisical,
621
+ allowInsecureGlobal: options.allowInsecureGlobal,
622
+ environment
623
+ });
624
+ sourceStatuses.push({
625
+ name: loaded.name,
626
+ provider: loaded.provider,
627
+ availability: loaded.availability,
628
+ keyCount: Object.keys(loaded.values).length
629
+ });
630
+ for (const [key, value] of Object.entries(loaded.values)) {
631
+ const previous = owner.get(key);
632
+ if (previous && !entry.source.override) {
633
+ throw new CompositionError(
634
+ `[morphix-env] Key "${key}" is provided by both "${previous.source}" and "${entry.name}". Declare override: true on the later source to allow this.`
635
+ );
636
+ }
637
+ environment[key] = value;
638
+ owner.set(key, {
639
+ key,
640
+ source: entry.name,
641
+ provider: entry.source.provider,
642
+ precedence,
643
+ overriddenSources: previous ? [...previous.overriddenSources, previous.source] : []
644
+ });
645
+ }
646
+ }
647
+ const provenance = Object.fromEntries(owner);
648
+ return {
649
+ environment,
650
+ profileName,
651
+ sourceStatuses,
652
+ provenance,
653
+ sourceEntries: entries,
654
+ redactionValues: collectRedactionValues(environment, provenance)
655
+ };
656
+ }
657
+
658
+ // src/redaction.ts
659
+ var StreamingRedactor = class {
660
+ constructor(values) {
661
+ this.pending = "";
662
+ this.values = [...new Set(values.filter(Boolean))].sort((a, b) => b.length - a.length);
663
+ }
664
+ write(chunk) {
665
+ if (!this.values.length) return typeof chunk === "string" ? chunk : chunk.toString("utf8");
666
+ const input = this.pending + (typeof chunk === "string" ? chunk : chunk.toString("utf8"));
667
+ const keep = this.incompletePrefixLength(input);
668
+ const safe = input.slice(0, input.length - keep);
669
+ this.pending = input.slice(input.length - keep);
670
+ return this.redact(safe);
671
+ }
672
+ flush() {
673
+ const output = this.redact(this.pending);
674
+ this.pending = "";
675
+ return output;
676
+ }
677
+ incompletePrefixLength(input) {
678
+ const completeEnd = this.lastCompleteMatchEnd(input);
679
+ const limit = Math.min(input.length, Math.max(...this.values.map((value) => value.length - 1), 0));
680
+ for (let length = limit; length > 0; length--) {
681
+ const suffix = input.slice(-length);
682
+ const start = input.length - length;
683
+ if (start >= completeEnd && this.values.some((value) => value.startsWith(suffix))) return length;
684
+ }
685
+ return 0;
686
+ }
687
+ redact(input) {
688
+ const marks = new Array(input.length).fill(false);
689
+ for (const value of this.values) {
690
+ let from = 0;
691
+ while (from < input.length) {
692
+ const index = input.indexOf(value, from);
693
+ if (index < 0) break;
694
+ for (let i = index; i < index + value.length; i++) marks[i] = true;
695
+ from = index + 1;
696
+ }
697
+ }
698
+ let output = "";
699
+ let inRedaction = false;
700
+ for (let i = 0; i < input.length; i++) {
701
+ if (marks[i]) {
702
+ if (!inRedaction) output += "[REDACTED]";
703
+ inRedaction = true;
704
+ } else {
705
+ output += input[i];
706
+ inRedaction = false;
707
+ }
708
+ }
709
+ return output;
710
+ }
711
+ lastCompleteMatchEnd(input) {
712
+ let lastEnd = 0;
713
+ for (const value of this.values) {
714
+ let from = 0;
715
+ while (from < input.length) {
716
+ const index = input.indexOf(value, from);
717
+ if (index < 0) break;
718
+ lastEnd = Math.max(lastEnd, index + value.length);
719
+ from = index + 1;
720
+ }
721
+ }
722
+ return lastEnd;
723
+ }
724
+ };
725
+ // Annotate the CommonJS export names for ESM import in node:
726
+ 0 && (module.exports = {
727
+ CompositionError,
728
+ StreamingRedactor,
729
+ composeEnvironment,
730
+ loadConfigLayers,
731
+ validateConfig
732
+ });