156 lines
4.5 KiB
TypeScript
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 });
|
|
}
|
|
}
|