@arki/env 0.0.11 → 0.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/README.md +231 -62
  3. package/dist/api.json +597 -0
  4. package/dist/core/define-env.d.ts +29 -4
  5. package/dist/core/define-env.d.ts.map +1 -1
  6. package/dist/core/define-env.js +85 -15
  7. package/dist/core/define-env.js.map +1 -1
  8. package/dist/core/describe.d.ts +29 -0
  9. package/dist/core/describe.d.ts.map +1 -0
  10. package/dist/core/describe.js +25 -0
  11. package/dist/core/describe.js.map +1 -0
  12. package/dist/core/errors.d.ts +10 -0
  13. package/dist/core/errors.d.ts.map +1 -0
  14. package/dist/core/errors.js +13 -0
  15. package/dist/core/errors.js.map +1 -0
  16. package/dist/core/index.d.ts +7 -2
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +6 -2
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/secret.d.ts +56 -0
  21. package/dist/core/secret.d.ts.map +1 -0
  22. package/dist/core/secret.js +85 -0
  23. package/dist/core/secret.js.map +1 -0
  24. package/dist/errors.json +128 -0
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/node/codec.d.ts +63 -0
  30. package/dist/node/codec.d.ts.map +1 -0
  31. package/dist/node/codec.js +182 -0
  32. package/dist/node/codec.js.map +1 -0
  33. package/dist/node/edit.d.ts +112 -0
  34. package/dist/node/edit.d.ts.map +1 -0
  35. package/dist/node/edit.js +268 -0
  36. package/dist/node/edit.js.map +1 -0
  37. package/dist/node/errors.d.ts +119 -0
  38. package/dist/node/errors.d.ts.map +1 -0
  39. package/dist/node/errors.js +144 -0
  40. package/dist/node/errors.js.map +1 -0
  41. package/dist/node/files.d.ts +9 -0
  42. package/dist/node/files.d.ts.map +1 -0
  43. package/dist/node/files.js +35 -0
  44. package/dist/node/files.js.map +1 -0
  45. package/dist/node/index.d.ts +20 -0
  46. package/dist/node/index.d.ts.map +1 -0
  47. package/dist/node/index.js +13 -0
  48. package/dist/node/index.js.map +1 -0
  49. package/dist/node/key-schemas.d.ts +25 -0
  50. package/dist/node/key-schemas.d.ts.map +1 -0
  51. package/dist/node/key-schemas.js +38 -0
  52. package/dist/node/key-schemas.js.map +1 -0
  53. package/dist/node/keys.d.ts +54 -0
  54. package/dist/node/keys.d.ts.map +1 -0
  55. package/dist/node/keys.js +76 -0
  56. package/dist/node/keys.js.map +1 -0
  57. package/dist/node/load.d.ts +103 -0
  58. package/dist/node/load.d.ts.map +1 -0
  59. package/dist/node/load.js +306 -0
  60. package/dist/node/load.js.map +1 -0
  61. package/dist/node/operation-telemetry.d.ts +3 -0
  62. package/dist/node/operation-telemetry.d.ts.map +1 -0
  63. package/dist/node/operation-telemetry.js +53 -0
  64. package/dist/node/operation-telemetry.js.map +1 -0
  65. package/dist/node/references.d.ts +16 -0
  66. package/dist/node/references.d.ts.map +1 -0
  67. package/dist/node/references.js +72 -0
  68. package/dist/node/references.js.map +1 -0
  69. package/dist/node/register.d.ts +2 -0
  70. package/dist/node/register.d.ts.map +1 -0
  71. package/dist/node/register.js +19 -0
  72. package/dist/node/register.js.map +1 -0
  73. package/dist/{contracts → presets}/common.d.ts +1 -1
  74. package/dist/presets/common.d.ts.map +1 -0
  75. package/dist/{contracts → presets}/common.js +4 -8
  76. package/dist/presets/common.js.map +1 -0
  77. package/dist/{contracts → presets}/index.d.ts +5 -5
  78. package/dist/presets/index.d.ts.map +1 -0
  79. package/dist/presets/index.js +15 -0
  80. package/dist/presets/index.js.map +1 -0
  81. package/dist/{contracts → presets}/parsing.d.ts +2 -2
  82. package/dist/presets/parsing.d.ts.map +1 -0
  83. package/dist/{contracts → presets}/parsing.js +13 -5
  84. package/dist/presets/parsing.js.map +1 -0
  85. package/dist/{contracts → presets}/urls.d.ts +1 -1
  86. package/dist/presets/urls.d.ts.map +1 -0
  87. package/dist/{contracts → presets}/urls.js +1 -1
  88. package/dist/presets/urls.js.map +1 -0
  89. package/docs/agent-guide.md +121 -0
  90. package/docs/upgrades/0.0.11-to-0.1.0.md +62 -0
  91. package/docs/upgrades/0.1.0-to-0.2.0.md +73 -0
  92. package/docs/upgrades/typed-node-outcomes.md +13 -0
  93. package/llms.txt +30 -0
  94. package/package.json +27 -20
  95. package/src/core/define-env.ts +101 -20
  96. package/src/core/describe.ts +52 -0
  97. package/src/core/errors.ts +14 -0
  98. package/src/core/index.ts +7 -2
  99. package/src/core/secret.ts +127 -0
  100. package/src/index.ts +3 -2
  101. package/src/node/codec.ts +220 -0
  102. package/src/node/edit.ts +279 -0
  103. package/src/node/errors.ts +147 -0
  104. package/src/node/files.ts +41 -0
  105. package/src/node/index.ts +48 -0
  106. package/src/node/key-schemas.ts +44 -0
  107. package/src/node/keys.ts +93 -0
  108. package/src/node/load.ts +386 -0
  109. package/src/node/operation-telemetry.ts +56 -0
  110. package/src/node/references.ts +90 -0
  111. package/src/node/register.ts +18 -0
  112. package/src/{contracts → presets}/common.ts +4 -8
  113. package/src/presets/index.ts +18 -0
  114. package/src/{contracts → presets}/parsing.ts +12 -5
  115. package/src/{contracts → presets}/urls.ts +1 -1
  116. package/dist/contracts/common.d.ts.map +0 -1
  117. package/dist/contracts/common.js.map +0 -1
  118. package/dist/contracts/index.d.ts.map +0 -1
  119. package/dist/contracts/index.js +0 -14
  120. package/dist/contracts/index.js.map +0 -1
  121. package/dist/contracts/parsing.d.ts.map +0 -1
  122. package/dist/contracts/parsing.js.map +0 -1
  123. package/dist/contracts/urls.d.ts.map +0 -1
  124. package/dist/contracts/urls.js.map +0 -1
  125. package/dist/dot.d.ts +0 -67
  126. package/dist/dot.d.ts.map +0 -1
  127. package/dist/dot.js +0 -65
  128. package/dist/dot.js.map +0 -1
  129. package/src/contracts/index.ts +0 -17
  130. package/src/dot.ts +0 -94
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Encrypted env files in the dotenvx 2.x format, for Node, Bun and Deno: load them into the
3
+ * environment (`loadEnvFiles`), edit them (`initEnvFile`, `setEnvFileValue`, ...), and the codec
4
+ * underneath. The Dot CLI's `dot env` commands are built on these.
5
+ */
6
+ export {
7
+ decryptValue,
8
+ ENCRYPTED_PREFIX,
9
+ encryptValue,
10
+ envHeader,
11
+ envValues,
12
+ generateKeypair,
13
+ isEncrypted,
14
+ isKeyName,
15
+ keyNamesFor,
16
+ parseEnv,
17
+ publicKeyOf,
18
+ readEnvValue,
19
+ removeEnvValue,
20
+ setEnvValue,
21
+ } from './codec.js';
22
+ export type { EnvDocument, EnvEntry, EnvKeypair } from './codec.js';
23
+ export { decryptEnvFile, encryptEnvFile, initEnvFile, publicKeyIn, rotateEnvFiles, setEnvFileValue } from './edit.js';
24
+ export type { InitResult, RotateResult } from './edit.js';
25
+ export { findPrivateKey, KEYS_FILE, keysFileCandidates, writePrivateKey } from './keys.js';
26
+ export type { FoundPrivateKey, PrivateKeySource } from './keys.js';
27
+ export { envDirs, ENV_FILES_ERROR_CODES, findRoot, formatEnvFilesErrors, loadEnvFiles, secretValues } from './load.js';
28
+ export type { EnvFilesError, EnvFilesReport, EnvFileValue, EnvValueKind, LoadEnvFilesOptions } from './load.js';
29
+ export { DEFAULT_RESOLVERS, referenceScheme } from './references.js';
30
+ export type { ReferenceResolver } from './references.js';
31
+ export type { ReferenceOptions } from './references.js';
32
+ export {
33
+ DecryptionFailed,
34
+ EncryptionFailed,
35
+ EnvCancelled,
36
+ EnvDeadline,
37
+ EnvFilesFailed,
38
+ FileReadFailed,
39
+ FileWriteFailed,
40
+ KeyGenerationFailed,
41
+ MissingPrivateKey,
42
+ MissingPublicKey,
43
+ ReferenceFailed,
44
+ RotationIncomplete,
45
+ UnrepresentableValue,
46
+ } from './errors.js';
47
+ export { envPrivateKeySchema, envPublicKeySchema, parsePrivateKey, parsePublicKey } from './key-schemas.js';
48
+ export type { EnvPrivateKey, EnvPublicKey } from './key-schemas.js';
@@ -0,0 +1,44 @@
1
+ import { PrivateKey, PublicKey } from 'eciesjs';
2
+ import { z } from 'zod';
3
+
4
+ import type { StandardSchemaV1 } from '@arki/schema';
5
+ import { is, parse } from '@arki/schema';
6
+
7
+ declare const publicKeyBrand: unique symbol;
8
+ /** A validated secp256k1 public key, distinct from its private key. */
9
+ export type EnvPublicKey = string & { readonly [publicKeyBrand]: true };
10
+ declare const privateKeyBrand: unique symbol;
11
+ /** A validated secp256k1 private key. Never serialize it into diagnostics. */
12
+ export type EnvPrivateKey = string & { readonly [privateKeyBrand]: true };
13
+ const publicHex = z.string().regex(/^(?:0[23][a-f\d]{64}|04[a-f\d]{128})$/i);
14
+ const privateHex = z.string().regex(/^[a-f\d]{64}$/i);
15
+
16
+ /** Standard Schema for an external public key; issue text never echoes the key. */
17
+ export const envPublicKeySchema: StandardSchemaV1<unknown, EnvPublicKey> = z.custom<EnvPublicKey>(value => {
18
+ if (!is(value, publicHex)) return false;
19
+ try {
20
+ PublicKey.fromHex(value, 'secp256k1');
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }, 'Invalid env public key');
26
+ /** Standard Schema for an external private key; issue text never echoes the key. */
27
+ export const envPrivateKeySchema: StandardSchemaV1<unknown, EnvPrivateKey> = z.custom<EnvPrivateKey>(value => {
28
+ if (!is(value, privateHex)) return false;
29
+ try {
30
+ PrivateKey.fromHex(value, 'secp256k1');
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }, 'Invalid env private key');
36
+
37
+ /** Parse an external public key without manufacturing a brand. */
38
+ export function parsePublicKey(value: unknown) {
39
+ return parse(envPublicKeySchema, value);
40
+ }
41
+ /** Parse an external private key without exposing its bytes in issues. */
42
+ export function parsePrivateKey(value: unknown) {
43
+ return parse(envPrivateKeySchema, value);
44
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Where a file's private key comes from: the `DOTENV_PRIVATE_KEY_<ENV>` variable first, then a
3
+ * `.env.keys` file beside the env file, then `.env.keys` at the repository root. The variable may
4
+ * hold several keys separated by commas (dotenvx does the same), so one process can read files
5
+ * encrypted to different keys of the same environment.
6
+ */
7
+ import { existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import { isFailure } from '@arki/failure';
11
+
12
+ import type { EnvPrivateKey } from './key-schemas.js';
13
+ import { keyNamesFor, parseEnv, readEnvValue, setEnvValue } from './codec.js';
14
+ import { readEnvText, writeEnvText } from './files.js';
15
+ import { parsePrivateKey } from './key-schemas.js';
16
+
17
+ /** The git-ignored file that holds private keys, one `DOTENV_PRIVATE_KEY_<ENV>=<hex>` line each. */
18
+ export const KEYS_FILE = '.env.keys';
19
+
20
+ const KEYS_BANNER = [
21
+ '#/------------------!DOTENV_PRIVATE_KEYS!-------------------/',
22
+ '#/ private decryption keys. DO NOT commit to source control /',
23
+ '#/ [how it works](https://dotenvx.com/encryption) /',
24
+ '#/----------------------------------------------------------/',
25
+ ].join('\n');
26
+
27
+ /** Where a private key was found. Never carries the key itself. */
28
+ export type PrivateKeySource =
29
+ | { readonly kind: 'env'; readonly name: string }
30
+ | { readonly kind: 'file'; readonly name: string; readonly path: string };
31
+
32
+ /** The candidate keys for a file, and where they came from. */
33
+ export type FoundPrivateKey = { readonly keys: readonly EnvPrivateKey[]; readonly source: PrivateKeySource };
34
+
35
+ /** The `.env.keys` paths searched for `file`, nearest first. */
36
+ export function keysFileCandidates(file: string, root: string): readonly string[] {
37
+ return [...new Set([path.join(path.dirname(file), KEYS_FILE), path.join(root, KEYS_FILE)])];
38
+ }
39
+
40
+ /** The private key for `file`, or `undefined` when no source has one. */
41
+ export function findPrivateKey(
42
+ file: string,
43
+ options: { readonly root: string; readonly env: Readonly<Record<string, string | undefined>> },
44
+ ) {
45
+ const name = keyNamesFor(path.basename(file)).privateKey;
46
+ const fromEnv = splitKeys(options.env[name]);
47
+ if (isFailure(fromEnv)) return fromEnv;
48
+ if (fromEnv.length > 0) return { keys: fromEnv, source: { kind: 'env', name } } satisfies FoundPrivateKey;
49
+ for (const candidate of keysFileCandidates(file, options.root)) {
50
+ if (!existsSync(candidate)) continue;
51
+ const text = readEnvText(candidate);
52
+ if (isFailure(text)) return text;
53
+ const keys = splitKeys(readEnvValue(parseEnv(text), name));
54
+ if (isFailure(keys)) return keys;
55
+ if (keys.length > 0) return { keys, source: { kind: 'file', name, path: candidate } } satisfies FoundPrivateKey;
56
+ }
57
+ return;
58
+ }
59
+
60
+ /**
61
+ * Store `privateKey` under `name` in a keys file, creating it with the dotenvx banner. An existing
62
+ * entry is replaced. The file is left readable by its owner only.
63
+ */
64
+ export function writePrivateKey(
65
+ keysPath: string,
66
+ name: string,
67
+ privateKey: EnvPrivateKey,
68
+ comment: string,
69
+ options: { readonly preserve?: readonly EnvPrivateKey[] } = {},
70
+ ) {
71
+ const existing = existsSync(keysPath) ? readEnvText(keysPath) : `${KEYS_BANNER}\n`;
72
+ if (isFailure(existing)) return existing;
73
+ const stored = [...new Set([privateKey, ...(options.preserve ?? [])])].join(',');
74
+ const next =
75
+ readEnvValue(parseEnv(existing), name) === undefined
76
+ ? `${existing}${existing.endsWith('\n') ? '' : '\n'}\n# ${comment}\n${name}=${stored}\n`
77
+ : setEnvValue(existing, name, stored);
78
+ if (isFailure(next)) return next;
79
+ return writeEnvText(keysPath, next, true);
80
+ }
81
+
82
+ function splitKeys(value: string | undefined) {
83
+ const keys: EnvPrivateKey[] = [];
84
+ for (const raw of (value ?? '')
85
+ .split(',')
86
+ .map(key => key.trim())
87
+ .filter(key => key !== '')) {
88
+ const key = parsePrivateKey(raw);
89
+ if (isFailure(key)) return key;
90
+ keys.push(key);
91
+ }
92
+ return keys;
93
+ }
@@ -0,0 +1,386 @@
1
+ /**
2
+ * `loadEnvFiles()`: read the env files from the repository root down to the working directory,
3
+ * decrypt and resolve their values, and write the ones the real environment does not set into it.
4
+ *
5
+ * Values go into `process.env` on purpose: package envs (`@arki/db/env` and others) read it, some
6
+ * of them while their module loads, so the files must be loaded before the app's modules are. Run
7
+ * `dot env run -- <cmd>` or import `@arki/env/node/register` first in the entry file.
8
+ */
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { z } from 'zod';
12
+
13
+ import type { FailureOf } from '@arki/failure';
14
+ import { attempt, isFailure } from '@arki/failure';
15
+ import { ensure } from '@arki/schema';
16
+
17
+ import type { EnvPrivateKey } from './key-schemas.js';
18
+ import type { ReferenceResolver } from './references.js';
19
+ import { decryptValue, envValues, isEncrypted, isKeyName, keyNamesFor, parseEnv } from './codec.js';
20
+ import {
21
+ DecryptionFailed,
22
+ ENV_FILES_ERROR_CODES,
23
+ EnvCancelled,
24
+ EnvDeadline,
25
+ EnvFilesFailed,
26
+ FileReadFailed,
27
+ ReferenceFailed,
28
+ } from './errors.js';
29
+ import { readEnvText } from './files.js';
30
+ import { findPrivateKey, keysFileCandidates } from './keys.js';
31
+ import { nodeOperation } from './operation-telemetry.js';
32
+ import { DEFAULT_RESOLVERS, referenceScheme } from './references.js';
33
+
34
+ export { ENV_FILES_ERROR_CODES } from './errors.js';
35
+
36
+ /** How `loadEnvFiles` finds, reads and applies the files. */
37
+ export type LoadEnvFilesOptions = {
38
+ /** Where to stop descending. Default: `process.cwd()`. */
39
+ readonly cwd?: string;
40
+ /** Where to start. Default: the nearest ancestor with `.git`, else with a `workspaces` package.json, else `cwd`. */
41
+ readonly root?: string;
42
+ /** Picks `.env.<environment>`. Default: `NODE_ENV`, else `development`. */
43
+ readonly environment?: string;
44
+ /** File names read in every directory, later wins. Default: `.env.<environment>`, then `.env.local` (not under `test`). */
45
+ readonly files?: readonly string[];
46
+ /** The environment read and written. Default: `process.env`. */
47
+ readonly env?: Record<string, string | undefined>;
48
+ /** `false` reads and decrypts without writing anything. Default: `true`. */
49
+ readonly write?: boolean;
50
+ /** Reference resolvers by scheme. Default: `op`, `infisical`, `doppler`. */
51
+ readonly references?: Readonly<Record<string, ReferenceResolver>>;
52
+ /** Cooperative cancellation between synchronous reads; passed to provider callbacks. */
53
+ readonly signal?: AbortSignal;
54
+ /** Aggregate load budget, shared by all provider subprocesses. Default: 30 seconds. */
55
+ readonly timeoutMs?: number;
56
+ };
57
+
58
+ /** How a value is stored in its file. */
59
+ export type EnvValueKind = 'plain' | 'encrypted' | 'reference';
60
+
61
+ /** The winning file entry of one key. `value` is the plaintext, absent when it could not be read. */
62
+ export type EnvFileValue = {
63
+ readonly key: string;
64
+ readonly file: string;
65
+ readonly line: number;
66
+ readonly kind: EnvValueKind;
67
+ readonly value?: string;
68
+ };
69
+
70
+ /** One problem, by name. Messages never contain a value. */
71
+ export type EnvFilesError = FailureOf<typeof EnvFilesFailed>['data']['errors'][number];
72
+
73
+ /** What a load found and did. Holds plaintext in `values`: never print it whole. */
74
+ export type EnvFilesReport = {
75
+ readonly root: string;
76
+ readonly environment: string;
77
+ /** Files read, in load order. */
78
+ readonly files: readonly string[];
79
+ readonly values: readonly EnvFileValue[];
80
+ /** Keys written into the environment. */
81
+ readonly applied: readonly string[];
82
+ /** Keys the real environment set to a different value, so the file value was not used. */
83
+ readonly shadowed: readonly { readonly key: string; readonly file: string; readonly line: number }[];
84
+ /** File entries never applied: loader-hijacking keys (`NODE_OPTIONS`, `LD_*`, `DYLD_*`) and references into public keys. */
85
+ readonly refused: readonly {
86
+ readonly key: string;
87
+ readonly file: string;
88
+ readonly line: number;
89
+ readonly reason: string;
90
+ }[];
91
+ readonly errors: readonly EnvFilesError[];
92
+ };
93
+
94
+ const loadedSecrets = new Set<string>();
95
+
96
+ /**
97
+ * Keys a file may never set: they make a process started with this environment run other code
98
+ * (PATH, interpreter options, preload and module paths, shell and git hooks) or change whom it
99
+ * trusts over TLS. A machine that needs one sets it in its own shell. This is defence in depth:
100
+ * a tracked env file is repository content, reviewed like code, not a security boundary.
101
+ */
102
+ const REFUSED_KEY = new RegExp(
103
+ `^(?:${[
104
+ 'PATH',
105
+ 'NODE_OPTIONS',
106
+ 'NODE_PATH',
107
+ 'NODE_EXTRA_CA_CERTS',
108
+ 'NODE_TLS_REJECT_UNAUTHORIZED',
109
+ 'NODE_REPL_EXTERNAL_MODULE',
110
+ 'BUN_OPTIONS',
111
+ 'SSL_CERT_FILE',
112
+ 'SSL_CERT_DIR',
113
+ 'LD_\\w+',
114
+ 'DYLD_\\w+',
115
+ 'BASH_ENV',
116
+ 'BASH_FUNC_.*',
117
+ 'ENV',
118
+ 'PROMPT_COMMAND',
119
+ 'ZDOTDIR',
120
+ 'GIT_\\w+',
121
+ 'npm_config_\\w+',
122
+ 'PERL5OPT',
123
+ 'PERL5LIB',
124
+ 'PERL5DB',
125
+ 'PYTHONPATH',
126
+ 'PYTHONHOME',
127
+ 'PYTHONSTARTUP',
128
+ 'PYTHONINSPECT',
129
+ 'RUBYOPT',
130
+ 'RUBYLIB',
131
+ 'JAVA_TOOL_OPTIONS',
132
+ '_JAVA_OPTIONS',
133
+ 'JDK_JAVA_OPTIONS',
134
+ ].join('|')})$`,
135
+ 'i',
136
+ );
137
+
138
+ /** A portable variable name. Anything else is refused rather than passed to a child process. */
139
+ const PLAIN_NAME = /^[_a-z]\w*$/i;
140
+
141
+ /** Keys bundlers inline into browser code. */
142
+ const PUBLIC_KEY = /^(?:VITE_|NEXT_PUBLIC_|PUBLIC_|EXPO_PUBLIC_)/;
143
+
144
+ const loadBudget = z.number().int().min(1).max(2_147_483_647);
145
+
146
+ /** Load atomically into the env map: operational problems return canonical failures before applying values. */
147
+ export function loadEnvFiles(options: LoadEnvFilesOptions = {}) {
148
+ const budget = ensure(options.timeoutMs ?? 30_000, loadBudget);
149
+ return nodeOperation('load', () => {
150
+ const deadline = Date.now() + budget;
151
+ const stopped = () =>
152
+ options.signal?.aborted
153
+ ? EnvCancelled({ cause: options.signal.reason })
154
+ : Date.now() >= deadline
155
+ ? EnvDeadline()
156
+ : undefined;
157
+ const initialStop = stopped();
158
+ if (initialStop !== undefined) return initialStop;
159
+ const errors: EnvFilesError[] = [];
160
+ const causes: unknown[] = [];
161
+ const target = options.env ?? process.env;
162
+ const cwd = path.resolve(options.cwd ?? process.cwd());
163
+ const root = path.resolve(options.root ?? findRoot(cwd));
164
+ const environment = options.environment ?? nonEmpty(target['NODE_ENV']) ?? 'development';
165
+ const names = options.files ?? [`.env.${environment}`, ...(environment === 'test' ? [] : ['.env.local'])];
166
+ const resolvers = options.references ?? DEFAULT_RESOLVERS;
167
+
168
+ const files = envDirs(root, cwd)
169
+ .flatMap(dir => names.map(name => path.join(dir, name)))
170
+ .filter(file => existsSync(file));
171
+
172
+ const winners = new Map<string, { readonly file: string; readonly line: number; readonly raw: string }>();
173
+ const refused: { key: string; file: string; line: number; reason: string }[] = [];
174
+ for (const file of files) {
175
+ const stop = stopped();
176
+ if (stop !== undefined) return stop;
177
+ const text = readEnvText(file);
178
+ if (isFailure(text)) {
179
+ errors.push({ code: ENV_FILES_ERROR_CODES.cannotRead, file, message: text.message });
180
+ causes.push(text);
181
+ continue;
182
+ }
183
+ for (const [key, entry] of envValues(parseEnv(text))) {
184
+ if (isKeyName(key)) continue;
185
+ if (REFUSED_KEY.test(key) || !PLAIN_NAME.test(key)) {
186
+ const reason = PLAIN_NAME.test(key)
187
+ ? 'it changes what code a process runs or whom it trusts'
188
+ : 'it is not a portable variable name';
189
+ refused.push({ key, file, line: entry.start + 1, reason });
190
+ continue;
191
+ }
192
+ winners.set(key, { file, line: entry.start + 1, raw: entry.value });
193
+ }
194
+ }
195
+
196
+ const keysByFile = new Map<string, readonly EnvPrivateKey[] | null>();
197
+ const missingReported = new Set<string>();
198
+ /** The keys for `file`. A missing key is an error only when a value actually needs it (`required`). */
199
+ const privateKeysFor = (file: string, required: boolean): readonly EnvPrivateKey[] | null => {
200
+ if (!keysByFile.has(file)) {
201
+ const found = findPrivateKey(file, { root, env: target });
202
+ if (isFailure(found)) {
203
+ causes.push(found);
204
+ errors.push({
205
+ code: FileReadFailed.is(found) ? ENV_FILES_ERROR_CODES.cannotRead : ENV_FILES_ERROR_CODES.cannotDecrypt,
206
+ file,
207
+ message: 'Cannot read a valid private key for this env file',
208
+ });
209
+ missingReported.add(file);
210
+ keysByFile.set(file, null);
211
+ } else keysByFile.set(file, found?.keys ?? null);
212
+ }
213
+ const keys = keysByFile.get(file) ?? null;
214
+ if (keys === null && required && !missingReported.has(file)) {
215
+ missingReported.add(file);
216
+ errors.push({
217
+ code: ENV_FILES_ERROR_CODES.missingPrivateKey,
218
+ file,
219
+ message: `${path.relative(root, file)} has encrypted values but no private key: set ${privateKeyHint(file)} or add it to ${keysFileCandidates(
220
+ file,
221
+ root,
222
+ )
223
+ .map(candidate => path.relative(root, candidate))
224
+ .join(' or ')}`,
225
+ });
226
+ }
227
+ return keys;
228
+ };
229
+
230
+ const values: EnvFileValue[] = [];
231
+ for (const [key, { file, line, raw }] of winners) {
232
+ const stop = stopped();
233
+ if (stop !== undefined) return stop;
234
+ if (isEncrypted(raw)) {
235
+ // A value the real environment already provides (Coolify, a parent `dot env run`) needs no key.
236
+ const current = nonEmpty(target[key]);
237
+ const keys = privateKeysFor(file, current === undefined || isEncrypted(current));
238
+ if (keys === null) {
239
+ values.push({ key, file, line, kind: 'encrypted' });
240
+ continue;
241
+ }
242
+ const value = tryDecrypt(raw, keys);
243
+ if (isFailure(value)) {
244
+ causes.push(value);
245
+ errors.push({
246
+ code: ENV_FILES_ERROR_CODES.cannotDecrypt,
247
+ file,
248
+ key,
249
+ message: `${key} in ${path.relative(root, file)}:${String(line)} cannot be decrypted with ${privateKeyHint(file)}`,
250
+ });
251
+ values.push({ key, file, line, kind: 'encrypted' });
252
+ } else {
253
+ values.push({ key, file, line, kind: 'encrypted', value });
254
+ }
255
+ continue;
256
+ }
257
+ const scheme = referenceScheme(raw, resolvers);
258
+ if (scheme === undefined) {
259
+ values.push({ key, file, line, kind: 'plain', value: raw });
260
+ continue;
261
+ }
262
+ if (PUBLIC_KEY.test(key)) {
263
+ refused.push({
264
+ key,
265
+ file,
266
+ line,
267
+ reason: 'a public (browser-bundled) key cannot hold a secret-manager reference',
268
+ });
269
+ continue;
270
+ }
271
+ const resolved = attempt(
272
+ () =>
273
+ resolvers[scheme]?.(raw, {
274
+ timeoutMs: Math.min(30_000, Math.max(1, deadline - Date.now())),
275
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
276
+ }),
277
+ cause => ReferenceFailed({ scheme }, { cause }),
278
+ );
279
+ const stopAfterProvider = stopped();
280
+ if (stopAfterProvider !== undefined) return stopAfterProvider;
281
+ if (EnvCancelled.is(resolved) || EnvDeadline.is(resolved)) return resolved;
282
+ if (isFailure(resolved) || resolved === undefined) {
283
+ const failure = isFailure(resolved) ? resolved : ReferenceFailed({ scheme });
284
+ causes.push(failure);
285
+ errors.push({
286
+ code: ENV_FILES_ERROR_CODES.unresolvedReference,
287
+ file,
288
+ key,
289
+ message: `${key} (${scheme}://) in ${path.relative(root, file)}:${String(line)} could not be resolved`,
290
+ });
291
+ values.push({ key, file, line, kind: 'reference' });
292
+ } else values.push({ key, file, line, kind: 'reference', value: resolved });
293
+ }
294
+ // Validate every entry first: a failed boot must not leave a partially applied environment.
295
+ if (errors.length > 0) return EnvFilesFailed({ environment, errors }, { cause: causes });
296
+
297
+ const applied: string[] = [];
298
+ const shadowed: { key: string; file: string; line: number }[] = [];
299
+ const fromFiles = new Set(values.map(value => value.key));
300
+ for (const [key, value] of Object.entries(target)) {
301
+ if (value !== undefined && isEncrypted(value) && !fromFiles.has(key)) {
302
+ errors.push({
303
+ code: ENV_FILES_ERROR_CODES.cannotDecrypt,
304
+ file: '(environment)',
305
+ key,
306
+ message: `${key} holds an encrypted value that no loaded env file explains (a file auto-loaded by Bun?)`,
307
+ });
308
+ }
309
+ }
310
+
311
+ if (errors.length > 0) return EnvFilesFailed({ environment, errors }, { cause: causes });
312
+ for (const entry of values) {
313
+ if (entry.value === undefined) continue;
314
+ const current = nonEmpty(target[entry.key]);
315
+ if (current !== undefined && !isEncrypted(current)) {
316
+ if (current !== entry.value) shadowed.push({ key: entry.key, file: entry.file, line: entry.line });
317
+ continue;
318
+ }
319
+ if (entry.kind !== 'plain') loadedSecrets.add(entry.value);
320
+ if (options.write !== false) target[entry.key] = entry.value;
321
+ applied.push(entry.key);
322
+ }
323
+
324
+ return { root, environment, files, values, applied, shadowed, refused, errors } satisfies EnvFilesReport;
325
+ });
326
+ }
327
+
328
+ /** Every value this process loaded from an encrypted or referenced entry: what log masking hides. */
329
+ export function secretValues(): readonly string[] {
330
+ return [...loadedSecrets];
331
+ }
332
+
333
+ /** The report's errors as one message, one line each. */
334
+ export function formatEnvFilesErrors(failure: FailureOf<typeof EnvFilesFailed>): string {
335
+ return [
336
+ `Cannot load env files (${failure.data.environment}):`,
337
+ ...failure.data.errors.map(error => ` ${error.code} ${error.message}`),
338
+ ].join('\n');
339
+ }
340
+
341
+ /** The nearest ancestor of `cwd` with `.git`, else with a `workspaces` package.json, else `cwd`. */
342
+ export function findRoot(cwd: string): string {
343
+ let workspace: string | undefined;
344
+ for (let dir = cwd; ; dir = path.dirname(dir)) {
345
+ if (existsSync(path.join(dir, '.git'))) return dir;
346
+ if (workspace === undefined && isWorkspaceRoot(path.join(dir, 'package.json'))) workspace = dir;
347
+ if (path.dirname(dir) === dir) return workspace ?? cwd;
348
+ }
349
+ }
350
+
351
+ /** The directories from `root` down to `cwd`, root first. Just `cwd` when it is not inside `root`. */
352
+ export function envDirs(root: string, cwd: string): readonly string[] {
353
+ const relative = path.relative(root, cwd);
354
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return [cwd];
355
+ const parts = relative === '' ? [] : relative.split(path.sep);
356
+ return [root, ...parts.map((_, index) => path.join(root, ...parts.slice(0, index + 1)))];
357
+ }
358
+
359
+ const workspaceManifest = z.object({
360
+ workspaces: z.union([z.array(z.string()), z.object({ packages: z.array(z.string()) })]),
361
+ });
362
+
363
+ function isWorkspaceRoot(manifest: string): boolean {
364
+ if (!existsSync(manifest)) return false;
365
+ try {
366
+ return workspaceManifest.safeParse(JSON.parse(readFileSync(manifest, 'utf8'))).success;
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+
372
+ function tryDecrypt(raw: string, keys: readonly EnvPrivateKey[]) {
373
+ for (const key of keys) {
374
+ const value = decryptValue(raw, key);
375
+ if (!isFailure(value)) return value;
376
+ }
377
+ return DecryptionFailed();
378
+ }
379
+
380
+ function privateKeyHint(file: string): string {
381
+ return keyNamesFor(path.basename(file)).privateKey;
382
+ }
383
+
384
+ function nonEmpty(value: string | undefined): string | undefined {
385
+ return value === undefined || value === '' ? undefined : value;
386
+ }
@@ -0,0 +1,56 @@
1
+ import { context, metrics, SpanStatusCode, trace } from '@opentelemetry/api';
2
+
3
+ import packageMetadata from '@arki/env/package.json' with { type: 'json' };
4
+ import { isFailure } from '@arki/failure';
5
+
6
+ function observe<Value>(fn: () => Value) {
7
+ try {
8
+ const value = fn();
9
+ void Promise.resolve(value).catch(() => {
10
+ /* Reporting never owns execution. */
11
+ });
12
+ return value;
13
+ } catch {
14
+ return;
15
+ }
16
+ }
17
+
18
+ /** Safe execution-boundary telemetry; never records paths, references, values, keys or raw causes. */
19
+ export function nodeOperation<Value>(name: string, run: () => Value): Value {
20
+ const span = observe(() => trace.getTracer('@arki/env', packageMetadata.version).startSpan(`env.${name}`));
21
+ const active = observe(() => (span === undefined ? context.active() : trace.setSpan(context.active(), span)));
22
+ const started = Date.now();
23
+ const invocation: { outcome?: { value: Value } | { cause: unknown } } = {};
24
+ const invoke = () => {
25
+ if (invocation.outcome === undefined) {
26
+ try {
27
+ invocation.outcome = { value: run() };
28
+ } catch (error) {
29
+ invocation.outcome = { cause: error };
30
+ }
31
+ }
32
+ if ('cause' in invocation.outcome) throw invocation.outcome.cause;
33
+ return invocation.outcome.value;
34
+ };
35
+ let result: Value | undefined;
36
+ try {
37
+ if (active !== undefined) observe(() => context.with(active, invoke));
38
+ result = invoke();
39
+ return result;
40
+ } finally {
41
+ const outcome: unknown = result;
42
+ const failed = isFailure(outcome) || (invocation.outcome !== undefined && 'cause' in invocation.outcome);
43
+ observe(() => span?.setStatus({ code: failed ? SpanStatusCode.ERROR : SpanStatusCode.OK }));
44
+ observe(() =>
45
+ metrics
46
+ .getMeter('@arki/env', packageMetadata.version)
47
+ .createHistogram('env.operation.duration', { unit: 'ms' })
48
+ .record(
49
+ Math.max(0, Date.now() - started),
50
+ { 'env.operation': name, 'env.outcome': failed ? 'failed' : 'ok' },
51
+ active,
52
+ ),
53
+ );
54
+ observe(() => span?.end());
55
+ }
56
+ }