@dbx-tools/core 0.1.9 → 0.1.10

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 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.9"
14
+ "@dbx-tools/shared-core": "0.1.10"
15
15
  },
16
16
  "main": "index.ts",
17
17
  "license": "UNLICENSED",
18
- "version": "0.1.9",
18
+ "version": "0.1.10",
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, statSync } from "node:fs";
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
- function statPath(path: string): Stats | undefined {
15
- if (path) {
16
- try {
17
- return statSync(path);
18
- } catch {}
19
- }
20
- return undefined;
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 directoryCommand(command: string, args: string[], cwd: string): string | undefined {
42
+ function projectContextCommandOutput(command: string, args: string[], cwd: string): ProjectContext {
27
43
  const result = spawnSync(command, args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
28
- if (result.status === 0) {
29
- const output = result.stdout.toString().trim();
30
- return statPath(output)?.isDirectory() ? output : undefined;
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 undefined;
54
+ return { cwd, output };
33
55
  }
34
56
 
35
- const rootDirectoryCommands: Record<string, [string, string[]]> = {
36
- npm: ["npm", ["prefix"]] as const,
37
- git: ["git", ["rev-parse", "--show-toplevel"]] as const,
38
- } as const;
39
-
40
- const rootDirectoryDefaultCache = new Map<
41
- keyof typeof rootDirectoryCommands,
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 rootDirectory(name: keyof typeof rootDirectoryCommands, cwd?: string): string | undefined {
46
- const [command, args] = rootDirectoryCommands[name];
47
- let cache: boolean;
48
- if (cwd === undefined) {
49
- cwd = process.cwd();
50
- cache = true;
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
- cache = cwd === process.cwd();
53
- }
54
- if (cache) {
55
- const cached = rootDirectoryDefaultCache.get(name);
56
- if (cached?.cwd === cwd) {
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 path = directoryCommand(command, args, cwd);
61
- if (cache) {
62
- rootDirectoryDefaultCache.set(name, { cwd, path });
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 path;
86
+ return result;
65
87
  }
88
+
66
89
  function npmRoot(cwd?: string): string | undefined {
67
- return rootDirectory("npm", cwd);
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
- return rootDirectory("git", cwd);
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 (!statPath(current)?.isDirectory()) {
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 (statPath(join(current, marker))?.isFile()) {
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 (statPath(dir)?.isDirectory()) yield dir;
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 (statPath(resolve(dir, "package.json"))?.isFile()) return dir;
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,92 @@ 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 = commandOutput("git", ["-C", rootDir, "remote", "get-url", "origin"], rootDir);
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
- /** Trimmed stdout of a command, or `undefined` when it fails or prints nothing. */
193
- function commandOutput(command: string, args: string[], cwd: string): string | undefined {
194
- const result = spawnSync(command, args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
195
- if (result.status !== 0) return undefined;
196
- return result.stdout.toString().trim() || undefined;
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;
197
288
  }
198
289
 
199
290
  function readPackageName(pkgPath: string): string | undefined {
200
- if (!statPath(pkgPath)?.isFile()) return undefined;
291
+ if (!stat(pkgPath)?.isFile()) return undefined;
201
292
  try {
202
293
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string };
203
294
  return pkg.name?.trim() || undefined;
@@ -211,4 +302,6 @@ if (import.meta.main) {
211
302
  console.log("repo root:", gitRoot());
212
303
  console.log("package root:", root());
213
304
  console.log("project name:", name());
305
+ console.log("repository url:", repositoryUrl());
306
+ console.log("repository url (npm):", repositoryUrl(process.cwd(), "npm"));
214
307
  }