@dbx-tools/core 0.6.89 → 0.6.90
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 +42 -12
- package/index.ts +2 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -2
- package/lib/src/config.d.ts +53 -30
- package/lib/src/config.js +269 -98
- package/lib/src/file.d.ts +8 -0
- package/lib/src/file.js +16 -1
- package/lib/src/project.d.ts +7 -4
- package/lib/src/project.js +52 -35
- package/package.json +2 -2
- package/src/config.ts +296 -122
- package/src/file.ts +19 -0
- package/src/project.ts +57 -42
package/src/config.ts
CHANGED
|
@@ -1,29 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Layered configuration lookup: environment, `.env`,
|
|
2
|
+
* Layered configuration lookup: environment, `.env`, bundle, and `app.yaml`.
|
|
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
|
|
7
|
-
* `databricks.yml`.
|
|
6
|
+
* locations: `.env` files, one App's `resources.apps.<app>.config.env` in
|
|
7
|
+
* `databricks.yml`, and literal env values in `app.yaml` / `app.yml`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* {@link values} is LAZY (an `object.Sequence`), so `databricks bundle validate`
|
|
10
|
+
* is only spawned when the environment and `.env` both missed; app YAML is only
|
|
11
|
+
* read after the bundle source also missed.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
* validate` is only spawned when the environment and `.env` both missed.
|
|
13
|
-
* - the parsed `.env` and bundle are cached through {@link context.cached}, so
|
|
14
|
-
* the spawn happens once per working directory and a `cwd` change misses
|
|
15
|
-
* rather than returning another project's config.
|
|
16
|
-
*
|
|
17
|
-
* Inside a deployed Databricks App both file sources are skipped by default
|
|
13
|
+
* Inside a deployed Databricks App all three file sources are skipped by default
|
|
18
14
|
* ({@link isDatabricksAppEnv}): the platform has already turned them into real
|
|
19
15
|
* environment variables, there is no bundle to validate, and the `databricks`
|
|
20
16
|
* CLI is not on the image. Boolean environment overrides can force either file
|
|
21
17
|
* source on or off when a tool needs different behavior.
|
|
22
18
|
*
|
|
23
|
-
* Only the single app's `config.env`
|
|
24
|
-
*
|
|
25
|
-
* targets, resources, and paths),
|
|
26
|
-
* names the deployed app never sees.
|
|
19
|
+
* Only the single bundle app's `config.env` and literal app YAML `env[].value`
|
|
20
|
+
* entries are consulted. Root bundle `variables` are not: they are authoring
|
|
21
|
+
* inputs for the bundle itself (interpolated into targets, resources, and paths),
|
|
22
|
+
* so treating one as a process setting resolves names the deployed app never sees.
|
|
27
23
|
*
|
|
28
24
|
* Node-only (`child_process`, `fs`, `process`).
|
|
29
25
|
*
|
|
@@ -34,32 +30,37 @@ import { spawnSync } from "node:child_process";
|
|
|
34
30
|
import { readFileSync } from "node:fs";
|
|
35
31
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
36
32
|
import { parseEnv } from "node:util";
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
json,
|
|
40
|
-
log,
|
|
41
|
-
object,
|
|
42
|
-
string as sharedString,
|
|
43
|
-
} from "@dbx-tools/shared-core";
|
|
33
|
+
import { json, log, object, string as sharedString } from "@dbx-tools/shared-core";
|
|
34
|
+
import { parse as parseYaml } from "yaml";
|
|
44
35
|
import { z } from "zod";
|
|
45
|
-
import { statSync } from "./file.ts";
|
|
46
|
-
import { root as resolveProjectRoot } from "./project.ts";
|
|
36
|
+
import { cachedRecord, statSync } from "./file.ts";
|
|
37
|
+
import { resolveWorkingDirectory, root as resolveProjectRoot } from "./project.ts";
|
|
47
38
|
|
|
48
39
|
const logger = log.logger("config");
|
|
40
|
+
const configFileCache = new Map<string, string | undefined>();
|
|
49
41
|
|
|
50
42
|
type ConfigKey = string | readonly string[];
|
|
51
43
|
|
|
44
|
+
export type ConfigMapValue = string | readonly string[] | null | undefined;
|
|
45
|
+
export type ConfigData = Readonly<Record<string, ConfigMapValue>>;
|
|
46
|
+
|
|
52
47
|
/** Where a value may come from, consulted in the order given. */
|
|
53
|
-
type ConfigSource = "env" | "dotenv" | "bundle";
|
|
48
|
+
export type ConfigSource = "config" | "env" | "dotenv" | "bundle" | "app";
|
|
54
49
|
|
|
55
|
-
interface ConfigOptions {
|
|
50
|
+
export interface ConfigOptions {
|
|
56
51
|
/**
|
|
57
52
|
* Outermost namespaces tried before each key. Defaults to `DBX_TOOLS`.
|
|
58
53
|
*/
|
|
59
54
|
scope?: string | readonly string[];
|
|
60
55
|
/** Capability namespaces inserted after the scope and before each key. */
|
|
61
56
|
prefix?: string | readonly string[];
|
|
62
|
-
/**
|
|
57
|
+
/** Constant config maps read by the `config` source. */
|
|
58
|
+
data?: ConfigData | readonly ConfigData[];
|
|
59
|
+
/** Parsed bundle data used instead of loading `databricks.yml`. */
|
|
60
|
+
bundleData?: ConfigFile | Record<string, unknown>;
|
|
61
|
+
/** Parsed app YAML data used instead of loading `app.yaml`. */
|
|
62
|
+
appData?: ConfigFile | Record<string, unknown>;
|
|
63
|
+
/** Sources in precedence order. Default: `config`, `env`, `dotenv`, `bundle`, `app`. */
|
|
63
64
|
sources?: ConfigSource | readonly ConfigSource[];
|
|
64
65
|
/** Directory to resolve `.env` and the bundle from. Default: `process.cwd()`. */
|
|
65
66
|
cwd?: string;
|
|
@@ -73,14 +74,15 @@ interface ConfigValue {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/** A config file found on disk, with its parsed contents. */
|
|
76
|
-
interface ConfigFile {
|
|
77
|
+
export interface ConfigFile {
|
|
77
78
|
path: string;
|
|
78
79
|
data: Record<string, unknown>;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
const DEFAULT_SCOPE = "DBX_TOOLS";
|
|
82
|
-
const DEFAULT_SOURCES: readonly ConfigSource[] = ["env", "dotenv", "bundle"];
|
|
83
|
+
const DEFAULT_SOURCES: readonly ConfigSource[] = ["config", "env", "dotenv", "bundle", "app"];
|
|
83
84
|
const BUNDLE_FILE_NAMES = ["databricks.yml", "databricks.yaml"] as const;
|
|
85
|
+
const APP_FILE_NAMES = ["app.yaml", "app.yml"] as const;
|
|
84
86
|
const DOTENV_FILE_NAME = ".env";
|
|
85
87
|
const NODE_ENV_ALTERNATIVES = {
|
|
86
88
|
production: ["prod"],
|
|
@@ -99,6 +101,9 @@ const CONFIG_DOTENV_KEY = "DBX_TOOLS_CONFIG_DOTENV";
|
|
|
99
101
|
/** Boolean environment override for Databricks bundle reads. */
|
|
100
102
|
const CONFIG_BUNDLE_KEY = "DBX_TOOLS_CONFIG_BUNDLE";
|
|
101
103
|
|
|
104
|
+
/** Boolean environment override for Databricks App YAML reads. */
|
|
105
|
+
const CONFIG_APP_KEY = "DBX_TOOLS_CONFIG_APP";
|
|
106
|
+
|
|
102
107
|
/** Exact process-environment lookup for callers that do not read local config files. */
|
|
103
108
|
export const ENV_ONLY = { scope: [] as const, sources: "env" as const };
|
|
104
109
|
|
|
@@ -135,13 +140,7 @@ const databricksAppEnvSchema = z.object({
|
|
|
135
140
|
DATABRICKS_APP_PORT: portValueSchema,
|
|
136
141
|
});
|
|
137
142
|
|
|
138
|
-
/**
|
|
139
|
-
* The GENERIC shape of a bundle resource: a name, and whatever else the resource
|
|
140
|
-
* type carries. Deliberately unopinionated and `passthrough()` - the concrete
|
|
141
|
-
* resource kinds (`sql_warehouse`, `genie_space`, `postgres`, ...) are
|
|
142
|
-
* Databricks-App concepts that belong to the package that resolves them, so
|
|
143
|
-
* node-appkit `.extend()`s this rather than this module knowing about them.
|
|
144
|
-
*/
|
|
143
|
+
/** A named bundle or App resource; concrete resource fields pass through. */
|
|
145
144
|
export const bundleResourceSchema = z.object({ name: valueSchema.optional() }).passthrough();
|
|
146
145
|
|
|
147
146
|
export const bundleEnvEntrySchema = z.object({
|
|
@@ -156,21 +155,108 @@ export const bundleAppSchema = z.object({
|
|
|
156
155
|
resources: z.array(bundleResourceSchema).optional(),
|
|
157
156
|
});
|
|
158
157
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
158
|
+
export const appEnvEntrySchema = z.object({
|
|
159
|
+
name: valueSchema,
|
|
160
|
+
value: z.string().optional(),
|
|
161
|
+
valueFrom: valueSchema.optional(),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
export const appSchema = z.object({
|
|
165
|
+
env: z.array(appEnvEntrySchema).optional(),
|
|
166
|
+
resources: z.array(bundleResourceSchema).optional(),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const bundleConfigSchema = z.object({
|
|
170
|
+
resources: z.object({ apps: z.record(z.string(), bundleAppSchema).optional() }).optional(),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/** Flatten the single bundle App's `config.env`, resolving resource references. */
|
|
174
|
+
export function flattenBundleEnv(data: unknown): Record<string, string> {
|
|
175
|
+
const parsed = bundleConfigSchema.safeParse(data);
|
|
176
|
+
if (!parsed.success) return {};
|
|
177
|
+
const apps = Object.values(parsed.data.resources?.apps ?? {});
|
|
178
|
+
if (apps.length !== 1) return {};
|
|
179
|
+
const app = apps[0];
|
|
180
|
+
const resources = new Map(
|
|
181
|
+
(app?.resources ?? [])
|
|
182
|
+
.filter((resource) => resource.name)
|
|
183
|
+
.map((resource) => [resource.name!, resource] as const),
|
|
184
|
+
);
|
|
185
|
+
const result: Record<string, string> = {};
|
|
186
|
+
for (const entry of app?.config?.env ?? []) {
|
|
187
|
+
if (!entry.name) continue;
|
|
188
|
+
const literal = valueSchema.safeParse(entry.value);
|
|
189
|
+
if (literal.success) {
|
|
190
|
+
result[entry.name] = literal.data;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const referenced = entry.value_from
|
|
194
|
+
? resourceValue(resources.get(entry.value_from))
|
|
195
|
+
: undefined;
|
|
196
|
+
if (referenced) result[entry.name] = referenced;
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Flatten `app.yaml` env entries, resolving `valueFrom` resource references. */
|
|
202
|
+
export function flattenAppEnv(data: unknown): Record<string, string> {
|
|
203
|
+
const parsed = appSchema.safeParse(data);
|
|
204
|
+
if (!parsed.success) return {};
|
|
205
|
+
const resources = new Map(
|
|
206
|
+
(parsed.data.resources ?? [])
|
|
207
|
+
.filter((resource) => resource.name)
|
|
208
|
+
.map((resource) => [resource.name!, resource] as const),
|
|
209
|
+
);
|
|
210
|
+
const result: Record<string, string> = {};
|
|
211
|
+
for (const entry of parsed.data.env ?? []) {
|
|
212
|
+
const literal = valueSchema.safeParse(entry.value);
|
|
213
|
+
if (literal.success) {
|
|
214
|
+
result[entry.name] = literal.data;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const referenced = entry.valueFrom ? resourceValue(resources.get(entry.valueFrom)) : undefined;
|
|
218
|
+
if (referenced) result[entry.name] = referenced;
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resourceValue(resource: unknown): string | undefined {
|
|
224
|
+
for (const path of [
|
|
225
|
+
["sql_warehouse", "id"],
|
|
226
|
+
["genie_space", "space_id"],
|
|
227
|
+
["postgres", "endpoint"],
|
|
228
|
+
["postgres", "database"],
|
|
229
|
+
["postgres", "branch"],
|
|
230
|
+
] as const) {
|
|
231
|
+
let value = resource;
|
|
232
|
+
for (const part of path) value = object.isRecord(value) ? value[part] : undefined;
|
|
233
|
+
const parsed = valueSchema.safeParse(value);
|
|
234
|
+
if (parsed.success) return parsed.data;
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Walk a dot-separated path through parsed bundle data. */
|
|
240
|
+
export function getBundlePath(data: Record<string, unknown>, path: string): string | undefined {
|
|
241
|
+
const parts = path.split(".").filter(Boolean);
|
|
242
|
+
if (parts.length === 0) return undefined;
|
|
243
|
+
let current: unknown = data;
|
|
244
|
+
for (let index = 0; index < parts.length; index++) {
|
|
245
|
+
if (!object.isRecord(current)) return undefined;
|
|
246
|
+
const next = current[parts[index]!];
|
|
247
|
+
if (index === parts.length - 1) {
|
|
248
|
+
const direct = valueSchema.safeParse(next);
|
|
249
|
+
if (direct.success) return direct.data;
|
|
250
|
+
if (object.isRecord(next)) {
|
|
251
|
+
const nested = valueSchema.safeParse(next.value);
|
|
252
|
+
return nested.success ? nested.data : undefined;
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
current = next;
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
174
260
|
|
|
175
261
|
/**
|
|
176
262
|
* Detect a Databricks App runtime from its required name, host, and port.
|
|
@@ -189,6 +275,21 @@ export function isDatabricksAppEnv(
|
|
|
189
275
|
return parsed.success;
|
|
190
276
|
}
|
|
191
277
|
|
|
278
|
+
/** Exact, uppercase, and tokenized-uppercase names for a human-friendly key. */
|
|
279
|
+
export function environmentKeys(name: string): readonly string[] {
|
|
280
|
+
const trimmed = name.trim();
|
|
281
|
+
if (!trimmed) return [];
|
|
282
|
+
return object
|
|
283
|
+
.sequence(
|
|
284
|
+
trimmed,
|
|
285
|
+
trimmed.toUpperCase(),
|
|
286
|
+
Array.from(sharedString.tokenize(trimmed)).join("_").toUpperCase(),
|
|
287
|
+
)
|
|
288
|
+
.filter(Boolean)
|
|
289
|
+
.distinct()
|
|
290
|
+
.toArray();
|
|
291
|
+
}
|
|
292
|
+
|
|
192
293
|
/** Candidate names in scope, prefix, and input precedence order. */
|
|
193
294
|
function keys(
|
|
194
295
|
input: ConfigKey,
|
|
@@ -235,13 +336,13 @@ function keys(
|
|
|
235
336
|
*/
|
|
236
337
|
function values(input: ConfigKey, options: ConfigOptions = {}): object.Sequence<ConfigValue> {
|
|
237
338
|
const candidates = keys(input, options);
|
|
238
|
-
const sources =
|
|
339
|
+
const sources = configSources(options);
|
|
239
340
|
return object.sequence({
|
|
240
341
|
*[Symbol.iterator](): Generator<ConfigValue> {
|
|
241
342
|
for (const source of sources) {
|
|
242
|
-
for (const map of read(source, options
|
|
343
|
+
for (const map of read(source, options)) {
|
|
243
344
|
for (const key of candidates) {
|
|
244
|
-
const value =
|
|
345
|
+
const value = configMapValue(map[key]);
|
|
245
346
|
if (value !== null) yield { key, source, value };
|
|
246
347
|
}
|
|
247
348
|
}
|
|
@@ -250,6 +351,27 @@ function values(input: ConfigKey, options: ConfigOptions = {}): object.Sequence<
|
|
|
250
351
|
});
|
|
251
352
|
}
|
|
252
353
|
|
|
354
|
+
function configSources(options: ConfigOptions): readonly ConfigSource[] {
|
|
355
|
+
const sources: ConfigSource[] = [
|
|
356
|
+
...object.sequence<ConfigSource>(options.sources ?? DEFAULT_SOURCES).distinct(),
|
|
357
|
+
];
|
|
358
|
+
if (options.data !== undefined && options.sources !== undefined && !sources.includes("config")) {
|
|
359
|
+
sources.push("config");
|
|
360
|
+
}
|
|
361
|
+
return sources;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function configMapValue(value: ConfigMapValue | unknown): string | null {
|
|
365
|
+
if (Array.isArray(value)) {
|
|
366
|
+
for (const entry of value) {
|
|
367
|
+
const resolved = sharedString.trimToNull(entry);
|
|
368
|
+
if (resolved !== null) return resolved;
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
return sharedString.trimToNull(value);
|
|
373
|
+
}
|
|
374
|
+
|
|
253
375
|
/**
|
|
254
376
|
* The first value that resolves for `input`, or `undefined`.
|
|
255
377
|
*
|
|
@@ -260,6 +382,11 @@ export function text(input: ConfigKey, options: ConfigOptions = {}): string | un
|
|
|
260
382
|
return values(input, options).at(0)?.value;
|
|
261
383
|
}
|
|
262
384
|
|
|
385
|
+
/** Resolve a human-friendly name through {@link environmentKeys} and {@link text}. */
|
|
386
|
+
export function resolveValue(name: string, options: ConfigOptions = {}): string | undefined {
|
|
387
|
+
return text(environmentKeys(name), options);
|
|
388
|
+
}
|
|
389
|
+
|
|
263
390
|
/**
|
|
264
391
|
* The PRIMARY (fully-scoped) name for `input` - what to print in a log line or an
|
|
265
392
|
* error, so the message names the variable a reader should set. Do not index
|
|
@@ -394,10 +521,8 @@ export function list(
|
|
|
394
521
|
* or a deployed App without an explicit override, there is no bundle, or the
|
|
395
522
|
* CLI produces no JSON.
|
|
396
523
|
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
* lookups do not rerun validation and changing either cannot return another
|
|
400
|
-
* context's bundle.
|
|
524
|
+
* Parsed validation output is cached by bundle path and Databricks profile.
|
|
525
|
+
*
|
|
401
526
|
*/
|
|
402
527
|
export function bundleFile(cwd?: string | null): ConfigFile | undefined {
|
|
403
528
|
const override = object.toBoolean(process.env[CONFIG_BUNDLE_KEY]);
|
|
@@ -408,60 +533,81 @@ export function bundleFile(cwd?: string | null): ConfigFile | undefined {
|
|
|
408
533
|
) {
|
|
409
534
|
return undefined;
|
|
410
535
|
}
|
|
411
|
-
const
|
|
412
|
-
return
|
|
413
|
-
|
|
414
|
-
|
|
536
|
+
const resolved = resolveWorkingDirectory(cwd);
|
|
537
|
+
return loadBundleFile(resolved, sharedString.trimToNull(process.env.DATABRICKS_CONFIG_PROFILE));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The parsed `app.yaml` / `app.yml` for `cwd`, when local App config reads are enabled. */
|
|
541
|
+
export function appFile(cwd?: string | null): ConfigFile | undefined {
|
|
542
|
+
const enabled = object.toBoolean(process.env[CONFIG_APP_KEY]) ?? !isDatabricksAppEnv();
|
|
543
|
+
if (!enabled) return undefined;
|
|
544
|
+
return loadAppFile(resolveWorkingDirectory(cwd));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** The single bundle App's resolved environment, when available. */
|
|
548
|
+
function bundleEnvironment(options: ConfigOptions): Record<string, string> | undefined {
|
|
549
|
+
const data = sourceData(options.bundleData) ?? bundleFile(options.cwd)?.data;
|
|
550
|
+
return data ? flattenBundleEnv(data) : undefined;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function appEnvironment(options: ConfigOptions): Record<string, string> | undefined {
|
|
554
|
+
const data = sourceData(options.appData) ?? appFile(options.cwd)?.data;
|
|
555
|
+
return data ? flattenAppEnv(data) : undefined;
|
|
415
556
|
}
|
|
416
557
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (!
|
|
421
|
-
|
|
422
|
-
|
|
558
|
+
function sourceData(
|
|
559
|
+
source: ConfigFile | Record<string, unknown> | undefined,
|
|
560
|
+
): Record<string, unknown> | undefined {
|
|
561
|
+
if (!source) return undefined;
|
|
562
|
+
if ("path" in source && typeof source.path === "string" && object.isRecord(source.data)) {
|
|
563
|
+
return source.data;
|
|
564
|
+
}
|
|
565
|
+
return source as Record<string, unknown>;
|
|
423
566
|
}
|
|
424
567
|
|
|
425
|
-
/** Parsed `.env` for `cwd`, or `{}
|
|
568
|
+
/** Parsed `.env` for `cwd`, or `{}`; parsed file data is cached by path. */
|
|
426
569
|
function dotenv(cwd?: string | null): Record<string, string | undefined> {
|
|
427
570
|
const enabled = object.toBoolean(process.env[CONFIG_DOTENV_KEY]) ?? !isDatabricksAppEnv();
|
|
428
571
|
if (!enabled) return {};
|
|
429
|
-
const
|
|
430
|
-
return
|
|
431
|
-
loadDotenv(resolved, environments),
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** Cache every target directory separately within the active process context. */
|
|
436
|
-
function cachedConfig<T>(
|
|
437
|
-
name: readonly string[],
|
|
438
|
-
cwd: string | null | undefined,
|
|
439
|
-
loader: (resolved: string) => T,
|
|
440
|
-
): T {
|
|
441
|
-
const active = context.getContext() ?? "";
|
|
442
|
-
const resolved = resolve(cwd ?? ".");
|
|
443
|
-
return context.cached(["config", active, resolved, ...name], () => loader(resolved));
|
|
572
|
+
const resolved = resolveWorkingDirectory(cwd);
|
|
573
|
+
return loadDotenv(resolved, nodeEnvNames(process.env.NODE_ENV));
|
|
444
574
|
}
|
|
445
575
|
|
|
446
576
|
/** Maps for one source in precedence order. */
|
|
447
577
|
function read(
|
|
448
578
|
source: ConfigSource,
|
|
449
|
-
|
|
450
|
-
): object.Sequence<Record<string,
|
|
579
|
+
options: ConfigOptions,
|
|
580
|
+
): object.Sequence<Readonly<Record<string, unknown>>> {
|
|
451
581
|
return object.sequence({
|
|
452
|
-
*[Symbol.iterator](): Generator<Record<string,
|
|
582
|
+
*[Symbol.iterator](): Generator<Readonly<Record<string, unknown>>> {
|
|
453
583
|
switch (source) {
|
|
584
|
+
case "config": {
|
|
585
|
+
const data = options.data;
|
|
586
|
+
if (Array.isArray(data)) {
|
|
587
|
+
yield* data;
|
|
588
|
+
} else if (data !== undefined) {
|
|
589
|
+
yield data as ConfigData;
|
|
590
|
+
}
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
454
593
|
case "env":
|
|
455
594
|
yield process.env;
|
|
456
595
|
break;
|
|
457
596
|
case "dotenv":
|
|
458
|
-
yield dotenv(cwd);
|
|
597
|
+
yield dotenv(options.cwd);
|
|
459
598
|
break;
|
|
460
599
|
case "bundle": {
|
|
461
|
-
const environment = bundleEnvironment(
|
|
600
|
+
const environment = bundleEnvironment(options);
|
|
601
|
+
if (environment) yield environment;
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
case "app": {
|
|
605
|
+
const environment = appEnvironment(options);
|
|
462
606
|
if (environment) yield environment;
|
|
463
607
|
break;
|
|
464
608
|
}
|
|
609
|
+
default:
|
|
610
|
+
throw new TypeError(`Unknown config source: ${source}`);
|
|
465
611
|
}
|
|
466
612
|
},
|
|
467
613
|
});
|
|
@@ -473,6 +619,8 @@ function read(
|
|
|
473
619
|
*/
|
|
474
620
|
function findConfigFile(cwd: string, names: readonly string[]): string | undefined {
|
|
475
621
|
const start = resolve(cwd);
|
|
622
|
+
const key = JSON.stringify([start, ...names]);
|
|
623
|
+
if (configFileCache.has(key)) return configFileCache.get(key);
|
|
476
624
|
const root = resolveProjectRoot(start);
|
|
477
625
|
const pathFromRoot = root ? relative(root, start) : undefined;
|
|
478
626
|
const boundary =
|
|
@@ -486,10 +634,14 @@ function findConfigFile(cwd: string, names: readonly string[]): string | undefin
|
|
|
486
634
|
for (let dir = start; ; dir = dirname(dir)) {
|
|
487
635
|
for (const name of names) {
|
|
488
636
|
const path = resolve(dir, name);
|
|
489
|
-
if (statSync(path)?.isFile())
|
|
637
|
+
if (statSync(path)?.isFile()) {
|
|
638
|
+
configFileCache.set(key, path);
|
|
639
|
+
return path;
|
|
640
|
+
}
|
|
490
641
|
}
|
|
491
642
|
if (boundary === undefined || dir === boundary) break;
|
|
492
643
|
}
|
|
644
|
+
configFileCache.set(key, undefined);
|
|
493
645
|
return undefined;
|
|
494
646
|
}
|
|
495
647
|
|
|
@@ -500,42 +652,64 @@ function loadDotenv(
|
|
|
500
652
|
const names = [...environments.map((name) => `${DOTENV_FILE_NAME}.${name}`), DOTENV_FILE_NAME];
|
|
501
653
|
const path = findConfigFile(cwd, names);
|
|
502
654
|
if (!path) return {};
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
655
|
+
return (
|
|
656
|
+
cachedRecord(JSON.stringify(["dotenv", path]), () => {
|
|
657
|
+
try {
|
|
658
|
+
return parseEnv(readFileSync(path, "utf8"));
|
|
659
|
+
} catch {
|
|
660
|
+
logger.warn("failed to read dotenv file", { path });
|
|
661
|
+
return {};
|
|
662
|
+
}
|
|
663
|
+
}) ?? {}
|
|
664
|
+
);
|
|
509
665
|
}
|
|
510
666
|
|
|
511
667
|
function loadBundleFile(cwd: string, profile: string | null): ConfigFile | undefined {
|
|
512
668
|
const path = findConfigFile(cwd, BUNDLE_FILE_NAMES);
|
|
513
669
|
if (!path) return undefined;
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
670
|
+
const data = cachedRecord(JSON.stringify(["bundle", path, profile]), () => {
|
|
671
|
+
const args = [
|
|
672
|
+
"bundle",
|
|
673
|
+
"validate",
|
|
674
|
+
"--output",
|
|
675
|
+
"json",
|
|
676
|
+
...(profile === null ? [] : ["--profile", profile]),
|
|
677
|
+
];
|
|
678
|
+
const result = spawnSync("databricks", args, {
|
|
679
|
+
cwd: resolve(path, ".."),
|
|
680
|
+
encoding: "utf8",
|
|
681
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
682
|
+
});
|
|
683
|
+
const output = sharedString.trimToNull(result.stdout);
|
|
684
|
+
const error = sharedString.trimToNull(result.stderr);
|
|
685
|
+
if (output === null) {
|
|
686
|
+
logger.debug("bundle validate produced no JSON", { path, status: result.status, error });
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
const parsed = json.parseRecord(output);
|
|
690
|
+
if (!parsed) {
|
|
691
|
+
logger.warn("failed to parse bundle validate output", { path, status: result.status });
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
if (result.status !== 0) {
|
|
695
|
+
logger.debug("using partial bundle output", { path, status: result.status, error });
|
|
696
|
+
}
|
|
697
|
+
return parsed;
|
|
525
698
|
});
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const data =
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
699
|
+
return data === undefined ? undefined : { path, data };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function loadAppFile(cwd: string): ConfigFile | undefined {
|
|
703
|
+
const path = findConfigFile(cwd, APP_FILE_NAMES);
|
|
704
|
+
if (!path) return undefined;
|
|
705
|
+
const data = cachedRecord(JSON.stringify(["app", path]), () => {
|
|
706
|
+
try {
|
|
707
|
+
const parsed = parseYaml(readFileSync(path, "utf8"));
|
|
708
|
+
return object.isRecord(parsed) ? parsed : undefined;
|
|
709
|
+
} catch {
|
|
710
|
+
logger.warn("failed to parse app yaml", { path });
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
return data === undefined ? undefined : { path, data };
|
|
541
715
|
}
|
package/src/file.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Stats, statSync as nodeStatSync } from "node:fs";
|
|
7
7
|
|
|
8
|
+
const recordCache = new Map<string, Record<string, unknown> | undefined>();
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Best-effort `fs.stat` (sync). Returns `undefined` for a blank path or when the
|
|
10
12
|
* path can't be stat'd (missing, permission denied, ...), so callers can treat
|
|
@@ -18,3 +20,20 @@ export function statSync(path: string): Stats | undefined {
|
|
|
18
20
|
}
|
|
19
21
|
return undefined;
|
|
20
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load and cache a parsed record by caller-defined source identity.
|
|
26
|
+
*
|
|
27
|
+
* Every result is cached, including empty records and `undefined`. Include every
|
|
28
|
+
* input that affects parsing in `key`, such as a file path plus a Databricks
|
|
29
|
+
* profile.
|
|
30
|
+
*/
|
|
31
|
+
export function cachedRecord<T extends Record<string, unknown>>(
|
|
32
|
+
key: string,
|
|
33
|
+
loader: () => T | undefined,
|
|
34
|
+
): T | undefined {
|
|
35
|
+
if (recordCache.has(key)) return recordCache.get(key) as T | undefined;
|
|
36
|
+
const value = loader();
|
|
37
|
+
recordCache.set(key, value);
|
|
38
|
+
return value;
|
|
39
|
+
}
|