@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,199 @@
1
+ /**
2
+ * Adapts GitHub CLI for credential preflight and dispatch reconciliation.
3
+ * @module create-version/github-workflow
4
+ */
5
+
6
+ import {
7
+ CI_DIAGNOSTIC_LIMIT, CI_DISPATCH_STATUS, CI_FAILURE_CODE, CI_GITHUB_TOKEN_VARIABLES, CI_ORGANIZATION_BINDING_PAGE_SIZE,
8
+ CI_ORGANIZATION_BINDING_RESOURCE, CI_RELEASE_RUN_TITLE_PREFIX, CI_RUN_LOOKUP_LIMIT,
9
+ } from "../constants/ci-release.js";
10
+ import { ciWorkflowPath } from "./ci-config.js";
11
+ import { GITHUB_REPOSITORY_PATTERN, MAIN_BRANCH, RELEASE_REMOTE, REMOTE_MAIN_REF } from "../constants/create-version.js";
12
+ import { CiReleaseError } from "./errors.js";
13
+ import { runCaptured } from "./process.js";
14
+
15
+ /**
16
+ * @typedef {{ version: string, tag: string, sha: string }} CiReleaseIdentity
17
+ * @typedef {{ status: "submitted" | "existing", url: string | null }} CiDispatchResult
18
+ * @typedef {"secret" | "variable"} ActionsBindingKind
19
+ * @typedef {{
20
+ * preflight: (workflow: string, secrets: string[], variables: string[]) => Promise<void>,
21
+ * dispatch: (workflow: string, release: CiReleaseIdentity, retryCommand: string) => Promise<CiDispatchResult>,
22
+ * }} GithubWorkflowClient
23
+ */
24
+
25
+ /**
26
+ * Builds the `run-name` the generated workflow gives a dispatched release, which identifies its runs.
27
+ * @param {CiReleaseIdentity} release - Dispatched release.
28
+ * @returns {string} Run title, such as `astra-rp release v1.2.4 <sha>`.
29
+ */
30
+ export function formatCiReleaseRunTitle(release) {
31
+ return `${CI_RELEASE_RUN_TITLE_PREFIX} ${release.tag} ${release.sha}`;
32
+ }
33
+
34
+ /**
35
+ * Redacts inherited GitHub credentials and bounds external command diagnostics.
36
+ * @param {string} diagnostic - GitHub CLI diagnostic.
37
+ * @returns {string} Safe terminal diagnostic.
38
+ */
39
+ function safeDiagnostic(diagnostic) {
40
+ return CI_GITHUB_TOKEN_VARIABLES.reduce((output, name) => process.env[name] ? output.replaceAll(/** @type {string} */ (process.env[name]), "[redactado]") : output, diagnostic).slice(0, CI_DIAGNOSTIC_LIMIT);
41
+ }
42
+
43
+ /**
44
+ * Builds the repository-level commands that would configure the missing bindings.
45
+ * @param {ActionsBindingKind} kind - Actions binding kind.
46
+ * @param {string[]} names - Missing binding names.
47
+ * @param {string} repository - `owner/name` repository.
48
+ * @returns {string} Commands joined for a terminal hint.
49
+ */
50
+ function repositorySetCommands(kind, names, repository) {
51
+ return names.map((name) => `gh ${kind} set ${name} --repo ${repository}`).join("; ");
52
+ }
53
+
54
+ /**
55
+ * Creates a GitHub workflow adapter with an explicit subprocess boundary.
56
+ * @param {string} repositoryRoot - Checkout whose origin selects the repository.
57
+ * @param {typeof runCaptured} [capture] - Process executor, injectable by other CLI hosts.
58
+ * @returns {GithubWorkflowClient} Preflight and dispatch operations.
59
+ */
60
+ export function createGithubWorkflowClient(repositoryRoot, capture = runCaptured) {
61
+ let repository = "";
62
+
63
+ /** @param {string[]} args - GitHub CLI arguments. @returns {ReturnType<typeof runCaptured>} Captured result. */
64
+ const github = (args) => capture("gh", args, { cwd: repositoryRoot });
65
+
66
+ /**
67
+ * Lists matching accepted runs before a dispatch is repeated.
68
+ * @param {string} workflow - Workflow filename.
69
+ * @param {CiReleaseIdentity} release - Existing immutable release.
70
+ * @param {string} retryCommand - Recovery command preserving the tag.
71
+ * @returns {Promise<{ status: string, conclusion: string | null, url: string } | null>} Existing active or successful run.
72
+ */
73
+ async function findAcceptedRun(workflow, release, retryCommand) {
74
+ const result = await github(["run", "list", "--repo", repository, "--workflow", workflow, "--event", "workflow_dispatch", "--limit", String(CI_RUN_LOOKUP_LIMIT), "--json", "displayTitle,status,conclusion,url"]);
75
+ if (result.status !== 0) throw new CiReleaseError(CI_FAILURE_CODE.lookup, `No se pudo verificar si ${release.tag} ya tiene una ejecución de ${workflow}: ${safeDiagnostic(result.stderr)}`, `No se envió otra ejecución. Revisá Actions y retomá con ${retryCommand} cuando se pueda consultar el estado.`);
76
+ const runs = JSON.parse(result.stdout);
77
+ return runs.find((/** @type {{ displayTitle: string, status: string, conclusion: string | null }} */ run) => run.displayTitle === formatCiReleaseRunTitle(release) && (run.status !== "completed" || run.conclusion === "success")) ?? null;
78
+ }
79
+
80
+ /**
81
+ * Lists organization bindings of one kind that GitHub shares with the repository, honoring their visibility.
82
+ * @param {ActionsBindingKind} kind - Actions binding kind.
83
+ * @param {string} workflow - Workflow to be dispatched, for diagnostics.
84
+ * @param {string[]} missing - Names absent at repository level, for diagnostics.
85
+ * @returns {Promise<string[]>} Binding names available to the repository through its organization.
86
+ * @throws {CiReleaseError} When organization bindings cannot be listed; the missing names stay unverified.
87
+ */
88
+ async function listOrganizationBindings(kind, workflow, missing) {
89
+ const resource = CI_ORGANIZATION_BINDING_RESOURCE[kind];
90
+ const result = await github(["api", "--paginate", `repos/${repository}/actions/${resource.path}?per_page=${CI_ORGANIZATION_BINDING_PAGE_SIZE}`, "--jq", `.${resource.collection}[].name`]);
91
+ if (result.status !== 0) throw new CiReleaseError(CI_FAILURE_CODE.preflight, `Faltan ${kind}s de Actions para ${workflow} a nivel repositorio (${missing.join(", ")}) y no se pudieron verificar los ${kind}s de organización compartidos con ${repository}: ${safeDiagnostic(result.stderr)}`, `Dale a gh permiso para leer los ${kind}s de Actions de ${repository} o configurá ${repositorySetCommands(kind, missing, repository)}; no se tocó la versión.`);
92
+ return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
93
+ }
94
+
95
+ /**
96
+ * Tells whether a revision of the checkout contains a file.
97
+ * @param {string} revision - Ref such as `refs/tags/v1.2.0`.
98
+ * @param {string} filePath - Root-relative path.
99
+ * @returns {Promise<boolean>} True when `git cat-file -e` finds the file at that revision.
100
+ */
101
+ async function hasFileAt(revision, filePath) {
102
+ return (await capture("git", ["cat-file", "-e", `${revision}:${filePath}`], { cwd: repositoryRoot })).status === 0;
103
+ }
104
+
105
+ /**
106
+ * Requires the release tag to contain the workflow, since dispatching with `--ref <tag>` runs the
107
+ * definition stored in that tag instead of whatever `main` holds today.
108
+ * @param {string} workflow - Workflow filename.
109
+ * @param {CiReleaseIdentity} release - Existing immutable release.
110
+ * @param {string} retryCommand - Recovery command preserving the tag.
111
+ * @returns {Promise<void>} Resolves when the tag carries the workflow.
112
+ * @throws {CiReleaseError} When the tag has no such workflow; nothing is dispatched.
113
+ */
114
+ async function assertWorkflowInReleaseTag(workflow, release, retryCommand) {
115
+ const workflowPath = ciWorkflowPath(workflow);
116
+ if (!(await hasFileAt(`refs/tags/${release.tag}`, workflowPath))) throw new CiReleaseError(CI_FAILURE_CODE.preflight, `${release.tag} no contiene ${workflowPath}; el release solo se ejecuta con el workflow de su propio tag, no con el de ${MAIN_BRANCH}.`, `No se envió ninguna ejecución. Publicá ${release.tag} con --local, o traé el tag con git fetch ${RELEASE_REMOTE} tag ${release.tag} si falta localmente y volvé a correr ${retryCommand}.`);
117
+ }
118
+
119
+ /**
120
+ * Requires the workflow file to exist on the default branch as well: GitHub only triggers
121
+ * `workflow_dispatch` for workflows present on the default branch, even when `--ref <tag>` selects
122
+ * the definition stored in the tag, so a workflow renamed or deleted on `main` after the release can
123
+ * no longer be dispatched under its old name. Reads `origin/main` as last fetched by the release flow.
124
+ * @param {string} workflow - Workflow filename.
125
+ * @param {CiReleaseIdentity} release - Existing immutable release.
126
+ * @param {string} retryCommand - Recovery command preserving the tag.
127
+ * @returns {Promise<void>} Resolves when the default branch carries the workflow.
128
+ * @throws {CiReleaseError} When the default branch has no such workflow; nothing is dispatched.
129
+ */
130
+ async function assertWorkflowOnDefaultBranch(workflow, release, retryCommand) {
131
+ const workflowPath = ciWorkflowPath(workflow);
132
+ if (!(await hasFileAt(`refs/remotes/${REMOTE_MAIN_REF}`, workflowPath))) throw new CiReleaseError(CI_FAILURE_CODE.preflight, `${REMOTE_MAIN_REF} no contiene ${workflowPath}: GitHub solo dispara workflow_dispatch para workflows que existen en la rama por defecto (${MAIN_BRANCH}), aunque ${release.tag} conserve el suyo.`, `No se envió ninguna ejecución. Restaurá ${workflowPath} con ese nombre en ${MAIN_BRANCH} y volvé a correr ${retryCommand}, o publicá ${release.tag} con --local.`);
133
+ }
134
+
135
+ return {
136
+ /**
137
+ * Checks GitHub CLI, authentication, default branch and configured worker credentials.
138
+ * @param {string} workflow - Workflow to be dispatched.
139
+ * @param {string[]} secrets - Secret names required by the worker, at repository or shared organization level.
140
+ * @param {string[]} variables - Variable names required by the worker, at repository or shared organization level.
141
+ * @returns {Promise<void>} Resolves before any bump when dispatch prerequisites are available.
142
+ * @throws {CiReleaseError} When a prerequisite is missing or cannot be checked.
143
+ */
144
+ async preflight(workflow, secrets, variables) {
145
+ const origin = await capture("git", ["remote", "get-url", RELEASE_REMOTE], { cwd: repositoryRoot });
146
+ repository = GITHUB_REPOSITORY_PATTERN.exec(origin.stdout)?.[1] ?? "";
147
+ if (!repository) throw new CiReleaseError(CI_FAILURE_CODE.preflight, "El modo CI requiere un origin de GitHub.", "Configurá el remoto de GitHub o elegí --local; no se tocó la versión.");
148
+ const installed = await github(["--version"]);
149
+ if (installed.status !== 0) throw new CiReleaseError(CI_FAILURE_CODE.preflight, "No se pudo ejecutar GitHub CLI (gh).", "Instalá gh y ejecutá gh auth login, o elegí --local.");
150
+ const authenticated = await github(["auth", "status", "--hostname", "github.com"]);
151
+ if (authenticated.status !== 0) throw new CiReleaseError(CI_FAILURE_CODE.preflight, `gh no está autenticado para disparar ${workflow}.`, "Ejecutá gh auth login con acceso al repo; no se tocó la versión.");
152
+ const metadata = await github(["repo", "view", repository, "--json", "defaultBranchRef,isInOrganization"]);
153
+ if (metadata.status !== 0) throw new CiReleaseError(CI_FAILURE_CODE.preflight, `No se pudo consultar ${repository}: ${safeDiagnostic(metadata.stderr)}`, "Verificá el acceso del usuario de gh al repo.");
154
+ const repositoryMetadata = JSON.parse(metadata.stdout);
155
+ if (repositoryMetadata.defaultBranchRef?.name !== MAIN_BRANCH) throw new CiReleaseError(CI_FAILURE_CODE.preflight, "workflow_dispatch requiere que el workflow exista en la rama por defecto, que en este flujo debe ser main.", "Usá main como rama por defecto o ejecutá el release con --local.");
156
+ const ownedByOrganization = repositoryMetadata.isInOrganization === true;
157
+ for (const [kind, required] of /** @type {[ActionsBindingKind, string[]][]} */ ([["secret", secrets], ["variable", variables]])) {
158
+ if (required.length === 0) continue;
159
+ const listed = await github([kind, "list", "--repo", repository, "--json", "name"]);
160
+ if (listed.status !== 0) throw new CiReleaseError(CI_FAILURE_CODE.preflight, `No se pudieron verificar los ${kind}s de CI en ${repository}.`, `Revisá los permisos de gh y los ${kind}s de Actions; no se tocó la versión.`);
161
+ const available = JSON.parse(listed.stdout).map((/** @type {{ name: string }} */ entry) => entry.name);
162
+ let missing = required.filter((name) => !available.includes(name));
163
+ if (missing.length > 0 && ownedByOrganization) {
164
+ const shared = await listOrganizationBindings(kind, workflow, missing);
165
+ missing = missing.filter((name) => !shared.includes(name));
166
+ }
167
+ if (missing.length > 0) {
168
+ const organizationHint = ownedByOrganization ? ` o compartí el ${kind} de organización con ${repository}` : "";
169
+ throw new CiReleaseError(CI_FAILURE_CODE.preflight, `Faltan ${kind}s de Actions para ${workflow}: ${missing.join(", ")}.`, `Configurá ${repositorySetCommands(kind, missing, repository)}${organizationHint} y volvé a correr el comando.`);
170
+ }
171
+ }
172
+ },
173
+
174
+ /**
175
+ * Submits the exact pushed tag pinned to the workflow version stored in that tag (`--ref <tag>`), so a
176
+ * retry never runs a newer workflow from `main` against the older tagged checkout; reconciles a failed
177
+ * response without automatic resubmission.
178
+ * @param {string} workflow - Workflow filename.
179
+ * @param {CiReleaseIdentity} release - Version, tag and commit already on origin.
180
+ * @param {string} retryCommand - Explicit retry command that never creates another bump.
181
+ * @returns {Promise<CiDispatchResult>} Submission state, never a claim that publication finished.
182
+ * @throws {CiReleaseError} When acceptance cannot be confirmed.
183
+ */
184
+ async dispatch(workflow, release, retryCommand) {
185
+ const existing = await findAcceptedRun(workflow, release, retryCommand);
186
+ if (existing) return { status: CI_DISPATCH_STATUS.existing, url: existing.url };
187
+ await assertWorkflowInReleaseTag(workflow, release, retryCommand);
188
+ await assertWorkflowOnDefaultBranch(workflow, release, retryCommand);
189
+ const dispatched = await github(["workflow", "run", workflow, "--repo", repository, "--ref", release.tag, "-f", `version=${release.version}`, "-f", `tag=${release.tag}`, "-f", `sha=${release.sha}`]);
190
+ if (dispatched.status !== 0) {
191
+ let accepted = null;
192
+ try { accepted = await findAcceptedRun(workflow, release, retryCommand); } catch { /* The original dispatch remains unconfirmed; do not repeat it. */ }
193
+ if (accepted) return { status: CI_DISPATCH_STATUS.existing, url: accepted.url };
194
+ throw new CiReleaseError(CI_FAILURE_CODE.dispatch, `${release.tag} ya está en origin, pero no se confirmó el inicio de ${workflow}: ${safeDiagnostic(dispatched.stderr)}`, `Revisá Actions antes de reenviar con ${retryCommand}; no hace otro bump ni repite el push.`);
195
+ }
196
+ return { status: CI_DISPATCH_STATUS.submitted, url: `https://github.com/${repository}/actions/workflows/${workflow}` };
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `@astra-kit/rp/create-version`: shared release command.
3
+ *
4
+ * Projects run it as `astra-rp create-version` and describe their differences
5
+ * in `astra-rp.config.js` (checks, migrations,
6
+ * preparation, publication). The pure planner and the state reader are
7
+ * exported for project tests and custom tooling.
8
+ *
9
+ * @module create-version
10
+ */
11
+
12
+ /**
13
+ * @typedef {import("./config.js").CreateVersionConfig} CreateVersionConfig
14
+ * @typedef {import("./ci-config.js").CiReleaseConfig} CiReleaseConfig
15
+ */
16
+
17
+ export {
18
+ computeNpmIntegrity,
19
+ computeSha256,
20
+ expandArtifactPattern,
21
+ findPnpmPackRewrites,
22
+ findPreparedArtifact,
23
+ isSafeArtifactPath,
24
+ verifyPreparedArtifact,
25
+ withArtifactOutsidePackageRoot,
26
+ } from "./artifact.js";
27
+ export { defineCreateVersionConfig, loadCreateVersionConfig, resolveCreateVersionConfig } from "./config.js";
28
+ export { CiReleaseError, ReleaseStepError } from "./errors.js";
29
+ export { resolveCiReleaseConfig } from "./ci-config.js";
30
+ export {
31
+ buildNpmAuthConfigLine,
32
+ buildNpmOwnerListArguments,
33
+ buildNpmPublishArguments,
34
+ buildNpmPublishEnvironment,
35
+ buildNpmTokenEnvironment,
36
+ buildNpmViewArguments,
37
+ buildNpmWhoamiArguments,
38
+ checkNpmPublishAccess,
39
+ lookupPublishedVersions,
40
+ parseNpmOwnerList,
41
+ parseNpmPackDryRunOutput,
42
+ publishToNpm,
43
+ readNpmPackIntegrity,
44
+ resolveNpmToken,
45
+ resolvePublishRegistry,
46
+ withNpmAuthConfig,
47
+ } from "./npm.js";
48
+ export { describeNpmAuthProblem, describeNpmPublishFailure, describeNpmTokenSource } from "./npm-auth.js";
49
+ export { DEFAULT_CAPABILITIES, RELEASE_USAGE, buildReleasePlan, describeFeatureBranchGaps, parseReleaseArguments } from "./plan.js";
50
+ export { createGitReader, listCommits, parseCommitLog, readPackageManifestAt, readPackageVersionAt, runCaptured, runCommandLine, runInherited } from "./process.js";
51
+ export { createHookContext, runCreateVersion } from "./run.js";
52
+ export { collectReleaseState, findLastRelease, readChangelogState } from "./state.js";
53
+ export { isRegistryProvider, resolvePublicationOptions } from "./registry-config.js";
54
+ export { checkRegistryAccess, lookupRegistryVersions, resolveRegistry, selectProjectRegistry } from "./registry.js";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * JSON helpers shared by the release modes: plain-object checks and manifest reads.
3
+ *
4
+ * @module create-version/json
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import path from "node:path";
9
+
10
+ import { PACKAGE_MANIFEST_FILE } from "../constants/create-version.js";
11
+
12
+ /**
13
+ * Tells whether a value is a plain JSON object.
14
+ *
15
+ * @param {unknown} value - Any value.
16
+ * @returns {value is Record<string, unknown>} Whether it is a non-array object.
17
+ */
18
+ export function isRecord(value) {
19
+ return value !== null && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+
22
+ /**
23
+ * Reads and parses a JSON manifest from the working tree; read and parse errors propagate as they are.
24
+ *
25
+ * @param {string} directory - Directory the manifest path is relative to.
26
+ * @param {string} [manifestPath] - Manifest path; the `package.json` of the directory by default.
27
+ * @returns {any} Parsed manifest.
28
+ * @throws {Error} When the file cannot be read or is not valid JSON.
29
+ */
30
+ export function readJsonManifest(directory, manifestPath = PACKAGE_MANIFEST_FILE) {
31
+ return JSON.parse(readFileSync(path.join(directory, manifestPath), "utf8"));
32
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Parses native registry JSONC manifests while preserving character offsets for version-only edits.
3
+ *
4
+ * @module create-version/jsonc
5
+ */
6
+
7
+ import { isRecord } from "./json.js";
8
+
9
+ /**
10
+ * Replaces comments and trailing commas with whitespace, without touching string values or offsets.
11
+ *
12
+ * @param {string} content - JSON or JSONC text.
13
+ * @returns {string} JSON text with the same length and line breaks.
14
+ * @throws {Error} When a block comment is unterminated.
15
+ */
16
+ export function normalizeJsonc(content) {
17
+ const characters = content.split("");
18
+ let quoted = false;
19
+ let escaped = false;
20
+ let lineComment = false;
21
+ let blockComment = false;
22
+ if (characters[0] === "\uFEFF") characters[0] = " ";
23
+ for (let index = 0; index < characters.length; index += 1) {
24
+ const character = characters[index];
25
+ const next = characters[index + 1];
26
+ if (lineComment || blockComment) {
27
+ if (blockComment && character === "*" && next === "/") {
28
+ characters[index] = characters[index + 1] = " ";
29
+ blockComment = false;
30
+ index += 1;
31
+ } else if (character === "\n" || character === "\r") {
32
+ lineComment = false;
33
+ } else characters[index] = " ";
34
+ } else if (quoted) {
35
+ if (escaped) escaped = false;
36
+ else if (character === "\\") escaped = true;
37
+ else if (character === '"') quoted = false;
38
+ } else if (character === '"') quoted = true;
39
+ else if (character === "/" && (next === "/" || next === "*")) {
40
+ lineComment = next === "/";
41
+ blockComment = next === "*";
42
+ characters[index] = characters[index + 1] = " ";
43
+ index += 1;
44
+ }
45
+ }
46
+ if (blockComment) throw new Error("Unterminated JSONC block comment");
47
+ quoted = false;
48
+ escaped = false;
49
+ for (let index = 0; index < characters.length; index += 1) {
50
+ const character = characters[index];
51
+ if (quoted) {
52
+ if (escaped) escaped = false;
53
+ else if (character === "\\") escaped = true;
54
+ else if (character === '"') quoted = false;
55
+ } else if (character === '"') quoted = true;
56
+ else if (character === ",") {
57
+ let following = index + 1;
58
+ while (/\s/u.test(characters[following] ?? "") && following < characters.length) following += 1;
59
+ if (characters[following] === "]" || characters[following] === "}") characters[index] = " ";
60
+ }
61
+ }
62
+ return characters.join("");
63
+ }
64
+
65
+ /**
66
+ * Reads a native JSONC manifest as JSON, preserving comments in the caller's source text.
67
+ *
68
+ * @param {string} content - JSONC manifest text.
69
+ * @returns {Record<string, unknown>} Parsed manifest.
70
+ * @throws {Error} When the manifest is not an object or is malformed.
71
+ */
72
+ export function parseJsoncManifest(content) {
73
+ const parsed = JSON.parse(normalizeJsonc(content));
74
+ if (!isRecord(parsed)) throw new Error("Registry manifest must be a JSON object");
75
+ return parsed;
76
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Reads and versions native JSR manifests, then publishes with the official JSR or Deno client.
3
+ *
4
+ * @module create-version/jsr
5
+ */
6
+
7
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { DEFAULT_NPM_REGISTRY_URL, PACKAGE_VERSION_FIELD_PATTERN, UNSAFE_QUOTED_PATH_PATTERN } from "../constants/create-version.js";
10
+ import { JSR_CHILD_TOKEN_VARIABLE, JSR_CONFIG_FILES, JSR_NPM_CLIENT_SPEC, JSR_PACKAGE_NAME_PATTERN, JSR_SHELL_SAFE_TOKEN_PATTERN } from "../constants/registry.js";
11
+ import { ReleaseStepError } from "./errors.js";
12
+ import { normalizeJsonc, parseJsoncManifest } from "./jsonc.js";
13
+ import { quoteShellArguments, runRedactedInherited, USES_SHELL_FOR_PACKAGE_MANAGERS } from "./process.js";
14
+ import { buildNpmPublishEnvironment } from "./npm.js";
15
+ import { STRICT_UTF8_DECODER } from "./strict-utf8.js";
16
+
17
+ /**
18
+ * Finds the native JSR manifest inside a package and reads only its publication metadata.
19
+ *
20
+ * @param {string} packageRoot - Package directory.
21
+ * @param {string | null} configFile - Explicit package-relative path, or auto-detection.
22
+ * @returns {{ filePath: string, originalBytes: Buffer, text: string, manifest: Record<string, unknown>, packageName: string }} Native manifest.
23
+ * @throws {Error} When the file is absent, linked outside the package, malformed or unscoped.
24
+ */
25
+ export function readJsrManifest(packageRoot, configFile = null) {
26
+ const filePath = configFile ?? JSR_CONFIG_FILES.find((candidate) => existsSync(path.join(packageRoot, candidate)));
27
+ if (!filePath) throw new Error("JSR requiere jsr.json, jsr.jsonc, deno.json o deno.jsonc dentro del paquete");
28
+ const absolutePath = path.join(packageRoot, filePath);
29
+ const relativeRealPath = path.relative(realpathSync(packageRoot), realpathSync(absolutePath));
30
+ if (!lstatSync(absolutePath).isFile() || relativeRealPath.split(path.sep).includes("..") || path.isAbsolute(relativeRealPath)) {
31
+ throw new Error(`${filePath} debe ser un archivo regular dentro del paquete`);
32
+ }
33
+ const originalBytes = readFileSync(absolutePath);
34
+ const text = STRICT_UTF8_DECODER.decode(originalBytes);
35
+ const manifest = parseJsoncManifest(text);
36
+ const packageName = manifest.name;
37
+ if (typeof packageName !== "string" || !JSR_PACKAGE_NAME_PATTERN.test(packageName)) {
38
+ throw new Error(`${filePath} debe declarar un nombre JSR con scope (@scope/package)`);
39
+ }
40
+ return { filePath: filePath.replaceAll("\\", "/"), originalBytes, text, manifest, packageName };
41
+ }
42
+
43
+ /**
44
+ * Prepares a native manifest version edit for the same transaction as package.json; never writes it.
45
+ *
46
+ * @param {{ repositoryRoot: string, reader: import("./process.js").GitReader, commands: import("../package-manager.js").ProjectCommands, config?: { versionFiles: string[] } }} context - Release context.
47
+ * @param {string} packageRoot - Package checkout directory.
48
+ * @param {import("./registry-config.js").ResolvedPublicationOptions} options - Native publication options.
49
+ * @param {string} version - New stable version.
50
+ * @returns {Promise<import("./run.js").ReleaseFileUpdate[]>} Transactional native manifest update.
51
+ * @throws {ReleaseStepError} When the manifest is dirty, untracked or lacks a usable top-level version.
52
+ */
53
+ export async function prepareJsrVersionUpdates(context, packageRoot, options, version) {
54
+ const native = readJsrManifest(packageRoot, options.configFile);
55
+ const filePath = path.relative(context.repositoryRoot, path.join(packageRoot, native.filePath)).replaceAll("\\", "/");
56
+ if (context.config?.versionFiles.includes(filePath)) throw new ReleaseStepError(`${filePath} ya sincroniza su versión como manifest JSR.`, "Sacalo de versionFiles para que una sola operación gestione su versión; no se escribió nada.");
57
+ const pathspec = `:(literal)${filePath}`;
58
+ const tracked = await context.reader.git(["ls-files", "-z", "--", pathspec]);
59
+ const changed = await context.reader.git(["diff", "--name-only", "HEAD", "--", pathspec]);
60
+ if (!tracked.split("\0").includes(filePath) || changed) {
61
+ throw new ReleaseStepError(`${filePath} debe estar trackeado y sin cambios antes de versionar JSR.`, `Commiteá el manifest y volvé a correr ${context.commands.createVersion}; no se tocó la versión.`);
62
+ }
63
+ const normalized = normalizeJsonc(native.text);
64
+ const match = PACKAGE_VERSION_FIELD_PATTERN.exec(normalized);
65
+ if (!match) throw new ReleaseStepError(`${filePath} no tiene un campo version para sincronizar.`, "Declaralo en el manifest JSR antes de crear el release.");
66
+ const start = match.index + match[1].length;
67
+ const end = match.index + match[0].length - match[2].length;
68
+ const content = `${native.text.slice(0, start)}${version}${native.text.slice(end)}`;
69
+ if (parseJsoncManifest(content).version !== version) {
70
+ throw new ReleaseStepError(`${filePath} tiene una versión anidada antes de la versión principal.`, "Mové version al nivel principal antes de los objetos anidados; no se escribió nada.");
71
+ }
72
+ return [{ filePath, originalBytes: native.originalBytes, content }];
73
+ }
74
+
75
+ /**
76
+ * Publishes with a pinned official client and a selected config; the token is never printed by astra-rp.
77
+ *
78
+ * @param {string} packageRoot - Release checkout.
79
+ * @param {{ registryUrl: string, options: import("./registry-config.js").ResolvedPublicationOptions }} registry - Selected native registry.
80
+ * @param {string | null} token - Personal token, or `null` for browser/OIDC authentication.
81
+ * @param {ReturnType<typeof readJsrManifest>} [native] - Native manifest of the package, when the caller already read it.
82
+ * @returns {Promise<number>} Official client exit code.
83
+ * @throws {Error} When a shell operand or token cannot be passed safely.
84
+ */
85
+ export async function publishToJsr(packageRoot, registry, token, native = readJsrManifest(packageRoot, registry.options.configFile)) {
86
+ const command = registry.options.jsrClient === "deno" ? "deno" : "npx";
87
+ const prefix = registry.options.jsrClient === "deno" ? [] : ["--yes", `--registry=${DEFAULT_NPM_REGISTRY_URL}`, JSR_NPM_CLIENT_SPEC];
88
+ const args = [...prefix, "publish", "--config", native.filePath];
89
+ if (args.some((argument) => UNSAFE_QUOTED_PATH_PATTERN.test(argument))) throw new Error("JSR: un argumento del cliente no es seguro para el shell");
90
+ if (token && !JSR_SHELL_SAFE_TOKEN_PATTERN.test(token)) throw new Error("JSR: el token no se puede pasar de forma segura al cliente");
91
+ const env = { ...buildNpmPublishEnvironment(), JSR_URL: registry.registryUrl, DEBUG: "", ...(token ? { [JSR_CHILD_TOKEN_VARIABLE]: token } : {}) };
92
+ if (USES_SHELL_FOR_PACKAGE_MANAGERS) {
93
+ return runRedactedInherited(`${command} ${quoteShellArguments(args)}${token ? ` --token "%${JSR_CHILD_TOKEN_VARIABLE}%"` : ""}`, [], { cwd: packageRoot, shell: true, env }, token ? [token] : []);
94
+ }
95
+ return runRedactedInherited(command, [...args, ...(token ? ["--token", token] : [])], { cwd: packageRoot, env }, token ? [token] : []);
96
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Local changes of `astra-rp create-version --ignore-local-changes`: the
3
+ * uncommitted changes (staged, unstaged and untracked) are set aside in a
4
+ * `git stash` entry while the release runs, so checks, preparation and
5
+ * publication only see the release commit, and are restored at the end.
6
+ *
7
+ * The restore is exact or nothing: staged changes go back to the index only,
8
+ * unstaged changes to the working tree only and untracked files stay
9
+ * untracked. Every layer is checked before anything is written; when one does
10
+ * not apply cleanly on top of the release, nothing is touched and the stash
11
+ * entry is kept.
12
+ *
13
+ * @module create-version/local-changes
14
+ */
15
+
16
+ import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+
20
+ import { CHANGELOG_FILE } from "../constants/changelog.js";
21
+ import { LOCAL_CHANGES_STASH_MESSAGE } from "../constants/create-version.js";
22
+ import { DEFAULT_PROJECT_COMMANDS } from "../package-manager.js";
23
+ import { ReleaseStepError } from "./errors.js";
24
+
25
+ /**
26
+ * @typedef {import("./process.js").GitReader} GitReader
27
+ * @typedef {{ sha: string, keptPaths: string[] }} SetAsideChanges
28
+ * Stash entry holding the changes, identified by its commit so a later stash does not shift it;
29
+ * `keptPaths` stayed in the working tree (the release commits them: its `CHANGELOG.md` files).
30
+ * @typedef {{ restored: true } | { restored: false, reason: string }} LocalChangesRestore
31
+ */
32
+
33
+ /** Stash references are `stash@{<index>}`; the index shifts when other entries are pushed. */
34
+ const STASH_REFERENCE_PREFIX = "stash@";
35
+
36
+ /** Prefix of the temporary directory holding the patches of a restore. */
37
+ const RESTORE_DIRECTORY_PREFIX = "astra-rp-local-changes-";
38
+
39
+ /**
40
+ * Builds the pathspec of the set-aside changes: the kept files are left out, because a stash made
41
+ * with a pathspec still records them in its index and tree.
42
+ *
43
+ * @param {readonly string[]} keptPaths - Files that stayed in the working tree, relative to the root.
44
+ * @returns {string[]} Pathspec arguments, after `--`.
45
+ */
46
+ function buildPathspec(keptPaths) {
47
+ return [".", ...keptPaths.map((keptPath) => `:(exclude,literal)${keptPath}`)];
48
+ }
49
+
50
+ /**
51
+ * Sets the uncommitted changes aside in a new stash entry.
52
+ *
53
+ * @param {GitReader} reader - Git reader of the repository root.
54
+ * @param {{ keepChangelog?: boolean, keptPaths?: readonly string[], createVersionCommand?: string }} options - Files
55
+ * that stay in the working tree because a new release commits them (`keepChangelog` keeps the root
56
+ * `CHANGELOG.md`; a monorepo passes every released package's `keptPaths`), and how the project
57
+ * runs create-version (for the hints).
58
+ * @returns {Promise<SetAsideChanges>} Stash entry holding the changes.
59
+ * @throws {ReleaseStepError} When Git cannot create the stash entry; nothing was changed then.
60
+ */
61
+ export async function setAsideLocalChanges(reader, { keepChangelog = false, keptPaths: configuredKeptPaths, createVersionCommand = DEFAULT_PROJECT_COMMANDS.createVersion }) {
62
+ const keptPaths = [...(configuredKeptPaths ?? (keepChangelog ? [CHANGELOG_FILE] : []))];
63
+ if ((await reader.tryGit(["stash", "push", "--include-untracked", "--message", LOCAL_CHANGES_STASH_MESSAGE, "--", ...buildPathspec(keptPaths)])) === null) {
64
+ throw new ReleaseStepError("No se pudieron apartar los cambios sin commitear (git stash push falló).", `No se tocó nada: revisá git status y volvé a correr ${createVersionCommand}.`);
65
+ }
66
+
67
+ const sha = await reader.tryGit(["rev-parse", "--verify", "--quiet", `${STASH_REFERENCE_PREFIX}{0}`]);
68
+
69
+ if (!sha) {
70
+ throw new ReleaseStepError("Se apartaron los cambios pero no se encontró la entrada de git stash.", `Buscalos con git stash list y recuperalos con git stash pop --index antes de volver a correr ${createVersionCommand}.`);
71
+ }
72
+
73
+ return { sha, keptPaths };
74
+ }
75
+
76
+ /**
77
+ * Writes the diff between two revisions of the set-aside paths to a patch file.
78
+ *
79
+ * @param {GitReader} reader - Git reader of the repository root.
80
+ * @param {string} from - Revision the patch starts from.
81
+ * @param {string} to - Revision the patch leads to.
82
+ * @param {string[]} pathspec - Paths included in the patch.
83
+ * @param {string} patchPath - File the patch is written to.
84
+ * @returns {Promise<boolean | null>} `true` with changes, `false` when empty, `null` when Git failed.
85
+ */
86
+ async function writePatch(reader, from, to, pathspec, patchPath) {
87
+ // `--output` keeps the exact bytes of the patch; `--no-ext-diff` and `--no-textconv` keep it appliable.
88
+ if ((await reader.tryGit(["diff", "--binary", "--full-index", "--no-ext-diff", "--no-textconv", "--no-color", `--output=${patchPath}`, from, to, "--", ...pathspec])) === null) {
89
+ return null;
90
+ }
91
+
92
+ return existsSync(patchPath) && statSync(patchPath).size > 0;
93
+ }
94
+
95
+ /**
96
+ * Lists the untracked files the stash entry holds (its third parent), if any.
97
+ *
98
+ * @param {GitReader} reader - Git reader of the repository root.
99
+ * @param {string} sha - Stash commit.
100
+ * @returns {Promise<string[] | null>} Paths relative to the root, or `null` when Git failed.
101
+ */
102
+ async function listStashedUntrackedFiles(reader, sha) {
103
+ if ((await reader.tryGit(["rev-parse", "--verify", "--quiet", `${sha}^3`])) === null) {
104
+ return [];
105
+ }
106
+
107
+ const output = await reader.tryGit(["ls-tree", "-r", "-z", "--name-only", `${sha}^3`]);
108
+ return output === null ? null : output.split("\0").filter(Boolean);
109
+ }
110
+
111
+ /**
112
+ * Restores the changes set aside by {@link setAsideLocalChanges} exactly as they were: staged
113
+ * changes staged, unstaged changes unstaged and untracked files untracked. Nothing is written
114
+ * unless every layer applies cleanly; the stash entry is dropped only after a full restore.
115
+ *
116
+ * @param {GitReader} reader - Git reader of the repository root.
117
+ * @param {string} repositoryRoot - Repository root.
118
+ * @param {SetAsideChanges} setAside - Stash entry returned by {@link setAsideLocalChanges}.
119
+ * @returns {Promise<LocalChangesRestore>} Whether the changes are back, and why not otherwise.
120
+ */
121
+ export async function restoreLocalChanges(reader, repositoryRoot, setAside) {
122
+ const { sha, keptPaths } = setAside;
123
+ const stashShas = ((await reader.tryGit(["stash", "list", "--format=%H"])) ?? "").split("\n").map((line) => line.trim());
124
+ const stashIndex = stashShas.indexOf(sha);
125
+
126
+ if (stashIndex === -1) {
127
+ return { restored: false, reason: `la entrada ${sha} ya no está en git stash list` };
128
+ }
129
+
130
+ const stashReference = `${STASH_REFERENCE_PREFIX}{${stashIndex}}`;
131
+ const keptReason = (/** @type {string} */ detail) => ({ restored: /** @type {const} */ (false), reason: `${detail}; no se tocó nada y siguen en ${stashReference}` });
132
+ const pathspec = buildPathspec(keptPaths);
133
+ const patchDirectory = mkdtempSync(path.join(os.tmpdir(), RESTORE_DIRECTORY_PREFIX));
134
+ const stagedPatch = path.join(patchDirectory, "staged.patch");
135
+ const workingTreePatch = path.join(patchDirectory, "working-tree.patch");
136
+
137
+ try {
138
+ // Staged layer: stash base -> stash index, applied to the index only. Working tree layer:
139
+ // stash base -> stash tree (staged plus unstaged), applied to the working tree only.
140
+ const hasStaged = await writePatch(reader, `${sha}^1`, `${sha}^2`, pathspec, stagedPatch);
141
+ const hasWorkingTree = await writePatch(reader, `${sha}^1`, sha, pathspec, workingTreePatch);
142
+ const untrackedFiles = await listStashedUntrackedFiles(reader, sha);
143
+
144
+ if (hasStaged === null || hasWorkingTree === null || untrackedFiles === null) {
145
+ return keptReason("Git no pudo leer la entrada de stash");
146
+ }
147
+
148
+ if (hasStaged && (await reader.tryGit(["apply", "--check", "--cached", stagedPatch])) === null) {
149
+ return keptReason("los cambios staged chocan con lo que cambió el release en el índice");
150
+ }
151
+
152
+ if (hasWorkingTree && (await reader.tryGit(["apply", "--check", workingTreePatch])) === null) {
153
+ return keptReason("los cambios del working tree chocan con lo que cambió el release");
154
+ }
155
+
156
+ const occupiedFile = untrackedFiles.find((file) => existsSync(path.join(repositoryRoot, file)));
157
+ if (occupiedFile) {
158
+ return keptReason(`el archivo sin trackear ${occupiedFile} ya existe después del release`);
159
+ }
160
+
161
+ if (hasStaged && (await reader.tryGit(["apply", "--cached", stagedPatch])) === null) {
162
+ return keptReason("git apply --cached falló después de validar");
163
+ }
164
+
165
+ if (hasWorkingTree && (await reader.tryGit(["apply", workingTreePatch])) === null) {
166
+ return { restored: false, reason: `los cambios staged volvieron pero git apply del working tree falló; el resto sigue en ${stashReference}` };
167
+ }
168
+
169
+ if (untrackedFiles.length > 0 && (await reader.tryGit(["restore", `--source=${sha}^3`, "--worktree", "--", ...untrackedFiles.map((file) => `:(literal)${file}`)])) === null) {
170
+ return { restored: false, reason: `volvieron los cambios trackeados pero no los archivos sin trackear; siguen en ${stashReference}` };
171
+ }
172
+
173
+ await reader.tryGit(["stash", "drop", "--quiet", stashReference]);
174
+ return { restored: true };
175
+ } finally {
176
+ rmSync(patchDirectory, { recursive: true, force: true });
177
+ }
178
+ }