@dbx-tools/core 0.6.87 → 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/README.md +9 -14
- package/index.ts +1 -2
- package/lib/index.d.ts +1 -2
- package/lib/index.js +2 -2
- package/lib/src/config.d.ts +26 -51
- package/lib/src/config.js +123 -274
- package/lib/src/project.d.ts +1 -1
- package/lib/src/project.js +8 -8
- package/package.json +2 -11
- package/src/config.ts +149 -279
- package/src/project.ts +8 -8
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`
|
|
7
|
-
* `
|
|
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
|
|
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
|
-
|
|
50
|
+
type ConfigKey = string | readonly string[];
|
|
71
51
|
|
|
72
52
|
/** Where a value may come from, consulted in the order given. */
|
|
73
|
-
|
|
53
|
+
type ConfigSource = "env" | "dotenv" | "bundle";
|
|
74
54
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
+
const DATABRICKS_APP_ENV_KEY = "DBX_TOOLS_DATABRICKS_APP_ENV";
|
|
115
95
|
|
|
116
96
|
/** Boolean environment override for project `.env` reads. */
|
|
117
|
-
|
|
97
|
+
const CONFIG_DOTENV_KEY = "DBX_TOOLS_CONFIG_DOTENV";
|
|
118
98
|
|
|
119
99
|
/** Boolean environment override for Databricks bundle reads. */
|
|
120
|
-
|
|
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
|
|
105
|
+
export const valueSchema = z
|
|
152
106
|
.string()
|
|
153
|
-
.
|
|
154
|
-
.
|
|
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:
|
|
145
|
+
export const bundleResourceSchema = z.object({ name: valueSchema.optional() }).passthrough();
|
|
167
146
|
|
|
168
147
|
export const bundleEnvEntrySchema = z.object({
|
|
169
|
-
name:
|
|
148
|
+
name: valueSchema.optional(),
|
|
170
149
|
value: z.string().optional(),
|
|
171
|
-
value_from:
|
|
150
|
+
value_from: valueSchema.optional(),
|
|
172
151
|
});
|
|
173
152
|
|
|
174
153
|
export const bundleAppSchema = z.object({
|
|
175
|
-
name:
|
|
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
|
|
182
|
-
|
|
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
|
|
199
|
-
|
|
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(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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).
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
362
|
-
return
|
|
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 =
|
|
383
|
+
const fromConfig = sharedString.parseList(configured, transform);
|
|
376
384
|
return fromConfig.length > 0
|
|
377
385
|
? fromConfig
|
|
378
|
-
:
|
|
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,
|
|
391
|
-
*
|
|
392
|
-
*
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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 =
|
|
418
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
|
587
|
-
if (
|
|
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 =
|
|
657
|
-
const error =
|
|
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;
|