fix: use shared sf webserver project config
This commit is contained in:
parent
c26de39afa
commit
6d8fc62243
10 changed files with 349 additions and 58 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue