@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/README.md +234 -0
- package/dist/index.client.d.ts +32 -0
- package/dist/index.client.js +32 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/src/api.d.ts +90 -0
- package/dist/src/api.js +165 -0
- package/dist/src/appkit.d.ts +59 -0
- package/dist/src/appkit.js +109 -0
- package/dist/src/common.d.ts +185 -0
- package/dist/src/common.js +277 -0
- package/dist/src/http.d.ts +77 -0
- package/dist/src/http.js +166 -0
- package/dist/src/log.d.ts +47 -0
- package/dist/src/log.js +80 -0
- package/dist/src/net.browser.d.ts +98 -0
- package/dist/src/net.browser.js +146 -0
- package/dist/src/net.d.ts +14 -0
- package/dist/src/net.js +29 -0
- package/dist/src/project.d.ts +33 -0
- package/dist/src/project.js +215 -0
- package/dist/src/string.d.ts +105 -0
- package/dist/src/string.js +220 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/index.client.ts +32 -0
- package/index.ts +26 -0
- package/package.json +54 -0
- package/src/api.ts +222 -0
- package/src/appkit.ts +161 -0
- package/src/common.ts +422 -0
- package/src/http.ts +203 -0
- package/src/log.ts +116 -0
- package/src/net.browser.ts +174 -0
- package/src/net.ts +32 -0
- package/src/project.ts +264 -0
- package/src/string.ts +276 -0
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
|
+
}
|