gflows 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +549 -0
- package/package.json +38 -0
- package/src/cli.ts +358 -0
- package/src/commands/bump.ts +213 -0
- package/src/commands/completion.ts +353 -0
- package/src/commands/delete.ts +133 -0
- package/src/commands/finish.ts +275 -0
- package/src/commands/help.ts +53 -0
- package/src/commands/init.ts +70 -0
- package/src/commands/list.ts +89 -0
- package/src/commands/start.ts +141 -0
- package/src/commands/status.ts +137 -0
- package/src/commands/switch.ts +102 -0
- package/src/commands/version.ts +27 -0
- package/src/config.ts +229 -0
- package/src/constants.ts +38 -0
- package/src/errors.ts +96 -0
- package/src/git.ts +481 -0
- package/src/index.ts +84 -0
- package/src/types.ts +124 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI entrypoint for gflows. Parses argv, resolves -C/path, dispatches to commands,
|
|
3
|
+
* and ensures exit codes and unhandled rejections are handled.
|
|
4
|
+
* @module cli
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, statSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import type { Command, BranchType, ParsedArgs } from "./types.js";
|
|
11
|
+
import { EXIT_OK, EXIT_USER, EXIT_GIT } from "./constants.js";
|
|
12
|
+
import { exitCodeForError } from "./errors.js";
|
|
13
|
+
|
|
14
|
+
/** Last parsed args, set at start of run(); used by catch/rejection to respect -v for stack trace. */
|
|
15
|
+
let lastParsedArgs: ParsedArgs | null = null;
|
|
16
|
+
|
|
17
|
+
const COMMANDS: Command[] = [
|
|
18
|
+
"init",
|
|
19
|
+
"start",
|
|
20
|
+
"finish",
|
|
21
|
+
"switch",
|
|
22
|
+
"delete",
|
|
23
|
+
"list",
|
|
24
|
+
"bump",
|
|
25
|
+
"completion",
|
|
26
|
+
"status",
|
|
27
|
+
"help",
|
|
28
|
+
"version",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const BRANCH_TYPES: BranchType[] = [
|
|
32
|
+
"feature",
|
|
33
|
+
"bugfix",
|
|
34
|
+
"chore",
|
|
35
|
+
"release",
|
|
36
|
+
"hotfix",
|
|
37
|
+
"spike",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Short flag → command (when used as main command). */
|
|
41
|
+
const SHORT_TO_COMMAND: Record<string, Command> = {
|
|
42
|
+
I: "init",
|
|
43
|
+
S: "start",
|
|
44
|
+
F: "finish",
|
|
45
|
+
W: "switch",
|
|
46
|
+
L: "delete",
|
|
47
|
+
l: "list",
|
|
48
|
+
t: "status",
|
|
49
|
+
h: "help",
|
|
50
|
+
V: "version",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function buildParseArgsOptions() {
|
|
54
|
+
return {
|
|
55
|
+
args: Bun.argv.slice(2),
|
|
56
|
+
strict: false,
|
|
57
|
+
allowPositionals: true,
|
|
58
|
+
options: {
|
|
59
|
+
// -C / --path (must be first conceptually for cwd resolution)
|
|
60
|
+
path: { type: "string" as const, short: "C" },
|
|
61
|
+
// Command shorts
|
|
62
|
+
init: { type: "boolean" as const, short: "I" },
|
|
63
|
+
start: { type: "boolean" as const, short: "S" },
|
|
64
|
+
finish: { type: "boolean" as const, short: "F" },
|
|
65
|
+
switch: { type: "boolean" as const, short: "W" },
|
|
66
|
+
delete: { type: "boolean" as const, short: "L" },
|
|
67
|
+
list: { type: "boolean" as const, short: "l" },
|
|
68
|
+
status: { type: "boolean" as const, short: "t" },
|
|
69
|
+
help: { type: "boolean" as const, short: "h" },
|
|
70
|
+
version: { type: "boolean" as const, short: "V" },
|
|
71
|
+
// Type shorts (-r is context-dependent: list → include-remote, start/finish → release)
|
|
72
|
+
feature: { type: "boolean" as const, short: "f" },
|
|
73
|
+
bugfix: { type: "boolean" as const, short: "b" },
|
|
74
|
+
chore: { type: "boolean" as const, short: "c" },
|
|
75
|
+
release: { type: "boolean" as const, short: "r" },
|
|
76
|
+
hotfix: { type: "boolean" as const, short: "x" },
|
|
77
|
+
spike: { type: "boolean" as const, short: "e" },
|
|
78
|
+
// Common
|
|
79
|
+
push: { type: "boolean" as const, short: "p" },
|
|
80
|
+
noPush: { type: "boolean" as const, short: "P" },
|
|
81
|
+
remote: { type: "string" as const, short: "R" },
|
|
82
|
+
from: { type: "string" as const, short: "o" },
|
|
83
|
+
branch: { type: "string" as const, short: "B" },
|
|
84
|
+
yes: { type: "boolean" as const, short: "y" },
|
|
85
|
+
dryRun: { type: "boolean" as const, short: "d" },
|
|
86
|
+
verbose: { type: "boolean" as const, short: "v" },
|
|
87
|
+
quiet: { type: "boolean" as const, short: "q" },
|
|
88
|
+
force: { type: "boolean" as const },
|
|
89
|
+
// finish (-D/--delete-branch, -N/--no-delete)
|
|
90
|
+
noFf: { type: "boolean" as const },
|
|
91
|
+
deleteBranch: { type: "boolean" as const, short: "D" },
|
|
92
|
+
noDelete: { type: "boolean" as const, short: "N" },
|
|
93
|
+
sign: { type: "boolean" as const, short: "s" },
|
|
94
|
+
noTag: { type: "boolean" as const, short: "T" },
|
|
95
|
+
tagMessage: { type: "string" as const, short: "M" },
|
|
96
|
+
message: { type: "string" as const, short: "m" },
|
|
97
|
+
// list (-r is context-dependent: list → include-remote; start/finish → release)
|
|
98
|
+
includeRemote: { type: "boolean" as const },
|
|
99
|
+
"include-remote": { type: "boolean" as const },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resolve -C/--path to absolute directory; validate it exists and is a directory. */
|
|
105
|
+
function resolveCwd(pathFlag: string | undefined): string {
|
|
106
|
+
if (!pathFlag || pathFlag.trim() === "") {
|
|
107
|
+
return process.cwd();
|
|
108
|
+
}
|
|
109
|
+
const absolute = resolve(process.cwd(), pathFlag.trim());
|
|
110
|
+
if (!existsSync(absolute)) {
|
|
111
|
+
console.error(`gflows: path does not exist: ${absolute}`);
|
|
112
|
+
process.exit(EXIT_USER);
|
|
113
|
+
}
|
|
114
|
+
const stat = statSync(absolute, { throwIfNoEntry: false });
|
|
115
|
+
if (!stat || !stat.isDirectory()) {
|
|
116
|
+
console.error(`gflows: path is not a directory: ${absolute}`);
|
|
117
|
+
process.exit(EXIT_USER);
|
|
118
|
+
}
|
|
119
|
+
return absolute;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve command from positionals and short flags. Short wins if both present. */
|
|
123
|
+
function resolveCommand(
|
|
124
|
+
positionals: string[],
|
|
125
|
+
values: Record<string, string | boolean | undefined>
|
|
126
|
+
): Command | undefined {
|
|
127
|
+
for (const [short, cmd] of Object.entries(SHORT_TO_COMMAND)) {
|
|
128
|
+
const key = cmd === "delete" ? "delete" : cmd;
|
|
129
|
+
if (values[key] === true) return cmd as Command;
|
|
130
|
+
}
|
|
131
|
+
const first = positionals[0];
|
|
132
|
+
if (first && COMMANDS.includes(first as Command)) {
|
|
133
|
+
return first as Command;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Resolve branch type for start/finish/list. -r means release for start/finish; for list, -r means include-remote (handled in flags). */
|
|
139
|
+
function resolveType(
|
|
140
|
+
command: Command,
|
|
141
|
+
positionals: string[],
|
|
142
|
+
values: Record<string, string | boolean | undefined>
|
|
143
|
+
): BranchType | undefined {
|
|
144
|
+
if (command !== "start" && command !== "finish" && command !== "list") {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
// Type from short flags (for start/finish, -r => release)
|
|
148
|
+
if (command === "list") {
|
|
149
|
+
// For list, -r is include-remote only (see includeRemote below); type from other shorts or positional (not -r)
|
|
150
|
+
if (values.feature === true) return "feature";
|
|
151
|
+
if (values.bugfix === true) return "bugfix";
|
|
152
|
+
if (values.chore === true) return "chore";
|
|
153
|
+
if (values.hotfix === true) return "hotfix";
|
|
154
|
+
if (values.spike === true) return "spike";
|
|
155
|
+
// release type for list only via positional, e.g. "gflows list release"
|
|
156
|
+
} else {
|
|
157
|
+
// start / finish: -r => release
|
|
158
|
+
if (values.release === true) return "release";
|
|
159
|
+
if (values.feature === true) return "feature";
|
|
160
|
+
if (values.bugfix === true) return "bugfix";
|
|
161
|
+
if (values.chore === true) return "chore";
|
|
162
|
+
if (values.hotfix === true) return "hotfix";
|
|
163
|
+
if (values.spike === true) return "spike";
|
|
164
|
+
}
|
|
165
|
+
// Type from second positional
|
|
166
|
+
const idx = positionals[0] && COMMANDS.includes(positionals[0] as Command) ? 1 : 0;
|
|
167
|
+
const pos = positionals[idx];
|
|
168
|
+
if (pos && BRANCH_TYPES.includes(pos as BranchType)) {
|
|
169
|
+
return pos as BranchType;
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Resolve name (third positional for start; -B for finish; first positional for completion). */
|
|
175
|
+
function resolveName(
|
|
176
|
+
command: Command,
|
|
177
|
+
positionals: string[],
|
|
178
|
+
values: Record<string, string | boolean | undefined>
|
|
179
|
+
): string | undefined {
|
|
180
|
+
const branch = values.branch;
|
|
181
|
+
if (typeof branch === "string" && branch.trim() !== "") {
|
|
182
|
+
return branch.trim();
|
|
183
|
+
}
|
|
184
|
+
const skip = positionals[0] && COMMANDS.includes(positionals[0] as Command) ? 1 : 0;
|
|
185
|
+
if (command === "start") {
|
|
186
|
+
const typeFromPos = positionals[skip] && BRANCH_TYPES.includes(positionals[skip] as BranchType);
|
|
187
|
+
if (typeFromPos) {
|
|
188
|
+
return positionals[skip + 1];
|
|
189
|
+
}
|
|
190
|
+
return positionals[skip];
|
|
191
|
+
}
|
|
192
|
+
if (command === "completion") {
|
|
193
|
+
const shell = positionals[skip];
|
|
194
|
+
if (shell === "bash" || shell === "zsh" || shell === "fish") return shell;
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if (command === "bump") {
|
|
198
|
+
const dir = positionals[skip];
|
|
199
|
+
const typ = positionals[skip + 1];
|
|
200
|
+
if (dir === "up" || dir === "down") {
|
|
201
|
+
return dir;
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
if (command === "switch") {
|
|
206
|
+
return positionals[skip];
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Resolve bump direction and type from positionals (bump [up|down] [patch|minor|major]). */
|
|
212
|
+
function resolveBump(
|
|
213
|
+
positionals: string[],
|
|
214
|
+
values: Record<string, string | boolean | undefined>
|
|
215
|
+
): { direction?: "up" | "down"; type?: "patch" | "minor" | "major" } {
|
|
216
|
+
const skip = positionals[0] === "bump" ? 1 : 0;
|
|
217
|
+
const a = positionals[skip];
|
|
218
|
+
const b = positionals[skip + 1];
|
|
219
|
+
const direction = a === "up" || a === "down" ? a : undefined;
|
|
220
|
+
const type = b === "patch" || b === "minor" || b === "major" ? b : undefined;
|
|
221
|
+
return { direction, type };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Parse raw argv into ParsedArgs. Resolves -C first, then command/type/name and flags. */
|
|
225
|
+
export function parse(argv: string[] = Bun.argv.slice(2)): ParsedArgs {
|
|
226
|
+
const config = {
|
|
227
|
+
...buildParseArgsOptions(),
|
|
228
|
+
args: argv,
|
|
229
|
+
};
|
|
230
|
+
const { values, positionals } = parseArgs(config);
|
|
231
|
+
const v = values as Record<string, string | boolean | undefined>;
|
|
232
|
+
|
|
233
|
+
const pathRaw = v.path;
|
|
234
|
+
const pathStr = typeof pathRaw === "string" ? pathRaw : undefined;
|
|
235
|
+
const cwd = resolveCwd(pathStr);
|
|
236
|
+
|
|
237
|
+
const command = resolveCommand(positionals, v);
|
|
238
|
+
if (!command) {
|
|
239
|
+
console.error("gflows: missing command. Use 'gflows help' for usage.");
|
|
240
|
+
process.exit(EXIT_USER);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const type = resolveType(command, positionals, v);
|
|
244
|
+
const name = resolveName(command, positionals, v);
|
|
245
|
+
const { direction: bumpDirection, type: bumpType } =
|
|
246
|
+
command === "bump" ? resolveBump(positionals, v) : { direction: undefined, type: undefined };
|
|
247
|
+
|
|
248
|
+
const branchNames =
|
|
249
|
+
command === "delete"
|
|
250
|
+
? positionals[0] && COMMANDS.includes(positionals[0] as Command)
|
|
251
|
+
? positionals.slice(1)
|
|
252
|
+
: positionals
|
|
253
|
+
: undefined;
|
|
254
|
+
|
|
255
|
+
// -r context: for list → includeRemote; for start/finish → already used as type release
|
|
256
|
+
const includeRemote =
|
|
257
|
+
command === "list"
|
|
258
|
+
? (v.includeRemote === true ||
|
|
259
|
+
v["include-remote"] === true ||
|
|
260
|
+
v.release === true)
|
|
261
|
+
: false;
|
|
262
|
+
|
|
263
|
+
let completionShell: "bash" | "zsh" | "fish" | undefined;
|
|
264
|
+
if (command === "completion" && name === "bash") completionShell = "bash";
|
|
265
|
+
else if (command === "completion" && name === "zsh") completionShell = "zsh";
|
|
266
|
+
else if (command === "completion" && name === "fish") completionShell = "fish";
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
command,
|
|
270
|
+
cwd,
|
|
271
|
+
type,
|
|
272
|
+
name,
|
|
273
|
+
completionShell,
|
|
274
|
+
branchNames,
|
|
275
|
+
bumpDirection,
|
|
276
|
+
bumpType,
|
|
277
|
+
push: v.push === true,
|
|
278
|
+
noPush: v.noPush === true,
|
|
279
|
+
remote: typeof v.remote === "string" ? v.remote : undefined,
|
|
280
|
+
branch: typeof v.branch === "string" ? v.branch : undefined,
|
|
281
|
+
yes: v.yes === true,
|
|
282
|
+
dryRun: v.dryRun === true,
|
|
283
|
+
verbose: v.verbose === true,
|
|
284
|
+
quiet: v.quiet === true,
|
|
285
|
+
force: v.force === true,
|
|
286
|
+
path: pathStr,
|
|
287
|
+
fromMain: v.from === "main",
|
|
288
|
+
noFf: v.noFf === true,
|
|
289
|
+
deleteAfterFinish: v.deleteBranch === true,
|
|
290
|
+
noDeleteAfterFinish: v.noDelete === true,
|
|
291
|
+
signTag: v.sign === true,
|
|
292
|
+
noTag: v.noTag === true,
|
|
293
|
+
tagMessage: typeof v.tagMessage === "string" ? v.tagMessage : undefined,
|
|
294
|
+
message: typeof v.message === "string" ? v.message : undefined,
|
|
295
|
+
includeRemote,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Run the CLI: parse, dispatch, set exit code. */
|
|
300
|
+
async function run(): Promise<void> {
|
|
301
|
+
const args = parse();
|
|
302
|
+
lastParsedArgs = args;
|
|
303
|
+
const { command } = args;
|
|
304
|
+
|
|
305
|
+
if (command === "help") {
|
|
306
|
+
const { run: runHelp } = await import("./commands/help.js");
|
|
307
|
+
await runHelp(args);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (command === "version") {
|
|
311
|
+
const { run: runVersion } = await import("./commands/version.js");
|
|
312
|
+
await runVersion(args);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const mod = await import(`./commands/${command}.js`).catch(() => null);
|
|
317
|
+
if (!mod || typeof mod.run !== "function") {
|
|
318
|
+
console.error(`gflows: command '${command}' is not implemented.`);
|
|
319
|
+
process.exit(EXIT_GIT);
|
|
320
|
+
}
|
|
321
|
+
await mod.run(args);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Top-level try/catch and unhandledRejection so exit code is always set
|
|
325
|
+
function main(): void {
|
|
326
|
+
let exitCode: number | null = null;
|
|
327
|
+
|
|
328
|
+
const handleRejection = (reason: unknown): void => {
|
|
329
|
+
if (exitCode !== null) return;
|
|
330
|
+
console.error("gflows:", reason instanceof Error ? reason.message : String(reason));
|
|
331
|
+
const verbose = lastParsedArgs?.verbose ?? !!process.env.GFLOWS_VERBOSE;
|
|
332
|
+
if (verbose && reason instanceof Error && reason.stack) {
|
|
333
|
+
console.error(reason.stack);
|
|
334
|
+
}
|
|
335
|
+
exitCode = exitCodeForError(reason instanceof Error ? reason : new Error(String(reason)));
|
|
336
|
+
process.exit(exitCode);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
process.on("unhandledRejection", handleRejection);
|
|
340
|
+
|
|
341
|
+
run()
|
|
342
|
+
.then(() => {
|
|
343
|
+
if (exitCode === null) exitCode = EXIT_OK;
|
|
344
|
+
process.exit(exitCode);
|
|
345
|
+
})
|
|
346
|
+
.catch((err: unknown) => {
|
|
347
|
+
if (exitCode !== null) return;
|
|
348
|
+
console.error("gflows:", err instanceof Error ? err.message : String(err));
|
|
349
|
+
const verbose = lastParsedArgs?.verbose ?? !!process.env.GFLOWS_VERBOSE;
|
|
350
|
+
if (verbose && err instanceof Error && err.stack) {
|
|
351
|
+
console.error(err.stack);
|
|
352
|
+
}
|
|
353
|
+
exitCode = exitCodeForError(err);
|
|
354
|
+
process.exit(exitCode);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
main();
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bump command: bump or rollback root package version (patch/minor/major).
|
|
3
|
+
* Keeps package.json and jsr.json in sync; no git operations.
|
|
4
|
+
* @module commands/bump
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { ParsedArgs } from "../types.js";
|
|
10
|
+
import type { BumpDirection, BumpType } from "../types.js";
|
|
11
|
+
import { EXIT_OK, EXIT_USER } from "../constants.js";
|
|
12
|
+
import { InvalidVersionError } from "../errors.js";
|
|
13
|
+
|
|
14
|
+
const PACKAGE_JSON = "package.json";
|
|
15
|
+
const JSR_JSON = "jsr.json";
|
|
16
|
+
|
|
17
|
+
/** Semver triplet. */
|
|
18
|
+
interface Semver {
|
|
19
|
+
major: number;
|
|
20
|
+
minor: number;
|
|
21
|
+
patch: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses a version string (vX.Y.Z or X.Y.Z) into components.
|
|
26
|
+
* @throws InvalidVersionError if format is invalid
|
|
27
|
+
*/
|
|
28
|
+
function parseVersion(version: string): Semver {
|
|
29
|
+
const trimmed = version.trim();
|
|
30
|
+
const normalized = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
|
|
31
|
+
const parts = normalized.split(".");
|
|
32
|
+
if (
|
|
33
|
+
parts.length !== 3 ||
|
|
34
|
+
parts.some((p) => !/^\d+$/.test(p))
|
|
35
|
+
) {
|
|
36
|
+
throw new InvalidVersionError(
|
|
37
|
+
`Invalid version '${version}'. Expected format: X.Y.Z or vX.Y.Z (e.g. 1.2.3 or v1.2.3).`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
major: parseInt(parts[0]!, 10),
|
|
42
|
+
minor: parseInt(parts[1]!, 10),
|
|
43
|
+
patch: parseInt(parts[2]!, 10),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Formats semver as string (no leading v, for package.json).
|
|
49
|
+
*/
|
|
50
|
+
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
|
+
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
|
+
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
|
+
* Reads version from package.json in dir.
|
|
99
|
+
* @throws InvalidVersionError if version is missing or invalid
|
|
100
|
+
*/
|
|
101
|
+
function readPackageVersion(dir: string): { raw: string; semver: Semver } {
|
|
102
|
+
const path = join(dir, PACKAGE_JSON);
|
|
103
|
+
if (!existsSync(path)) {
|
|
104
|
+
throw new InvalidVersionError(
|
|
105
|
+
`No package.json found at ${path}. Run from project root or use -C <dir>.`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const raw = readFileSync(path, "utf-8");
|
|
109
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
110
|
+
const version = data.version;
|
|
111
|
+
if (typeof version !== "string" || version.trim() === "") {
|
|
112
|
+
throw new InvalidVersionError(
|
|
113
|
+
"package.json has no valid 'version' field."
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const semver = parseVersion(version);
|
|
117
|
+
return { raw: version.trim(), semver };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Writes version to package.json, preserving other keys and formatting as much as possible.
|
|
122
|
+
* Uses JSON.stringify with 2 spaces for consistency.
|
|
123
|
+
*/
|
|
124
|
+
function writePackageVersion(dir: string, newVersion: string): void {
|
|
125
|
+
const path = join(dir, PACKAGE_JSON);
|
|
126
|
+
const raw = readFileSync(path, "utf-8");
|
|
127
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
128
|
+
data.version = newVersion;
|
|
129
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Updates version in jsr.json if the file exists; preserves other keys.
|
|
134
|
+
*/
|
|
135
|
+
function syncJsrVersion(dir: string, newVersion: string): boolean {
|
|
136
|
+
const path = join(dir, JSR_JSON);
|
|
137
|
+
if (!existsSync(path)) return false;
|
|
138
|
+
const raw = readFileSync(path, "utf-8");
|
|
139
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
140
|
+
data.version = newVersion;
|
|
141
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run the bump command.
|
|
147
|
+
* Interactive (select direction and type) when TTY and args omitted; otherwise require both.
|
|
148
|
+
* With --dry-run, only prints old→new and files that would be updated.
|
|
149
|
+
*/
|
|
150
|
+
export async function run(args: ParsedArgs): Promise<void> {
|
|
151
|
+
const { cwd, bumpDirection, bumpType, dryRun, quiet } = args;
|
|
152
|
+
|
|
153
|
+
let direction: BumpDirection;
|
|
154
|
+
let type: BumpType;
|
|
155
|
+
|
|
156
|
+
const isTTY =
|
|
157
|
+
typeof process.stdin.isTTY === "boolean" && process.stdin.isTTY;
|
|
158
|
+
|
|
159
|
+
if (bumpDirection && bumpType) {
|
|
160
|
+
direction = bumpDirection;
|
|
161
|
+
type = bumpType;
|
|
162
|
+
} else if (!isTTY) {
|
|
163
|
+
console.error(
|
|
164
|
+
"gflows bump: when not in a TTY, both direction and type are required. Example: gflows bump up patch"
|
|
165
|
+
);
|
|
166
|
+
process.exit(EXIT_USER);
|
|
167
|
+
} else {
|
|
168
|
+
const { select } = await import("@inquirer/prompts");
|
|
169
|
+
direction = await select({
|
|
170
|
+
message: "Direction",
|
|
171
|
+
choices: [
|
|
172
|
+
{ name: "Up (bump)", value: "up" as const },
|
|
173
|
+
{ name: "Down (rollback)", value: "down" as const },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
type = await select({
|
|
177
|
+
message: "Type",
|
|
178
|
+
choices: [
|
|
179
|
+
{ name: "patch (x.y.Z)", value: "patch" as const },
|
|
180
|
+
{ name: "minor (x.Y.0)", value: "minor" as const },
|
|
181
|
+
{ name: "major (X.0.0)", value: "major" as const },
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { raw: oldVersion, semver } = readPackageVersion(cwd);
|
|
187
|
+
const newSemver =
|
|
188
|
+
direction === "up" ? bumpUp(semver, type) : bumpDown(semver, type);
|
|
189
|
+
const newVersion = formatVersion(newSemver);
|
|
190
|
+
|
|
191
|
+
const filesToUpdate: string[] = [PACKAGE_JSON];
|
|
192
|
+
if (existsSync(join(cwd, JSR_JSON))) {
|
|
193
|
+
filesToUpdate.push(JSR_JSON);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (dryRun) {
|
|
197
|
+
if (!quiet) {
|
|
198
|
+
console.error(`Would bump version: ${oldVersion} → ${newVersion}`);
|
|
199
|
+
console.error(`Would update: ${filesToUpdate.join(", ")}`);
|
|
200
|
+
}
|
|
201
|
+
process.exit(EXIT_OK);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
writePackageVersion(cwd, newVersion);
|
|
205
|
+
const jsrUpdated = syncJsrVersion(cwd, newVersion);
|
|
206
|
+
|
|
207
|
+
if (!quiet) {
|
|
208
|
+
console.error(`Bumped version: ${oldVersion} → ${newVersion}`);
|
|
209
|
+
const updated = [PACKAGE_JSON];
|
|
210
|
+
if (jsrUpdated) updated.push(JSR_JSON);
|
|
211
|
+
console.error(`Updated: ${updated.join(", ")}`);
|
|
212
|
+
}
|
|
213
|
+
}
|