@harryisfish/gitt 1.4.0 → 1.5.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.
- package/README.md +58 -1
- package/dist/commands/clean.js +111 -68
- package/dist/index.js +38 -1
- package/dist/utils/git.js +59 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -18,8 +18,10 @@ pnpm add -g @harryisfish/gitt
|
|
|
18
18
|
|
|
19
19
|
## Usage
|
|
20
20
|
|
|
21
|
+
### Basic Commands
|
|
22
|
+
|
|
21
23
|
```bash
|
|
22
|
-
# Default behavior (auto-clean)
|
|
24
|
+
# Default behavior (auto-clean deleted branches)
|
|
23
25
|
gitt
|
|
24
26
|
|
|
25
27
|
# Interactive mode (select branches to delete)
|
|
@@ -31,8 +33,63 @@ gitt --interactive
|
|
|
31
33
|
gitt -d
|
|
32
34
|
# or
|
|
33
35
|
gitt --dry-run
|
|
36
|
+
|
|
37
|
+
# Check version
|
|
38
|
+
gitt -v
|
|
39
|
+
# or
|
|
40
|
+
gitt --version
|
|
41
|
+
|
|
42
|
+
# Show help
|
|
43
|
+
gitt -h
|
|
44
|
+
# or
|
|
45
|
+
gitt --help
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Stale Branch Cleaning (NEW)
|
|
49
|
+
|
|
50
|
+
Clean up branches that haven't been updated for a long time:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Find branches inactive for 90+ days (default)
|
|
54
|
+
gitt --stale
|
|
55
|
+
|
|
56
|
+
# Custom threshold (e.g., 30 days)
|
|
57
|
+
gitt --stale 30
|
|
58
|
+
|
|
59
|
+
# Preview stale branches without deleting
|
|
60
|
+
gitt --stale --dry-run
|
|
61
|
+
|
|
62
|
+
# Interactively select stale branches to delete
|
|
63
|
+
gitt --stale -i
|
|
34
64
|
```
|
|
35
65
|
|
|
66
|
+
**Note:** Stale branch detection:
|
|
67
|
+
- Checks the last commit date on each branch
|
|
68
|
+
- Automatically excludes the main branch
|
|
69
|
+
- Protects branches in use by Git worktrees
|
|
70
|
+
- Can be combined with interactive (`-i`) and dry-run (`-d`) modes
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## Features
|
|
74
|
+
|
|
75
|
+
### 🔄 Auto Update Notification
|
|
76
|
+
Gitt automatically checks for updates on each run and notifies you when a new version is available.
|
|
77
|
+
|
|
78
|
+
### 🌳 Worktree Protection
|
|
79
|
+
Branches currently checked out in Git worktrees are automatically protected from deletion to prevent errors.
|
|
80
|
+
|
|
81
|
+
### 🧹 Smart Branch Cleanup
|
|
82
|
+
- **Remote-deleted branches**: Automatically detect and clean branches removed from remote
|
|
83
|
+
- **Stale branches**: Find and remove branches inactive for X days
|
|
84
|
+
- **Merge status check**: Safely handles both merged and unmerged branches
|
|
85
|
+
- **Interactive mode**: Manual selection with clear indicators
|
|
86
|
+
- **Dry-run mode**: Preview changes before applying
|
|
87
|
+
|
|
88
|
+
### 🛡️ Branch Protection
|
|
89
|
+
- Honors `.gitt` ignore patterns
|
|
90
|
+
- Respects Git worktree usage
|
|
91
|
+
- Never touches the main branch
|
|
92
|
+
|
|
36
93
|
## Configuration
|
|
37
94
|
|
|
38
95
|
### Main Branch Detection
|
package/dist/commands/clean.js
CHANGED
|
@@ -16,7 +16,12 @@ const git = (0, simple_git_1.simpleGit)();
|
|
|
16
16
|
*/
|
|
17
17
|
async function cleanDeletedBranches(options = {}) {
|
|
18
18
|
try {
|
|
19
|
-
const
|
|
19
|
+
const state = {
|
|
20
|
+
mainBranch: '',
|
|
21
|
+
deletedBranches: []
|
|
22
|
+
};
|
|
23
|
+
// Phase 1: Discovery
|
|
24
|
+
const discoveryTasks = new listr2_1.Listr([
|
|
20
25
|
{
|
|
21
26
|
title: 'Fetch main from remote',
|
|
22
27
|
task: async (ctx) => {
|
|
@@ -28,100 +33,138 @@ async function cleanDeletedBranches(options = {}) {
|
|
|
28
33
|
{
|
|
29
34
|
title: 'Switch to main branch',
|
|
30
35
|
task: async (ctx) => {
|
|
31
|
-
|
|
36
|
+
// Check if we are on a branch that will be deleted?
|
|
37
|
+
// For now, just try to switch to main to be safe.
|
|
38
|
+
// But if main is checked out in another worktree, this might fail?
|
|
39
|
+
// Let's just try.
|
|
40
|
+
try {
|
|
41
|
+
await git.checkout(ctx.mainBranch);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
// If we can't checkout main (e.g. dirty state), warn but continue?
|
|
45
|
+
// Ideally we should be on main to delete other branches safely.
|
|
46
|
+
}
|
|
32
47
|
}
|
|
33
48
|
},
|
|
34
49
|
{
|
|
35
50
|
title: 'Sync main with remote',
|
|
36
51
|
task: async () => {
|
|
37
|
-
|
|
52
|
+
try {
|
|
53
|
+
await git.pull();
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
// Ignore pull errors (e.g. if not on branch)
|
|
57
|
+
}
|
|
38
58
|
}
|
|
39
59
|
},
|
|
40
60
|
{
|
|
41
|
-
title: '
|
|
61
|
+
title: 'Analyze branches',
|
|
42
62
|
task: async (ctx) => {
|
|
43
63
|
await git.fetch(['--prune']);
|
|
44
64
|
const branchSummary = await git.branch(['-vv']);
|
|
45
65
|
const config = await (0, config_1.readConfigFile)();
|
|
46
66
|
const ignorePatterns = config.ignoreBranches || [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
67
|
+
const worktreeBranches = await (0, git_1.getWorktrees)();
|
|
68
|
+
let candidates = [];
|
|
69
|
+
if (options.stale) {
|
|
70
|
+
// Stale mode: check all local branches
|
|
71
|
+
const allBranches = branchSummary.all;
|
|
72
|
+
for (const branch of allBranches) {
|
|
73
|
+
if (branch === ctx.mainBranch)
|
|
74
|
+
continue;
|
|
75
|
+
const days = await (0, git_1.getBranchLastCommitTime)(branch);
|
|
76
|
+
if (days > (options.staleDays || 90)) {
|
|
77
|
+
candidates.push({
|
|
78
|
+
name: branch,
|
|
79
|
+
reason: `Stale (${days} days)`
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Default mode: check "gone" branches
|
|
86
|
+
const goneBranches = branchSummary.all.filter(branch => {
|
|
87
|
+
const branchInfo = branchSummary.branches[branch];
|
|
88
|
+
return branchInfo.label && branchInfo.label.includes(': gone]');
|
|
89
|
+
});
|
|
90
|
+
candidates = goneBranches.map(b => ({ name: b, reason: 'Remote deleted' }));
|
|
91
|
+
}
|
|
51
92
|
// Filter out ignored branches
|
|
52
93
|
if (ignorePatterns.length > 0) {
|
|
53
|
-
|
|
54
|
-
const isIgnored = ignorePatterns.some(pattern => (0, minimatch_1.minimatch)(
|
|
94
|
+
candidates = candidates.filter(c => {
|
|
95
|
+
const isIgnored = ignorePatterns.some(pattern => (0, minimatch_1.minimatch)(c.name, pattern));
|
|
55
96
|
return !isIgnored;
|
|
56
97
|
});
|
|
57
98
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// Pause the listr task to show prompt
|
|
67
|
-
// We need to stop the spinner to show the prompt clearly
|
|
68
|
-
// But listr2 doesn't make it easy to pause/resume in the middle of a task flow easily without custom renderers
|
|
69
|
-
// So we will do the prompt and then update the context
|
|
70
|
-
// Actually, listr2 supports prompts but it's complex.
|
|
71
|
-
// Let's try to use the prompt directly.
|
|
72
|
-
// We might need to handle the UI interference.
|
|
73
|
-
// For now, let's just run the prompt.
|
|
74
|
-
// In a real TTY, listr2 might fight for control.
|
|
75
|
-
// A common pattern is to run the prompt outside listr or use listr's prompt adapter.
|
|
76
|
-
// But here we are inside a task.
|
|
77
|
-
// Let's try to use the prompt.
|
|
78
|
-
try {
|
|
79
|
-
const selected = await (0, prompts_1.checkbox)({
|
|
80
|
-
message: 'Select branches to delete:',
|
|
81
|
-
choices: branches.map(b => ({ name: b, value: b, checked: true })),
|
|
82
|
-
});
|
|
83
|
-
ctx.deletedBranches = selected;
|
|
84
|
-
}
|
|
85
|
-
catch (e) {
|
|
86
|
-
// If user cancels, we might want to abort or just delete nothing
|
|
87
|
-
ctx.deletedBranches = [];
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
title: 'Dry Run: List branches to be deleted',
|
|
93
|
-
enabled: (ctx) => !!options.dryRun && Array.isArray(ctx.deletedBranches) && ctx.deletedBranches.length > 0,
|
|
94
|
-
task: (ctx, task) => {
|
|
95
|
-
const branches = ctx.deletedBranches;
|
|
96
|
-
task.output = `The following branches would be deleted:\n${branches.map(b => ` - ${b}`).join('\n')}`;
|
|
97
|
-
// We don't want to fail, just show info.
|
|
98
|
-
// But listr output is transient.
|
|
99
|
-
// We will print it at the end or use a separate log.
|
|
100
|
-
console.log('\nDry Run: The following branches would be deleted:');
|
|
101
|
-
branches.forEach(b => console.log(` - ${b}`));
|
|
102
|
-
ctx.deletedBranches = []; // Clear so next step does nothing
|
|
99
|
+
// Filter out worktree branches
|
|
100
|
+
candidates = candidates.filter(c => !worktreeBranches.includes(c.name));
|
|
101
|
+
// Check merge status for each branch
|
|
102
|
+
const branchesWithStatus = await Promise.all(candidates.map(async (c) => {
|
|
103
|
+
const isMerged = await (0, git_1.isBranchMerged)(c.name, ctx.mainBranch);
|
|
104
|
+
return { ...c, isMerged };
|
|
105
|
+
}));
|
|
106
|
+
ctx.deletedBranches = branchesWithStatus;
|
|
103
107
|
}
|
|
104
|
-
}
|
|
108
|
+
}
|
|
109
|
+
]);
|
|
110
|
+
await discoveryTasks.run(state);
|
|
111
|
+
// Phase 2: Interaction / Filtering
|
|
112
|
+
if (state.deletedBranches.length === 0) {
|
|
113
|
+
(0, errors_2.printSuccess)('No branches need to be cleaned up');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (options.interactive) {
|
|
117
|
+
try {
|
|
118
|
+
const choices = state.deletedBranches.map(b => ({
|
|
119
|
+
name: `${b.name} (${b.reason}${b.isMerged ? '' : ', Unmerged'})`,
|
|
120
|
+
value: b,
|
|
121
|
+
checked: b.isMerged // Only check merged branches by default
|
|
122
|
+
}));
|
|
123
|
+
const selected = await (0, prompts_1.checkbox)({
|
|
124
|
+
message: 'Select branches to delete:',
|
|
125
|
+
choices: choices,
|
|
126
|
+
});
|
|
127
|
+
state.deletedBranches = selected;
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
// User cancelled
|
|
131
|
+
throw new Error('Operation cancelled');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
// Auto mode: Filter out unmerged branches
|
|
136
|
+
const unmerged = state.deletedBranches.filter(b => !b.isMerged);
|
|
137
|
+
if (unmerged.length > 0) {
|
|
138
|
+
console.log('\nSkipping unmerged branches (use -i to force delete):');
|
|
139
|
+
unmerged.forEach(b => console.log(` - ${b.name} (${b.reason})`));
|
|
140
|
+
}
|
|
141
|
+
state.deletedBranches = state.deletedBranches.filter(b => b.isMerged);
|
|
142
|
+
}
|
|
143
|
+
if (state.deletedBranches.length === 0) {
|
|
144
|
+
(0, errors_2.printSuccess)('No branches selected for deletion');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (options.dryRun) {
|
|
148
|
+
console.log('\nDry Run: The following branches would be deleted:');
|
|
149
|
+
state.deletedBranches.forEach(b => console.log(` - ${b.name} (${b.reason})`));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Phase 3: Execution
|
|
153
|
+
const deleteTasks = new listr2_1.Listr([
|
|
105
154
|
{
|
|
106
|
-
title: 'Delete branches
|
|
107
|
-
enabled: (ctx) => Array.isArray(ctx.deletedBranches),
|
|
108
|
-
skip: (ctx) => {
|
|
109
|
-
if (!ctx.deletedBranches || ctx.deletedBranches.length === 0) {
|
|
110
|
-
return 'No branches need to be cleaned up';
|
|
111
|
-
}
|
|
112
|
-
return false;
|
|
113
|
-
},
|
|
155
|
+
title: 'Delete branches',
|
|
114
156
|
task: (ctx) => {
|
|
115
|
-
return new listr2_1.Listr(
|
|
116
|
-
title: `Delete ${branch}`,
|
|
157
|
+
return new listr2_1.Listr(state.deletedBranches.map(branch => ({
|
|
158
|
+
title: `Delete ${branch.name}`,
|
|
117
159
|
task: async () => {
|
|
118
|
-
|
|
160
|
+
// Always use -D to force delete if we are here (user confirmed or it's merged)
|
|
161
|
+
await git.branch(['-D', branch.name]);
|
|
119
162
|
}
|
|
120
163
|
})), { concurrent: false });
|
|
121
164
|
}
|
|
122
165
|
}
|
|
123
166
|
]);
|
|
124
|
-
await
|
|
167
|
+
await deleteTasks.run(state);
|
|
125
168
|
(0, errors_2.printSuccess)('Branch cleanup completed');
|
|
126
169
|
}
|
|
127
170
|
catch (error) {
|
package/dist/index.js
CHANGED
|
@@ -86,12 +86,16 @@ Commands:
|
|
|
86
86
|
Options:
|
|
87
87
|
-i, --interactive Interactive mode: Select branches to delete
|
|
88
88
|
-d, --dry-run Dry run: Show what would be deleted without deleting
|
|
89
|
+
--stale [days] Find stale branches (default: 90 days, use with a number for custom)
|
|
90
|
+
-v, --version Show version number
|
|
89
91
|
-h, --help Show this help message
|
|
90
92
|
|
|
91
93
|
Examples:
|
|
92
94
|
gitt # Auto-clean deleted branches
|
|
93
95
|
gitt -i # Select branches to delete interactively
|
|
94
96
|
gitt -d # Preview deletion
|
|
97
|
+
gitt --stale # Find branches inactive for 90+ days
|
|
98
|
+
gitt --stale 30 # Find branches inactive for 30+ days
|
|
95
99
|
gitt ignore "temp/*" # Ignore branches matching "temp/*"
|
|
96
100
|
gitt set-main master # Set main branch to 'master'
|
|
97
101
|
`);
|
|
@@ -100,11 +104,27 @@ async function main() {
|
|
|
100
104
|
try {
|
|
101
105
|
const args = process.argv.slice(2);
|
|
102
106
|
const command = args[0];
|
|
107
|
+
// Check for version command
|
|
108
|
+
if (args.includes('-v') || args.includes('--version')) {
|
|
109
|
+
const packageJson = require('../package.json');
|
|
110
|
+
console.log(`v${packageJson.version}`);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
103
113
|
// Check for help command
|
|
104
114
|
if (args.includes('-h') || args.includes('--help')) {
|
|
105
115
|
printHelp();
|
|
106
116
|
process.exit(0);
|
|
107
117
|
}
|
|
118
|
+
// Check for updates
|
|
119
|
+
try {
|
|
120
|
+
// Use eval to prevent TypeScript from transpiling dynamic import to require()
|
|
121
|
+
const { default: updateNotifier } = await eval('import("update-notifier")');
|
|
122
|
+
const pkg = require('../package.json');
|
|
123
|
+
updateNotifier({ pkg }).notify();
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
// Ignore update check errors
|
|
127
|
+
}
|
|
108
128
|
// 检查 Git 仓库
|
|
109
129
|
await checkGitRepo();
|
|
110
130
|
if (command === 'set-main') {
|
|
@@ -125,8 +145,25 @@ async function main() {
|
|
|
125
145
|
// Parse options
|
|
126
146
|
const isInteractive = args.includes('-i') || args.includes('--interactive');
|
|
127
147
|
const isDryRun = args.includes('-d') || args.includes('--dry-run');
|
|
148
|
+
// Parse stale options
|
|
149
|
+
const staleIndex = args.indexOf('--stale');
|
|
150
|
+
const isStale = staleIndex !== -1;
|
|
151
|
+
let staleDays = 90; // Default 3 months
|
|
152
|
+
if (isStale) {
|
|
153
|
+
// Check if next arg is a number (days)
|
|
154
|
+
// Note: This is a simple check, ideally use a proper arg parser like commander or yargs
|
|
155
|
+
const nextArg = args[staleIndex + 1];
|
|
156
|
+
if (nextArg && !nextArg.startsWith('-') && !isNaN(Number(nextArg))) {
|
|
157
|
+
staleDays = Number(nextArg);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
128
160
|
// 默认执行清理操作
|
|
129
|
-
await (0, clean_1.cleanDeletedBranches)({
|
|
161
|
+
await (0, clean_1.cleanDeletedBranches)({
|
|
162
|
+
interactive: isInteractive,
|
|
163
|
+
dryRun: isDryRun,
|
|
164
|
+
stale: isStale,
|
|
165
|
+
staleDays: staleDays
|
|
166
|
+
});
|
|
130
167
|
}
|
|
131
168
|
// 退出程序
|
|
132
169
|
process.exit(0);
|
package/dist/utils/git.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getMainBranch = getMainBranch;
|
|
4
4
|
exports.setMainBranch = setMainBranch;
|
|
5
|
+
exports.isBranchMerged = isBranchMerged;
|
|
6
|
+
exports.getWorktrees = getWorktrees;
|
|
7
|
+
exports.getBranchLastCommitTime = getBranchLastCommitTime;
|
|
5
8
|
const simple_git_1 = require("simple-git");
|
|
6
9
|
const errors_1 = require("../errors");
|
|
7
10
|
const config_1 = require("./config");
|
|
@@ -74,3 +77,59 @@ async function setMainBranch(branch) {
|
|
|
74
77
|
throw new errors_1.GitError('Failed to set main branch configuration');
|
|
75
78
|
}
|
|
76
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Check if a branch is merged into the main branch.
|
|
82
|
+
*/
|
|
83
|
+
async function isBranchMerged(branch, mainBranch) {
|
|
84
|
+
try {
|
|
85
|
+
const mergedBranches = await git.branch(['--merged', mainBranch]);
|
|
86
|
+
return mergedBranches.all.includes(branch);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get a list of branches that are currently checked out in worktrees.
|
|
94
|
+
*/
|
|
95
|
+
async function getWorktrees() {
|
|
96
|
+
try {
|
|
97
|
+
// Output format:
|
|
98
|
+
// /path/to/repo (HEAD detached at 123456)
|
|
99
|
+
// /path/to/worktree [branch-name]
|
|
100
|
+
const worktrees = await git.raw(['worktree', 'list']);
|
|
101
|
+
const lines = worktrees.split('\n').filter(Boolean);
|
|
102
|
+
const branches = [];
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
// Extract branch name from [branch-name]
|
|
105
|
+
const match = line.match(/\[(.*?)\]/);
|
|
106
|
+
if (match && match[1]) {
|
|
107
|
+
branches.push(match[1]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return branches;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// If worktrees are not supported or error occurs, return empty list
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get the last commit time (in days) for a branch.
|
|
119
|
+
* Returns the number of days since the last commit.
|
|
120
|
+
*/
|
|
121
|
+
async function getBranchLastCommitTime(branch) {
|
|
122
|
+
try {
|
|
123
|
+
// Get unix timestamp of last commit
|
|
124
|
+
const timestamp = await git.raw(['log', '-1', '--format=%at', branch]);
|
|
125
|
+
const lastCommitDate = new Date(parseInt(timestamp.trim()) * 1000);
|
|
126
|
+
const now = new Date();
|
|
127
|
+
const diffTime = Math.abs(now.getTime() - lastCommitDate.getTime());
|
|
128
|
+
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
129
|
+
return diffDays;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
// If branch doesn't exist or error, return 0 (treat as active/new to be safe)
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harryisfish/gitt",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "A command-line tool to help you manage Git repositories and remote repositories, such as keeping in sync, pushing, pulling, etc.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -39,9 +39,11 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@inquirer/prompts": "^3.3.0",
|
|
41
41
|
"@types/minimatch": "^6.0.0",
|
|
42
|
+
"@types/update-notifier": "^6.0.8",
|
|
42
43
|
"listr2": "^8.0.0",
|
|
43
44
|
"minimatch": "^10.1.1",
|
|
44
|
-
"simple-git": "^3.22.0"
|
|
45
|
+
"simple-git": "^3.22.0",
|
|
46
|
+
"update-notifier": "^7.3.1"
|
|
45
47
|
},
|
|
46
48
|
"engines": {
|
|
47
49
|
"node": ">=14.0.0"
|