@astra-kit/rp 0.0.0-stage → 0.10.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +352 -2
  4. package/bin/astra-rp.js +51 -0
  5. package/package.json +93 -4
  6. package/src/build-gate.js +157 -0
  7. package/src/changelog-blocks.js +53 -0
  8. package/src/changelog.js +108 -0
  9. package/src/constants/build-gate.js +30 -0
  10. package/src/constants/changelog-draft.js +180 -0
  11. package/src/constants/changelog.js +74 -0
  12. package/src/constants/ci-release.js +346 -0
  13. package/src/constants/cli.js +12 -0
  14. package/src/constants/create-version.js +523 -0
  15. package/src/constants/guard-publish.js +29 -0
  16. package/src/constants/index.js +18 -0
  17. package/src/constants/monorepo.js +57 -0
  18. package/src/constants/package-manager.js +56 -0
  19. package/src/constants/registry.js +72 -0
  20. package/src/constants/terminal-ui.js +93 -0
  21. package/src/constants/version-files.js +63 -0
  22. package/src/constants/versions.js +60 -0
  23. package/src/create-version/artifact.js +343 -0
  24. package/src/create-version/changelog-draft-workspace.js +124 -0
  25. package/src/create-version/changelog-draft.js +380 -0
  26. package/src/create-version/changelog.js +264 -0
  27. package/src/create-version/ci-config.js +54 -0
  28. package/src/create-version/ci-publication-history.js +113 -0
  29. package/src/create-version/ci-release-identity-step.js +58 -0
  30. package/src/create-version/ci-setup.js +794 -0
  31. package/src/create-version/ci.js +555 -0
  32. package/src/create-version/committed-tree.js +75 -0
  33. package/src/create-version/config.js +489 -0
  34. package/src/create-version/errors.js +46 -0
  35. package/src/create-version/github-workflow.js +199 -0
  36. package/src/create-version/index.js +54 -0
  37. package/src/create-version/json.js +32 -0
  38. package/src/create-version/jsonc.js +76 -0
  39. package/src/create-version/jsr.js +96 -0
  40. package/src/create-version/local-changes.js +178 -0
  41. package/src/create-version/npm-auth.js +142 -0
  42. package/src/create-version/npm.js +812 -0
  43. package/src/create-version/plan.js +1200 -0
  44. package/src/create-version/process.js +334 -0
  45. package/src/create-version/registry-config.js +121 -0
  46. package/src/create-version/registry-http.js +62 -0
  47. package/src/create-version/registry.js +220 -0
  48. package/src/create-version/release-execution.js +45 -0
  49. package/src/create-version/release-tag-config.js +76 -0
  50. package/src/create-version/run.js +2087 -0
  51. package/src/create-version/state.js +461 -0
  52. package/src/create-version/strict-utf8.js +11 -0
  53. package/src/create-version/templates/release.yml +52 -0
  54. package/src/create-version/templates/vercel-ignore-build.mjs +66 -0
  55. package/src/guard-publish.js +77 -0
  56. package/src/index.js +42 -0
  57. package/src/monorepo/plan.js +475 -0
  58. package/src/monorepo/release-commit.js +46 -0
  59. package/src/monorepo/run.js +927 -0
  60. package/src/monorepo/state.js +206 -0
  61. package/src/monorepo/workspaces.js +327 -0
  62. package/src/package-manager.js +107 -0
  63. package/src/terminal-ui.js +548 -0
  64. package/src/testing.js +70 -0
  65. package/src/version-files.js +142 -0
  66. package/src/versions.js +303 -0
  67. package/types/build-gate.d.ts +43 -0
  68. package/types/changelog-blocks.d.ts +33 -0
  69. package/types/changelog.d.ts +61 -0
  70. package/types/constants/build-gate.d.ts +23 -0
  71. package/types/constants/changelog-draft.d.ts +138 -0
  72. package/types/constants/changelog.d.ts +56 -0
  73. package/types/constants/ci-release.d.ts +284 -0
  74. package/types/constants/cli.d.ts +11 -0
  75. package/types/constants/create-version.d.ts +397 -0
  76. package/types/constants/guard-publish.d.ts +25 -0
  77. package/types/constants/index.d.ts +17 -0
  78. package/types/constants/monorepo.d.ts +45 -0
  79. package/types/constants/package-manager.d.ts +40 -0
  80. package/types/constants/registry.d.ts +79 -0
  81. package/types/constants/terminal-ui.d.ts +73 -0
  82. package/types/constants/version-files.d.ts +51 -0
  83. package/types/constants/versions.d.ts +47 -0
  84. package/types/create-version/artifact.d.ts +113 -0
  85. package/types/create-version/changelog-draft-workspace.d.ts +33 -0
  86. package/types/create-version/changelog-draft.d.ts +127 -0
  87. package/types/create-version/changelog.d.ts +141 -0
  88. package/types/create-version/ci-config.d.ts +33 -0
  89. package/types/create-version/ci-publication-history.d.ts +51 -0
  90. package/types/create-version/ci-release-identity-step.d.ts +12 -0
  91. package/types/create-version/ci-setup.d.ts +148 -0
  92. package/types/create-version/ci.d.ts +145 -0
  93. package/types/create-version/committed-tree.d.ts +55 -0
  94. package/types/create-version/config.d.ts +128 -0
  95. package/types/create-version/errors.d.ts +39 -0
  96. package/types/create-version/github-workflow.d.ts +41 -0
  97. package/types/create-version/index.d.ts +28 -0
  98. package/types/create-version/json.d.ts +21 -0
  99. package/types/create-version/jsonc.d.ts +21 -0
  100. package/types/create-version/jsr.d.ts +52 -0
  101. package/types/create-version/local-changes.d.ts +52 -0
  102. package/types/create-version/npm-auth.d.ts +60 -0
  103. package/types/create-version/npm.d.ts +331 -0
  104. package/types/create-version/plan.d.ts +369 -0
  105. package/types/create-version/process.d.ts +151 -0
  106. package/types/create-version/registry-config.d.ts +56 -0
  107. package/types/create-version/registry-http.d.ts +22 -0
  108. package/types/create-version/registry.d.ts +86 -0
  109. package/types/create-version/release-execution.d.ts +22 -0
  110. package/types/create-version/release-tag-config.d.ts +18 -0
  111. package/types/create-version/run.d.ts +407 -0
  112. package/types/create-version/state.d.ts +204 -0
  113. package/types/create-version/strict-utf8.d.ts +10 -0
  114. package/types/guard-publish.d.ts +49 -0
  115. package/types/index.d.ts +23 -0
  116. package/types/monorepo/plan.d.ts +68 -0
  117. package/types/monorepo/release-commit.d.ts +29 -0
  118. package/types/monorepo/run.d.ts +68 -0
  119. package/types/monorepo/state.d.ts +96 -0
  120. package/types/monorepo/workspaces.d.ts +86 -0
  121. package/types/package-manager.d.ts +58 -0
  122. package/types/terminal-ui.d.ts +196 -0
  123. package/types/testing.d.ts +31 -0
  124. package/types/version-files.d.ts +59 -0
  125. package/types/versions.d.ts +158 -0
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Process and Git helpers of `astra-rp create-version`: captured and inherited
3
+ * commands that never reject, a Git reader bound to a repository, commit
4
+ * listing and manifest reads at any revision.
5
+ *
6
+ * @module create-version/process
7
+ */
8
+
9
+ import { spawn, spawnSync } from "node:child_process";
10
+
11
+ import { isRecord } from "./json.js";
12
+ import { FIELD_SEPARATOR, PACKAGE_MANIFEST_FILE, RECORD_SEPARATOR, VERSION_FIELD_CHANGE_PATTERN } from "../constants/create-version.js";
13
+
14
+ /**
15
+ * @typedef {{ status: number, stdout: string, stderr: string }} CapturedResult
16
+ * @typedef {{ cwd?: string, shell?: boolean, env?: NodeJS.ProcessEnv }} CommandOptions
17
+ * @typedef {{ git: (gitArguments: string[]) => Promise<string>, tryGit: (gitArguments: string[]) => Promise<string | null> }} GitReader
18
+ * @typedef {{ sha: string, subject: string, body: string }} CommitRecord
19
+ */
20
+
21
+ /** Windows resolves `pnpm.cmd` and `npm.cmd` only through a shell. */
22
+ export const USES_SHELL_FOR_PACKAGE_MANAGERS = process.platform === "win32";
23
+
24
+ /**
25
+ * Double-quotes every argument for a Windows shell command line; callers validate them first against
26
+ * characters that could break out of the quotes.
27
+ *
28
+ * @param {readonly string[]} commandArguments - Validated arguments.
29
+ * @returns {string} Arguments quoted and joined with spaces.
30
+ */
31
+ export function quoteShellArguments(commandArguments) {
32
+ return commandArguments.map((commandArgument) => `"${commandArgument}"`).join(" ");
33
+ }
34
+
35
+ /**
36
+ * Runs a command and captures its output. Leading whitespace is kept because
37
+ * `git status --porcelain` encodes the file state in the first columns.
38
+ *
39
+ * @param {string} command - Executable name, or a full command line when `shell` is set.
40
+ * @param {string[]} commandArguments - Arguments.
41
+ * @param {CommandOptions} [options] - Spawn options.
42
+ * @returns {Promise<CapturedResult>} Result; never rejects.
43
+ */
44
+ export function runCaptured(command, commandArguments, options = {}) {
45
+ return new Promise((resolve) => {
46
+ const child = spawn(command, commandArguments, {
47
+ cwd: options.cwd,
48
+ env: options.env,
49
+ shell: options.shell ?? false,
50
+ stdio: ["ignore", "pipe", "pipe"],
51
+ windowsHide: true,
52
+ });
53
+ let stdout = "";
54
+ let stderr = "";
55
+
56
+ child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
57
+ child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
58
+ child.on("error", (error) => resolve({ status: 1, stdout, stderr: error.message }));
59
+ child.on("close", (status) => resolve({ status: status ?? 1, stdout: stdout.trimEnd(), stderr: stderr.trim() }));
60
+ });
61
+ }
62
+
63
+ /**
64
+ * @typedef {CommandOptions & { input: string, timeoutMs: number }} InputCommandOptions
65
+ * @typedef {CapturedResult & { timedOut: boolean, spawnErrorCode: string | null }} InputCommandResult
66
+ */
67
+
68
+ /**
69
+ * Whether stoppable commands run as the leader of their own process group: true outside Windows,
70
+ * where signaling the negative pid reaches every process the command started.
71
+ */
72
+ const RUNS_COMMANDS_IN_OWN_PROCESS_GROUP = process.platform !== "win32";
73
+
74
+ /**
75
+ * Stops a command together with every process it started, such as the shell or Git commands an
76
+ * agent CLI is running: on Windows `taskkill /t` ends the whole tree (a shell-launched `.cmd` shim
77
+ * runs the real program as a grandchild); elsewhere the command leads its own process group, which
78
+ * is killed as a whole. A group that already ended leaves nothing to stop; any other failure is
79
+ * logged so a process left running is not silent.
80
+ *
81
+ * @param {import("node:child_process").ChildProcess} child - Command to stop, spawned with `detached`
82
+ * outside Windows.
83
+ * @param {string} command - Executable name or command line, only used to describe a failure.
84
+ * @returns {void}
85
+ */
86
+ function stopProcessTree(child, command) {
87
+ if (child.pid === undefined) return;
88
+ if (!RUNS_COMMANDS_IN_OWN_PROCESS_GROUP) {
89
+ // A tree that already ended makes taskkill fail, which leaves nothing to stop.
90
+ spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true });
91
+ return;
92
+ }
93
+ try {
94
+ process.kill(-child.pid, "SIGKILL");
95
+ } catch (error) {
96
+ if (/** @type {NodeJS.ErrnoException} */ (error).code === "ESRCH") return;
97
+ console.error(`astra-rp create-version: stopProcessTree failed to kill the process group of "${command}" (pid ${child.pid}) after its timeout`, error);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Runs a command that reads `input` from stdin and captures its output, stopping it after
103
+ * `timeoutMs`. Used for agent CLIs that receive their prompt through stdin. Outside Windows the
104
+ * command runs in its own process group so a timeout also stops the processes it started; the
105
+ * promise still waits for its pipes to close when it ends normally, so no `unref` is needed.
106
+ *
107
+ * @param {string} command - Executable name, or a full command line when `shell` is set.
108
+ * @param {string[]} commandArguments - Arguments.
109
+ * @param {InputCommandOptions} options - Spawn options, stdin contents and time limit.
110
+ * @returns {Promise<InputCommandResult>} Result, whether it was stopped for taking too long and the
111
+ * spawn error code (such as `ENOENT`); never rejects.
112
+ */
113
+ export function runCapturedWithInput(command, commandArguments, options) {
114
+ return new Promise((resolve) => {
115
+ const child = spawn(command, commandArguments, {
116
+ cwd: options.cwd,
117
+ env: options.env,
118
+ shell: options.shell ?? false,
119
+ stdio: ["pipe", "pipe", "pipe"],
120
+ windowsHide: true,
121
+ detached: RUNS_COMMANDS_IN_OWN_PROCESS_GROUP,
122
+ });
123
+ let stdout = "";
124
+ let stderr = "";
125
+ let timedOut = false;
126
+ /** @type {string | null} */
127
+ let spawnErrorCode = null;
128
+
129
+ child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
130
+ child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
131
+ // A command that exits without reading stdin closes the pipe: its exit code tells what happened.
132
+ child.stdin.on("error", () => {});
133
+ child.stdin.end(options.input);
134
+ let settled = false;
135
+ /** @param {number} status - Exit code to report. */
136
+ const finish = (status) => {
137
+ if (settled) return;
138
+ settled = true;
139
+ clearTimeout(timer);
140
+ resolve({ status, stdout: stdout.trim(), stderr: stderr.trim(), timedOut, spawnErrorCode });
141
+ };
142
+ const timer = setTimeout(() => {
143
+ timedOut = true;
144
+ stopProcessTree(child, command);
145
+ // Grandchildren that ignore the signal keep the pipes open and would delay `close` forever.
146
+ finish(1);
147
+ }, options.timeoutMs);
148
+ // A command that could not start may never emit `close`.
149
+ child.on("error", (error) => {
150
+ spawnErrorCode = /** @type {NodeJS.ErrnoException} */ (error).code ?? null;
151
+ stderr = error.message;
152
+ finish(1);
153
+ });
154
+ child.on("close", (status) => finish(status ?? 1));
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Runs a command with inherited stdio so its progress stays visible.
160
+ *
161
+ * @param {string} command - Executable name, or a full command line when `shell` is set.
162
+ * @param {string[]} commandArguments - Arguments.
163
+ * @param {CommandOptions} [options] - Spawn options.
164
+ * @returns {Promise<number>} Exit code; never rejects.
165
+ */
166
+ export function runInherited(command, commandArguments, options = {}) {
167
+ return new Promise((resolve) => {
168
+ const child = spawn(command, commandArguments, { cwd: options.cwd, env: options.env, shell: options.shell ?? false, stdio: "inherit" });
169
+ child.on("error", () => resolve(1));
170
+ child.on("close", (status) => resolve(status ?? 1));
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Forwards complete output lines, retaining incomplete chunks so a split credential is redacted.
176
+ *
177
+ * @param {import("node:stream").Readable} source - Child stream.
178
+ * @param {NodeJS.WriteStream} destination - Parent stream.
179
+ * @param {string[]} secrets - Values that must not be logged.
180
+ * @returns {void}
181
+ */
182
+ function streamRedactedOutput(source, destination, secrets) {
183
+ let pending = "";
184
+ /** @param {string} value - Complete output line or final tail. */
185
+ const write = (value) => destination.write(secrets.filter(Boolean).reduce((output, secret) => output.replaceAll(secret, "[redactado]"), value));
186
+ source.setEncoding("utf8");
187
+ source.on("data", (chunk) => {
188
+ pending += chunk;
189
+ let newline;
190
+ while ((newline = pending.indexOf("\n")) !== -1) {
191
+ write(pending.slice(0, newline + 1));
192
+ pending = pending.slice(newline + 1);
193
+ }
194
+ });
195
+ source.on("end", () => { if (pending) write(pending); });
196
+ }
197
+
198
+ /**
199
+ * Streams a client that may print its own command on failure, redacting credentials across chunks.
200
+ *
201
+ * @param {string} command - Executable or trusted shell command.
202
+ * @param {string[]} commandArguments - Client arguments.
203
+ * @param {CommandOptions} options - Child environment and checkout.
204
+ * @param {string[]} secrets - Values to redact; never included in diagnostics.
205
+ * @returns {Promise<number>} Exit code with interactive stdin and safe terminal output.
206
+ */
207
+ export function runRedactedInherited(command, commandArguments, options, secrets) {
208
+ return new Promise((resolve) => {
209
+ const child = spawn(command, commandArguments, { cwd: options.cwd, env: options.env, shell: options.shell ?? false, stdio: ["inherit", "pipe", "pipe"], windowsHide: true });
210
+ streamRedactedOutput(child.stdout, process.stdout, secrets);
211
+ streamRedactedOutput(child.stderr, process.stderr, secrets);
212
+ child.on("error", () => resolve(1));
213
+ child.on("close", (status) => resolve(status ?? 1));
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Runs a trusted, configured command line (for example `pnpm check`) through the shell.
219
+ *
220
+ * @param {string} commandLine - Command line written in the project configuration, never user input.
221
+ * @param {string} cwd - Working directory.
222
+ * @returns {Promise<number>} Exit code; never rejects.
223
+ */
224
+ export function runCommandLine(commandLine, cwd) {
225
+ return runInherited(commandLine, [], { cwd, shell: true });
226
+ }
227
+
228
+ /**
229
+ * Creates a Git reader bound to a repository.
230
+ *
231
+ * @param {string} repositoryRoot - Repository root.
232
+ * @returns {GitReader} Readers.
233
+ */
234
+ export function createGitReader(repositoryRoot) {
235
+ /** @param {string[]} gitArguments */
236
+ const tryGit = async (gitArguments) => {
237
+ const result = await runCaptured("git", gitArguments, { cwd: repositoryRoot });
238
+ return result.status === 0 ? result.stdout : null;
239
+ };
240
+
241
+ /** @param {string[]} gitArguments */
242
+ const git = async (gitArguments) => {
243
+ const result = await runCaptured("git", gitArguments, { cwd: repositoryRoot });
244
+
245
+ if (result.status !== 0) {
246
+ throw new Error(`astra-rp create-version: git ${gitArguments.join(" ")} failed in ${repositoryRoot}: ${result.stderr}`);
247
+ }
248
+
249
+ return result.stdout;
250
+ };
251
+
252
+ return { git, tryGit };
253
+ }
254
+
255
+ /**
256
+ * Parses `git log` records produced with {@link FIELD_SEPARATOR} and {@link RECORD_SEPARATOR}.
257
+ *
258
+ * @param {string} output - Raw `git log` output.
259
+ * @returns {CommitRecord[]} Commits, newest first.
260
+ */
261
+ export function parseCommitLog(output) {
262
+ return output
263
+ .split(RECORD_SEPARATOR)
264
+ .map((record) => record.trim())
265
+ .filter(Boolean)
266
+ .map((record) => {
267
+ const [sha = "", subject = "", body = ""] = record.split(FIELD_SEPARATOR);
268
+ return { sha, subject, body: body.trim() };
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Lists the commits of a revision range, optionally only those touching some paths.
274
+ *
275
+ * @param {GitReader} reader - Git reader.
276
+ * @param {string} range - Revision range such as `abc..origin/main`.
277
+ * @param {readonly string[]} [paths] - Paths relative to the root; every commit when empty.
278
+ * @returns {Promise<CommitRecord[]>} Commits, newest first.
279
+ */
280
+ export async function listCommits(reader, range, paths = []) {
281
+ const pathArguments = paths.length > 0 ? ["--", ...paths] : [];
282
+ const output = await reader.tryGit(["log", `--format=%H${FIELD_SEPARATOR}%s${FIELD_SEPARATOR}%b${RECORD_SEPARATOR}`, range, ...pathArguments]);
283
+ return output ? parseCommitLog(output) : [];
284
+ }
285
+
286
+ /**
287
+ * Reads a `package.json` at a revision.
288
+ *
289
+ * @param {GitReader} reader - Git reader.
290
+ * @param {string} revision - Revision such as `HEAD`, `v1.2.0` or `origin/main`.
291
+ * @param {string} [manifestPath] - Manifest path relative to the root, with `/` separators (a
292
+ * workspace package's `packages/x/package.json`); the root `package.json` by default.
293
+ * @returns {Promise<Record<string, unknown> | null>} Parsed manifest, or `null` when missing, unreadable or not a JSON object.
294
+ */
295
+ export async function readPackageManifestAt(reader, revision, manifestPath = PACKAGE_MANIFEST_FILE) {
296
+ const manifest = await reader.tryGit(["show", `${revision}:${manifestPath}`]);
297
+
298
+ try {
299
+ const parsed = manifest ? JSON.parse(manifest) : null;
300
+ return isRecord(parsed) ? parsed : null;
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Reads the `version` field of a `package.json` at a revision.
308
+ *
309
+ * @param {GitReader} reader - Git reader.
310
+ * @param {string} revision - Revision such as `HEAD` or `origin/main`.
311
+ * @param {string} [manifestPath] - Manifest path relative to the root; the root `package.json` by default.
312
+ * @returns {Promise<string | null>} Version, or `null` when unreadable.
313
+ */
314
+ export async function readPackageVersionAt(reader, revision, manifestPath = PACKAGE_MANIFEST_FILE) {
315
+ const manifest = await readPackageManifestAt(reader, revision, manifestPath);
316
+ return typeof manifest?.version === "string" ? manifest.version : null;
317
+ }
318
+
319
+ /**
320
+ * Finds the newest top-level version change, including compact JSON and excluding edits to nested versions.
321
+ *
322
+ * @param {GitReader} reader - Read-only Git boundary.
323
+ * @param {string} revision - History to inspect.
324
+ * @param {string} [manifestPath] - Package manifest path.
325
+ * @returns {Promise<string | null>} Commit that actually changed the top-level version.
326
+ */
327
+ export async function findVersionChangeCommit(reader, revision, manifestPath = PACKAGE_MANIFEST_FILE) {
328
+ const history = await reader.tryGit(["log", "--format=%H", `-G${VERSION_FIELD_CHANGE_PATTERN}`, revision, "--", manifestPath]);
329
+ for (const sha of (history ?? "").split("\n").filter(Boolean)) {
330
+ const [version, previous] = await Promise.all([readPackageVersionAt(reader, sha, manifestPath), readPackageVersionAt(reader, `${sha}^`, manifestPath)]);
331
+ if (version !== null && version !== previous) return sha;
332
+ }
333
+ return null;
334
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Validates provider-specific publication options while keeping legacy npm configuration intact.
3
+ *
4
+ * @module create-version/registry-config
5
+ */
6
+
7
+ import path from "node:path";
8
+ import { CHANGELOG_FILE } from "../constants/changelog.js";
9
+
10
+ import {
11
+ BROWSER_AUTHENTICATION,
12
+ JSR_CLIENTS,
13
+ JSR_REGISTRY_PROVIDER,
14
+ NPM_REGISTRY_PROVIDER,
15
+ REGISTRY_ACCESS_MODES,
16
+ REGISTRY_AUTHENTICATION_MODES,
17
+ REGISTRY_DIST_TAG_PATTERN,
18
+ REGISTRY_PROVIDERS,
19
+ REGISTRY_TOKEN_VARIABLE_PATTERN,
20
+ REGISTRY_TOKEN_VARIABLES,
21
+ TOKEN_AUTHENTICATION,
22
+ } from "../constants/registry.js";
23
+ import { NPM_DIST_TAG, NPM_REGISTRY_PROTOCOLS } from "../constants/create-version.js";
24
+ import { isRecord } from "./json.js";
25
+
26
+ /**
27
+ * @typedef {"npm" | "github" | "gitlab" | "jsr"} RegistryProvider
28
+ * @typedef {"token" | "oidc" | "browser"} RegistryAuthentication
29
+ * @typedef {{ registryUrl?: string, packageName?: string, authentication?: RegistryAuthentication, tokenEnv?: string, access?: "public" | "restricted", tag?: string, configFile?: string, jsrClient?: "npx" | "deno" }} PublicationOptions
30
+ * @typedef {{ registryUrl: string | null, packageName: string | null, authentication: RegistryAuthentication, tokenEnv: string, access: "public" | "restricted" | null, tag: string | null, configFile: string | null, jsrClient: "npx" | "deno" }} ResolvedPublicationOptions
31
+ */
32
+
33
+ /**
34
+ * Identifies one of the four built-in publishers.
35
+ *
36
+ * @param {unknown} value - Configured publisher or registry.
37
+ * @returns {value is RegistryProvider} Whether it is a provider name.
38
+ */
39
+ export function isRegistryProvider(value) {
40
+ return typeof value === "string" && /** @type {readonly string[]} */ (REGISTRY_PROVIDERS).includes(value);
41
+ }
42
+
43
+ /**
44
+ * Tells whether a URL is a plain http(s) URL: without credentials, query nor fragment.
45
+ *
46
+ * @param {URL} url - Parsed URL.
47
+ * @returns {boolean} Whether a registry can be addressed by it.
48
+ */
49
+ export function isPlainHttpUrl(url) {
50
+ return NPM_REGISTRY_PROTOCOLS.includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
51
+ }
52
+
53
+ /**
54
+ * Resolves publication options for a single selected provider; arrays are deliberately unsupported.
55
+ *
56
+ * @param {unknown} value - Raw `publication` configuration.
57
+ * @param {RegistryProvider} [provider] - Selected publisher or monitored registry.
58
+ * @returns {ResolvedPublicationOptions} Validated options and provider defaults.
59
+ * @throws {Error} When a field is invalid or unsupported by the selected provider.
60
+ */
61
+ export function resolvePublicationOptions(value, provider = NPM_REGISTRY_PROVIDER) {
62
+ if (value !== undefined && !isRecord(value)) {
63
+ throw new Error("astra-rp create-version: publication must be an object for a single registry");
64
+ }
65
+ const options = /** @type {PublicationOptions} */ (value ?? {});
66
+ const authentication = options.authentication ?? (provider === JSR_REGISTRY_PROVIDER ? BROWSER_AUTHENTICATION : TOKEN_AUTHENTICATION);
67
+ if (!(/** @type {readonly string[]} */ (REGISTRY_AUTHENTICATION_MODES)).includes(authentication)) {
68
+ throw new Error("astra-rp create-version: publication.authentication must be token, oidc or browser");
69
+ }
70
+ if (authentication !== TOKEN_AUTHENTICATION && provider !== NPM_REGISTRY_PROVIDER && provider !== JSR_REGISTRY_PROVIDER) {
71
+ throw new Error(`astra-rp create-version: ${provider} only supports token authentication`);
72
+ }
73
+ if (authentication === BROWSER_AUTHENTICATION && provider !== JSR_REGISTRY_PROVIDER) {
74
+ throw new Error("astra-rp create-version: browser authentication is supported only by JSR");
75
+ }
76
+ const tokenEnv = options.tokenEnv ?? REGISTRY_TOKEN_VARIABLES[provider];
77
+ if (typeof tokenEnv !== "string" || !REGISTRY_TOKEN_VARIABLE_PATTERN.test(tokenEnv)) {
78
+ throw new Error("astra-rp create-version: publication.tokenEnv must be an environment variable name");
79
+ }
80
+ for (const field of ["registryUrl", "packageName", "configFile"]) {
81
+ const candidate = options[/** @type {"registryUrl" | "packageName" | "configFile"} */ (field)];
82
+ if (candidate !== undefined && (typeof candidate !== "string" || candidate.trim() === "")) {
83
+ throw new Error(`astra-rp create-version: publication.${field} must be a non-empty string`);
84
+ }
85
+ }
86
+ if (options.registryUrl !== undefined) {
87
+ const url = new URL(options.registryUrl);
88
+ if (!isPlainHttpUrl(url)) {
89
+ throw new Error("astra-rp create-version: publication.registryUrl must be an http(s) URL without credentials, query or fragment");
90
+ }
91
+ }
92
+ if (options.access !== undefined && !(/** @type {readonly string[]} */ (REGISTRY_ACCESS_MODES)).includes(options.access)) {
93
+ throw new Error("astra-rp create-version: publication.access must be public or restricted");
94
+ }
95
+ if (options.tag !== undefined && (typeof options.tag !== "string" || !REGISTRY_DIST_TAG_PATTERN.test(options.tag))) {
96
+ throw new Error("astra-rp create-version: publication.tag must be a distribution tag without shell characters");
97
+ }
98
+ if (provider === JSR_REGISTRY_PROVIDER && (options.access === "restricted" || options.tag !== undefined)) {
99
+ throw new Error("astra-rp create-version: JSR supports public packages and does not support npm distribution tags");
100
+ }
101
+ if (provider !== JSR_REGISTRY_PROVIDER && (options.configFile !== undefined || options.jsrClient !== undefined)) {
102
+ throw new Error("astra-rp create-version: publication.configFile and publication.jsrClient require publish: jsr");
103
+ }
104
+ const jsrClient = options.jsrClient ?? "npx";
105
+ if (!(/** @type {readonly string[]} */ (JSR_CLIENTS)).includes(jsrClient)) {
106
+ throw new Error("astra-rp create-version: publication.jsrClient must be npx or deno");
107
+ }
108
+ if (options.configFile !== undefined && (path.win32.parse(options.configFile).root !== "" || options.configFile.split(/[\\/]/u).includes("..") || /[%!"\r\n]/u.test(options.configFile) || path.win32.basename(options.configFile).toLowerCase() === CHANGELOG_FILE.toLowerCase())) {
109
+ throw new Error("astra-rp create-version: publication.configFile must be a safe path inside the package root");
110
+ }
111
+ return {
112
+ registryUrl: options.registryUrl ?? null,
113
+ packageName: options.packageName ?? null,
114
+ authentication,
115
+ tokenEnv,
116
+ access: options.access ?? (provider === NPM_REGISTRY_PROVIDER ? "public" : null),
117
+ tag: provider === JSR_REGISTRY_PROVIDER ? null : options.tag ?? NPM_DIST_TAG,
118
+ configFile: options.configFile?.replaceAll("\\", "/") ?? null,
119
+ jsrClient,
120
+ };
121
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Reads provider metadata without following registry forwarding redirects or exposing upstream bodies.
3
+ *
4
+ * @module create-version/registry-http
5
+ */
6
+
7
+ import { NPM_LOOKUP_STATUS } from "../constants/create-version.js";
8
+ import { GITLAB_REGISTRY_PROVIDER, JSR_REGISTRY_PROVIDER, REGISTRY_LOOKUP_TIMEOUT_MS } from "../constants/registry.js";
9
+ import { findHighestStableVersion } from "../versions.js";
10
+
11
+ /**
12
+ * Fetches versions directly from GitLab or JSR; a forwarding redirect means absent in this registry.
13
+ *
14
+ * @param {{ provider: import("./registry-config.js").RegistryProvider, registryUrl: string, packageName: string, label: string, tag: string | null, options?: { tokenEnv: string } }} registry - Selected registry.
15
+ * @param {string | null} token - Read credential, never included in returned state.
16
+ * @returns {Promise<import("./npm.js").NpmLookup>} Shared published-version contract.
17
+ */
18
+ export async function lookupHttpRegistry(registry, token) {
19
+ const baseUrl = registry.registryUrl.endsWith("/") ? registry.registryUrl : `${registry.registryUrl}/`;
20
+ const resource = registry.provider === JSR_REGISTRY_PROVIDER ? `${registry.packageName}/meta.json` : encodeURIComponent(registry.packageName);
21
+ const url = new URL(resource, baseUrl);
22
+ const empty = { status: NPM_LOOKUP_STATUS.ok, publishedVersions: [], latestVersion: null, reason: null, registryLabel: registry.label, tag: registry.tag };
23
+ try {
24
+ const response = await fetch(url, {
25
+ redirect: "manual",
26
+ headers: { Accept: "application/json", "Cache-Control": "no-cache", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
27
+ signal: AbortSignal.timeout(REGISTRY_LOOKUP_TIMEOUT_MS),
28
+ });
29
+ if (response.status === 404) {
30
+ await response.body?.cancel();
31
+ if (registry.provider === GITLAB_REGISTRY_PROVIDER && !token) {
32
+ return { ...empty, status: NPM_LOOKUP_STATUS.failed, reason: `GitLab: HTTP 404 sin ${registry.options?.tokenEnv ?? "credencial de lectura"}; no se puede distinguir un paquete ausente de un proyecto privado. Definí la credencial antes de diagnosticar.` };
33
+ }
34
+ return empty;
35
+ }
36
+ if (response.status >= 300 && response.status < 400) {
37
+ const location = response.headers.get("location");
38
+ const redirected = location ? new URL(location, url) : null;
39
+ await response.body?.cancel();
40
+ return registry.provider !== JSR_REGISTRY_PROVIDER && redirected && ["registry.npmjs.org", "npmjs.com", "www.npmjs.com"].includes(redirected.hostname)
41
+ ? empty
42
+ : { ...empty, status: NPM_LOOKUP_STATUS.failed, reason: `${registry.label}: la consulta se redirigió fuera del endpoint de metadata; no se confirmó el paquete.` };
43
+ }
44
+ if (!response.ok) {
45
+ await response.body?.cancel();
46
+ return { ...empty, status: NPM_LOOKUP_STATUS.failed, reason: `${registry.label}: GET de ${registry.packageName} respondió HTTP ${response.status}; revisá el endpoint y los permisos de lectura.` };
47
+ }
48
+ const metadata = /** @type {{ versions?: Record<string, unknown> | string[], "dist-tags"?: Record<string, string> } | null} */ (await response.json());
49
+ const versions = metadata?.versions;
50
+ if (!versions || typeof versions !== "object") {
51
+ return { ...empty, status: NPM_LOOKUP_STATUS.failed, reason: `${registry.label}: no se pudo leer la lista de versiones de ${registry.packageName}.` };
52
+ }
53
+ const publishedVersions = Array.isArray(versions) ? versions : Object.keys(versions);
54
+ const latestVersion = registry.provider === JSR_REGISTRY_PROVIDER
55
+ ? findHighestStableVersion(publishedVersions)
56
+ : metadata?.["dist-tags"]?.[registry.tag ?? "latest"] ?? null;
57
+ return { ...empty, publishedVersions, latestVersion: typeof latestVersion === "string" ? latestVersion : null };
58
+ } catch (error) {
59
+ const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
60
+ return { ...empty, status: NPM_LOOKUP_STATUS.failed, reason: `${registry.label}: ${timedOut ? "se agotó el tiempo" : "falló la conexión o la lectura de metadata"} al consultar ${registry.packageName}.` };
61
+ }
62
+ }