@jasonfutch/worktree-manager 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Type definitions for the worktree manager
3
+ */
4
+ /**
5
+ * Represents a git worktree
6
+ */
7
+ export interface Worktree {
8
+ /** Absolute filesystem path to the worktree */
9
+ path: string;
10
+ /** Branch name, or "HEAD (detached)" for detached HEAD state */
11
+ branch: string;
12
+ /** Short SHA of the current commit (7 characters) */
13
+ commit: string;
14
+ /** Whether this is the main worktree (same path as repo root) */
15
+ isMain: boolean;
16
+ /** Whether this is a bare repository */
17
+ isBare: boolean;
18
+ /** Whether the worktree is locked */
19
+ isLocked: boolean;
20
+ /** Reason for lock, if locked with a reason */
21
+ lockedReason?: string;
22
+ /** Whether the worktree is prunable (stale) */
23
+ prunable?: boolean;
24
+ /** Friendly name derived from path basename */
25
+ name: string;
26
+ }
27
+ /**
28
+ * Options for creating a new worktree
29
+ */
30
+ export interface CreateWorktreeOptions {
31
+ /** Name of the branch to create or checkout */
32
+ branch: string;
33
+ /** Base branch to create new branch from (default: 'main') */
34
+ baseBranch?: string;
35
+ /** Custom path for the worktree (default: auto-generated) */
36
+ path?: string;
37
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Type definitions for the worktree manager
3
+ */
4
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Check if Git is installed and accessible
3
+ */
4
+ export declare function checkGitInstalled(): boolean;
5
+ /**
6
+ * Check if Node.js version meets minimum requirement
7
+ */
8
+ export declare function checkNodeVersion(): boolean;
9
+ /**
10
+ * Run all startup checks and exit with helpful messages if any fail
11
+ */
12
+ export declare function runStartupChecks(): void;
@@ -0,0 +1,109 @@
1
+ import { execSync } from 'child_process';
2
+ import chalk from 'chalk';
3
+ const MIN_NODE_VERSION = 18;
4
+ /**
5
+ * Check if Git is installed and accessible
6
+ */
7
+ export function checkGitInstalled() {
8
+ try {
9
+ execSync('git --version', { stdio: 'pipe' });
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /**
17
+ * Check if Node.js version meets minimum requirement
18
+ */
19
+ export function checkNodeVersion() {
20
+ const currentVersion = parseInt(process.versions.node.split('.')[0], 10);
21
+ return currentVersion >= MIN_NODE_VERSION;
22
+ }
23
+ /**
24
+ * Get current Node.js version string
25
+ */
26
+ function getNodeVersion() {
27
+ return process.versions.node;
28
+ }
29
+ /**
30
+ * Detect platform for install instructions
31
+ */
32
+ function getPlatform() {
33
+ if (process.platform === 'darwin')
34
+ return 'mac';
35
+ if (process.platform === 'win32')
36
+ return 'win';
37
+ return 'linux';
38
+ }
39
+ /**
40
+ * Run all startup checks and exit with helpful messages if any fail
41
+ */
42
+ export function runStartupChecks() {
43
+ const errors = [];
44
+ const platform = getPlatform();
45
+ // Check Git
46
+ if (!checkGitInstalled()) {
47
+ let installInstructions = '';
48
+ if (platform === 'mac') {
49
+ installInstructions = `
50
+ ${chalk.cyan('macOS:')}
51
+ brew install git
52
+ ${chalk.gray('or install Xcode Command Line Tools:')} xcode-select --install`;
53
+ }
54
+ else if (platform === 'win') {
55
+ installInstructions = `
56
+ ${chalk.cyan('Windows:')}
57
+ Download from: ${chalk.underline('https://git-scm.com/download/win')}
58
+ ${chalk.gray('or use:')} winget install Git.Git`;
59
+ }
60
+ else {
61
+ installInstructions = `
62
+ ${chalk.cyan('Ubuntu/Debian:')} sudo apt install git
63
+ ${chalk.cyan('Fedora:')} sudo dnf install git
64
+ ${chalk.cyan('Arch:')} sudo pacman -S git`;
65
+ }
66
+ errors.push(`
67
+ ${chalk.red.bold('Git is not installed or not in PATH.')}
68
+
69
+ ${chalk.yellow('Install Git:')}${installInstructions}
70
+ `);
71
+ }
72
+ // Check Node version
73
+ if (!checkNodeVersion()) {
74
+ let updateInstructions = '';
75
+ if (platform === 'mac') {
76
+ updateInstructions = `
77
+ ${chalk.cyan('macOS:')}
78
+ brew upgrade node
79
+ ${chalk.gray('or use nvm:')} nvm install 18`;
80
+ }
81
+ else if (platform === 'win') {
82
+ updateInstructions = `
83
+ ${chalk.cyan('Windows:')}
84
+ Download from: ${chalk.underline('https://nodejs.org/')}
85
+ ${chalk.gray('or use:')} winget upgrade OpenJS.NodeJS.LTS`;
86
+ }
87
+ else {
88
+ updateInstructions = `
89
+ ${chalk.cyan('Linux:')}
90
+ Download from: ${chalk.underline('https://nodejs.org/')}
91
+ ${chalk.gray('or use nvm:')} nvm install 18`;
92
+ }
93
+ errors.push(`
94
+ ${chalk.red.bold(`Node.js ${MIN_NODE_VERSION}+ is required.`)} ${chalk.gray(`You have: ${getNodeVersion()}`)}
95
+
96
+ ${chalk.yellow('Update Node.js:')}${updateInstructions}
97
+ `);
98
+ }
99
+ // If any errors, print them and exit
100
+ if (errors.length > 0) {
101
+ console.error(chalk.red.bold('\n Dependency Check Failed\n'));
102
+ console.error(chalk.gray('─'.repeat(50)));
103
+ for (const error of errors) {
104
+ console.error(error);
105
+ }
106
+ console.error(chalk.gray('─'.repeat(50)));
107
+ process.exit(1);
108
+ }
109
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Helper utilities for the worktree manager
3
+ */
4
+ /**
5
+ * Get a truncated string with ellipsis
6
+ */
7
+ export declare function truncate(str: string, maxLength: number): string;
8
+ /**
9
+ * Format a relative path from a base path
10
+ */
11
+ export declare function formatPath(fullPath: string, basePath: string): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Helper utilities for the worktree manager
3
+ */
4
+ /**
5
+ * Get a truncated string with ellipsis
6
+ */
7
+ export function truncate(str, maxLength) {
8
+ if (str.length <= maxLength)
9
+ return str;
10
+ return str.slice(0, maxLength - 3) + '...';
11
+ }
12
+ /**
13
+ * Format a relative path from a base path
14
+ */
15
+ export function formatPath(fullPath, basePath) {
16
+ if (fullPath.startsWith(basePath)) {
17
+ return fullPath.slice(basePath.length + 1) || '.';
18
+ }
19
+ return fullPath;
20
+ }
@@ -0,0 +1,17 @@
1
+ import { EDITORS, AI_TOOLS } from '../constants.js';
2
+ export type EditorType = keyof typeof EDITORS;
3
+ export type AIToolType = keyof typeof AI_TOOLS;
4
+ /**
5
+ * Open a path in an editor
6
+ */
7
+ export declare function openEditor(targetPath: string, editor: EditorType, onError?: (message: string) => void, onSuccess?: (message: string) => void): void;
8
+ /**
9
+ * Open a terminal at a path
10
+ */
11
+ export declare function openTerminal(targetPath: string, onError?: (message: string) => void, onSuccess?: (message: string) => void): void;
12
+ /**
13
+ * Launch an AI tool at a path
14
+ */
15
+ export declare function launchAI(targetPath: string, tool: AIToolType, onError?: (message: string) => void, onSuccess?: (message: string) => void): void;
16
+ export declare const validEditors: EditorType[];
17
+ export declare const validAITools: AIToolType[];
@@ -0,0 +1,223 @@
1
+ import { spawn } from 'child_process';
2
+ import chalk from 'chalk';
3
+ import { escapeAppleScript, escapeWindowsArg } from './shell.js';
4
+ import { PLATFORM, getInstallInstruction, EDITORS } from '../constants.js';
5
+ import { ToolNotFoundError } from '../errors.js';
6
+ const editorNames = {
7
+ code: 'VS Code',
8
+ cursor: 'Cursor',
9
+ zed: 'Zed',
10
+ webstorm: 'WebStorm',
11
+ subl: 'Sublime Text',
12
+ nvim: 'Neovim'
13
+ };
14
+ const aiToolNames = {
15
+ claude: 'Claude',
16
+ gemini: 'Gemini',
17
+ codex: 'Codex'
18
+ };
19
+ /**
20
+ * Open a path in an editor
21
+ */
22
+ export function openEditor(targetPath, editor, onError, onSuccess) {
23
+ const name = editorNames[editor];
24
+ const editorConfig = EDITORS[editor];
25
+ const isTerminalEditor = editorConfig?.terminal ?? false;
26
+ const handleError = (err) => {
27
+ if (err.code === 'ENOENT') {
28
+ const hint = getInstallInstruction(editor);
29
+ const message = `${name} not found. ${hint}`;
30
+ if (onError)
31
+ onError(message);
32
+ else
33
+ console.error(chalk.red(message));
34
+ }
35
+ else {
36
+ const message = `Error: ${err.message}`;
37
+ if (onError)
38
+ onError(message);
39
+ else
40
+ console.error(chalk.red(message));
41
+ }
42
+ };
43
+ try {
44
+ let proc;
45
+ if (isTerminalEditor) {
46
+ if (PLATFORM.IS_MAC) {
47
+ // Escape the path for AppleScript context
48
+ const escapedPath = escapeAppleScript(targetPath);
49
+ const script = `
50
+ tell application "Terminal"
51
+ do script "cd '${escapedPath}' && ${editor} ."
52
+ activate
53
+ end tell
54
+ `;
55
+ proc = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
56
+ }
57
+ else if (PLATFORM.IS_WIN) {
58
+ // Escape the path for Windows cmd context
59
+ const escapedPath = escapeWindowsArg(targetPath);
60
+ proc = spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d ${escapedPath} && ${editor} .`], {
61
+ detached: true,
62
+ stdio: 'ignore',
63
+ shell: true
64
+ });
65
+ }
66
+ else {
67
+ // Linux - pass path directly to gnome-terminal which handles it safely
68
+ proc = spawn('gnome-terminal', ['--working-directory', targetPath, '--', editor, '.'], {
69
+ detached: true,
70
+ stdio: 'ignore'
71
+ });
72
+ }
73
+ }
74
+ else {
75
+ // GUI editors can open directly - spawn with array args (safe)
76
+ proc = spawn(editor, [targetPath], { detached: true, stdio: 'ignore' });
77
+ }
78
+ proc.on('error', handleError);
79
+ proc.unref();
80
+ const successMsg = `Opened in ${name}`;
81
+ if (onSuccess)
82
+ onSuccess(successMsg);
83
+ else
84
+ console.log(chalk.green(`✓ ${successMsg}`));
85
+ }
86
+ catch (error) {
87
+ const message = `Error: ${error instanceof Error ? error.message : error}`;
88
+ if (onError)
89
+ onError(message);
90
+ else
91
+ console.error(chalk.red(message));
92
+ }
93
+ }
94
+ /**
95
+ * Open a terminal at a path
96
+ */
97
+ export function openTerminal(targetPath, onError, onSuccess) {
98
+ try {
99
+ if (PLATFORM.IS_MAC) {
100
+ // macOS Terminal.app - use open command with path as argument (safe)
101
+ spawn('open', ['-a', 'Terminal', targetPath], { detached: true, stdio: 'ignore' });
102
+ }
103
+ else if (PLATFORM.IS_WIN) {
104
+ // Windows - escape path for cmd.exe
105
+ const escapedPath = escapeWindowsArg(targetPath);
106
+ spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d ${escapedPath}`], {
107
+ detached: true,
108
+ stdio: 'ignore',
109
+ shell: true
110
+ });
111
+ }
112
+ else if (PLATFORM.IS_WSL) {
113
+ // WSL - pass path directly to wt.exe
114
+ spawn('wt.exe', ['-d', targetPath], { detached: true, stdio: 'ignore' });
115
+ }
116
+ else {
117
+ // Linux - try common terminal emulators
118
+ const terminals = ['gnome-terminal', 'konsole', 'xterm', 'terminator'];
119
+ let launched = false;
120
+ for (const term of terminals) {
121
+ try {
122
+ const proc = spawn(term, ['--working-directory', targetPath], {
123
+ detached: true,
124
+ stdio: 'ignore'
125
+ });
126
+ proc.on('error', () => {
127
+ // Silently fail and try next terminal
128
+ });
129
+ proc.unref();
130
+ launched = true;
131
+ break;
132
+ }
133
+ catch {
134
+ // Try next terminal
135
+ }
136
+ }
137
+ if (!launched) {
138
+ throw new ToolNotFoundError('Terminal emulator', 'Install gnome-terminal, konsole, xterm, or terminator');
139
+ }
140
+ }
141
+ const successMsg = 'Opened terminal';
142
+ if (onSuccess)
143
+ onSuccess(successMsg);
144
+ else
145
+ console.log(chalk.green(`✓ ${successMsg}`));
146
+ }
147
+ catch (error) {
148
+ if (error instanceof ToolNotFoundError) {
149
+ if (onError)
150
+ onError(error.message);
151
+ else
152
+ console.error(chalk.red(error.message));
153
+ }
154
+ else {
155
+ const message = `Error: ${error instanceof Error ? error.message : error}`;
156
+ if (onError)
157
+ onError(message);
158
+ else
159
+ console.error(chalk.red(message));
160
+ }
161
+ }
162
+ }
163
+ /**
164
+ * Launch an AI tool at a path
165
+ */
166
+ export function launchAI(targetPath, tool, onError, onSuccess) {
167
+ const name = aiToolNames[tool];
168
+ try {
169
+ let proc;
170
+ if (PLATFORM.IS_MAC) {
171
+ // Escape the path for AppleScript context
172
+ const escapedPath = escapeAppleScript(targetPath);
173
+ const script = `
174
+ tell application "Terminal"
175
+ do script "cd '${escapedPath}' && ${tool}"
176
+ activate
177
+ end tell
178
+ `;
179
+ proc = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
180
+ }
181
+ else if (PLATFORM.IS_WIN) {
182
+ // Escape the path for Windows cmd context
183
+ const escapedPath = escapeWindowsArg(targetPath);
184
+ proc = spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d ${escapedPath} && ${tool}`], {
185
+ detached: true,
186
+ stdio: 'ignore',
187
+ shell: true
188
+ });
189
+ }
190
+ else {
191
+ // Linux - pass arguments safely via array
192
+ proc = spawn('gnome-terminal', ['--working-directory', targetPath, '--', tool], {
193
+ detached: true,
194
+ stdio: 'ignore'
195
+ });
196
+ }
197
+ proc.on('error', (err) => {
198
+ if (err.code === 'ENOENT') {
199
+ const hint = getInstallInstruction(tool);
200
+ const message = `${name} not found. ${hint}`;
201
+ if (onError)
202
+ onError(message);
203
+ else
204
+ console.error(chalk.red(message));
205
+ }
206
+ });
207
+ proc.unref();
208
+ const successMsg = `Launched ${name}`;
209
+ if (onSuccess)
210
+ onSuccess(successMsg);
211
+ else
212
+ console.log(chalk.green(`✓ ${successMsg}`));
213
+ }
214
+ catch (error) {
215
+ const message = `Error: ${error instanceof Error ? error.message : error}`;
216
+ if (onError)
217
+ onError(message);
218
+ else
219
+ console.error(chalk.red(message));
220
+ }
221
+ }
222
+ export const validEditors = ['code', 'cursor', 'zed', 'webstorm', 'subl', 'nvim'];
223
+ export const validAITools = ['claude', 'gemini', 'codex'];
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shell escaping utilities to prevent command injection
3
+ */
4
+ /**
5
+ * Escape a string for use as a shell argument (POSIX shells)
6
+ * Wraps the argument in single quotes and escapes any existing single quotes
7
+ */
8
+ export declare function escapeShellArg(arg: string): string;
9
+ /**
10
+ * Escape a string for use in AppleScript
11
+ * Handles single quotes, double quotes, and backslashes
12
+ */
13
+ export declare function escapeAppleScript(str: string): string;
14
+ /**
15
+ * Escape a string for use in Windows cmd.exe
16
+ * Handles special characters that need escaping in batch scripts
17
+ */
18
+ export declare function escapeWindowsArg(arg: string): string;
19
+ /**
20
+ * Validate that a path doesn't contain path traversal attempts
21
+ * @returns true if the path is safe, false if it contains suspicious patterns
22
+ */
23
+ export declare function isPathSafe(targetPath: string, basePath?: string): boolean;
24
+ /**
25
+ * Validate a git branch name
26
+ * Based on git-check-ref-format rules
27
+ */
28
+ export declare function isValidBranchName(branch: string): boolean;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shell escaping utilities to prevent command injection
3
+ */
4
+ import path from 'path';
5
+ /**
6
+ * Escape a string for use as a shell argument (POSIX shells)
7
+ * Wraps the argument in single quotes and escapes any existing single quotes
8
+ */
9
+ export function escapeShellArg(arg) {
10
+ // Replace single quotes with '\'' (end quote, escaped quote, start quote)
11
+ return `'${arg.replace(/'/g, "'\\''")}'`;
12
+ }
13
+ /**
14
+ * Escape a string for use in AppleScript
15
+ * Handles single quotes, double quotes, and backslashes
16
+ */
17
+ export function escapeAppleScript(str) {
18
+ // In AppleScript strings, escape backslashes and quotes
19
+ return str
20
+ .replace(/\\/g, '\\\\')
21
+ .replace(/'/g, "\\'")
22
+ .replace(/"/g, '\\"');
23
+ }
24
+ /**
25
+ * Escape a string for use in Windows cmd.exe
26
+ * Handles special characters that need escaping in batch scripts
27
+ */
28
+ export function escapeWindowsArg(arg) {
29
+ // For cmd.exe, we need to handle special characters
30
+ // Wrap in double quotes and escape internal double quotes
31
+ return `"${arg.replace(/"/g, '""')}"`;
32
+ }
33
+ /**
34
+ * Validate that a path doesn't contain path traversal attempts
35
+ * @returns true if the path is safe, false if it contains suspicious patterns
36
+ */
37
+ export function isPathSafe(targetPath, basePath) {
38
+ // Check for obvious path traversal patterns
39
+ if (targetPath.includes('..')) {
40
+ return false;
41
+ }
42
+ // Check for null bytes (can be used to bypass security checks)
43
+ if (targetPath.includes('\0')) {
44
+ return false;
45
+ }
46
+ // If a base path is provided, ensure target is within it
47
+ if (basePath) {
48
+ const resolved = path.resolve(targetPath);
49
+ const resolvedBase = path.resolve(basePath);
50
+ // Target should start with base path
51
+ if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
52
+ return false;
53
+ }
54
+ }
55
+ return true;
56
+ }
57
+ /**
58
+ * Validate a git branch name
59
+ * Based on git-check-ref-format rules
60
+ */
61
+ export function isValidBranchName(branch) {
62
+ // Can't be empty
63
+ if (!branch || branch.length === 0) {
64
+ return false;
65
+ }
66
+ // Can't start or end with /
67
+ if (branch.startsWith('/') || branch.endsWith('/')) {
68
+ return false;
69
+ }
70
+ // Can't contain consecutive slashes
71
+ if (branch.includes('//')) {
72
+ return false;
73
+ }
74
+ // Can't contain special characters
75
+ const forbidden = ['..', ' ', '~', '^', ':', '?', '*', '[', '\\', '\x7f'];
76
+ for (const char of forbidden) {
77
+ if (branch.includes(char)) {
78
+ return false;
79
+ }
80
+ }
81
+ // Can't start with a dash
82
+ if (branch.startsWith('-')) {
83
+ return false;
84
+ }
85
+ // Can't end with .lock
86
+ if (branch.endsWith('.lock')) {
87
+ return false;
88
+ }
89
+ // Can't contain @{
90
+ if (branch.includes('@{')) {
91
+ return false;
92
+ }
93
+ return true;
94
+ }
@@ -0,0 +1,6 @@
1
+ export declare const VERSION: string;
2
+ export declare const PACKAGE_NAME: string;
3
+ export declare const pkg: {
4
+ name: string;
5
+ version: string;
6
+ };
@@ -0,0 +1,11 @@
1
+ import { readFileSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = dirname(__filename);
6
+ // Read version from package.json
7
+ const packageJsonPath = join(__dirname, '..', 'package.json');
8
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
9
+ export const VERSION = packageJson.version;
10
+ export const PACKAGE_NAME = packageJson.name;
11
+ export const pkg = packageJson;
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@jasonfutch/worktree-manager",
3
+ "version": "1.0.0",
4
+ "description": "Terminal app for managing git worktrees with AI assistance",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "wtm": "dist/index.js",
9
+ "worktree-manager": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "start": "node dist/index.js",
19
+ "dev": "tsx src/index.ts",
20
+ "clean": "rm -rf dist",
21
+ "lint": "eslint src/",
22
+ "lint:fix": "eslint src/ --fix",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "git",
27
+ "worktree",
28
+ "terminal",
29
+ "tui",
30
+ "cli",
31
+ "claude",
32
+ "ai",
33
+ "vscode",
34
+ "cursor"
35
+ ],
36
+ "author": "jasonfutch",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/jasonfutch/worktree-manager.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/jasonfutch/worktree-manager/issues"
44
+ },
45
+ "homepage": "https://github.com/jasonfutch/worktree-manager#readme",
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "dependencies": {
53
+ "blessed": "^0.1.81",
54
+ "chalk": "^5.6.2",
55
+ "commander": "^14.0.2",
56
+ "update-notifier": "^7.3.1"
57
+ },
58
+ "devDependencies": {
59
+ "@eslint/js": "^9.17.0",
60
+ "@types/blessed": "^0.1.27",
61
+ "@types/node": "^25.0.3",
62
+ "@types/update-notifier": "^6.0.8",
63
+ "eslint": "^9.17.0",
64
+ "tsx": "^4.21.0",
65
+ "typescript": "^5.9.3",
66
+ "typescript-eslint": "^8.18.2"
67
+ }
68
+ }