From a519e67eac90aa59a957cfe3708868d0488e4195 Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Tue, 11 Aug 2026 15:58:14 +0000 Subject: [PATCH] Initial project creation panel --- .env.example | 2 + .eslintrc.json | 3 + .gitignore | 9 + README.md | 33 + app/api/projects/route.ts | 83 + app/globals.css | 40 + app/layout.tsx | 20 + app/page.tsx | 55 + components.json | 20 + components/project-creator.tsx | 130 + components/ui/button.tsx | 42 + components/ui/input.tsx | 20 + components/ui/label.tsx | 14 + lib/projects.ts | 29 + lib/utils.ts | 6 + next-env.d.ts | 6 + next.config.ts | 7 + package-lock.json | 6545 ++++++++++++++++++++++++++++++++ package.json | 32 + postcss.config.mjs | 9 + tailwind.config.ts | 45 + tsconfig.json | 23 + 22 files changed, 7173 insertions(+) create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/api/projects/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components.json create mode 100644 components/project-creator.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 lib/projects.ts create mode 100644 lib/utils.ts create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..226cc01 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Projects are created below this directory. Defaults to ~/projects. +PROJECTS_ROOT=/home/your-user/projects diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38ae0f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +.next +out +.env* +!.env.example +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/README.md b/README.md new file mode 100644 index 0000000..487e467 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Forge + +A small local panel for creating project directories with Git initialized. + +## Requirements + +- Node.js 20+ +- Git available on `PATH` + +## Setup + +```bash +npm install +cp .env.example .env.local +``` + +Set `PROJECTS_ROOT` in `.env.local` to the directory that should contain your projects. If it is omitted, Forge uses `~/projects`. + +Start the development server: + +```bash +npm run dev +``` + +Open `http://localhost:3000`. + +## Verification + +```bash +npm run build +``` + +The API only accepts project names and never accepts an arbitrary filesystem path. It rejects names that could escape the configured root or collide with an existing directory. diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..0b2b221 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,83 @@ +import { mkdir, rm } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { NextResponse } from "next/server"; + +import { + getProjectPath, + getProjectsRoot, + projectNameSchema, +} from "@/lib/projects"; + +export const runtime = "nodejs"; + +const execFileAsync = promisify(execFile); + +export async function POST(request: Request) { + let body: unknown; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "The request body must be valid JSON." }, { status: 400 }); + } + + const result = projectNameSchema.safeParse( + typeof body === "object" && body !== null && "name" in body + ? (body as { name: unknown }).name + : undefined, + ); + + if (!result.success) { + return NextResponse.json( + { error: result.error.issues[0]?.message || "Enter a valid project name." }, + { status: 400 }, + ); + } + + const name = result.data; + const projectPath = getProjectPath(name); + let directoryCreated = false; + + try { + await mkdir(getProjectsRoot(), { recursive: true }); + await mkdir(projectPath); + directoryCreated = true; + + await execFileAsync("git", ["init"], { cwd: projectPath }); + + return NextResponse.json( + { name, path: projectPath, gitInitialized: true }, + { status: 201 }, + ); + } catch (error) { + if (directoryCreated) { + await rm(projectPath, { recursive: true, force: true }).catch(() => undefined); + } + + const code = error && typeof error === "object" && "code" in error + ? (error as { code?: string }).code + : undefined; + + if (code === "EEXIST") { + return NextResponse.json( + { error: "A project with that name already exists." }, + { status: 409 }, + ); + } + + if (code === "ENOENT") { + return NextResponse.json( + { error: "Git was not found. Install Git and try again." }, + { status: 503 }, + ); + } + + console.error("Project creation failed", error); + return NextResponse.json( + { error: "The project could not be created. Check the server logs for details." }, + { status: 500 }, + ); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..ee3377f --- /dev/null +++ b/app/globals.css @@ -0,0 +1,40 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --font-geist-sans: Arial, Helvetica, sans-serif; + --font-geist-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --background: 36 33% 96%; + --foreground: 225 24% 14%; + --card: 40 33% 99%; + --card-foreground: 225 24% 14%; + --muted: 36 20% 91%; + --muted-foreground: 225 10% 43%; + --primary: 225 24% 14%; + --primary-foreground: 36 33% 96%; + --border: 35 18% 84%; + --input: 35 18% 78%; + --ring: 14 72% 53%; + --radius: 0.65rem; +} + +* { + border-color: hsl(var(--border)); +} + +body { + min-height: 100vh; + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} + +::selection { + background: hsl(14 72% 53% / 0.22); +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..25e1b67 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +import "./globals.css"; + +export const metadata: Metadata = { + title: "Forge | Project workspace", + description: "Create a local project workspace with Git initialized.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..46737a9 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,55 @@ +import { FolderKanban } from "lucide-react"; + +import { ProjectCreator } from "@/components/project-creator"; +import { getProjectsRoot } from "@/lib/projects"; + +export default function Home() { + return ( +
+
+
+ +
+
+
+
+ +
+ + FORGE + +
+ + Local workspace utility + +
+ +
+
+

+ Project / 001 +

+

+ Start with a clean slate. +

+

+ Create a local project workspace and initialize its Git history in one deliberate step. +

+ +
+ + Ready to create +
+
+ + +
+ +
+ Git initialization enabled + v0.1 / Local only +
+
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..43eec4c --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/components/project-creator.tsx b/components/project-creator.tsx new file mode 100644 index 0000000..070dfb8 --- /dev/null +++ b/components/project-creator.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Check, ChevronRight, GitBranch, Loader2, Terminal } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +type CreationResult = { + name: string; + path: string; + gitInitialized: boolean; +}; + +export function ProjectCreator({ projectsRoot }: { projectsRoot: string }) { + const [name, setName] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(""); + const [createdProject, setCreatedProject] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setIsCreating(true); + setError(""); + setCreatedProject(null); + + try { + const response = await fetch("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "The project could not be created."); + return; + } + + setCreatedProject(data); + setName(""); + } catch { + setError("Could not reach the local server. Try again."); + } finally { + setIsCreating(false); + } + } + + return ( +
+
+
+
+ +
+

+ New project +

+

+ A directory, plus a Git repository. +

+
+ 01 / 01 +
+ +
+
+ + setName(event.target.value)} + placeholder="e.g. morning-star" + autoComplete="off" + autoFocus + disabled={isCreating} + aria-describedby="project-name-help" + /> +

+ Letters, numbers, spaces, hyphens, and underscores. +

+
+ +
+
+ + Destination +
+

+ {projectsRoot}/{name.trim() || "project-name"} +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {createdProject ? ( +
+
+ + Project created and Git initialized. +
+

+ {createdProject.path} +

+
+ ) : null} + + +
+
+ ); +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..f4d1021 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", + outline: "border border-input bg-background hover:bg-muted", + }, + size: { + default: "h-10 px-4 py-2", + lg: "h-12 px-6 text-base", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( +