From 3e0002503fcfbaefaa2720b805cfae6a5b213890 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 27 May 2026 20:47:37 +0200 Subject: [PATCH] feat(monorepo): apps/* aware worktrees, preflight, and gx watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four wins for repos with apps/ (storefront + backend monorepos like the Medusa shops) where the root worktree stays on the protected base so dev servers visualize merged state: 1. Env symlink + auto port on worktree creation prepareAgentWorktree() symlinks root apps//.env into every spawned agent worktree and writes a .env.local with a free PORT=N (storefront pool 5174+, backend pool 9101+). 2. AGENTS.md monorepo block on gx setup templates/AGENTS.monorepo-apps.md with the per-task loop and cross-app guardrails. Auto-detected via apps//package.json. 3. Pre-ship typecheck + lint preflight runPreflight() walks the diff against the base, finds touched apps/ workspace packages, runs typecheck + lint scripts. Blocks the PR on any failure. Bypass with --skip-preflight. 4. gx watch โ€” live TUI for agent worktrees Alternate-screen dashboard refreshing every 2s with last commit, dirty count, dev server port(s), and PR status (when gh present). Zero new test failures (38 pass / 6 fail baseline preserved). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/args.js | 5 + src/cli/commands/watch.js | 234 ++++++++++++++++++++++++++++ src/cli/main.js | 2 + src/cli/shared/sandbox.js | 18 ++- src/cli/shared/scaffolding.js | 3 + src/context.js | 4 + src/finish/index.js | 23 +++ src/finish/preflight.js | 177 +++++++++++++++++++++ src/sandbox/index.js | 15 +- src/scaffold/agent-worktree-prep.js | 213 +++++++++++++++++++++++++ src/scaffold/index.js | 75 +++++++++ templates/AGENTS.monorepo-apps.md | 26 ++++ 12 files changed, 791 insertions(+), 4 deletions(-) create mode 100644 src/cli/commands/watch.js create mode 100644 src/finish/preflight.js create mode 100644 src/scaffold/agent-worktree-prep.js create mode 100644 templates/AGENTS.monorepo-apps.md diff --git a/src/cli/args.js b/src/cli/args.js index decb421b..9d480e44 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -1084,6 +1084,7 @@ function parseFinishArgs(rawArgs, defaults = {}) { failFast: false, commitMessage: '', mergeMode: defaults.mergeMode || 'pr', + skipPreflight: false, }; for (let index = 0; index < rawArgs.length; index += 1) { @@ -1196,6 +1197,10 @@ function parseFinishArgs(rawArgs, defaults = {}) { options.advanceSubmodules = false; continue; } + if (arg === '--skip-preflight') { + options.skipPreflight = true; + continue; + } throw new Error(`Unknown option: ${arg}`); } diff --git a/src/cli/commands/watch.js b/src/cli/commands/watch.js new file mode 100644 index 00000000..ad70b82e --- /dev/null +++ b/src/cli/commands/watch.js @@ -0,0 +1,234 @@ +'use strict'; + +// `gx watch` โ€” live TUI showing every agent worktree on a single screen. +// One row per branch: last commit (age + short message), uncommitted file +// count, dev server port from .env.local, optional PR status (when `gh` is +// available). Uses the terminal's alternate screen buffer so the regular +// scrollback survives. SIGINT restores it cleanly. + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { resolveRepoRoot } = require('../../git'); + +const ALT_SCREEN_ON = '\x1b[?1049h'; +const ALT_SCREEN_OFF = '\x1b[?1049l'; +const CURSOR_HIDE = '\x1b[?25l'; +const CURSOR_SHOW = '\x1b[?25h'; +const CLEAR_HOME = '\x1b[2J\x1b[H'; + +function dim(s) { return `\x1b[2m${s}\x1b[22m`; } +function bold(s) { return `\x1b[1m${s}\x1b[22m`; } +function green(s) { return `\x1b[32m${s}\x1b[39m`; } +function yellow(s) { return `\x1b[33m${s}\x1b[39m`; } +function red(s) { return `\x1b[31m${s}\x1b[39m`; } +function cyan(s) { return `\x1b[36m${s}\x1b[39m`; } + +function parseWatchArgs(rawArgs) { + const options = { target: process.cwd(), intervalMs: 2000, once: false }; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (arg === '--target') { + options.target = rawArgs[i + 1]; + i += 1; + } else if (arg === '--interval') { + const n = Number(rawArgs[i + 1]); + if (Number.isFinite(n) && n >= 0.5) options.intervalMs = Math.round(n * 1000); + i += 1; + } else if (arg === '--once') { + options.once = true; + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } + } + return options; +} + +function gitCapture(repoRoot, args, timeoutMs = 4000) { + const r = spawnSync('git', ['-C', repoRoot, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: timeoutMs, + }); + if (r.status !== 0) return null; + return (r.stdout || '').toString(); +} + +function listAgentWorktrees(repoRoot) { + const out = gitCapture(repoRoot, ['worktree', 'list', '--porcelain']); + if (!out) return []; + const entries = []; + let current = {}; + for (const line of out.split('\n')) { + if (line.startsWith('worktree ')) { + if (current.path) entries.push(current); + current = { path: line.slice('worktree '.length).trim() }; + } else if (line.startsWith('branch ')) { + current.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, ''); + } else if (line.startsWith('HEAD ')) { + current.head = line.slice('HEAD '.length).trim(); + } + } + if (current.path) entries.push(current); + return entries.filter((e) => e.branch && e.branch.startsWith('agent/')); +} + +function lastCommit(worktreePath) { + const out = gitCapture(worktreePath, ['log', '-1', '--format=%h%x09%cr%x09%s']); + if (!out) return null; + const [sha, age, ...rest] = out.trim().split('\t'); + return { sha, age, subject: rest.join('\t') }; +} + +function dirtyCount(worktreePath) { + const out = gitCapture(worktreePath, ['status', '--porcelain']); + if (out === null) return null; + return out.split('\n').filter(Boolean).length; +} + +function readPortFromEnvLocal(worktreePath) { + const ports = []; + const appsRoot = path.join(worktreePath, 'apps'); + let entries; + try { + entries = fs.readdirSync(appsRoot, { withFileTypes: true }); + } catch { + return ports; + } + for (const e of entries) { + if (!e.isDirectory()) continue; + const envLocal = path.join(appsRoot, e.name, '.env.local'); + let content; + try { + content = fs.readFileSync(envLocal, 'utf8'); + } catch { + continue; + } + const m = content.match(/^PORT=(\d+)/m); + if (m) ports.push({ app: e.name, port: Number(m[1]) }); + } + return ports; +} + +function ghPrStatus(repoRoot, branch) { + const r = spawnSync( + 'gh', + ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', '--json', 'number,state,url'], + { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 3000 }, + ); + if (r.error || r.status !== 0) return null; + try { + const arr = JSON.parse((r.stdout || '').toString()); + return arr[0] || null; + } catch { + return null; + } +} + +function paintStatus(state) { + const upper = state ? state.toUpperCase() : ''; + if (upper === 'OPEN') return green('OPEN'); + if (upper === 'MERGED') return cyan('MERGED'); + if (upper === 'CLOSED') return dim('CLOSED'); + return dim('โ€”'); +} + +function render(repoRoot, hasGh) { + const lines = []; + const now = new Date().toLocaleTimeString(); + lines.push( + bold('gx watch ') + + dim(`ยท ${path.basename(repoRoot)} ยท refreshed ${now}`), + ); + lines.push(dim('โ”€'.repeat(78))); + + const worktrees = listAgentWorktrees(repoRoot); + if (worktrees.length === 0) { + lines.push(dim(' (no agent/* worktrees โ€” use `gx pivot` or `gx branch start` to spawn one)')); + lines.push(''); + lines.push(dim('Press Ctrl+C to exit')); + return lines.join('\n'); + } + + for (const wt of worktrees) { + const commit = lastCommit(wt.path) || { sha: 'โ€”', age: 'โ€”', subject: '(no commits)' }; + const dirty = dirtyCount(wt.path); + const ports = readPortFromEnvLocal(wt.path); + const pr = hasGh ? ghPrStatus(repoRoot, wt.branch) : null; + const dirtyTag = dirty == null + ? dim('โ€”') + : dirty === 0 + ? green('clean') + : yellow(`${dirty} dirty`); + const prTag = hasGh + ? (pr ? `${paintStatus(pr.state)} #${pr.number}` : dim('no PR')) + : dim('gh n/a'); + const portsTag = ports.length + ? ports.map((p) => `${p.app}:${cyan(String(p.port))}`).join(' ยท ') + : dim('no port'); + + lines.push(bold(wt.branch)); + lines.push( + ` ${cyan(commit.sha)} ${dim(commit.age)} โ€” ${commit.subject.slice(0, 60)}`, + ); + lines.push( + ` ${dirtyTag} ยท ${portsTag} ยท ${prTag}`, + ); + lines.push(dim(` ${wt.path}`)); + lines.push(''); + } + + lines.push(dim('Press Ctrl+C to exit')); + return lines.join('\n'); +} + +function detectGh() { + const r = spawnSync('gh', ['--version'], { stdio: 'ignore', timeout: 1500 }); + return !r.error && r.status === 0; +} + +function printHelp() { + console.log(`gx watch โ€” live dashboard of agent worktrees + +Usage: + gx watch [--interval ] [--target ] [--once] + +Options: + --interval N Refresh interval in seconds (default 2) + --target PATH Repo root (default: current dir) + --once Render once and exit (good for scripting) +`); +} + +function watch(rawArgs) { + const options = parseWatchArgs(rawArgs); + if (options.help) { + printHelp(); + return; + } + const repoRoot = resolveRepoRoot(options.target); + const hasGh = detectGh(); + + if (options.once) { + process.stdout.write(render(repoRoot, hasGh) + '\n'); + return; + } + + process.stdout.write(ALT_SCREEN_ON + CURSOR_HIDE); + const restore = () => { + process.stdout.write(CURSOR_SHOW + ALT_SCREEN_OFF); + }; + const onExit = () => { restore(); process.exit(0); }; + process.on('SIGINT', onExit); + process.on('SIGTERM', onExit); + process.on('exit', restore); + + const tick = () => { + process.stdout.write(CLEAR_HOME + render(repoRoot, hasGh)); + }; + tick(); + const id = setInterval(tick, options.intervalMs); + // Keep the process alive; clearInterval happens via SIGINT only. + void id; +} + +module.exports = { watch, parseWatchArgs }; diff --git a/src/cli/main.js b/src/cli/main.js index 3e05df60..37cacafe 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -41,6 +41,7 @@ const { claude: claudeCommand } = require('./commands/claude'); const { agents } = require('./commands/agents'); const { report } = require('./commands/report'); const { release } = require('./commands/release'); +const { watch } = require('./commands/watch'); const { prompt, printAgentsSnippet, @@ -229,6 +230,7 @@ async function main() { if (command === 'submodule') return submodule(rest); if (command === 'cleanup') return cleanup(rest); if (command === 'release') return release(rest); + if (command === 'watch') return watch(rest); if (command === 'budget') return budgetModule.runBudgetCommand(rest); if (command === 'ci-init') return ciInitModule.runCiInitCommand(rest); if (command === 'speckit') return speckitModule.runSpeckitCommand(rest); diff --git a/src/cli/shared/sandbox.js b/src/cli/shared/sandbox.js index 40d47e55..319bf3af 100644 --- a/src/cli/shared/sandbox.js +++ b/src/cli/shared/sandbox.js @@ -21,6 +21,14 @@ const { } = require('../../git'); const sandboxModule = require('../../sandbox'); const doctorModule = require('../../doctor'); +const { prepareAgentWorktree } = require('../../scaffold/agent-worktree-prep'); + +function formatWorktreePrepOps(operations) { + if (!operations || operations.length === 0) return ''; + return operations + .map((op) => `[agent-branch-start] worktree-prep ${op.status} ${op.file}${op.note ? ' โ€” ' + op.note : ''}`) + .join('\n') + '\n'; +} const { run, runPackageAsset, @@ -284,6 +292,7 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) { } } + const prepOps = prepareAgentWorktree(blocked.repoRoot, selectedWorktreePath); return { metadata: { branch: selectedBranch, @@ -291,7 +300,8 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) { }, stdout: `[agent-branch-start] Created branch: ${selectedBranch}\n` + - `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`, + `[agent-branch-start] Worktree: ${selectedWorktreePath}\n` + + formatWorktreePrepOps(prepOps), stderr: addResult.stderr || '', }; } @@ -335,9 +345,13 @@ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) { return startProtectedBaseSandboxFallback(blocked, sandboxSuffix); } + const worktreePathResolved = metadata.worktreePath + ? path.resolve(metadata.worktreePath) + : ''; + const prepOps = prepareAgentWorktree(blocked.repoRoot, worktreePathResolved); return { metadata, - stdout: startResult.stdout || '', + stdout: (startResult.stdout || '') + formatWorktreePrepOps(prepOps), stderr: startResult.stderr || '', }; } diff --git a/src/cli/shared/scaffolding.js b/src/cli/shared/scaffolding.js index 9b0606a7..80a8ec36 100644 --- a/src/cli/shared/scaffolding.js +++ b/src/cli/shared/scaffolding.js @@ -36,6 +36,7 @@ const { writeLockState, ensureAgentsSnippet, ensureClaudeAgentsLink, + ensureMonorepoAppsSnippet, ensureManagedGitignore, ensureRepoVscodeSettings, configureHooks, @@ -162,6 +163,7 @@ function runInstallInternal(options) { if (!options.skipAgents) { operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) })); + operations.push(ensureMonorepoAppsSnippet(repoRoot, Boolean(options.dryRun))); operations.push(ensureClaudeAgentsLink(repoRoot, Boolean(options.dryRun))); } @@ -246,6 +248,7 @@ function runFixInternal(options) { if (!options.skipAgents) { operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) })); + operations.push(ensureMonorepoAppsSnippet(repoRoot, Boolean(options.dryRun))); operations.push(ensureClaudeAgentsLink(repoRoot, Boolean(options.dryRun))); } diff --git a/src/context.js b/src/context.js index 800b2625..abd890b4 100644 --- a/src/context.js +++ b/src/context.js @@ -280,6 +280,8 @@ const LOCK_FILE_RELATIVE = '.omx/state/agent-file-locks.json'; const AGENTS_BOTS_STATE_RELATIVE = '.omx/state/agents-bots.json'; const AGENTS_MARKER_START = ''; const AGENTS_MARKER_END = ''; +const MONOREPO_MARKER_START = ''; +const MONOREPO_MARKER_END = ''; const GITIGNORE_MARKER_START = '# multiagent-safety:START'; const GITIGNORE_MARKER_END = '# multiagent-safety:END'; const CODEX_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees'); @@ -977,6 +979,8 @@ module.exports = { AGENTS_BOTS_STATE_RELATIVE, AGENTS_MARKER_START, AGENTS_MARKER_END, + MONOREPO_MARKER_START, + MONOREPO_MARKER_END, GITIGNORE_MARKER_START, GITIGNORE_MARKER_END, CODEX_WORKTREE_RELATIVE_DIR, diff --git a/src/finish/index.js b/src/finish/index.js index d188b4d1..0b2530ea 100644 --- a/src/finish/index.js +++ b/src/finish/index.js @@ -29,6 +29,7 @@ const { parseSyncArgs, } = require('../cli/args'); const submoduleModule = require('../submodule'); +const { runPreflight, summarizePreflight } = require('./preflight'); /** * Options recognized by {@link autoCommitWorktreeForFinish} and the public @@ -458,6 +459,28 @@ function finish(rawArgs, defaults = {}) { continue; } + // Preflight: typecheck + lint touched workspace packages before opening + // a PR. Only enforced for PR-mode finishes; bypass with --skip-preflight. + if (options.mergeMode === 'pr' && !options.skipPreflight) { + const preflight = runPreflight(repoRoot, worktreePath, branch, baseBranch, { + verbose: !terse, + }); + console.log(`[${TOOL_NAME}] ${summarizePreflight(preflight)}`); + if (preflight.status === 'failed') { + for (const f of preflight.failures) { + console.error(`[${TOOL_NAME}] preflight failure: ${f.label} (exit ${f.status})`); + if (f.stderr && f.stderr.trim()) { + console.error(f.stderr.trim()); + } else if (f.stdout && f.stdout.trim()) { + console.error(f.stdout.trim()); + } + } + throw new Error( + `preflight failed for ${preflight.failures.length} script(s). Fix the failures or rerun with --skip-preflight to bypass.`, + ); + } + } + const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot, stdio: 'pipe', diff --git a/src/finish/preflight.js b/src/finish/preflight.js new file mode 100644 index 00000000..32b4a3c1 --- /dev/null +++ b/src/finish/preflight.js @@ -0,0 +1,177 @@ +'use strict'; + +// Pre-ship preflight: before `gx finish --via-pr` opens a PR, walk the diff +// against the base branch, find which `apps//` workspace packages were +// touched, and run their `typecheck` + `lint` scripts. If any fail, abort the +// PR creation โ€” keeps main green so the user's root-worktree dev server (the +// one they're "visualizing" against) never breaks. +// +// Bypass with `--skip-preflight`. Non-monorepo repos (no `apps//package.json`) +// are silently no-ops. + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const PREFLIGHT_SCRIPTS = ['typecheck', 'lint']; + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return null; + } +} + +function detectAppPackages(repoRoot) { + const appsRoot = path.join(repoRoot, 'apps'); + let stat; + try { + stat = fs.statSync(appsRoot); + } catch { + return []; + } + if (!stat.isDirectory()) return []; + let entries; + try { + entries = fs.readdirSync(appsRoot, { withFileTypes: true }); + } catch { + return []; + } + const packages = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + const pkgPath = path.join(appsRoot, e.name, 'package.json'); + const pkg = readJson(pkgPath); + if (!pkg) continue; + packages.push({ + dir: `apps/${e.name}`, + name: pkg.name || e.name, + scripts: pkg.scripts || {}, + }); + } + return packages; +} + +function detectTouchedDirs(workingDir, baseBranch, branch) { + const ref = baseBranch + ? `${baseBranch}...${branch || 'HEAD'}` + : (branch || 'HEAD'); + const diff = spawnSync('git', ['-C', workingDir, 'diff', '--name-only', ref], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15_000, + }); + if (diff.status !== 0) { + // Try a fallback range with the local base. + const fallback = spawnSync( + 'git', + ['-C', workingDir, 'diff', '--name-only', 'HEAD'], + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000 }, + ); + if (fallback.status !== 0) return null; + return (fallback.stdout || '').toString().split('\n').filter(Boolean); + } + return (diff.stdout || '').toString().split('\n').filter(Boolean); +} + +function pickPackageManager(repoRoot) { + // Prefer pnpm if a lockfile exists; fall back to npm. + if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) return 'pnpm'; + if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +function buildScriptInvocation(pm, pkgName, script) { + if (pm === 'pnpm') return { cmd: 'pnpm', args: ['--filter', pkgName, script] }; + if (pm === 'yarn') return { cmd: 'yarn', args: ['workspace', pkgName, 'run', script] }; + return { cmd: 'npm', args: ['--workspace', pkgName, 'run', script] }; +} + +function runPreflight(repoRoot, worktreePath, branch, baseBranch, options = {}) { + const workingDir = worktreePath || repoRoot; + const packages = detectAppPackages(repoRoot); + if (packages.length === 0) { + return { status: 'skipped', reason: 'no-monorepo', failures: [], ran: [] }; + } + + const touchedFiles = detectTouchedDirs(workingDir, baseBranch, branch); + if (touchedFiles === null) { + return { + status: 'skipped', + reason: 'diff-unavailable', + failures: [], + ran: [], + }; + } + + const touchedPkgs = packages.filter((pkg) => + touchedFiles.some((file) => file.startsWith(pkg.dir + '/')), + ); + if (touchedPkgs.length === 0) { + return { + status: 'skipped', + reason: 'no-app-changes', + failures: [], + ran: [], + }; + } + + const pm = pickPackageManager(repoRoot); + const ran = []; + const failures = []; + for (const pkg of touchedPkgs) { + for (const script of PREFLIGHT_SCRIPTS) { + if (!pkg.scripts[script]) continue; + const { cmd, args } = buildScriptInvocation(pm, pkg.name, script); + const label = `${pkg.name}:${script}`; + if (options.verbose) { + process.stdout.write(`[preflight] running ${label}โ€ฆ\n`); + } + const result = spawnSync(cmd, args, { + cwd: repoRoot, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5 * 60_000, + }); + const stdout = (result.stdout || '').toString(); + const stderr = (result.stderr || '').toString(); + const ok = !result.error && result.status === 0; + ran.push({ label, ok, status: result.status, cmd, args }); + if (!ok) { + failures.push({ + label, + status: result.status, + stdout: stdout.slice(-2000), + stderr: stderr.slice(-2000), + error: result.error ? result.error.message : null, + }); + } + } + } + + return { + status: failures.length === 0 ? 'ok' : 'failed', + reason: failures.length === 0 ? 'all-passed' : 'script-failures', + packageManager: pm, + touched: touchedPkgs.map((p) => p.name), + ran, + failures, + }; +} + +function summarizePreflight(result) { + if (result.status === 'skipped') { + return `[preflight] skipped (${result.reason})`; + } + const tail = result.ran + .map((r) => `${r.ok ? 'โœ“' : 'โœ—'} ${r.label}`) + .join(', '); + return `[preflight] ${result.status} โ€” ${tail}`; +} + +module.exports = { + detectAppPackages, + detectTouchedDirs, + pickPackageManager, + runPreflight, + summarizePreflight, +}; diff --git a/src/sandbox/index.js b/src/sandbox/index.js index 0e665aba..195df768 100644 --- a/src/sandbox/index.js +++ b/src/sandbox/index.js @@ -13,6 +13,14 @@ const { gitRefExists, ensureRepoBranch, } = require('../git'); +const { prepareAgentWorktree } = require('../scaffold/agent-worktree-prep'); + +function formatWorktreePrepOps(operations) { + if (!operations || operations.length === 0) return ''; + return operations + .map((op) => `[agent-branch-start] worktree-prep ${op.status} ${op.file}${op.note ? ' โ€” ' + op.note : ''}`) + .join('\n') + '\n'; +} function hasGuardexBootstrapFiles(repoRoot) { const required = [ @@ -195,6 +203,7 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) { } } + const prepOps = prepareAgentWorktree(blocked.repoRoot, selectedWorktreePath); return { metadata: { branch: selectedBranch, @@ -202,7 +211,8 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) { }, stdout: `[agent-branch-start] Created branch: ${selectedBranch}\n` + - `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`, + `[agent-branch-start] Worktree: ${selectedWorktreePath}\n` + + formatWorktreePrepOps(prepOps), stderr: addResult.stderr || '', }; } @@ -246,9 +256,10 @@ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) { return startProtectedBaseSandboxFallback(blocked, sandboxSuffix); } + const prepOps = prepareAgentWorktree(blocked.repoRoot, worktreePath); return { metadata, - stdout: startResult.stdout || '', + stdout: (startResult.stdout || '') + formatWorktreePrepOps(prepOps), stderr: startResult.stderr || '', }; } diff --git a/src/scaffold/agent-worktree-prep.js b/src/scaffold/agent-worktree-prep.js new file mode 100644 index 00000000..88f891fb --- /dev/null +++ b/src/scaffold/agent-worktree-prep.js @@ -0,0 +1,213 @@ +'use strict'; + +// Prepares a freshly-created agent worktree for monorepos that have `apps/*` +// packages. Two jobs: +// +// 1. Symlink the root's `apps//.env` (and friends) into the worktree +// so backend / storefront / etc. can boot with the same secrets without +// asking the user to copy gitignored env files manually. +// +// 2. Pick a free port per app and write it into the worktree's +// `apps//.env.local` (which both Vite and Medusa's loadEnv read with +// higher precedence than `.env`). This stops agent dev servers from +// colliding with whatever's running in the root worktree on the default +// port. +// +// Both jobs are best-effort: if `apps/` doesn't exist, or there are no env +// files / no package.json in a subfolder, we silently skip โ€” non-monorepo +// repos see no change. + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const ENV_FILE_CANDIDATES = [ + '.env', + '.env.local', + '.env.development', + '.env.development.local', + '.env.production', + '.env.production.local', +]; + +// Port pool by detected app role. Storefronts get the Vite/Next range, +// backends get the Medusa range, everything else gets a generic mid-range. +const PORT_POOLS = { + storefront: 5174, + backend: 9101, + default: 8100, +}; + +function detectAppPackages(repoRoot) { + const appsRoot = path.join(repoRoot, 'apps'); + let stat; + try { + stat = fs.statSync(appsRoot); + } catch { + return []; + } + if (!stat.isDirectory()) return []; + let entries; + try { + entries = fs.readdirSync(appsRoot, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => fs.existsSync(path.join(appsRoot, name, 'package.json'))); +} + +function inferAppRole(appName) { + const n = appName.toLowerCase(); + if (n.includes('storefront') || n.includes('frontend') || n.includes('web')) { + return 'storefront'; + } + if (n.includes('backend') || n.includes('api') || n.includes('server')) { + return 'backend'; + } + return 'default'; +} + +function isPortFree(port) { + // Use `lsof` if available โ€” it's on macOS and most Linux distros. Fall + // back to assuming free when lsof isn't installed (e.g. minimal Alpine + // CI image); the dev server will fail loudly if it isn't. + const probe = spawnSync('lsof', ['-iTCP:' + port, '-sTCP:LISTEN', '-t'], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 2000, + }); + if (probe.error) return true; + const out = (probe.stdout && probe.stdout.toString().trim()) || ''; + return out === ''; +} + +function pickFreePort(start) { + for (let p = start; p < start + 200; p++) { + if (isPortFree(p)) return p; + } + return null; +} + +function symlinkAppEnvFiles(repoRoot, worktreePath, appName) { + const operations = []; + const rootAppDir = path.join(repoRoot, 'apps', appName); + const wtAppDir = path.join(worktreePath, 'apps', appName); + if (!fs.existsSync(wtAppDir)) { + return operations; + } + for (const candidate of ENV_FILE_CANDIDATES) { + const rootEnv = path.join(rootAppDir, candidate); + const wtEnv = path.join(wtAppDir, candidate); + if (!fs.existsSync(rootEnv)) continue; + // Don't overwrite an existing file/symlink in the worktree. + let alreadyExists = false; + try { + fs.lstatSync(wtEnv); + alreadyExists = true; + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + if (alreadyExists) { + operations.push({ + status: 'unchanged', + file: `apps/${appName}/${candidate}`, + note: 'already present in worktree', + }); + continue; + } + try { + fs.symlinkSync(rootEnv, wtEnv); + operations.push({ + status: 'linked', + file: `apps/${appName}/${candidate}`, + note: `โ†’ ${path.relative(worktreePath, rootEnv)}`, + }); + } catch (err) { + operations.push({ + status: 'failed', + file: `apps/${appName}/${candidate}`, + note: `symlink failed: ${err.message}`, + }); + } + } + return operations; +} + +function assignAgentPort(repoRoot, worktreePath, appName, takenPorts) { + const wtAppDir = path.join(worktreePath, 'apps', appName); + if (!fs.existsSync(wtAppDir)) { + return { status: 'skipped', file: `apps/${appName}`, note: 'no app dir in worktree' }; + } + const role = inferAppRole(appName); + const base = PORT_POOLS[role] || PORT_POOLS.default; + let port = pickFreePort(base); + // Bump past anything we've already assigned this run. + while (port !== null && takenPorts.has(port)) { + port = pickFreePort(port + 1); + } + if (port === null) { + return { + status: 'failed', + file: `apps/${appName}/.env.local`, + note: 'no free port found in pool', + }; + } + takenPorts.add(port); + + const envLocalPath = path.join(wtAppDir, '.env.local'); + let existing = ''; + try { + existing = fs.readFileSync(envLocalPath, 'utf8'); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + + // If a .env.local already exists, replace the PORT= line if present, + // otherwise append. Keep everything else the user might have added. + const portLine = `PORT=${port}`; + let next; + if (/^PORT=/m.test(existing)) { + next = existing.replace(/^PORT=.*$/m, portLine); + } else { + const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n'; + next = `${existing}${sep}${portLine}\n`; + // Header on fresh files so the user knows what wrote this. + if (existing.length === 0) { + next = `# Written by gitguardex on worktree creation โ€” agent dev server port.\n${portLine}\n`; + } + } + fs.writeFileSync(envLocalPath, next, 'utf8'); + return { + status: 'wrote', + file: `apps/${appName}/.env.local`, + note: `PORT=${port} (${role} pool)`, + }; +} + +function prepareAgentWorktree(repoRoot, worktreePath) { + if (!repoRoot || !worktreePath) return []; + if (repoRoot === worktreePath) return []; + if (!fs.existsSync(worktreePath)) return []; + const apps = detectAppPackages(repoRoot); + if (apps.length === 0) return []; + + const operations = []; + const takenPorts = new Set(); + for (const appName of apps) { + operations.push(...symlinkAppEnvFiles(repoRoot, worktreePath, appName)); + operations.push(assignAgentPort(repoRoot, worktreePath, appName, takenPorts)); + } + return operations; +} + +module.exports = { + detectAppPackages, + inferAppRole, + isPortFree, + pickFreePort, + symlinkAppEnvFiles, + assignAgentPort, + prepareAgentWorktree, +}; diff --git a/src/scaffold/index.js b/src/scaffold/index.js index d5631551..d40a1b8f 100644 --- a/src/scaffold/index.js +++ b/src/scaffold/index.js @@ -14,6 +14,8 @@ const { USER_LEVEL_SKILL_ASSETS, AGENTS_MARKER_START, AGENTS_MARKER_END, + MONOREPO_MARKER_START, + MONOREPO_MARKER_END, GITIGNORE_MARKER_START, GITIGNORE_MARKER_END, SHARED_VSCODE_SETTINGS_RELATIVE, @@ -557,6 +559,77 @@ function ensureClaudeAgentsLink(repoRoot, dryRun) { return { status: dryRun ? 'would-create' : 'created', file: 'CLAUDE.md', note: 'symlink to AGENTS.md' }; } +function detectMonorepoApps(repoRoot) { + const appsDir = path.join(repoRoot, 'apps'); + let stat; + try { + stat = fs.statSync(appsDir); + } catch { + return false; + } + if (!stat.isDirectory()) return false; + let entries; + try { + entries = fs.readdirSync(appsDir, { withFileTypes: true }); + } catch { + return false; + } + return entries.some( + (entry) => + entry.isDirectory() && + fs.existsSync(path.join(appsDir, entry.name, 'package.json')), + ); +} + +function ensureMonorepoAppsSnippet(repoRoot, dryRun) { + const agentsPath = path.join(repoRoot, 'AGENTS.md'); + if (!detectMonorepoApps(repoRoot)) { + return { + status: 'skipped', + file: 'AGENTS.md', + note: 'no apps//package.json detected โ€” monorepo block not needed', + }; + } + const snippet = fs + .readFileSync(path.join(TEMPLATE_ROOT, 'AGENTS.monorepo-apps.md'), 'utf8') + .trimEnd(); + const managedRegex = new RegExp( + `${MONOREPO_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${MONOREPO_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + 'm', + ); + + // Ensure AGENTS.md exists first (created by ensureAgentsSnippet upstream). + if (!fs.existsSync(agentsPath)) { + if (!dryRun) { + fs.writeFileSync(agentsPath, `# AGENTS\n\n${snippet}\n`, 'utf8'); + } + return { status: 'created', file: 'AGENTS.md', note: 'monorepo-apps block' }; + } + + const existing = fs.readFileSync(agentsPath, 'utf8'); + if (managedRegex.test(existing)) { + const next = existing.replace(managedRegex, snippet); + if (next === existing) { + return { status: 'unchanged', file: 'AGENTS.md', note: 'monorepo-apps block' }; + } + if (!dryRun) { + fs.writeFileSync(agentsPath, next, 'utf8'); + } + return { + status: 'updated', + file: 'AGENTS.md', + note: 'refreshed monorepo-apps block', + }; + } + + const separator = existing.endsWith('\n') ? '\n' : '\n\n'; + if (!dryRun) { + fs.writeFileSync(agentsPath, `${existing}${separator}${snippet}\n`, 'utf8'); + } + + return { status: 'updated', file: 'AGENTS.md', note: 'appended monorepo-apps block' }; +} + function ensureManagedGitignore(repoRoot, dryRun) { const gitignorePath = path.join(repoRoot, '.gitignore'); const managedBlock = [ @@ -782,6 +855,8 @@ module.exports = { removeLegacyManagedRepoFile, ensureAgentsSnippet, ensureClaudeAgentsLink, + ensureMonorepoAppsSnippet, + detectMonorepoApps, ensureManagedGitignore, parseJsonObjectLikeFile, buildRepoVscodeSettings, diff --git a/templates/AGENTS.monorepo-apps.md b/templates/AGENTS.monorepo-apps.md new file mode 100644 index 00000000..ad870b87 --- /dev/null +++ b/templates/AGENTS.monorepo-apps.md @@ -0,0 +1,26 @@ + +## Monorepo workflow (`apps/*`) + +This repo has `apps/*` (storefront, backend, etc.). The **root worktree is kept on the protected base branch** so the user can keep `pnpm :dev` running there and see merged-to-main state in real time. Never edit or commit on the root worktree. + +### Per-task loop + +1. **Start in a sibling worktree.** Run `gx pivot` (auto) or `gx branch start --type --task ` โ€” both spawn a worktree under `.omx/agent-worktrees/` on a fresh `agent/*` branch. +2. **Run scoped dev servers from your worktree**, e.g. `pnpm --filter storefront dev` from `.omx/agent-worktrees//`. Pick a non-conflicting port if the user is already running the root. +3. **Commit + push** as you go โ€” the agent branch tracks `origin/agent/*`. The user can watch your branch live in their git client. +4. **Ship via PR.** When the user approves the work, run `gx ship` (alias for `gx finish --via-pr --wait-for-merge --cleanup`). This: opens a PR โ†’ auto-merges to the protected base โ†’ prunes the worktree + branch. +5. The user's root worktree is now showing the merged result on next pull. + +### Cross-app guardrails + +- Edits to **both** `apps/storefront` AND `apps/backend` in one branch โ†’ split into two PRs unless they must land atomically. Reviews stay clean, rollbacks stay surgical. +- Edits to root configs (`pnpm-workspace.yaml`, `turbo.json`, `package.json`) lock every other agent. Claim โ†’ change โ†’ release fast. +- Migrations under `apps/backend/src/migrations/*` require explicit user OK before commit โ€” they're irreversible on prod. +- Don't `pnpm install` from the root unless the user asks; do it inside your worktree if you added a dep. + +### What the user sees + +- `git log --all --graph --oneline` shows every active agent branch in real time. +- `gx status` lists active worktrees + their branches. +- Each `gx ship` produces a PR โ€” link goes in the user's GitHub notifications. +