From 52f7447f85fd875d48730b3c6d892f4c0ae30122 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 3 Jan 2026 09:31:35 -0800 Subject: [PATCH] Support running multiple Yaak instances via git worktrees (#341) --- .claude/skills/worktree.md | 35 ++++++ .husky/post-checkout | 1 + package-lock.json | 140 +++++++++++++++++++++ package.json | 6 +- plugins-external/mcp-server/src/index.ts | 2 +- scripts/git-hooks/post-checkout.mjs | 153 +++++++++++++++++++++++ scripts/run-dev.mjs | 49 ++++++++ src-tauri/src/lib.rs | 67 +++++----- src-web/vite.config.ts | 19 +-- 9 files changed, 425 insertions(+), 47 deletions(-) create mode 100644 .claude/skills/worktree.md create mode 100755 .husky/post-checkout create mode 100644 scripts/git-hooks/post-checkout.mjs create mode 100644 scripts/run-dev.mjs diff --git a/.claude/skills/worktree.md b/.claude/skills/worktree.md new file mode 100644 index 00000000..a97f2891 --- /dev/null +++ b/.claude/skills/worktree.md @@ -0,0 +1,35 @@ +# Worktree Management Skill + +## Creating Worktrees + +When creating git worktrees for this project, ALWAYS use the path format: +``` +../yaak-worktrees/ +``` + +For example: +- `git worktree add ../yaak-worktrees/feature-auth` +- `git worktree add ../yaak-worktrees/bugfix-login` +- `git worktree add ../yaak-worktrees/refactor-api` + +## What Happens Automatically + +The post-checkout hook will automatically: +1. Create `.env.local` with unique ports (YAAK_DEV_PORT and YAAK_PLUGIN_MCP_SERVER_PORT) +2. Copy gitignored editor config folders (.zed, .idea, etc.) +3. Run `npm install && npm run bootstrap` + +## Deleting Worktrees + +```bash +git worktree remove ../yaak-worktrees/ +``` + +## Port Assignments + +- Main worktree: 1420 (Vite), 64343 (MCP) +- First worktree: 1421, 64344 +- Second worktree: 1422, 64345 +- etc. + +Each worktree can run `npm run app-dev` simultaneously without conflicts. diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 00000000..8ca7227b --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1 @@ +node scripts/git-hooks/post-checkout.mjs "$@" diff --git a/package-lock.json b/package-lock.json index 795db237..170f2167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,8 @@ "@biomejs/biome": "^2.3.10", "@tauri-apps/cli": "^2.9.6", "@yaakapp/cli": "^0.3.4", + "dotenv-cli": "^11.0.0", + "husky": "^9.1.7", "nodejs-file-downloader": "^4.13.0", "npm-run-all": "^4.1.5", "typescript": "^5.8.3", @@ -7057,6 +7059,128 @@ "node": ">=10" } }, + "node_modules/dotenv-cli": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz", + "integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^17.1.0", + "dotenv-expand": "^12.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-cli/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dotenv-cli/node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-cli/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv-cli/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv-cli/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv-cli/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -9423,6 +9547,22 @@ "node": ">=14.18.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", diff --git a/package.json b/package.json index dfb8b480..3815c4f4 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,11 @@ "src-web" ], "scripts": { + "prepare": "husky", + "init": "npm install && npm run bootstrap", "start": "npm run app-dev", "app-build": "tauri build", - "app-dev": "tauri dev --no-watch --config ./src-tauri/tauri.development.conf.json", + "app-dev": "node scripts/run-dev.mjs", "migration": "node scripts/create-migration.cjs", "build": "npm run --workspaces --if-present build", "build-plugins": "npm run --workspaces --if-present build", @@ -93,6 +95,8 @@ "@biomejs/biome": "^2.3.10", "@tauri-apps/cli": "^2.9.6", "@yaakapp/cli": "^0.3.4", + "dotenv-cli": "^11.0.0", + "husky": "^9.1.7", "nodejs-file-downloader": "^4.13.0", "npm-run-all": "^4.1.5", "typescript": "^5.8.3", diff --git a/plugins-external/mcp-server/src/index.ts b/plugins-external/mcp-server/src/index.ts index 1b52f55e..6561c47e 100644 --- a/plugins-external/mcp-server/src/index.ts +++ b/plugins-external/mcp-server/src/index.ts @@ -1,7 +1,7 @@ import type { Context, PluginDefinition } from '@yaakapp/api'; import { createMcpServer } from './server.js'; -const serverPort = 64343; +const serverPort = parseInt(process.env.YAAK_PLUGIN_MCP_SERVER_PORT ?? '64343', 10); let mcpServer: Awaited> | null = null; diff --git a/scripts/git-hooks/post-checkout.mjs b/scripts/git-hooks/post-checkout.mjs new file mode 100644 index 00000000..1cc50e36 --- /dev/null +++ b/scripts/git-hooks/post-checkout.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node + +/** + * Git post-checkout hook for auto-configuring worktree environments. + * This runs after 'git checkout' or 'git worktree add'. + * + * Args from git: + * process.argv[2] - previous HEAD ref + * process.argv[3] - new HEAD ref + * process.argv[4] - flag (1 = branch checkout, 0 = file checkout) + */ + +import fs from 'fs'; +import path from 'path'; +import { execSync, execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const isBranchCheckout = process.argv[4] === '1'; + +if (!isBranchCheckout) { + process.exit(0); +} + +// Check if we're in a worktree by looking for .git file (not directory) +const gitPath = path.join(process.cwd(), '.git'); +const isWorktree = fs.existsSync(gitPath) && fs.statSync(gitPath).isFile(); + +if (!isWorktree) { + process.exit(0); +} + +const envLocalPath = path.join(process.cwd(), '.env.local'); + +// Don't overwrite existing .env.local +if (fs.existsSync(envLocalPath)) { + process.exit(0); +} + +console.log('Detected new worktree - configuring ports in .env.local'); + +// Find the highest ports in use across all worktrees +// Main worktree (first in list) is assumed to use default ports 1420/64343 +let maxMcpPort = 64343; +let maxDevPort = 1420; + +try { + const worktreeList = execSync('git worktree list --porcelain', { encoding: 'utf8' }); + const worktreePaths = worktreeList + .split('\n') + .filter(line => line.startsWith('worktree ')) + .map(line => line.replace('worktree ', '').trim()); + + // Skip the first worktree (main) since it uses default ports + for (let i = 1; i < worktreePaths.length; i++) { + const worktreePath = worktreePaths[i]; + const envPath = path.join(worktreePath, '.env.local'); + + if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, 'utf8'); + + const mcpMatch = content.match(/^YAAK_PLUGIN_MCP_SERVER_PORT=(\d+)/m); + if (mcpMatch) { + const port = parseInt(mcpMatch[1], 10); + if (port > maxMcpPort) { + maxMcpPort = port; + } + } + + const devMatch = content.match(/^YAAK_DEV_PORT=(\d+)/m); + if (devMatch) { + const port = parseInt(devMatch[1], 10); + if (port > maxDevPort) { + maxDevPort = port; + } + } + } + } + + // Increment to get the next available port + maxDevPort++; + maxMcpPort++; +} catch (err) { + console.error('Warning: Could not check other worktrees for port conflicts:', err.message); + // Continue with default ports +} + +// Create .env.local with unique ports +const envContent = `# Auto-generated by git post-checkout hook +# This file configures ports for this worktree to avoid conflicts + +# Vite dev server port (main worktree uses 1420) +YAAK_DEV_PORT=${maxDevPort} + +# MCP Server port (main worktree uses 64343) +YAAK_PLUGIN_MCP_SERVER_PORT=${maxMcpPort} +`; + +fs.writeFileSync(envLocalPath, envContent, 'utf8'); +console.log(`Created .env.local with YAAK_DEV_PORT=${maxDevPort} and YAAK_PLUGIN_MCP_SERVER_PORT=${maxMcpPort}`); + +// Copy gitignored editor config folders from main worktree (.zed, .vscode, .claude, etc.) +// This ensures your editor settings, tasks, and configurations are available in the new worktree +// without needing to manually copy them or commit them to git. +try { + const worktreeList = execSync('git worktree list --porcelain', { encoding: 'utf8' }); + const mainWorktreePath = worktreeList + .split('\n') + .find(line => line.startsWith('worktree ')) + ?.replace('worktree ', '') + .trim(); + + if (mainWorktreePath) { + // Find all .* folders in main worktree root that are gitignored + const entries = fs.readdirSync(mainWorktreePath, { withFileTypes: true }); + const dotFolders = entries + .filter(entry => entry.isDirectory() && entry.name.startsWith('.')) + .map(entry => entry.name); + + for (const folder of dotFolders) { + const sourcePath = path.join(mainWorktreePath, folder); + const destPath = path.join(process.cwd(), folder); + + try { + // Check if it's gitignored - run from main worktree directory + execFileSync('git', ['check-ignore', '-q', folder], { + stdio: 'pipe', + cwd: mainWorktreePath + }); + + // It's gitignored, copy it + fs.cpSync(sourcePath, destPath, { recursive: true }); + console.log(`Copied ${folder} from main worktree`); + } catch { + // Not gitignored or doesn't exist, skip + } + } + } +} catch (err) { + console.warn('Warning: Could not copy files from main worktree:', err.message); + // Continue anyway +} + +// Run npm run init to install dependencies and bootstrap +console.log('\nRunning npm run init to install dependencies and bootstrap...'); +try { + execSync('npm run init', { stdio: 'inherit' }); + console.log('\n✓ Worktree setup complete!'); +} catch (err) { + console.error('\n✗ Failed to run npm run init. You may need to run it manually.'); + process.exit(1); +} diff --git a/scripts/run-dev.mjs b/scripts/run-dev.mjs new file mode 100644 index 00000000..f4170181 --- /dev/null +++ b/scripts/run-dev.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +/** + * Script to run Tauri dev server with dynamic port configuration. + * Loads port from .env.local if present, otherwise uses default port 1420. + */ + +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Load .env.local if it exists +const envLocalPath = path.join(__dirname, '..', '.env.local'); +if (fs.existsSync(envLocalPath)) { + const envContent = fs.readFileSync(envLocalPath, 'utf8'); + const envVars = envContent + .split('\n') + .filter(line => line && !line.startsWith('#')) + .reduce((acc, line) => { + const [key, value] = line.split('='); + if (key && value) { + acc[key.trim()] = value.trim(); + } + return acc; + }, {}); + + Object.assign(process.env, envVars); +} + +const port = process.env.YAAK_DEV_PORT || '1420'; +const config = JSON.stringify({ build: { devUrl: `http://localhost:${port}` } }); + +// Get additional arguments passed after npm run app-dev -- +const additionalArgs = process.argv.slice(2); + +const args = [ + 'dev', + '--no-watch', + '--config', './src-tauri/tauri.development.conf.json', + '--config', config, + ...additionalArgs +]; + +const result = spawnSync('tauri', args, { stdio: 'inherit', shell: false, env: process.env }); + +process.exit(result.status || 0); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e335248b..daad7e9b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1417,43 +1417,48 @@ async fn cmd_check_for_updates( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - #[allow(unused_mut)] - let mut builder = tauri::Builder::default() - .plugin( - Builder::default() - .targets([ - Target::new(TargetKind::Stdout), - Target::new(TargetKind::LogDir { file_name: None }), - Target::new(TargetKind::Webview), - ]) - .level_for("plugin_runtime", log::LevelFilter::Info) - .level_for("cookie_store", log::LevelFilter::Info) - .level_for("eventsource_client::event_parser", log::LevelFilter::Info) - .level_for("h2", log::LevelFilter::Info) - .level_for("hyper", log::LevelFilter::Info) - .level_for("hyper_util", log::LevelFilter::Info) - .level_for("hyper_rustls", log::LevelFilter::Info) - .level_for("reqwest", log::LevelFilter::Info) - .level_for("sqlx", log::LevelFilter::Debug) - .level_for("tao", log::LevelFilter::Info) - .level_for("tokio_util", log::LevelFilter::Info) - .level_for("tonic", log::LevelFilter::Info) - .level_for("tower", log::LevelFilter::Info) - .level_for("tracing", log::LevelFilter::Warn) - .level_for("swc_ecma_codegen", log::LevelFilter::Off) - .level_for("swc_ecma_transforms_base", log::LevelFilter::Off) - .with_colors(ColoredLevelConfig::default()) - .level(if is_dev() { log::LevelFilter::Debug } else { log::LevelFilter::Info }) - .build(), - ) - .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + let mut builder = tauri::Builder::default().plugin( + Builder::default() + .targets([ + Target::new(TargetKind::Stdout), + Target::new(TargetKind::LogDir { file_name: None }), + Target::new(TargetKind::Webview), + ]) + .level_for("plugin_runtime", log::LevelFilter::Info) + .level_for("cookie_store", log::LevelFilter::Info) + .level_for("eventsource_client::event_parser", log::LevelFilter::Info) + .level_for("h2", log::LevelFilter::Info) + .level_for("hyper", log::LevelFilter::Info) + .level_for("hyper_util", log::LevelFilter::Info) + .level_for("hyper_rustls", log::LevelFilter::Info) + .level_for("reqwest", log::LevelFilter::Info) + .level_for("sqlx", log::LevelFilter::Debug) + .level_for("tao", log::LevelFilter::Info) + .level_for("tokio_util", log::LevelFilter::Info) + .level_for("tonic", log::LevelFilter::Info) + .level_for("tower", log::LevelFilter::Info) + .level_for("tracing", log::LevelFilter::Warn) + .level_for("swc_ecma_codegen", log::LevelFilter::Off) + .level_for("swc_ecma_transforms_base", log::LevelFilter::Off) + .with_colors(ColoredLevelConfig::default()) + .level(if is_dev() { log::LevelFilter::Debug } else { log::LevelFilter::Info }) + .build(), + ); + + // Only enable single-instance in production builds. In dev mode, we want to allow + // multiple instances for testing and worktree workflows (running multiple branches). + if !is_dev() { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { // When trying to open a new app instance (common operation on Linux), // focus the first existing window we find instead of opening a new one // TODO: Keep track of the last focused window and always focus that one if let Some(window) = app.webview_windows().values().next() { let _ = window.set_focus(); } - })) + })); + } + + builder = builder .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_opener::init()) // Don't restore StateFlags::DECORATIONS because we want to be able to toggle them on/off on a restart diff --git a/src-web/vite.config.ts b/src-web/vite.config.ts index 36d14962..41fe2dc5 100644 --- a/src-web/vite.config.ts +++ b/src-web/vite.config.ts @@ -1,7 +1,6 @@ // @ts-ignore import { tanstackRouter } from '@tanstack/router-plugin/vite'; import react from '@vitejs/plugin-react'; -import { internalIpV4 } from 'internal-ip'; import { createRequire } from 'node:module'; import path from 'node:path'; import { defineConfig, normalizePath } from 'vite'; @@ -18,10 +17,9 @@ const standardFontsDir = normalizePath( path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts'), ); -const mobile = !!/android|ios/.exec(process.env.TAURI_ENV_PLATFORM ?? ''); - // https://vitejs.dev/config/ -export default defineConfig(async () => ({ +export default defineConfig(async () => { + return { plugins: [ wasm(), tanstackRouter({ @@ -54,16 +52,9 @@ export default defineConfig(async () => ({ }, clearScreen: false, server: { - port: 1420, + port: parseInt(process.env.YAAK_DEV_PORT ?? '1420', 10), strictPort: true, - host: mobile ? '0.0.0.0' : false, - hmr: mobile - ? { - protocol: 'ws', - host: await internalIpV4(), - port: 1421, - } - : undefined, }, envPrefix: ['VITE_', 'TAURI_'], -})); +}; +});