beez-rp 0.2.0 → 0.3.0

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,43 +1,135 @@
1
1
  /**
2
- * npm adapter of `beez-rp create-version`: lists the published versions of a
3
- * package and publishes the working tree with the token referenced by `.npmrc`.
2
+ * npm adapter of `beez-rp create-version`: resolves the registry a package is
3
+ * published to (`publishConfig`, else npm's own config), lists the versions
4
+ * published there (authenticated with `NPM_TOKEN` when available), reads the integrity npm
5
+ * would pack from the release checkout, and publishes the working tree or a
6
+ * prepared archive with `NPM_TOKEN` bound to the publish registry.
4
7
  *
5
8
  * @module create-version/npm
6
9
  */
7
10
 
8
- import { existsSync } from "node:fs";
11
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
9
13
  import path from "node:path";
10
14
 
11
15
  import {
16
+ DEFAULT_NPM_REGISTRY_URL,
12
17
  LOCAL_ENVIRONMENT_FILE,
18
+ LOCAL_PATH_PREFIX,
19
+ NPM_AUTH_DIRECTORY_PREFIX,
20
+ NPM_AUTH_TOKEN_REFERENCE,
21
+ NPM_CONFIG_GET_ARGUMENTS,
13
22
  NPM_DIST_TAG,
23
+ NPM_INTEGRITY_PATTERN,
14
24
  NPM_LOOKUP_STATUS,
15
25
  NPM_NOT_FOUND_CODE,
26
+ NPM_PACK_DRY_RUN_ARGUMENTS,
16
27
  NPM_PACKAGE_NAME_PATTERN,
28
+ NPM_REGISTRY_OPTION,
29
+ NPM_REGISTRY_PROTOCOLS,
17
30
  NPM_TOKEN_VARIABLE,
31
+ NPM_UNSET_CONFIG_VALUE,
32
+ NPM_USER_CONFIG_OPTION,
33
+ NPMJS_PACKAGE_PAGE_URL,
34
+ PACKAGE_SCOPE_PATTERN,
35
+ PUBLISH_CONFIG_FIELD,
36
+ PUBLISH_CONFIG_REGISTRY_KEY,
37
+ SCOPED_REGISTRY_KEY_SUFFIX,
38
+ SHELL_SAFE_REGISTRY_URL_PATTERN,
39
+ UNSAFE_QUOTED_PATH_PATTERN,
18
40
  } from "../constants/create-version.js";
41
+ import { PACKAGE_MANAGER_USER_AGENT_VARIABLE } from "../constants/guard-publish.js";
19
42
  import { USES_SHELL_FOR_PACKAGE_MANAGERS, runCaptured, runInherited } from "./process.js";
20
43
 
21
44
  /**
22
45
  * @typedef {{ status: string, publishedVersions: string[], reason: string | null }} NpmLookup
46
+ * @typedef {{ name: unknown, version: unknown, integrity: string }} NpmPackDescription
47
+ * What `npm pack --dry-run --json` reports for the release checkout: package name, version and `sha512-<base64>` integrity.
48
+ * @typedef {{ pack: NpmPackDescription | null, problem: string | null }} NpmPackResult
23
49
  */
24
50
 
25
51
  /**
26
- * Lists the versions of a package published on npm.
52
+ * Runs npm with captured output. On Windows npm only resolves through the shell, so every argument
53
+ * is double-quoted there; callers pass validated names, registries and temporary paths only.
54
+ *
55
+ * @param {string[]} npmArguments - Arguments that follow `npm`.
56
+ * @param {string} repositoryRoot - Working directory, whose `.npmrc` npm reads.
57
+ * @param {NodeJS.ProcessEnv} [environment] - Environment of npm; the current process by default.
58
+ * @returns {ReturnType<typeof runCaptured>} Exit status and output.
59
+ * @throws {Error} When an argument could break out of its quotes on the Windows shell.
60
+ */
61
+ function runNpmCaptured(npmArguments, repositoryRoot, environment = process.env) {
62
+ const unsafeArgument = npmArguments.find((npmArgument) => UNSAFE_QUOTED_PATH_PATTERN.test(npmArgument));
63
+ if (unsafeArgument !== undefined) {
64
+ throw new Error(`beez-rp create-version: argumento de npm no permitido en la línea de comandos: ${unsafeArgument}`);
65
+ }
66
+
67
+ return USES_SHELL_FOR_PACKAGE_MANAGERS
68
+ ? runCaptured(`npm ${npmArguments.map((npmArgument) => `"${npmArgument}"`).join(" ")}`, [], { cwd: repositoryRoot, shell: true, env: environment })
69
+ : runCaptured("npm", npmArguments, { cwd: repositoryRoot, env: environment });
70
+ }
71
+
72
+ /**
73
+ * Builds the `npm view` arguments that list the published versions of a package on a registry.
74
+ * `npm view` ignores the manifest `publishConfig`, so the registry is always passed explicitly.
75
+ *
76
+ * @param {string} packageName - npm package name, already checked with `NPM_PACKAGE_NAME_PATTERN`.
77
+ * @param {string} registryUrl - Registry resolved by {@link resolvePublishRegistry}.
78
+ * @param {string | null} [userConfigPath] - Temporary npm config from {@link withNpmAuthConfig} that
79
+ * authenticates the query; `null` queries with npm's usual config.
80
+ * @returns {string[]} Arguments that follow `npm`.
81
+ * @throws {Error} When the registry is not a valid http(s) URL or has characters unsafe on the Windows shell.
82
+ */
83
+ export function buildNpmViewArguments(packageName, registryUrl, userConfigPath = null) {
84
+ const { href } = parseRegistryUrl(registryUrl);
85
+ if (!SHELL_SAFE_REGISTRY_URL_PATTERN.test(href)) {
86
+ throw new Error(`beez-rp create-version: el registry "${registryUrl}" tiene caracteres no permitidos en la línea de comandos de npm view`);
87
+ }
88
+ const userConfig = userConfigPath ? [NPM_USER_CONFIG_OPTION, userConfigPath] : [];
89
+ return ["view", packageName, "versions", "--json", NPM_REGISTRY_OPTION, href, ...userConfig];
90
+ }
91
+
92
+ /**
93
+ * Loads `NPM_TOKEN` from the ignored `.env` when the environment does not define it.
94
+ *
95
+ * @param {string} repositoryRoot - Repository root holding the optional `.env`.
96
+ * @returns {boolean} Whether `NPM_TOKEN` is available in the environment.
97
+ */
98
+ export function loadNpmToken(repositoryRoot) {
99
+ const environmentFilePath = path.join(repositoryRoot, LOCAL_ENVIRONMENT_FILE);
100
+
101
+ if (!process.env[NPM_TOKEN_VARIABLE] && existsSync(environmentFilePath)) {
102
+ process.loadEnvFile(environmentFilePath);
103
+ }
104
+
105
+ return Boolean(process.env[NPM_TOKEN_VARIABLE]);
106
+ }
107
+
108
+ /**
109
+ * Lists the versions of a package published on a registry. With `NPM_TOKEN` (environment or `.env`)
110
+ * the query authenticates through the same temporary config the publication uses, so a private
111
+ * package can be diagnosed; without it the registry is queried with npm's usual config.
27
112
  *
28
113
  * @param {string} packageName - npm package name.
29
114
  * @param {string} repositoryRoot - Directory whose `.npmrc` npm reads.
115
+ * @param {string} [registryUrl] - Registry the package is published to, from {@link resolvePublishRegistry};
116
+ * defaults to npm's default registry.
30
117
  * @returns {Promise<NpmLookup>} Published versions; a never-published package has none.
31
118
  */
32
- export async function lookupPublishedVersions(packageName, repositoryRoot) {
119
+ export async function lookupPublishedVersions(packageName, repositoryRoot, registryUrl = DEFAULT_NPM_REGISTRY_URL) {
33
120
  if (!NPM_PACKAGE_NAME_PATTERN.test(packageName)) {
34
121
  return { status: NPM_LOOKUP_STATUS.failed, publishedVersions: [], reason: `nombre de paquete inválido: ${packageName}` };
35
122
  }
36
123
 
37
- // The name is validated above, so the command line built for the Windows shell keeps a fixed shape.
38
- const result = USES_SHELL_FOR_PACKAGE_MANAGERS
39
- ? await runCaptured(`npm view ${packageName} versions --json`, [], { cwd: repositoryRoot, shell: true })
40
- : await runCaptured("npm", ["view", packageName, "versions", "--json"], { cwd: repositoryRoot });
124
+ /** @param {string | null} userConfigPath - Temporary authenticated config, or `null`. */
125
+ const view = (userConfigPath) => runNpmCaptured(buildNpmViewArguments(packageName, registryUrl, userConfigPath), repositoryRoot);
126
+
127
+ let result;
128
+ try {
129
+ result = loadNpmToken(repositoryRoot) ? await withNpmAuthConfig(buildNpmAuthConfigLine(registryUrl), view) : await view(null);
130
+ } catch (error) {
131
+ return { status: NPM_LOOKUP_STATUS.failed, publishedVersions: [], reason: error instanceof Error ? error.message : String(error) };
132
+ }
41
133
 
42
134
  if (result.status !== 0) {
43
135
  return `${result.stdout}\n${result.stderr}`.includes(NPM_NOT_FOUND_CODE)
@@ -58,28 +150,273 @@ export async function lookupPublishedVersions(packageName, repositoryRoot) {
58
150
  }
59
151
 
60
152
  /**
61
- * Publishes the working tree to npm. `NPM_TOKEN` comes from the environment or
62
- * the ignored `.env`, and only reaches npm through the environment and `.npmrc`.
153
+ * Parses the output of `npm pack --dry-run --json`: an array with one package and its `integrity`.
154
+ *
155
+ * @param {string} output - Standard output of npm.
156
+ * @returns {NpmPackResult} Package name, version and integrity, or why the output is unusable.
157
+ */
158
+ export function parseNpmPackDryRunOutput(output) {
159
+ let parsed;
160
+ try {
161
+ parsed = JSON.parse(output);
162
+ } catch (error) {
163
+ return { pack: null, problem: `la salida de npm pack --dry-run no es JSON válido (${error instanceof Error ? error.message : String(error)})` };
164
+ }
165
+
166
+ const packages = Array.isArray(parsed) ? parsed : [];
167
+ const described = packages.length === 1 && packages[0] && typeof packages[0] === "object" ? packages[0] : null;
168
+ if (!described || typeof described.integrity !== "string" || !NPM_INTEGRITY_PATTERN.test(described.integrity)) {
169
+ return { pack: null, problem: "la salida de npm pack --dry-run no describe un único paquete con su integrity sha512" };
170
+ }
171
+
172
+ return { pack: { name: described.name, version: described.version, integrity: described.integrity }, problem: null };
173
+ }
174
+
175
+ /**
176
+ * Asks npm for the integrity of the archive it would pack from the release checkout, without
177
+ * writing anything nor running lifecycle scripts. The command line is constant, so it is safe
178
+ * on the Windows shell.
179
+ *
180
+ * @param {string} repositoryRoot - Package root, as `prepare` left it.
181
+ * @returns {Promise<NpmPackResult>} npm description, or why npm failed or its output is unusable.
182
+ */
183
+ export async function readNpmPackIntegrity(repositoryRoot) {
184
+ const result = USES_SHELL_FOR_PACKAGE_MANAGERS
185
+ ? await runCaptured(`npm ${NPM_PACK_DRY_RUN_ARGUMENTS.join(" ")}`, [], { cwd: repositoryRoot, shell: true })
186
+ : await runCaptured("npm", [...NPM_PACK_DRY_RUN_ARGUMENTS], { cwd: repositoryRoot });
187
+
188
+ if (result.status !== 0) {
189
+ return { pack: null, problem: `npm pack --dry-run salió con código ${result.status}: ${result.stderr.split("\n")[0] || "sin detalle"}` };
190
+ }
191
+
192
+ return parseNpmPackDryRunOutput(result.stdout);
193
+ }
194
+
195
+ /**
196
+ * Parses a publish registry URL, accepting only plain http(s) URLs.
197
+ *
198
+ * @param {string} registryUrl - Registry URL as written in the manifest.
199
+ * @returns {URL} Parsed URL.
200
+ * @throws {Error} When the registry is not a plain http(s) URL (credentials, query and fragment are rejected).
201
+ */
202
+ function parseRegistryUrl(registryUrl) {
203
+ /** @type {URL} */
204
+ let registry;
205
+ try {
206
+ registry = new URL(registryUrl);
207
+ } catch (error) {
208
+ throw new Error(`beez-rp create-version: el registry de publicación "${registryUrl}" no es una URL válida`, { cause: error });
209
+ }
210
+
211
+ if (!NPM_REGISTRY_PROTOCOLS.includes(registry.protocol) || registry.username || registry.password || registry.search || registry.hash) {
212
+ throw new Error(`beez-rp create-version: el registry de publicación "${registryUrl}" tiene que ser una URL http(s) sin credenciales, query ni fragmento`);
213
+ }
214
+
215
+ return registry;
216
+ }
217
+
218
+ /**
219
+ * Returns the scope of a package name (`team` for `@team/pkg`), or `undefined` when it has none.
63
220
  *
221
+ * @param {unknown} packageName - `name` of the manifest.
222
+ * @returns {string | undefined} Scope without `@`.
223
+ */
224
+ function readPackageScope(packageName) {
225
+ return typeof packageName === "string" ? PACKAGE_SCOPE_PATTERN.exec(packageName)?.groups?.scope : undefined;
226
+ }
227
+
228
+ /**
229
+ * Returns the registry the manifest `publishConfig` declares: `publishConfig["@scope:registry"]`
230
+ * for a scoped package that declares it, else `publishConfig.registry`.
231
+ *
232
+ * @param {Record<string, unknown>} manifest - `package.json` being published.
233
+ * @returns {string | null} Declared registry, or `null` when `publishConfig` declares none.
234
+ */
235
+ function readPublishConfigRegistry(manifest) {
236
+ const publishConfig = manifest[PUBLISH_CONFIG_FIELD];
237
+ const registries = publishConfig && typeof publishConfig === "object" ? /** @type {Record<string, unknown>} */ (publishConfig) : {};
238
+ const scope = readPackageScope(manifest.name);
239
+ const candidates = [scope ? registries[`@${scope}${SCOPED_REGISTRY_KEY_SUFFIX}`] : undefined, registries[PUBLISH_CONFIG_REGISTRY_KEY]];
240
+ const declared = candidates.find((candidate) => typeof candidate === "string" && candidate !== "");
241
+ return typeof declared === "string" ? declared : null;
242
+ }
243
+
244
+ /**
245
+ * Asks npm for the registry it would publish the package to without `publishConfig`:
246
+ * `@scope:registry` for a scoped package when some config sets it, else `registry`. It runs in the
247
+ * repository root with the environment and the kind of temporary user config `npm publish` gets,
248
+ * so it reads the project `.npmrc`, `npm_config_*` variables and the global config, like the
249
+ * publication (which replaces `~/.npmrc` with its temporary config).
250
+ *
251
+ * @param {Record<string, unknown>} manifest - `package.json` being published.
64
252
  * @param {string} repositoryRoot - Package root.
65
- * @returns {Promise<{ exitCode: number, missingToken: boolean }>} npm exit code, or a missing-token result without running npm.
253
+ * @returns {Promise<string>} Registry URL npm resolves.
254
+ * @throws {Error} When the package name is invalid or `npm config get` fails.
66
255
  */
67
- export async function publishToNpm(repositoryRoot) {
68
- const environmentFilePath = path.join(repositoryRoot, LOCAL_ENVIRONMENT_FILE);
256
+ async function readNpmConfigRegistry(manifest, repositoryRoot) {
257
+ const packageName = String(manifest.name);
258
+ if (!NPM_PACKAGE_NAME_PATTERN.test(packageName)) {
259
+ throw new Error(`beez-rp create-version: nombre de paquete inválido: ${packageName}`);
260
+ }
69
261
 
70
- if (!process.env[NPM_TOKEN_VARIABLE] && existsSync(environmentFilePath)) {
71
- process.loadEnvFile(environmentFilePath);
262
+ // The scope comes from a name that matches the npm name pattern, so the key keeps a fixed shape.
263
+ const scope = readPackageScope(packageName);
264
+ const configKeys = [...(scope ? [`@${scope}${SCOPED_REGISTRY_KEY_SUFFIX}`] : []), PUBLISH_CONFIG_REGISTRY_KEY];
265
+
266
+ return withNpmAuthConfig("", async (userConfigPath) => {
267
+ for (const configKey of configKeys) {
268
+ const result = await runNpmCaptured(
269
+ [...NPM_CONFIG_GET_ARGUMENTS, configKey, NPM_USER_CONFIG_OPTION, userConfigPath],
270
+ repositoryRoot,
271
+ buildNpmPublishEnvironment()
272
+ );
273
+
274
+ if (result.status !== 0) {
275
+ throw new Error(`beez-rp create-version: npm config get ${configKey} salió con código ${result.status}: ${result.stderr.split("\n")[0] || "sin detalle"}`);
276
+ }
277
+
278
+ const value = result.stdout.trim();
279
+ if (value !== "" && value !== NPM_UNSET_CONFIG_VALUE) {
280
+ return value;
281
+ }
282
+ }
283
+
284
+ return DEFAULT_NPM_REGISTRY_URL;
285
+ });
286
+ }
287
+
288
+ /**
289
+ * Returns the registry `npm publish` sends the package to: `publishConfig["@scope:registry"]` for a
290
+ * scoped package that declares it, else `publishConfig.registry`, else the registry npm's own config
291
+ * resolves (project `.npmrc`, environment, global config; see {@link readNpmConfigRegistry}).
292
+ *
293
+ * @param {Record<string, unknown>} manifest - `package.json` being published.
294
+ * @param {string} repositoryRoot - Package root, where npm reads its project config.
295
+ * @returns {Promise<string>} Registry URL, already checked to be a plain http(s) URL.
296
+ * @throws {Error} When the resolved registry is not a plain http(s) URL or npm cannot report its config.
297
+ */
298
+ export async function resolvePublishRegistry(manifest, repositoryRoot) {
299
+ const registryUrl = readPublishConfigRegistry(manifest) ?? (await readNpmConfigRegistry(manifest, repositoryRoot));
300
+
301
+ parseRegistryUrl(registryUrl);
302
+ return registryUrl;
303
+ }
304
+
305
+ /**
306
+ * Describes where a published release can be seen: its npmjs.com page when the registry is the
307
+ * public npm registry, else the registry URL with the package and version.
308
+ *
309
+ * @param {{ registryUrl: string, packageName: string, version: string }} release - Published release.
310
+ * @returns {string} Summary text.
311
+ */
312
+ export function describePublishedRelease({ registryUrl, packageName, version }) {
313
+ return parseRegistryUrl(registryUrl).href === DEFAULT_NPM_REGISTRY_URL
314
+ ? `${NPMJS_PACKAGE_PAGE_URL}${packageName}/v/${version}`
315
+ : `Registro: ${registryUrl} · ${packageName}@${version}`;
316
+ }
317
+
318
+ /**
319
+ * Builds the npm config line that binds `${NPM_TOKEN}` to a registry, in the form npm matches
320
+ * credentials with: `//<host>[:port]<path>/:_authToken=${NPM_TOKEN}` (no protocol, trailing `/`).
321
+ *
322
+ * @param {string} registryUrl - Registry `npm publish` uses.
323
+ * @returns {string} Config line ending with a newline; the token itself is never written.
324
+ * @throws {Error} When the registry is not a plain http(s) URL (credentials, query and fragment are rejected).
325
+ */
326
+ export function buildNpmAuthConfigLine(registryUrl) {
327
+ const registry = parseRegistryUrl(registryUrl);
328
+ const registryPath = registry.pathname.endsWith("/") ? registry.pathname : `${registry.pathname}/`;
329
+ return `//${registry.host}${registryPath}:_authToken=${NPM_AUTH_TOKEN_REFERENCE}\n`;
330
+ }
331
+
332
+ /**
333
+ * Runs an operation with a temporary npm user config outside the repository
334
+ * that only references `${NPM_TOKEN}` for the publish registry: npm expands it
335
+ * from the environment, so the token never reaches the disk or a command line,
336
+ * and no repository needs an `.npmrc` (which pnpm refuses to expand and warns
337
+ * about). The config is always removed afterwards.
338
+ *
339
+ * @template T
340
+ * @param {string} authConfigLine - Line built by {@link buildNpmAuthConfigLine}; an empty line
341
+ * gives npm a user config without credentials, which only replaces `~/.npmrc`.
342
+ * @param {(userConfigPath: string) => Promise<T>} operation - Receives the path for `npm --userconfig`.
343
+ * @param {string} [parentDirectory] - Where the temporary directory is created; defaults to the OS temp directory.
344
+ * @returns {Promise<T>} The operation result.
345
+ */
346
+ export async function withNpmAuthConfig(authConfigLine, operation, parentDirectory = tmpdir()) {
347
+ const directory = mkdtempSync(path.join(parentDirectory, NPM_AUTH_DIRECTORY_PREFIX));
348
+ const userConfigPath = path.join(directory, "npmrc");
349
+
350
+ try {
351
+ writeFileSync(userConfigPath, authConfigLine, { mode: 0o600 });
352
+ return await operation(userConfigPath);
353
+ } finally {
354
+ rmSync(directory, { recursive: true, force: true });
72
355
  }
356
+ }
357
+
358
+ /**
359
+ * Builds the `npm publish` arguments, without the user config. A prepared
360
+ * archive is prefixed with `./` because npm parses a bare relative operand
361
+ * such as `releases/1.9.0-abc/pkg-1.9.0.tgz` as a package spec instead of a file.
362
+ *
363
+ * @param {string | null} [artifactPath] - Archive relative to the root, already checked with `isSafeArtifactPath`; `null` publishes the working tree.
364
+ * @returns {string[]} Arguments that follow `npm`.
365
+ */
366
+ export function buildNpmPublishArguments(artifactPath = null) {
367
+ const publishTarget = artifactPath
368
+ ? [artifactPath.startsWith(LOCAL_PATH_PREFIX) ? artifactPath : `${LOCAL_PATH_PREFIX}${artifactPath}`]
369
+ : [];
370
+ return ["publish", ...publishTarget, "--access", "public", "--tag", NPM_DIST_TAG];
371
+ }
73
372
 
74
- if (!process.env[NPM_TOKEN_VARIABLE]) {
373
+ /**
374
+ * Builds the environment of `npm publish` without the inherited package
375
+ * manager user agent. npm reads `npm_config_user_agent` as its `user-agent`
376
+ * config, so under `pnpm create-version` its lifecycle scripts would report
377
+ * pnpm and `beez-rp guard-publish` would block beez-rp's own npm publication.
378
+ * Without it npm reports its own user agent (`npm/…`). npm reads its config
379
+ * variables in any case (`NPM_CONFIG_USER_AGENT` too), so every casing is dropped.
380
+ *
381
+ * @param {NodeJS.ProcessEnv} [environment] - Environment to copy; the current process by default.
382
+ * @returns {NodeJS.ProcessEnv} Copy without `npm_config_user_agent` in any casing.
383
+ */
384
+ export function buildNpmPublishEnvironment(environment = process.env) {
385
+ return Object.fromEntries(
386
+ Object.entries(environment).filter(([variableName]) => variableName.toLowerCase() !== PACKAGE_MANAGER_USER_AGENT_VARIABLE),
387
+ );
388
+ }
389
+
390
+ /**
391
+ * Publishes the working tree, or a prepared archive, to npm. `NPM_TOKEN` comes
392
+ * from the environment or the ignored `.env` and only reaches npm through the
393
+ * environment and a temporary user config. npm inherits the terminal, so its
394
+ * interactive browser or one-time-password (2FA) confirmation works.
395
+ *
396
+ * @param {string} repositoryRoot - Package root.
397
+ * @param {{ authConfigLine: string, artifactPath?: string | null }} publication - Registry credential line from
398
+ * {@link buildNpmAuthConfigLine}, and the archive relative to the root (already checked with `isSafeArtifactPath`);
399
+ * without `artifactPath` the working tree is published.
400
+ * @returns {Promise<{ exitCode: number, missingToken: boolean }>} npm exit code, or a missing-token result without running npm.
401
+ * @throws {Error} When the temporary config path could break out of its shell quotes.
402
+ */
403
+ export async function publishToNpm(repositoryRoot, { authConfigLine, artifactPath = null }) {
404
+ if (!loadNpmToken(repositoryRoot)) {
75
405
  return { exitCode: 1, missingToken: true };
76
406
  }
77
407
 
78
- // The command line is constant; the token only travels through the environment and `.npmrc`.
79
- const publishArguments = ["publish", "--access", "public", "--tag", NPM_DIST_TAG];
80
- const exitCode = USES_SHELL_FOR_PACKAGE_MANAGERS
81
- ? await runInherited(`npm ${publishArguments.join(" ")}`, [], { cwd: repositoryRoot, shell: true })
82
- : await runInherited("npm", publishArguments, { cwd: repositoryRoot });
408
+ const exitCode = await withNpmAuthConfig(authConfigLine, async (userConfigPath) => {
409
+ if (UNSAFE_QUOTED_PATH_PATTERN.test(userConfigPath)) {
410
+ throw new Error(`beez-rp create-version: unsafe temporary npm config path ${userConfigPath}`);
411
+ }
412
+
413
+ // The command line is constant apart from validated paths; the token only travels through the environment.
414
+ const publishArguments = buildNpmPublishArguments(artifactPath);
415
+ const env = buildNpmPublishEnvironment();
416
+ return USES_SHELL_FOR_PACKAGE_MANAGERS
417
+ ? runInherited(`npm ${publishArguments.join(" ")} --userconfig "${userConfigPath}"`, [], { cwd: repositoryRoot, shell: true, env })
418
+ : runInherited("npm", [...publishArguments, "--userconfig", userConfigPath], { cwd: repositoryRoot, env });
419
+ });
83
420
 
84
421
  return { exitCode, missingToken: false };
85
422
  }
@@ -314,8 +314,15 @@ export function buildReleasePlan(state, capabilities = DEFAULT_CAPABILITIES) {
314
314
  /** @type {string[]} */
315
315
  const warnings = [];
316
316
 
317
+ // The rest of the plan depends on the code and configuration of the updated main, so the run
318
+ // stops after syncing and the next one diagnoses again.
317
319
  if (state.main.behindCount > 0) {
318
- steps.push({ id: RELEASE_STEP.syncMain, title: `Actualizar ${MAIN_BRANCH} desde origin`, detail: `${state.main.behindCount} commit(s) nuevos.` });
320
+ steps.push({
321
+ id: RELEASE_STEP.syncMain,
322
+ title: `Actualizar ${MAIN_BRANCH} desde origin`,
323
+ detail: `${state.main.behindCount} commit(s) nuevos. Después hay que volver a correr pnpm create-version, que diagnostica con el código nuevo.`,
324
+ });
325
+ return { mode: RELEASE_MODE.newRelease, steps, blockers: [], warnings, pendingVersion: null };
319
326
  }
320
327
 
321
328
  if (state.migrations?.status === MIGRATION_STATUS.pending) {
@@ -12,7 +12,7 @@ import { FIELD_SEPARATOR, PACKAGE_MANIFEST_FILE, RECORD_SEPARATOR } from "../con
12
12
 
13
13
  /**
14
14
  * @typedef {{ status: number, stdout: string, stderr: string }} CapturedResult
15
- * @typedef {{ cwd?: string, shell?: boolean }} CommandOptions
15
+ * @typedef {{ cwd?: string, shell?: boolean, env?: NodeJS.ProcessEnv }} CommandOptions
16
16
  * @typedef {{ git: (gitArguments: string[]) => Promise<string>, tryGit: (gitArguments: string[]) => Promise<string | null> }} GitReader
17
17
  * @typedef {{ sha: string, subject: string, body: string }} CommitRecord
18
18
  */
@@ -57,7 +57,7 @@ export function runCaptured(command, commandArguments, options = {}) {
57
57
  */
58
58
  export function runInherited(command, commandArguments, options = {}) {
59
59
  return new Promise((resolve) => {
60
- const child = spawn(command, commandArguments, { cwd: options.cwd, shell: options.shell ?? false, stdio: "inherit" });
60
+ const child = spawn(command, commandArguments, { cwd: options.cwd, env: options.env, shell: options.shell ?? false, stdio: "inherit" });
61
61
  child.on("error", () => resolve(1));
62
62
  child.on("close", (status) => resolve(status ?? 1));
63
63
  });
@@ -131,18 +131,31 @@ export async function listCommits(reader, range) {
131
131
  }
132
132
 
133
133
  /**
134
- * Reads the `version` field of `package.json` at a revision.
134
+ * Reads `package.json` at a revision.
135
135
  *
136
136
  * @param {GitReader} reader - Git reader.
137
- * @param {string} revision - Revision such as `HEAD` or `origin/main`.
138
- * @returns {Promise<string | null>} Version, or `null` when unreadable.
137
+ * @param {string} revision - Revision such as `HEAD`, `v1.2.0` or `origin/main`.
138
+ * @returns {Promise<Record<string, unknown> | null>} Parsed manifest, or `null` when missing, unreadable or not a JSON object.
139
139
  */
140
- export async function readPackageVersionAt(reader, revision) {
140
+ export async function readPackageManifestAt(reader, revision) {
141
141
  const manifest = await reader.tryGit(["show", `${revision}:${PACKAGE_MANIFEST_FILE}`]);
142
142
 
143
143
  try {
144
- return manifest ? (JSON.parse(manifest).version ?? null) : null;
144
+ const parsed = manifest ? JSON.parse(manifest) : null;
145
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
145
146
  } catch {
146
147
  return null;
147
148
  }
148
149
  }
150
+
151
+ /**
152
+ * Reads the `version` field of `package.json` at a revision.
153
+ *
154
+ * @param {GitReader} reader - Git reader.
155
+ * @param {string} revision - Revision such as `HEAD` or `origin/main`.
156
+ * @returns {Promise<string | null>} Version, or `null` when unreadable.
157
+ */
158
+ export async function readPackageVersionAt(reader, revision) {
159
+ const manifest = await readPackageManifestAt(reader, revision);
160
+ return typeof manifest?.version === "string" ? manifest.version : null;
161
+ }