@dbx-tools/core 0.9.69 → 0.9.71
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 +3 -3
- package/lib/index.d.ts +3 -3
- package/lib/index.js +2 -2
- package/lib/src/exec.d.ts +38 -6
- package/lib/src/exec.js +287 -2
- package/lib/src/project-utils.d.ts +41 -1
- package/lib/src/project-utils.js +149 -27
- package/package.json +2 -2
- package/src/exec.ts +378 -6
- package/src/project-utils.ts +225 -24
package/src/project-utils.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
56
|
-
|
|
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(
|
|
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
|
|
75
|
-
|
|
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
|
|
86
|
-
const
|
|
87
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
}
|