diff --git a/web/components/sf/Login.tsx b/web/components/sf/Login.tsx index bb7a4977d..aed3fb6ce 100644 --- a/web/components/sf/Login.tsx +++ b/web/components/sf/Login.tsx @@ -23,28 +23,31 @@ export default function Login() { }; return ( -
-

Sign in to SF

-
+
+ +
+

Sign in to SF

+

+ Enter the local web password for this server. +

+
setPassword(e.target.value)} - style={{ width: "100%", padding: 8, marginBottom: 16 }} + className="h-10 w-full rounded border border-input bg-background px-3 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" /> - - {error &&
{error}
} + {error &&
{error}
}
); diff --git a/web/components/sf/app-shell.tsx b/web/components/sf/app-shell.tsx index 272da4a71..1892cc646 100644 --- a/web/components/sf/app-shell.tsx +++ b/web/components/sf/app-shell.tsx @@ -37,7 +37,7 @@ import { UpdateBanner } from "@/components/sf/update-banner"; import { VisualizerView } from "@/components/sf/visualizer-view"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; -import { getAuthToken } from "@/lib/auth"; +import { authFetch, getAuthToken } from "@/lib/auth"; import { DevOverridesProvider } from "@/lib/dev-overrides"; import { ProjectStoreManagerProvider, @@ -722,6 +722,26 @@ function ProjectAwareWorkspace() { ); const activeStore = activeProjectCwd ? manager.getActiveStore() : null; + useEffect(() => { + if (activeProjectCwd) return; + let cancelled = false; + authFetch("/api/boot", { cache: "no-store" }) + .then(async (response) => { + if (!response.ok) return null; + return (await response.json()) as { project?: { cwd?: string } | null }; + }) + .then((boot) => { + const cwd = boot?.project?.cwd; + if (!cancelled && cwd) manager.switchProject(cwd); + }) + .catch(() => { + // No configured project: leave the project picker visible. + }); + return () => { + cancelled = true; + }; + }, [activeProjectCwd, manager]); + // Shut down all projects when the tab actually closes. // IMPORTANT: pagehide fires both on real page unload AND on mobile/Safari // tab switches (bfcache entry). When event.persisted is true the page is diff --git a/web/components/sf/projects-view.tsx b/web/components/sf/projects-view.tsx index 2471ec5a8..4fe9e768d 100644 --- a/web/components/sf/projects-view.tsx +++ b/web/components/sf/projects-view.tsx @@ -1348,7 +1348,7 @@ export function ProjectSelectionGate() { {/* Project rows — table-like, dense */}
{filteredProjects.map((project) => { - const style = KIND_STYLE[project.kind]; + const style = KIND_STYLE[project.kind] ?? KIND_STYLE.blank; const KindIcon = style.icon; const stack = techStack(project.signals); const progress = project.progress diff --git a/web/lib/auth.ts b/web/lib/auth.ts index 8ec9319f3..2334f2648 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -25,8 +25,12 @@ const AUTH_STORAGE_KEY = "sf-auth-token"; let cachedToken: string | null = null; /** - * Extract the auth token from the URL fragment, localStorage, or prompt for login. - * If not found, redirect to /login page. + * Extract the auth token from the URL fragment or localStorage. + * + * Purpose: let launched web sessions use the bearer token fragment while still + * allowing direct dev/server mode when the server has not configured + * SF_WEB_AUTH_TOKEN. Missing tokens are handled by authFetch after the server + * proves it is protected with a 401. */ export function getAuthToken(): string | null { if (cachedToken !== null) return cachedToken; @@ -60,10 +64,6 @@ export function getAuthToken(): string | null { } } catch {} - // 3. If not found, redirect to login - if (window.location.pathname !== "/login") { - window.location.href = "/login"; - } return null; } @@ -98,10 +98,9 @@ export function authHeaders( /** * Wrapper around `fetch()` that automatically injects the auth token. * - * When no token is available (missing `#token=` fragment and no localStorage - * entry), returns a synthetic 401 Response instead of making an unauthenticated - * request that will fail server-side anyway. This lets callers handle the - * missing-token case uniformly rather than silently cascading 401s. + * When no token is available, the request is sent unauthenticated. The server + * allows that in direct dev/server mode when SF_WEB_AUTH_TOKEN is unset. If the + * server returns 401, redirect to the login page. */ export async function authFetch( input: RequestInfo | URL, @@ -109,10 +108,15 @@ export async function authFetch( ): Promise { const token = getAuthToken(); if (!token) { - return new Response(JSON.stringify({ error: "No auth token available" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); + const response = await fetch(input, init); + if ( + response.status === 401 && + typeof window !== "undefined" && + window.location.pathname !== "/login" + ) { + window.location.href = "/login"; + } + return response; } const headers = new Headers(init?.headers); @@ -120,7 +124,15 @@ export async function authFetch( headers.set("Authorization", `Bearer ${token}`); } - return fetch(input, { ...init, headers }); + const response = await fetch(input, { ...init, headers }); + if ( + response.status === 401 && + typeof window !== "undefined" && + window.location.pathname !== "/login" + ) { + window.location.href = "/login"; + } + return response; } /** diff --git a/web/pages/api/projects.ts b/web/pages/api/projects.ts index 2e18a5060..aaf674162 100644 --- a/web/pages/api/projects.ts +++ b/web/pages/api/projects.ts @@ -1,25 +1,155 @@ -import { readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; import type { NextApiRequest, NextApiResponse } from "next"; -// Returns a list of subfolders in the dev root that contain a .sf directory -export default function handler(req: NextApiRequest, res: NextApiResponse) { - const devRoot = req.query.devRoot as string; - if (!devRoot) return res.status(400).json({ error: "Missing devRoot" }); - let projects: string[] = []; +type ProjectMetadata = { + name: string; + path: string; + kind: "active-sf" | "empty-sf" | "brownfield" | "blank"; + signals: { + hasSfFolder: boolean; + hasPlanningFolder: boolean; + hasGitRepo: boolean; + hasPackageJson: boolean; + fileCount: number; + hasMilestones?: boolean; + hasCargo?: boolean; + hasGoMod?: boolean; + hasPyproject?: boolean; + isMonorepo: boolean; + }; + lastModified: number; + progress?: { + activeMilestone: string | null; + activeSlice: string | null; + phase: string | null; + milestonesCompleted: number; + milestonesTotal: number; + } | null; +}; + +const EXCLUDED_DIRS = new Set(["node_modules", ".git"]); + +function detectProject(path: string): ProjectMetadata["signals"] { + const hasGitRepo = existsSync(join(path, ".git")); + const hasSfFolder = existsSync(join(path, ".sf")); + const hasPlanningFolder = existsSync(join(path, "planning")); + const hasPackageJson = existsSync(join(path, "package.json")); + const hasCargo = existsSync(join(path, "Cargo.toml")); + const hasGoMod = existsSync(join(path, "go.mod")); + const hasPyproject = existsSync(join(path, "pyproject.toml")); + const hasMilestones = existsSync(join(path, ".sf", "milestones")); + const fileCount = readdirSync(path).filter( + (entry) => entry !== ".git", + ).length; + const isMonorepo = + existsSync(join(path, "pnpm-workspace.yaml")) || + existsSync(join(path, "lerna.json")) || + existsSync(join(path, "package.json")); + return { + hasSfFolder, + hasPlanningFolder, + hasGitRepo, + hasPackageJson, + fileCount, + hasMilestones, + hasCargo, + hasGoMod, + hasPyproject, + isMonorepo, + }; +} + +function readProgress(path: string): ProjectMetadata["progress"] { try { - const entries = readdirSync(devRoot, { withFileTypes: true }); - projects = entries - .filter((entry) => entry.isDirectory()) - .filter((entry) => { - try { - return statSync(join(devRoot, entry.name, ".sf")).isDirectory(); - } catch { - return false; - } - }) - .map((entry) => entry.name); - res.status(200).json({ projects }); + const content = readFileSync(join(path, ".sf", "STATE.md"), "utf8"); + let activeMilestone: string | null = null; + let activeSlice: string | null = null; + let phase: string | null = null; + let milestonesCompleted = 0; + let milestonesTotal = 0; + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("**Active Milestone:**")) { + activeMilestone = + trimmed.replace("**Active Milestone:**", "").trim() || null; + } else if (trimmed.startsWith("**Active Slice:**")) { + activeSlice = trimmed.replace("**Active Slice:**", "").trim() || null; + } else if (trimmed.startsWith("**Phase:**")) { + phase = trimmed.replace("**Phase:**", "").trim() || null; + } else if (trimmed.startsWith("- \u2705")) { + milestonesCompleted++; + milestonesTotal++; + } else if (trimmed.startsWith("- \u{1f504}")) { + milestonesTotal++; + } + } + return { + activeMilestone, + activeSlice, + phase, + milestonesCompleted, + milestonesTotal, + }; + } catch { + return null; + } +} + +function projectMetadata( + path: string, + includeProgress: boolean, +): ProjectMetadata { + const stat = statSync(path); + const signals = detectProject(path); + const kind = signals.hasSfFolder + ? signals.hasMilestones + ? "active-sf" + : "empty-sf" + : signals.hasGitRepo || signals.hasPackageJson + ? "brownfield" + : "blank"; + return { + name: basename(path), + path, + kind, + signals, + lastModified: stat.mtimeMs, + ...(includeProgress ? { progress: readProgress(path) } : {}), + }; +} + +function discoverProjects(root: string, includeProgress: boolean) { + const rootSignals = detectProject(root); + if (rootSignals.hasSfFolder || rootSignals.isMonorepo) { + return [projectMetadata(root, includeProgress)]; + } + + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => !entry.name.startsWith(".")) + .filter((entry) => !EXCLUDED_DIRS.has(entry.name)) + .map((entry) => join(root, entry.name)) + .filter((path) => { + const signals = detectProject(path); + return ( + signals.hasSfFolder || signals.hasGitRepo || signals.hasPackageJson + ); + }) + .map((path) => projectMetadata(path, includeProgress)) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +// Returns discovered projects under a configured development root. +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const rootParam = req.query.root ?? req.query.devRoot; + const devRoot = Array.isArray(rootParam) ? rootParam[0] : rootParam; + if (!devRoot) return res.status(400).json({ error: "Missing devRoot" }); + + try { + const includeProgress = req.query.detail === "true"; + res.status(200).json(discoverProjects(devRoot, includeProgress)); } catch (e) { res.status(500).json({ error: (e as Error).message }); }