@dbx-tools/core 0.9.12 → 0.9.17

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.
@@ -1,8 +1,10 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { Stats, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
3
4
  import { basename, dirname, join, resolve } from "node:path";
4
- import { json, net, stringUtils } from "@dbx-tools/shared-core";
5
- import { statSync as fileStatSync } from "./file.ts";
5
+ import { json, net, object, stringUtils } from "@dbx-tools/shared-core";
6
+ import { parse as parseYaml } from "yaml";
7
+ import { cachedRecord, statSync as fileStatSync } from "./file.ts";
6
8
 
7
9
  const ROOT_MARKERS = [
8
10
  ".projenrc.ts",
@@ -75,11 +77,33 @@ function commandRoot(command: string, args: string[], cwd: string): string | und
75
77
  return parsed.pathStats?.isDirectory() ? parsed.path : undefined;
76
78
  }
77
79
 
80
+ /**
81
+ * Closest directory with a `package.json` or `node_modules`, matching `npm prefix`.
82
+ *
83
+ * Lifecycle scripts from npm and Bun set `npm_config_local_prefix` to that
84
+ * directory. Read it first when resolving this process's cwd so a missing npm
85
+ * CLI is not required. `npm_config_prefix` / `PREFIX` are the global install
86
+ * prefix and must not be used here.
87
+ *
88
+ * Bun has no prefix command (`bun pm bin` is the `.bin` directory). pnpm's
89
+ * `prefix` command is unimplemented; `pnpm root` prints `node_modules`.
90
+ */
78
91
  function npmRoot(cwd?: string): string | undefined {
79
92
  const resolved = resolveWorkingDirectory(cwd);
93
+ if (resolved === resolveWorkingDirectory()) {
94
+ const fromEnv = envDirectory(process.env.npm_config_local_prefix);
95
+ if (fromEnv) return fromEnv;
96
+ }
80
97
  return commandRoot("npm", ["prefix"], resolved);
81
98
  }
82
99
 
100
+ function envDirectory(value: string | undefined): string | undefined {
101
+ const path = stringUtils.trimToNull(value);
102
+ if (!path) return undefined;
103
+ const resolved = resolve(path);
104
+ return fileStatSync(resolved)?.isDirectory() ? resolved : undefined;
105
+ }
106
+
83
107
  function gitRoot(cwd?: string): string | undefined {
84
108
  const resolved = resolveWorkingDirectory(cwd);
85
109
  return commandRoot("git", ["rev-parse", "--show-toplevel"], resolved);
@@ -275,57 +299,228 @@ export function repositoryUrl(cwd?: string, format: "https" | "npm" = "https"):
275
299
  return format === "npm" ? `git+${https.replace(/\.git$/, "")}.git` : https;
276
300
  }
277
301
 
302
+ /** Public npm, used only after env, npmrc, bunfig, and pnpm yaml miss. */
303
+ const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/";
304
+
305
+ /** Inputs that control active npm registry discovery. */
306
+ export type NpmRegistryOptions = {
307
+ /** Skip public npmjs.org so callers can detect a configured override. */
308
+ overrideOnly?: boolean;
309
+ /**
310
+ * Read `npm_config_registry`, `NPM_CONFIG_REGISTRY`, and `BUN_CONFIG_REGISTRY`
311
+ * first. Defaults to true so the same cascade a package manager uses is the
312
+ * default.
313
+ */
314
+ envVars?: boolean;
315
+ };
316
+
317
+ const npmRegistryCache = new Map<string, net.UrlBuilder | undefined>();
318
+
278
319
  /**
279
- * The active npm registry (`npm config get registry`) as a chainable
280
- * {@link net.UrlBuilder}, or `undefined` when npm is absent or prints no URL.
281
- * A blank, null, omitted, or explicitly current `cwd` reuses the cached npm
282
- * command; another resolved directory executes it directly. Environment options
283
- * are evaluated on every call.
320
+ * The active npm registry as a chainable {@link net.UrlBuilder}.
321
+ *
322
+ * Resolution is manager-agnostic and memoized per cwd plus options: environment
323
+ * variables, then `.npmrc` (project ancestors, user, global), then Bun
324
+ * `bunfig.toml` `install.registry`, then pnpm workspace/global YAML, then
325
+ * `https://registry.npmjs.org/`. `overrideOnly` skips that public default.
284
326
  */
285
327
  export function npmRegistry(
286
328
  cwd?: string | null,
287
- options?: { overrideOnly?: boolean; envVars?: boolean },
329
+ options?: NpmRegistryOptions,
288
330
  ): net.UrlBuilder | undefined {
289
331
  const resolved = resolveWorkingDirectory(cwd);
290
- return resolveNpmRegistry(resolved, options);
332
+ const envVars = options?.envVars !== false;
333
+ const overrideOnly = Boolean(options?.overrideOnly);
334
+ const key = JSON.stringify([
335
+ resolved,
336
+ overrideOnly,
337
+ envVars,
338
+ homedir(),
339
+ process.env.NPM_CONFIG_USERCONFIG ?? "",
340
+ process.env.NPM_CONFIG_GLOBALCONFIG ?? "",
341
+ process.env.PREFIX ?? "",
342
+ envVars ? (process.env.npm_config_registry ?? "") : "",
343
+ envVars ? (process.env.NPM_CONFIG_REGISTRY ?? "") : "",
344
+ envVars ? (process.env.BUN_CONFIG_REGISTRY ?? "") : "",
345
+ ]);
346
+ if (npmRegistryCache.has(key)) return npmRegistryCache.get(key);
347
+ const value = resolveNpmRegistry(resolved, { overrideOnly, envVars });
348
+ npmRegistryCache.set(key, value);
349
+ return value;
291
350
  }
292
351
 
293
352
  function resolveNpmRegistry(
294
- cwd: string | null | undefined,
295
- options: { overrideOnly?: boolean; envVars?: boolean } | undefined,
353
+ cwd: string,
354
+ options: { overrideOnly: boolean; envVars: boolean },
296
355
  ): net.UrlBuilder | undefined {
297
- const candidates = (function* () {
298
- yield projectContextCommand(
299
- "npm",
300
- ["config", "get", "registry"],
301
- cwd ?? undefined,
302
- ).output?.trim();
303
- if (typeof process !== "undefined" && process?.env) {
304
- const envKey = "npm_config_registry";
305
- if (options?.envVars) {
306
- yield process.env[envKey];
307
- yield process.env[envKey.toUpperCase()];
308
- }
309
- }
310
- })();
311
- for (const candidate of candidates) {
312
- if (candidate) {
313
- const url = net.urlBuilder(candidate);
314
- if (!url) {
315
- continue;
316
- } else if (options?.overrideOnly) {
317
- const npmjsRegistryUrl = "https://registry.npmjs.org";
318
- const registryUrl = url.toString();
319
- if (registryUrl === npmjsRegistryUrl || registryUrl.startsWith(npmjsRegistryUrl + "/")) {
320
- continue;
321
- }
322
- }
323
- return url;
356
+ for (const candidate of npmRegistryCandidates(cwd, options.envVars)) {
357
+ const url = toRegistryUrl(candidate, options.overrideOnly);
358
+ if (url) return url;
359
+ }
360
+ return toRegistryUrl(DEFAULT_NPM_REGISTRY, options.overrideOnly);
361
+ }
362
+
363
+ function* npmRegistryCandidates(cwd: string, envVars: boolean): Generator<string | undefined> {
364
+ if (envVars) {
365
+ yield process.env.npm_config_registry;
366
+ yield process.env.NPM_CONFIG_REGISTRY;
367
+ yield process.env.BUN_CONFIG_REGISTRY;
368
+ }
369
+ for (const dir of ancestorDirs(cwd)) {
370
+ yield readNpmrcRegistry(join(dir, ".npmrc"));
371
+ }
372
+ yield readNpmrcRegistry(process.env.NPM_CONFIG_USERCONFIG ?? join(homedir(), ".npmrc"));
373
+ yield readNpmrcRegistry(process.env.NPM_CONFIG_GLOBALCONFIG ?? defaultGlobalNpmrc());
374
+ for (const dir of ancestorDirs(cwd)) {
375
+ yield readBunfigRegistry(join(dir, "bunfig.toml"));
376
+ }
377
+ yield readBunfigRegistry(join(homedir(), "bunfig.toml"));
378
+ for (const dir of ancestorDirs(cwd)) {
379
+ yield readYamlRegistry(join(dir, "pnpm-workspace.yaml"));
380
+ }
381
+ for (const path of pnpmGlobalConfigPaths()) {
382
+ yield readYamlRegistry(path);
383
+ }
384
+ }
385
+
386
+ function toRegistryUrl(
387
+ candidate: string | undefined,
388
+ overrideOnly: boolean,
389
+ ): net.UrlBuilder | undefined {
390
+ const trimmed = stringUtils.trimToNull(candidate);
391
+ if (!trimmed) return undefined;
392
+ const url = net.urlBuilder(trimmed);
393
+ if (!url) return undefined;
394
+ if (overrideOnly && isPublicNpmRegistry(url)) return undefined;
395
+ return url;
396
+ }
397
+
398
+ function isPublicNpmRegistry(url: net.UrlBuilder): boolean {
399
+ return url.hostname === "registry.npmjs.org";
400
+ }
401
+
402
+ function* ancestorDirs(cwd: string): Generator<string> {
403
+ let current = cwd;
404
+ while (true) {
405
+ yield current;
406
+ const parent = dirname(current);
407
+ if (parent === current) return;
408
+ current = parent;
409
+ }
410
+ }
411
+
412
+ function defaultGlobalNpmrc(): string {
413
+ if (process.platform === "win32") {
414
+ return join(process.env.PROGRAMDATA ?? "C:\\ProgramData", "npm", "etc", "npmrc");
415
+ }
416
+ return join(process.env.PREFIX ?? "/usr/local", "etc", "npmrc");
417
+ }
418
+
419
+ function pnpmGlobalConfigPaths(): string[] {
420
+ const home = homedir();
421
+ if (process.platform === "darwin") {
422
+ return [join(home, "Library/Preferences/pnpm/config.yaml")];
423
+ }
424
+ if (process.platform === "win32") {
425
+ return [join(process.env.LOCALAPPDATA ?? join(home, "AppData/Local"), "pnpm/config.yaml")];
426
+ }
427
+ return [join(home, ".config/pnpm/config.yaml")];
428
+ }
429
+
430
+ function readNpmrcRegistry(path: string): string | undefined {
431
+ return cachedRecord(`npmrc-registry:${path}`, () => {
432
+ const text = readTextFile(path);
433
+ const registry = text ? parseIniRegistry(text) : undefined;
434
+ return registry ? { registry } : undefined;
435
+ })?.registry;
436
+ }
437
+
438
+ function readBunfigRegistry(path: string): string | undefined {
439
+ return cachedRecord(`bunfig-registry:${path}`, () => {
440
+ const text = readTextFile(path);
441
+ const registry = text ? parseBunfigRegistry(text) : undefined;
442
+ return registry ? { registry } : undefined;
443
+ })?.registry;
444
+ }
445
+
446
+ function readYamlRegistry(path: string): string | undefined {
447
+ return cachedRecord(`yaml-registry:${path}`, () => {
448
+ const text = readTextFile(path);
449
+ if (!text) return undefined;
450
+ const parsed = parseYaml(text);
451
+ if (!object.isRecord(parsed)) return undefined;
452
+ const registry = yamlRegistry(parsed);
453
+ return registry ? { registry } : undefined;
454
+ })?.registry;
455
+ }
456
+
457
+ function readTextFile(path: string): string | undefined {
458
+ if (!fileStatSync(path)?.isFile()) return undefined;
459
+ return readFileSync(path, "utf8");
460
+ }
461
+
462
+ /** Parse a `registry=` assignment from npmrc / ini text. */
463
+ function parseIniRegistry(text: string): string | undefined {
464
+ for (const raw of text.split(/\r?\n/)) {
465
+ const line = raw.trim();
466
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
467
+ const eq = line.indexOf("=");
468
+ if (eq === -1) continue;
469
+ if (line.slice(0, eq).trim() !== "registry") continue;
470
+ return unquoteIniValue(line.slice(eq + 1));
471
+ }
472
+ return undefined;
473
+ }
474
+
475
+ /**
476
+ * Read Bun `install.registry` from a bunfig.toml. Table form
477
+ * `[install.registry]` with `url = "..."` is accepted; scoped maps are ignored.
478
+ */
479
+ function parseBunfigRegistry(text: string): string | undefined {
480
+ let section = "";
481
+ for (const raw of text.split(/\r?\n/)) {
482
+ const line = stripTomlComment(raw).trim();
483
+ if (!line) continue;
484
+ const header = /^\[([^\]]+)\]$/.exec(line);
485
+ if (header) {
486
+ section = header[1]!.trim();
487
+ continue;
324
488
  }
489
+ const eq = line.indexOf("=");
490
+ if (eq === -1) continue;
491
+ const key = line.slice(0, eq).trim();
492
+ const value = unquoteIniValue(line.slice(eq + 1));
493
+ if (section === "install" && key === "registry" && !value.startsWith("{")) return value;
494
+ if (section === "install.registry" && key === "url") return value;
495
+ if (!section && key === "install.registry") return value;
325
496
  }
326
497
  return undefined;
327
498
  }
328
499
 
500
+ function yamlRegistry(data: Record<string, unknown>): string | undefined {
501
+ if (typeof data.registry === "string") return data.registry;
502
+ const registries = data.registries;
503
+ if (object.isRecord(registries) && typeof registries.default === "string") {
504
+ return registries.default;
505
+ }
506
+ return undefined;
507
+ }
508
+
509
+ function unquoteIniValue(value: string): string {
510
+ const trimmed = value.trim();
511
+ const comment = trimmed.search(/\s+#/);
512
+ const raw = comment === -1 ? trimmed : trimmed.slice(0, comment).trim();
513
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
514
+ return raw.slice(1, -1);
515
+ }
516
+ return raw;
517
+ }
518
+
519
+ function stripTomlComment(line: string): string {
520
+ const hash = line.indexOf("#");
521
+ return hash === -1 ? line : line.slice(0, hash);
522
+ }
523
+
329
524
  function readPackageName(pkgPath: string): string | undefined {
330
525
  if (!fileStatSync(pkgPath)?.isFile()) return undefined;
331
526
  return stringUtils.trimToNull(json.parseRecord(readFileSync(pkgPath, "utf8"))?.name) ?? undefined;