gflows 1.1.0 → 1.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,236 @@
1
+ /**
2
+ * Shared semver bump helpers for package.json / jsr.json.
3
+ * Writes preserve original file formatting (tabs vs spaces) via surgical replace.
4
+ * @module version-bump
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join, relative } from "node:path";
9
+ import { InvalidVersionError } from "./errors.js";
10
+ import { findPackageRoots, PACKAGE_JSON } from "./packages.js";
11
+ import type { BumpDirection, BumpType } from "./types.js";
12
+
13
+ /** Filename for JSR package manifests. */
14
+ export const JSR_JSON = "jsr.json";
15
+
16
+ /** Semver triplet. */
17
+ export interface Semver {
18
+ major: number;
19
+ minor: number;
20
+ patch: number;
21
+ }
22
+
23
+ /** Matches a JSON `"version": "…"` field for in-place updates. */
24
+ const VERSION_FIELD_RE = /"version"\s*:\s*"[^"]*"/;
25
+
26
+ /**
27
+ * Parses a version string (vX.Y.Z or X.Y.Z) into components.
28
+ * @throws InvalidVersionError if format is invalid
29
+ */
30
+ export function parseVersion(version: string): Semver {
31
+ const trimmed = version.trim();
32
+ const normalized = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
33
+ const parts = normalized.split(".");
34
+ if (parts.length !== 3 || parts.some((p) => !/^\d+$/.test(p))) {
35
+ throw new InvalidVersionError(
36
+ `Invalid version '${version}'. Expected format: X.Y.Z or vX.Y.Z (e.g. 1.2.3 or v1.2.3).`,
37
+ );
38
+ }
39
+ const [p0, p1, p2] = parts;
40
+ return {
41
+ major: parseInt(p0 ?? "0", 10),
42
+ minor: parseInt(p1 ?? "0", 10),
43
+ patch: parseInt(p2 ?? "0", 10),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Formats semver as string (no leading v, for package.json).
49
+ */
50
+ export function formatVersion(semver: Semver): string {
51
+ return `${semver.major}.${semver.minor}.${semver.patch}`;
52
+ }
53
+
54
+ /**
55
+ * Computes new version for bump up (patch/minor/major).
56
+ */
57
+ export function bumpUp(semver: Semver, type: BumpType): Semver {
58
+ switch (type) {
59
+ case "patch":
60
+ return { ...semver, patch: semver.patch + 1 };
61
+ case "minor":
62
+ return { major: semver.major, minor: semver.minor + 1, patch: 0 };
63
+ case "major":
64
+ return { major: semver.major + 1, minor: 0, patch: 0 };
65
+ default:
66
+ return semver;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Computes new version for rollback (down), with floor at 0.
72
+ */
73
+ export function bumpDown(semver: Semver, type: BumpType): Semver {
74
+ switch (type) {
75
+ case "patch":
76
+ return {
77
+ ...semver,
78
+ patch: Math.max(0, semver.patch - 1),
79
+ };
80
+ case "minor":
81
+ return {
82
+ major: semver.major,
83
+ minor: Math.max(0, semver.minor - 1),
84
+ patch: 0,
85
+ };
86
+ case "major":
87
+ return {
88
+ major: Math.max(0, semver.major - 1),
89
+ minor: semver.minor,
90
+ patch: semver.patch,
91
+ };
92
+ default:
93
+ return semver;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Replaces the `"version"` string value in raw JSON text without reformatting.
99
+ * @throws InvalidVersionError if no version field string is found
100
+ */
101
+ export function replaceVersionInJsonText(raw: string, newVersion: string): string {
102
+ const updated = raw.replace(VERSION_FIELD_RE, `"version": "${newVersion}"`);
103
+ if (updated === raw) {
104
+ throw new InvalidVersionError(
105
+ 'Could not find a "version" string field to update in place. Fix the JSON file manually.',
106
+ );
107
+ }
108
+ return updated;
109
+ }
110
+
111
+ /**
112
+ * Reads version from package.json in dir.
113
+ * @throws InvalidVersionError if version is missing or invalid
114
+ */
115
+ export function readPackageVersion(dir: string): { raw: string; semver: Semver } {
116
+ const path = join(dir, PACKAGE_JSON);
117
+ if (!existsSync(path)) {
118
+ throw new InvalidVersionError(
119
+ `No package.json found at ${path}. Run from project root or use -C <dir>.`,
120
+ );
121
+ }
122
+ const fileRaw = readFileSync(path, "utf-8");
123
+ let data: Record<string, unknown>;
124
+ try {
125
+ data = JSON.parse(fileRaw) as Record<string, unknown>;
126
+ } catch {
127
+ throw new InvalidVersionError(`Invalid JSON in ${path}.`);
128
+ }
129
+ const version = data.version;
130
+ if (typeof version !== "string" || version.trim() === "") {
131
+ throw new InvalidVersionError(
132
+ 'package.json has no valid \'version\' field. Add a "version" field (e.g. "0.0.0") to package.json.',
133
+ );
134
+ }
135
+ const semver = parseVersion(version);
136
+ return { raw: version.trim(), semver };
137
+ }
138
+
139
+ /**
140
+ * Writes version to package.json, preserving indent style and the rest of the file.
141
+ * @throws InvalidVersionError if the version field cannot be updated in place
142
+ */
143
+ export function writePackageVersion(dir: string, newVersion: string): void {
144
+ const path = join(dir, PACKAGE_JSON);
145
+ const raw = readFileSync(path, "utf-8");
146
+ // Ensure the file parses and has a version before writing
147
+ readPackageVersion(dir);
148
+ const updated = replaceVersionInJsonText(raw, newVersion);
149
+ writeFileSync(path, updated, "utf-8");
150
+ }
151
+
152
+ /**
153
+ * Updates version in jsr.json if the file exists. Only the version value is changed.
154
+ * @returns true if the file was written
155
+ */
156
+ export function syncJsrVersion(dir: string, newVersion: string): boolean {
157
+ const path = join(dir, JSR_JSON);
158
+ if (!existsSync(path)) return false;
159
+ const raw = readFileSync(path, "utf-8");
160
+ try {
161
+ const updated = replaceVersionInJsonText(raw, newVersion);
162
+ if (updated === raw) return false;
163
+ writeFileSync(path, updated, "utf-8");
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Sorted package roots under cwd (cwd first when it has a package.json).
172
+ */
173
+ export function sortedPackageRoots(cwd: string): string[] {
174
+ const roots = findPackageRoots(cwd);
175
+ return [...roots].sort((a, b) => {
176
+ if (a === cwd && b !== cwd) return -1;
177
+ if (a !== cwd && b === cwd) return 1;
178
+ return a.localeCompare(b);
179
+ });
180
+ }
181
+
182
+ /** Result of computing a bump across discovered packages. */
183
+ export interface ComputedBump {
184
+ oldVersion: string;
185
+ newVersion: string;
186
+ roots: string[];
187
+ filesToUpdate: string[];
188
+ }
189
+
190
+ /**
191
+ * Computes the next version from the primary package.json under cwd.
192
+ * @throws InvalidVersionError if no packages or invalid version
193
+ */
194
+ export function computeBump(cwd: string, direction: BumpDirection, type: BumpType): ComputedBump {
195
+ const roots = sortedPackageRoots(cwd);
196
+ if (roots.length === 0) {
197
+ throw new InvalidVersionError(
198
+ `No package.json found under ${cwd}. Run from project root or use -C <dir>.`,
199
+ );
200
+ }
201
+ const primaryRoot = roots[0];
202
+ if (primaryRoot === undefined) {
203
+ throw new InvalidVersionError(
204
+ `No package.json found under ${cwd}. Run from project root or use -C <dir>.`,
205
+ );
206
+ }
207
+ const { raw: oldVersion, semver } = readPackageVersion(primaryRoot);
208
+ const newSemver = direction === "up" ? bumpUp(semver, type) : bumpDown(semver, type);
209
+ const newVersion = formatVersion(newSemver);
210
+
211
+ const filesToUpdate: string[] = [];
212
+ for (const dir of roots) {
213
+ filesToUpdate.push(relative(cwd, join(dir, PACKAGE_JSON)) || PACKAGE_JSON);
214
+ if (existsSync(join(dir, JSR_JSON))) {
215
+ filesToUpdate.push(relative(cwd, join(dir, JSR_JSON)) || JSR_JSON);
216
+ }
217
+ }
218
+
219
+ return { oldVersion, newVersion, roots, filesToUpdate };
220
+ }
221
+
222
+ /**
223
+ * Applies a version to all package roots (package.json + optional jsr.json).
224
+ * @returns relative paths that were updated
225
+ */
226
+ export function applyVersionToPackages(cwd: string, roots: string[], newVersion: string): string[] {
227
+ const updated: string[] = [];
228
+ for (const dir of roots) {
229
+ writePackageVersion(dir, newVersion);
230
+ updated.push(relative(cwd, join(dir, PACKAGE_JSON)) || PACKAGE_JSON);
231
+ if (syncJsrVersion(dir, newVersion)) {
232
+ updated.push(relative(cwd, join(dir, JSR_JSON)) || JSR_JSON);
233
+ }
234
+ }
235
+ return updated;
236
+ }