@dbx-tools/core 0.1.9 → 0.1.11
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/index.ts +2 -0
- package/package.json +2 -2
- package/src/file.ts +18 -0
- package/src/project.ts +159 -55
package/index.ts
CHANGED
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
4
|
|
|
5
5
|
export * as exec from "./src/exec";
|
|
6
|
+
export * as file from "./src/file";
|
|
6
7
|
export * as project from "./src/project";
|
|
7
8
|
export type { ExecStdio, LineHandler, StdioOption, ExecResult, ExecOptions, SyncExecStdio, SyncExecOptions, SpawnArgs } from "./src/exec";
|
|
9
|
+
export type { ProjectContext } from "./src/project";
|
package/package.json
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
"typescript": "^5.9.3"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@dbx-tools/shared-core": "0.1.
|
|
14
|
+
"@dbx-tools/shared-core": "0.1.11"
|
|
15
15
|
},
|
|
16
16
|
"main": "index.ts",
|
|
17
17
|
"license": "UNLICENSED",
|
|
18
|
-
"version": "0.1.
|
|
18
|
+
"version": "0.1.11",
|
|
19
19
|
"types": "index.ts",
|
|
20
20
|
"type": "module",
|
|
21
21
|
"exports": {
|
package/src/file.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem utilities: best-effort `fs.stat` helpers that never throw.
|
|
3
|
+
*/
|
|
4
|
+
import { Stats, statSync as nodeStatSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Best-effort `fs.stat` (sync). Returns `undefined` for a blank path or when the
|
|
8
|
+
* path can't be stat'd (missing, permission denied, ...), so callers can treat
|
|
9
|
+
* "not there" and "not accessible" the same and never handle an exception.
|
|
10
|
+
*/
|
|
11
|
+
export function statSync(path: string): Stats | undefined {
|
|
12
|
+
if (path) {
|
|
13
|
+
try {
|
|
14
|
+
return nodeStatSync(path);
|
|
15
|
+
} catch { }
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
package/src/project.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { Stats
|
|
2
|
+
import { Stats } from "node:fs";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { hash, net } from "@dbx-tools/shared-core";
|
|
6
|
+
import { statSync as stat } from "./file";
|
|
7
|
+
|
|
5
8
|
|
|
6
9
|
const ROOT_MARKERS = [
|
|
7
10
|
".projenrc.ts",
|
|
@@ -11,70 +14,92 @@ const ROOT_MARKERS = [
|
|
|
11
14
|
"package.json",
|
|
12
15
|
] as const;
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
/** A command's stdout, classified as a filesystem path and/or a URL. */
|
|
18
|
+
export interface ProjectContext {
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
readonly output: string
|
|
21
|
+
/** `output` when it names something on disk. */
|
|
22
|
+
readonly path?: string;
|
|
23
|
+
/** `fs.stat` of {@link path}, when it exists. */
|
|
24
|
+
readonly pathStats?: Stats;
|
|
25
|
+
/**
|
|
26
|
+
* `output` parsed into a chainable {@link net.UrlBuilder}, when it is a real
|
|
27
|
+
* network URL - a non-blank scheme (not `file:`) AND a non-blank hostname.
|
|
28
|
+
* Bare paths, scp-like `git@host:...` remotes, and `file:` URLs stay unset.
|
|
29
|
+
*/
|
|
30
|
+
readonly url?: net.UrlBuilder;
|
|
21
31
|
}
|
|
22
32
|
|
|
33
|
+
|
|
23
34
|
/**
|
|
24
35
|
* because this is crucial do not use exec.spawnSync
|
|
36
|
+
*
|
|
37
|
+
* Run `command args` in `cwd` and classify its stdout: `path` + `pathStats` when
|
|
38
|
+
* the output names something on disk, `url` (a {@link net.UrlBuilder}) when it
|
|
39
|
+
* parses as a real network URL. Empty {@link ProjectContext} on a non-zero exit
|
|
40
|
+
* or empty output.
|
|
25
41
|
*/
|
|
26
|
-
function
|
|
42
|
+
function projectContextCommandOutput(command: string, args: string[], cwd: string): ProjectContext {
|
|
27
43
|
const result = spawnSync(command, args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
const output = result.stdout.toString().trim();
|
|
45
|
+
if (result.status === 0 && output) {
|
|
46
|
+
const pathStats = stat(output);
|
|
47
|
+
// Only an EXPLICIT `scheme://...` counts as a URL. `urlBuilder` otherwise
|
|
48
|
+
// synthesizes one (a bare `example.com` -> `https://…`, an absolute path ->
|
|
49
|
+
// `http://localhost/…`), which would mislabel directory outputs and bare
|
|
50
|
+
// tokens - so gate on the raw output already carrying a scheme + authority.
|
|
51
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(output) ? net.urlBuilder(output) : undefined;
|
|
52
|
+
return { cwd, output, path: pathStats ? output : undefined, pathStats, url };
|
|
31
53
|
}
|
|
32
|
-
return
|
|
54
|
+
return { cwd, output };
|
|
33
55
|
}
|
|
34
56
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{ cwd: string; path?: string }
|
|
43
|
-
>();
|
|
57
|
+
/**
|
|
58
|
+
* `parseCommand`, memoized per `(command, args, cwd)` - stores the whole
|
|
59
|
+
* {@link ProjectContext}. The cache key is a stable {@link hash.fnvHash} of that
|
|
60
|
+
* tuple (order-sensitive over `args`), so `cwd` is part of the key and a lookup
|
|
61
|
+
* in another directory can't collide with the default-cwd entry.
|
|
62
|
+
*/
|
|
63
|
+
const parsedCommandCache = new Map<string, ProjectContext>();
|
|
44
64
|
|
|
45
|
-
function
|
|
46
|
-
const
|
|
47
|
-
let
|
|
48
|
-
if (cwd
|
|
49
|
-
cwd =
|
|
50
|
-
|
|
65
|
+
function projectContextCommand(command: string, args: string[], cwd?: string): ProjectContext {
|
|
66
|
+
const processCwd = resolve(process.cwd());
|
|
67
|
+
let cacheEnabled: boolean
|
|
68
|
+
if (!cwd) {
|
|
69
|
+
cwd = processCwd;
|
|
70
|
+
cacheEnabled = true;
|
|
51
71
|
} else {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return cached!.path;
|
|
72
|
+
cwd = resolve(cwd);
|
|
73
|
+
if (cwd == processCwd) {
|
|
74
|
+
cacheEnabled = true;
|
|
75
|
+
} else {
|
|
76
|
+
cacheEnabled = false;
|
|
58
77
|
}
|
|
59
78
|
}
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
79
|
+
const cacheKey = cacheEnabled ? hash.fnvHash(command, args) : undefined;
|
|
80
|
+
const cacheHit = cacheKey ? parsedCommandCache.get(cacheKey) : undefined;
|
|
81
|
+
if (cacheHit?.cwd === cwd) { return cacheHit; }
|
|
82
|
+
const result = projectContextCommandOutput(command, args, cwd);
|
|
83
|
+
if (cacheKey) {
|
|
84
|
+
parsedCommandCache.set(cacheKey, result);
|
|
63
85
|
}
|
|
64
|
-
return
|
|
86
|
+
return result;
|
|
65
87
|
}
|
|
88
|
+
|
|
66
89
|
function npmRoot(cwd?: string): string | undefined {
|
|
67
|
-
|
|
90
|
+
const parsed = projectContextCommand("npm", ["prefix"], cwd);
|
|
91
|
+
return parsed.pathStats?.isDirectory() ? parsed.path : undefined;
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
function gitRoot(cwd?: string): string | undefined {
|
|
71
|
-
|
|
95
|
+
const parsed = projectContextCommand("git", ["rev-parse", "--show-toplevel"], cwd);
|
|
96
|
+
return parsed.pathStats?.isDirectory() ? parsed.path : undefined;
|
|
72
97
|
}
|
|
73
98
|
|
|
74
99
|
export function root(cwd: string = process.cwd()): string | undefined {
|
|
75
100
|
let current = resolve(cwd);
|
|
76
101
|
|
|
77
|
-
if (!
|
|
102
|
+
if (!stat(current)?.isDirectory()) {
|
|
78
103
|
current = dirname(current);
|
|
79
104
|
}
|
|
80
105
|
const boundaries = new Set(
|
|
@@ -86,7 +111,7 @@ export function root(cwd: string = process.cwd()): string | undefined {
|
|
|
86
111
|
let best: { dir: string; priority: number } | undefined;
|
|
87
112
|
while (true) {
|
|
88
113
|
for (const [priority, marker] of ROOT_MARKERS.entries()) {
|
|
89
|
-
if (
|
|
114
|
+
if (stat(join(current, marker))?.isFile()) {
|
|
90
115
|
if (
|
|
91
116
|
best === undefined ||
|
|
92
117
|
priority < best.priority ||
|
|
@@ -111,10 +136,6 @@ export function root(cwd: string = process.cwd()): string | undefined {
|
|
|
111
136
|
}
|
|
112
137
|
}
|
|
113
138
|
|
|
114
|
-
/** Best-effort `fs.stat` (sync). Returns `undefined` when `path` can't be stat'd. */
|
|
115
|
-
export function stat(path: string): Stats | undefined {
|
|
116
|
-
return statPath(path);
|
|
117
|
-
}
|
|
118
139
|
|
|
119
140
|
/**
|
|
120
141
|
* Parse a git remote URL (`https://...`, `git@host:owner/repo.git`, etc.) and
|
|
@@ -156,7 +177,7 @@ export function* resolveProjectRoots(cwd: string = process.cwd()): Generator<str
|
|
|
156
177
|
const dir = resolve(candidate);
|
|
157
178
|
if (seen.has(dir)) continue;
|
|
158
179
|
seen.add(dir);
|
|
159
|
-
if (
|
|
180
|
+
if (stat(dir)?.isDirectory()) yield dir;
|
|
160
181
|
}
|
|
161
182
|
if (!seen.has(base)) yield base;
|
|
162
183
|
}
|
|
@@ -165,7 +186,7 @@ export function* resolveProjectRoots(cwd: string = process.cwd()): Generator<str
|
|
|
165
186
|
function workspaceRoot(cwd: string = process.cwd()): string {
|
|
166
187
|
let last: string | undefined;
|
|
167
188
|
for (const dir of resolveProjectRoots(cwd)) {
|
|
168
|
-
if (
|
|
189
|
+
if (stat(resolve(dir, "package.json"))?.isFile()) return dir;
|
|
169
190
|
last = dir;
|
|
170
191
|
}
|
|
171
192
|
return last ?? resolve(cwd);
|
|
@@ -182,22 +203,103 @@ export function name(cwd: string = process.cwd()): string {
|
|
|
182
203
|
const fromPackage = readPackageName(resolve(rootDir, "package.json"));
|
|
183
204
|
if (fromPackage) return fromPackage;
|
|
184
205
|
|
|
185
|
-
const remote =
|
|
206
|
+
const remote = projectContextCommand("git", ["-C", rootDir, "remote", "get-url", "origin"], rootDir).output;
|
|
186
207
|
const fromGit = remote ? parseGitRemote(remote) : undefined;
|
|
187
208
|
if (fromGit) return fromGit;
|
|
188
209
|
|
|
189
210
|
return basename(rootDir);
|
|
190
211
|
}
|
|
191
212
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
213
|
+
/**
|
|
214
|
+
* The GitHub CLI's canonical repo URL - the easy path. `gh` already resolves the
|
|
215
|
+
* true host (no ssh-alias parsing) and prints a clean `https://host/owner/repo`.
|
|
216
|
+
* `undefined` when `gh` is absent, unauthenticated, or the dir isn't a GH repo.
|
|
217
|
+
*/
|
|
218
|
+
function repositoryUrlFromGh(cwd?: string): string | undefined {
|
|
219
|
+
const out = projectContextCommand("gh", ["repo", "view", "--json", "url"], cwd).output;
|
|
220
|
+
if (!out) return undefined;
|
|
221
|
+
try {
|
|
222
|
+
return (JSON.parse(out) as { url?: string }).url?.trim() || undefined;
|
|
223
|
+
} catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Resolve an ssh host alias (`~/.ssh/config`) to its effective `hostname` via `ssh -G`. */
|
|
229
|
+
function resolveSshHostName(host: string, cwd?: string): string | undefined {
|
|
230
|
+
const line = projectContextCommand("ssh", ["-G", host], cwd)
|
|
231
|
+
.output?.split("\n")
|
|
232
|
+
.find((l) => /^hostname\s/i.test(l.trim()));
|
|
233
|
+
const name = line?.trim().split(/\s+/)[1];
|
|
234
|
+
return name && name !== host ? name : undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Fallback: normalize `git remote get-url origin` to a plain
|
|
239
|
+
* `https://host/owner/repo` URL. scp-like / `ssh://` / `git://` /
|
|
240
|
+
* embedded-credential forms are rewritten to https, and an ssh host alias is
|
|
241
|
+
* followed to its real hostname.
|
|
242
|
+
*/
|
|
243
|
+
function repositoryUrlFromGit(cwd?: string): string | undefined {
|
|
244
|
+
const raw = projectContextCommand("git", ["remote", "get-url", "origin"], cwd).output;
|
|
245
|
+
if (!raw) return undefined;
|
|
246
|
+
|
|
247
|
+
// Normalize the scheme to https at the string level first: the WHATWG `URL`
|
|
248
|
+
// parser can't convert a non-special scheme (`ssh`/`git`) to `https` (the
|
|
249
|
+
// `protocol` setter no-ops), and scp-like `git@host:owner/repo` isn't a URL at
|
|
250
|
+
// all. Rewrite both into an `https://` string, then let {@link net.urlBuilder}
|
|
251
|
+
// own the structured edits (strip credentials, swap the host).
|
|
252
|
+
let https = raw.replace(/^git\+/, "");
|
|
253
|
+
const scp = /^[^@]+@([^:]+):(.+)$/.exec(https);
|
|
254
|
+
if (scp) https = `https://${scp[1]}/${scp[2]}`;
|
|
255
|
+
https = https.replace(/^(ssh|git):\/\//, "https://");
|
|
256
|
+
|
|
257
|
+
let builder = net.urlBuilder(https);
|
|
258
|
+
if (!builder) return undefined;
|
|
259
|
+
// Drop any embedded `user[:pass]@` credentials.
|
|
260
|
+
if (builder.username || builder.password) {
|
|
261
|
+
builder = builder.with("username", "").with("password", "");
|
|
262
|
+
}
|
|
263
|
+
// Follow an ssh host alias to the true host (so `github-reggie-db` -> `github.com`).
|
|
264
|
+
const realHost = resolveSshHostName(builder.hostname, cwd);
|
|
265
|
+
if (realHost) builder = builder.with("hostname", realHost);
|
|
266
|
+
|
|
267
|
+
return `${builder.origin}${builder.pathname.replace(/\.git$/, "")}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The repo's canonical remote URL, or `undefined` when there is no git remote.
|
|
272
|
+
* Tries `gh repo view` first (host-accurate, no parsing), then normalizes
|
|
273
|
+
* `git remote get-url origin`. Both underlying commands are cached per `cwd` via
|
|
274
|
+
* {@link projectContextCommand}.
|
|
275
|
+
*
|
|
276
|
+
* @param cwd - directory to resolve from (defaults to `process.cwd()`).
|
|
277
|
+
* @param format - `"https"` (default) yields `https://host/owner/repo`;
|
|
278
|
+
* `"npm"` yields npm's `git+https://host/owner/repo.git` form (for a
|
|
279
|
+
* `package.json` `repository.url` that passes npm provenance).
|
|
280
|
+
*/
|
|
281
|
+
export function repositoryUrl(
|
|
282
|
+
cwd: string = process.cwd(),
|
|
283
|
+
format: "https" | "npm" = "https",
|
|
284
|
+
): string | undefined {
|
|
285
|
+
const https = repositoryUrlFromGh(cwd) ?? repositoryUrlFromGit(cwd);
|
|
286
|
+
if (!https) return undefined;
|
|
287
|
+
return format === "npm" ? `git+${https.replace(/\.git$/, "")}.git` : https;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The active npm registry (`npm config get registry`) as a chainable
|
|
292
|
+
* {@link net.UrlBuilder}, or `undefined` when npm is absent or prints no URL.
|
|
293
|
+
* Cached per `cwd` via {@link projectContextCommand}.
|
|
294
|
+
*/
|
|
295
|
+
export function npmRegistry(cwd?: string): net.UrlBuilder | undefined {
|
|
296
|
+
const out = projectContextCommand("npm", ["config", "get", "registry"], cwd).output;
|
|
297
|
+
const trimmed = out?.trim();
|
|
298
|
+
return trimmed && trimmed !== "undefined" ? (net.urlBuilder(trimmed) ?? undefined) : undefined;
|
|
197
299
|
}
|
|
198
300
|
|
|
199
301
|
function readPackageName(pkgPath: string): string | undefined {
|
|
200
|
-
if (!
|
|
302
|
+
if (!stat(pkgPath)?.isFile()) return undefined;
|
|
201
303
|
try {
|
|
202
304
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string };
|
|
203
305
|
return pkg.name?.trim() || undefined;
|
|
@@ -211,4 +313,6 @@ if (import.meta.main) {
|
|
|
211
313
|
console.log("repo root:", gitRoot());
|
|
212
314
|
console.log("package root:", root());
|
|
213
315
|
console.log("project name:", name());
|
|
316
|
+
console.log("repository url:", repositoryUrl());
|
|
317
|
+
console.log("repository url (npm):", repositoryUrl(process.cwd(), "npm"));
|
|
214
318
|
}
|