beez-rp 0.1.1 → 0.2.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.
@@ -0,0 +1,85 @@
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`.
4
+ *
5
+ * @module create-version/npm
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import path from "node:path";
10
+
11
+ import {
12
+ LOCAL_ENVIRONMENT_FILE,
13
+ NPM_DIST_TAG,
14
+ NPM_LOOKUP_STATUS,
15
+ NPM_NOT_FOUND_CODE,
16
+ NPM_PACKAGE_NAME_PATTERN,
17
+ NPM_TOKEN_VARIABLE,
18
+ } from "../constants/create-version.js";
19
+ import { USES_SHELL_FOR_PACKAGE_MANAGERS, runCaptured, runInherited } from "./process.js";
20
+
21
+ /**
22
+ * @typedef {{ status: string, publishedVersions: string[], reason: string | null }} NpmLookup
23
+ */
24
+
25
+ /**
26
+ * Lists the versions of a package published on npm.
27
+ *
28
+ * @param {string} packageName - npm package name.
29
+ * @param {string} repositoryRoot - Directory whose `.npmrc` npm reads.
30
+ * @returns {Promise<NpmLookup>} Published versions; a never-published package has none.
31
+ */
32
+ export async function lookupPublishedVersions(packageName, repositoryRoot) {
33
+ if (!NPM_PACKAGE_NAME_PATTERN.test(packageName)) {
34
+ return { status: NPM_LOOKUP_STATUS.failed, publishedVersions: [], reason: `nombre de paquete inválido: ${packageName}` };
35
+ }
36
+
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 });
41
+
42
+ if (result.status !== 0) {
43
+ return `${result.stdout}\n${result.stderr}`.includes(NPM_NOT_FOUND_CODE)
44
+ ? { status: NPM_LOOKUP_STATUS.ok, publishedVersions: [], reason: null }
45
+ : { status: NPM_LOOKUP_STATUS.failed, publishedVersions: [], reason: result.stderr.split("\n")[0] || `npm view salió con código ${result.status}` };
46
+ }
47
+
48
+ try {
49
+ const versions = JSON.parse(result.stdout);
50
+ return { status: NPM_LOOKUP_STATUS.ok, publishedVersions: Array.isArray(versions) ? versions : [versions], reason: null };
51
+ } catch (error) {
52
+ return {
53
+ status: NPM_LOOKUP_STATUS.failed,
54
+ publishedVersions: [],
55
+ reason: `respuesta inválida de npm view (${error instanceof Error ? error.message : String(error)})`,
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
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`.
63
+ *
64
+ * @param {string} repositoryRoot - Package root.
65
+ * @returns {Promise<{ exitCode: number, missingToken: boolean }>} npm exit code, or a missing-token result without running npm.
66
+ */
67
+ export async function publishToNpm(repositoryRoot) {
68
+ const environmentFilePath = path.join(repositoryRoot, LOCAL_ENVIRONMENT_FILE);
69
+
70
+ if (!process.env[NPM_TOKEN_VARIABLE] && existsSync(environmentFilePath)) {
71
+ process.loadEnvFile(environmentFilePath);
72
+ }
73
+
74
+ if (!process.env[NPM_TOKEN_VARIABLE]) {
75
+ return { exitCode: 1, missingToken: true };
76
+ }
77
+
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 });
83
+
84
+ return { exitCode, missingToken: false };
85
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Pure release planning of `beez-rp create-version`.
3
+ *
4
+ * Receives the snapshot gathered by `state.js` and the project capabilities,
5
+ * and decides without touching Git, npm or databases what is still missing
6
+ * to ship a release from `main`:
7
+ *
8
+ * - a new release: sync `main`, apply migrations, fill the changelog, run the
9
+ * checks, bump (commit `X.Y.Z` + tag `vX.Y.Z`), prepare, push and publish;
10
+ * - the resume of a release commit that never reached `origin` or the registry;
11
+ * - nothing, when every commit is already released;
12
+ * - blockers that explain what to fix first (feature branch, uncommitted
13
+ * files, foreign commits on `main`, unreachable registry, invalid changelog).
14
+ *
15
+ * @module create-version/plan
16
+ */
17
+
18
+ import { parseArgs } from "node:util";
19
+
20
+ import { CHANGE_TYPES, CHANGELOG_FILE, UNRELEASED_HEADING } from "../constants/changelog.js";
21
+ import {
22
+ CREATE_VERSION_FLAG,
23
+ MAIN_BRANCH,
24
+ MAX_LISTED_ITEMS,
25
+ MIGRATION_STATUS,
26
+ NPM_LOOKUP_STATUS,
27
+ PORCELAIN_STATUS_WIDTH,
28
+ PULL_REQUEST_STATE,
29
+ RELEASE_MODE,
30
+ RELEASE_STEP,
31
+ VERSION_PREFIX_PATTERN,
32
+ } from "../constants/create-version.js";
33
+ import { RELEASE_TYPE } from "../constants/versions.js";
34
+ import { isReleaseCommitSubject, isStableReleaseVersion, toReleaseTag } from "../versions.js";
35
+
36
+ /**
37
+ * @typedef {{ sha?: string, subject: string, body?: string }} ReleaseCommit
38
+ * @typedef {{ name: string, headSha: string, hasUpstream: boolean, unpushedCount: number, aheadOfMainCount: number }} FeatureBranchSnapshot
39
+ * @typedef {{ number: number, url: string, title?: string, state: string, isDraft: boolean, headRefOid: string }} PullRequestSnapshot
40
+ * @typedef {import("./npm.js").NpmLookup} NpmLookup
41
+ * @typedef {import("./config.js").MigrationCheck} MigrationCheck
42
+ * @typedef {{
43
+ * currentBranch: string | null,
44
+ * workingTreeChanges: string[],
45
+ * branch?: FeatureBranchSnapshot | null,
46
+ * pullRequest?: PullRequestSnapshot | null,
47
+ * githubError?: string | null,
48
+ * main: { aheadCommits: ReleaseCommit[], behindCount: number },
49
+ * headVersion: string | null,
50
+ * headSubject: string | null,
51
+ * unreleasedCommits: ReleaseCommit[],
52
+ * npm: NpmLookup | null,
53
+ * migrations: MigrationCheck | null,
54
+ * changelog: { exists: boolean, entryCount: number, unknownSections: string[] },
55
+ * }} ReleaseState
56
+ * @typedef {{ checks: boolean, prepare: boolean, publish: boolean, publishTitle: string }} ReleaseCapabilities
57
+ * @typedef {{ id: string, title: string, detail?: string }} ReleasePlanStep
58
+ * @typedef {{ title: string, details: string[] }} ReleaseBlocker
59
+ * @typedef {{ mode: string, steps: ReleasePlanStep[], blockers: ReleaseBlocker[], warnings: string[], pendingVersion: string | null }} ReleasePlan
60
+ * @typedef {{ bump: "patch" | "minor" | "major" | null, setVersion: string | null, dryRun: boolean, help: boolean }} ReleaseOptions
61
+ */
62
+
63
+ /** Capabilities of a project without checks, preparation or publication. */
64
+ export const DEFAULT_CAPABILITIES = Object.freeze({ checks: false, prepare: false, publish: false, publishTitle: "Publicar el release" });
65
+
66
+ /** Usage printed by `create-version --help`. */
67
+ export const RELEASE_USAGE = [
68
+ "Uso: pnpm create-version [opciones]",
69
+ "",
70
+ " --bump patch|minor|major Elige el tipo de versión sin preguntar.",
71
+ " --set-version X.Y.Z Fija la versión exacta (solo el siguiente patch, minor o major).",
72
+ " --dry-run Diagnostica y muestra el plan sin cambiar nada.",
73
+ " --help Muestra esta ayuda.",
74
+ ].join("\n");
75
+
76
+ /**
77
+ * Parses the command-line arguments of `create-version`.
78
+ *
79
+ * @param {string[]} argv - Arguments after the command name.
80
+ * @returns {ReleaseOptions} Options.
81
+ * @throws {Error} With a Spanish message when an argument is unknown or invalid.
82
+ */
83
+ export function parseReleaseArguments(argv) {
84
+ let values;
85
+
86
+ try {
87
+ ({ values } = parseArgs({
88
+ args: argv.filter((argument) => argument !== CREATE_VERSION_FLAG.endOfOptions),
89
+ options: {
90
+ [CREATE_VERSION_FLAG.bump]: { type: "string" },
91
+ [CREATE_VERSION_FLAG.setVersion]: { type: "string" },
92
+ [CREATE_VERSION_FLAG.dryRun]: { type: "boolean", default: false },
93
+ [CREATE_VERSION_FLAG.help]: { type: "boolean", short: CREATE_VERSION_FLAG.helpShort, default: false },
94
+ },
95
+ }));
96
+ } catch (error) {
97
+ throw new Error(`Opción inválida: ${error instanceof Error ? error.message : String(error)}. Usá --help para ver las opciones.`, { cause: error });
98
+ }
99
+
100
+ const releaseTypes = /** @type {string[]} */ (Object.values(RELEASE_TYPE));
101
+ const bump = /** @type {string | undefined} */ (values[CREATE_VERSION_FLAG.bump]);
102
+ const setVersion = /** @type {string | undefined} */ (values[CREATE_VERSION_FLAG.setVersion]);
103
+
104
+ if (bump !== undefined && !releaseTypes.includes(bump)) {
105
+ throw new Error(`--bump espera ${releaseTypes.join("|")} y recibió "${bump}".`);
106
+ }
107
+
108
+ if (bump !== undefined && setVersion !== undefined) {
109
+ throw new Error("Usá --bump o --set-version, no los dos a la vez.");
110
+ }
111
+
112
+ return {
113
+ bump: /** @type {ReleaseOptions["bump"]} */ (bump ?? null),
114
+ // A typed `v1.2.0` means `1.2.0`; the version rules validate the rest.
115
+ setVersion: setVersion === undefined ? null : setVersion.replace(VERSION_PREFIX_PATTERN, ""),
116
+ dryRun: Boolean(values[CREATE_VERSION_FLAG.dryRun]),
117
+ help: Boolean(values[CREATE_VERSION_FLAG.help]),
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Describes what is still missing for a feature branch to reach `main`, so
123
+ * the blocker tells the user the next concrete action.
124
+ *
125
+ * @param {FeatureBranchSnapshot} branch - Branch snapshot.
126
+ * @param {PullRequestSnapshot | null} pullRequest - Pull request of the branch.
127
+ * @param {string | null} githubError - Why the pull request could not be read.
128
+ * @returns {string[]} Spanish lines, most urgent first.
129
+ */
130
+ export function describeFeatureBranchGaps(branch, pullRequest, githubError) {
131
+ if (pullRequest?.state === PULL_REQUEST_STATE.merged && branch.headSha === pullRequest.headRefOid) {
132
+ return [`El PR #${pullRequest.number} ya está mergeado: hacé git switch ${MAIN_BRANCH}.`];
133
+ }
134
+
135
+ if (branch.aheadOfMainCount === 0) {
136
+ return [`La rama no tiene commits nuevos respecto de ${MAIN_BRANCH}: hacé git switch ${MAIN_BRANCH}.`];
137
+ }
138
+
139
+ const gaps = [];
140
+
141
+ if (!branch.hasUpstream) {
142
+ gaps.push(`La rama nunca se subió: git push -u origin ${branch.name}.`);
143
+ } else if (branch.unpushedCount > 0) {
144
+ gaps.push(`${branch.unpushedCount} commit(s) sin subir: git push.`);
145
+ }
146
+
147
+ if (githubError) {
148
+ gaps.push(`No se pudo consultar el PR (${githubError}).`);
149
+ } else if (pullRequest?.state === PULL_REQUEST_STATE.merged) {
150
+ gaps.push(`El PR #${pullRequest.number} ya se mergeó, pero la rama tiene commits posteriores: abrí un PR nuevo.`);
151
+ } else if (pullRequest?.state === PULL_REQUEST_STATE.open) {
152
+ const draftNote = pullRequest.isDraft ? " (está en borrador)" : "";
153
+ gaps.push(`Falta mergear el PR #${pullRequest.number}${draftNote}: ${pullRequest.url}`);
154
+ } else {
155
+ gaps.push(`Falta abrir el PR contra ${MAIN_BRANCH}: gh pr create --fill.`);
156
+ }
157
+
158
+ gaps.push(`Después hacé git switch ${MAIN_BRANCH} y corré pnpm create-version.`);
159
+
160
+ return gaps;
161
+ }
162
+
163
+ /**
164
+ * Lists the blockers that must be fixed before any release step runs.
165
+ *
166
+ * @param {ReleaseState} state - Snapshot.
167
+ * @returns {ReleaseBlocker[]} Blockers, most urgent first.
168
+ */
169
+ function findBlockers(state) {
170
+ if (!state.currentBranch) {
171
+ return [{ title: "HEAD está desacoplado (detached)", details: [`Hacé git switch ${MAIN_BRANCH} y volvé a correr pnpm create-version.`] }];
172
+ }
173
+
174
+ /** @type {ReleaseBlocker[]} */
175
+ const blockers = [];
176
+
177
+ if (state.currentBranch !== MAIN_BRANCH) {
178
+ blockers.push({
179
+ title: `Estás en ${state.currentBranch}: los releases salen solo desde ${MAIN_BRANCH}`,
180
+ details: state.branch
181
+ ? describeFeatureBranchGaps(state.branch, state.pullRequest ?? null, state.githubError ?? null)
182
+ : [`Hacé git switch ${MAIN_BRANCH} y volvé a correr pnpm create-version.`],
183
+ });
184
+ }
185
+
186
+ // CHANGELOG.md may be uncommitted: it travels in the release commit.
187
+ const blockingChanges = state.workingTreeChanges.filter((line) => line.slice(PORCELAIN_STATUS_WIDTH) !== CHANGELOG_FILE);
188
+
189
+ if (blockingChanges.length > 0) {
190
+ blockers.push({
191
+ title: `Hay ${blockingChanges.length} archivo(s) sin commitear`,
192
+ details: [...blockingChanges.slice(0, MAX_LISTED_ITEMS), "Commitealos en una rama (o git stash) y volvé a correr pnpm create-version."],
193
+ });
194
+ }
195
+
196
+ if (state.npm && state.npm.status !== NPM_LOOKUP_STATUS.ok) {
197
+ blockers.push({
198
+ title: "No se pudo consultar npm",
199
+ details: [`${state.npm.reason ?? "npm no respondió"}.`, "Revisá la conexión y volvé a correr pnpm create-version."],
200
+ });
201
+ }
202
+
203
+ return blockers;
204
+ }
205
+
206
+ /**
207
+ * Builds the blocker for local `main` commits that are not release commits.
208
+ *
209
+ * @param {ReleaseCommit[]} commits - Foreign commits.
210
+ * @returns {ReleaseBlocker} Blocker.
211
+ */
212
+ function foreignCommitsBlocker(commits) {
213
+ return {
214
+ title: `${MAIN_BRANCH} local tiene ${commits.length} commit(s) que no están en origin`,
215
+ details: [
216
+ ...commits.slice(0, MAX_LISTED_ITEMS).map((commit) => `· ${commit.subject}`),
217
+ "Movelos a una rama (git switch -c <rama>) y llevalos por un PR.",
218
+ ],
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Plans the steps still missing for a release commit already created at `HEAD`.
224
+ *
225
+ * @param {ReleaseState} state - Snapshot.
226
+ * @param {ReleaseCapabilities} capabilities - Project capabilities.
227
+ * @returns {ReleasePlan | null} Resume plan, or `null` when `HEAD` is not a pending release.
228
+ */
229
+ function planResume(state, capabilities) {
230
+ const { headVersion, headSubject, main, npm } = state;
231
+
232
+ if (!isStableReleaseVersion(headVersion) || headSubject?.trim() !== headVersion) {
233
+ return null;
234
+ }
235
+
236
+ const version = /** @type {string} */ (headVersion);
237
+ const pendingPush = main.aheadCommits.length > 0;
238
+ const pendingPublish = npm !== null && !npm.publishedVersions.includes(version);
239
+
240
+ if (!pendingPush && !pendingPublish) {
241
+ return null;
242
+ }
243
+
244
+ const foreignCommits = main.aheadCommits.filter((commit) => !isReleaseCommitSubject(commit.subject));
245
+
246
+ if (foreignCommits.length > 0) {
247
+ return { mode: RELEASE_MODE.blocked, steps: [], blockers: [foreignCommitsBlocker(foreignCommits)], warnings: [], pendingVersion: null };
248
+ }
249
+
250
+ const needsPublish = capabilities.publish && (npm !== null ? pendingPublish : pendingPush);
251
+ /** @type {ReleasePlanStep[]} */
252
+ const steps = [];
253
+
254
+ if (capabilities.prepare && (pendingPush || needsPublish)) {
255
+ steps.push({ id: RELEASE_STEP.prepareRelease, title: `Preparar el release ${version}`, detail: "El commit de versión ya existe: se vuelve a preparar lo necesario." });
256
+ }
257
+
258
+ if (pendingPush) {
259
+ steps.push({ id: RELEASE_STEP.pushRelease, title: `Subir ${MAIN_BRANCH} y ${toReleaseTag(version)} a origin`, detail: "El release quedó creado solo en local." });
260
+ }
261
+
262
+ if (needsPublish) {
263
+ steps.push({ id: RELEASE_STEP.publishRelease, title: `${capabilities.publishTitle} (${version})`, detail: "Solo falta lo que no se completó." });
264
+ }
265
+
266
+ return steps.length > 0 ? { mode: RELEASE_MODE.resume, steps, blockers: [], warnings: [], pendingVersion: version } : null;
267
+ }
268
+
269
+ /**
270
+ * Decides what is still missing to publish a release from `main`.
271
+ *
272
+ * @param {ReleaseState} state - Snapshot gathered by `state.js`.
273
+ * @param {ReleaseCapabilities} [capabilities] - Steps the project configured.
274
+ * @returns {ReleasePlan} Ordered plan.
275
+ */
276
+ export function buildReleasePlan(state, capabilities = DEFAULT_CAPABILITIES) {
277
+ const blockers = findBlockers(state);
278
+
279
+ if (blockers.length > 0) {
280
+ return { mode: RELEASE_MODE.blocked, steps: [], blockers, warnings: [], pendingVersion: null };
281
+ }
282
+
283
+ const resume = planResume(state, capabilities);
284
+
285
+ if (resume) {
286
+ return resume;
287
+ }
288
+
289
+ if (state.main.aheadCommits.length > 0) {
290
+ return { mode: RELEASE_MODE.blocked, steps: [], blockers: [foreignCommitsBlocker(state.main.aheadCommits)], warnings: [], pendingVersion: null };
291
+ }
292
+
293
+ if (state.unreleasedCommits.length === 0) {
294
+ return { mode: RELEASE_MODE.upToDate, steps: [], blockers: [], warnings: [], pendingVersion: null };
295
+ }
296
+
297
+ if (state.changelog.unknownSections.length > 0) {
298
+ return {
299
+ mode: RELEASE_MODE.blocked,
300
+ steps: [],
301
+ blockers: [
302
+ {
303
+ title: `CHANGELOG.md ${UNRELEASED_HEADING} usa secciones no válidas: ${state.changelog.unknownSections.join(", ")}`,
304
+ details: [`Usá solo ${CHANGE_TYPES.map((type) => `### ${type}`).join(", ")} y volvé a correr pnpm create-version.`],
305
+ },
306
+ ],
307
+ warnings: [],
308
+ pendingVersion: null,
309
+ };
310
+ }
311
+
312
+ /** @type {ReleasePlanStep[]} */
313
+ const steps = [];
314
+ /** @type {string[]} */
315
+ const warnings = [];
316
+
317
+ 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.` });
319
+ }
320
+
321
+ if (state.migrations?.status === MIGRATION_STATUS.pending) {
322
+ steps.push({
323
+ id: RELEASE_STEP.applyMigrations,
324
+ title: `Aplicar ${state.migrations.pending.length} migración(es) en la base de datos`,
325
+ detail: `Destino: ${state.migrations.target ?? "desconocido"} · se pide confirmación antes.`,
326
+ });
327
+ } else if (state.migrations?.status === MIGRATION_STATUS.unknown) {
328
+ warnings.push(`No se pudo verificar si hay migraciones pendientes: ${state.migrations.reason ?? "motivo desconocido"}.`);
329
+ }
330
+
331
+ if (state.changelog.entryCount === 0) {
332
+ steps.push({
333
+ id: RELEASE_STEP.generateChangelog,
334
+ title: `Completar ${UNRELEASED_HEADING} del CHANGELOG con Codex`,
335
+ detail: "Está vacío: Codex lo arma desde los commits sin publicar. Si no puede, el release se corta.",
336
+ });
337
+ }
338
+
339
+ if (capabilities.checks) {
340
+ steps.push({ id: RELEASE_STEP.runChecks, title: "Validar el proyecto", detail: "Corre los checks configurados antes de tocar la versión." });
341
+ }
342
+
343
+ steps.push({
344
+ id: RELEASE_STEP.bumpVersion,
345
+ title: "Elegir la nueva versión y crear commit + tag",
346
+ detail: `${UNRELEASED_HEADING} pasa a esa versión con la fecha de hoy y se commitea junto con package.json.`,
347
+ });
348
+
349
+ if (capabilities.prepare) {
350
+ steps.push({ id: RELEASE_STEP.prepareRelease, title: "Preparar el release", detail: "Corre la preparación configurada sobre el commit de versión." });
351
+ }
352
+
353
+ steps.push({ id: RELEASE_STEP.pushRelease, title: `Subir ${MAIN_BRANCH} y el tag a origin` });
354
+
355
+ if (capabilities.publish) {
356
+ steps.push({ id: RELEASE_STEP.publishRelease, title: capabilities.publishTitle });
357
+ }
358
+
359
+ return { mode: RELEASE_MODE.newRelease, steps, blockers: [], warnings, pendingVersion: null };
360
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Process and Git helpers of `beez-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 } from "node:child_process";
10
+
11
+ import { FIELD_SEPARATOR, PACKAGE_MANIFEST_FILE, RECORD_SEPARATOR } from "../constants/create-version.js";
12
+
13
+ /**
14
+ * @typedef {{ status: number, stdout: string, stderr: string }} CapturedResult
15
+ * @typedef {{ cwd?: string, shell?: boolean }} CommandOptions
16
+ * @typedef {{ git: (gitArguments: string[]) => Promise<string>, tryGit: (gitArguments: string[]) => Promise<string | null> }} GitReader
17
+ * @typedef {{ sha: string, subject: string, body: string }} CommitRecord
18
+ */
19
+
20
+ /** Windows resolves `pnpm.cmd`, `npm.cmd` and `codex.cmd` only through a shell. */
21
+ export const USES_SHELL_FOR_PACKAGE_MANAGERS = process.platform === "win32";
22
+
23
+ /**
24
+ * Runs a command and captures its output. Leading whitespace is kept because
25
+ * `git status --porcelain` encodes the file state in the first columns.
26
+ *
27
+ * @param {string} command - Executable name, or a full command line when `shell` is set.
28
+ * @param {string[]} commandArguments - Arguments.
29
+ * @param {CommandOptions} [options] - Spawn options.
30
+ * @returns {Promise<CapturedResult>} Result; never rejects.
31
+ */
32
+ export function runCaptured(command, commandArguments, options = {}) {
33
+ return new Promise((resolve) => {
34
+ const child = spawn(command, commandArguments, {
35
+ cwd: options.cwd,
36
+ shell: options.shell ?? false,
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ windowsHide: true,
39
+ });
40
+ let stdout = "";
41
+ let stderr = "";
42
+
43
+ child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
44
+ child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
45
+ child.on("error", (error) => resolve({ status: 1, stdout, stderr: error.message }));
46
+ child.on("close", (status) => resolve({ status: status ?? 1, stdout: stdout.trimEnd(), stderr: stderr.trim() }));
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Runs a command with inherited stdio so its progress stays visible.
52
+ *
53
+ * @param {string} command - Executable name, or a full command line when `shell` is set.
54
+ * @param {string[]} commandArguments - Arguments.
55
+ * @param {CommandOptions} [options] - Spawn options.
56
+ * @returns {Promise<number>} Exit code; never rejects.
57
+ */
58
+ export function runInherited(command, commandArguments, options = {}) {
59
+ return new Promise((resolve) => {
60
+ const child = spawn(command, commandArguments, { cwd: options.cwd, shell: options.shell ?? false, stdio: "inherit" });
61
+ child.on("error", () => resolve(1));
62
+ child.on("close", (status) => resolve(status ?? 1));
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Runs a trusted, configured command line (for example `pnpm check`) through the shell.
68
+ *
69
+ * @param {string} commandLine - Command line written in the project configuration, never user input.
70
+ * @param {string} cwd - Working directory.
71
+ * @returns {Promise<number>} Exit code; never rejects.
72
+ */
73
+ export function runCommandLine(commandLine, cwd) {
74
+ return runInherited(commandLine, [], { cwd, shell: true });
75
+ }
76
+
77
+ /**
78
+ * Creates a Git reader bound to a repository.
79
+ *
80
+ * @param {string} repositoryRoot - Repository root.
81
+ * @returns {GitReader} Readers.
82
+ */
83
+ export function createGitReader(repositoryRoot) {
84
+ /** @param {string[]} gitArguments */
85
+ const tryGit = async (gitArguments) => {
86
+ const result = await runCaptured("git", gitArguments, { cwd: repositoryRoot });
87
+ return result.status === 0 ? result.stdout : null;
88
+ };
89
+
90
+ /** @param {string[]} gitArguments */
91
+ const git = async (gitArguments) => {
92
+ const result = await runCaptured("git", gitArguments, { cwd: repositoryRoot });
93
+
94
+ if (result.status !== 0) {
95
+ throw new Error(`beez-rp create-version: git ${gitArguments.join(" ")} failed in ${repositoryRoot}: ${result.stderr}`);
96
+ }
97
+
98
+ return result.stdout;
99
+ };
100
+
101
+ return { git, tryGit };
102
+ }
103
+
104
+ /**
105
+ * Parses `git log` records produced with {@link FIELD_SEPARATOR} and {@link RECORD_SEPARATOR}.
106
+ *
107
+ * @param {string} output - Raw `git log` output.
108
+ * @returns {CommitRecord[]} Commits, newest first.
109
+ */
110
+ export function parseCommitLog(output) {
111
+ return output
112
+ .split(RECORD_SEPARATOR)
113
+ .map((record) => record.trim())
114
+ .filter(Boolean)
115
+ .map((record) => {
116
+ const [sha = "", subject = "", body = ""] = record.split(FIELD_SEPARATOR);
117
+ return { sha, subject, body: body.trim() };
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Lists the commits of a revision range.
123
+ *
124
+ * @param {GitReader} reader - Git reader.
125
+ * @param {string} range - Revision range such as `abc..origin/main`.
126
+ * @returns {Promise<CommitRecord[]>} Commits, newest first.
127
+ */
128
+ export async function listCommits(reader, range) {
129
+ const output = await reader.tryGit(["log", `--format=%H${FIELD_SEPARATOR}%s${FIELD_SEPARATOR}%b${RECORD_SEPARATOR}`, range]);
130
+ return output ? parseCommitLog(output) : [];
131
+ }
132
+
133
+ /**
134
+ * Reads the `version` field of `package.json` at a revision.
135
+ *
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.
139
+ */
140
+ export async function readPackageVersionAt(reader, revision) {
141
+ const manifest = await reader.tryGit(["show", `${revision}:${PACKAGE_MANIFEST_FILE}`]);
142
+
143
+ try {
144
+ return manifest ? (JSON.parse(manifest).version ?? null) : null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }