@dbx-tools/shared 0.1.18

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/log.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { NameLike } from "./common.js";
2
+
3
+ /** Plugin-facing logger surface returned by {@link logger}. */
4
+ export interface Logger {
5
+ debug(message: string, attributes?: Record<string, unknown>): void;
6
+ info(message: string, attributes?: Record<string, unknown>): void;
7
+ warn(message: string, attributes?: Record<string, unknown>): void;
8
+ error(message: string, attributes?: Record<string, unknown>): void;
9
+ }
10
+
11
+ /**
12
+ * Severity ordering. A log call below the active threshold is
13
+ * discarded entirely (no string formatting, no console call). The
14
+ * threshold is read on every call from `process.env.LOG_LEVEL`,
15
+ * case-insensitive, defaulting to `info` when unset / empty /
16
+ * unrecognised. Set `LOG_LEVEL=debug` for verbose dev output,
17
+ * `LOG_LEVEL=warn` to silence info chatter in production, etc.
18
+ *
19
+ * Lazy on purpose: looking up `process.env.LOG_LEVEL` per call
20
+ * costs nothing meaningful, and it lets test runners (and other
21
+ * embedders) flip the level after `log.ts` has already been
22
+ * imported, without restarting the process or reaching into
23
+ * private state.
24
+ */
25
+ export type LogLevel = "debug" | "info" | "warn" | "error";
26
+
27
+ const LEVEL_RANK: Readonly<Record<LogLevel, number>> = {
28
+ debug: 10,
29
+ info: 20,
30
+ warn: 30,
31
+ error: 40,
32
+ };
33
+
34
+ const DEFAULT_LEVEL: LogLevel = "info";
35
+
36
+ /**
37
+ * Read the active threshold from `process.env.LOG_LEVEL`. Recognises
38
+ * any case (`DEBUG`, `Debug`, `debug`), trims whitespace, falls back
39
+ * to {@link DEFAULT_LEVEL} when unset / empty / unrecognised.
40
+ *
41
+ * Browser-safe: `process` is undefined in browser bundles unless a
42
+ * polyfill or build-time replace is set up, so we guard the access.
43
+ * In a Vite app, set `LOG_LEVEL` via `define` config or just leave
44
+ * it - the default `info` is sane for production browser code.
45
+ */
46
+ function activeLevel(): LogLevel {
47
+ const env = typeof process !== "undefined" ? process.env : undefined;
48
+ const raw = env?.LOG_LEVEL?.toLowerCase().trim();
49
+ if (raw && Object.prototype.hasOwnProperty.call(LEVEL_RANK, raw)) {
50
+ return raw as LogLevel;
51
+ }
52
+ return DEFAULT_LEVEL;
53
+ }
54
+
55
+ /** True when calls at `level` should reach the console. */
56
+ function shouldEmit(level: LogLevel): boolean {
57
+ return LEVEL_RANK[level] >= LEVEL_RANK[activeLevel()];
58
+ }
59
+
60
+ const LOGGER_NAME_REGEX = /^(?:[a-z][a-z0-9+.-]*:\/\/)?.*\/([^/.]+)(?:\.[^/]+)?$/i;
61
+
62
+ function extractLoggerName(
63
+ loggerName: NameLike | string | undefined,
64
+ ): string | undefined {
65
+ if (!loggerName) return undefined;
66
+ else if (typeof loggerName === "string") {
67
+ const match = loggerName.match(LOGGER_NAME_REGEX);
68
+ return match?.[1] ?? loggerName;
69
+ } else {
70
+ return extractLoggerName(loggerName?.name);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Build a per-plugin logger that writes to `console` with an optional
76
+ * `[plugin-name]` prefix derived from `plugin.name` or the string you pass in.
77
+ *
78
+ * Calls below `process.env.LOG_LEVEL` are discarded before any
79
+ * string work happens, so leaving `log.debug({...heavy details})`
80
+ * in production code is free as long as `LOG_LEVEL` is `info` or
81
+ * higher. Default level is `info`.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * import { logUtils } from "@dbx-tools/shared";
86
+ *
87
+ * class MyPlugin extends Plugin {
88
+ * private log = logUtils.logger(this);
89
+ *
90
+ * override async setup() {
91
+ * this.log.info("starting");
92
+ * this.log.warn("missing optional config", { reason: "no env var" });
93
+ * }
94
+ * }
95
+ * ```
96
+ */
97
+ export function logger(loggerName: NameLike | string | undefined): Logger {
98
+ const name = extractLoggerName(loggerName);
99
+ function log(
100
+ level: LogLevel,
101
+ consoleFn: (...args: unknown[]) => void,
102
+ message: string,
103
+ attributes?: Record<string, unknown>,
104
+ ): void {
105
+ if (!shouldEmit(level)) return;
106
+ const logMessage = name ? `[${name}] ${message}` : message;
107
+ const logArgs = [logMessage, attributes].filter(Boolean);
108
+ consoleFn(...logArgs);
109
+ }
110
+ return {
111
+ debug: (msg, attrs) => log("debug", console.debug, msg, attrs),
112
+ info: (msg, attrs) => log("info", console.info, msg, attrs),
113
+ warn: (msg, attrs) => log("warn", console.warn, msg, attrs),
114
+ error: (msg, attrs) => log("error", console.error, msg, attrs),
115
+ };
116
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Browser-safe networking helpers: URL parsing and path joining that
3
+ * gracefully handle partial inputs. No node-only imports, so this
4
+ * module is the canonical home for anything URL-shaped that also has
5
+ * to run in a Vite / Webpack / esbuild client bundle.
6
+ *
7
+ * Public API: {@link joinUrl}, {@link parseUrl}. The
8
+ * single-leading/trailing-slash stripper `stripBoundarySlashes` is
9
+ * private to this module.
10
+ *
11
+ * The server-side `./net.ts` re-exports everything here verbatim and
12
+ * tacks on its own node-only helpers (e.g. `getRandomPort`), so the
13
+ * `netUtils` namespace looks identical from both entry points.
14
+ */
15
+
16
+ // ────────────────────────────────────────────────────────────────
17
+ // Types
18
+ // ────────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * One input to {@link joinUrl}: a string, a (recursively nested)
22
+ * array of segments, or `null` / `undefined` (skipped).
23
+ *
24
+ * The recursive shape lets callers compose path fragments without
25
+ * pre-flattening: `joinUrl("a", ["b", ["c", "d"]])` is valid.
26
+ *
27
+ * The array variant uses an interface (rather than a self-referential
28
+ * type alias) because Bun's TS parser bails on `type X = ... | X[] | ...`
29
+ * but accepts the equivalent `interface XArr extends Array<X>`.
30
+ */
31
+ export type UrlSegmentLike = string | UrlSegmentArray | null | undefined;
32
+ export interface UrlSegmentArray extends ReadonlyArray<UrlSegmentLike> {}
33
+
34
+ /**
35
+ * Anything {@link parseUrl} knows how to coerce into a `URL`:
36
+ *
37
+ * - A WHATWG `URL` instance: returned as-is when no extra path is
38
+ * supplied; otherwise re-parsed with the path appended.
39
+ * - A string: parsed by the `URL` constructor; `https://` is
40
+ * auto-prefixed when no scheme is present, so bare hostnames like
41
+ * `"example.com"` round-trip into a usable URL.
42
+ * - Any object with a `url` field of the above shapes (e.g. a fetch
43
+ * `Request`, a Databricks `WorkspaceClient` config, etc.).
44
+ */
45
+ export type UrlLike = URL | string | { url: string };
46
+
47
+ // ────────────────────────────────────────────────────────────────
48
+ // URL helpers
49
+ // ────────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Join URL path segments with `/`, with three pragmatic conveniences
53
+ * for building URLs piecemeal:
54
+ *
55
+ * 1. Nullish or blank segments are dropped, so callers don't have
56
+ * to guard around optional path components.
57
+ * 2. Each string segment has any leading or trailing `/` stripped
58
+ * before joining, so `"/api"` + `"v2/"` round-trips to `/api/v2`
59
+ * regardless of how the caller terminated each part.
60
+ * 3. Array segments recurse, with their joined form inlined into
61
+ * the outer result.
62
+ *
63
+ * The result is prefixed with `/` to make it path-absolute, except
64
+ * when any segment carries an explicit `://` scheme (e.g.
65
+ * `"https://x.com"`), in which case the scheme is preserved verbatim.
66
+ *
67
+ * Returns `""` when every input was nullish or blank.
68
+ *
69
+ * @example
70
+ * joinUrl("a", "b"); // "/a/b"
71
+ * joinUrl("/api/", "/v2/", "x"); // "/api/v2/x"
72
+ * joinUrl(["a", "b"], "c"); // "/a/b/c"
73
+ * joinUrl("https://ex.com", "/api/x"); // "https://ex.com/api/x"
74
+ * joinUrl(null, "", "x", undefined); // "/x"
75
+ * joinUrl(); // ""
76
+ * joinUrl(null); // ""
77
+ */
78
+ export function joinUrl(...urlSegments: UrlSegmentLike[]): string {
79
+ const parts: string[] = [];
80
+ for (const segment of urlSegments) {
81
+ if (segment == null) continue;
82
+ if (Array.isArray(segment)) {
83
+ // Recurse, then strip the inner result's leading `/` so the
84
+ // outer path-absolute prepend doesn't double up to `//a/b/c`.
85
+ const inner = joinUrl(...segment);
86
+ if (inner) parts.push(stripBoundarySlashes(inner));
87
+ continue;
88
+ }
89
+ // `Array.isArray` narrows away the UrlSegmentArray branch but
90
+ // leaves TS believing `segment` could still be a `string` only,
91
+ // which it now is - the type cast is just to silence a known
92
+ // TS limitation around recursive interface narrowing.
93
+ const trimmed = (segment as string).trim();
94
+ if (!trimmed) continue;
95
+ parts.push(stripBoundarySlashes(trimmed));
96
+ }
97
+ const joined = parts.filter(Boolean).join("/");
98
+ if (!joined) return "";
99
+ return joined.includes("://") ? joined : "/" + joined;
100
+ }
101
+
102
+ /**
103
+ * Coerce a {@link UrlLike} input into a parsed `URL`, returning `null`
104
+ * for anything that cannot be coerced (null/undefined, empty string,
105
+ * malformed URL, an object whose `url` field doesn't parse).
106
+ *
107
+ * Mirrors WHATWG `URL.parse(...)` semantics: parse on success, `null`
108
+ * on failure - never throws.
109
+ *
110
+ * Bare hostnames are upgraded to `https://` before parsing, so callers
111
+ * can hand in user-provided values like `"workspace.cloud.databricks.com"`
112
+ * without an explicit scheme.
113
+ *
114
+ * Optional trailing `path` arguments are appended via {@link joinUrl}.
115
+ * When `input` is nullish (or just `"/"` / blank) but a `path` is
116
+ * provided, the URL is built against `http://localhost`, which is
117
+ * convenient for tests and for callers that resolve the host later.
118
+ *
119
+ * @example
120
+ * parseUrl("example.com"); // URL { https://example.com/ }
121
+ * parseUrl("http://example.com/path"); // URL { http://example.com/path }
122
+ * parseUrl({ url: "https://api.example" }); // URL { https://api.example/ }
123
+ * parseUrl("example.com", "/api", "v2", "items"); // URL { https://example.com/api/v2/items }
124
+ * parseUrl("example.com", ["api", "v2"]); // URL { https://example.com/api/v2 }
125
+ * parseUrl(null, "/api/x"); // URL { http://localhost/api/x }
126
+ * parseUrl(""); // null
127
+ * parseUrl(null); // null
128
+ */
129
+ export function parseUrl(
130
+ input: UrlLike | null | undefined,
131
+ ...path: UrlSegmentLike[]
132
+ ): URL | null {
133
+ if (typeof input === "string") {
134
+ input = input.trim();
135
+ const match = input.match(/^([a-z][a-z0-9+.-]*):\/\/(.*)$/i);
136
+ const rest = match?.[2] ?? input;
137
+ if (!rest || rest === "/") return parseUrl(null, ...path);
138
+ const scheme = match?.[1];
139
+ if (!scheme) input = `https://${input}`;
140
+ }
141
+ const joinedPath = joinUrl(...path);
142
+ if (input == null) {
143
+ if (joinedPath) return parseUrl("http://localhost", joinedPath);
144
+ return null;
145
+ }
146
+ if (input instanceof URL) {
147
+ if (joinedPath) return parseUrl(input.toString(), joinedPath);
148
+ return input;
149
+ }
150
+ if (typeof input === "string") {
151
+ const candidate = joinUrl(input, joinedPath);
152
+ if (candidate) {
153
+ try {
154
+ return new URL(candidate);
155
+ } catch {
156
+ // Fall through to `null`.
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+ return parseUrl(input.url, joinedPath);
162
+ }
163
+
164
+ // ────────────────────────────────────────────────────────────────
165
+ // Private helpers
166
+ // ────────────────────────────────────────────────────────────────
167
+
168
+ /** Strip a single leading `/` and a single trailing `/`, if present. */
169
+ function stripBoundarySlashes(s: string): string {
170
+ let out = s;
171
+ if (out.startsWith("/")) out = out.slice(1);
172
+ if (out.endsWith("/")) out = out.slice(0, -1);
173
+ return out;
174
+ }
package/src/net.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Server-side networking helpers. Re-exports every browser-safe URL
3
+ * helper from {@link ./net.browser.ts} verbatim and adds node-only
4
+ * additions (currently {@link getRandomPort}), so the `netUtils`
5
+ * namespace exposes the same URL surface from either entry point and
6
+ * picks up node-only helpers automatically on the server.
7
+ */
8
+
9
+ import net from "node:net";
10
+
11
+ export * from "./net.browser.js";
12
+
13
+ /**
14
+ * Bind a transient TCP listener on port `0`, read the OS-assigned
15
+ * port, close the listener, and resolve with the port. Used to grab
16
+ * a free local port for tests, devloops, and child processes.
17
+ */
18
+ export async function getRandomPort(): Promise<number> {
19
+ return await new Promise((resolve, reject) => {
20
+ const server = net.createServer();
21
+ server.listen(0, () => {
22
+ const address = server.address();
23
+ if (!address || typeof address === "string") {
24
+ reject(new Error("Failed to get port"));
25
+ return;
26
+ }
27
+ const port = address.port;
28
+ server.close(() => resolve(port));
29
+ });
30
+ server.on("error", reject);
31
+ });
32
+ }
package/src/project.ts ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Project introspection helpers shared across AppKit plugins.
3
+ *
4
+ * Resolve a human-friendly project name and parse git remote URLs into
5
+ * repo names. Exposed as `projectUtils.*` from the shared barrel so
6
+ * naming inside this module drops the redundant `project` prefix:
7
+ * `projectUtils.name()` instead of `projectName()`, etc.
8
+ *
9
+ * **Server-only.** Imports `node:fs`, `node:path`, `node:child_process`,
10
+ * and `node:util` at module load. Browser bundles must use
11
+ * `@dbx-tools/shared`'s `index.client.ts` entry point, which
12
+ * skips this module entirely.
13
+ */
14
+
15
+ import { execFile } from "node:child_process";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { basename, dirname, resolve } from "node:path";
18
+ import { promisify } from "node:util";
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ const nameByCwd = new Map<string, Promise<string>>();
23
+
24
+ export interface NameOptions {
25
+ /** Directory to start from. Defaults to `process.cwd()`. */
26
+ cwd?: string;
27
+ }
28
+
29
+ interface PackageJson {
30
+ name?: string;
31
+ workspaces?: unknown;
32
+ }
33
+
34
+ /**
35
+ * Resolve a human-friendly project name for the repo rooted at `cwd`.
36
+ *
37
+ * Order:
38
+ * 1. `name` from the root `package.json` (via `npm pkg get name` when available,
39
+ * otherwise read the file after locating the root).
40
+ * 2. Repository name from `git remote get-url origin`.
41
+ * 3. Basename of the project root directory.
42
+ */
43
+ export function name(options?: NameOptions): Promise<string> {
44
+ const cwd = resolve(options?.cwd ?? process.cwd());
45
+ let pending = nameByCwd.get(cwd);
46
+ if (pending === undefined) {
47
+ pending = resolveProjectName(cwd);
48
+ nameByCwd.set(cwd, pending);
49
+ }
50
+ return pending;
51
+ }
52
+
53
+ /**
54
+ * Parse a git remote URL (`https://...`, `git@host:owner/repo.git`, etc.)
55
+ * and return the repo segment, stripping any `.git` suffix. Returns
56
+ * `undefined` for empty or unparsable input.
57
+ */
58
+ export function parseGitRemote(url: string): string | undefined {
59
+ const trimmed = url.trim();
60
+ if (!trimmed) {
61
+ return undefined;
62
+ }
63
+
64
+ const scp = /^[^@]+@[^:]+:(.+)$/i.exec(trimmed);
65
+ if (scp) {
66
+ const segment = scp[1];
67
+ return lastPathSegment(segment ?? "");
68
+ }
69
+
70
+ try {
71
+ const normalized = trimmed.replace(/\.git$/i, "");
72
+ const pathname = new URL(normalized).pathname;
73
+ const segment = pathname.split("/").filter(Boolean).at(-1);
74
+ return segment ? lastPathSegment(segment) : undefined;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ async function resolveProjectName(cwd: string): Promise<string> {
81
+ const root = await findProjectRoot(cwd);
82
+
83
+ const fromPackage = (await readNameViaNpm(root)) ?? readNameFromPackageJson(root);
84
+ if (fromPackage) {
85
+ return fromPackage;
86
+ }
87
+
88
+ const fromGit = await readNameFromGitRemote(root);
89
+ if (fromGit) {
90
+ return fromGit;
91
+ }
92
+
93
+ return basename(root);
94
+ }
95
+
96
+ async function findProjectRoot(startDir: string): Promise<string> {
97
+ const cwd = resolve(startDir);
98
+
99
+ const fromNpmWorkspace = await npmWorkspaceRoot(cwd);
100
+ if (fromNpmWorkspace && hasPackageJson(fromNpmWorkspace)) {
101
+ return preferWorkspacesRoot(fromNpmWorkspace);
102
+ }
103
+
104
+ const fromNpmPrefix = await npmPrefix(cwd);
105
+ if (fromNpmPrefix && hasPackageJson(fromNpmPrefix)) {
106
+ return preferWorkspacesRoot(fromNpmPrefix);
107
+ }
108
+
109
+ const walked = walkUpForPackageRoot(cwd);
110
+ if (walked) {
111
+ return walked;
112
+ }
113
+
114
+ const fromGit = await gitTopLevel(cwd);
115
+ if (fromGit && hasPackageJson(fromGit)) {
116
+ return fromGit;
117
+ }
118
+
119
+ return cwd;
120
+ }
121
+
122
+ function preferWorkspacesRoot(startDir: string): string {
123
+ return walkUpForPackageRoot(startDir) ?? startDir;
124
+ }
125
+
126
+ function walkUpForPackageRoot(startDir: string): string | undefined {
127
+ let dir = resolve(startDir);
128
+ let topmost: string | undefined;
129
+ let workspacesRoot: string | undefined;
130
+
131
+ while (true) {
132
+ const pkgPath = resolve(dir, "package.json");
133
+ if (existsSync(pkgPath)) {
134
+ topmost = dir;
135
+ const pkg = readPackageJson(pkgPath);
136
+ if (pkg?.workspaces !== undefined) {
137
+ workspacesRoot = dir;
138
+ }
139
+ }
140
+
141
+ const parent = dirname(dir);
142
+ if (parent === dir) {
143
+ break;
144
+ }
145
+ dir = parent;
146
+ }
147
+
148
+ return workspacesRoot ?? topmost;
149
+ }
150
+
151
+ function hasPackageJson(dir: string): boolean {
152
+ return existsSync(resolve(dir, "package.json"));
153
+ }
154
+
155
+ async function npmPrefix(cwd: string): Promise<string | undefined> {
156
+ return runNpm(["prefix"], cwd);
157
+ }
158
+
159
+ async function npmWorkspaceRoot(cwd: string): Promise<string | undefined> {
160
+ const nodeModules = await runNpm(["root", "-w"], cwd);
161
+ if (!nodeModules) {
162
+ return undefined;
163
+ }
164
+ return dirname(nodeModules);
165
+ }
166
+
167
+ async function runNpm(args: string[], cwd: string): Promise<string | undefined> {
168
+ try {
169
+ const { stdout } = await execFileAsync("npm", args, {
170
+ cwd,
171
+ encoding: "utf8",
172
+ });
173
+ const value = stdout.trim();
174
+ return value || undefined;
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ async function readNameViaNpm(root: string): Promise<string | undefined> {
181
+ try {
182
+ const { stdout } = await execFileAsync(
183
+ "npm",
184
+ ["pkg", "get", "name", "--prefix", root],
185
+ { encoding: "utf8" },
186
+ );
187
+ return parseNpmPkgGetValue(stdout);
188
+ } catch {
189
+ return undefined;
190
+ }
191
+ }
192
+
193
+ function parseNpmPkgGetValue(stdout: string): string | undefined {
194
+ const trimmed = stdout.trim();
195
+ if (!trimmed) {
196
+ return undefined;
197
+ }
198
+ try {
199
+ const parsed: unknown = JSON.parse(trimmed);
200
+ if (typeof parsed === "string" && parsed.trim()) {
201
+ return parsed.trim();
202
+ }
203
+ } catch {
204
+ return trimmed.replace(/^"|"$/g, "").trim() || undefined;
205
+ }
206
+ return undefined;
207
+ }
208
+
209
+ function readNameFromPackageJson(root: string): string | undefined {
210
+ const pkgPath = resolve(root, "package.json");
211
+ if (!existsSync(pkgPath)) {
212
+ return undefined;
213
+ }
214
+ const pkg = readPackageJson(pkgPath);
215
+ const pkgName = pkg?.name?.trim();
216
+ return pkgName || undefined;
217
+ }
218
+
219
+ function readPackageJson(path: string): PackageJson | undefined {
220
+ try {
221
+ return JSON.parse(readFileSync(path, "utf8")) as PackageJson;
222
+ } catch {
223
+ return undefined;
224
+ }
225
+ }
226
+
227
+ async function readNameFromGitRemote(root: string): Promise<string | undefined> {
228
+ const url = await gitRemoteOriginUrl(root);
229
+ if (!url) {
230
+ return undefined;
231
+ }
232
+ return parseGitRemote(url);
233
+ }
234
+
235
+ async function gitRemoteOriginUrl(root: string): Promise<string | undefined> {
236
+ try {
237
+ const { stdout } = await execFileAsync(
238
+ "git",
239
+ ["-C", root, "remote", "get-url", "origin"],
240
+ { encoding: "utf8" },
241
+ );
242
+ return stdout.trim() || undefined;
243
+ } catch {
244
+ return undefined;
245
+ }
246
+ }
247
+
248
+ async function gitTopLevel(cwd: string): Promise<string | undefined> {
249
+ try {
250
+ const { stdout } = await execFileAsync(
251
+ "git",
252
+ ["-C", cwd, "rev-parse", "--show-toplevel"],
253
+ { encoding: "utf8" },
254
+ );
255
+ return stdout.trim() || undefined;
256
+ } catch {
257
+ return undefined;
258
+ }
259
+ }
260
+
261
+ function lastPathSegment(path: string): string {
262
+ const segment = path.split("/").filter(Boolean).at(-1) ?? path;
263
+ return segment.replace(/\.git$/i, "");
264
+ }