diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 117df23e..106cbc74 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -3,7 +3,14 @@ import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { describe, expect, it } from "vitest"; -import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js"; +import { + copyGitHooksToWorktreeGitDir, + copySeededSecretsKey, + rebindWorkspaceCwd, + resolveGitWorktreeAddArgs, + resolveWorktreeMakeTargetPath, + worktreeMakeCommand, +} from "../commands/worktree.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, @@ -78,6 +85,36 @@ describe("worktree helpers", () => { expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree"); }); + it("resolves worktree:make target paths under the user home directory", () => { + expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe( + path.resolve(os.homedir(), "paperclip-pr-432"), + ); + }); + + it("rejects worktree:make names that are not safe directory/branch names", () => { + expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow( + "Worktree name must contain only letters, numbers, dots, underscores, or dashes.", + ); + }); + + it("builds git worktree add args for new and existing branches", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "feature-branch", + targetPath: "/tmp/feature-branch", + branchExists: false, + }), + ).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]); + + expect( + resolveGitWorktreeAddArgs({ + branchName: "feature-branch", + targetPath: "/tmp/feature-branch", + branchExists: true, + }), + ).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]); + }); + it("rewrites loopback auth URLs to the new port only", () => { expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/"); expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example"); @@ -249,4 +286,44 @@ describe("worktree helpers", () => { fs.rmSync(tempRoot, { recursive: true, force: true }); } }); + + it("creates and initializes a worktree from the top-level worktree:make command", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-")); + const repoRoot = path.join(tempRoot, "repo"); + const fakeHome = path.join(tempRoot, "home"); + const worktreePath = path.join(fakeHome, "paperclip-make-test"); + const originalCwd = process.cwd(); + const originalHome = process.env.HOME; + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(fakeHome, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + + process.env.HOME = fakeHome; + process.chdir(repoRoot); + + await worktreeMakeCommand("paperclip-make-test", { + seed: false, + home: path.join(tempRoot, ".paperclip-worktrees"), + }); + + expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true); + expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true); + expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true); + } finally { + process.chdir(originalCwd); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index e2fa8da8..2ef42abf 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -62,6 +62,8 @@ type WorktreeInitOptions = { force?: boolean; }; +type WorktreeMakeOptions = WorktreeInitOptions; + type WorktreeEnvOptions = { config?: string; json?: boolean; @@ -115,6 +117,62 @@ function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function resolveWorktreeMakeName(name: string): string { + const value = nonEmpty(name); + if (!value) { + throw new Error("Worktree name is required."); + } + if (!/^[A-Za-z0-9._-]+$/.test(value)) { + throw new Error( + "Worktree name must contain only letters, numbers, dots, underscores, or dashes.", + ); + } + return value; +} + +export function resolveWorktreeMakeTargetPath(name: string): string { + return path.resolve(os.homedir(), resolveWorktreeMakeName(name)); +} + +function extractExecSyncErrorMessage(error: unknown): string | null { + if (!error || typeof error !== "object") { + return error instanceof Error ? error.message : null; + } + + const stderr = "stderr" in error ? error.stderr : null; + if (typeof stderr === "string") { + return nonEmpty(stderr); + } + if (stderr instanceof Buffer) { + return nonEmpty(stderr.toString("utf8")); + } + + return error instanceof Error ? nonEmpty(error.message) : null; +} + +function localBranchExists(cwd: string, branchName: string): boolean { + try { + execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { + cwd, + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +export function resolveGitWorktreeAddArgs(input: { + branchName: string; + targetPath: string; + branchExists: boolean; +}): string[] { + if (input.branchExists) { + return ["worktree", "add", input.targetPath, input.branchName]; + } + return ["worktree", "add", "-b", input.branchName, input.targetPath, "HEAD"]; +} + function readPidFilePort(postmasterPidFile: string): number | null { if (!existsSync(postmasterPidFile)) return null; try { @@ -538,10 +596,7 @@ async function seedWorktreeDatabase(input: { } } -export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { - printPaperclipCliBanner(); - p.intro(pc.bgCyan(pc.black(" paperclipai worktree init "))); - +async function runWorktreeInit(opts: WorktreeInitOptions): Promise { const cwd = process.cwd(); const name = resolveSuggestedWorktreeName( cwd, @@ -642,6 +697,57 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree init "))); + await runWorktreeInit(opts); +} + +export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make "))); + + const name = resolveWorktreeMakeName(nameArg); + const sourceCwd = process.cwd(); + const targetPath = resolveWorktreeMakeTargetPath(name); + if (existsSync(targetPath)) { + throw new Error(`Target path already exists: ${targetPath}`); + } + + mkdirSync(path.dirname(targetPath), { recursive: true }); + const worktreeArgs = resolveGitWorktreeAddArgs({ + branchName: name, + targetPath, + branchExists: localBranchExists(sourceCwd, name), + }); + + const spinner = p.spinner(); + spinner.start(`Creating git worktree at ${targetPath}...`); + try { + execFileSync("git", worktreeArgs, { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + spinner.stop(`Created git worktree at ${targetPath}.`); + } catch (error) { + spinner.stop(pc.red("Failed to create git worktree.")); + throw new Error(extractExecSyncErrorMessage(error) ?? String(error)); + } + + const originalCwd = process.cwd(); + try { + process.chdir(targetPath); + await runWorktreeInit({ + ...opts, + name, + }); + } catch (error) { + throw error; + } finally { + process.chdir(originalCwd); + } +} + export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise { const configPath = resolveConfigPath(opts.config); const envPath = resolvePaperclipEnvFile(configPath); @@ -665,6 +771,22 @@ export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise", "Worktree directory and branch name (created at ~/NAME)") + .option("--instance ", "Explicit isolated instance id") + .option("--home ", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`) + .option("--from-config ", "Source config.json to seed from") + .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") + .option("--from-instance ", "Source instance id when deriving the source config", "default") + .option("--server-port ", "Preferred server port", (value) => Number(value)) + .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) + .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option("--no-seed", "Skip database seeding from the source instance") + .option("--force", "Replace existing repo-local config and isolated instance data", false) + .action(worktreeMakeCommand); + worktree .command("init") .description("Create repo-local config/env and an isolated instance for this worktree") diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 2d9ae9a0..b1adb579 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -132,6 +132,8 @@ Instead, create a repo-local Paperclip config plus an isolated instance for the ```sh paperclipai worktree init +# or create the git worktree and initialize it in one step: +pnpm paperclipai worktree:make paperclip-pr-432 ``` This command: diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index be47e3c5..b268ebf3 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -23,6 +23,7 @@ const logFile = path.join(logDir, "server.log"); const sharedOpts = { translateTime: "HH:MM:ss", ignore: "pid,hostname", + singleLine: true, }; export const logger = pino({ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8af35bca..d47af808 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -28,6 +28,7 @@ import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { InviteLandingPage } from "./pages/InviteLanding"; +import { NotFoundPage } from "./pages/NotFound"; import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; @@ -141,6 +142,7 @@ function boardRoutes() { } /> } /> } /> + } /> ); } @@ -240,6 +242,7 @@ export function App() { }> {boardRoutes()} + } /> diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 9c272b64..3a58e614 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Sun } from "lucide-react"; import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; @@ -24,13 +24,20 @@ import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; +import { NotFoundPage } from "../pages/NotFound"; import { Button } from "@/components/ui/button"; export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); const { togglePanelVisible } = usePanel(); - const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany(); + const { + companies, + loading: companiesLoading, + selectedCompany, + selectedCompanyId, + setSelectedCompanyId, + } = useCompany(); const { theme, toggleTheme } = useTheme(); const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); @@ -39,6 +46,13 @@ export function Layout() { const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); const nextTheme = theme === "dark" ? "light" : "dark"; + const matchedCompany = useMemo(() => { + if (!companyPrefix) return null; + const requestedPrefix = companyPrefix.toUpperCase(); + return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null; + }, [companies, companyPrefix]); + const hasUnknownCompanyPrefix = + Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany; const { data: health } = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), @@ -57,30 +71,30 @@ export function Layout() { useEffect(() => { if (!companyPrefix || companiesLoading || companies.length === 0) return; - const requestedPrefix = companyPrefix.toUpperCase(); - const matched = companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix); - - if (!matched) { - const fallback = - (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null) - ?? companies[0]!; - navigate(`/${fallback.issuePrefix}/dashboard`, { replace: true }); + if (!matchedCompany) { + const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null) + ?? companies[0] + ?? null; + if (fallback && selectedCompanyId !== fallback.id) { + setSelectedCompanyId(fallback.id, { source: "route_sync" }); + } return; } - if (companyPrefix !== matched.issuePrefix) { + if (companyPrefix !== matchedCompany.issuePrefix) { const suffix = location.pathname.replace(/^\/[^/]+/, ""); - navigate(`/${matched.issuePrefix}${suffix}${location.search}`, { replace: true }); + navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true }); return; } - if (selectedCompanyId !== matched.id) { - setSelectedCompanyId(matched.id, { source: "route_sync" }); + if (selectedCompanyId !== matchedCompany.id) { + setSelectedCompanyId(matchedCompany.id, { source: "route_sync" }); } }, [ companyPrefix, companies, companiesLoading, + matchedCompany, location.pathname, location.search, navigate, @@ -282,7 +296,14 @@ export function Layout() { className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")} onScroll={handleMainScroll} > - + {hasUnknownCompanyPrefix ? ( + + ) : ( + + )} diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 990f30ca..cc77e0f0 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -539,12 +539,6 @@ export function Inbox() { const hasJoinRequests = joinRequests.length > 0; const hasTouchedIssues = touchedIssues.length > 0; - const newItemCount = - failedRuns.length + - staleIssues.length + - (showAggregateAgentError ? 1 : 0) + - (showBudgetAlert ? 1 : 0); - const showJoinRequestsCategory = allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; const showTouchedCategory = @@ -595,16 +589,7 @@ export function Inbox() { items={[ { value: "new", - label: ( - <> - New - {newItemCount > 0 && ( - - {newItemCount} - - )} - - ), + label: "New", }, { value: "all", label: "All" }, ]} diff --git a/ui/src/pages/NotFound.tsx b/ui/src/pages/NotFound.tsx new file mode 100644 index 00000000..bcaf3898 --- /dev/null +++ b/ui/src/pages/NotFound.tsx @@ -0,0 +1,66 @@ +import { useEffect } from "react"; +import { Link, useLocation } from "@/lib/router"; +import { AlertTriangle, Compass } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; + +type NotFoundScope = "board" | "invalid_company_prefix" | "global"; + +interface NotFoundPageProps { + scope?: NotFoundScope; + requestedPrefix?: string; +} + +export function NotFoundPage({ scope = "global", requestedPrefix }: NotFoundPageProps) { + const location = useLocation(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { companies, selectedCompany } = useCompany(); + + useEffect(() => { + setBreadcrumbs([{ label: "Not Found" }]); + }, [setBreadcrumbs]); + + const fallbackCompany = selectedCompany ?? companies[0] ?? null; + const dashboardHref = fallbackCompany ? `/${fallbackCompany.issuePrefix}/dashboard` : "/"; + const currentPath = `${location.pathname}${location.search}${location.hash}`; + const normalizedPrefix = requestedPrefix?.toUpperCase(); + + const title = scope === "invalid_company_prefix" ? "Company not found" : "Page not found"; + const description = + scope === "invalid_company_prefix" + ? `No company matches prefix "${normalizedPrefix ?? "unknown"}".` + : "This route does not exist."; + + return ( +
+
+
+
+ +
+
+

{title}

+

{description}

+
+
+ +
+ Requested path: {currentPath} +
+ +
+ + +
+
+
+ ); +}