UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

359 lines 13.1 kB
/** * Persistent identity and on-disk state model for the dashboard's recent-projects list. * * Resolves a stable identity for each checkout (git remote hash, then a gitignored `.goat-flow` * marker, then the absolute path) so the same project is recognised after it moves on disk, and * hydrates/normalises the JSON state file into a deduplicated, deterministically ordered shape. * Reads and writes the local marker file and shells out to `git config` with a short timeout; all * filesystem and git failures are swallowed into path-based fallbacks so a read-only or non-git * project still loads. Consumed by dashboard-project-routes.ts. */ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { resolveLocalStatePath } from "./local-paths.js"; /** Hash cache and identity inputs without storing raw remote URLs in keys. */ function hashString(value) { return createHash("sha256").update(value).digest("hex"); } const PROJECT_MARKER_COMMENT = "# Local goat-flow dashboard project identity. Gitignored by default."; /** Accept only persisted identity-source values understood by this dashboard build. */ function identitySourceFrom(value) { return value === "git-remote" || value === "goat-marker" || value === "path" ? value : null; } /** Preserve first-seen path order while removing duplicate project paths. */ function dedupeStrings(values) { const result = []; for (const value of values) { if (value && !result.includes(value)) result.push(value); } return result; } /** Resolve a project path to its realpath, with fallback when realpath lookup fails. */ function normalizeProjectPath(projectPath) { const resolved = resolve(projectPath); try { return realpathSync(resolved); } catch { return resolved; } } /** Probe optional project directories; swallows permission and removal races. */ function directoryExists(path) { try { return statSync(path).isDirectory(); } catch { return false; } } /** Canonicalise a git remote host/path pair into the identity hash input. */ function cleanRemotePath(host, path) { const remotePath = path?.replace(/^\/+/u, ""); if (!host || !remotePath) return null; return `${host.toLowerCase()}/${remotePath}` .replace(/\.git$/u, "") .replace(/\/+$/u, ""); } /** Normalise `git@host:owner/repo` remotes before URL parsing gets a chance. */ function normalizeScpLikeRemote(trimmed) { const scpLike = trimmed.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/u); if (!scpLike || trimmed.includes("://")) return null; return cleanRemotePath(scpLike[1], scpLike[2]); } /** Normalise URL-style git remotes; swallows invalid URL inputs as `null`. */ function normalizeUrlRemote(trimmed) { try { const parsed = new URL(trimmed); return cleanRemotePath(parsed.hostname, parsed.pathname); } catch { return null; } } /** Build the stable remote identity string used before hashing project records. */ function normalizeGitRemoteUrl(raw) { const trimmed = raw.trim(); if (!trimmed) return null; return (normalizeScpLikeRemote(trimmed) ?? normalizeUrlRemote(trimmed) ?? trimmed.replace(/\.git$/u, "").replace(/\/+$/u, "")); } /** Spawns `git config` with a short timeout; swallows failures into marker/path fallback. */ function readGitRemote(projectPath) { try { const output = execFileSync("git", ["-C", projectPath, "config", "--get", "remote.origin.url"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000, }); return typeof output === "string" ? output.trim() : String(output).trim(); } catch { return null; } } /** Read the first non-comment project marker line; swallows missing marker files. */ function readProjectMarkerIdentifier(markerPath) { try { const raw = readFileSync(markerPath, "utf-8"); for (const line of raw.split(/\r?\n/u)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; return trimmed; } } catch { /* missing or unreadable marker */ } return null; } /** Writes a gitignored project marker; swallows read-only projects as `null`. */ function writeProjectMarkerIdentifier(markerPath) { try { const markerIdentifier = `gf_${randomUUID()}`; writeFileSync(markerPath, `${PROJECT_MARKER_COMMENT}\n${markerIdentifier}\n`, { encoding: "utf-8", }); return markerIdentifier; } catch { return null; } } function resolveGitRemoteIdentity(currentPath) { const normalizedRemote = normalizeGitRemoteUrl(readGitRemote(currentPath) ?? ""); if (!normalizedRemote) return null; const remoteUrlHash = hashString(normalizedRemote); return { identity: `git-remote:${remoteUrlHash}`, identitySource: "git-remote", currentPath, remoteUrlHash, }; } function resolveMarkerIdentity(currentPath, allowMarkerWrite) { const goatFlowDir = join(currentPath, ".goat-flow"); if (!directoryExists(goatFlowDir)) return null; let markerPath = null; try { markerPath = resolveLocalStatePath(currentPath, "project-id"); } catch (err) { if (allowMarkerWrite) throw err; } const markerIdentifier = markerPath === null ? null : (readProjectMarkerIdentifier(markerPath) ?? (allowMarkerWrite ? writeProjectMarkerIdentifier(markerPath) : null)); if (!markerIdentifier) return null; return { identity: `goat-marker:${markerIdentifier}`, identitySource: "goat-marker", currentPath, markerId: markerIdentifier, }; } export function resolveProjectIdentity(projectPath, options = {}) { const currentPath = normalizeProjectPath(projectPath); return (resolveGitRemoteIdentity(currentPath) ?? resolveMarkerIdentity(currentPath, options.allowMarkerWrite === true) ?? { identity: `path:${currentPath}`, identitySource: "path", currentPath, }); } /** Read one optional string array property from a parsed dashboard state file. */ function readOptionalStringArrayProperty(value, key) { const raw = value[key]; if (raw === undefined) return []; if (!Array.isArray(raw)) return null; const items = []; for (const item of raw) { if (typeof item !== "string") return null; items.push(item); } return items; } /** Read an optional `{ [path]: title }` map from parsed dashboard state. * Invalid entries are dropped rather than failing the whole load so one bad * title can't wipe the user's `paths` / `favorites`. */ function readOptionalStringMapProperty(value, key) { const raw = value[key]; if (raw === undefined) return {}; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const result = {}; for (const [k, v] of Object.entries(raw)) { if (typeof v === "string" && v.length > 0) result[k] = v; } return result; } /** Normalise legacy project-record paths before merging them into identity records. */ function normalizeProjectRecordPaths(record) { return Array.isArray(record.paths) ? record.paths .filter((entry) => typeof entry === "string") .map((entry) => normalizeProjectPath(entry)) : []; } function readRecordString(record, key) { const value = record[key]; return typeof value === "string" && value.length > 0 ? value : null; } function applyOptionalProjectRecordFields(normalized, record) { const remoteUrlHash = readRecordString(record, "remoteUrlHash"); const markerId = readRecordString(record, "markerId"); const title = readRecordString(record, "title")?.trim(); if (remoteUrlHash) normalized.remoteUrlHash = remoteUrlHash; if (markerId) normalized.markerId = markerId; if (title) normalized.title = title.slice(0, 120); } function normalizeDashboardProjectRecord(identity, value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const record = value; const identityValue = readRecordString(record, "identity") ?? identity; const identitySource = identitySourceFrom(record.identitySource); const currentPath = readRecordString(record, "currentPath"); if (!identityValue || !identitySource || !currentPath) return null; const normalized = { identity: identityValue, identitySource, currentPath: normalizeProjectPath(currentPath), paths: dedupeStrings([ normalizeProjectPath(currentPath), ...normalizeProjectRecordPaths(record), ]), }; applyOptionalProjectRecordFields(normalized, record); return normalized; } function readOptionalProjectRecordsProperty(value) { const raw = value.projects; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const records = {}; for (const [identity, record] of Object.entries(raw)) { const normalized = normalizeDashboardProjectRecord(identity, record); if (normalized) records[normalized.identity] = normalized; } return records; } function addProjectRecord(records, next) { const existing = records.get(next.identity); if (!existing) { records.set(next.identity, { ...next, paths: dedupeStrings(next.paths), }); return; } records.set(next.identity, { ...existing, currentPath: next.currentPath, paths: dedupeStrings([...existing.paths, ...next.paths]), title: next.title ?? existing.title, remoteUrlHash: next.remoteUrlHash ?? existing.remoteUrlHash, markerId: next.markerId ?? existing.markerId, }); } export function hydrateDashboardState(state, options) { const records = new Map(); for (const record of Object.values(state.projects)) { addProjectRecord(records, record); } for (const path of state.paths) { const identity = resolveProjectIdentity(path, { allowMarkerWrite: options.allowMarkerWrite, }); const title = state.projectTitles[identity.identity] ?? state.projectTitles[path]; addProjectRecord(records, { ...identity, paths: [identity.currentPath], ...(title ? { title } : {}), }); } const projectTitles = {}; for (const record of records.values()) { const title = record.title ?? state.projectTitles[record.identity] ?? state.projectTitles[record.currentPath]; if (title) { record.title = title; projectTitles[record.identity] = title; } } const projects = Object.fromEntries([...records.entries()].sort(([a], [b]) => a.localeCompare(b))); const paths = dedupeStrings(Object.values(projects).flatMap((record) => record.paths)); return { paths, favorites: dedupeStrings(state.favorites), projectTitles, projects, }; } /** Normalize parsed dashboard state JSON into the server's expected shape. */ function normalizeDashboardState(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const record = value; const paths = readOptionalStringArrayProperty(record, "paths"); if (paths === null) return null; const favorites = readOptionalStringArrayProperty(record, "favorites"); if (favorites === null) return null; const projectTitles = readOptionalStringMapProperty(record, "projectTitles"); return hydrateDashboardState({ paths, favorites, projectTitles, projects: readOptionalProjectRecordsProperty(record), }, { allowMarkerWrite: false }); } /** * Read dashboard state from the new file first, then the legacy projects-only file. * * Swallows malformed or missing state files so the dashboard can recover to empty state. */ export async function loadDashboardState(dashboardStateFile, legacyProjectsListFile) { const { readFile } = await import("node:fs/promises"); for (const filePath of [dashboardStateFile, legacyProjectsListFile]) { try { const parsed = normalizeDashboardState(JSON.parse(await readFile(filePath, "utf-8"))); if (parsed) return parsed; } catch { /* try next location */ } } return { paths: [], favorites: [], projectTitles: {}, projects: {} }; } //# sourceMappingURL=dashboard-project-state.js.map