@burglekitt/worktree 0.1.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/LICENSE +21 -0
- package/README.md +15 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/dist/commands/branch.d.ts +18 -0
- package/dist/commands/branch.js +75 -0
- package/dist/commands/branch.test.d.ts +1 -0
- package/dist/commands/branch.test.js +207 -0
- package/dist/commands/config.d.ts +19 -0
- package/dist/commands/config.js +125 -0
- package/dist/commands/config.test.d.ts +1 -0
- package/dist/commands/config.test.js +109 -0
- package/dist/lib/base-command.d.ts +6 -0
- package/dist/lib/base-command.js +36 -0
- package/dist/lib/cli.d.ts +6 -0
- package/dist/lib/cli.js +30 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +7 -0
- package/dist/lib/env.d.ts +1 -0
- package/dist/lib/env.js +21 -0
- package/dist/lib/git.d.ts +12 -0
- package/dist/lib/git.js +89 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +11 -0
- package/dist/lib/utils.test.d.ts +1 -0
- package/dist/lib/utils.test.js +22 -0
- package/dist/lib/validators.d.ts +8 -0
- package/dist/lib/validators.js +68 -0
- package/dist/lib/validators.test.d.ts +1 -0
- package/dist/lib/validators.test.js +122 -0
- package/dist/test-setup.d.ts +3 -0
- package/dist/test-setup.js +28 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Burglekitt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Worktree
|
|
2
|
+
|
|
3
|
+
A CLI tool for managing Git worktrees with enhanced functionality.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## TODO
|
|
8
|
+
|
|
9
|
+
- [x] Add `config` command to configure everything needed
|
|
10
|
+
- [x] Add `branch` command to create new worktrees
|
|
11
|
+
- [ ] Add `delete` command to delete worktrees
|
|
12
|
+
- [ ] Add `checkout` command to create worktree from a remote branch
|
|
13
|
+
- [ ] Add `cleanup` command to cleanup stale worktrees
|
|
14
|
+
- [ ] Integrate with JIRA for automated branch naming
|
|
15
|
+
- [ ] Integrate with ClickUp for automated branch naming
|
package/bin/dev.cmd
ADDED
package/bin/dev.js
ADDED
package/bin/run.cmd
ADDED
package/bin/run.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BaseCommand } from "../lib/base-command.js";
|
|
2
|
+
export default class Branch extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
branchName: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
help: import("@oclif/core/interfaces").BooleanFlag<void>;
|
|
10
|
+
source: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
};
|
|
12
|
+
private confirmNonOriginSource;
|
|
13
|
+
private confirmRemoteNameConflict;
|
|
14
|
+
private getSourceBranch;
|
|
15
|
+
private validateBranchName;
|
|
16
|
+
private getBranchName;
|
|
17
|
+
run(): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { confirm, input } from "@inquirer/prompts";
|
|
2
|
+
import { Args, Flags } from "@oclif/core";
|
|
3
|
+
import { BaseCommand } from "../lib/base-command.js";
|
|
4
|
+
import { copyEnvFilesFromRootPath } from "../lib/env.js";
|
|
5
|
+
import { gitCreateWorktree, gitGetConfigValue, gitGetLocalBranches, gitGetRemoteBranches, } from "../lib/git.js";
|
|
6
|
+
import { isValidBranchName } from "../lib/validators.js";
|
|
7
|
+
export default class Branch extends BaseCommand {
|
|
8
|
+
static args = {
|
|
9
|
+
branchName: Args.string({ description: "Name of the branch to create" }),
|
|
10
|
+
};
|
|
11
|
+
static description = "Create a worktree branch";
|
|
12
|
+
static examples = [
|
|
13
|
+
"<%= config.bin %> <%= command.id %> my-new-branch",
|
|
14
|
+
];
|
|
15
|
+
static flags = {
|
|
16
|
+
help: Flags.help({ char: "h", description: "Show branch help" }),
|
|
17
|
+
source: Flags.string({
|
|
18
|
+
char: "s",
|
|
19
|
+
description: "Source branch to create the worktree from",
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
confirmNonOriginSource() {
|
|
23
|
+
const message = "The source branch does not start with 'origin/'. Are you sure you want to use a local source?";
|
|
24
|
+
return confirm({ message });
|
|
25
|
+
}
|
|
26
|
+
confirmRemoteNameConflict() {
|
|
27
|
+
const message = "A remote branch with the same name exists. Do you want to use the remote branch instead?";
|
|
28
|
+
return confirm({ message });
|
|
29
|
+
}
|
|
30
|
+
async getSourceBranch(sourceFlag) {
|
|
31
|
+
if (sourceFlag) {
|
|
32
|
+
const remoteBranches = await gitGetRemoteBranches();
|
|
33
|
+
if (sourceFlag.startsWith("origin/")) {
|
|
34
|
+
if (!remoteBranches.includes(sourceFlag)) {
|
|
35
|
+
this.error(`Source branch doesn't exist: ${sourceFlag}`);
|
|
36
|
+
}
|
|
37
|
+
return sourceFlag;
|
|
38
|
+
}
|
|
39
|
+
if (await this.confirmNonOriginSource()) {
|
|
40
|
+
const localBranches = await gitGetLocalBranches();
|
|
41
|
+
if (!localBranches.includes(sourceFlag)) {
|
|
42
|
+
this.error(`Source branch doesn't exist: ${sourceFlag}`);
|
|
43
|
+
}
|
|
44
|
+
if (remoteBranches.includes(`origin/${sourceFlag}`)) {
|
|
45
|
+
if (await this.confirmRemoteNameConflict()) {
|
|
46
|
+
return `origin/${sourceFlag}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return sourceFlag;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return (await gitGetConfigValue("defaultSourceBranch")) || "origin/main";
|
|
53
|
+
}
|
|
54
|
+
validateBranchName(branchName) {
|
|
55
|
+
const result = isValidBranchName(branchName);
|
|
56
|
+
if (result !== true) {
|
|
57
|
+
this.error(result);
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
async getBranchName(branchNameArg) {
|
|
62
|
+
if (branchNameArg && this.validateBranchName(branchNameArg)) {
|
|
63
|
+
return branchNameArg;
|
|
64
|
+
}
|
|
65
|
+
return await input({ message: "Branch name", validate: isValidBranchName });
|
|
66
|
+
}
|
|
67
|
+
async run() {
|
|
68
|
+
const { args, flags } = await this.parse(Branch);
|
|
69
|
+
const branchName = await this.getBranchName(args.branchName);
|
|
70
|
+
const sourceBranch = await this.getSourceBranch(flags.source);
|
|
71
|
+
const projectPath = await gitCreateWorktree(branchName, sourceBranch);
|
|
72
|
+
await copyEnvFilesFromRootPath(projectPath);
|
|
73
|
+
await this.openWorktreePath(projectPath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { confirm, input } from "@inquirer/prompts";
|
|
2
|
+
import { copyEnvFilesFromRootPath } from "../lib/env.js";
|
|
3
|
+
import * as git from "../lib/git.js";
|
|
4
|
+
import * as validators from "../lib/validators.js";
|
|
5
|
+
import Branch from "./branch.js";
|
|
6
|
+
// Mock the inquirer module
|
|
7
|
+
vi.mock("@inquirer/prompts", () => ({
|
|
8
|
+
confirm: vi.fn(),
|
|
9
|
+
input: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
// Mock env functions
|
|
12
|
+
vi.mock("../lib/env.js", () => ({
|
|
13
|
+
copyEnvFilesFromRootPath: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
}));
|
|
15
|
+
describe("branch command", () => {
|
|
16
|
+
let branch;
|
|
17
|
+
let mockOpenWorktreePath;
|
|
18
|
+
const mockInput = vi.mocked(input);
|
|
19
|
+
const mockConfirm = vi.mocked(confirm);
|
|
20
|
+
const mockCopyEnvFiles = vi.mocked(copyEnvFilesFromRootPath);
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif testing
|
|
24
|
+
branch = new Branch([], {});
|
|
25
|
+
mockOpenWorktreePath = vi
|
|
26
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for spying on protected method
|
|
27
|
+
.spyOn(branch, "openWorktreePath")
|
|
28
|
+
.mockResolvedValue(undefined);
|
|
29
|
+
});
|
|
30
|
+
describe("basic functionality", () => {
|
|
31
|
+
it("should create worktree with valid branch name and default source", async () => {
|
|
32
|
+
const mockGitCreateWorktree = vi
|
|
33
|
+
.spyOn(git, "gitCreateWorktree")
|
|
34
|
+
.mockResolvedValue("/path/to/worktree");
|
|
35
|
+
vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("origin/main");
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
37
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
38
|
+
args: { branchName: "feature/test" },
|
|
39
|
+
flags: {},
|
|
40
|
+
});
|
|
41
|
+
await branch.run();
|
|
42
|
+
expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/main");
|
|
43
|
+
expect(mockCopyEnvFiles).toHaveBeenCalledWith("/path/to/worktree");
|
|
44
|
+
expect(mockOpenWorktreePath).toHaveBeenCalledWith("/path/to/worktree");
|
|
45
|
+
});
|
|
46
|
+
it("should prompt for branch name when not provided", async () => {
|
|
47
|
+
mockInput.mockResolvedValue("prompted-branch");
|
|
48
|
+
const mockGitCreateWorktree = vi
|
|
49
|
+
.spyOn(git, "gitCreateWorktree")
|
|
50
|
+
.mockResolvedValue("/path/to/worktree");
|
|
51
|
+
vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("origin/main");
|
|
52
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
53
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
54
|
+
args: {},
|
|
55
|
+
flags: {},
|
|
56
|
+
});
|
|
57
|
+
await branch.run();
|
|
58
|
+
expect(mockInput).toHaveBeenCalledWith({
|
|
59
|
+
message: "Branch name",
|
|
60
|
+
validate: validators.isValidBranchName,
|
|
61
|
+
});
|
|
62
|
+
expect(mockGitCreateWorktree).toHaveBeenCalledWith("prompted-branch", "origin/main");
|
|
63
|
+
});
|
|
64
|
+
it("should use custom source branch when provided", async () => {
|
|
65
|
+
const mockGitCreateWorktree = vi
|
|
66
|
+
.spyOn(git, "gitCreateWorktree")
|
|
67
|
+
.mockResolvedValue("/path/to/worktree");
|
|
68
|
+
vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue([
|
|
69
|
+
"origin/main",
|
|
70
|
+
"origin/develop",
|
|
71
|
+
]);
|
|
72
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
73
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
74
|
+
args: { branchName: "feature/test" },
|
|
75
|
+
flags: { source: "origin/develop" },
|
|
76
|
+
});
|
|
77
|
+
await branch.run();
|
|
78
|
+
expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/develop");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("branch name validation", () => {
|
|
82
|
+
it("should validate branch name and throw error for invalid name", async () => {
|
|
83
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
84
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
85
|
+
args: { branchName: "invalid..branch" },
|
|
86
|
+
flags: {},
|
|
87
|
+
});
|
|
88
|
+
const mockError = vi.spyOn(branch, "error").mockImplementation(() => {
|
|
89
|
+
throw new Error("Branch name cannot contain double dots");
|
|
90
|
+
});
|
|
91
|
+
await expect(branch.run()).rejects.toThrow("Branch name cannot contain double dots");
|
|
92
|
+
expect(mockError).toHaveBeenCalledWith("Branch name cannot contain double dots");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe("source branch handling", () => {
|
|
96
|
+
it("should handle non-existing origin source branch", async () => {
|
|
97
|
+
vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(["origin/main"]);
|
|
98
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
99
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
100
|
+
args: { branchName: "feature/test" },
|
|
101
|
+
flags: { source: "origin/nonexistent" },
|
|
102
|
+
});
|
|
103
|
+
const mockError = vi.spyOn(branch, "error").mockImplementation(() => {
|
|
104
|
+
throw new Error("Source branch doesn't exist: origin/nonexistent");
|
|
105
|
+
});
|
|
106
|
+
await expect(branch.run()).rejects.toThrow("Source branch doesn't exist: origin/nonexistent");
|
|
107
|
+
expect(mockError).toHaveBeenCalledWith("Source branch doesn't exist: origin/nonexistent");
|
|
108
|
+
});
|
|
109
|
+
it("should handle local source branch with confirmation", async () => {
|
|
110
|
+
vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(["origin/main"]);
|
|
111
|
+
vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue([
|
|
112
|
+
"main",
|
|
113
|
+
"develop",
|
|
114
|
+
]);
|
|
115
|
+
mockConfirm.mockResolvedValue(true);
|
|
116
|
+
const mockGitCreateWorktree = vi
|
|
117
|
+
.spyOn(git, "gitCreateWorktree")
|
|
118
|
+
.mockResolvedValue("/path/to/worktree");
|
|
119
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
120
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
121
|
+
args: { branchName: "feature/test" },
|
|
122
|
+
flags: { source: "develop" },
|
|
123
|
+
});
|
|
124
|
+
await branch.run();
|
|
125
|
+
expect(mockConfirm).toHaveBeenCalledWith({
|
|
126
|
+
message: "The source branch does not start with 'origin/'. Are you sure you want to use a local source?",
|
|
127
|
+
});
|
|
128
|
+
expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "develop");
|
|
129
|
+
});
|
|
130
|
+
it("should handle local source branch with remote conflict confirmation", async () => {
|
|
131
|
+
vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue([
|
|
132
|
+
"origin/main",
|
|
133
|
+
"origin/develop",
|
|
134
|
+
]);
|
|
135
|
+
vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue([
|
|
136
|
+
"main",
|
|
137
|
+
"develop",
|
|
138
|
+
]);
|
|
139
|
+
mockConfirm
|
|
140
|
+
.mockResolvedValueOnce(true) // Confirm using local source
|
|
141
|
+
.mockResolvedValueOnce(true); // Confirm using remote instead
|
|
142
|
+
const mockGitCreateWorktree = vi
|
|
143
|
+
.spyOn(git, "gitCreateWorktree")
|
|
144
|
+
.mockResolvedValue("/path/to/worktree");
|
|
145
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
146
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
147
|
+
args: { branchName: "feature/test" },
|
|
148
|
+
flags: { source: "develop" },
|
|
149
|
+
});
|
|
150
|
+
await branch.run();
|
|
151
|
+
expect(mockConfirm).toHaveBeenNthCalledWith(2, {
|
|
152
|
+
message: "A remote branch with the same name exists. Do you want to use the remote branch instead?",
|
|
153
|
+
});
|
|
154
|
+
expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/develop");
|
|
155
|
+
});
|
|
156
|
+
it("should fallback to origin/main when no default source is configured", async () => {
|
|
157
|
+
const mockGetConfigValue = vi
|
|
158
|
+
.spyOn(git, "gitGetConfigValue")
|
|
159
|
+
.mockResolvedValue("");
|
|
160
|
+
const mockGitCreateWorktree = vi
|
|
161
|
+
.spyOn(git, "gitCreateWorktree")
|
|
162
|
+
.mockResolvedValue("/path/to/worktree");
|
|
163
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
164
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
165
|
+
args: { branchName: "feature/test" },
|
|
166
|
+
flags: {},
|
|
167
|
+
});
|
|
168
|
+
await branch.run();
|
|
169
|
+
expect(mockGetConfigValue).toHaveBeenCalledWith("defaultSourceBranch");
|
|
170
|
+
expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/main");
|
|
171
|
+
});
|
|
172
|
+
it("should handle non-existing local source branch", async () => {
|
|
173
|
+
vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(["origin/main"]);
|
|
174
|
+
vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue(["main"]);
|
|
175
|
+
mockConfirm.mockResolvedValue(true);
|
|
176
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
177
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
178
|
+
args: { branchName: "feature/test" },
|
|
179
|
+
flags: { source: "nonexistent" },
|
|
180
|
+
});
|
|
181
|
+
const mockError = vi.spyOn(branch, "error").mockImplementation(() => {
|
|
182
|
+
throw new Error("Source branch doesn't exist: nonexistent");
|
|
183
|
+
});
|
|
184
|
+
await expect(branch.run()).rejects.toThrow("Source branch doesn't exist: nonexistent");
|
|
185
|
+
expect(mockError).toHaveBeenCalledWith("Source branch doesn't exist: nonexistent");
|
|
186
|
+
});
|
|
187
|
+
it("should reject using local source when confirmation is denied", async () => {
|
|
188
|
+
vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(["origin/main"]);
|
|
189
|
+
mockConfirm.mockResolvedValue(false);
|
|
190
|
+
vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("origin/main");
|
|
191
|
+
const mockGitCreateWorktree = vi
|
|
192
|
+
.spyOn(git, "gitCreateWorktree")
|
|
193
|
+
.mockResolvedValue("/path/to/worktree");
|
|
194
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
195
|
+
branch.parse = vi.fn().mockResolvedValue({
|
|
196
|
+
args: { branchName: "feature/test" },
|
|
197
|
+
flags: { source: "develop" },
|
|
198
|
+
});
|
|
199
|
+
await branch.run();
|
|
200
|
+
expect(mockConfirm).toHaveBeenCalledWith({
|
|
201
|
+
message: "The source branch does not start with 'origin/'. Are you sure you want to use a local source?",
|
|
202
|
+
});
|
|
203
|
+
// Should fallback to default source branch
|
|
204
|
+
expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/main");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BaseCommand } from "../lib/base-command.js";
|
|
2
|
+
export default class Config extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static args: {
|
|
6
|
+
name: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
7
|
+
value: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
8
|
+
};
|
|
9
|
+
static flags: {
|
|
10
|
+
list: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
help: import("@oclif/core/interfaces").BooleanFlag<void>;
|
|
12
|
+
missing: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
};
|
|
14
|
+
private renderList;
|
|
15
|
+
private getPromptConfigNames;
|
|
16
|
+
private renderInput;
|
|
17
|
+
private isValidArgName;
|
|
18
|
+
run(): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { EOL } from "node:os";
|
|
2
|
+
import { confirm, input } from "@inquirer/prompts";
|
|
3
|
+
import { Args, Flags } from "@oclif/core";
|
|
4
|
+
import { BaseCommand } from "../lib/base-command.js";
|
|
5
|
+
import { CONFIG_NAMES } from "../lib/constants.js";
|
|
6
|
+
import { gitGetConfigValue, gitSetConfigValue } from "../lib/git.js";
|
|
7
|
+
import { conjoin } from "../lib/utils.js";
|
|
8
|
+
import { isValidBranch, isValidCommand, isValidEmail, validateConfigValue, } from "../lib/validators.js";
|
|
9
|
+
export default class Config extends BaseCommand {
|
|
10
|
+
static description = "Configure worktree CLI settings";
|
|
11
|
+
static examples = ["<%= config.bin %> <%= command.id %>"];
|
|
12
|
+
static args = {
|
|
13
|
+
name: Args.string(),
|
|
14
|
+
value: Args.string(),
|
|
15
|
+
};
|
|
16
|
+
static flags = {
|
|
17
|
+
list: Flags.boolean({
|
|
18
|
+
char: "l",
|
|
19
|
+
description: "List all available variables",
|
|
20
|
+
}),
|
|
21
|
+
help: Flags.help({ char: "h", description: "Show config help" }),
|
|
22
|
+
missing: Flags.boolean({
|
|
23
|
+
char: "m",
|
|
24
|
+
description: "Only prompt missing variables",
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
async renderList(missing) {
|
|
28
|
+
let count = 0;
|
|
29
|
+
for (const variable of CONFIG_NAMES) {
|
|
30
|
+
const value = await gitGetConfigValue(variable);
|
|
31
|
+
if (missing && value) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
this.log(value ? `${variable}=${value}` : variable);
|
|
35
|
+
count++;
|
|
36
|
+
}
|
|
37
|
+
if (missing && count === 0) {
|
|
38
|
+
this.log("No variables have missing values");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async getPromptConfigNames(missing) {
|
|
42
|
+
if (missing) {
|
|
43
|
+
const configNames = [];
|
|
44
|
+
for (const name of CONFIG_NAMES) {
|
|
45
|
+
if (!(await gitGetConfigValue(name))) {
|
|
46
|
+
configNames.push(name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return configNames;
|
|
50
|
+
}
|
|
51
|
+
return [...CONFIG_NAMES];
|
|
52
|
+
}
|
|
53
|
+
async renderInput(missing) {
|
|
54
|
+
const configNames = await this.getPromptConfigNames(missing);
|
|
55
|
+
const hasJiraPrompt = !!configNames.find((name) => name.startsWith("jira"));
|
|
56
|
+
// First check if there is anything to prompt
|
|
57
|
+
if (configNames.length === 0) {
|
|
58
|
+
this.log("No missing config found.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
function shouldPrompt(name) {
|
|
62
|
+
return configNames.includes(name);
|
|
63
|
+
}
|
|
64
|
+
if (hasJiraPrompt &&
|
|
65
|
+
(await confirm({ message: "Do you want to configure Jira integration?" }))) {
|
|
66
|
+
if (shouldPrompt("jira.domain")) {
|
|
67
|
+
const jiraDomain = await input({
|
|
68
|
+
message: "Jira subdomain or full domain",
|
|
69
|
+
});
|
|
70
|
+
await gitSetConfigValue("jira.domain", jiraDomain);
|
|
71
|
+
}
|
|
72
|
+
if (shouldPrompt("jira.email")) {
|
|
73
|
+
const jiraEmail = await input({
|
|
74
|
+
message: "Jira email",
|
|
75
|
+
validate: isValidEmail,
|
|
76
|
+
});
|
|
77
|
+
await gitSetConfigValue("jira.email", jiraEmail);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (shouldPrompt("defaultSourceBranch")) {
|
|
81
|
+
const defaultBranchName = await input({
|
|
82
|
+
message: "Which branch should new worktrees be based on?",
|
|
83
|
+
default: "origin/main",
|
|
84
|
+
validate: isValidBranch,
|
|
85
|
+
});
|
|
86
|
+
await gitSetConfigValue("defaultSourceBranch", defaultBranchName);
|
|
87
|
+
}
|
|
88
|
+
if (shouldPrompt("codeEditor") &&
|
|
89
|
+
(await confirm({
|
|
90
|
+
message: "Do you want to automatically open the worktree in a code editor?",
|
|
91
|
+
}))) {
|
|
92
|
+
const codeEditor = await input({
|
|
93
|
+
message: "Command to open code editor?",
|
|
94
|
+
default: "code",
|
|
95
|
+
validate: isValidCommand,
|
|
96
|
+
});
|
|
97
|
+
await gitSetConfigValue("codeEditor", codeEditor);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
isValidArgName(name) {
|
|
101
|
+
return CONFIG_NAMES.includes(name);
|
|
102
|
+
}
|
|
103
|
+
async run() {
|
|
104
|
+
const { args, flags } = await this.parse(Config);
|
|
105
|
+
if (args.name) {
|
|
106
|
+
if (!this.isValidArgName(args.name)) {
|
|
107
|
+
this.error([
|
|
108
|
+
`Unknown config name: ${args.name}`,
|
|
109
|
+
`Available variables: ${conjoin(CONFIG_NAMES)}`,
|
|
110
|
+
].join(EOL));
|
|
111
|
+
}
|
|
112
|
+
if (args.value) {
|
|
113
|
+
await validateConfigValue(args.name, args.value);
|
|
114
|
+
await gitSetConfigValue(args.name, args.value);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await gitGetConfigValue(args.name);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (flags.list) {
|
|
121
|
+
return this.renderList(flags.missing);
|
|
122
|
+
}
|
|
123
|
+
return this.renderInput(flags.missing);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as git from "../lib/git.js";
|
|
2
|
+
import * as validators from "../lib/validators.js";
|
|
3
|
+
import Config from "./config.js";
|
|
4
|
+
describe("config command", () => {
|
|
5
|
+
let config;
|
|
6
|
+
let mockConsoleLog;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.clearAllMocks();
|
|
9
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif testing
|
|
10
|
+
config = new Config([], {});
|
|
11
|
+
mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
12
|
+
});
|
|
13
|
+
describe("--list flag", () => {
|
|
14
|
+
it("should list all config values when they exist", async () => {
|
|
15
|
+
// Mock the parse method to simulate --list flag
|
|
16
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
17
|
+
config.parse = vi.fn().mockResolvedValue({
|
|
18
|
+
args: {},
|
|
19
|
+
flags: { list: true, missing: false },
|
|
20
|
+
});
|
|
21
|
+
vi.spyOn(git, "gitGetConfigValue")
|
|
22
|
+
.mockResolvedValueOnce("https://test.atlassian.net") // jira.domain
|
|
23
|
+
.mockResolvedValueOnce("test@example.com") // jira.email
|
|
24
|
+
.mockResolvedValueOnce("api-token-123") // jira.apiToken
|
|
25
|
+
.mockResolvedValueOnce("code") // codeEditor
|
|
26
|
+
.mockResolvedValueOnce("origin/main"); // defaultSourceBranch
|
|
27
|
+
await config.run();
|
|
28
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("jira.domain=https://test.atlassian.net");
|
|
29
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("jira.email=test@example.com");
|
|
30
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("jira.apiToken=api-token-123");
|
|
31
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("codeEditor=code");
|
|
32
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("defaultSourceBranch=origin/main");
|
|
33
|
+
});
|
|
34
|
+
it("should list config names when values are missing", async () => {
|
|
35
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
36
|
+
config.parse = vi.fn().mockResolvedValue({
|
|
37
|
+
args: {},
|
|
38
|
+
flags: { list: true, missing: false },
|
|
39
|
+
});
|
|
40
|
+
vi.spyOn(git, "gitGetConfigValue").mockResolvedValue(""); // All empty
|
|
41
|
+
await config.run();
|
|
42
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("jira.domain");
|
|
43
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("jira.email");
|
|
44
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("jira.apiToken");
|
|
45
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("codeEditor");
|
|
46
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("defaultSourceBranch");
|
|
47
|
+
});
|
|
48
|
+
it("should handle --missing flag with no missing values", async () => {
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
50
|
+
config.parse = vi.fn().mockResolvedValue({
|
|
51
|
+
args: {},
|
|
52
|
+
flags: { list: true, missing: true },
|
|
53
|
+
});
|
|
54
|
+
vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("some-value"); // All have values
|
|
55
|
+
await config.run();
|
|
56
|
+
expect(mockConsoleLog).toHaveBeenCalledWith("No variables have missing values");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe("setting config values", () => {
|
|
60
|
+
it("should set a valid config value", async () => {
|
|
61
|
+
const mockSetConfigValue = vi
|
|
62
|
+
.spyOn(git, "gitSetConfigValue")
|
|
63
|
+
.mockResolvedValue();
|
|
64
|
+
const mockValidateConfigValue = vi
|
|
65
|
+
.spyOn(validators, "validateConfigValue")
|
|
66
|
+
.mockResolvedValue();
|
|
67
|
+
// Mock the parse method to return the args we want
|
|
68
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
69
|
+
config.parse = vi.fn().mockResolvedValue({
|
|
70
|
+
args: { name: "jira.email", value: "test@example.com" },
|
|
71
|
+
flags: {},
|
|
72
|
+
});
|
|
73
|
+
await config.run();
|
|
74
|
+
expect(mockValidateConfigValue).toHaveBeenCalledWith("jira.email", "test@example.com");
|
|
75
|
+
expect(mockSetConfigValue).toHaveBeenCalledWith("jira.email", "test@example.com");
|
|
76
|
+
});
|
|
77
|
+
it("should get config value when only name is provided", async () => {
|
|
78
|
+
const mockGetConfigValue = vi
|
|
79
|
+
.spyOn(git, "gitGetConfigValue")
|
|
80
|
+
.mockResolvedValue("test@example.com");
|
|
81
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
82
|
+
config.parse = vi.fn().mockResolvedValue({
|
|
83
|
+
args: { name: "jira.email" },
|
|
84
|
+
flags: {},
|
|
85
|
+
});
|
|
86
|
+
await config.run();
|
|
87
|
+
expect(mockGetConfigValue).toHaveBeenCalledWith("jira.email");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("error handling", () => {
|
|
91
|
+
it("should handle unknown config name", async () => {
|
|
92
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
93
|
+
config.parse = vi.fn().mockResolvedValue({
|
|
94
|
+
args: { name: "unknown.setting", value: "test" },
|
|
95
|
+
flags: {},
|
|
96
|
+
});
|
|
97
|
+
await expect(config.run()).rejects.toThrow("Unknown config name: unknown.setting");
|
|
98
|
+
});
|
|
99
|
+
it("should handle invalid config value", async () => {
|
|
100
|
+
vi.spyOn(validators, "validateConfigValue").mockRejectedValue(new validators.InvalidConfigValueError("Invalid email address"));
|
|
101
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
|
|
102
|
+
config.parse = vi.fn().mockResolvedValue({
|
|
103
|
+
args: { name: "jira.email", value: "invalid-email" },
|
|
104
|
+
flags: {},
|
|
105
|
+
});
|
|
106
|
+
await expect(config.run()).rejects.toThrow("Invalid email address");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Command } from "@oclif/core";
|
|
2
|
+
import type { CommandError } from "@oclif/core/interfaces";
|
|
3
|
+
export declare abstract class BaseCommand extends Command {
|
|
4
|
+
protected openWorktreePath(path: string): Promise<void>;
|
|
5
|
+
protected catch(error: CommandError): Promise<any>;
|
|
6
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { Command } from "@oclif/core";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { gitGetConfigValue } from "./git.js";
|
|
6
|
+
export class BaseCommand extends Command {
|
|
7
|
+
async openWorktreePath(path) {
|
|
8
|
+
const codeEditor = await gitGetConfigValue("codeEditor");
|
|
9
|
+
if (codeEditor) {
|
|
10
|
+
const spinner = ora(`Opening in ${codeEditor}`).start();
|
|
11
|
+
exec(`${codeEditor} ${path}`, (error) => {
|
|
12
|
+
if (error) {
|
|
13
|
+
spinner.fail(error.message);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
spinner.succeed();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
this.log(`${chalk.green("✔")} Worktree created in ${path}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async catch(error) {
|
|
25
|
+
if (error instanceof Error) {
|
|
26
|
+
if (error.name === "ExitPromptError") {
|
|
27
|
+
// Silently exit
|
|
28
|
+
this.exit();
|
|
29
|
+
}
|
|
30
|
+
// Color the error message red for better visibility
|
|
31
|
+
this.log(chalk.red(`Error: ${error.message}`));
|
|
32
|
+
this.exit();
|
|
33
|
+
}
|
|
34
|
+
return super.catch(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
package/dist/lib/cli.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
export function cmd(cmd, { debug = false } = {}) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
if (debug) {
|
|
5
|
+
console.log(`DEBUG: ${cmd}`);
|
|
6
|
+
resolve("");
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
exec(cmd, (error, stdout) => {
|
|
10
|
+
if (error) {
|
|
11
|
+
reject(error);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
resolve(stdout?.trim() ?? "");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export async function commandExists(command) {
|
|
19
|
+
try {
|
|
20
|
+
// Extract the base command
|
|
21
|
+
const baseCommand = command.split(" ")[0];
|
|
22
|
+
// Use 'which' on *nix, 'where' on Windows
|
|
23
|
+
const checkCommand = process.platform === "win32" ? "where" : "which";
|
|
24
|
+
await cmd(`${checkCommand} ${baseCommand}`);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function copyEnvFilesFromRootPath(destinationWorktreePath: string): Promise<void>;
|
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { glob } from "glob";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { gitGetRootPath } from "./git.js";
|
|
5
|
+
export async function copyEnvFilesFromRootPath(destinationWorktreePath) {
|
|
6
|
+
const gitRootPath = await gitGetRootPath();
|
|
7
|
+
// Find both .env and .env.local files
|
|
8
|
+
const envResults = await glob(`${gitRootPath}/**/.{env,env.local}`, {
|
|
9
|
+
ignore: ["node_modules/**"],
|
|
10
|
+
});
|
|
11
|
+
const rootEnvFiles = envResults
|
|
12
|
+
// Change to a relative path
|
|
13
|
+
.map((filePath) => filePath.substring(gitRootPath.length + 1));
|
|
14
|
+
for (const envFile of rootEnvFiles) {
|
|
15
|
+
const sourceFile = `${gitRootPath}/${envFile}`;
|
|
16
|
+
const destinationFile = `${destinationWorktreePath}/${envFile}`;
|
|
17
|
+
const spinner = ora(`Copying ${sourceFile} to worktree`).start();
|
|
18
|
+
fs.copyFileSync(sourceFile, destinationFile);
|
|
19
|
+
spinner.succeed();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ConfigName } from "./constants.js";
|
|
2
|
+
export declare function gitGetConfigValue(name: ConfigName): Promise<string>;
|
|
3
|
+
export declare function gitSetConfigValue(name: ConfigName, value: string): Promise<void>;
|
|
4
|
+
export declare function gitFetch(): Promise<string>;
|
|
5
|
+
export declare function gitGetRootPath(): Promise<string>;
|
|
6
|
+
export declare function gitGetLocalBranches(): Promise<string[]>;
|
|
7
|
+
export declare function gitGetRemoteBranches(): Promise<string[]>;
|
|
8
|
+
interface GitCreateWorktreeOptions {
|
|
9
|
+
isRemoteBranch?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function gitCreateWorktree(branchName: string, sourceBranch: string, { isRemoteBranch }?: GitCreateWorktreeOptions): Promise<string>;
|
|
12
|
+
export {};
|
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { EOL } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { cmd } from "./cli.js";
|
|
5
|
+
export async function gitGetConfigValue(name) {
|
|
6
|
+
try {
|
|
7
|
+
return await cmd(`git config burglekitt.worktree.${name}`);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function gitSetConfigValue(name, value) {
|
|
14
|
+
await cmd(`git config burglekitt.worktree.${name} "${value}"`);
|
|
15
|
+
}
|
|
16
|
+
async function gitCmdShowTopLevel() {
|
|
17
|
+
return cmd("git rev-parse --show-toplevel");
|
|
18
|
+
}
|
|
19
|
+
async function gitCmdGitPath() {
|
|
20
|
+
return cmd("git rev-parse --absolute-git-dir");
|
|
21
|
+
}
|
|
22
|
+
export async function gitFetch() {
|
|
23
|
+
return cmd("git fetch --prune");
|
|
24
|
+
}
|
|
25
|
+
export async function gitGetRootPath() {
|
|
26
|
+
try {
|
|
27
|
+
const topLevelPath = await gitCmdShowTopLevel();
|
|
28
|
+
if (!topLevelPath.includes(".worktrees/")) {
|
|
29
|
+
return topLevelPath;
|
|
30
|
+
}
|
|
31
|
+
const gitPathResult = await gitCmdGitPath();
|
|
32
|
+
const gitPath = gitPathResult.split("/.git")[0];
|
|
33
|
+
return gitPath;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error(`Git: Unable find the root path. Are you in a git repository?`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function gitGetLocalBranches() {
|
|
40
|
+
const res = await cmd("git --no-pager branch");
|
|
41
|
+
return res.split(EOL).map((branch) => {
|
|
42
|
+
const trimmed = branch.trim();
|
|
43
|
+
if (trimmed.includes(" ")) {
|
|
44
|
+
return trimmed.split(" ")[1];
|
|
45
|
+
}
|
|
46
|
+
return trimmed;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export async function gitGetRemoteBranches() {
|
|
50
|
+
const res = await cmd("git --no-pager branch -r");
|
|
51
|
+
return res.split(EOL).map((branch) => {
|
|
52
|
+
const trimmed = branch.trim();
|
|
53
|
+
if (trimmed.includes(" ")) {
|
|
54
|
+
return trimmed.split(" ")[1];
|
|
55
|
+
}
|
|
56
|
+
return trimmed;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export async function gitCreateWorktree(branchName, sourceBranch, { isRemoteBranch = false } = {}) {
|
|
60
|
+
const spinner = ora(`Creating worktree ${branchName}`).start();
|
|
61
|
+
try {
|
|
62
|
+
const currentPath = process.env.PWD;
|
|
63
|
+
const gitRootPath = await gitGetRootPath();
|
|
64
|
+
const worktreesRootPath = `../${path.basename(gitRootPath)}.worktrees`;
|
|
65
|
+
const worktreePath = `${worktreesRootPath}/${branchName}`;
|
|
66
|
+
const absoluteWorktreePath = `${gitRootPath}.worktrees/${branchName}`;
|
|
67
|
+
// cd into the root path so we can create a relative worktree. This ensures that
|
|
68
|
+
// everything stays in sync in case the project is moved in the filesystem.
|
|
69
|
+
const cdRoot = `cd ${gitRootPath}`;
|
|
70
|
+
// Fetch the latest changes from the remote
|
|
71
|
+
const gitFetch = "git fetch";
|
|
72
|
+
// If adding from a remote branch, allow tracking
|
|
73
|
+
const addWorktree = isRemoteBranch
|
|
74
|
+
? `git worktree add ${worktreePath} ${branchName}`
|
|
75
|
+
: `git worktree add --no-track -b ${branchName} ${worktreePath} ${sourceBranch}`;
|
|
76
|
+
// Go back
|
|
77
|
+
const gotoBack = `cd ${currentPath}`;
|
|
78
|
+
// Run them all in sequence
|
|
79
|
+
await cmd(`${cdRoot} && ${gitFetch} && ${addWorktree} && ${gotoBack}`);
|
|
80
|
+
// Stop the spinner
|
|
81
|
+
spinner.succeed();
|
|
82
|
+
// Return the absolute path to the new worktree
|
|
83
|
+
return absoluteWorktreePath;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
spinner.fail(error instanceof Error ? error.message : String(error));
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function conjoin(arr: readonly (string | number)[], conjunction?: "and" | "or"): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function conjoin(arr, conjunction = "and") {
|
|
2
|
+
if (arr.length === 0)
|
|
3
|
+
return "";
|
|
4
|
+
if (arr.length === 1)
|
|
5
|
+
return String(arr[0]);
|
|
6
|
+
if (arr.length === 2)
|
|
7
|
+
return `${arr[0]} ${conjunction} ${arr[1]}`;
|
|
8
|
+
const allButLast = arr.slice(0, -1).join(", ");
|
|
9
|
+
const last = arr[arr.length - 1];
|
|
10
|
+
return `${allButLast} ${conjunction} ${last}`;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { conjoin } from "./utils.js";
|
|
2
|
+
describe("conjoin", () => {
|
|
3
|
+
it.each `
|
|
4
|
+
input | conjunction | expected
|
|
5
|
+
${[]} | ${undefined} | ${""}
|
|
6
|
+
${["apple"]} | ${undefined} | ${"apple"}
|
|
7
|
+
${[42]} | ${undefined} | ${"42"}
|
|
8
|
+
${["apple", "banana"]} | ${undefined} | ${"apple and banana"}
|
|
9
|
+
${[1, 2]} | ${undefined} | ${"1 and 2"}
|
|
10
|
+
${["apple", "banana"]} | ${"or"} | ${"apple or banana"}
|
|
11
|
+
${[1, 2]} | ${"or"} | ${"1 or 2"}
|
|
12
|
+
${["apple", "banana", "cherry"]} | ${undefined} | ${"apple, banana and cherry"}
|
|
13
|
+
${[1, 2, 3, 4]} | ${undefined} | ${"1, 2, 3 and 4"}
|
|
14
|
+
${["apple", "banana", "cherry"]} | ${"or"} | ${"apple, banana or cherry"}
|
|
15
|
+
${[1, 2, 3, 4, 5]} | ${"or"} | ${"1, 2, 3, 4 or 5"}
|
|
16
|
+
${["item1", 2, "item3"]} | ${undefined} | ${"item1, 2 and item3"}
|
|
17
|
+
${[100, "apples", 200, "oranges"]} | ${"or"} | ${"100, apples, 200 or oranges"}
|
|
18
|
+
`('should return "$expected" for input $input with conjunction "$conjunction"', ({ input, conjunction, expected }) => {
|
|
19
|
+
const result = conjunction ? conjoin(input, conjunction) : conjoin(input);
|
|
20
|
+
expect(result).toBe(expected);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function isValidEmail(value: string): true | string;
|
|
2
|
+
export declare function isValidCommand(value: string): Promise<true | string>;
|
|
3
|
+
export declare function isValidBranch(value: string): Promise<true | string>;
|
|
4
|
+
export declare function isValidBranchName(value: string): true | string;
|
|
5
|
+
export declare function isValidConfigValue(configName: string, value: string): Promise<true | string>;
|
|
6
|
+
export declare class InvalidConfigValueError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export declare function validateConfigValue(configName: string, value: string): Promise<void>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { commandExists } from "./cli.js";
|
|
2
|
+
import { gitGetLocalBranches, gitGetRemoteBranches } from "./git.js";
|
|
3
|
+
export function isValidEmail(value) {
|
|
4
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || "Invalid email address";
|
|
5
|
+
}
|
|
6
|
+
export async function isValidCommand(value) {
|
|
7
|
+
if (!(await commandExists(value))) {
|
|
8
|
+
return `Command not found: ${value}`;
|
|
9
|
+
}
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
export async function isValidBranch(value) {
|
|
13
|
+
const branches = value.startsWith("origin/")
|
|
14
|
+
? await gitGetRemoteBranches()
|
|
15
|
+
: await gitGetLocalBranches();
|
|
16
|
+
return branches.includes(value) || `Branch not found: ${value}`;
|
|
17
|
+
}
|
|
18
|
+
export function isValidBranchName(value) {
|
|
19
|
+
if (!value || value.trim() === "") {
|
|
20
|
+
return "Branch name cannot be empty";
|
|
21
|
+
}
|
|
22
|
+
// Git branch naming rules
|
|
23
|
+
const invalidPatterns = [
|
|
24
|
+
{ regex: /^\./, message: "Branch name cannot start with a dot" },
|
|
25
|
+
{ regex: /\.\.$/, message: "Branch name cannot end with double dots" },
|
|
26
|
+
{ regex: /\.\./, message: "Branch name cannot contain double dots" },
|
|
27
|
+
{ regex: /\s/, message: "Branch name cannot contain spaces" },
|
|
28
|
+
{
|
|
29
|
+
regex: /[~^:\\*?[\]@{]/,
|
|
30
|
+
message: "Branch name contains invalid characters",
|
|
31
|
+
},
|
|
32
|
+
{ regex: /\.$/, message: "Branch name cannot end with a dot" },
|
|
33
|
+
{ regex: /\/$/, message: "Branch name cannot end with a slash" },
|
|
34
|
+
{
|
|
35
|
+
regex: /\/\//,
|
|
36
|
+
message: "Branch name cannot contain consecutive slashes",
|
|
37
|
+
},
|
|
38
|
+
{ regex: /^\//, message: "Branch name cannot start with a slash" },
|
|
39
|
+
{ regex: /\.$/, message: "Branch name cannot end with a dot" },
|
|
40
|
+
{ regex: /\.lock$/, message: "Branch name cannot end with '.lock'" },
|
|
41
|
+
];
|
|
42
|
+
for (const { regex, message } of invalidPatterns) {
|
|
43
|
+
if (regex.test(value)) {
|
|
44
|
+
return message;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
export async function isValidConfigValue(configName, value) {
|
|
50
|
+
switch (configName) {
|
|
51
|
+
case "jira.email":
|
|
52
|
+
return isValidEmail(value);
|
|
53
|
+
case "defaultSourceBranch":
|
|
54
|
+
return isValidBranch(value);
|
|
55
|
+
case "codeEditor":
|
|
56
|
+
return await isValidCommand(value);
|
|
57
|
+
default:
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export class InvalidConfigValueError extends Error {
|
|
62
|
+
}
|
|
63
|
+
export async function validateConfigValue(configName, value) {
|
|
64
|
+
const validationResult = await isValidConfigValue(configName, value);
|
|
65
|
+
if (validationResult !== true) {
|
|
66
|
+
throw new InvalidConfigValueError(validationResult);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as cli from "./cli.js";
|
|
2
|
+
import * as git from "./git.js";
|
|
3
|
+
import { isValidBranch, isValidBranchName, isValidCommand, isValidEmail, } from "./validators.js";
|
|
4
|
+
describe("isValidEmail", () => {
|
|
5
|
+
it.each `
|
|
6
|
+
email | expected | description
|
|
7
|
+
${"user@example.com"} | ${true} | ${"valid basic email"}
|
|
8
|
+
${"test.email@domain.co.uk"} | ${true} | ${"valid email with dots and co.uk"}
|
|
9
|
+
${"user+tag@example.org"} | ${true} | ${"valid email with plus sign"}
|
|
10
|
+
${"user_name@example.net"} | ${true} | ${"valid email with underscore"}
|
|
11
|
+
${"123@example.com"} | ${true} | ${"valid email with numbers"}
|
|
12
|
+
${"user-name@example-site.com"} | ${true} | ${"valid email with hyphens"}
|
|
13
|
+
${"a@b.co"} | ${true} | ${"valid minimal email"}
|
|
14
|
+
${"user@subdomain.example.com"} | ${true} | ${"valid email with subdomain"}
|
|
15
|
+
${"user@example.info"} | ${true} | ${"valid email with .info tld"}
|
|
16
|
+
${"plainaddress"} | ${"Invalid email address"} | ${"missing @ symbol"}
|
|
17
|
+
${"@example.com"} | ${"Invalid email address"} | ${"missing local part"}
|
|
18
|
+
${"user@"} | ${"Invalid email address"} | ${"missing domain"}
|
|
19
|
+
${"user..double.dot@example.com"} | ${true} | ${"double dots in local part (allowed by current regex)"}
|
|
20
|
+
${"user@.example.com"} | ${true} | ${"domain starts with dot (allowed by current regex)"}
|
|
21
|
+
${"user@example."} | ${"Invalid email address"} | ${"domain ends with dot"}
|
|
22
|
+
${"user@com"} | ${"Invalid email address"} | ${"missing top-level domain"}
|
|
23
|
+
${"user name@example.com"} | ${"Invalid email address"} | ${"space in local part"}
|
|
24
|
+
${"user@example com"} | ${"Invalid email address"} | ${"space in domain"}
|
|
25
|
+
${""} | ${"Invalid email address"} | ${"empty string"}
|
|
26
|
+
${"user@@example.com"} | ${"Invalid email address"} | ${"double @ symbol"}
|
|
27
|
+
${"user@example..com"} | ${true} | ${"double dots in domain (allowed by current regex)"}
|
|
28
|
+
`('should return $expected for "$email" ($description)', ({ email, expected }) => {
|
|
29
|
+
expect(isValidEmail(email)).toBe(expected);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("isValidCommand", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
});
|
|
36
|
+
it.each `
|
|
37
|
+
command | commandExists | expected | description
|
|
38
|
+
${"git"} | ${true} | ${true} | ${"existing command"}
|
|
39
|
+
${"node"} | ${true} | ${true} | ${"another existing command"}
|
|
40
|
+
${"git status"} | ${true} | ${true} | ${"command with arguments"}
|
|
41
|
+
${"nonexistent"} | ${false} | ${"Command not found: nonexistent"} | ${"non-existing command"}
|
|
42
|
+
${"fake-cmd"} | ${false} | ${"Command not found: fake-cmd"} | ${"another non-existing command"}
|
|
43
|
+
${"bad command with args"} | ${false} | ${"Command not found: bad command with args"} | ${"non-existing command with args"}
|
|
44
|
+
`('should return $expected for "$command" when commandExists returns $commandExists ($description)', async ({ command, commandExists, expected }) => {
|
|
45
|
+
// Mock the commandExists function
|
|
46
|
+
vi.spyOn(cli, "commandExists").mockResolvedValue(commandExists);
|
|
47
|
+
expect(await isValidCommand(command)).toBe(expected);
|
|
48
|
+
expect(cli.commandExists).toHaveBeenCalledWith(command);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("isValidBranch", () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
// Mock console.log to avoid cluttering test output
|
|
55
|
+
vi.spyOn(console, "log").mockImplementation(() => { });
|
|
56
|
+
});
|
|
57
|
+
it.each `
|
|
58
|
+
branch | branchType | availableBranches | expected | description
|
|
59
|
+
${"main"} | ${"local"} | ${["main", "develop", "feature/test"]} | ${true} | ${"existing local branch"}
|
|
60
|
+
${"develop"} | ${"local"} | ${["main", "develop", "feature/test"]} | ${true} | ${"another existing local branch"}
|
|
61
|
+
${"feature/test"} | ${"local"} | ${["main", "develop", "feature/test"]} | ${true} | ${"local branch with slash"}
|
|
62
|
+
${"nonexistent"} | ${"local"} | ${["main", "develop", "feature/test"]} | ${"Branch not found: nonexistent"} | ${"non-existing local branch"}
|
|
63
|
+
${"origin/main"} | ${"remote"} | ${["origin/main", "origin/develop", "origin/feature/test"]} | ${true} | ${"existing remote branch"}
|
|
64
|
+
${"origin/develop"} | ${"remote"} | ${["origin/main", "origin/develop", "origin/feature/test"]} | ${true} | ${"another existing remote branch"}
|
|
65
|
+
${"origin/feature/test"} | ${"remote"} | ${["origin/main", "origin/develop", "origin/feature/test"]} | ${true} | ${"remote branch with slash"}
|
|
66
|
+
${"origin/nonexistent"} | ${"remote"} | ${["origin/main", "origin/develop", "origin/feature/test"]} | ${"Branch not found: origin/nonexistent"} | ${"non-existing remote branch"}
|
|
67
|
+
${"upstream/main"} | ${"remote"} | ${["origin/main", "origin/develop"]} | ${"Branch not found: upstream/main"} | ${"different remote prefix"}
|
|
68
|
+
`('should return $expected for "$branch" when $branchType branches are $availableBranches ($description)', async ({ branch, branchType, availableBranches, expected }) => {
|
|
69
|
+
if (branchType === "local") {
|
|
70
|
+
vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue(availableBranches);
|
|
71
|
+
vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue([]);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(availableBranches);
|
|
75
|
+
vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue([]);
|
|
76
|
+
}
|
|
77
|
+
const result = await isValidBranch(branch);
|
|
78
|
+
expect(result).toBe(expected);
|
|
79
|
+
if (branch.startsWith("origin/")) {
|
|
80
|
+
expect(git.gitGetRemoteBranches).toHaveBeenCalled();
|
|
81
|
+
expect(git.gitGetLocalBranches).not.toHaveBeenCalled();
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
expect(git.gitGetLocalBranches).toHaveBeenCalled();
|
|
85
|
+
expect(git.gitGetRemoteBranches).not.toHaveBeenCalled();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe("isValidBranchName", () => {
|
|
90
|
+
it.each `
|
|
91
|
+
branchName | expected | description
|
|
92
|
+
${"main"} | ${true} | ${"valid simple branch name"}
|
|
93
|
+
${"feature/new-feature"} | ${true} | ${"valid branch with slash"}
|
|
94
|
+
${"bugfix/fix-123"} | ${true} | ${"valid branch with numbers"}
|
|
95
|
+
${"release/v1.0.0"} | ${true} | ${"valid release branch"}
|
|
96
|
+
${"user/john-doe"} | ${true} | ${"valid branch with hyphens"}
|
|
97
|
+
${"feature_branch"} | ${true} | ${"valid branch with underscores"}
|
|
98
|
+
${"123-branch"} | ${true} | ${"valid branch starting with numbers"}
|
|
99
|
+
${""} | ${"Branch name cannot be empty"} | ${"empty string"}
|
|
100
|
+
${" "} | ${"Branch name cannot be empty"} | ${"whitespace only"}
|
|
101
|
+
${".hidden"} | ${"Branch name cannot start with a dot"} | ${"starts with dot"}
|
|
102
|
+
${"feature..branch"} | ${"Branch name cannot contain double dots"} | ${"contains double dots"}
|
|
103
|
+
${"branch.."} | ${"Branch name cannot end with double dots"} | ${"ends with double dots"}
|
|
104
|
+
${"feature branch"} | ${"Branch name cannot contain spaces"} | ${"contains spaces"}
|
|
105
|
+
${"branch~1"} | ${"Branch name contains invalid characters"} | ${"contains tilde"}
|
|
106
|
+
${"branch^master"} | ${"Branch name contains invalid characters"} | ${"contains caret"}
|
|
107
|
+
${"branch:colon"} | ${"Branch name contains invalid characters"} | ${"contains colon"}
|
|
108
|
+
${"branch\\backslash"} | ${"Branch name contains invalid characters"} | ${"contains backslash"}
|
|
109
|
+
${"branch*star"} | ${"Branch name contains invalid characters"} | ${"contains asterisk"}
|
|
110
|
+
${"branch?question"} | ${"Branch name contains invalid characters"} | ${"contains question mark"}
|
|
111
|
+
${"branch[bracket"} | ${"Branch name contains invalid characters"} | ${"contains bracket"}
|
|
112
|
+
${"branch@at"} | ${"Branch name contains invalid characters"} | ${"contains at sign"}
|
|
113
|
+
${"branch."} | ${"Branch name cannot end with a dot"} | ${"ends with dot"}
|
|
114
|
+
${"branch/"} | ${"Branch name cannot end with a slash"} | ${"ends with slash"}
|
|
115
|
+
${"feature//double"} | ${"Branch name cannot contain consecutive slashes"} | ${"consecutive slashes"}
|
|
116
|
+
${"/starts-slash"} | ${"Branch name cannot start with a slash"} | ${"starts with slash"}
|
|
117
|
+
${"branch.lock"} | ${"Branch name cannot end with '.lock'"} | ${"ends with .lock"}
|
|
118
|
+
`('should return $expected for "$branchName" ($description)', ({ branchName, expected }) => {
|
|
119
|
+
const result = isValidBranchName(branchName);
|
|
120
|
+
expect(result).toBe(expected);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Global mock for the cmd function to prevent actual shell command execution
|
|
2
|
+
const mockCmd = vi.fn();
|
|
3
|
+
let expectedCommands = [];
|
|
4
|
+
vi.mock("./lib/cli.js", () => ({
|
|
5
|
+
cmd: mockCmd,
|
|
6
|
+
commandExists: vi.fn().mockResolvedValue(true),
|
|
7
|
+
}));
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Clear all mocks before each test
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
expectedCommands = [];
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
// Assert that no unexpected commands were called
|
|
15
|
+
const actualCalls = mockCmd.mock.calls.map((call) => call[0]);
|
|
16
|
+
const unexpectedCalls = actualCalls.filter((call) => !expectedCommands.includes(call));
|
|
17
|
+
if (unexpectedCalls.length > 0) {
|
|
18
|
+
console.warn(`Unexpected cmd calls detected:\n${unexpectedCalls
|
|
19
|
+
.map((call) => ` - ${call}`)
|
|
20
|
+
.join("\n")}`);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
// Helper function for tests to declare expected commands
|
|
24
|
+
function expectCommands(...commands) {
|
|
25
|
+
expectedCommands.push(...commands);
|
|
26
|
+
}
|
|
27
|
+
// Export the mock and helper for use in tests
|
|
28
|
+
export { mockCmd, expectCommands };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@burglekitt/worktree",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A CLI tool for managing git worktrees with enhanced workflow features",
|
|
5
|
+
"main": "./bin/run.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "shx rm -rf dist && tsc -b",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"format": "biome format",
|
|
12
|
+
"format:fix": "biome format --write",
|
|
13
|
+
"lint": "biome lint",
|
|
14
|
+
"lint:fix": "biome lint --write"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"bin",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"git",
|
|
23
|
+
"worktree",
|
|
24
|
+
"cli",
|
|
25
|
+
"workflow"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/burglekitt/worktree.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/burglekitt/worktree",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
|
34
|
+
"bin": {
|
|
35
|
+
"worktree": "./bin/run.js",
|
|
36
|
+
"@burglekitt/worktree": "./bin/run.js"
|
|
37
|
+
},
|
|
38
|
+
"oclif": {
|
|
39
|
+
"bin": "worktree",
|
|
40
|
+
"commands": "./dist/commands",
|
|
41
|
+
"dirname": "worktree",
|
|
42
|
+
"topicSeparator": " "
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@inquirer/prompts": "^8.3.0",
|
|
46
|
+
"@oclif/core": "^4.8.3",
|
|
47
|
+
"chalk": "^5.6.2",
|
|
48
|
+
"glob": "^13.0.6",
|
|
49
|
+
"ora": "^9.3.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@biomejs/biome": "2.4.6",
|
|
53
|
+
"@oclif/test": "^4.1.16",
|
|
54
|
+
"@types/node": "^18.19.130",
|
|
55
|
+
"shx": "^0.4.0",
|
|
56
|
+
"ts-node": "^10.9.2",
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"vitest": "^4.0.18"
|
|
59
|
+
}
|
|
60
|
+
}
|