@willbooster/wb 14.1.0 → 15.0.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 (3) hide show
  1. package/bin/dotenv.js +133 -23
  2. package/dist/index.js +38 -31
  3. package/package.json +1 -1
package/bin/dotenv.js CHANGED
@@ -39,14 +39,12 @@ export function runDotenvCommand(args) {
39
39
  stdio: 'inherit',
40
40
  });
41
41
  const signalHandlers = new Map();
42
- let forwardedShutdownSignal;
43
42
  child.on('error', (error) => {
44
43
  console.error(error);
45
44
  process.exit(1);
46
45
  });
47
46
  for (const signal of shutdownSignals) {
48
47
  const signalHandler = () => {
49
- forwardedShutdownSignal = signal;
50
48
  child.kill(signal);
51
49
  };
52
50
  signalHandlers.set(signal, signalHandler);
@@ -56,10 +54,9 @@ export function runDotenvCommand(args) {
56
54
  for (const [shutdownSignal, signalHandler] of signalHandlers) {
57
55
  process.off(shutdownSignal, signalHandler);
58
56
  }
59
- if (signal && signal === forwardedShutdownSignal) {
60
- process.exit(0);
61
- }
62
57
  if (signal) {
58
+ // Re-raise even for forwarded shutdown signals so callers observe the conventional
59
+ // signal exit status (e.g. 130 for SIGINT) instead of a misleading success.
63
60
  process.kill(process.pid, signal);
64
61
  return;
65
62
  }
@@ -67,59 +64,172 @@ export function runDotenvCommand(args) {
67
64
  });
68
65
  }
69
66
 
67
+ // Mirrors src/commands/dotenv.ts (validateStandardWbEnv) for this startup fast path.
68
+ function validateStandardWbEnv(value, fixTarget) {
69
+ if (
70
+ !value ||
71
+ ['development', 'test', 'staging', 'production'].includes(value) ||
72
+ process.env.WB_SKIP_ENV_CHECK === '1' ||
73
+ process.env.WB_SKIP_ENV_CHECK === 'true'
74
+ ) {
75
+ return;
76
+ }
77
+ console.error(
78
+ `WB_ENV must be one of development, test, staging, or production, but is "${value}". ` +
79
+ `Fix ${fixTarget}, or set WB_SKIP_ENV_CHECK=1 to skip this check.`
80
+ );
81
+ process.exit(1);
82
+ }
83
+
84
+ // Mirrors src/commands/dotenv.ts (readAndApplyEnvironmentVariables) for this startup fast path.
70
85
  function readAndApplyEnvironmentVariables(cwd) {
86
+ const mode = process.env.WB_ENV;
87
+ // Mode-specific sources drive the forced-mode override below.
88
+ const modeVars = mode
89
+ ? { ...readEnvFile(path.join(cwd, `.env.${mode}`)), ...readEnvFile(path.join(cwd, `.env.${mode}.local`)) }
90
+ : {};
91
+ // Mirror the shared cascade's precedence: .env.<mode>.local > .env.local > .env.<mode> > .env.
71
92
  const dotenvVars = {
72
93
  ...readEnvFile(path.join(cwd, '.env')),
73
- ...(process.env.WB_ENV ? readEnvFile(path.join(cwd, `.env.${process.env.WB_ENV}`)) : {}),
94
+ ...(mode ? readEnvFile(path.join(cwd, `.env.${mode}`)) : {}),
95
+ ...readEnvFile(path.join(cwd, '.env.local')),
96
+ ...(mode ? readEnvFile(path.join(cwd, `.env.${mode}.local`)) : {}),
74
97
  };
75
- // fnox.toml is the committed, encrypted equivalent of .env files; local .env files still win
76
- // over it. Mirrors src/commands/dotenv.ts for this startup fast path.
77
- const parsed = { ...readFnoxEnvironmentVariables(cwd, process.env.WB_ENV, dotenvVars), ...dotenvVars };
78
- const envVars = expand({ parsed, processEnv: {} }).parsed ?? parsed;
98
+ // WB_ENV in process.env means the mode is explicitly forced, so values from the mode's own
99
+ // sources (.env.<mode>[.local] and the fnox profile) win over variables inherited from the
100
+ // parent shell — except on CI, where injected env vars must keep overriding committed files
101
+ // (see https://github.com/WillBooster/shared/issues/930).
102
+ const modeFileOverridesProcessEnv = !!mode && !isCI(process.env.CI);
103
+ // fnox.toml is the committed, encrypted equivalent of .env files; local .env files still win over it.
104
+ const fnoxVars = readFnoxEnvironmentVariables(cwd, mode, dotenvVars, modeFileOverridesProcessEnv);
105
+ const parsed = { ...fnoxVars, ...dotenvVars };
106
+ // Expand ${...} references against exported variables whose FILE value loses to the shell
107
+ // (mirroring readEnvironmentVariables' effective-value semantics): a reference must resolve to
108
+ // the value the child will actually see, so a shell-shadowed key expands to the shell value
109
+ // while a winning file/override value expands to the file value.
110
+ const fileValueWins = (key) =>
111
+ !(key in process.env) || (modeFileOverridesProcessEnv && (key in modeVars || key in fnoxVars));
112
+ const referenceEnv = {};
113
+ for (const [key, value] of Object.entries(process.env)) {
114
+ // Escape dollar signs so exported values substitute literally (pa$word stays pa$word).
115
+ if (value !== undefined && !(key in parsed && fileValueWins(key))) {
116
+ referenceEnv[key] = value.replaceAll('$', String.raw`\$`);
117
+ }
118
+ }
119
+ const envVars = expand({ parsed, processEnv: referenceEnv }).parsed ?? parsed;
79
120
  for (const [key, value] of Object.entries(envVars)) {
80
121
  if (!(key in process.env)) {
81
122
  process.env[key] = value;
123
+ continue;
124
+ }
125
+ // readFnoxEnvironmentVariables returns a process.env-shadowed key only when the forced
126
+ // profile overrides it, so fnox-provided keys count as mode-specific here.
127
+ if (!(key in modeVars || key in fnoxVars) || process.env[key] === value) continue;
128
+
129
+ if (modeFileOverridesProcessEnv) {
130
+ console.warn(
131
+ `Warning: ${key} in the "${mode}" mode's env sources overrides the value inherited from the parent environment because WB_ENV is explicitly set.`
132
+ );
133
+ process.env[key] = value;
82
134
  }
135
+ // On CI, inherited variables intentionally win; no warning is emitted.
136
+ }
137
+ // Validate only AFTER applying the sources, so a WB_SKIP_ENV_CHECK defined in an env FILE is
138
+ // honored: both the captured exported mode (it selected the cascade) and the FINAL value the
139
+ // child will see.
140
+ validateStandardWbEnv(mode, 'the exported variable');
141
+ validateStandardWbEnv(process.env.WB_ENV, 'the env source or the exported variable');
142
+ // The exported mode selected the cascade, so a mode file silently replacing it with a DIFFERENT
143
+ // valid mode (e.g. `.env.production` containing `WB_ENV=development`) must be rejected as well.
144
+ if (
145
+ mode &&
146
+ process.env.WB_ENV &&
147
+ process.env.WB_ENV !== mode &&
148
+ process.env.WB_SKIP_ENV_CHECK !== '1' &&
149
+ process.env.WB_SKIP_ENV_CHECK !== 'true'
150
+ ) {
151
+ console.error(
152
+ `WB_ENV resolves to "${process.env.WB_ENV}" although the "${mode}" environment was selected. ` +
153
+ `Fix the WB_ENV defined in the mode's env sources, or set WB_SKIP_ENV_CHECK=1 to skip this check.`
154
+ );
155
+ process.exit(1);
83
156
  }
84
157
  }
85
158
 
86
159
  // Mirrors readFnoxEnvironmentVariables in @willbooster/shared-lib-node for this startup fast path.
87
- function readFnoxEnvironmentVariables(cwd, cascade, currentEnvVars) {
160
+ function readFnoxEnvironmentVariables(cwd, cascade, currentEnvVars, modeFileOverridesProcessEnv) {
88
161
  if (!hasProjectFnoxConfig(cwd)) return {};
89
162
 
163
+ const secrets = runFnoxExport(cwd, cascade, { quiet: false });
164
+ if (!secrets) return {};
165
+ // A key is profile-specific (and may override process.env off CI) when the profile export's
166
+ // value differs from the base export's; when the base export fails, no override is applied.
167
+ // The base export runs lazily, only when a process.env collision needs adjudicating.
168
+ let cachedBaseSecrets = false;
169
+ const getBaseSecrets = () => {
170
+ if (cachedBaseSecrets === false) {
171
+ cachedBaseSecrets = runFnoxExport(cwd, undefined, { quiet: true, ignoreProfileEnvVar: true });
172
+ }
173
+ return cachedBaseSecrets;
174
+ };
175
+
176
+ const envVars = {};
177
+ for (const [key, value] of Object.entries(secrets)) {
178
+ if (typeof value !== 'string' || key in currentEnvVars) continue;
179
+ if (key in process.env) {
180
+ const baseSecrets = modeFileOverridesProcessEnv && cascade ? getBaseSecrets() : undefined;
181
+ const overridesProcessEnv = baseSecrets !== undefined && baseSecrets[key] !== value;
182
+ if (!overridesProcessEnv) continue;
183
+ }
184
+ envVars[key] = value;
185
+ }
186
+ return envVars;
187
+ }
188
+
189
+ function runFnoxExport(cwd, cascade, options) {
90
190
  // `--if-missing error`: fnox otherwise exits 0 and silently omits secrets it fails to resolve.
91
191
  // `--non-interactive`: prompts or browser auth flows would hang forever because stdin is ignored.
92
192
  const args = ['export', '--format', 'json', '--no-color', '--if-missing', 'error', '--non-interactive'];
193
+ const env = { ...process.env };
93
194
  if (cascade) {
94
195
  args.push('--profile', cascade);
95
196
  }
197
+ if (options.ignoreProfileEnvVar) {
198
+ // Without `--profile`, fnox falls back to FNOX_PROFILE; the base-adjudication export must
199
+ // read the BASE secrets, so the inherited profile selection is cleared for it — and only for
200
+ // it: a profile-less PRIMARY export (e.g. `wb dotenv` without WB_ENV) keeps honoring
201
+ // FNOX_PROFILE.
202
+ delete env.FNOX_PROFILE;
203
+ }
96
204
  const result = childProcess.spawnSync('fnox', args, {
97
205
  cwd,
206
+ env,
98
207
  encoding: 'utf8',
99
208
  stdio: ['ignore', 'pipe', 'pipe'],
100
209
  });
101
210
  if (result.error || result.status !== 0 || !result.stdout?.trim()) {
102
- console.warn(
103
- `Failed to read fnox secrets: ${result.error?.message || result.stderr?.trim() || `fnox exited with status ${result.status}`}`
104
- );
105
- return {};
211
+ if (!options.quiet) {
212
+ console.warn(
213
+ `Failed to read fnox secrets: ${result.error?.message || result.stderr?.trim() || `fnox exited with status ${result.status}`}`
214
+ );
215
+ }
216
+ return;
106
217
  }
107
218
 
108
219
  let parsed;
109
220
  try {
110
221
  parsed = JSON.parse(result.stdout);
111
222
  } catch {
112
- return {};
223
+ return;
113
224
  }
114
225
  const secrets = parsed?.secrets;
115
- if (!secrets || typeof secrets !== 'object' || Array.isArray(secrets)) return {};
226
+ if (!secrets || typeof secrets !== 'object' || Array.isArray(secrets)) return;
227
+ return secrets;
228
+ }
116
229
 
117
- const envVars = {};
118
- for (const [key, value] of Object.entries(secrets)) {
119
- if (typeof value !== 'string' || key in currentEnvVars || key in process.env) continue;
120
- envVars[key] = value;
121
- }
122
- return envVars;
230
+ // Mirrors src/utils/ci.ts for this startup fast path.
231
+ function isCI(ciEnv) {
232
+ return !!ciEnv && ciEnv !== '0' && ciEnv !== 'false';
123
233
  }
124
234
 
125
235
  function hasProjectFnoxConfig(cwd) {