package-registry-manager 0.22.3 → 0.22.5

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/python.mjs ADDED
@@ -0,0 +1,149 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { exec } from "command-stream";
4
+
5
+ // requires-python describes interpreter releases. Reject unsupported or
6
+ // prerelease syntax rather than silently choosing an incompatible executable.
7
+ function release(value) {
8
+ return /^\d+(?:\.\d+){0,2}$/.test(value)
9
+ ? value.split(".").map(Number)
10
+ : null;
11
+ }
12
+
13
+ function compare(left, right) {
14
+ for (let i = 0; i < 3; i += 1) {
15
+ const difference = (left[i] ?? 0) - (right[i] ?? 0);
16
+ if (difference) {
17
+ return Math.sign(difference);
18
+ }
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ /** Tests stable interpreter versions against requires-python specifiers. */
24
+ export function pythonMatches(version, requirement = "") {
25
+ const actual = release(version);
26
+ if (!actual) {
27
+ return false;
28
+ }
29
+ if (!requirement.trim()) {
30
+ return true;
31
+ }
32
+ return requirement.split(",").every((clause) => {
33
+ const match =
34
+ /^\s*(~=|==|!=|<=|>=|<|>)\s*(\d+(?:\.\d+){0,2})(\.\*)?\s*$/.exec(clause);
35
+ if (!match) {
36
+ return false;
37
+ }
38
+ const [, operator, text, wildcard] = match;
39
+ const expected = release(text);
40
+ if (wildcard) {
41
+ const equal = expected.every((part, i) => actual[i] === part);
42
+ return operator === "==" ? equal : operator === "!=" && !equal;
43
+ }
44
+ const order = compare(actual, expected);
45
+ switch (operator) {
46
+ case "==":
47
+ return order === 0;
48
+ case "!=":
49
+ return order !== 0;
50
+ case ">=":
51
+ return order >= 0;
52
+ case "<=":
53
+ return order <= 0;
54
+ case ">":
55
+ return order > 0;
56
+ case "<":
57
+ return order < 0;
58
+ case "~=": {
59
+ if (expected.length < 2) {
60
+ return false;
61
+ }
62
+ const upper = expected.slice(0, -1);
63
+ upper[upper.length - 1] += 1;
64
+ return order >= 0 && compare(actual, upper) < 0;
65
+ }
66
+ default:
67
+ return false;
68
+ }
69
+ });
70
+ }
71
+
72
+ /** Selects the first installed compatible interpreter, preferring PATH defaults. */
73
+ export function choosePython(tools, requirement) {
74
+ return tools.find((tool) => pythonMatches(tool.version, requirement)) ?? null;
75
+ }
76
+
77
+ async function candidatePrograms() {
78
+ const candidates = new Set(["python", "python3"]);
79
+ for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
80
+ if (!directory) {
81
+ continue;
82
+ }
83
+ const entries = await readdir(directory).catch(() => []);
84
+ for (const entry of entries.sort()) {
85
+ if (/^python3\.\d+(?:\.exe)?$/.test(entry)) {
86
+ candidates.add(entry);
87
+ }
88
+ }
89
+ }
90
+ return [...candidates].slice(0, 32);
91
+ }
92
+
93
+ /** Probes only interpreter versions, without loading manifests or package code. */
94
+ export async function probePython(options = {}) {
95
+ const candidates = options.candidates ?? (await candidatePrograms());
96
+ const probe =
97
+ options.probe ??
98
+ (async (program, args) => {
99
+ if (options.verbose) {
100
+ console.error(`+ ${program} ${args.join(" ")}`);
101
+ }
102
+ return exec(program, args, {
103
+ capture: true,
104
+ mirror: false,
105
+ stdin: "ignore",
106
+ });
107
+ });
108
+ const tools = [];
109
+ for (const program of candidates) {
110
+ try {
111
+ const result = await probe(program, ["--version"]);
112
+ const version = /^Python (\d+\.\d+\.\d+)\s*$/.exec(
113
+ String(result.stdout ?? "").trim(),
114
+ )?.[1];
115
+ if (result.code === 0 && version) {
116
+ tools.push({ program, version });
117
+ }
118
+ } catch {
119
+ // An unavailable candidate is not an installed interpreter.
120
+ }
121
+ }
122
+ return tools;
123
+ }
124
+
125
+ /** Adds a Python prerequisite and selects the executable used by the build step. */
126
+ export function applyPython(plan, tools) {
127
+ const build = plan.steps.find((step) => step.id === "build-package");
128
+ if (plan.registry !== "pypi" || !build || !tools) {
129
+ return;
130
+ }
131
+ const requirement = plan.package.requires_python ?? "";
132
+ const selected = choosePython(tools, requirement);
133
+ const item = {
134
+ id: "python",
135
+ title: "Python",
136
+ detected: selected
137
+ ? `${selected.program} (${selected.version})`
138
+ : "no compatible interpreter found",
139
+ required: `Install Python satisfying ${requirement || "the project requirements"} and its build module (python -m pip install build)`,
140
+ ok: Boolean(selected),
141
+ };
142
+ plan.prerequisites = [
143
+ ...(plan.prerequisites ?? []).filter((entry) => entry.id !== "python"),
144
+ item,
145
+ ];
146
+ if (selected) {
147
+ build.command = { program: selected.program, args: ["-m", "build"] };
148
+ }
149
+ }
@@ -0,0 +1,174 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { protectLegacyProfile } from "./profile.mjs";
3
+ import { SetupSession, prompt } from "./setup.mjs";
4
+ import { offerWorkflow } from "./workflow-proposal.mjs";
5
+ import { signInDomains } from "./sign-in-import.mjs";
6
+ import { oidcReleaseNote } from "./approvals.mjs";
7
+
8
+ /** Validates a plan before running any registry or repository mutations. */
9
+ export function validatePlan(plan, execute = true) {
10
+ if (!plan.package.publishable) {
11
+ throw new Error(
12
+ `${plan.package.name} is not publishable: ${(plan.package.problems ?? []).join("; ")}`,
13
+ );
14
+ }
15
+ if (
16
+ execute &&
17
+ plan.skipped_reason &&
18
+ !plan.steps.some((step) => step.id === "add-publishing-workflow")
19
+ ) {
20
+ throw new Error(plan.skipped_reason);
21
+ }
22
+ const python = plan.prerequisites?.find(
23
+ (item) => item.id === "python" && item.ok === false,
24
+ );
25
+ if (execute && python) {
26
+ throw new Error(`${python.detected}; ${python.required}`);
27
+ }
28
+ }
29
+
30
+ /** Runs one package, using the same executor as setup --all. */
31
+ export async function executePlan(plan, options) {
32
+ return executePlans([plan], options, false);
33
+ }
34
+
35
+ /** Runs all plans with one browser session and defers sign-out until the end. */
36
+ export async function executePlans(plans, options, summary = true) {
37
+ if (plans.length === 0) {
38
+ throw new Error("no publishable package manifests were found");
39
+ }
40
+ for (const plan of plans) {
41
+ validatePlan(plan, options.execute);
42
+ }
43
+ for (const plan of plans) {
44
+ if (plan.mode === "complete") {
45
+ console.log(
46
+ `${plan.package.name} already publishes through trusted publishing; ${plan.steps.length ? "only the repository checks remain." : "nothing to do."}`,
47
+ );
48
+ }
49
+ }
50
+ if (
51
+ plans.every((plan) => plan.mode === "complete" && plan.steps.length === 0)
52
+ ) {
53
+ const outcomes = plans.map((plan) => ({
54
+ registry: plan.registry,
55
+ package: plan.package.name,
56
+ status: "complete",
57
+ }));
58
+ printSummary(outcomes, summary);
59
+ return outcomes;
60
+ }
61
+ if (!options.execute) {
62
+ console.log(
63
+ "Dry run only. Re-run with --execute to run these steps and open the registry.",
64
+ );
65
+ return [];
66
+ }
67
+ const missing = plans.filter((plan) =>
68
+ plan.steps.some((step) => step.id === "add-publishing-workflow"),
69
+ );
70
+ if (missing.length) {
71
+ const result = await offerWorkflow(missing, {
72
+ ...options,
73
+ prompt: options.prompt ?? prompt,
74
+ });
75
+ const outcomes = plans.map((plan) => ({
76
+ registry: plan.registry,
77
+ package: plan.package.name,
78
+ status: missing.includes(plan) ? result.status : "blocked",
79
+ ...(result.url ? { url: result.url } : {}),
80
+ }));
81
+ printSummary(outcomes, summary);
82
+ return outcomes;
83
+ }
84
+ if (options.browserProfile) {
85
+ await protectLegacyProfile(
86
+ options.repository,
87
+ options.browserProfile,
88
+ options,
89
+ );
90
+ }
91
+ const domains = [
92
+ ...new Set(plans.flatMap((plan) => signInDomains(plan.registry))),
93
+ ];
94
+ let automation = options.automation ?? null;
95
+ const deferred = new Map();
96
+ const outcomes = [];
97
+ let failure;
98
+ try {
99
+ for (const plan of plans) {
100
+ const session = new SetupSession(plan, {
101
+ ...options,
102
+ automation,
103
+ domains,
104
+ });
105
+ try {
106
+ await session.run();
107
+ outcomes.push({
108
+ registry: plan.registry,
109
+ package: plan.package.name,
110
+ status: plan.steps.length === 0 ? "complete" : "configured",
111
+ });
112
+ if (plan.trusted_publisher?.workflow) {
113
+ console.log(`\n${oidcReleaseNote(plan.trusted_publisher.workflow)}`);
114
+ }
115
+ } catch (error) {
116
+ outcomes.push({
117
+ registry: plan.registry,
118
+ package: plan.package.name,
119
+ status: "failed",
120
+ });
121
+ failure = error;
122
+ } finally {
123
+ const steps = await session.cleanup({
124
+ deferAuth: plans.length > 1,
125
+ closeBrowser: false,
126
+ });
127
+ automation = session.automation;
128
+ for (const step of steps) {
129
+ deferred.set(`${plan.registry}:${step.id}`, { session, step });
130
+ }
131
+ }
132
+ if (failure) {
133
+ break;
134
+ }
135
+ }
136
+ } finally {
137
+ for (const { session, step } of deferred.values()) {
138
+ session.automation = automation;
139
+ try {
140
+ await session.runStep(step);
141
+ } catch (error) {
142
+ console.error(`warning: ${step.id} failed: ${error.message}`);
143
+ } finally {
144
+ if (session.shim?.directory) {
145
+ await rm(session.shim.directory, { recursive: true, force: true });
146
+ }
147
+ }
148
+ }
149
+ await automation?.close();
150
+ for (const plan of plans.slice(outcomes.length)) {
151
+ outcomes.push({
152
+ registry: plan.registry,
153
+ package: plan.package.name,
154
+ status: "not-run",
155
+ });
156
+ }
157
+ printSummary(outcomes, summary);
158
+ }
159
+ if (failure) {
160
+ throw failure;
161
+ }
162
+ return outcomes;
163
+ }
164
+
165
+ function printSummary(outcomes, enabled) {
166
+ if (enabled) {
167
+ console.log("\nSetup summary:");
168
+ for (const item of outcomes) {
169
+ console.log(
170
+ `- ${item.registry}: ${item.package}: ${item.status}${item.url ? ` (${item.url})` : ""}`,
171
+ );
172
+ }
173
+ }
174
+ }
package/src/setup.mjs CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  import {
16
16
  approvalDeadline,
17
17
  TWO_FACTOR_HINT,
18
- oidcReleaseNote,
19
18
  withFreshLinks,
20
19
  } from "./approvals.mjs";
21
20
  import { connectAutomation } from "./automation.mjs";
@@ -30,7 +29,6 @@ import { CLEANUP_CONDITIONS } from "./flows.mjs";
30
29
  import { pagesSettingsUrl, pagesState } from "./pages.mjs";
31
30
  import { packageDirectory } from "./plan.mjs";
32
31
  import { twoFactorMode } from "./prerequisites.mjs";
33
- import { protectLegacyProfile } from "./profile.mjs";
34
32
  import { getJson, probePackage } from "./registry-state.mjs";
35
33
  import { launchBrowser, signInDomains } from "./sign-in-import.mjs";
36
34
  import { reportTokenSecrets, verifyTokenRevoked } from "./tokens.mjs";
@@ -54,54 +52,9 @@ const PUBLISH_REJECTED = /\bE404\b|404 Not Found|invalid-publisher/i;
54
52
  /** Where `--browser` opens URLs that need no automation. */
55
53
  export const BROWSER_MODES = ["default", "automated"];
56
54
 
57
- /**
58
- * Runs a setup plan. Without `options.execute` it only reports a dry run.
59
- * Steps whose `when` condition does not hold are skipped, so a re-run after a
60
- * partial success resumes where the previous run stopped.
61
- */
62
- export async function executePlan(plan, options) {
63
- if (!plan.package.publishable) {
64
- throw new Error(
65
- `${plan.package.name} is not publishable: ${(plan.package.problems ?? []).join("; ")}`,
66
- );
67
- }
68
- if (plan.mode === "complete") {
69
- const remaining =
70
- plan.steps.length === 0
71
- ? "nothing to do."
72
- : "only the repository checks remain.";
73
- console.log(
74
- `${plan.package.name} already publishes through trusted publishing; ${remaining}`,
75
- );
76
- if (plan.steps.length === 0) {
77
- return;
78
- }
79
- }
80
- if (!options.execute) {
81
- console.log(
82
- "Dry run only. Re-run with --execute to run these steps and open the registry.",
83
- );
84
- return;
85
- }
86
- if (options.browserProfile) {
87
- await protectLegacyProfile(
88
- options.repository,
89
- options.browserProfile,
90
- options,
91
- );
92
- }
93
- const session = new SetupSession(plan, options);
94
- try {
95
- await session.run();
96
- } finally {
97
- await session.cleanup();
98
- }
99
- if (plan.trusted_publisher?.workflow) {
100
- console.log(`\n${oidcReleaseNote(plan.trusted_publisher.workflow)}`);
101
- }
102
- }
55
+ export { executePlan, executePlans } from "./setup-batch.mjs";
103
56
 
104
- class SetupSession {
57
+ export class SetupSession {
105
58
  constructor(plan, options) {
106
59
  this.plan = plan;
107
60
  this.options = options;
@@ -135,9 +88,22 @@ class SetupSession {
135
88
  * Runs every cleanup step of the plan whose condition holds, including
136
89
  * those after a step that failed (#24).
137
90
  */
138
- async cleanup() {
91
+ async cleanup({ deferAuth = false, closeBrowser = true } = {}) {
92
+ const deferred = [];
139
93
  for (const step of cleanupSteps(this.plan)) {
140
- if (step.id === "sign-out" && this.keepsSession(step)) {
94
+ if (
95
+ deferAuth &&
96
+ ["sign-out", "crates-sign-out"].includes(step.id) &&
97
+ this.conditions.has(step.when)
98
+ ) {
99
+ if (step.id === "sign-out" && this.options.keepSession) {
100
+ console.log(
101
+ "Keeping the npm session (--keep-session); run npm logout to revoke it.",
102
+ );
103
+ } else {
104
+ deferred.push(step);
105
+ }
106
+ } else if (step.id === "sign-out" && this.keepsSession(step)) {
141
107
  console.log(
142
108
  "Keeping the npm session (--keep-session): its token stays in npm's user configuration until you run npm logout, and the next run reuses it while npm whoami succeeds.",
143
109
  );
@@ -148,13 +114,17 @@ class SetupSession {
148
114
  );
149
115
  } else if (this.conditions.has(step.when)) {
150
116
  this.log(`==> ${step.title}`);
151
- const result = await this.runProcess(step, true);
152
- if (result.code !== 0) {
153
- console.error(`warning: ${step.id} exited with ${result.code}`);
117
+ try {
118
+ const result = await this.runProcess(step, true);
119
+ if (result.code !== 0) {
120
+ console.error(`warning: ${step.id} exited with ${result.code}`);
121
+ }
122
+ } catch (error) {
123
+ console.error(`warning: ${step.id} failed: ${error.message}`);
154
124
  }
155
125
  }
156
126
  }
157
- if (this.automation) {
127
+ if (this.automation && closeBrowser) {
158
128
  await this.automation.close();
159
129
  }
160
130
  for (const directory of [this.temporary, this.shim?.directory]) {
@@ -162,6 +132,9 @@ class SetupSession {
162
132
  await rm(directory, { recursive: true, force: true });
163
133
  }
164
134
  }
135
+ this.shim = null;
136
+ this.temporary = null;
137
+ return deferred;
165
138
  }
166
139
 
167
140
  keepsSession(step) {
@@ -643,7 +616,7 @@ class SetupSession {
643
616
  */
644
617
  async automatedPage(browser = this.options.browserOptions) {
645
618
  if (!this.automation) {
646
- const domains = signInDomains(this.plan.registry);
619
+ const domains = this.options.domains ?? signInDomains(this.plan.registry);
647
620
  this.automation = await connectAutomation({
648
621
  browser: await launchBrowser(browser, domains, {
649
622
  deferred: this.plan.registry === "crates-io",
@@ -872,7 +845,7 @@ function render(command) {
872
845
  }
873
846
 
874
847
  /** Asks a question on the terminal; a closed stdin answers with "". */
875
- async function prompt(message) {
848
+ export async function prompt(message) {
876
849
  const terminal = createInterface({ input: stdin, output: stdout });
877
850
  try {
878
851
  return await new Promise((resolve) => {
@@ -0,0 +1,125 @@
1
+ // Firefox-family browsers use Browser Commander's native WebDriver/BiDi API.
2
+ // Its real/CDP launcher and migration target writers do not support them yet.
3
+ import { launchWebDriver, readBrowserCookies } from "browser-commander";
4
+
5
+ import {
6
+ assertDedicatedProfile,
7
+ resolveExecutable,
8
+ } from "./browser-catalogue.mjs";
9
+ import { ensureProfileIgnored } from "./profile.mjs";
10
+ import { IMPORT_CHOICES } from "./sign-in-import.mjs";
11
+
12
+ /** True only for a requested host or its subdomains, never a substring. */
13
+ export function matchesDomains(host, domains) {
14
+ const normalized = host.replace(/^\./, "").toLowerCase();
15
+ return domains.some(
16
+ (domain) => normalized === domain || normalized.endsWith(`.${domain}`),
17
+ );
18
+ }
19
+
20
+ /** The BiDi cookie shape; session cookies omit expiry. */
21
+ export function bidiCookie(cookie) {
22
+ const result = {
23
+ name: cookie.name,
24
+ value: { type: "string", value: cookie.value },
25
+ domain: cookie.domain,
26
+ path: cookie.path ?? "/",
27
+ httpOnly: cookie.httpOnly ?? false,
28
+ secure: cookie.secure ?? false,
29
+ sameSite: (cookie.sameSite ?? "Lax").toLowerCase(),
30
+ };
31
+ if (cookie.expires > 0) {
32
+ result.expiry = cookie.expires;
33
+ }
34
+ return result;
35
+ }
36
+
37
+ /** Launches the selected installed Firefox variant and exposes the common page API. */
38
+ export async function connectWebDriverAutomation(
39
+ { browser, profile, verbose, domains },
40
+ dependencies = {},
41
+ ) {
42
+ // Validation happens before profile creation, including programmatic callers.
43
+ if (
44
+ browser.attach ||
45
+ browser.restrictions.length ||
46
+ Object.keys(browser.preferences).length ||
47
+ (browser.import && browser.importScope === "full")
48
+ ) {
49
+ throw new Error(
50
+ `unsupported Firefox launch options for ${browser.channel}; use --browser-import-scope domains without snapshot, preferences or restrictions`,
51
+ );
52
+ }
53
+ if (browser.import && domains.length === 0) {
54
+ throw new Error("a domains import needs the registry's sign-in domains");
55
+ }
56
+ const executablePath = await (
57
+ dependencies.resolveExecutable ?? resolveExecutable
58
+ )({
59
+ channel: browser.channel.toLowerCase(),
60
+ executablePath: browser.executable,
61
+ });
62
+ assertDedicatedProfile(profile);
63
+ await ensureProfileIgnored(profile, { verbose });
64
+ const connection = await (dependencies.launchWebDriver ?? launchWebDriver)({
65
+ browser: "firefox",
66
+ executablePath,
67
+ userDataDir: profile,
68
+ headless: false,
69
+ bidi: true,
70
+ verbose,
71
+ });
72
+ try {
73
+ await connection.page.bidiCommand("storage.getCookies", {});
74
+ if (browser.import && !IMPORT_CHOICES.includes(browser.import.browser)) {
75
+ const read = dependencies.readCookies ?? readBrowserCookies;
76
+ const seen = new Set();
77
+ for (const domain of domains) {
78
+ const cookies = await read({
79
+ browser: browser.import.browser,
80
+ profile: browser.import.profile ?? undefined,
81
+ domainFilter: domain,
82
+ cache: false,
83
+ });
84
+ for (const cookie of cookies) {
85
+ const key = JSON.stringify([cookie.domain, cookie.path, cookie.name]);
86
+ if (!matchesDomains(cookie.domain, domains) || seen.has(key)) {
87
+ continue;
88
+ }
89
+ seen.add(key);
90
+ await connection.page.bidiCommand("storage.setCookie", {
91
+ cookie: bidiCookie(cookie),
92
+ });
93
+ }
94
+ }
95
+ }
96
+ } catch (error) {
97
+ await connection.close().catch(() => {});
98
+ throw new Error(
99
+ `could not prepare ${browser.channel} sign-in cookies (WebDriver BiDi storage is required)`,
100
+ { cause: error },
101
+ );
102
+ }
103
+ return {
104
+ goto: (url) => connection.page.goto(url),
105
+ evaluate: (script) => connection.page.evaluate(script),
106
+ close: () => connection.close(),
107
+ async clearCookies(names) {
108
+ const { cookies } = await connection.page.bidiCommand(
109
+ "storage.getCookies",
110
+ {},
111
+ );
112
+ for (const cookie of cookies) {
113
+ if (matchesDomains(cookie.domain, names)) {
114
+ await connection.page.bidiCommand("storage.deleteCookies", {
115
+ filter: {
116
+ name: cookie.name,
117
+ domain: cookie.domain,
118
+ path: cookie.path,
119
+ },
120
+ });
121
+ }
122
+ }
123
+ },
124
+ };
125
+ }