@willbooster/wb 14.0.0 → 14.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 (3) hide show
  1. package/bin/dotenv.js +96 -23
  2. package/dist/index.js +38 -31
  3. package/package.json +13 -12
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,135 @@ export function runDotenvCommand(args) {
67
64
  });
68
65
  }
69
66
 
67
+ // Mirrors src/commands/dotenv.ts (readAndApplyEnvironmentVariables) for this startup fast path.
70
68
  function readAndApplyEnvironmentVariables(cwd) {
69
+ const mode = process.env.WB_ENV;
70
+ // Mode-specific sources drive the forced-mode override below.
71
+ const modeVars = mode
72
+ ? { ...readEnvFile(path.join(cwd, `.env.${mode}`)), ...readEnvFile(path.join(cwd, `.env.${mode}.local`)) }
73
+ : {};
74
+ // Mirror the shared cascade's precedence: .env.<mode>.local > .env.local > .env.<mode> > .env.
71
75
  const dotenvVars = {
72
76
  ...readEnvFile(path.join(cwd, '.env')),
73
- ...(process.env.WB_ENV ? readEnvFile(path.join(cwd, `.env.${process.env.WB_ENV}`)) : {}),
77
+ ...(mode ? readEnvFile(path.join(cwd, `.env.${mode}`)) : {}),
78
+ ...readEnvFile(path.join(cwd, '.env.local')),
79
+ ...(mode ? readEnvFile(path.join(cwd, `.env.${mode}.local`)) : {}),
74
80
  };
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;
81
+ // WB_ENV in process.env means the mode is explicitly forced, so values from the mode's own
82
+ // sources (.env.<mode>[.local] and the fnox profile) win over variables inherited from the
83
+ // parent shell — except on CI, where injected env vars must keep overriding committed files
84
+ // (see https://github.com/WillBooster/shared/issues/930).
85
+ const modeFileOverridesProcessEnv = !!mode && !isCI(process.env.CI);
86
+ // fnox.toml is the committed, encrypted equivalent of .env files; local .env files still win over it.
87
+ const fnoxVars = readFnoxEnvironmentVariables(cwd, mode, dotenvVars, modeFileOverridesProcessEnv);
88
+ const parsed = { ...fnoxVars, ...dotenvVars };
89
+ // Expand ${...} references against exported variables whose FILE value loses to the shell
90
+ // (mirroring readEnvironmentVariables' effective-value semantics): a reference must resolve to
91
+ // the value the child will actually see, so a shell-shadowed key expands to the shell value
92
+ // while a winning file/override value expands to the file value.
93
+ const fileValueWins = (key) =>
94
+ !(key in process.env) || (modeFileOverridesProcessEnv && (key in modeVars || key in fnoxVars));
95
+ const referenceEnv = {};
96
+ for (const [key, value] of Object.entries(process.env)) {
97
+ // Escape dollar signs so exported values substitute literally (pa$word stays pa$word).
98
+ if (value !== undefined && !(key in parsed && fileValueWins(key))) {
99
+ referenceEnv[key] = value.replaceAll('$', String.raw`\$`);
100
+ }
101
+ }
102
+ const envVars = expand({ parsed, processEnv: referenceEnv }).parsed ?? parsed;
79
103
  for (const [key, value] of Object.entries(envVars)) {
80
104
  if (!(key in process.env)) {
81
105
  process.env[key] = value;
106
+ continue;
82
107
  }
108
+ // readFnoxEnvironmentVariables returns a process.env-shadowed key only when the forced
109
+ // profile overrides it, so fnox-provided keys count as mode-specific here.
110
+ if (!(key in modeVars || key in fnoxVars) || process.env[key] === value) continue;
111
+
112
+ if (modeFileOverridesProcessEnv) {
113
+ console.warn(
114
+ `Warning: ${key} in the "${mode}" mode's env sources overrides the value inherited from the parent environment because WB_ENV is explicitly set.`
115
+ );
116
+ process.env[key] = value;
117
+ }
118
+ // On CI, inherited variables intentionally win; no warning is emitted.
83
119
  }
84
120
  }
85
121
 
86
122
  // Mirrors readFnoxEnvironmentVariables in @willbooster/shared-lib-node for this startup fast path.
87
- function readFnoxEnvironmentVariables(cwd, cascade, currentEnvVars) {
123
+ function readFnoxEnvironmentVariables(cwd, cascade, currentEnvVars, modeFileOverridesProcessEnv) {
88
124
  if (!hasProjectFnoxConfig(cwd)) return {};
89
125
 
126
+ const secrets = runFnoxExport(cwd, cascade, { quiet: false });
127
+ if (!secrets) return {};
128
+ // A key is profile-specific (and may override process.env off CI) when the profile export's
129
+ // value differs from the base export's; when the base export fails, no override is applied.
130
+ // The base export runs lazily, only when a process.env collision needs adjudicating.
131
+ let cachedBaseSecrets = false;
132
+ const getBaseSecrets = () => {
133
+ if (cachedBaseSecrets === false) {
134
+ cachedBaseSecrets = runFnoxExport(cwd, undefined, { quiet: true, ignoreProfileEnvVar: true });
135
+ }
136
+ return cachedBaseSecrets;
137
+ };
138
+
139
+ const envVars = {};
140
+ for (const [key, value] of Object.entries(secrets)) {
141
+ if (typeof value !== 'string' || key in currentEnvVars) continue;
142
+ if (key in process.env) {
143
+ const baseSecrets = modeFileOverridesProcessEnv && cascade ? getBaseSecrets() : undefined;
144
+ const overridesProcessEnv = baseSecrets !== undefined && baseSecrets[key] !== value;
145
+ if (!overridesProcessEnv) continue;
146
+ }
147
+ envVars[key] = value;
148
+ }
149
+ return envVars;
150
+ }
151
+
152
+ function runFnoxExport(cwd, cascade, options) {
90
153
  // `--if-missing error`: fnox otherwise exits 0 and silently omits secrets it fails to resolve.
91
154
  // `--non-interactive`: prompts or browser auth flows would hang forever because stdin is ignored.
92
155
  const args = ['export', '--format', 'json', '--no-color', '--if-missing', 'error', '--non-interactive'];
156
+ const env = { ...process.env };
93
157
  if (cascade) {
94
158
  args.push('--profile', cascade);
95
159
  }
160
+ if (options.ignoreProfileEnvVar) {
161
+ // Without `--profile`, fnox falls back to FNOX_PROFILE; the base-adjudication export must
162
+ // read the BASE secrets, so the inherited profile selection is cleared for it — and only for
163
+ // it: a profile-less PRIMARY export (e.g. `wb dotenv` without WB_ENV) keeps honoring
164
+ // FNOX_PROFILE.
165
+ delete env.FNOX_PROFILE;
166
+ }
96
167
  const result = childProcess.spawnSync('fnox', args, {
97
168
  cwd,
169
+ env,
98
170
  encoding: 'utf8',
99
171
  stdio: ['ignore', 'pipe', 'pipe'],
100
172
  });
101
173
  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 {};
174
+ if (!options.quiet) {
175
+ console.warn(
176
+ `Failed to read fnox secrets: ${result.error?.message || result.stderr?.trim() || `fnox exited with status ${result.status}`}`
177
+ );
178
+ }
179
+ return;
106
180
  }
107
181
 
108
182
  let parsed;
109
183
  try {
110
184
  parsed = JSON.parse(result.stdout);
111
185
  } catch {
112
- return {};
186
+ return;
113
187
  }
114
188
  const secrets = parsed?.secrets;
115
- if (!secrets || typeof secrets !== 'object' || Array.isArray(secrets)) return {};
189
+ if (!secrets || typeof secrets !== 'object' || Array.isArray(secrets)) return;
190
+ return secrets;
191
+ }
116
192
 
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;
193
+ // Mirrors src/utils/ci.ts for this startup fast path.
194
+ function isCI(ciEnv) {
195
+ return !!ciEnv && ciEnv !== '0' && ciEnv !== 'false';
123
196
  }
124
197
 
125
198
  function hasProjectFnoxConfig(cwd) {