singularity-forge/web/pages/api/projects.ts
2026-05-17 16:25:47 +02:00

156 lines
4.5 KiB
TypeScript

import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import type { NextApiRequest, NextApiResponse } from "next";
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 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 });
}
}