@dbx-tools/core 0.9.69 → 0.9.70

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.
@@ -31,7 +31,9 @@ export function resolveWorkingDirectory(cwd?: string | null): string {
31
31
 
32
32
  /** A command's stdout, classified as a filesystem path and/or a URL. */
33
33
  export interface ProjectContext {
34
- readonly output: string;
34
+ readonly output?: string;
35
+ /** Whether the command exited successfully. */
36
+ readonly success: boolean;
35
37
  /** `output` when it names something on disk. */
36
38
  readonly path?: string;
37
39
  /** `file.statSync` of {@link path}, when it exists. */
@@ -52,8 +54,22 @@ export interface ProjectContext {
52
54
  * parses as a real network URL. Empty {@link ProjectContext} on a non-zero exit
53
55
  * or empty output.
54
56
  */
55
- function projectContextCommandOutput(command: string, args: string[], cwd: string): ProjectContext {
56
- const result = spawnSync(command, args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
57
+ interface ProjectCommandOptions {
58
+ readonly cache?: boolean;
59
+ readonly env?: NodeJS.ProcessEnv;
60
+ }
61
+
62
+ function projectContextCommandOutput(
63
+ command: string,
64
+ args: string[],
65
+ cwd: string,
66
+ env?: NodeJS.ProcessEnv,
67
+ ): ProjectContext {
68
+ const result = spawnSync(command, args, {
69
+ cwd,
70
+ env: env ?? process.env,
71
+ stdio: ["ignore", "pipe", "ignore"],
72
+ });
57
73
  const output = result?.stdout?.toString()?.trim();
58
74
  if (result.status === 0 && output) {
59
75
  const pathStats = fileStatSync(output);
@@ -62,29 +78,63 @@ function projectContextCommandOutput(command: string, args: string[], cwd: strin
62
78
  // `http://localhost/…`), which would mislabel directory outputs and bare
63
79
  // tokens - so gate on the raw output already carrying a scheme + authority.
64
80
  const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(output) ? net.urlBuilder(output) : undefined;
65
- return { output, path: pathStats ? output : undefined, pathStats, url };
81
+ return { output, path: pathStats ? output : undefined, pathStats, success: true, url };
66
82
  }
67
- return { output };
83
+ return { output, success: result.status === 0 };
68
84
  }
69
85
 
70
86
  const projectCommandCache = new Map<string, ProjectContext>();
71
87
 
72
- function projectContextCommand(command: string, args: string[], cwd?: string): ProjectContext {
88
+ function projectContextCommand(
89
+ command: string,
90
+ args: string[],
91
+ cwd?: string,
92
+ options: ProjectCommandOptions = {},
93
+ ): ProjectContext {
73
94
  const resolved = resolveWorkingDirectory(cwd);
74
- const key =
75
- resolved === resolveWorkingDirectory() ? JSON.stringify([command, ...args]) : undefined;
95
+ const cache = options.cache ?? (!options.env && resolved === resolveWorkingDirectory());
96
+ const key = cache ? JSON.stringify([resolved, command, ...args]) : undefined;
76
97
  if (key !== undefined) {
77
98
  const cached = projectCommandCache.get(key);
78
99
  if (cached !== undefined) return cached;
79
100
  }
80
- const value = projectContextCommandOutput(command, args, resolved);
101
+ const value = projectContextCommandOutput(command, args, resolved, options.env);
81
102
  if (key !== undefined) projectCommandCache.set(key, value);
82
103
  return value;
83
104
  }
84
105
 
85
- function commandRoot(command: string, args: string[], cwd: string): string | undefined {
86
- const parsed = projectContextCommand(command, args, cwd);
87
- return parsed.pathStats?.isDirectory() ? parsed.path : undefined;
106
+ function projectCommand(command: string) {
107
+ const context = (
108
+ args: string[],
109
+ cwd?: string,
110
+ options?: ProjectCommandOptions,
111
+ ): ProjectContext => projectContextCommand(command, args, cwd, options);
112
+ return {
113
+ context,
114
+ output(
115
+ args: string[],
116
+ cwd?: string,
117
+ options?: ProjectCommandOptions,
118
+ ): string | undefined {
119
+ return stringUtils.trimToNull(context(args, cwd, options).output) ?? undefined;
120
+ },
121
+ root(args: string[], cwd: string): string | undefined {
122
+ const parsed = context(args, cwd);
123
+ return parsed.pathStats?.isDirectory() ? parsed.path : undefined;
124
+ },
125
+ succeeds(args: string[], cwd?: string, options?: ProjectCommandOptions): boolean {
126
+ return context(args, cwd, options).success;
127
+ },
128
+ };
129
+ }
130
+
131
+ const gitCommand = projectCommand("git");
132
+ const ghCommand = projectCommand("gh");
133
+ const npmCommand = projectCommand("npm");
134
+ const sshCommand = projectCommand("ssh");
135
+
136
+ function gitRemoteUrl(cwd?: string, remote = "origin", refresh = false): string | undefined {
137
+ return gitCommand.output(["remote", "get-url", remote], cwd, { cache: !refresh });
88
138
  }
89
139
 
90
140
  /**
@@ -104,7 +154,7 @@ function npmRoot(cwd?: string): string | undefined {
104
154
  const fromEnv = envDirectory(process.env.npm_config_local_prefix);
105
155
  if (fromEnv) return fromEnv;
106
156
  }
107
- return commandRoot("npm", ["prefix"], resolved);
157
+ return npmCommand.root(["prefix"], resolved);
108
158
  }
109
159
 
110
160
  function envDirectory(value: string | undefined): string | undefined {
@@ -116,7 +166,7 @@ function envDirectory(value: string | undefined): string | undefined {
116
166
 
117
167
  function gitRoot(cwd?: string): string | undefined {
118
168
  const resolved = resolveWorkingDirectory(cwd);
119
- return commandRoot("git", ["rev-parse", "--show-toplevel"], resolved);
169
+ return gitCommand.root(["rev-parse", "--show-toplevel"], resolved);
120
170
  }
121
171
 
122
172
  /** Resolve the nearest project root without walking above its npm or Git boundary. */
@@ -237,11 +287,7 @@ export function name(cwd?: string): string {
237
287
  const fromPackage = readPackageName(resolve(rootDir, "package.json"));
238
288
  if (fromPackage) return fromPackage;
239
289
 
240
- const remote = projectContextCommand(
241
- "git",
242
- ["-C", rootDir, "remote", "get-url", "origin"],
243
- rootDir,
244
- ).output;
290
+ const remote = gitRemoteUrl(rootDir);
245
291
  const fromGit = remote ? parseGitRemote(remote) : undefined;
246
292
  if (fromGit) return fromGit;
247
293
 
@@ -254,19 +300,166 @@ export function name(cwd?: string): string {
254
300
  * `undefined` when `gh` is absent, unauthenticated, or the dir isn't a GH repo.
255
301
  */
256
302
  function repositoryUrlFromGh(cwd?: string): string | undefined {
257
- const out = projectContextCommand("gh", ["repo", "view", "--json", "url"], cwd).output;
303
+ const account = resolveProjectGhAccount(cwd);
304
+ if (!account) return undefined;
305
+ const out = ghCommand.output(
306
+ ["repo", "view", account.remote.repository, "--json", "url"],
307
+ account.root,
308
+ { cache: false, env: account.env },
309
+ );
258
310
  return stringUtils.trimToNull(json.parseRecord(out)?.url) ?? undefined;
259
311
  }
260
312
 
261
313
  /** Resolve an ssh host alias (`~/.ssh/config`) to its effective `hostname` via `ssh -G`. */
262
- function resolveSshHostName(host: string, cwd?: string): string | undefined {
263
- const line = projectContextCommand("ssh", ["-G", host], cwd)
314
+ function resolveSshHostName(host: string, cwd?: string, refresh = false): string | undefined {
315
+ const line = sshCommand
316
+ .context(["-G", host], cwd, { cache: !refresh })
264
317
  .output?.split("\n")
265
318
  .find((l) => /^hostname\s/i.test(l.trim()));
266
319
  const name = line?.trim().split(/\s+/)[1];
267
320
  return name && name !== host ? name : undefined;
268
321
  }
269
322
 
323
+ /** Parsed GitHub-style remote coordinates. */
324
+ export interface ProjectGhRemote {
325
+ /** Account hinted by an SSH alias such as `git@github-work:`. */
326
+ readonly accountHint?: string;
327
+ /** Host written in the remote before SSH alias resolution. */
328
+ readonly host: string;
329
+ /** `owner/repository` path. */
330
+ readonly repository: string;
331
+ }
332
+
333
+ /** Authenticated GitHub CLI account selected for one project remote. */
334
+ export interface ProjectGhAccount {
335
+ /** Environment to pass to every `gh` command for this project. */
336
+ readonly env: NodeJS.ProcessEnv;
337
+ /** Actual GitHub host selected from `gh auth status`. */
338
+ readonly host: string;
339
+ /** Authenticated GitHub login. */
340
+ readonly login: string;
341
+ /** Parsed project remote. */
342
+ readonly remote: ProjectGhRemote;
343
+ /** Resolved project directory used for command execution. */
344
+ readonly root: string;
345
+ }
346
+
347
+ /** Options for cached project GitHub-account resolution. */
348
+ export interface ResolveProjectGhAccountOptions {
349
+ /** Git remote name. Defaults to `origin`. */
350
+ readonly remote?: string;
351
+ /** Re-read git, SSH, and GitHub CLI state instead of using the process cache. */
352
+ readonly refresh?: boolean;
353
+ }
354
+
355
+ /** Parse HTTPS, SSH URL, or scp-like GitHub remote coordinates. */
356
+ export function parseProjectGhRemote(remoteUrl: string): ProjectGhRemote | undefined {
357
+ const value = remoteUrl.trim().replace(/\.git$/i, "");
358
+ const https = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)$/i.exec(value);
359
+ if (https) return { host: https[1]!, repository: `${https[2]}/${https[3]}` };
360
+ const ssh = /^(?:ssh:\/\/)?git@([^/:]+)[:/]([^/]+)\/([^/]+)$/i.exec(value);
361
+ if (!ssh) return undefined;
362
+ const accountHint = /^github-(.+)$/i.exec(ssh[1]!)?.[1];
363
+ return {
364
+ ...(accountHint ? { accountHint } : {}),
365
+ host: ssh[1]!,
366
+ repository: `${ssh[2]}/${ssh[3]}`,
367
+ };
368
+ }
369
+
370
+ const projectGhAccountCache = new Map<string, ProjectGhAccount | null>();
371
+
372
+ /**
373
+ * Resolve and cache the authenticated `gh` account that can access a project's
374
+ * remote. SSH alias account hints win, followed by the active account on the
375
+ * effective SSH/HTTPS host, then other authenticated accounts. Every candidate
376
+ * is verified against the repository before it is returned.
377
+ */
378
+ export function resolveProjectGhAccount(
379
+ cwd?: string,
380
+ options: ResolveProjectGhAccountOptions = {},
381
+ ): ProjectGhAccount | undefined {
382
+ const root = resolveWorkingDirectory(cwd);
383
+ const remoteName = stringUtils.trimToNull(options.remote) ?? "origin";
384
+ const key = JSON.stringify([
385
+ root,
386
+ remoteName,
387
+ homedir(),
388
+ process.env.GH_CONFIG_DIR ?? "",
389
+ ]);
390
+ if (!options.refresh && projectGhAccountCache.has(key)) {
391
+ return projectGhAccountCache.get(key) ?? undefined;
392
+ }
393
+ const remoteUrl = gitRemoteUrl(root, remoteName, options.refresh);
394
+ if (!remoteUrl) return undefined;
395
+ const remote = parseProjectGhRemote(remoteUrl);
396
+ if (!remote) return undefined;
397
+ const effectiveHost = resolveSshHostName(remote.host, root, options.refresh) ?? remote.host;
398
+
399
+ const status = ghCommand.output(["auth", "status", "--json", "hosts"], root, {
400
+ cache: false,
401
+ });
402
+ if (!status) {
403
+ projectGhAccountCache.set(key, null);
404
+ return undefined;
405
+ }
406
+ let parsed: {
407
+ hosts?: Record<
408
+ string,
409
+ Array<{ active?: boolean; host?: string; login?: string; state?: string }>
410
+ >;
411
+ };
412
+ try {
413
+ parsed = JSON.parse(status) as typeof parsed;
414
+ } catch {
415
+ projectGhAccountCache.set(key, null);
416
+ return undefined;
417
+ }
418
+ const accounts = Object.entries(parsed.hosts ?? {}).flatMap(([host, entries]) =>
419
+ entries
420
+ .filter((entry) => entry.state === "success" && entry.login)
421
+ .map((entry) => ({
422
+ active: entry.active === true,
423
+ host: entry.host ?? host,
424
+ login: entry.login!,
425
+ })),
426
+ );
427
+ accounts.sort((left, right) => {
428
+ const rank = (account: (typeof accounts)[number]): number => {
429
+ if (remote.accountHint && account.login === remote.accountHint) return 0;
430
+ if (account.host === effectiveHost && account.active) return 1;
431
+ if (account.host === effectiveHost) return 2;
432
+ if (account.active) return 3;
433
+ return 4;
434
+ };
435
+ return rank(left) - rank(right);
436
+ });
437
+
438
+ for (const account of accounts) {
439
+ const token = ghCommand.output(
440
+ ["auth", "token", "--hostname", account.host, "--user", account.login],
441
+ root,
442
+ { cache: false },
443
+ );
444
+ if (!token) continue;
445
+ const env = { ...process.env, GH_HOST: account.host, GH_TOKEN: token };
446
+ if (
447
+ !ghCommand.succeeds(
448
+ ["api", "--hostname", account.host, `repos/${remote.repository}`, "--silent"],
449
+ root,
450
+ { cache: false, env },
451
+ )
452
+ ) {
453
+ continue;
454
+ }
455
+ const selected = { env, host: account.host, login: account.login, remote, root };
456
+ projectGhAccountCache.set(key, selected);
457
+ return selected;
458
+ }
459
+ projectGhAccountCache.set(key, null);
460
+ return undefined;
461
+ }
462
+
270
463
  /**
271
464
  * Fallback: normalize `git remote get-url origin` to a plain
272
465
  * `https://host/owner/repo` URL. scp-like / `ssh://` / `git://` /
@@ -274,7 +467,7 @@ function resolveSshHostName(host: string, cwd?: string): string | undefined {
274
467
  * followed to its real hostname.
275
468
  */
276
469
  function repositoryUrlFromGit(cwd?: string): string | undefined {
277
- const raw = projectContextCommand("git", ["remote", "get-url", "origin"], cwd).output;
470
+ const raw = gitRemoteUrl(cwd);
278
471
  if (!raw) return undefined;
279
472
 
280
473
  // Normalize the scheme to https at the string level first: the WHATWG `URL`
@@ -293,7 +486,7 @@ function repositoryUrlFromGit(cwd?: string): string | undefined {
293
486
  if (builder.username || builder.password) {
294
487
  builder = builder.with("username", "").with("password", "");
295
488
  }
296
- // Follow an ssh host alias to the true host (so `github-reggie-db` -> `github.com`).
489
+ // Follow an ssh host alias to the true host.
297
490
  const realHost = resolveSshHostName(builder.hostname, cwd);
298
491
  if (realHost) builder = builder.with("hostname", realHost);
299
492
 
@@ -552,4 +745,12 @@ if (import.meta.main) {
552
745
  console.log("project name:", name());
553
746
  console.log("repository url:", repositoryUrl());
554
747
  console.log("repository url (npm):", repositoryUrl(undefined, "npm"));
748
+ const ghAccount = resolveProjectGhAccount();
749
+ if (ghAccount) {
750
+ console.log("project gh host:", ghAccount.host);
751
+ console.log("project gh login:", ghAccount.login);
752
+ console.log("project gh remote:", ghAccount.remote.repository);
753
+ } else {
754
+ console.log("project gh account: not found");
755
+ }
555
756
  }