package-registry-manager 0.22.5 → 0.22.7

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/src/discovery.mjs CHANGED
@@ -2,7 +2,9 @@ import { readFile, readdir, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { CONTAINER_FILES, containerPackages } from "./containers.mjs";
5
+ import { packageWorkflows, auditWrappers } from "./package-coverage.mjs";
5
6
  import { REGISTRIES } from "./model.mjs";
7
+ import { inspectManifestRepositories } from "./repository-identity.mjs";
6
8
  import {
7
9
  TRUSTED_REGISTRIES,
8
10
  detectPublisher,
@@ -39,6 +41,8 @@ const MANIFEST_NAMES = new Set([
39
41
  "build.gradle",
40
42
  "build.gradle.kts",
41
43
  "composer.json",
44
+ "jsr.json",
45
+ "deno.json",
42
46
  ]);
43
47
 
44
48
  /**
@@ -73,7 +77,9 @@ export async function inspectRepository(repository, options = {}) {
73
77
  const directory = testDirectory(manifest);
74
78
  return (
75
79
  directory === null ||
76
- (publishable && referencedByWorkflow(workflows, manifest)) ||
80
+ (!["docs", "experiments", "case-studies"].includes(directory) &&
81
+ publishable &&
82
+ referencedByWorkflow(workflows, manifest)) ||
77
83
  skip(manifest, testDirectoryReason(directory))
78
84
  );
79
85
  };
@@ -103,14 +109,82 @@ export async function inspectRepository(repository, options = {}) {
103
109
  for (const registry of TRUSTED_REGISTRIES) {
104
110
  publishers.set(registry, await detectPublisher(root, workflows, registry));
105
111
  }
112
+ // Extension packages can publish to both marketplaces independently.
113
+ for (const item of [...parsed].filter(Boolean)) {
114
+ if (item.registry !== "npm") {
115
+ continue;
116
+ }
117
+ const data = JSON.parse(await readManifest(path.join(root, item.manifest)));
118
+ if (data.publisher && data.engines?.vscode) {
119
+ for (const registry of ["vscode-marketplace", "open-vsx"]) {
120
+ const publisher = await detectPublisher(root, workflows, registry);
121
+ if (publisher.workflow) {
122
+ publishers.set(registry, publisher);
123
+ parsed.push({
124
+ ...item,
125
+ registry,
126
+ name: `${data.publisher}.${item.name}`,
127
+ });
128
+ }
129
+ }
130
+ }
131
+ }
132
+ publishers.set(
133
+ "maven-central",
134
+ await detectPublisher(root, workflows, "maven-central"),
135
+ );
106
136
  const containerFiles = dockerfiles
107
137
  .map((item) => relativePath(root, item))
108
138
  .filter((item) => !ignored(item) && kept(item, true));
109
- const packages = parsed
110
- .filter(Boolean)
111
- .filter((item) => kept(item.manifest, item.publishable))
112
- .map((item) => withPublisher(item, workflows, publishers))
113
- .concat(containerPackages(containerFiles, workflows, coordinates))
139
+ const counts = new Map();
140
+ for (const item of parsed.filter(Boolean)) {
141
+ if (!testDirectory(item.manifest)) {
142
+ counts.set(item.registry, (counts.get(item.registry) ?? 0) + 1);
143
+ }
144
+ }
145
+ const chrome = await detectPublisher(root, workflows, "chrome-web-store");
146
+ const chromePackages = chrome.workflow
147
+ ? [
148
+ withPublisher(
149
+ packageInfo(
150
+ "chrome-web-store",
151
+ coordinates.github_repository ?? "chrome-extension",
152
+ null,
153
+ `.github/workflows/${chrome.workflow}`,
154
+ ),
155
+ workflows,
156
+ new Map([["chrome-web-store", chrome]]),
157
+ ),
158
+ ]
159
+ : [];
160
+ const packages = (
161
+ await Promise.all(
162
+ parsed
163
+ .filter(Boolean)
164
+ .filter((item) => kept(item.manifest, item.publishable))
165
+ .map(async (item) => {
166
+ const scoped =
167
+ counts.get(item.registry) > 1 &&
168
+ TRUSTED_REGISTRIES.includes(item.registry)
169
+ ? new Map([
170
+ [
171
+ item.registry,
172
+ await detectPublisher(
173
+ root,
174
+ packageWorkflows(workflows, item),
175
+ item.registry,
176
+ ),
177
+ ],
178
+ ])
179
+ : publishers;
180
+ return withPublisher(item, workflows, scoped);
181
+ }),
182
+ )
183
+ )
184
+ .concat(
185
+ containerPackages(containerFiles, workflows, coordinates),
186
+ chromePackages,
187
+ )
114
188
  .sort((left, right) => {
115
189
  const registryOrder =
116
190
  REGISTRIES.indexOf(left.registry) - REGISTRIES.indexOf(right.registry);
@@ -122,6 +196,7 @@ export async function inspectRepository(repository, options = {}) {
122
196
  );
123
197
  });
124
198
 
199
+ await auditWrappers(root, packages);
125
200
  const inspection = {
126
201
  schema_version: 1,
127
202
  repository: {
@@ -140,7 +215,7 @@ export async function inspectRepository(repository, options = {}) {
140
215
  left.manifest.localeCompare(right.manifest),
141
216
  );
142
217
  }
143
- return inspection;
218
+ return inspectManifestRepositories(inspection);
144
219
  }
145
220
 
146
221
  /**
@@ -206,7 +281,7 @@ async function collectManifests(directory, manifests, dockerfiles) {
206
281
  } else if (
207
282
  entry.isFile() &&
208
283
  (MANIFEST_NAMES.has(entry.name) ||
209
- /\.(?:csproj|fsproj|vbproj)$/.test(entry.name))
284
+ /\.(?:csproj|fsproj|vbproj|gemspec)$/.test(entry.name))
210
285
  ) {
211
286
  manifests.push(item);
212
287
  }
@@ -217,6 +292,13 @@ async function parseManifest(manifestPath, manifest) {
217
292
  const contents = await readManifest(manifestPath);
218
293
  const filename = path.basename(manifestPath);
219
294
  switch (filename) {
295
+ case "jsr.json":
296
+ case "deno.json": {
297
+ const value = JSON.parse(contents);
298
+ return value.name && value.version
299
+ ? packageInfo("jsr", value.name, value.version, manifest)
300
+ : null;
301
+ }
220
302
  case "package.json":
221
303
  return parseNpm(contents, manifest);
222
304
  case "Cargo.toml":
@@ -246,6 +328,15 @@ async function parseManifest(manifestPath, manifest) {
246
328
  case "composer.json":
247
329
  return parseComposer(contents, manifest);
248
330
  default:
331
+ if (filename.endsWith(".gemspec")) {
332
+ return packageInfo(
333
+ "rubygems",
334
+ capture(contents, /\.name\s*=\s*['"]([^'"]+)/) ??
335
+ "unknown-ruby-package",
336
+ capture(contents, /\.version\s*=\s*['"]([^'"]+)/),
337
+ manifest,
338
+ );
339
+ }
249
340
  if (/\.(?:csproj|fsproj|vbproj)$/.test(filename)) {
250
341
  return parseDotnet(contents, manifestPath, manifest);
251
342
  }
@@ -0,0 +1,88 @@
1
+ import { credentialSteps } from "./credential-cycle.mjs";
2
+ import { tokenSecretSteps } from "./tokens.mjs";
3
+
4
+ /** Additional OIDC registries never fall back to a stored publishing token. */
5
+ export function trustedFlow(item, context) {
6
+ const specs = {
7
+ rubygems: [
8
+ "gem",
9
+ ["build", item.manifest.split("/").at(-1)],
10
+ "https://rubygems.org/profile/oidc/pending_trusted_publishers",
11
+ "Register the GitHub Actions identity as a pending publisher, or add it in the existing gem settings.",
12
+ ],
13
+ nuget: [
14
+ "dotnet",
15
+ ["pack", "--configuration", "Release"],
16
+ "https://www.nuget.org/account/TrustedPublishing",
17
+ "Register a trusted publishing policy for this GitHub Actions identity. Use NuGet/login to exchange OIDC for a short-lived API key; never store that key.",
18
+ ],
19
+ jsr: [
20
+ "deno",
21
+ ["publish", "--dry-run"],
22
+ `https://jsr.io/${item.name}/settings`,
23
+ "Link this package to the GitHub repository; publish from the configured workflow through OIDC.",
24
+ ],
25
+ };
26
+ const [program, args, url, description] = specs[item.registry];
27
+ const steps = [
28
+ {
29
+ id: "validate-package",
30
+ title: `Validate ${item.registry} package`,
31
+ kind: "check",
32
+ description: "Build or validate locally without uploading.",
33
+ command: { program, args },
34
+ },
35
+ {
36
+ id: "configure-trusted-publisher",
37
+ title: `Configure ${item.registry} trusted publishing`,
38
+ kind: "browser",
39
+ description,
40
+ url,
41
+ },
42
+ {
43
+ id: "verify-oidc-release",
44
+ title: "Verify an OIDC release before removing tokens",
45
+ kind: "manual",
46
+ description: `Run ${context.workflow} and verify registry acceptance through OIDC; remove secret references from every workflow before deleting unused credentials.`,
47
+ },
48
+ ];
49
+ if (context.slug) {
50
+ steps.push(...tokenSecretSteps(item, context.slug));
51
+ }
52
+ return steps;
53
+ }
54
+
55
+ export function tokenFlow(item) {
56
+ const checks = {
57
+ "maven-central": { program: "mvn", args: ["--batch-mode", "verify"] },
58
+ "vscode-marketplace": {
59
+ program: "npx",
60
+ args: ["--no-install", "vsce", "package"],
61
+ },
62
+ "open-vsx": { program: "npx", args: ["--no-install", "vsce", "package"] },
63
+ };
64
+ const steps = checks[item.registry]
65
+ ? [
66
+ {
67
+ id: "validate-package",
68
+ title: "Validate package before credential changes",
69
+ kind: "check",
70
+ description: "Build without publishing.",
71
+ command: checks[item.registry],
72
+ },
73
+ ]
74
+ : [];
75
+ if (item.registry === "maven-central") {
76
+ steps.push({
77
+ id: "verify-namespace",
78
+ title: "Verify a Central namespace",
79
+ kind: "browser",
80
+ description:
81
+ "Sign in to the Central Portal and verify the namespace used by the package coordinates.",
82
+ url: "https://central.sonatype.com/publishing/namespaces",
83
+ });
84
+ }
85
+ return steps.concat(
86
+ credentialSteps(item.registry, item.token_secrets?.[0] ?? item.registry),
87
+ );
88
+ }
package/src/flows.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { credentialSteps } from "./credential-cycle.mjs";
1
2
  import { DEFAULT_TRUST_NPM, NPM_TFA_URL } from "./prerequisites.mjs";
2
3
  import {
3
4
  npmName,
@@ -255,6 +256,13 @@ export function npmFlow(packageInfo, context) {
255
256
  },
256
257
  ),
257
258
  checkRegistryStep("npm", registryStateUrl(packageInfo)),
259
+ step(
260
+ "check-name-policy",
261
+ "Check npm name availability and policy",
262
+ "check",
263
+ "Check exact and punctuation variants before approval. Server-side blocks can still refuse publication.",
264
+ { when: "package-missing" },
265
+ ),
258
266
  step(
259
267
  "check-sign-in",
260
268
  "Check the npm session",
@@ -308,6 +316,25 @@ export function npmFlow(packageInfo, context) {
308
316
  "Compare the bin entries of the packed package.json with package.json and check that each installed bin prints its version for --version.",
309
317
  { when: "package-missing" },
310
318
  ),
319
+ step(
320
+ "publish-dry-run",
321
+ "Validate first publication without upload",
322
+ "check",
323
+ "Validate the reviewed tarball before asking for publication approval.",
324
+ {
325
+ command: command("npm", [
326
+ "publish",
327
+ "{tarball}",
328
+ "--dry-run",
329
+ "--ignore-scripts",
330
+ "--access",
331
+ "public",
332
+ "--provenance=false",
333
+ ]),
334
+ when: "package-missing",
335
+ cwd: packageCwd(context.directory),
336
+ },
337
+ ),
311
338
  // Sign in right before publishing, so the sign-in, publish, and trust
312
339
  // approvals happen together and npm can skip repeated 2FA prompts.
313
340
  step(
@@ -416,7 +443,6 @@ export function npmFlow(packageInfo, context) {
416
443
  "Confirm that npm lists the GitHub Actions trusted publisher.",
417
444
  { command: trustList, when: "trust-missing" },
418
445
  ),
419
- ...tokenSecretSteps(packageInfo, context.slug),
420
446
  step(
421
447
  "rerun-release",
422
448
  "Re-run the failed release jobs",
@@ -439,6 +465,7 @@ export function npmFlow(packageInfo, context) {
439
465
  if (context.verifyRelease) {
440
466
  steps.push(...verifyReleaseSteps(context, registryStateUrl(packageInfo)));
441
467
  }
468
+ steps.push(...tokenSecretSteps(packageInfo, context.slug));
442
469
  }
443
470
  steps.push(
444
471
  step(
@@ -707,13 +734,6 @@ export function dockerHubFlow(packageInfo, context) {
707
734
  when: "package-missing",
708
735
  },
709
736
  ),
710
- step(
711
- "create-access-token",
712
- "Create a Read & Write access token",
713
- "browser",
714
- `Create a token with Read & Write access and a short expiry. Personal access tokens cannot be limited to one repository; an organization access token can be restricted to ${packageInfo.name}. Keep the token only for the next step.`,
715
- { url: "https://app.docker.com/settings/personal-access-tokens/create" },
716
- ),
717
737
  step(
718
738
  "check-github-cli",
719
739
  "Check the GitHub CLI session",
@@ -755,17 +775,7 @@ export function dockerHubFlow(packageInfo, context) {
755
775
  cwd: ".",
756
776
  },
757
777
  ),
758
- step(
759
- "set-token-secret",
760
- "Set DOCKERHUB_TOKEN",
761
- "command",
762
- "gh reads the token from the terminal without echoing it; the tool never sees it.",
763
- {
764
- command: command("gh", ["secret", "set", "DOCKERHUB_TOKEN", ...repo]),
765
- cwd: ".",
766
- confirm: true,
767
- },
768
- ),
778
+ ...credentialSteps("docker-hub", "DOCKERHUB_TOKEN"),
769
779
  ];
770
780
  }
771
781
 
@@ -0,0 +1,201 @@
1
+ import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { exec } from "command-stream";
5
+ import {
6
+ githubSlug,
7
+ manifestUrls,
8
+ repositorySlug,
9
+ } from "./repository-identity.mjs";
10
+
11
+ function runner(options) {
12
+ return (
13
+ options.run ??
14
+ (async (program, args, cwd = options.repository) => {
15
+ const result = await exec(program, args, {
16
+ cwd,
17
+ env: process.env,
18
+ capture: true,
19
+ mirror: false,
20
+ stdin: "ignore",
21
+ });
22
+ if (result.code !== 0) {
23
+ throw new Error(
24
+ `${program} failed while preparing the manifest repository proposal`,
25
+ );
26
+ }
27
+ return String(result.stdout ?? "").trim();
28
+ })
29
+ );
30
+ }
31
+
32
+ function safeManifest(manifest) {
33
+ if (
34
+ !manifest ||
35
+ manifest
36
+ .split("/")
37
+ .some((part) => !part || part === "." || part === "..") ||
38
+ path.isAbsolute(manifest) ||
39
+ manifest.includes("\\")
40
+ ) {
41
+ throw new Error("unsafe manifest path for repository repair");
42
+ }
43
+ }
44
+
45
+ /** Change only stale repository values; preserve npm's repository.directory. */
46
+ export function manifestRepositoryProposal(contents, manifest, slug) {
47
+ if (!githubSlug(slug)) {
48
+ throw new Error("invalid canonical repository");
49
+ }
50
+ const urls = manifestUrls(contents, manifest);
51
+ const stale = urls.filter(
52
+ (url) =>
53
+ githubSlug(url) && githubSlug(url).toLowerCase() !== slug.toLowerCase(),
54
+ );
55
+ if (!stale.length) {
56
+ return contents;
57
+ }
58
+ const url = `https://github.com/${slug}`;
59
+ if (manifest.endsWith("package.json")) {
60
+ const document = JSON.parse(contents.replace(/^\uFEFF/, ""));
61
+ if (typeof document.repository === "string") {
62
+ document.repository = url;
63
+ } else {
64
+ document.repository.url = `git+${url}.git`;
65
+ }
66
+ return `${JSON.stringify(document, null, 2)}\n`;
67
+ }
68
+ let proposal = contents;
69
+ for (const old of stale) {
70
+ proposal = proposal.replaceAll(old, old.replace(githubSlug(old), slug));
71
+ }
72
+ return proposal;
73
+ }
74
+
75
+ /** Check remote metadata before retrying; the original run may use an old SHA. */
76
+ export async function verifyRemoteManifest(plan, options, reference) {
77
+ safeManifest(plan.package.manifest);
78
+ const run = runner(options);
79
+ const slug = repositorySlug(plan.repository);
80
+ const ref =
81
+ reference ??
82
+ (await run("gh", ["api", `repos/${slug}`, "--jq", ".default_branch"]));
83
+ if (!ref || ref.startsWith("-") || /[\s:]/.test(ref)) {
84
+ throw new Error("invalid remote manifest reference");
85
+ }
86
+ await run("git", ["fetch", "origin", ref]);
87
+ const contents = await run("git", [
88
+ "show",
89
+ `FETCH_HEAD:${plan.package.manifest}`,
90
+ ]);
91
+ return (
92
+ manifestRepositoryProposal(contents, plan.package.manifest, slug) ===
93
+ contents
94
+ );
95
+ }
96
+
97
+ /** Offer a reviewed branch and draft PR from the current remote default branch. */
98
+ export async function offerManifestRepository(plan, options) {
99
+ const run = runner(options);
100
+ const slug = repositorySlug(plan.repository);
101
+ const manifest = plan.package.manifest;
102
+ safeManifest(manifest);
103
+ const remote = githubSlug(await run("git", ["remote", "get-url", "origin"]));
104
+ if (!remote) {
105
+ throw new Error(
106
+ "origin must be a GitHub repository for the manifest proposal",
107
+ );
108
+ }
109
+ const canonical = await run("gh", [
110
+ "api",
111
+ `repos/${remote}`,
112
+ "--jq",
113
+ ".full_name",
114
+ ]);
115
+ if (canonical.toLowerCase() !== slug.toLowerCase()) {
116
+ throw new Error("origin must resolve to the repository being repaired");
117
+ }
118
+ const base = await run("gh", [
119
+ "api",
120
+ `repos/${slug}`,
121
+ "--jq",
122
+ ".default_branch",
123
+ ]);
124
+ if (!base || base.startsWith("-") || /[\s:]/.test(base)) {
125
+ throw new Error("invalid default branch");
126
+ }
127
+ await run("git", ["fetch", "origin", base]);
128
+ const contents = await run("git", ["show", `FETCH_HEAD:${manifest}`]);
129
+ const proposal = manifestRepositoryProposal(contents, manifest, slug);
130
+ if (proposal === contents) {
131
+ return undefined;
132
+ }
133
+ console.log(`Proposed ${manifest}:\n${proposal}`);
134
+ if (
135
+ !options.yes &&
136
+ !/^y(?:es)?$/i.test(
137
+ await options.prompt(
138
+ "Create a branch and draft pull request with this repository URL fix? [y/N] ",
139
+ ),
140
+ )
141
+ ) {
142
+ return { status: "blocked" };
143
+ }
144
+ const branch = `prm/repository-url-${Date.now()}`;
145
+ const temporary = await mkdtemp(path.join(os.tmpdir(), "prm-manifest-"));
146
+ const checkout = path.join(temporary, "checkout");
147
+ let created = false;
148
+ try {
149
+ await run("git", ["worktree", "add", "-b", branch, checkout, "FETCH_HEAD"]);
150
+ created = true;
151
+ const target = path.join(checkout, manifest);
152
+ const root = await realpath(checkout);
153
+ const physical = await realpath(target);
154
+ if (
155
+ !physical.startsWith(`${root}${path.sep}`) ||
156
+ physical !== path.join(root, manifest)
157
+ ) {
158
+ throw new Error(
159
+ "manifest must stay inside the proposal worktree without symlinks",
160
+ );
161
+ }
162
+ const current = await readFile(target, "utf8");
163
+ const updated = manifestRepositoryProposal(current, manifest, slug);
164
+ await writeFile(target, updated);
165
+ await run("git", ["add", "--", manifest], checkout);
166
+ await run(
167
+ "git",
168
+ ["commit", "-m", "Fix manifest repository URL after repository transfer"],
169
+ checkout,
170
+ );
171
+ await run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], checkout);
172
+ const url = await run(
173
+ "gh",
174
+ [
175
+ "pr",
176
+ "create",
177
+ "--repo",
178
+ slug,
179
+ "--base",
180
+ base,
181
+ "--head",
182
+ branch,
183
+ "--draft",
184
+ "--title",
185
+ "Fix manifest repository URL after repository transfer",
186
+ "--body",
187
+ `Update ${manifest} to name ${slug}. Review and merge this change, then re-run package-registry-manager setup to verify the repository metadata and retry the release.`,
188
+ ],
189
+ checkout,
190
+ );
191
+ console.log(
192
+ `Review and merge ${url}, then re-run setup. Release retries are paused.`,
193
+ );
194
+ return { status: "manifest-pr", url };
195
+ } finally {
196
+ if (created) {
197
+ await run("git", ["worktree", "remove", "--force", checkout]);
198
+ }
199
+ await rm(temporary, { recursive: true, force: true });
200
+ }
201
+ }
package/src/model.mjs CHANGED
@@ -8,9 +8,24 @@ export const REGISTRIES = Object.freeze([
8
8
  "packagist",
9
9
  "docker-hub",
10
10
  "ghcr",
11
+ "rubygems",
12
+ "jsr",
13
+ "vscode-marketplace",
14
+ "open-vsx",
15
+ "chrome-web-store",
11
16
  ]);
12
17
 
13
18
  const REGISTRY_ALIASES = new Map([
19
+ ["ruby", "rubygems"],
20
+ ["rubygems", "rubygems"],
21
+ ["gem", "rubygems"],
22
+ ["jsr", "jsr"],
23
+ ["vscode", "vscode-marketplace"],
24
+ ["vsce", "vscode-marketplace"],
25
+ ["vscode-marketplace", "vscode-marketplace"],
26
+ ["ovsx", "open-vsx"],
27
+ ["open-vsx", "open-vsx"],
28
+ ["chrome-web-store", "chrome-web-store"],
14
29
  ["npm", "npm"],
15
30
  ["cargo", "crates-io"],
16
31
  ["crate", "crates-io"],
@@ -0,0 +1,69 @@
1
+ import { npmName, registryEndpoint, USER_AGENT } from "./registry-state.mjs";
2
+
3
+ /** Bounded punctuation variants, including npm's punctuation-free moniker. */
4
+ export function nameVariants(name) {
5
+ const slash = name.lastIndexOf("/");
6
+ const scope = slash < 0 ? "" : name.slice(0, slash + 1);
7
+ const leaf = name.slice(slash + 1);
8
+ const pieces = leaf.split(/[._-]+/).filter(Boolean);
9
+ let variants = [pieces[0]];
10
+ for (const piece of pieces.slice(1)) {
11
+ variants = variants
12
+ .flatMap((prefix) =>
13
+ ["", "-", ".", "_"].map((separator) => prefix + separator + piece),
14
+ )
15
+ .slice(0, 64);
16
+ }
17
+ return [...new Set(variants.map((variant) => scope + variant))].filter(
18
+ (variant) => variant !== name,
19
+ );
20
+ }
21
+
22
+ /** npm dry-run cannot validate unpublished server-side block/similarity rules. */
23
+ export async function checkNamePolicy(name, options = {}) {
24
+ if (
25
+ name.length > 214 ||
26
+ !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name) ||
27
+ ["node_modules", "favicon.ico"].includes(name)
28
+ ) {
29
+ throw new Error(
30
+ `npm name '${name}' is invalid; choose a lowercase descriptive name or @owner/name`,
31
+ );
32
+ }
33
+ const base = registryEndpoint("npm", options.env ?? process.env);
34
+ for (const variant of [name, ...nameVariants(name)]) {
35
+ const response = await (options.fetch ?? globalThis.fetch)(
36
+ `${base}/${npmName(variant)}?_=${Date.now()}`,
37
+ {
38
+ headers: {
39
+ accept: "application/json",
40
+ "cache-control": "no-cache, no-store",
41
+ "user-agent": USER_AGENT,
42
+ },
43
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
44
+ },
45
+ );
46
+ if (response.status === 404) {
47
+ continue;
48
+ }
49
+ if (!response.ok) {
50
+ throw new Error(
51
+ `npm name policy lookup failed (${response.status}); no approval requested`,
52
+ );
53
+ }
54
+ if (variant !== name) {
55
+ throw new Error(
56
+ `npm name '${name}' is similar to existing '${variant}'; use @owner/${name} or a longer descriptive name`,
57
+ );
58
+ }
59
+ return;
60
+ }
61
+ }
62
+
63
+ /** Report npm policy refusal once, without entering authentication retry logic. */
64
+ export function policyRefusal(output) {
65
+ return (
66
+ /\bE403\b|403 Forbidden/i.test(output) &&
67
+ /too similar|forbidden|blocked|not allowed/i.test(output)
68
+ );
69
+ }