fix: use shared sf webserver project config
Some checks are pending
sf self-deploy / build, test, and publish server image (push) Waiting to run
sf self-deploy / deploy test and probe (push) Blocked by required conditions
sf self-deploy / promote prod (push) Blocked by required conditions

This commit is contained in:
Mikael Hugo 2026-05-17 22:09:28 +02:00
parent c26de39afa
commit 6d8fc62243
10 changed files with 349 additions and 58 deletions

View file

@ -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

View file

@ -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`.

View file

@ -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

View file

@ -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

View file

@ -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, {

View file

@ -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<string>();
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;
}

View file

@ -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<Response> {
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);

View file

@ -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. */

View file

@ -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<ProjectDetectionKind, number> = {
@ -506,6 +515,26 @@ export function ProjectsPanel({
</div>
</button>
<button
type="button"
onClick={() => setAddRepoOpen(true)}
className={cn(
"flex w-full items-center gap-3.5 rounded-xl border border-dashed px-4 py-3.5 text-left transition-all duration-200",
"border-border/50 text-muted-foreground hover:border-foreground/15 hover:text-foreground",
"active:scale-[0.98]",
)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-foreground/[0.04]">
<FolderOpen className="h-4 w-4" />
</div>
<div>
<span className="text-sm font-medium">Add repo path</span>
<p className="mt-0.5 text-[11px] text-muted-foreground">
Pin an existing repo by full path
</p>
</div>
</button>
{/* New project dialog */}
<NewProjectDialog
open={newProjectOpen}
@ -580,11 +609,150 @@ export function ProjectsPanel({
onSelect={(path) => void handleDevRootSaved(path)}
initialPath={devRoot}
/>
{devRoot && (
<AddRepoPathDialog
open={addRepoOpen}
onOpenChange={setAddRepoOpen}
devRoot={devRoot}
onAdded={handleRepoPathAdded}
/>
)}
</SheetContent>
</Sheet>
);
}
// ─── 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<string | null>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Repo Path</DialogTitle>
<DialogDescription>
Add an existing SF repo by absolute path.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault();
void handleAdd();
}}
className="space-y-3 py-2"
>
<div className="space-y-2">
<Label htmlFor="repo-path">Repo path</Label>
<Input
ref={inputRef}
id="repo-path"
value={repoPath}
onChange={(event) => {
setRepoPath(event.target.value);
setError(null);
}}
placeholder="/home/mhugo/code/dr-repo"
autoComplete="off"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
</form>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => void handleAdd()}
disabled={!canSubmit}
className="gap-1.5"
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FolderOpen className="h-3.5 w-3.5" />
)}
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── Active project inline summary (compact for panel card) ────────────
function _ActiveProjectSummary({

View file

@ -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<string>();
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.