@dbx-tools/core 0.6.88 → 0.6.89

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/src/config.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Every package resolves settings the same way: take the caller's value, else
5
5
  * an environment variable, else a default. Local development adds two fallback
6
- * locations: `.env` files and `resources.apps.<app>.config.env` or root
7
- * `variables` in `databricks.yml`.
6
+ * locations: `.env` files and one App's `resources.apps.<app>.config.env` in
7
+ * `databricks.yml`.
8
8
  *
9
9
  * Two things make it cheap to call from a hot path:
10
10
  *
@@ -20,24 +20,6 @@
20
20
  * CLI is not on the image. Boolean environment overrides can force either file
21
21
  * source on or off when a tool needs different behavior.
22
22
  *
23
- * The bundle is gated on this being an AppKit project at all. `databricks bundle
24
- * validate` is a process spawn measured in seconds, and a plain library or CLI
25
- * consumer has no bundle to find, so the gate runs in three steps before the
26
- * spawn is allowed - see {@link bundleFile}:
27
- *
28
- * 1. `@databricks/appkit` is resolved WITHOUT evaluating it. It is an optional
29
- * peer, so a consumer that never installed it is not an AppKit project and
30
- * the bundle is never loaded. The probe is cached, so this costs one
31
- * resolution for the life of the process.
32
- * 2. The bundle is read (once per context) and must describe EXACTLY ONE app
33
- * with `config.env`. Nothing here says which of several apps this process
34
- * is, so an ambiguous bundle contributes nothing rather than a guess.
35
- * 3. AppKit's execution context confirms this process really is that app. The
36
- * context does not exist until AppKit boots and `getExecutionContext()`
37
- * THROWS until then, so the probe is caught and only the affirmative is
38
- * remembered - a lookup before boot still resolves, and re-confirms later
39
- * once the context is available.
40
- *
41
23
  * Only the single app's `config.env` is consulted. Root bundle `variables` are
42
24
  * not: they are authoring inputs for the bundle itself (interpolated into
43
25
  * targets, resources, and paths), so treating one as a process setting resolves
@@ -50,16 +32,14 @@
50
32
 
51
33
  import { spawnSync } from "node:child_process";
52
34
  import { readFileSync } from "node:fs";
53
- import { createRequire } from "node:module";
54
35
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
55
36
  import { parseEnv } from "node:util";
56
37
  import {
57
38
  context,
58
- functionModule,
59
39
  json,
60
40
  log,
61
41
  object,
62
- string as stringModule,
42
+ string as sharedString,
63
43
  } from "@dbx-tools/shared-core";
64
44
  import { z } from "zod";
65
45
  import { statSync } from "./file.ts";
@@ -67,22 +47,22 @@ import { root as resolveProjectRoot } from "./project.ts";
67
47
 
68
48
  const logger = log.logger("config");
69
49
 
70
- export type ConfigKey = string | readonly string[];
50
+ type ConfigKey = string | readonly string[];
71
51
 
72
52
  /** Where a value may come from, consulted in the order given. */
73
- export type ConfigSource = "env" | "dotenv" | "bundle";
53
+ type ConfigSource = "env" | "dotenv" | "bundle";
74
54
 
75
- export interface ConfigOptions {
55
+ interface ConfigOptions {
76
56
  /**
77
57
  * Outermost namespaces tried before each key. Defaults to `DBX_TOOLS`.
78
58
  */
79
59
  scope?: string | readonly string[];
80
60
  /** Capability namespaces inserted after the scope and before each key. */
81
61
  prefix?: string | readonly string[];
82
- /** Directory to resolve `.env` and the bundle from. Default: `process.cwd()`. */
83
- cwd?: string;
84
62
  /** Sources in precedence order. Default: `env`, `dotenv`, `bundle`. */
85
63
  sources?: ConfigSource | readonly ConfigSource[];
64
+ /** Directory to resolve `.env` and the bundle from. Default: `process.cwd()`. */
65
+ cwd?: string;
86
66
  }
87
67
 
88
68
  /** One resolved value, tagged with the key and source it came from. */
@@ -93,7 +73,7 @@ interface ConfigValue {
93
73
  }
94
74
 
95
75
  /** A config file found on disk, with its parsed contents. */
96
- export interface ConfigFile {
76
+ interface ConfigFile {
97
77
  path: string;
98
78
  data: Record<string, unknown>;
99
79
  }
@@ -111,51 +91,50 @@ const NODE_ENV_ALTERNATIVES = {
111
91
  export const MAX_TCP_PORT = 65_535;
112
92
 
113
93
  /** Boolean environment override for {@link isDatabricksAppEnv}. */
114
- export const DATABRICKS_APP_ENV_KEY = "DBX_TOOLS_DATABRICKS_APP_ENV";
94
+ const DATABRICKS_APP_ENV_KEY = "DBX_TOOLS_DATABRICKS_APP_ENV";
115
95
 
116
96
  /** Boolean environment override for project `.env` reads. */
117
- export const CONFIG_DOTENV_KEY = "DBX_TOOLS_CONFIG_DOTENV";
97
+ const CONFIG_DOTENV_KEY = "DBX_TOOLS_CONFIG_DOTENV";
118
98
 
119
99
  /** Boolean environment override for Databricks bundle reads. */
120
- export const CONFIG_BUNDLE_KEY = "DBX_TOOLS_CONFIG_BUNDLE";
121
-
122
- /**
123
- * The OPTIONAL AppKit peer whose presence marks this as a Databricks App
124
- * project. Held as a constant so the resolve probe and the lazy import name it
125
- * once, and so neither site carries a literal a bundler would try to follow.
126
- */
127
- const APPKIT_MODULE = "@databricks/appkit";
128
-
129
- /**
130
- * The one thing this module needs from AppKit: whether a real execution context
131
- * is active. Typed structurally so an OPTIONAL peer never becomes a type
132
- * dependency, and `client` is `unknown` because its presence is all that is read.
133
- */
134
- type AppKitModule = {
135
- getExecutionContext(): { client?: unknown } | undefined;
136
- };
137
-
138
- /**
139
- * Whether AppKit's execution context has EVER been observed. Latching is the
140
- * point: a context confirms this process is the bundle's app, and that fact does
141
- * not stop being true when a later lookup happens outside a request scope.
142
- */
143
- let appKitContext = false;
144
-
145
- /** Flattened App `config.env` per parsed bundle - see {@link bundleApp}. */
146
- const BUNDLE_APP_CACHE = new WeakMap<object, Record<string, string>>();
100
+ const CONFIG_BUNDLE_KEY = "DBX_TOOLS_CONFIG_BUNDLE";
147
101
 
148
102
  /** Exact process-environment lookup for callers that do not read local config files. */
149
103
  export const ENV_ONLY = { scope: [] as const, sources: "env" as const };
150
104
 
151
- export const bundleValue = z
105
+ export const valueSchema = z
152
106
  .string()
153
- .transform((value: string) => value.trim())
154
- .refine((value: string) => value.length > 0)
107
+ .trim()
108
+ .min(1)
155
109
  .refine((value: string) => !/\$\{[^}]+\}/.test(value), {
156
110
  message: "Interpolated values are not allowed",
157
111
  });
158
112
 
113
+ const portValueSchema = z.preprocess(
114
+ (input) => object.toNumber(input),
115
+ z.number().int().min(1).max(MAX_TCP_PORT),
116
+ );
117
+
118
+ const positiveNumberValue = z.preprocess((input) => object.toNumber(input), z.number().positive());
119
+ const positiveIntValue = positiveNumberValue.transform(Math.floor);
120
+
121
+ const workspaceHostSchema = valueSchema.refine(
122
+ (host) => {
123
+ try {
124
+ return ["http:", "https:"].includes(new URL(host).protocol);
125
+ } catch {
126
+ return false;
127
+ }
128
+ },
129
+ { message: "Must be an HTTP(S) URL" },
130
+ );
131
+
132
+ const databricksAppEnvSchema = z.object({
133
+ DATABRICKS_APP_NAME: valueSchema,
134
+ DATABRICKS_HOST: workspaceHostSchema,
135
+ DATABRICKS_APP_PORT: portValueSchema,
136
+ });
137
+
159
138
  /**
160
139
  * The GENERIC shape of a bundle resource: a name, and whatever else the resource
161
140
  * type carries. Deliberately unopinionated and `passthrough()` - the concrete
@@ -163,24 +142,35 @@ export const bundleValue = z
163
142
  * Databricks-App concepts that belong to the package that resolves them, so
164
143
  * node-appkit `.extend()`s this rather than this module knowing about them.
165
144
  */
166
- export const bundleResourceSchema = z.object({ name: bundleValue.optional() }).passthrough();
145
+ export const bundleResourceSchema = z.object({ name: valueSchema.optional() }).passthrough();
167
146
 
168
147
  export const bundleEnvEntrySchema = z.object({
169
- name: bundleValue.optional(),
148
+ name: valueSchema.optional(),
170
149
  value: z.string().optional(),
171
- value_from: bundleValue.optional(),
150
+ value_from: valueSchema.optional(),
172
151
  });
173
152
 
174
153
  export const bundleAppSchema = z.object({
175
- name: bundleValue.optional(),
176
- source_code_path: z.string().optional(),
154
+ name: valueSchema.optional(),
177
155
  config: z.object({ env: z.array(bundleEnvEntrySchema).optional() }).optional(),
178
156
  resources: z.array(bundleResourceSchema).optional(),
179
157
  });
180
158
 
181
- const bundleAppsSchema = z.object({
182
- resources: z.object({ apps: z.record(z.string(), bundleAppSchema).optional() }).optional(),
183
- });
159
+ const bundleAppConfigEnvSchema = z
160
+ .object({
161
+ resources: z.object({ apps: z.record(z.string(), bundleAppSchema).optional() }).optional(),
162
+ })
163
+ .transform((bundle): Record<string, string> => {
164
+ const apps = Object.values(bundle.resources?.apps ?? {});
165
+ if (apps.length !== 1) return {};
166
+ return Object.fromEntries(
167
+ (apps[0]?.config?.env ?? []).flatMap((entry) => {
168
+ if (!entry.name) return [];
169
+ const parsed = valueSchema.safeParse(entry.value);
170
+ return parsed.success ? ([[entry.name, parsed.data]] as const) : [];
171
+ }),
172
+ );
173
+ });
184
174
 
185
175
  /**
186
176
  * Detect a Databricks App runtime from its required name, host, and port.
@@ -195,48 +185,51 @@ export function isDatabricksAppEnv(
195
185
  ): boolean {
196
186
  const override = object.toBoolean(source[DATABRICKS_APP_ENV_KEY]);
197
187
  if (override !== undefined) return override;
198
- const appName = source.DATABRICKS_APP_NAME?.trim();
199
- const host = source.DATABRICKS_HOST?.trim();
200
- const port = source.DATABRICKS_APP_PORT?.trim();
201
- if (!appName || !host || !port) return false;
202
- try {
203
- if (!["http:", "https:"].includes(new URL(host).protocol)) return false;
204
- } catch {
205
- return false;
206
- }
207
- const portNumber = object.toNumber(port);
208
- return (
209
- portNumber !== undefined &&
210
- Number.isInteger(portNumber) &&
211
- portNumber >= 1 &&
212
- portNumber <= MAX_TCP_PORT
213
- );
188
+ const parsed = databricksAppEnvSchema.safeParse(source);
189
+ return parsed.success;
214
190
  }
215
191
 
216
192
  /** Candidate names in scope, prefix, and input precedence order. */
217
- function keys(input: ConfigKey, options: Pick<ConfigOptions, "scope" | "prefix"> = {}): string[] {
218
- const scopes = object.sequence(options.scope ?? DEFAULT_SCOPE).toArray();
219
- const prefixes = options.prefix === undefined ? [] : object.sequence(options.prefix).toArray();
220
- return [
221
- ...object
222
- .sequence(input)
223
- .flatMap((key) => {
224
- const prefixed = prefixes.length > 0 ? prefixes.map((prefix) => join(prefix, key)) : [key];
225
- return [
226
- ...scopes.flatMap((scope) => prefixed.map((key) => join(scope, key))),
227
- ...prefixed,
228
- key,
229
- ];
230
- })
231
- .filter((key) => key.length > 0)
232
- .distinct(),
233
- ];
193
+ function keys(
194
+ input: ConfigKey,
195
+ options: Pick<ConfigOptions, "scope" | "prefix"> = {},
196
+ ): readonly string[] {
197
+ const scopes = object
198
+ .sequence(options.scope ?? DEFAULT_SCOPE)
199
+ .map(sharedString.trimToEmpty)
200
+ .filter(Boolean)
201
+ .distinct()
202
+ .toArray();
203
+ const prefixes =
204
+ options.prefix === undefined
205
+ ? []
206
+ : object
207
+ .sequence(options.prefix)
208
+ .map(sharedString.trimToEmpty)
209
+ .filter(Boolean)
210
+ .distinct()
211
+ .toArray();
212
+ return object
213
+ .sequence(input)
214
+ .map(sharedString.trimToEmpty)
215
+ .filter(Boolean)
216
+ .distinct()
217
+ .flatMap((key) => {
218
+ const prefixed = prefixes.length > 0 ? prefixes.map((prefix) => `${prefix}_${key}`) : [key];
219
+ return [
220
+ ...scopes.flatMap((scope) => prefixed.map((name) => `${scope}_${name}`)),
221
+ ...prefixed,
222
+ key,
223
+ ];
224
+ })
225
+ .distinct()
226
+ .toArray();
234
227
  }
235
228
 
236
229
  /**
237
230
  * Every value that resolves for `input`, in source-then-key precedence order,
238
231
  * as a LAZY sequence: nothing past the first consumed element is read, so
239
- * `values(key).first()` never spawns the Databricks CLI when the environment
232
+ * `values(key).at(0)` never spawns the Databricks CLI when the environment
240
233
  * already answered.
241
234
  *
242
235
  */
@@ -248,7 +241,7 @@ function values(input: ConfigKey, options: ConfigOptions = {}): object.Sequence<
248
241
  for (const source of sources) {
249
242
  for (const map of read(source, options.cwd)) {
250
243
  for (const key of candidates) {
251
- const value = stringModule.trimToNull(map[key]);
244
+ const value = sharedString.trimToNull(map[key]);
252
245
  if (value !== null) yield { key, source, value };
253
246
  }
254
247
  }
@@ -287,7 +280,7 @@ export function name(
287
280
  * Known long and short names are interchangeable; unknown names pass through.
288
281
  */
289
282
  function nodeEnvNames(nodeEnv: unknown): string[] {
290
- const name = stringModule.trimToNull(nodeEnv)?.toLowerCase();
283
+ const name = sharedString.trimToNull(nodeEnv)?.toLowerCase();
291
284
  if (!name || !/^[a-z0-9_-]+$/.test(name)) return [];
292
285
  for (const [canonical, alternatives] of Object.entries(NODE_ENV_ALTERNATIVES)) {
293
286
  const names: readonly string[] = alternatives;
@@ -298,13 +291,6 @@ function nodeEnvNames(nodeEnv: unknown): string[] {
298
291
  return [name];
299
292
  }
300
293
 
301
- function join(...parts: string[]): string {
302
- return parts
303
- .map((part) => part.trim())
304
- .filter(Boolean)
305
- .join("_");
306
- }
307
-
308
294
  /**
309
295
  * Resolve a string: `configured` when non-empty, else {@link text}, else `undefined`.
310
296
  *
@@ -319,7 +305,7 @@ export function string(
319
305
  input: ConfigKey,
320
306
  options?: ConfigOptions,
321
307
  ): string | undefined {
322
- return stringModule.trimToNull(configured) ?? text(input, options);
308
+ return sharedString.trimToNull(configured) ?? text(input, options);
323
309
  }
324
310
 
325
311
  /**
@@ -344,7 +330,10 @@ export function positiveNumber(
344
330
  fallback: number,
345
331
  options?: ConfigOptions,
346
332
  ): number {
347
- return toPositiveNumber(configured) ?? toPositiveNumber(text(input, options)) ?? fallback;
333
+ const fromConfig = positiveNumberValue.safeParse(configured);
334
+ if (fromConfig.success) return fromConfig.data;
335
+ const fromSources = positiveNumberValue.safeParse(text(input, options));
336
+ return fromSources.success ? fromSources.data : fallback;
348
337
  }
349
338
 
350
339
  /**
@@ -358,8 +347,27 @@ export function positiveInt(
358
347
  fallback: number,
359
348
  options?: ConfigOptions,
360
349
  ): number {
361
- const resolved = positiveNumber(configured, input, fallback, options);
362
- return Math.floor(resolved);
350
+ const fromConfig = positiveIntValue.safeParse(configured);
351
+ if (fromConfig.success) return fromConfig.data;
352
+ const fromSources = positiveIntValue.safeParse(text(input, options));
353
+ return fromSources.success ? fromSources.data : Math.floor(fallback);
354
+ }
355
+
356
+ /**
357
+ * Resolve a TCP port between 1 and {@link MAX_TCP_PORT}. Invalid configured or
358
+ * sourced values fall back to the caller's default, which may be a sentinel
359
+ * such as `0` when the caller uses one.
360
+ */
361
+ export function port(
362
+ configured: unknown,
363
+ input: ConfigKey,
364
+ fallback: number,
365
+ options?: ConfigOptions,
366
+ ): number {
367
+ const fromConfig = portValueSchema.safeParse(configured);
368
+ if (fromConfig.success) return fromConfig.data;
369
+ const fromSources = portValueSchema.safeParse(text(input, options));
370
+ return fromSources.success ? fromSources.data : fallback;
363
371
  }
364
372
 
365
373
  /**
@@ -372,31 +380,19 @@ export function list(
372
380
  transform?: (entry: string) => string,
373
381
  options?: ConfigOptions,
374
382
  ): string[] {
375
- const fromConfig = stringModule.parseList(configured, transform);
383
+ const fromConfig = sharedString.parseList(configured, transform);
376
384
  return fromConfig.length > 0
377
385
  ? fromConfig
378
- : stringModule.parseList(text(input, options), transform);
379
- }
380
-
381
- function toPositiveNumber(value: unknown): number | undefined {
382
- const parsed = object.toNumber(value);
383
- return parsed !== undefined && parsed > 0 ? parsed : undefined;
386
+ : sharedString.parseList(text(input, options), transform);
384
387
  }
385
388
 
386
389
  /**
387
390
  * The Databricks bundle output for `cwd` - `databricks bundle validate --output
388
391
  * json` run from the directory holding `databricks.yml`, with the config file's
389
392
  * path. A non-zero validation may still return partial JSON with usable App
390
- * config. `undefined` when bundle reads are disabled, this is not an AppKit
391
- * project, the bundle does not describe exactly one app with `config.env`, there
392
- * is no bundle, or the CLI produces no JSON.
393
- *
394
- * The spawn is guarded because it is expensive and usually pointless. In order:
395
- * `@databricks/appkit` must be RESOLVABLE (no AppKit, no bundle - and the probe
396
- * never evaluates the module); the bundle must describe exactly ONE app carrying
397
- * `config.env`; and AppKit's execution context must confirm this process is that
398
- * app. Only the confirmation is remembered, so a lookup during boot - before any
399
- * context exists - still resolves from the bundle and re-confirms later.
393
+ * config. `undefined` when bundle reads are disabled, the process is production
394
+ * or a deployed App without an explicit override, there is no bundle, or the
395
+ * CLI produces no JSON.
400
396
  *
401
397
  * Cached once per resolved working-directory context and
402
398
  * `DATABRICKS_CONFIG_PROFILE` through {@link context.cached}, so repeated
@@ -404,149 +400,32 @@ function toPositiveNumber(value: unknown): number | undefined {
404
400
  * context's bundle.
405
401
  */
406
402
  export function bundleFile(cwd?: string | null): ConfigFile | undefined {
407
- const production = process.env.NODE_ENV?.trim().toLowerCase() === "production";
408
403
  const override = object.toBoolean(process.env[CONFIG_BUNDLE_KEY]);
409
404
  if (override === false) return undefined;
410
- // An explicit `true` is the escape hatch for a tool that wants the bundle
411
- // without being the app (`dbx` commands, tests), so it skips the AppKit gate
412
- // entirely - but not the App/production defaults, which it also overrides.
413
- if (override === undefined) {
414
- if (production || isDatabricksAppEnv()) return undefined;
415
- if (!appKitInstalled()) return undefined;
405
+ if (
406
+ override === undefined &&
407
+ (process.env.NODE_ENV?.trim().toLowerCase() === "production" || isDatabricksAppEnv())
408
+ ) {
409
+ return undefined;
416
410
  }
417
- const profile = stringModule.trimToNull(process.env.DATABRICKS_CONFIG_PROFILE);
418
- const file = cachedConfig(["bundle", profile ?? ""], cwd, (resolved) =>
411
+ const profile = sharedString.trimToNull(process.env.DATABRICKS_CONFIG_PROFILE);
412
+ return cachedConfig(["bundle", profile ?? ""], cwd, (resolved) =>
419
413
  loadBundleFile(resolved, profile),
420
414
  );
421
- if (override !== undefined || file === undefined) return file;
422
- // Already confirmed: this process is the app, and that does not stop being
423
- // true, so skip re-deriving the gate on every lookup.
424
- if (appKitContext) return file;
425
- // The app must be unambiguous. `bundleApp` is what decides "exactly one app
426
- // with `config.env`", so an empty map IS the ambiguous or app-less bundle and
427
- // needs no separate check.
428
- if (Object.keys(bundleApp(file.data)).length === 0) return undefined;
429
- confirmAppKitContext();
430
- return file;
431
415
  }
432
416
 
433
- /**
434
- * The single App's literal `config.env` entries, flattened to `name -> value`.
435
- *
436
- * A `value_from` entry names a bundle resource whose id is known after
437
- * deployment, and reading it needs the resource vocabulary this module
438
- * deliberately does not have - node-appkit resolves those against its extended
439
- * {@link bundleResourceSchema}. An `apps` block with more than one app is
440
- * ambiguous (nothing here says which app this process is), so it yields nothing
441
- * rather than a guess.
442
- *
443
- * Memoized against the parsed bundle it came from, because both the gate in
444
- * {@link bundleFile} and the lookup in {@link read} ask the same question of the
445
- * same cached object - validating that payload twice per lookup would be pure
446
- * waste. A `WeakMap` keeps the entry alive exactly as long as the bundle is.
447
- */
448
- function bundleApp(input: unknown): Record<string, string> {
449
- if (!object.isRecord(input)) return bundleAppEntries(input);
450
- const cached = BUNDLE_APP_CACHE.get(input);
451
- if (cached) return cached;
452
- const result = bundleAppEntries(input);
453
- BUNDLE_APP_CACHE.set(input, result);
454
- return result;
455
- }
456
-
457
- /** {@link bundleApp} without the memoization, so the cache has one filler. */
458
- function bundleAppEntries(input: unknown): Record<string, string> {
459
- const parsed = bundleAppsSchema.safeParse(input);
460
- if (!parsed.success) return {};
461
- const apps = parsed.data.resources?.apps;
462
- if (!apps) return {};
463
- const names = Object.keys(apps);
464
- if (names.length !== 1) return {};
465
- const entries = apps[names[0]!]?.config?.env ?? [];
466
- const result: Record<string, string> = {};
467
- for (const entry of entries) {
468
- if (!entry.name) continue;
469
- const value = resolvedString(entry.value);
470
- if (value !== null) result[entry.name] = value;
471
- }
472
- return result;
473
- }
474
-
475
- /**
476
- * Whether `@databricks/appkit` can be RESOLVED from this process, cached for its
477
- * lifetime.
478
- *
479
- * Resolution, not import: this answers "is this an AppKit project" without
480
- * evaluating the module, so the cheap negative (a plain library or CLI consumer
481
- * that never installed the optional peer) costs one path lookup and never boots
482
- * AppKit as a side effect of reading config. A runtime with no `createRequire`
483
- * (a browser bundle that reached this Node-only module anyway) reports absent.
484
- */
485
- const appKitInstalled = functionModule.memoize((): boolean => {
486
- try {
487
- // Indirect specifier: a bundler must not try to follow an OPTIONAL peer that
488
- // a browser build has no way to resolve.
489
- const specifier = APPKIT_MODULE;
490
- createRequire(import.meta.url).resolve(specifier);
491
- return true;
492
- } catch {
493
- return false;
494
- }
495
- });
496
-
497
- /**
498
- * AppKit's execution context once it exists, or `undefined`.
499
- *
500
- * Lazily imported (and `@vite-ignore`d) because `@databricks/appkit` is an
501
- * OPTIONAL peer this module must never make required. The import is only
502
- * attempted once {@link appKitInstalled} says there is something to import, and a
503
- * failure resolves to `undefined` rather than rejecting - a missing peer is the
504
- * expected path, not an error.
505
- *
506
- * `getExecutionContext()` THROWS until AppKit has booted, so the call is caught
507
- * and only a real context is reported. The module handle is memoized; the
508
- * context lookup is not, so a call before boot answers "not yet" and a later one
509
- * still sees the context.
510
- */
511
- const appKitModule = functionModule.memoize(async (): Promise<AppKitModule | undefined> => {
512
- if (!appKitInstalled()) return undefined;
513
- const specifier = APPKIT_MODULE;
514
- return (await import(/* @vite-ignore */ specifier).catch(() => undefined)) as
515
- AppKitModule | undefined;
516
- });
517
-
518
- /**
519
- * Latch {@link appKitContext} once AppKit's execution context exists.
520
- *
521
- * Fire-and-forget, because the caller is a SYNCHRONOUS lookup and the check
522
- * needs a lazy import. The affirmative is all that is stored: AppKit's context
523
- * does not exist until it boots, and `getExecutionContext()` throws until then,
524
- * so remembering a negative would permanently disable the bundle for a process
525
- * that is seconds away from having one. Every call before the latch closes
526
- * re-attempts, which is what makes "not available yet" retry rather than stick.
527
- */
528
- function confirmAppKitContext(): void {
529
- void appKitModule()
530
- .then((appkit) => {
531
- if (!appkit) return;
532
- try {
533
- if (appkit.getExecutionContext()?.client) appKitContext = true;
534
- } catch {
535
- // AppKit has not booted yet; a later call re-checks.
536
- }
537
- })
538
- .catch(() => undefined);
539
- }
540
-
541
- /** A non-empty bundle value with every `${...}` interpolation resolved. */
542
- function resolvedString(value: unknown): string | null {
543
- const parsed = bundleValue.safeParse(value);
544
- return parsed.success ? parsed.data : null;
417
+ /** The single bundle App's resolved literal environment, when available. */
418
+ function bundleEnvironment(cwd?: string): Record<string, string> | undefined {
419
+ const file = bundleFile(cwd);
420
+ if (!file) return undefined;
421
+ const parsed = bundleAppConfigEnvSchema.safeParse(file.data);
422
+ return parsed.success ? parsed.data : undefined;
545
423
  }
546
424
 
547
425
  /** Parsed `.env` for `cwd`, or `{}`. Read once per resolved context. */
548
426
  function dotenv(cwd?: string | null): Record<string, string | undefined> {
549
- if (!fileSourceEnabled(CONFIG_DOTENV_KEY)) return {};
427
+ const enabled = object.toBoolean(process.env[CONFIG_DOTENV_KEY]) ?? !isDatabricksAppEnv();
428
+ if (!enabled) return {};
550
429
  const environments = nodeEnvNames(process.env.NODE_ENV);
551
430
  return cachedConfig(["dotenv", ...environments], cwd, (resolved) =>
552
431
  loadDotenv(resolved, environments),
@@ -564,11 +443,7 @@ function cachedConfig<T>(
564
443
  return context.cached(["config", active, resolved, ...name], () => loader(resolved));
565
444
  }
566
445
 
567
- /**
568
- * Maps for one source in precedence order. Bundle App config and root variables
569
- * stay separate so a first-match lookup does not parse variables after an App
570
- * value resolves.
571
- */
446
+ /** Maps for one source in precedence order. */
572
447
  function read(
573
448
  source: ConfigSource,
574
449
  cwd?: string,
@@ -583,8 +458,8 @@ function read(
583
458
  yield dotenv(cwd);
584
459
  break;
585
460
  case "bundle": {
586
- const bundle = bundleFile(cwd);
587
- if (bundle) yield bundleApp(bundle.data);
461
+ const environment = bundleEnvironment(cwd);
462
+ if (environment) yield environment;
588
463
  break;
589
464
  }
590
465
  }
@@ -618,11 +493,6 @@ function findConfigFile(cwd: string, names: readonly string[]): string | undefin
618
493
  return undefined;
619
494
  }
620
495
 
621
- /** A recognized source override, else the caller's automatic default. */
622
- function fileSourceEnabled(key: string, fallback: boolean = !isDatabricksAppEnv()): boolean {
623
- return object.toBoolean(process.env[key]) ?? fallback;
624
- }
625
-
626
496
  function loadDotenv(
627
497
  cwd: string,
628
498
  environments: readonly string[],
@@ -653,8 +523,8 @@ function loadBundleFile(cwd: string, profile: string | null): ConfigFile | undef
653
523
  encoding: "utf8",
654
524
  stdio: ["ignore", "pipe", "pipe"],
655
525
  });
656
- const output = stringModule.trimToNull(result.stdout);
657
- const error = stringModule.trimToNull(result.stderr);
526
+ const output = sharedString.trimToNull(result.stdout);
527
+ const error = sharedString.trimToNull(result.stderr);
658
528
  if (output === null) {
659
529
  logger.debug("bundle validate produced no JSON", { path, status: result.status, error });
660
530
  return undefined;