From 6d8fc62243ff2d4f5bce140a114bfcf944ce21cb Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 17 May 2026 22:09:28 +0200 Subject: [PATCH] fix: use shared sf webserver project config --- AGENTS.md | 3 +- CLAUDE.md | 3 +- docker/docker-compose.vega.yaml | 7 +- docs/specs/sf-self-deploy.md | 23 ++-- scripts/run-vega-source-server.mjs | 65 ++++------- src/web/project-discovery-service.ts | 44 +++++++ web/app/api/preferences/route.ts | 7 ++ web/app/api/switch-root/route.ts | 1 + web/components/sf/projects-view.tsx | 168 +++++++++++++++++++++++++++ web/pages/api/projects.ts | 86 +++++++++++++- 10 files changed, 349 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81ddbdc0e..36ca4706b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,8 @@ Forgejo builds a source-pinned server image, writes `dist/sf-release-manifest.json`, rolls test first, probes `/api/healthz`, `/api/ready`, and `/api/version`, then promotes the same image to prod. On vega, use `npm run docker:vega:up` for the containerized source-mounted -server on port 4000. +shared webserver on port 4000. It mounts SF at `/opt/sf`, the initial repo at +`/workspace`, and the repo parent at `/workspaces`. ## Coding Style & Naming Conventions diff --git a/CLAUDE.md b/CLAUDE.md index 64e4a1a0f..d143b2219 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,4 +135,5 @@ Forgejo builds the source-pinned server image, generates `dist/sf-release-manifest.json`, rolls test, probes `/api/healthz`, `/api/ready`, and `/api/version`, then promotes the same image to prod. On vega, use `npm run docker:vega:up` to run the source-mounted container on -port 4000. +port 4000. It is one shared webserver with SF mounted at `/opt/sf`, the initial +repo at `/workspace`, and the repo parent at `/workspaces`. diff --git a/docker/docker-compose.vega.yaml b/docker/docker-compose.vega.yaml index 745c8b3d0..87d8747ef 100644 --- a/docker/docker-compose.vega.yaml +++ b/docker/docker-compose.vega.yaml @@ -3,7 +3,7 @@ services: build: context: .. dockerfile: docker/Dockerfile.source-server - container_name: sf-server-vega + container_name: ${SF_VEGA_CONTAINER:-sf-server-vega} working_dir: /opt/sf user: "${PUID:-1000}:${PGID:-1000}" ports: @@ -11,6 +11,8 @@ services: volumes: - ../:/opt/sf - ${SF_WORKSPACE_DIR:-..}:/workspace + - ${SF_WORKSPACES_DIR:-..}:/workspaces + - ${SF_WORKSPACES_DIR:-/home/mhugo/code}:${SF_WORKSPACES_DIR:-/home/mhugo/code} - ${HOME}/.sf:/home/node/.sf - ${HOME}/.gitconfig:/home/node/.gitconfig:ro environment: @@ -19,7 +21,8 @@ services: SF_SOURCE_ROOT: /opt/sf SF_RUNTIME_SOURCE_ROOT: /opt/sf SF_RELEASE_MANIFEST: /opt/sf/dist/sf-release-manifest.json - SF_WEB_PROJECT_CWD: /workspace + SF_WEB_PROJECT_CWD: ${SF_WORKSPACE_DIR:-/home/mhugo/code/singularity-forge} + SF_WORKSPACES_DIR: ${SF_WORKSPACES_DIR:-/home/mhugo/code} SF_WEB_HOST: 0.0.0.0 SF_WEB_PORT: "4000" HOSTNAME: 0.0.0.0 diff --git a/docs/specs/sf-self-deploy.md b/docs/specs/sf-self-deploy.md index 47e35f19a..330472719 100644 --- a/docs/specs/sf-self-deploy.md +++ b/docs/specs/sf-self-deploy.md @@ -53,14 +53,17 @@ being containerised: npm run docker:vega:up ``` -That profile mounts this SF checkout at `/opt/sf`, mounts the controlled repo at -`/workspace`, persists `~/.sf`, and binds port 4000 to -`${SF_VEGA_BIND:-127.0.0.1}`. `SF_WORKSPACE_DIR` selects which repo the server -controls; it defaults to this checkout for dogfooding, but the same server code -can be reused with any repo: +That profile runs one shared SF webserver. It mounts this SF checkout at +`/opt/sf`, mounts the initial controlled repo at `/workspace`, mounts the repo +parent at `/workspaces`, also mounts the repo parent at its real host path +(`/home/mhugo/code` on vega), persists `~/.sf`, and binds port 4000 to +`${SF_VEGA_BIND:-127.0.0.1}`. `SF_WORKSPACE_DIR` selects the initial repo; it +defaults to this checkout for dogfooding. `SF_WORKSPACES_DIR` selects the parent +directory available for repo switching and defaults to the parent of this SF +checkout: ```bash -SF_WORKSPACE_DIR=/home/mhugo/code/other-repo npm run docker:vega:up +SF_WORKSPACE_DIR=/home/mhugo/code/other-repo SF_WORKSPACES_DIR=/home/mhugo/code npm run docker:vega:up ``` Set `SF_VEGA_BIND` to the vega Tailscale address when the server should be @@ -70,11 +73,9 @@ owns access control. On hosts without the Docker Compose plugin, `npm run docker:vega:up` uses `scripts/run-vega-source-server.mjs` to build `docker/Dockerfile.source-server` and run the equivalent `docker run` command directly. This is one SF server -implementation and image, with one running server process per workspace repo. -Each workspace gets a stable container name derived from the workspace path. A -restart replaces only that workspace's container; other repo servers keep -running. If `SF_VEGA_PORT` is not set, the runner picks the first free port in -`4000-4099`, so several repos can run at the same time. +implementation, one shared webserver process, and repo-scoped worker/session +state underneath it. Restarting the runner replaces the shared vega webserver, +not one container per repo. ## Promotion diff --git a/scripts/run-vega-source-server.mjs b/scripts/run-vega-source-server.mjs index b66fd9eb8..f3db94b2a 100644 --- a/scripts/run-vega-source-server.mjs +++ b/scripts/run-vega-source-server.mjs @@ -9,17 +9,17 @@ * Consumer: `npm run docker:vega:up` on vega. */ import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { createServer } from "node:net"; import { homedir } from "node:os"; -import { basename, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(fileURLToPath(new URL("..", import.meta.url))); const image = process.env.SF_VEGA_IMAGE || "sf-source-server:vega"; const bind = process.env.SF_VEGA_BIND || "127.0.0.1"; const workspace = resolve(process.env.SF_WORKSPACE_DIR || root); -const name = process.env.SF_VEGA_CONTAINER || defaultContainerName(workspace); +const workspacesRoot = resolve(process.env.SF_WORKSPACES_DIR || dirname(root)); +const name = process.env.SF_VEGA_CONTAINER || "sf-server-vega"; +const port = process.env.SF_VEGA_PORT || "4000"; const uid = process.env.PUID || String(process.getuid?.() ?? 1000); const gid = process.env.PGID || String(process.getgid?.() ?? 1000); const command = process.argv[2] ?? "up"; @@ -33,19 +33,27 @@ if (command === "--help" || command === "-h" || command === "help") { node scripts/run-vega-source-server.mjs down Environment: - SF_WORKSPACE_DIR repo mounted at /workspace, defaults to this checkout + SF_WORKSPACE_DIR initial repo mounted at /workspace, defaults to this checkout + SF_WORKSPACES_DIR repo parent mounted at /workspaces, defaults to ../ SF_VEGA_BIND host bind address, defaults to 127.0.0.1 - SF_VEGA_PORT fixed host port; unset auto-picks 4000-4099 + SF_VEGA_PORT shared webserver port, defaults to 4000 SF_VEGA_CONTAINER explicit container name `); process.exit(0); } if (command === "print") { - const port = await resolvePort(); process.stdout.write( JSON.stringify( - { name, image, bind, port, workspace, sfSource: root }, + { + name, + image, + bind, + port, + workspace, + workspacesRoot, + sfSource: root, + }, null, 2, ) + "\n", @@ -68,7 +76,6 @@ if (command !== "up") { process.exit(2); } -const port = await resolvePort(); const allowedOrigins = process.env.SF_WEB_ALLOWED_ORIGINS || `http://127.0.0.1:${port},http://localhost:${port}`; @@ -106,7 +113,9 @@ run("docker", [ "-e", "SF_RELEASE_MANIFEST=/opt/sf/dist/sf-release-manifest.json", "-e", - "SF_WEB_PROJECT_CWD=/workspace", + `SF_WEB_PROJECT_CWD=${workspace}`, + "-e", + `SF_WORKSPACES_DIR=${workspacesRoot}`, "-e", "HOSTNAME=0.0.0.0", "-e", @@ -124,6 +133,10 @@ run("docker", [ "-v", `${workspace}:/workspace`, "-v", + `${workspacesRoot}:/workspaces`, + "-v", + `${workspacesRoot}:${workspacesRoot}`, + "-v", `${homedir()}/.sf:/home/node/.sf`, "-v", `${homedir()}/.gitconfig:/home/node/.gitconfig:ro`, @@ -134,36 +147,8 @@ run("docker", [ process.stdout.write(`${name} listening on ${bind}:${port}\n`); process.stdout.write(`SF source: ${root}\n`); -process.stdout.write(`Workspace: ${workspace}\n`); - -async function resolvePort() { - if (process.env.SF_VEGA_PORT) return process.env.SF_VEGA_PORT; - for (let candidate = 4000; candidate <= 4099; candidate++) { - if (await isPortAvailable(candidate)) return String(candidate); - } - process.stderr.write("No free SF vega port found in 4000-4099\n"); - process.exit(1); -} - -function isPortAvailable(port) { - return new Promise((resolveAvailable) => { - const server = createServer(); - server.once("error", () => resolveAvailable(false)); - server.once("listening", () => { - server.close(() => resolveAvailable(true)); - }); - server.listen(port, bind); - }); -} - -function defaultContainerName(path) { - const slug = basename(path) - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - const hash = createHash("sha1").update(path).digest("hex").slice(0, 8); - return `sf-server-${slug || "workspace"}-${hash}`; -} +process.stdout.write(`Initial workspace: ${workspace}\n`); +process.stdout.write(`Workspace parent: ${workspacesRoot}\n`); function run(command, args) { const result = spawnSync(command, args, { diff --git a/src/web/project-discovery-service.ts b/src/web/project-discovery-service.ts index a720800f7..7530c02ce 100644 --- a/src/web/project-discovery-service.ts +++ b/src/web/project-discovery-service.ts @@ -27,6 +27,7 @@ export interface ProjectMetadata { /** Excluded directory names when scanning a dev root. */ const EXCLUDED_DIRS = new Set(["node_modules", ".git"]); +const MAX_NESTED_SF_DEPTH = 3; /** * Parse a project's `.sf/STATE.md` for active milestone, slice, phase, @@ -122,6 +123,7 @@ export function discoverProjects( // ── Standard multi-project scan ───────────────────────────────── const entries = readdirSync(devRootPath, { withFileTypes: true }); const projects: ProjectMetadata[] = []; + const seen = new Set(); for (const entry of entries) { if (!entry.isDirectory()) continue; @@ -140,6 +142,24 @@ export function discoverProjects( lastModified: stat.mtimeMs, ...(includeProgress ? { progress: readProjectProgress(fullPath) } : {}), }); + seen.add(fullPath); + } + + for (const nestedSfProject of findNestedSfProjects(devRootPath)) { + if (seen.has(nestedSfProject)) continue; + const { kind, signals } = detectProjectKind(nestedSfProject); + const stat = statSync(nestedSfProject); + projects.push({ + name: nestedSfProject.slice(devRootPath.length + 1), + path: nestedSfProject, + kind, + signals, + lastModified: stat.mtimeMs, + ...(includeProgress + ? { progress: readProjectProgress(nestedSfProject) } + : {}), + }); + seen.add(nestedSfProject); } projects.sort((a, b) => a.name.localeCompare(b.name)); @@ -149,3 +169,27 @@ export function discoverProjects( return []; } } + +function findNestedSfProjects(devRootPath: string): string[] { + const projects: string[] = []; + const walk = (dir: string, depth: number) => { + if (depth > MAX_NESTED_SF_DEPTH) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".")) continue; + if (EXCLUDED_DIRS.has(entry.name)) continue; + const fullPath = join(dir, entry.name); + const { signals } = detectProjectKind(fullPath); + if (signals.hasSfFolder) projects.push(fullPath); + walk(fullPath, depth + 1); + } + }; + walk(devRootPath, 1); + return projects; +} diff --git a/web/app/api/preferences/route.ts b/web/app/api/preferences/route.ts index e7d626d65..2d668fbf4 100644 --- a/web/app/api/preferences/route.ts +++ b/web/app/api/preferences/route.ts @@ -9,6 +9,7 @@ export const dynamic = "force-dynamic"; interface WebPreferences { devRoot?: string; lastActiveProject?: string; + projectPaths?: string[]; } // ─── GET: read current preferences ───────────────────────────────────────── @@ -51,6 +52,12 @@ export async function PUT(request: Request): Promise { if (typeof body.lastActiveProject === "string") { prefs.lastActiveProject = body.lastActiveProject; } + if (Array.isArray(body.projectPaths)) { + prefs.projectPaths = body.projectPaths + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + } // Ensure parent directory exists const dir = dirname(webPreferencesPath); diff --git a/web/app/api/switch-root/route.ts b/web/app/api/switch-root/route.ts index e0dca4f32..5350e95ba 100644 --- a/web/app/api/switch-root/route.ts +++ b/web/app/api/switch-root/route.ts @@ -17,6 +17,7 @@ export const dynamic = "force-dynamic"; interface WebPreferences { devRoot?: string; lastActiveProject?: string; + projectPaths?: string[]; } /** Expand leading `~/` to the user's home directory. */ diff --git a/web/components/sf/projects-view.tsx b/web/components/sf/projects-view.tsx index d61692b64..cf3cbe6b6 100644 --- a/web/components/sf/projects-view.tsx +++ b/web/components/sf/projects-view.tsx @@ -392,6 +392,7 @@ export function ProjectsPanel({ const [newProjectOpen, setNewProjectOpen] = useState(false); const [changeRootOpen, setChangeRootOpen] = useState(false); + const [addRepoOpen, setAddRepoOpen] = useState(false); const _workspaceState = useSFWorkspaceState(); const handleProjectCreated = useCallback( @@ -418,6 +419,14 @@ export function ProjectsPanel({ manager.switchProject(project.path); } + const handleRepoPathAdded = useCallback( + (projectsAfterAdd: ProjectMetadata[]) => { + setProjects(projectsAfterAdd); + setAddRepoOpen(false); + }, + [], + ); + // Sort: active-sf first, then by name const sortedProjects = [...projects].sort((a, b) => { const kindOrder: Record = { @@ -506,6 +515,26 @@ export function ProjectsPanel({ + + {/* New project dialog */} void handleDevRootSaved(path)} initialPath={devRoot} /> + {devRoot && ( + + )} ); } +// ─── Add Existing Repo Dialog ─────────────────────────────────────────── + +function AddRepoPathDialog({ + open, + onOpenChange, + devRoot, + onAdded, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + devRoot: string; + onAdded: (projects: ProjectMetadata[]) => void; +}) { + const [repoPath, setRepoPath] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!open) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset dialog-local form state when opened + setRepoPath(""); + setError(null); + setSaving(false); + const timer = setTimeout(() => inputRef.current?.focus(), 100); + return () => clearTimeout(timer); + }, [open]); + + const canSubmit = repoPath.trim().length > 0 && !saving; + + async function handleAdd() { + if (!canSubmit) return; + setSaving(true); + setError(null); + try { + const prefsRes = await authFetch("/api/preferences"); + if (!prefsRes.ok) + throw new Error(`Failed to load preferences: ${prefsRes.status}`); + const prefs = await prefsRes.json(); + const currentPaths = Array.isArray(prefs.projectPaths) + ? prefs.projectPaths.filter( + (value: unknown): value is string => typeof value === "string", + ) + : []; + const trimmed = repoPath.trim(); + const projectPaths = Array.from(new Set([...currentPaths, trimmed])); + const saveRes = await authFetch("/api/preferences", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectPaths }), + }); + if (!saveRes.ok) { + const body = await saveRes.json().catch(() => ({})); + throw new Error( + (body as { error?: string }).error ?? + `Failed to save repo path: ${saveRes.status}`, + ); + } + const projectsRes = await authFetch( + `/api/projects?root=${encodeURIComponent(devRoot)}&detail=true`, + ); + if (!projectsRes.ok) + throw new Error(`Failed to refresh projects: ${projectsRes.status}`); + onAdded((await projectsRes.json()) as ProjectMetadata[]); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add repo path"); + } finally { + setSaving(false); + } + } + + return ( + + + + Add Repo Path + + Add an existing SF repo by absolute path. + + +
{ + event.preventDefault(); + void handleAdd(); + }} + className="space-y-3 py-2" + > +
+ + { + setRepoPath(event.target.value); + setError(null); + }} + placeholder="/home/mhugo/code/dr-repo" + autoComplete="off" + /> + {error &&

{error}

} +
+
+ + + + +
+
+ ); +} + // ─── Active project inline summary (compact for panel card) ──────────── function _ActiveProjectSummary({ diff --git a/web/pages/api/projects.ts b/web/pages/api/projects.ts index aaf674162..f5829d4c8 100644 --- a/web/pages/api/projects.ts +++ b/web/pages/api/projects.ts @@ -1,5 +1,6 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { basename, join } from "node:path"; +import { homedir } from "node:os"; +import { basename, join, resolve } from "node:path"; import type { NextApiRequest, NextApiResponse } from "next"; type ProjectMetadata = { @@ -28,7 +29,16 @@ type ProjectMetadata = { } | null; }; +type WebPreferences = { + projectPaths?: string[]; +}; + const EXCLUDED_DIRS = new Set(["node_modules", ".git"]); +const MAX_NESTED_SF_DEPTH = 3; +const webPreferencesPath = join( + process.env.SF_HOME || join(homedir(), ".sf"), + "web-preferences.json", +); function detectProject(path: string): ProjectMetadata["signals"] { const hasGitRepo = existsSync(join(path, ".git")); @@ -121,12 +131,28 @@ function projectMetadata( } function discoverProjects(root: string, includeProgress: boolean) { + const explicitProjects = readExplicitProjectPaths(); + if (explicitProjects.length > 0) { + return explicitProjects + .filter((path) => existsSync(path)) + .filter((path) => { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } + }) + .map((path) => projectMetadata(path, includeProgress)) + .sort((a, b) => a.name.localeCompare(b.name)); + } + const rootSignals = detectProject(root); if (rootSignals.hasSfFolder || rootSignals.isMonorepo) { return [projectMetadata(root, includeProgress)]; } - return readdirSync(root, { withFileTypes: true }) + const seen = new Set(); + const projects = readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .filter((entry) => !entry.name.startsWith(".")) .filter((entry) => !EXCLUDED_DIRS.has(entry.name)) @@ -138,7 +164,61 @@ function discoverProjects(root: string, includeProgress: boolean) { ); }) .map((path) => projectMetadata(path, includeProgress)) - .sort((a, b) => a.name.localeCompare(b.name)); + .map((project) => { + seen.add(project.path); + return project; + }); + + for (const nestedSfProject of findNestedSfProjects(root)) { + if (seen.has(nestedSfProject)) continue; + const project = projectMetadata(nestedSfProject, includeProgress); + projects.push({ + ...project, + name: nestedSfProject.slice(root.length + 1), + }); + seen.add(nestedSfProject); + } + + return projects.sort((a, b) => a.name.localeCompare(b.name)); +} + +function readExplicitProjectPaths(): string[] { + try { + const prefs = JSON.parse( + readFileSync(webPreferencesPath, "utf8"), + ) as WebPreferences; + if (!Array.isArray(prefs.projectPaths)) return []; + return prefs.projectPaths + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => resolve(value)); + } catch { + return []; + } +} + +function findNestedSfProjects(root: string): string[] { + const projects: string[] = []; + const walk = (dir: string, depth: number) => { + if (depth > MAX_NESTED_SF_DEPTH) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".")) continue; + if (EXCLUDED_DIRS.has(entry.name)) continue; + const fullPath = join(dir, entry.name); + if (detectProject(fullPath).hasSfFolder) projects.push(fullPath); + walk(fullPath, depth + 1); + } + }; + walk(root, 1); + return projects; } // Returns discovered projects under a configured development root.