@dbx-tools/core 0.6.81 → 0.6.85

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 ADDED
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Layered configuration lookup: environment, `.env`, Databricks bundle.
3
+ *
4
+ * Every package resolves settings the same way: take the caller's value, else
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`.
8
+ *
9
+ * Two things make it cheap to call from a hot path:
10
+ *
11
+ * - {@link values} is LAZY (an `object.Sequence`), so `databricks bundle
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 outright
18
+ * ({@link isDatabricksAppEnv}): the platform has already turned them into real
19
+ * environment variables, there is no bundle to validate, and the `databricks`
20
+ * CLI is not on the image.
21
+ *
22
+ * Node-only (`child_process`, `fs`, `process`).
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import { spawnSync } from "node:child_process";
28
+ import { readFileSync } from "node:fs";
29
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
30
+ import { parseEnv } from "node:util";
31
+ import { context, json, log, object, string as stringModule } from "@dbx-tools/shared-core";
32
+ import { z } from "zod";
33
+ import { statSync } from "./file.ts";
34
+ import { root as resolveProjectRoot } from "./project.ts";
35
+
36
+ const logger = log.logger("config");
37
+
38
+ type ConfigKey = string | readonly string[];
39
+
40
+ /** Where a value may come from, consulted in the order given. */
41
+ type ConfigSource = "env" | "dotenv" | "bundle";
42
+
43
+ interface ConfigOptions {
44
+ /**
45
+ * Outermost namespaces tried before each key. Defaults to `DBX_TOOLS`.
46
+ */
47
+ scope?: string | readonly string[];
48
+ /** Capability namespaces inserted after the scope and before each key. */
49
+ prefix?: string | readonly string[];
50
+ /** Directory to resolve `.env` and the bundle from. Default: `process.cwd()`. */
51
+ cwd?: string;
52
+ /** Sources in precedence order. Default: `env`, `dotenv`, `bundle`. */
53
+ sources?: ConfigSource | readonly ConfigSource[];
54
+ }
55
+
56
+ /** One resolved value, tagged with the key and source it came from. */
57
+ interface ConfigValue {
58
+ key: string;
59
+ source: ConfigSource;
60
+ value: string;
61
+ }
62
+
63
+ /** A config file found on disk, with its parsed contents. */
64
+ export interface ConfigFile {
65
+ path: string;
66
+ data: Record<string, unknown>;
67
+ }
68
+
69
+ const DEFAULT_SCOPE = "DBX_TOOLS";
70
+ const DEFAULT_SOURCES: readonly ConfigSource[] = ["env", "dotenv", "bundle"];
71
+ const BUNDLE_FILE_NAMES = ["databricks.yml", "databricks.yaml"] as const;
72
+ const DOTENV_FILE_NAME = ".env";
73
+ const NODE_ENV_ALTERNATIVES = {
74
+ production: ["prod"],
75
+ development: ["dev"],
76
+ } as const satisfies Record<string, readonly string[]>;
77
+
78
+ /** Highest valid TCP port number. */
79
+ export const MAX_TCP_PORT = 65_535;
80
+
81
+ /** Exact process-environment lookup for callers that do not read local config files. */
82
+ export const ENV_ONLY = { scope: [] as const, sources: "env" as const };
83
+
84
+ export const bundleValue = z
85
+ .string()
86
+ .transform((value: string) => value.trim())
87
+ .refine((value: string) => value.length > 0)
88
+ .refine((value: string) => !/\$\{[^}]+\}/.test(value), {
89
+ message: "Interpolated values are not allowed",
90
+ });
91
+
92
+ /**
93
+ * The GENERIC shape of a bundle resource: a name, and whatever else the resource
94
+ * type carries. Deliberately unopinionated and `passthrough()` - the concrete
95
+ * resource kinds (`sql_warehouse`, `genie_space`, `postgres`, ...) are
96
+ * Databricks-App concepts that belong to the package that resolves them, so
97
+ * node-appkit `.extend()`s this rather than this module knowing about them.
98
+ */
99
+ export const bundleResourceSchema = z.object({ name: bundleValue.optional() }).passthrough();
100
+
101
+ export const bundleEnvEntrySchema = z.object({
102
+ name: bundleValue.optional(),
103
+ value: z.string().optional(),
104
+ value_from: bundleValue.optional(),
105
+ });
106
+
107
+ const bundleVariableSchema = z.object({
108
+ default: z.string().optional(),
109
+ value: z.string().optional(),
110
+ });
111
+
112
+ export const bundleAppSchema = z.object({
113
+ name: bundleValue.optional(),
114
+ source_code_path: z.string().optional(),
115
+ config: z.object({ env: z.array(bundleEnvEntrySchema).optional() }).optional(),
116
+ resources: z.array(bundleResourceSchema).optional(),
117
+ });
118
+
119
+ const bundleAppsSchema = z.object({
120
+ resources: z.object({ apps: z.record(z.string(), bundleAppSchema).optional() }).optional(),
121
+ });
122
+
123
+ const bundleVariablesSchema = z.object({
124
+ variables: z.record(z.string(), bundleVariableSchema).optional(),
125
+ });
126
+
127
+ /**
128
+ * Detect a Databricks App runtime from its required name, host, and port.
129
+ */
130
+ export function isDatabricksAppEnv(
131
+ source: Record<string, string | undefined> = process.env,
132
+ ): boolean {
133
+ const appName = source.DATABRICKS_APP_NAME?.trim();
134
+ const host = source.DATABRICKS_HOST?.trim();
135
+ const port = source.DATABRICKS_APP_PORT?.trim();
136
+ if (!appName || !host || !port) return false;
137
+ try {
138
+ if (!["http:", "https:"].includes(new URL(host).protocol)) return false;
139
+ } catch {
140
+ return false;
141
+ }
142
+ const portNumber = object.toNumber(port);
143
+ return (
144
+ portNumber !== undefined &&
145
+ Number.isInteger(portNumber) &&
146
+ portNumber >= 1 &&
147
+ portNumber <= MAX_TCP_PORT
148
+ );
149
+ }
150
+
151
+ /** Candidate names in scope, prefix, and input precedence order. */
152
+ function keys(input: ConfigKey, options: Pick<ConfigOptions, "scope" | "prefix"> = {}): string[] {
153
+ const scopes = object.sequence(options.scope ?? DEFAULT_SCOPE).toArray();
154
+ const prefixes = options.prefix === undefined ? [] : object.sequence(options.prefix).toArray();
155
+ return [
156
+ ...object
157
+ .sequence(input)
158
+ .flatMap((key) => {
159
+ const prefixed = prefixes.length > 0 ? prefixes.map((prefix) => join(prefix, key)) : [key];
160
+ return [
161
+ ...scopes.flatMap((scope) => prefixed.map((key) => join(scope, key))),
162
+ ...prefixed,
163
+ key,
164
+ ];
165
+ })
166
+ .filter((key) => key.length > 0)
167
+ .distinct(),
168
+ ];
169
+ }
170
+
171
+ /**
172
+ * Every value that resolves for `input`, in source-then-key precedence order,
173
+ * as a LAZY sequence: nothing past the first consumed element is read, so
174
+ * `values(key).first()` never spawns the Databricks CLI when the environment
175
+ * already answered.
176
+ *
177
+ */
178
+ function values(input: ConfigKey, options: ConfigOptions = {}): object.Sequence<ConfigValue> {
179
+ const candidates = keys(input, options);
180
+ const sources = object.sequence<ConfigSource>(options.sources ?? DEFAULT_SOURCES);
181
+ return object.sequence({
182
+ *[Symbol.iterator](): Generator<ConfigValue> {
183
+ for (const source of sources) {
184
+ for (const map of read(source, options.cwd)) {
185
+ for (const key of candidates) {
186
+ const value = stringModule.trimToNull(map[key]);
187
+ if (value !== null) yield { key, source, value };
188
+ }
189
+ }
190
+ }
191
+ },
192
+ });
193
+ }
194
+
195
+ /**
196
+ * The first value that resolves for `input`, or `undefined`.
197
+ *
198
+ * @example
199
+ * const domain = config.text(["TUNNEL_PUBLIC_DOMAIN", "PUBLIC_DOMAIN"]);
200
+ */
201
+ export function text(input: ConfigKey, options: ConfigOptions = {}): string | undefined {
202
+ return values(input, options).at(0)?.value;
203
+ }
204
+
205
+ /**
206
+ * The PRIMARY (fully-scoped) name for `input` - what to print in a log line or an
207
+ * error, so the message names the variable a reader should set. Do not index
208
+ * `keys(...)[0]` for this if `input` may be a bare string.
209
+ *
210
+ * @example
211
+ * logger.warn(`${config.name(JWT_SECRET_ENV)} is not set`);
212
+ */
213
+ export function name(
214
+ input: ConfigKey,
215
+ options: Pick<ConfigOptions, "scope" | "prefix"> = {},
216
+ ): string {
217
+ return keys(input, options)[0] ?? "";
218
+ }
219
+
220
+ /**
221
+ * Safe `.env.<name>` suffixes for a Node environment, exact spelling first.
222
+ * Known long and short names are interchangeable; unknown names pass through.
223
+ */
224
+ function nodeEnvNames(nodeEnv: unknown): string[] {
225
+ const name = stringModule.trimToNull(nodeEnv)?.toLowerCase();
226
+ if (!name || !/^[a-z0-9_-]+$/.test(name)) return [];
227
+ for (const [canonical, alternatives] of Object.entries(NODE_ENV_ALTERNATIVES)) {
228
+ const names: readonly string[] = alternatives;
229
+ if (name === canonical || names.includes(name)) {
230
+ return [...object.sequence(name, canonical, alternatives).distinct()];
231
+ }
232
+ }
233
+ return [name];
234
+ }
235
+
236
+ function join(...parts: string[]): string {
237
+ return parts
238
+ .map((part) => part.trim())
239
+ .filter(Boolean)
240
+ .join("_");
241
+ }
242
+
243
+ /**
244
+ * Resolve a string: `configured` when non-empty, else {@link text}, else `undefined`.
245
+ *
246
+ * The coercion rules are deliberately loose (`on` / `yes` / `1` are all
247
+ * `true`) because values may come from a file a human typed.
248
+ *
249
+ * @example
250
+ * config.string(options.host, "SMTP_HOST");
251
+ */
252
+ export function string(
253
+ configured: unknown,
254
+ input: ConfigKey,
255
+ options?: ConfigOptions,
256
+ ): string | undefined {
257
+ return stringModule.trimToNull(configured) ?? text(input, options);
258
+ }
259
+
260
+ /**
261
+ * Resolve a boolean through `object.toBoolean`. `undefined` when neither source
262
+ * is interpretable, so the caller picks a default with `??`.
263
+ */
264
+ export function boolean(
265
+ configured: unknown,
266
+ input: ConfigKey,
267
+ options?: ConfigOptions,
268
+ ): boolean | undefined {
269
+ return object.toBoolean(configured) ?? object.toBoolean(text(input, options));
270
+ }
271
+
272
+ /**
273
+ * Resolve a positive number that may be fractional (a score threshold, a ratio).
274
+ * Use {@link positiveInt} for a count, port, or timeout.
275
+ */
276
+ export function positiveNumber(
277
+ configured: unknown,
278
+ input: ConfigKey,
279
+ fallback: number,
280
+ options?: ConfigOptions,
281
+ ): number {
282
+ return toPositiveNumber(configured) ?? toPositiveNumber(text(input, options)) ?? fallback;
283
+ }
284
+
285
+ /**
286
+ * Resolve a positive integer (a port, a timeout, a page size), floored. A
287
+ * non-numeric or non-positive value is treated as ABSENT rather than fatal -
288
+ * these are ceilings where a sane default beats a boot failure.
289
+ */
290
+ export function positiveInt(
291
+ configured: unknown,
292
+ input: ConfigKey,
293
+ fallback: number,
294
+ options?: ConfigOptions,
295
+ ): number {
296
+ const resolved = positiveNumber(configured, input, fallback, options);
297
+ return Math.floor(resolved);
298
+ }
299
+
300
+ /**
301
+ * Resolve a list through `string.parseList`, so an array from typed config and a
302
+ * `"a, b c"` string normalize identically. `[]` when neither source has entries.
303
+ */
304
+ export function list(
305
+ configured: string | readonly string[] | undefined | null,
306
+ input: ConfigKey,
307
+ transform?: (entry: string) => string,
308
+ options?: ConfigOptions,
309
+ ): string[] {
310
+ const fromConfig = stringModule.parseList(configured, transform);
311
+ return fromConfig.length > 0
312
+ ? fromConfig
313
+ : stringModule.parseList(text(input, options), transform);
314
+ }
315
+
316
+ function toPositiveNumber(value: unknown): number | undefined {
317
+ const parsed = object.toNumber(value);
318
+ return parsed !== undefined && parsed > 0 ? parsed : undefined;
319
+ }
320
+
321
+ /**
322
+ * The Databricks bundle output for `cwd` - `databricks bundle validate --output
323
+ * json` run from the directory holding `databricks.yml`, with the config file's
324
+ * path. A non-zero validation may still return partial JSON with usable
325
+ * variables. `undefined` when there is no bundle, the CLI produces no JSON, or
326
+ * this process is a deployed Databricks App.
327
+ *
328
+ * Cached per working directory and `DATABRICKS_CONFIG_PROFILE` through
329
+ * {@link context.cached}, so changing either cannot return another context's
330
+ * bundle. A lookup for some other directory is not cached at all.
331
+ */
332
+ export function bundleFile(cwd?: string | null): ConfigFile | undefined {
333
+ const profile = stringModule.trimToNull(process.env.DATABRICKS_CONFIG_PROFILE);
334
+ return context.cached(
335
+ ["config", "bundle", profile ?? ""],
336
+ (resolved) => loadBundleFile(resolved ?? ".", profile),
337
+ cwd,
338
+ );
339
+ }
340
+
341
+ /**
342
+ * The single App's literal `config.env` entries, flattened to `name -> value`.
343
+ *
344
+ * A `value_from` entry names a bundle resource whose id is known after
345
+ * deployment, and reading it needs the resource vocabulary this module
346
+ * deliberately does not have - node-appkit resolves those against its extended
347
+ * {@link bundleResourceSchema}. An `apps` block with more than one app is
348
+ * ambiguous (nothing here says which app this process is), so it yields nothing
349
+ * rather than a guess.
350
+ */
351
+ function bundleApp(input: unknown): Record<string, string> {
352
+ const parsed = bundleAppsSchema.safeParse(input);
353
+ if (!parsed.success) return {};
354
+ const apps = parsed.data.resources?.apps;
355
+ if (!apps) return {};
356
+ const names = Object.keys(apps);
357
+ if (names.length !== 1) return {};
358
+ const entries = apps[names[0]!]?.config?.env ?? [];
359
+ const result: Record<string, string> = {};
360
+ for (const entry of entries) {
361
+ if (!entry.name) continue;
362
+ const value = resolvedString(entry.value);
363
+ if (value !== null) result[entry.name] = value;
364
+ }
365
+ return result;
366
+ }
367
+
368
+ /**
369
+ * Root bundle variables flattened to environment-style `NAME -> value` entries.
370
+ *
371
+ * `databricks bundle validate` resolves each variable to `value`; `default` is
372
+ * retained as a fallback for parsed bundle data that has not been fully
373
+ * resolved. Names are normalized so a bundle variable such as
374
+ * `tunnel_public_domain` answers a lookup for `TUNNEL_PUBLIC_DOMAIN`.
375
+ */
376
+ function bundleVariables(input: unknown): Record<string, string> {
377
+ const parsed = bundleVariablesSchema.safeParse(input);
378
+ if (!parsed.success) return {};
379
+ const result: Record<string, string> = {};
380
+ for (const [name, variable] of Object.entries(parsed.data.variables ?? {})) {
381
+ const key = [...stringModule.tokenize(name)].join("_").toUpperCase();
382
+ const value = resolvedString(variable.value) ?? resolvedString(variable.default);
383
+ if (key && value !== null) result[key] = value;
384
+ }
385
+ return result;
386
+ }
387
+
388
+ /** A non-empty bundle value with every `${...}` interpolation resolved. */
389
+ function resolvedString(value: unknown): string | null {
390
+ const parsed = bundleValue.safeParse(value);
391
+ return parsed.success ? parsed.data : null;
392
+ }
393
+
394
+ /** Parsed `.env` for `cwd`, or `{}`. Cached per working directory. */
395
+ function dotenv(cwd?: string | null): Record<string, string | undefined> {
396
+ const environments = nodeEnvNames(process.env.NODE_ENV);
397
+ return context.cached(
398
+ ["config", "dotenv", ...environments],
399
+ (resolved) => loadDotenv(resolved ?? ".", environments),
400
+ cwd,
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Maps for one source in precedence order. Bundle App config and root variables
406
+ * stay separate so a first-match lookup does not parse variables after an App
407
+ * value resolves.
408
+ */
409
+ function read(
410
+ source: ConfigSource,
411
+ cwd?: string,
412
+ ): object.Sequence<Record<string, string | undefined>> {
413
+ return object.sequence({
414
+ *[Symbol.iterator](): Generator<Record<string, string | undefined>> {
415
+ switch (source) {
416
+ case "env":
417
+ yield process.env;
418
+ break;
419
+ case "dotenv":
420
+ yield dotenv(cwd);
421
+ break;
422
+ case "bundle": {
423
+ const bundle = bundleFile(cwd);
424
+ if (bundle) {
425
+ yield bundleApp(bundle.data);
426
+ yield bundleVariables(bundle.data);
427
+ }
428
+ break;
429
+ }
430
+ }
431
+ },
432
+ });
433
+ }
434
+
435
+ /**
436
+ * Locate `names` from `cwd` outward. `cwd` itself is checked before the
437
+ * discovered project roots so a package-local file wins over the workspace's.
438
+ */
439
+ function findConfigFile(cwd: string, names: readonly string[]): string | undefined {
440
+ if (isDatabricksAppEnv()) return undefined;
441
+ const start = resolve(cwd);
442
+ const root = resolveProjectRoot(start);
443
+ const pathFromRoot = root ? relative(root, start) : undefined;
444
+ const boundary =
445
+ root &&
446
+ pathFromRoot !== undefined &&
447
+ !isAbsolute(pathFromRoot) &&
448
+ pathFromRoot !== ".." &&
449
+ !pathFromRoot.startsWith(`..${sep}`)
450
+ ? root
451
+ : undefined;
452
+ for (let dir = start; ; dir = dirname(dir)) {
453
+ for (const name of names) {
454
+ const path = resolve(dir, name);
455
+ if (statSync(path)?.isFile()) return path;
456
+ }
457
+ if (boundary === undefined || dir === boundary) break;
458
+ }
459
+ return undefined;
460
+ }
461
+
462
+ function loadDotenv(
463
+ cwd: string,
464
+ environments: readonly string[],
465
+ ): Record<string, string | undefined> {
466
+ const names = [...environments.map((name) => `${DOTENV_FILE_NAME}.${name}`), DOTENV_FILE_NAME];
467
+ const path = findConfigFile(cwd, names);
468
+ if (!path) return {};
469
+ try {
470
+ return parseEnv(readFileSync(path, "utf8"));
471
+ } catch {
472
+ logger.warn("failed to read dotenv file", { path });
473
+ return {};
474
+ }
475
+ }
476
+
477
+ function loadBundleFile(cwd: string, profile: string | null): ConfigFile | undefined {
478
+ const path = findConfigFile(cwd, BUNDLE_FILE_NAMES);
479
+ if (!path) return undefined;
480
+ const args = [
481
+ "bundle",
482
+ "validate",
483
+ "--output",
484
+ "json",
485
+ ...(profile === null ? [] : ["--profile", profile]),
486
+ ];
487
+ const result = spawnSync("databricks", args, {
488
+ cwd: resolve(path, ".."),
489
+ encoding: "utf8",
490
+ stdio: ["ignore", "pipe", "pipe"],
491
+ });
492
+ const output = stringModule.trimToNull(result.stdout);
493
+ const error = stringModule.trimToNull(result.stderr);
494
+ if (output === null) {
495
+ logger.debug("bundle validate produced no JSON", { path, status: result.status, error });
496
+ return undefined;
497
+ }
498
+ const data = json.parseRecord(output);
499
+ if (!data) {
500
+ logger.warn("failed to parse bundle validate output", { path, status: result.status });
501
+ return undefined;
502
+ }
503
+ if (result.status !== 0) {
504
+ logger.debug("using partial bundle output", { path, status: result.status, error });
505
+ }
506
+ return { path, data };
507
+ }
package/src/project.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { Stats, readFileSync } from "node:fs";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
- import { hash, json, net, string } from "@dbx-tools/shared-core";
4
+ import { context, json, net, string } from "@dbx-tools/shared-core";
5
5
  import { statSync as stat } from "./file.ts";
6
6
 
7
7
  const ROOT_MARKERS = [
@@ -52,37 +52,22 @@ function projectContextCommandOutput(command: string, args: string[], cwd: strin
52
52
  }
53
53
 
54
54
  /**
55
- * `parseCommand`, memoized per `(command, args, cwd)` - stores the whole
56
- * {@link ProjectContext}. The cache key is a stable {@link hash.fnvHash} of that
57
- * tuple (order-sensitive over `args`), so `cwd` is part of the key and a lookup
58
- * in another directory can't collide with the default-cwd entry.
55
+ * {@link projectContextCommandOutput} through the process-context cache.
56
+ *
57
+ * These commands (`npm prefix`, `git rev-parse`, `gh repo view`) are pure
58
+ * functions of the directory they run in, so their output is worth keeping - but
59
+ * only while the process is still IN that directory. {@link context.cached}
60
+ * owns that rule: the slot remembers the `cwd` it was loaded under and misses
61
+ * once the process moves, and a lookup for some OTHER directory runs the command
62
+ * without caching, so it can't evict the hot entry. The slot name carries the
63
+ * command and its arguments (order-sensitive), so two commands never share one.
59
64
  */
60
- const parsedCommandCache = new Map<string, ProjectContext>();
61
-
62
65
  function projectContextCommand(command: string, args: string[], cwd?: string): ProjectContext {
63
- const processCwd = resolve(process.cwd());
64
- let cacheEnabled: boolean;
65
- if (!cwd) {
66
- cwd = processCwd;
67
- cacheEnabled = true;
68
- } else {
69
- cwd = resolve(cwd);
70
- if (cwd == processCwd) {
71
- cacheEnabled = true;
72
- } else {
73
- cacheEnabled = false;
74
- }
75
- }
76
- const cacheKey = cacheEnabled ? hash.fnvHash(command, args) : undefined;
77
- const cacheHit = cacheKey ? parsedCommandCache.get(cacheKey) : undefined;
78
- if (cacheHit?.cwd === cwd) {
79
- return cacheHit;
80
- }
81
- const result = projectContextCommandOutput(command, args, cwd);
82
- if (cacheKey) {
83
- parsedCommandCache.set(cacheKey, result);
84
- }
85
- return result;
66
+ return context.cached(
67
+ ["project", command, ...args],
68
+ (resolved) => projectContextCommandOutput(command, args, resolved ?? resolve(cwd ?? ".")),
69
+ cwd ? resolve(cwd) : undefined,
70
+ );
86
71
  }
87
72
 
88
73
  function npmRoot(cwd?: string): string | undefined {