@errhythm/gitmux 1.6.6 → 1.6.12
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/package.json +17 -4
- package/src/commands/portal.js +160 -96
- package/src/main.js +168 -114
- package/src/utils/update-check.js +105 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@errhythm/gitmux",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.12",
|
|
4
4
|
"description": "Multi-repo Git & GitLab workflow CLI — switch branches, manage epics & issues, create MRs, all from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
"bin",
|
|
12
12
|
"src"
|
|
13
13
|
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/errhythm/gitmux.git"
|
|
17
|
+
},
|
|
14
18
|
"engines": {
|
|
15
19
|
"node": ">=22.13.0"
|
|
16
20
|
},
|
|
@@ -30,14 +34,23 @@
|
|
|
30
34
|
"license": "MIT",
|
|
31
35
|
"main": "src/main.js",
|
|
32
36
|
"scripts": {
|
|
33
|
-
"test": "echo \"
|
|
37
|
+
"test": "echo \"No tests specified yet\""
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
34
42
|
},
|
|
35
43
|
"dependencies": {
|
|
36
44
|
"@inquirer/prompts": "^8.3.0",
|
|
45
|
+
"ansi-styles": "^6.2.3",
|
|
37
46
|
"boxen": "^8.0.1",
|
|
38
47
|
"chalk": "^5.6.2",
|
|
39
48
|
"enquirer": "^2.4.1",
|
|
40
|
-
"figlet": "^1.
|
|
41
|
-
"
|
|
49
|
+
"figlet": "^1.11.0",
|
|
50
|
+
"get-east-asian-width": "^1.5.0",
|
|
51
|
+
"listr2": "^10.2.1",
|
|
52
|
+
"string-width": "^8.2.0",
|
|
53
|
+
"strip-ansi": "^7.2.0",
|
|
54
|
+
"wrap-ansi": "^10.0.0"
|
|
42
55
|
}
|
|
43
56
|
}
|
package/src/commands/portal.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
isGitLabRemote,
|
|
18
18
|
detectGroupFromRepos,
|
|
19
19
|
getProjectPath,
|
|
20
|
+
getDefaultBranch,
|
|
20
21
|
slugify,
|
|
21
22
|
} from "../gitlab/helpers.js";
|
|
22
23
|
import { loadConfig, saveConfig } from "../config/index.js";
|
|
@@ -263,6 +264,11 @@ async function cmdIssueView(issue, glabRepos = [], portalConfig = {}) {
|
|
|
263
264
|
name: p.purple("⊞ Create merge request"),
|
|
264
265
|
description: p.muted("MR from ") + colorBranch(currentPrimary) + p.muted(" → default base branch"),
|
|
265
266
|
}] : []),
|
|
267
|
+
{
|
|
268
|
+
value: "createBranch",
|
|
269
|
+
name: p.green("+ Create branch"),
|
|
270
|
+
description: p.muted("create a new branch for this issue"),
|
|
271
|
+
},
|
|
266
272
|
{
|
|
267
273
|
value: "open",
|
|
268
274
|
name: p.teal("⊕ View in GitLab"),
|
|
@@ -286,6 +292,63 @@ async function cmdIssueView(issue, glabRepos = [], portalConfig = {}) {
|
|
|
286
292
|
return;
|
|
287
293
|
}
|
|
288
294
|
|
|
295
|
+
// Create a new branch for this issue
|
|
296
|
+
if (issueAction === "createBranch") {
|
|
297
|
+
const localRepo = findLocalRepo(glabRepos, projectPath);
|
|
298
|
+
const projectDefaultBranch = localRepo ? getDefaultBranch(localRepo.repo) : null;
|
|
299
|
+
const defaultBranchName = `feature/${localIid}-${slugify(issue.title)}`;
|
|
300
|
+
|
|
301
|
+
const { branchName } = await enquirer.prompt({
|
|
302
|
+
type: "input",
|
|
303
|
+
name: "branchName",
|
|
304
|
+
message: p.white("Branch name:"),
|
|
305
|
+
initial: defaultBranchName,
|
|
306
|
+
validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const { baseBranchName } = await enquirer.prompt({
|
|
310
|
+
type: "input",
|
|
311
|
+
name: "baseBranchName",
|
|
312
|
+
message: p.white("Base branch:"),
|
|
313
|
+
initial: projectDefaultBranch || portalConfig.defaultBaseBranch || "develop",
|
|
314
|
+
validate: (v) => v.trim() !== "" || "Base branch cannot be empty",
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const enc = encodeURIComponent(projectPath);
|
|
318
|
+
process.stdout.write(" " + p.muted("Creating branch…\r"));
|
|
319
|
+
try {
|
|
320
|
+
await glabApi(`projects/${enc}/repository/branches`, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
fields: { branch: branchName.trim(), ref: baseBranchName.trim() },
|
|
323
|
+
});
|
|
324
|
+
process.stdout.write(" ".repeat(40) + "\r");
|
|
325
|
+
console.log(
|
|
326
|
+
boxen(
|
|
327
|
+
chalk.bold(p.green("✔ Branch created")) + " " + colorBranch(branchName.trim()),
|
|
328
|
+
{ padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#4ade80" },
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
// Auto-set as primary if none is set yet
|
|
332
|
+
const cfgNow = loadConfig();
|
|
333
|
+
const pb = cfgNow.portal?.primaryBranches ?? {};
|
|
334
|
+
if (!pb[configKey]) {
|
|
335
|
+
pb[configKey] = branchName.trim();
|
|
336
|
+
saveConfig({ ...cfgNow, portal: { ...cfgNow.portal, primaryBranches: pb } });
|
|
337
|
+
console.log(" " + p.teal("★") + " " + p.muted("Set as primary branch"));
|
|
338
|
+
}
|
|
339
|
+
} catch (e) {
|
|
340
|
+
process.stdout.write(" ".repeat(40) + "\r");
|
|
341
|
+
console.log(
|
|
342
|
+
boxen(
|
|
343
|
+
chalk.bold(p.red("Branch creation failed")) + "\n\n" + p.muted(e.message.slice(0, 80)),
|
|
344
|
+
{ padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#f87171" },
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
console.log();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
289
352
|
// Set primary branch
|
|
290
353
|
const primaryChoice = await search({
|
|
291
354
|
message: p.white("Set primary branch:"),
|
|
@@ -477,62 +540,62 @@ async function cmdEpicMr(epicIssues, glabRepos, portalConfig, { autoConfirm = fa
|
|
|
477
540
|
console.log();
|
|
478
541
|
}
|
|
479
542
|
} else {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
543
|
+
// Multi-select: all ready repos pre-selected, space to toggle, Esc to go back
|
|
544
|
+
const choices = ready.map((c) => ({
|
|
545
|
+
name: c.localRepo.name,
|
|
546
|
+
value: c.localRepo.name,
|
|
547
|
+
message: chalk.bold(p.white(c.localRepo.name)) + " " + colorBranch(c.primary),
|
|
548
|
+
hint: "",
|
|
549
|
+
enabled: true, // pre-select all
|
|
550
|
+
}));
|
|
551
|
+
|
|
552
|
+
const selectedNames = await enquirer.autocomplete({
|
|
553
|
+
name: "repos",
|
|
554
|
+
message: "Select repos to create MRs for:",
|
|
555
|
+
multiple: true,
|
|
556
|
+
initial: 0,
|
|
557
|
+
limit: 12,
|
|
558
|
+
choices,
|
|
559
|
+
symbols: { indicator: { on: "◉", off: "◯" } },
|
|
560
|
+
footer() { return p.muted("space to toggle · type to filter · enter to confirm · esc to go back"); },
|
|
561
|
+
suggest(input = "", allChoices = []) {
|
|
562
|
+
const term = (input ?? "").toLowerCase().trim();
|
|
563
|
+
const selected = allChoices.filter((c) => c.enabled);
|
|
564
|
+
const unselected = allChoices.filter((c) => !c.enabled);
|
|
565
|
+
const filtered = term ? unselected.filter((c) => c.value.toLowerCase().includes(term)) : unselected;
|
|
566
|
+
return [...selected, ...filtered];
|
|
567
|
+
},
|
|
568
|
+
}).catch(() => "__back__");
|
|
506
569
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
570
|
+
console.log();
|
|
571
|
+
if (selectedNames === "__back__" || !Array.isArray(selectedNames) || selectedNames.length === 0) {
|
|
572
|
+
if (selectedNames !== "__back__") console.log(" " + p.muted("Nothing selected.\n"));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
512
575
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
576
|
+
selectedReady = selectedNames
|
|
577
|
+
.map((name) => ready.find((c) => c.localRepo.name === name))
|
|
578
|
+
.filter(Boolean);
|
|
516
579
|
} // end autoConfirm / interactive split
|
|
517
580
|
|
|
518
581
|
// Shared options — prompt once for all (skip prompts when auto-confirming)
|
|
519
582
|
const targetBranch = autoConfirm
|
|
520
583
|
? (portalConfig.defaultBaseBranch ?? "develop")
|
|
521
584
|
: (await enquirer.prompt({
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
585
|
+
type: "input",
|
|
586
|
+
name: "targetBranch",
|
|
587
|
+
message: p.white("Target branch") + p.muted(" (for all):"),
|
|
588
|
+
initial: portalConfig.defaultBaseBranch ?? "develop",
|
|
589
|
+
validate: (v) => v.trim() !== "" || "Required",
|
|
590
|
+
})).targetBranch;
|
|
528
591
|
|
|
529
592
|
const labels = autoConfirm
|
|
530
593
|
? (portalConfig.defaultLabels ?? "")
|
|
531
594
|
: await input({
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
595
|
+
message: p.white("Labels") + p.muted(" (optional, applied to all):"),
|
|
596
|
+
default: portalConfig.defaultLabels ?? "",
|
|
597
|
+
theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
|
|
598
|
+
});
|
|
536
599
|
|
|
537
600
|
const isDraft = autoConfirm
|
|
538
601
|
? false
|
|
@@ -547,9 +610,9 @@ async function cmdEpicMr(epicIssues, glabRepos, portalConfig, { autoConfirm = fa
|
|
|
547
610
|
const confirmed = autoConfirm
|
|
548
611
|
? true
|
|
549
612
|
: await confirm({
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
613
|
+
message: p.white("Create ") + p.cyan(String(selectedReady.length)) + p.white(` MR${selectedReady.length !== 1 ? "s" : ""}?`),
|
|
614
|
+
default: true, theme: THEME,
|
|
615
|
+
});
|
|
553
616
|
console.log();
|
|
554
617
|
if (!confirmed) return;
|
|
555
618
|
|
|
@@ -753,10 +816,10 @@ async function cmdEpicCheckout(epicIssues, glabRepos, { autoConfirm = false } =
|
|
|
753
816
|
const confirmed = autoConfirm
|
|
754
817
|
? true
|
|
755
818
|
: await confirm({
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
819
|
+
message: p.white("Checkout ") + p.cyan(String(checkouts.length)) + p.white(" repo" + (checkouts.length !== 1 ? "s" : "") + "?"),
|
|
820
|
+
default: true,
|
|
821
|
+
theme: THEME,
|
|
822
|
+
});
|
|
760
823
|
console.log();
|
|
761
824
|
if (!confirmed) return;
|
|
762
825
|
|
|
@@ -901,29 +964,29 @@ async function cmdEpicCrReview(epicIssues, glabRepos, portalConfig = {}) {
|
|
|
901
964
|
// ── Main portal command ────────────────────────────────────────────────────────
|
|
902
965
|
|
|
903
966
|
export async function cmdPortal(repos, {
|
|
904
|
-
settings
|
|
967
|
+
settings = false,
|
|
905
968
|
// ── non-interactive opts ────────────────────────────────────────────────────────────
|
|
906
|
-
epic:
|
|
907
|
-
checkout:
|
|
908
|
-
createMr:
|
|
909
|
-
createIssue:
|
|
910
|
-
review:
|
|
969
|
+
epic: cliEpic = null, // --epic <iid>
|
|
970
|
+
checkout: cliCheckout = false, // --checkout
|
|
971
|
+
createMr: cliCreateMr = false, // --create-mr
|
|
972
|
+
createIssue: cliCreateIssue = false, // --create-issue
|
|
973
|
+
review: cliReview = false, // --review
|
|
911
974
|
// shared
|
|
912
|
-
target:
|
|
913
|
-
title:
|
|
914
|
-
description:
|
|
915
|
-
labels:
|
|
916
|
-
draft:
|
|
917
|
-
noPush:
|
|
975
|
+
target: cliTarget = null, // --target
|
|
976
|
+
title: cliTitle = null, // --title (MR)
|
|
977
|
+
description: cliDescription = null,
|
|
978
|
+
labels: cliLabels = null,
|
|
979
|
+
draft: cliDraft = false,
|
|
980
|
+
noPush: cliNoPush = false,
|
|
918
981
|
// issue creation
|
|
919
|
-
issueProject:
|
|
920
|
-
issueTitle:
|
|
982
|
+
issueProject: cliIssueProject = null,
|
|
983
|
+
issueTitle: cliIssueTitle = null,
|
|
921
984
|
issueDescription: cliIssueDescription = null,
|
|
922
|
-
issueLabels:
|
|
923
|
-
branchName:
|
|
924
|
-
baseBranch:
|
|
985
|
+
issueLabels: cliIssueLabels = null,
|
|
986
|
+
branchName: cliBranchName = null,
|
|
987
|
+
baseBranch: cliBaseBranch = null,
|
|
925
988
|
// auto-confirm
|
|
926
|
-
yes:
|
|
989
|
+
yes: autoConfirm = false,
|
|
927
990
|
} = {}) {
|
|
928
991
|
// Detect CodeRabbit CLI once up front
|
|
929
992
|
const crAvailable = isCrAvailable();
|
|
@@ -933,28 +996,28 @@ export async function cmdPortal(repos, {
|
|
|
933
996
|
try {
|
|
934
997
|
execSync("glab version", { encoding: "utf8", stdio: "pipe" });
|
|
935
998
|
} catch {
|
|
936
|
-
const isMac
|
|
937
|
-
const isWin
|
|
999
|
+
const isMac = process.platform === "darwin";
|
|
1000
|
+
const isWin = process.platform === "win32";
|
|
938
1001
|
const isLinux = process.platform === "linux";
|
|
939
1002
|
|
|
940
1003
|
const platform = isMac ? "macOS" : isWin ? "Windows" : isLinux ? "Linux" : null;
|
|
941
1004
|
|
|
942
1005
|
const installLines = isMac
|
|
943
1006
|
? p.muted(" brew ") + p.cyan("brew install glab") + "\n" +
|
|
944
|
-
|
|
945
|
-
|
|
1007
|
+
p.muted(" MacPorts ") + p.cyan("sudo port install glab") + "\n" +
|
|
1008
|
+
p.muted(" asdf ") + p.cyan("asdf plugin add glab && asdf install glab latest")
|
|
946
1009
|
: isWin
|
|
947
|
-
|
|
1010
|
+
? p.muted(" winget ") + p.cyan("winget install glab.glab") + "\n" +
|
|
948
1011
|
p.muted(" choco ") + p.cyan("choco install glab") + "\n" +
|
|
949
1012
|
p.muted(" scoop ") + p.cyan("scoop install glab") + "\n" +
|
|
950
1013
|
p.muted(" brew ") + p.cyan("brew install glab") + p.muted(" (via WSL)")
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1014
|
+
: isLinux
|
|
1015
|
+
? p.muted(" brew ") + p.cyan("brew install glab") + "\n" +
|
|
1016
|
+
p.muted(" snap ") + p.cyan("sudo snap install glab && sudo snap connect glab:ssh-keys") + "\n" +
|
|
1017
|
+
p.muted(" apt ") + p.cyan("sudo apt install glab") + p.muted(" (WakeMeOps repo)") + "\n" +
|
|
1018
|
+
p.muted(" pacman ") + p.cyan("pacman -S glab") + "\n" +
|
|
1019
|
+
p.muted(" dnf ") + p.cyan("dnf install glab")
|
|
1020
|
+
: p.muted(" ") + p.cyan("https://gitlab.com/gitlab-org/cli#installation");
|
|
958
1021
|
|
|
959
1022
|
console.log(
|
|
960
1023
|
boxen(
|
|
@@ -1110,13 +1173,13 @@ export async function cmdPortal(repos, {
|
|
|
1110
1173
|
// --create-mr via portal (non-interactive MR command)
|
|
1111
1174
|
if (cliCreateMr && !cliEpic) {
|
|
1112
1175
|
return await cmdMr(repos, {
|
|
1113
|
-
target:
|
|
1114
|
-
title:
|
|
1176
|
+
target: cliTarget,
|
|
1177
|
+
title: cliTitle,
|
|
1115
1178
|
description: cliDescription,
|
|
1116
|
-
labels:
|
|
1117
|
-
draft:
|
|
1118
|
-
noPush:
|
|
1119
|
-
yes:
|
|
1179
|
+
labels: cliLabels,
|
|
1180
|
+
draft: cliDraft,
|
|
1181
|
+
noPush: cliNoPush,
|
|
1182
|
+
yes: autoConfirm,
|
|
1120
1183
|
});
|
|
1121
1184
|
}
|
|
1122
1185
|
|
|
@@ -1155,8 +1218,8 @@ export async function cmdPortal(repos, {
|
|
|
1155
1218
|
// Bulk epic MR — override shared options in portalConfig for non-interactive
|
|
1156
1219
|
const mergedPortalConfig = {
|
|
1157
1220
|
...portalConfig,
|
|
1158
|
-
...(cliTarget
|
|
1159
|
-
...(cliLabels
|
|
1221
|
+
...(cliTarget ? { defaultBaseBranch: cliTarget } : {}),
|
|
1222
|
+
...(cliLabels ? { defaultLabels: cliLabels } : {}),
|
|
1160
1223
|
};
|
|
1161
1224
|
return await cmdEpicMr(epicIssues, glabRepos, mergedPortalConfig, { autoConfirm });
|
|
1162
1225
|
}
|
|
@@ -1297,13 +1360,13 @@ export async function cmdPortal(repos, {
|
|
|
1297
1360
|
|
|
1298
1361
|
if (section === "mr") {
|
|
1299
1362
|
const r = await cmdMr(repos, {
|
|
1300
|
-
target:
|
|
1301
|
-
title:
|
|
1363
|
+
target: cliTarget,
|
|
1364
|
+
title: cliTitle,
|
|
1302
1365
|
description: cliDescription,
|
|
1303
|
-
labels:
|
|
1304
|
-
draft:
|
|
1305
|
-
noPush:
|
|
1306
|
-
yes:
|
|
1366
|
+
labels: cliLabels,
|
|
1367
|
+
draft: cliDraft,
|
|
1368
|
+
noPush: cliNoPush,
|
|
1369
|
+
yes: autoConfirm,
|
|
1307
1370
|
});
|
|
1308
1371
|
if (r !== "__back__") return r;
|
|
1309
1372
|
continue portalHome;
|
|
@@ -1657,11 +1720,12 @@ export async function cmdPortal(repos, {
|
|
|
1657
1720
|
validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
|
|
1658
1721
|
});
|
|
1659
1722
|
|
|
1723
|
+
const projectDefaultBranch = getDefaultBranch(projectChoice.repo);
|
|
1660
1724
|
const { baseBranchName } = await enquirer.prompt({
|
|
1661
1725
|
type: "input",
|
|
1662
1726
|
name: "baseBranchName",
|
|
1663
1727
|
message: p.white("Base branch:"),
|
|
1664
|
-
initial: portalConfig.defaultBaseBranch
|
|
1728
|
+
initial: projectDefaultBranch || portalConfig.defaultBaseBranch || "develop",
|
|
1665
1729
|
validate: (v) => v.trim() !== "" || "Base branch cannot be empty",
|
|
1666
1730
|
});
|
|
1667
1731
|
|
package/src/main.js
CHANGED
|
@@ -17,27 +17,32 @@ import { cmdSwitch } from "./commands/switch.js";
|
|
|
17
17
|
import { cmdMr } from "./commands/mr.js";
|
|
18
18
|
import { cmdPortal } from "./commands/portal.js";
|
|
19
19
|
import { cmdSettings } from "./commands/settings.js";
|
|
20
|
+
import { checkForUpdate, awaitUpdateCheck } from "./utils/update-check.js";
|
|
21
|
+
|
|
22
|
+
// Kick off the update check in the background immediately so the network
|
|
23
|
+
// request runs while the rest of startup happens.
|
|
24
|
+
checkForUpdate();
|
|
20
25
|
|
|
21
26
|
// ── Dependency checks ─────────────────────────────────────────────────────────
|
|
22
27
|
|
|
23
28
|
function checkRequiredDeps() {
|
|
24
29
|
// git — required by every command
|
|
25
30
|
try { execSync("git --version", { stdio: "pipe" }); } catch {
|
|
26
|
-
const isMac
|
|
27
|
-
const isWin
|
|
31
|
+
const isMac = process.platform === "darwin";
|
|
32
|
+
const isWin = process.platform === "win32";
|
|
28
33
|
const isLinux = process.platform === "linux";
|
|
29
34
|
|
|
30
35
|
const installLines = isMac
|
|
31
36
|
? p.muted(" brew ") + p.cyan("brew install git") + "\n" +
|
|
32
|
-
|
|
37
|
+
p.muted(" direct ") + p.cyan("https://git-scm.com/download/mac")
|
|
33
38
|
: isWin
|
|
34
|
-
|
|
39
|
+
? p.muted(" winget ") + p.cyan("winget install --id Git.Git") + "\n" +
|
|
35
40
|
p.muted(" direct ") + p.cyan("https://git-scm.com/download/win")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
: isLinux
|
|
42
|
+
? p.muted(" apt ") + p.cyan("sudo apt install git") + "\n" +
|
|
43
|
+
p.muted(" dnf ") + p.cyan("sudo dnf install git") + "\n" +
|
|
44
|
+
p.muted(" pacman ") + p.cyan("sudo pacman -S git")
|
|
45
|
+
: p.muted(" ") + p.cyan("https://git-scm.com/downloads");
|
|
41
46
|
|
|
42
47
|
console.log(
|
|
43
48
|
boxen(
|
|
@@ -45,10 +50,10 @@ function checkRequiredDeps() {
|
|
|
45
50
|
p.white("gitmux requires git. Install it:\n\n") +
|
|
46
51
|
installLines,
|
|
47
52
|
{
|
|
48
|
-
padding:
|
|
49
|
-
borderStyle:
|
|
50
|
-
borderColor:
|
|
51
|
-
title:
|
|
53
|
+
padding: { top: 1, bottom: 1, left: 3, right: 3 },
|
|
54
|
+
borderStyle: "round",
|
|
55
|
+
borderColor: "#f87171",
|
|
56
|
+
title: p.red(" missing dependency: git "),
|
|
52
57
|
titleAlignment: "center",
|
|
53
58
|
},
|
|
54
59
|
),
|
|
@@ -108,55 +113,55 @@ function parseArgs(rawArgs) {
|
|
|
108
113
|
branch: isSubcmd ? (positional[1] ?? "") : first,
|
|
109
114
|
|
|
110
115
|
// ── Switch flags ────────────────────────────────────────────────────────
|
|
111
|
-
pull:
|
|
112
|
-
fuzzy:
|
|
113
|
-
create:
|
|
114
|
-
stash:
|
|
115
|
-
fetch:
|
|
116
|
-
dryRun:
|
|
116
|
+
pull: flags.has("--pull") || flags.has("-p"),
|
|
117
|
+
fuzzy: flags.has("--fuzzy") || flags.has("-f"),
|
|
118
|
+
create: flags.has("--create") || flags.has("-c"),
|
|
119
|
+
stash: flags.has("--stash") || flags.has("-s"),
|
|
120
|
+
fetch: flags.has("--fetch"),
|
|
121
|
+
dryRun: flags.has("--dry-run"),
|
|
117
122
|
|
|
118
123
|
// ── Global ──────────────────────────────────────────────────────────────
|
|
119
|
-
version:
|
|
120
|
-
help:
|
|
124
|
+
version: flags.has("--version") || flags.has("-v"),
|
|
125
|
+
help: flags.has("--help") || flags.has("-h"),
|
|
121
126
|
settings: flags.has("--settings"),
|
|
122
|
-
debug:
|
|
123
|
-
yes:
|
|
124
|
-
depth:
|
|
125
|
-
exclude:
|
|
126
|
-
filter:
|
|
127
|
+
debug: flags.has("--debug"),
|
|
128
|
+
yes: flags.has("--yes") || flags.has("-y"),
|
|
129
|
+
depth: parseInt(options.depth ?? String(DEFAULT_DEPTH), 10) || DEFAULT_DEPTH,
|
|
130
|
+
exclude: options.exclude ?? null,
|
|
131
|
+
filter: options.filter ?? null,
|
|
127
132
|
|
|
128
133
|
// ── MR flags ─────────────────────────────────────────────────────────────
|
|
129
134
|
// --target / -t <branch> — MR target branch
|
|
130
|
-
target:
|
|
135
|
+
target: options.target ?? options.t ?? null,
|
|
131
136
|
// --repo <name> (repeatable) — restrict MR to these repos
|
|
132
|
-
mrRepos:
|
|
137
|
+
mrRepos: repos.length > 0 ? repos : null,
|
|
133
138
|
// --title / --description / --labels — MR or issue metadata
|
|
134
|
-
title:
|
|
139
|
+
title: options.title ?? null,
|
|
135
140
|
description: options.description ?? null,
|
|
136
|
-
labels:
|
|
141
|
+
labels: options.labels ?? null,
|
|
137
142
|
// --draft — mark MR as draft
|
|
138
|
-
draft:
|
|
143
|
+
draft: flags.has("--draft"),
|
|
139
144
|
// --no-push — skip git push before MR
|
|
140
|
-
noPush:
|
|
145
|
+
noPush: flags.has("--no-push"),
|
|
141
146
|
|
|
142
147
|
// ── Portal flags ──────────────────────────────────────────────────────────
|
|
143
148
|
// --epic <iid> — select epic by IID non-interactively
|
|
144
|
-
epic:
|
|
149
|
+
epic: options.epic ?? null,
|
|
145
150
|
// --checkout — run epic checkout without menus
|
|
146
|
-
checkout:
|
|
151
|
+
checkout: flags.has("--checkout"),
|
|
147
152
|
// --create-mr — create bulk MRs for epic without menus
|
|
148
|
-
createMr:
|
|
153
|
+
createMr: flags.has("--create-mr"),
|
|
149
154
|
// --create-issue — go straight to issue creation
|
|
150
|
-
createIssue:
|
|
155
|
+
createIssue: flags.has("--create-issue"),
|
|
151
156
|
// --review — run cr review on all primary branches of the epic
|
|
152
|
-
review:
|
|
157
|
+
review: flags.has("--review"),
|
|
153
158
|
// Issue creation params
|
|
154
|
-
issueProject:
|
|
155
|
-
issueTitle:
|
|
159
|
+
issueProject: options.issueProject ?? null,
|
|
160
|
+
issueTitle: options.issueTitle ?? null,
|
|
156
161
|
issueDescription: options.issueDescription ?? null,
|
|
157
|
-
issueLabels:
|
|
158
|
-
branchName:
|
|
159
|
-
baseBranch:
|
|
162
|
+
issueLabels: options.issueLabels ?? null,
|
|
163
|
+
branchName: options.branchName ?? null,
|
|
164
|
+
baseBranch: options.baseBranch ?? null,
|
|
160
165
|
};
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -189,6 +194,27 @@ export async function main() {
|
|
|
189
194
|
printLogo();
|
|
190
195
|
checkRequiredDeps();
|
|
191
196
|
|
|
197
|
+
// Show update notice if available (result already fetched in background)
|
|
198
|
+
const updateInfo = await awaitUpdateCheck();
|
|
199
|
+
if (updateInfo) {
|
|
200
|
+
const notice =
|
|
201
|
+
chalk.green("◆") + " " +
|
|
202
|
+
chalk.bold(p.white("update available")) + " " +
|
|
203
|
+
p.muted("·") + " " +
|
|
204
|
+
p.muted(updateInfo.current) + p.muted(" → ") + chalk.bold(p.green(updateInfo.latest)) + " " +
|
|
205
|
+
p.muted("·") + " " +
|
|
206
|
+
p.cyan(`npm i -g @errhythm/gitmux@${updateInfo.latest}`);
|
|
207
|
+
|
|
208
|
+
console.log(
|
|
209
|
+
boxen(notice, {
|
|
210
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
211
|
+
borderStyle: "round",
|
|
212
|
+
borderColor: "#22c55e",
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
console.log();
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
const cwd = process.cwd();
|
|
193
219
|
let repos = findRepos(cwd, opts.depth);
|
|
194
220
|
|
|
@@ -246,38 +272,38 @@ export async function main() {
|
|
|
246
272
|
|
|
247
273
|
if (opts.subcommand === "mr") {
|
|
248
274
|
return await cmdMr(repos, {
|
|
249
|
-
target:
|
|
250
|
-
mrRepos:
|
|
251
|
-
title:
|
|
275
|
+
target: opts.target,
|
|
276
|
+
mrRepos: opts.mrRepos,
|
|
277
|
+
title: opts.title,
|
|
252
278
|
description: opts.description,
|
|
253
|
-
labels:
|
|
254
|
-
draft:
|
|
255
|
-
noPush:
|
|
256
|
-
yes:
|
|
279
|
+
labels: opts.labels,
|
|
280
|
+
draft: opts.draft,
|
|
281
|
+
noPush: opts.noPush,
|
|
282
|
+
yes: opts.yes,
|
|
257
283
|
});
|
|
258
284
|
}
|
|
259
285
|
|
|
260
286
|
if (opts.subcommand === "portal") {
|
|
261
287
|
return await cmdPortal(repos, {
|
|
262
|
-
settings:
|
|
263
|
-
epic:
|
|
264
|
-
checkout:
|
|
265
|
-
createMr:
|
|
266
|
-
createIssue:
|
|
267
|
-
review:
|
|
268
|
-
target:
|
|
269
|
-
title:
|
|
270
|
-
description:
|
|
271
|
-
labels:
|
|
272
|
-
draft:
|
|
273
|
-
noPush:
|
|
274
|
-
issueProject:
|
|
275
|
-
issueTitle:
|
|
288
|
+
settings: opts.settings,
|
|
289
|
+
epic: opts.epic,
|
|
290
|
+
checkout: opts.checkout,
|
|
291
|
+
createMr: opts.createMr,
|
|
292
|
+
createIssue: opts.createIssue,
|
|
293
|
+
review: opts.review,
|
|
294
|
+
target: opts.target,
|
|
295
|
+
title: opts.title,
|
|
296
|
+
description: opts.description,
|
|
297
|
+
labels: opts.labels,
|
|
298
|
+
draft: opts.draft,
|
|
299
|
+
noPush: opts.noPush,
|
|
300
|
+
issueProject: opts.issueProject,
|
|
301
|
+
issueTitle: opts.issueTitle,
|
|
276
302
|
issueDescription: opts.issueDescription,
|
|
277
|
-
issueLabels:
|
|
278
|
-
branchName:
|
|
279
|
-
baseBranch:
|
|
280
|
-
yes:
|
|
303
|
+
issueLabels: opts.issueLabels,
|
|
304
|
+
branchName: opts.branchName,
|
|
305
|
+
baseBranch: opts.baseBranch,
|
|
306
|
+
yes: opts.yes,
|
|
281
307
|
});
|
|
282
308
|
}
|
|
283
309
|
|
|
@@ -303,22 +329,31 @@ export async function main() {
|
|
|
303
329
|
p.muted(` ${repos.length === 1 ? "repo" : "repos"} in scope\n`),
|
|
304
330
|
);
|
|
305
331
|
|
|
306
|
-
//
|
|
332
|
+
// Helper to detect ESC / ExitPromptError from @inquirer/prompts
|
|
333
|
+
const isEsc = (e) => e?.name === "ExitPromptError";
|
|
334
|
+
|
|
335
|
+
// Mode selector — loop so ESC / "go back" from sub-commands returns here
|
|
307
336
|
modeLoop: while (true) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
337
|
+
let mode;
|
|
338
|
+
try {
|
|
339
|
+
mode = await select({
|
|
340
|
+
message: p.white("What do you want to do?"),
|
|
341
|
+
choices: [
|
|
342
|
+
{
|
|
343
|
+
value: "switch",
|
|
344
|
+
name: p.cyan("⇌ Switch branches") + p.muted(" checkout a branch across all repos"),
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
value: "portal",
|
|
348
|
+
name: chalk.hex("#FC6D26")("◈ GitLab") + p.muted(" development portal — epics, MRs & branches"),
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
theme: THEME,
|
|
352
|
+
});
|
|
353
|
+
} catch (e) {
|
|
354
|
+
if (isEsc(e)) { console.log("\n" + p.muted(" Exited.\n")); return 0; }
|
|
355
|
+
throw e;
|
|
356
|
+
}
|
|
322
357
|
|
|
323
358
|
console.log();
|
|
324
359
|
|
|
@@ -328,47 +363,66 @@ export async function main() {
|
|
|
328
363
|
continue;
|
|
329
364
|
}
|
|
330
365
|
|
|
331
|
-
// ── Switch branch flow
|
|
366
|
+
// ── Switch branch flow ─────────────────────────────────────────────────
|
|
367
|
+
// ESC at any prompt navigates back one level; no explicit "← Go back" needed.
|
|
332
368
|
const branchSuggestions = getSwitchBranchSuggestions(config);
|
|
333
369
|
|
|
334
370
|
if (branchSuggestions.length > 0) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
371
|
+
// Inner loop so ESC on the custom-branch input returns to suggestions.
|
|
372
|
+
suggestionLoop: while (true) {
|
|
373
|
+
let branchChoice;
|
|
374
|
+
try {
|
|
375
|
+
branchChoice = await select({
|
|
376
|
+
message: p.white("Suggested branch:"),
|
|
377
|
+
choices: [
|
|
378
|
+
...branchSuggestions.map((item) => ({
|
|
379
|
+
value: item.value,
|
|
380
|
+
name: colorBranch(item.value),
|
|
381
|
+
description: p.muted(item.template),
|
|
382
|
+
})),
|
|
383
|
+
{
|
|
384
|
+
value: "__custom__",
|
|
385
|
+
name: p.white("Custom branch..."),
|
|
386
|
+
description: p.muted("type any branch name or partial"),
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
theme: THEME,
|
|
390
|
+
});
|
|
391
|
+
} catch (e) {
|
|
392
|
+
if (isEsc(e)) { console.log(); continue modeLoop; } // ESC → mode selector
|
|
393
|
+
throw e;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
console.log();
|
|
397
|
+
|
|
398
|
+
if (branchChoice === "__custom__") {
|
|
399
|
+
try {
|
|
400
|
+
targetBranch = await input({
|
|
401
|
+
message: p.white("Branch name") + p.muted(" (or partial):"),
|
|
402
|
+
theme: THEME,
|
|
403
|
+
validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
|
|
404
|
+
});
|
|
405
|
+
} catch (e) {
|
|
406
|
+
if (isEsc(e)) { console.log(); continue suggestionLoop; } // ESC → suggestions
|
|
407
|
+
throw e;
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
targetBranch = branchChoice;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
break suggestionLoop;
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
try {
|
|
358
417
|
targetBranch = await input({
|
|
359
418
|
message: p.white("Branch name") + p.muted(" (or partial):"),
|
|
360
419
|
theme: THEME,
|
|
361
420
|
validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
|
|
362
421
|
});
|
|
363
|
-
}
|
|
364
|
-
|
|
422
|
+
} catch (e) {
|
|
423
|
+
if (isEsc(e)) { console.log(); continue modeLoop; } // ESC → mode selector
|
|
424
|
+
throw e;
|
|
365
425
|
}
|
|
366
|
-
} else {
|
|
367
|
-
targetBranch = await input({
|
|
368
|
-
message: p.white("Branch name") + p.muted(" (or partial):"),
|
|
369
|
-
theme: THEME,
|
|
370
|
-
validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
|
|
371
|
-
});
|
|
372
426
|
}
|
|
373
427
|
|
|
374
428
|
break modeLoop; // branch was chosen — proceed to confirm prompts
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-blocking npm update checker.
|
|
3
|
+
*
|
|
4
|
+
* - Checks the npm registry for a newer version of the package.
|
|
5
|
+
* - Throttled to at most once per day using a cache entry in the gitmux config.
|
|
6
|
+
* - Run checkForUpdate() early (don't await) to fetch in the background, then
|
|
7
|
+
* call awaitUpdateCheck() before blocking on user input to show the notice.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createRequire } from "module";
|
|
11
|
+
import { loadConfig, saveConfig } from "../config/index.js";
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
|
|
15
|
+
// Read own package.json without dynamic import (avoids assertion syntax issues)
|
|
16
|
+
let PKG_NAME = "@errhythm/gitmux";
|
|
17
|
+
let PKG_VERSION = "0.0.0";
|
|
18
|
+
try {
|
|
19
|
+
// Walk up from this file: src/utils/ → src/ → root
|
|
20
|
+
const pkg = require("../../package.json");
|
|
21
|
+
PKG_NAME = pkg.name;
|
|
22
|
+
PKG_VERSION = pkg.version;
|
|
23
|
+
} catch { /* ignore */ }
|
|
24
|
+
|
|
25
|
+
/** ms in one day */
|
|
26
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
/** Registry URL — use the unscoped form to avoid encoding issues */
|
|
29
|
+
function registryUrl(name) {
|
|
30
|
+
return `https://registry.npmjs.org/${encodeURIComponent(name).replace("%40", "@")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Internal promise handle — resolved by checkForUpdate()
|
|
34
|
+
let _resolve;
|
|
35
|
+
let _updatePromise = new Promise((r) => { _resolve = r; });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Kick off the update check in the background.
|
|
39
|
+
* Call this EARLY in startup (do NOT await).
|
|
40
|
+
*/
|
|
41
|
+
export function checkForUpdate() {
|
|
42
|
+
(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
const cache = cfg._updateCheck ?? {};
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
|
|
48
|
+
// Only hit the network once per day
|
|
49
|
+
if (cache.lastChecked && (now - cache.lastChecked) < ONE_DAY_MS) {
|
|
50
|
+
_resolve({ latest: cache.latest ?? null, current: PKG_VERSION });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const timeout = setTimeout(() => controller.abort(), 4000);
|
|
56
|
+
|
|
57
|
+
let latest = null;
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(registryUrl(PKG_NAME), { signal: controller.signal });
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
latest = data["dist-tags"]?.latest ?? null;
|
|
62
|
+
} catch {
|
|
63
|
+
// Network error / timeout — silently skip
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Persist the result so we don't hammer npm
|
|
69
|
+
if (latest) {
|
|
70
|
+
saveConfig({
|
|
71
|
+
...loadConfig(),
|
|
72
|
+
_updateCheck: { lastChecked: now, latest },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_resolve({ latest, current: PKG_VERSION });
|
|
77
|
+
} catch {
|
|
78
|
+
_resolve({ latest: null, current: PKG_VERSION });
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Await the background check and return a boxen-ready notice string,
|
|
85
|
+
* or null if the user is already on the latest version.
|
|
86
|
+
*/
|
|
87
|
+
export async function awaitUpdateCheck() {
|
|
88
|
+
const { latest, current } = await _updatePromise;
|
|
89
|
+
if (!latest) return null;
|
|
90
|
+
|
|
91
|
+
// Simple semver comparison (major.minor.patch — no pre-release needed)
|
|
92
|
+
if (!isNewer(latest, current)) return null;
|
|
93
|
+
|
|
94
|
+
return { latest, current };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Returns true if `candidate` is strictly newer than `baseline`. */
|
|
98
|
+
function isNewer(candidate, baseline) {
|
|
99
|
+
const toNum = (v) => v.split(".").map(Number);
|
|
100
|
+
const [ma, mi, pa] = toNum(candidate);
|
|
101
|
+
const [mb, mib, pb] = toNum(baseline);
|
|
102
|
+
if (ma !== mb) return ma > mb;
|
|
103
|
+
if (mi !== mib) return mi > mib;
|
|
104
|
+
return pa > pb;
|
|
105
|
+
}
|