UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

587 lines (586 loc) • 29.6 kB
import * as fs from "fs"; import * as crypto from "crypto"; import * as path from "path"; import * as yaml from "js-yaml"; import { expandHomePath, atomicWriteText, runtimeDir } from "./phren-paths.js"; import { withFileLock } from "./governance/locks.js"; import { isRecord, loadYamlDocument, PhrenError } from "./phren-core.js"; import { getProjectDirs } from "./shared.js"; // ── Constants ──────────────────────────────────────────────────────────────── /** * The synced registry at the store root. It holds the primary store's entry * only, so every machine on this personal store agrees on its id. Team and * readonly stores used to live here too, which pushed one machine's `team * join` onto every other machine and left doctor failing forever there. */ export const STORES_FILENAME = "stores.yaml"; /** * The stores attached on this machine, under `.runtime/` (never synced). * Using the personal store must never connect a machine to a team store. */ export const ATTACHED_STORES_FILENAME = "attached-stores.yaml"; const TEAM_BOOTSTRAP_FILENAME = ".phren-team.yaml"; const VALID_ROLES = new Set(["primary", "team", "readonly"]); const VALID_SYNC_MODES = new Set(["managed-git", "pull-only"]); // ── Path helpers ───────────────────────────────────────────────────────────── export function storesFilePath(phrenPath) { return path.join(phrenPath, STORES_FILENAME); } export function attachedStoresFilePath(phrenPath) { return path.join(runtimeDir(phrenPath), ATTACHED_STORES_FILENAME); } // ── ID generation ──────────────────────────────────────────────────────────── export function generateStoreId() { return crypto.randomUUID().replace(/-/g, "").slice(0, 8); } /** * Deterministic ID from a filesystem path — used for PHREN_FEDERATION_PATHS * backward-compat entries so the same path always produces the same ID. */ function deterministicIdFromPath(storePath) { return crypto.createHash("sha256").update(storePath).digest("hex").slice(0, 8); } export function readStoreRegistryDetailed(phrenPath, options = {}) { const synced = readSyncedRegistry(phrenPath); const attached = readAttachedStores(phrenPath, synced.registry, options.migrateAttachments !== false); const problems = [...synced.problems, ...attached.problems]; const lossy = synced.lossy || attached.lossy; if (!synced.registry && attached.stores.length === 0) return { registry: null, problems, lossy }; const primary = synced.registry?.stores.find((s) => s.role === "primary") ?? implicitPrimaryStore(phrenPath); const registry = { version: 1, stores: [primary, ...attached.stores] }; const err = validateRegistry(registry); if (err) { problems.push(`${ATTACHED_STORES_FILENAME} failed validation: ${err}`); return { registry: synced.registry ? { version: 1, stores: [primary] } : null, problems, lossy: true }; } return { registry, problems, lossy }; } /** The synced stores.yaml as written, including entries this machine ignores. */ function readSyncedRegistry(phrenPath) { const parsed = readYamlFile(storesFilePath(phrenPath), STORES_FILENAME); if (!parsed.exists || parsed.problems.length > 0) { return { registry: null, problems: parsed.problems, lossy: parsed.exists }; } const problems = []; const { registry, skippedEntries } = normalizeRegistry(parsed.value, problems, STORES_FILENAME); if (!registry) { return { registry: null, problems, lossy: true }; } // Validate on read too — reject malformed registries before they reach hooks/sync const err = validateRegistry(registry); if (err) { problems.push(`${STORES_FILENAME} failed validation: ${err}`); return { registry: null, problems, lossy: true }; } return { registry, problems, lossy: skippedEntries > 0 }; } /** Identity carried by the store itself, never an attachment ID or path hash. * Reading identity must not migrate this machine's attachments. */ export function registeredStoreIdentity(phrenPath) { const result = readSyncedRegistry(phrenPath); const id = result.lossy ? undefined : result.registry?.stores.find(s => s.role === "primary")?.id; return id && /^[a-f0-9]{8}$/.test(id) ? id : undefined; } /** Explicit, idempotent registration for legacy stores. Callers enforce the * existing manage_config permission. No task or attachment is rewritten. */ export function registerStoreIdentity(phrenPath) { return withFileLock(registryLockPath(phrenPath), () => { const result = readSyncedRegistry(phrenPath); if (result.lossy) throw new Error(`Cannot register store identity: ${result.problems.join(" | ")}`); const existing = result.registry?.stores.find(s => s.role === "primary"); if (existing) { if (!/^[a-f0-9]{8}$/.test(existing.id)) throw new Error("Existing store identity is not a canonical eight-hex ID. Review the registry; identity registration cannot replace an immutable ID."); return existing.id; } const primary = { ...implicitPrimaryStore(phrenPath), id: generateStoreId() }; writeSyncedPrimary(phrenPath, primary); return primary.id; }); } function readAttachedStores(phrenPath, synced, migrate = true) { const parsed = readYamlFile(attachedStoresFilePath(phrenPath), ATTACHED_STORES_FILENAME); if (!parsed.exists) return { stores: migrate ? migrateSyncedAttachments(phrenPath, synced) : (synced?.stores.filter(s => s.role !== "primary" && storePathExists(s.path)) ?? []), problems: [], lossy: false }; if (parsed.problems.length > 0) return { stores: [], problems: parsed.problems, lossy: true }; const problems = []; const { registry, skippedEntries } = normalizeRegistry(parsed.value, problems, ATTACHED_STORES_FILENAME, true); if (!registry) return { stores: [], problems, lossy: true }; const stores = registry.stores.filter((s) => s.role !== "primary"); if (stores.length < registry.stores.length) { problems.push(`${ATTACHED_STORES_FILENAME}: the primary store belongs in ${STORES_FILENAME} — entry skipped.`); } return { stores, problems, lossy: skippedEntries > 0 || stores.length < registry.stores.length }; } /** * One-time move from the old layout, where team stores were listed in the * synced stores.yaml. Only stores whose folder exists here were ever attached * on this machine; the rest came from another machine and are dropped. The * synced file is left as it is: machines still on an older phren read it. */ function migrateSyncedAttachments(phrenPath, synced) { const listed = synced?.stores.filter((s) => s.role !== "primary") ?? []; if (listed.length === 0) return []; const attached = listed.filter((s) => storePathExists(s.path)); try { writeAttachedStores(phrenPath, attached); } catch (err) { // Still honor them for this read; the next read retries the move. console.warn(`phren: could not write ${attachedStoresFilePath(phrenPath)}: ${String(err)}`); } return attached; } function readYamlFile(filePath, label) { if (!fs.existsSync(filePath)) return { exists: false, problems: [] }; let raw; try { raw = fs.readFileSync(filePath, "utf8"); } catch (err) { return { exists: true, problems: [`${label} is unreadable: ${String(err)}`] }; } try { return { exists: true, value: loadYamlDocument(raw, (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA })), problems: [] }; } catch (err) { return { exists: true, problems: [`${label} is not valid YAML: ${String(err)}`] }; } } /** Registry read problems already warned about, keyed by path + content. */ const warnedRegistryProblems = new Set(); export function readStoreRegistry(phrenPath) { const result = readStoreRegistryDetailed(phrenPath); if (result.problems.length > 0) { // Loud exactly once per process per distinct problem set: a malformed // stores.yaml used to be swallowed as "no registry", which silently // dropped every team store and re-routed their writes into the primary. const signature = `${phrenPath}\n${result.problems.join("\n")}`; if (!warnedRegistryProblems.has(signature)) { warnedRegistryProblems.add(signature); for (const problem of result.problems) { console.warn(`phren: ${problem}`); } if (!result.registry) { console.warn(`phren: ignoring ${storesFilePath(phrenPath)} and falling back to the primary store only — fix the file to restore multi-store routing.`); } } } return result.registry; } /** * Writes this machine's view of the stores: the primary entry to the synced * stores.yaml (only when it changed) and every other store to the machine's * own attached-stores.yaml. */ export function writeStoreRegistry(phrenPath, registry) { const err = validateRegistry(registry); if (err) throw new Error(`${PhrenError.VALIDATION_ERROR}: ${err}`); writeSyncedPrimary(phrenPath, registry.stores.find((s) => s.role === "primary")); writeAttachedStores(phrenPath, registry.stores.filter((s) => s.role !== "primary")); } function writeSyncedPrimary(phrenPath, primary) { const current = readSyncedRegistry(phrenPath); const existing = current.registry?.stores.find((s) => s.role === "primary"); if (existing && sameEntry(existing, primary)) return; // Keep what older phren versions on other machines still list here. const others = current.lossy ? [] : current.registry?.stores.filter((s) => s.role !== "primary") ?? []; const portable = { version: 1, stores: [primary, ...others].map(portableEntry) }; atomicWriteText(storesFilePath(phrenPath), yaml.dump(portable, { lineWidth: 200 })); } function writeAttachedStores(phrenPath, stores) { fs.mkdirSync(runtimeDir(phrenPath), { recursive: true }); const portable = { version: 1, stores: stores.map(portableEntry) }; atomicWriteText(attachedStoresFilePath(phrenPath), yaml.dump(portable, { lineWidth: 200 })); } /** * Collapse paths to a ~ prefix for portability. `available` is computed at * resolve time and is never written. */ function portableEntry({ available: _available, ...s }) { return { ...s, path: collapsePath(s.path) }; } function sameEntry(a, b) { return a.id === b.id && a.name === b.name && a.path === b.path && a.role === b.role && a.sync === b.sync && a.remote === b.remote && (a.projects ?? []).join("\n") === (b.projects ?? []).join("\n"); } // ── Resolution ─────────────────────────────────────────────────────────────── /** True when a store's directory exists on this machine. */ export function storePathExists(storePath) { try { return fs.statSync(storePath).isDirectory(); } catch { return false; } } /** * Resolve the full list of stores on this machine. This is the **key * backward-compat function**: * - The primary comes from the synced stores.yaml, or is implicit when the * file is missing * - Then every store attached on this machine (.runtime/attached-stores.yaml) * - Then PHREN_FEDERATION_PATHS entries as readonly stores * * Every returned entry carries `available`, recording whether its path exists * here. Attached stores whose folder is gone are deliberately **still * returned**: callers must be able to see that a store claims a project before * deciding what to do, otherwise its writes would land in the primary store. */ export function resolveAllStores(phrenPath) { const registry = readStoreRegistry(phrenPath); return resolveStoreEntries(phrenPath, registry); } /** Task discovery must neither migrate attachments nor treat a partial registry * as proof of an unambiguous task identity. No writes or warnings on this path. */ export function readTaskStoreEntries(phrenPath) { const result = readStoreRegistryDetailed(phrenPath, { migrateAttachments: false }); if (result.lossy) return []; // The legacy registry normalizer tolerates bad subscription members by // dropping them. For task access that could turn an invalid list into "all // projects", so validate the original evidence before using that view. for (const [file, label] of [[storesFilePath(phrenPath), STORES_FILENAME], [attachedStoresFilePath(phrenPath), ATTACHED_STORES_FILENAME]]) { const raw = readYamlFile(file, label); if (raw.exists && (!isRecord(raw.value) || !Array.isArray(raw.value.stores) || raw.value.stores.some(store => !isRecord(store) || (store.projects !== undefined && (!Array.isArray(store.projects) || store.projects.some(project => typeof project !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(project))))))) return []; } const registry = result.registry && { ...result.registry, stores: result.registry.stores.map(store => store.role === "primary" ? { ...store, path: path.resolve(phrenPath) } : store) }; return resolveStoreEntries(phrenPath, registry); } function resolveStoreEntries(phrenPath, registry) { const stores = registry ? [...registry.stores] : [implicitPrimaryStore(phrenPath)]; // Append PHREN_FEDERATION_PATHS entries that aren't already in the registry const knownPaths = new Set(stores.map((s) => s.path)); for (const fedPath of parseFederationPathsEnv(phrenPath)) { if (!knownPaths.has(fedPath)) { stores.push({ id: deterministicIdFromPath(fedPath), name: path.basename(fedPath), path: fedPath, role: "readonly", sync: "pull-only", }); knownPaths.add(fedPath); } } return stores.map((s) => ({ ...s, available: storePathExists(s.path) })); } /** * Non-primary entries in the synced stores.yaml that this machine does not use: * joined on another machine with an older phren. Attached ones were moved into * attached-stores.yaml; the rest mean nothing here. */ export function ignoredSyncedStores(phrenPath) { readStoreRegistry(phrenPath); // runs the one-time move first const synced = readSyncedRegistry(phrenPath).registry?.stores.filter((s) => s.role !== "primary") ?? []; const attached = new Set(resolveAllStores(phrenPath).map((s) => s.id)); return synced.filter((s) => !attached.has(s.id)); } /** Stores attached on this machine whose directory is missing. */ export function getUnavailableStores(phrenPath) { return resolveAllStores(phrenPath).filter((s) => s.available === false); } /** * One actionable sentence about a store attached on this machine whose folder * is gone. Shared by write routing, `phren status`, and `phren doctor` so all * three name the same store, the same expected path, and the same remedy. */ export function describeUnavailableStore(store) { const restore = store.remote ? `git clone ${store.remote} "${store.path}"` : `restore the folder`; return (`Store "${store.name}" (role=${store.role}) is attached on this machine but its folder is missing — ` + `expected path: ${store.path}. Either ${restore}, or detach it with: phren store remove ${store.name}`); } /** The primary store (role=primary). Falls back to implicit entry. */ export function getPrimaryStore(phrenPath) { const stores = resolveAllStores(phrenPath); return stores.find((s) => s.role === "primary") ?? implicitPrimaryStore(phrenPath); } /** All stores that can be read (all roles). */ export function getReadableStores(phrenPath) { return resolveAllStores(phrenPath); } /** Non-primary stores (for federation search, multi-store sync). */ export function getNonPrimaryStores(phrenPath) { return resolveAllStores(phrenPath).filter((s) => s.role !== "primary"); } /** Find a store by name. */ export function findStoreByName(phrenPath, name) { return resolveAllStores(phrenPath).find((s) => s.name === name); } /** Get project directories for a store, filtered by the store's subscription list (if set). */ export function getStoreProjectDirs(store) { const allDirs = getProjectDirs(store.path); if (!store.projects || store.projects.length === 0) return allDirs; const allowed = new Set(store.projects); return allDirs.filter(dir => path.basename(dir) !== "global" && allowed.has(path.basename(dir))); } // ── Team bootstrap ─────────────────────────────────────────────────────────── export function readTeamBootstrap(storePath) { const filePath = path.join(storePath, TEAM_BOOTSTRAP_FILENAME); if (!fs.existsSync(filePath)) return null; try { const raw = fs.readFileSync(filePath, "utf8"); const parsed = loadYamlDocument(raw, (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA })); if (!isRecord(parsed) || typeof parsed.name !== "string") return null; return { name: parsed.name, description: typeof parsed.description === "string" ? parsed.description : undefined, default_role: typeof parsed.default_role === "string" && isStoreRole(parsed.default_role) ? parsed.default_role : undefined, }; } catch { return null; } } // ── Registry mutation helpers ──────────────────────────────────────────────── /** * A registry read that mutators are allowed to write back. A lossy read * (stores.yaml exists but was unreadable, unparsable, or had entries skipped) * must never round-trip through a writer: `addStoreToRegistry` used to treat * that null as "first install" and OVERWRITE the user's malformed-but-real * stores.yaml with a fresh single-store registry — one typo'd role plus one * `phren store add` silently destroyed every other store entry. */ function readRegistryForMutation(phrenPath) { const result = readStoreRegistryDetailed(phrenPath); if (result.lossy) { throw new Error(`${PhrenError.VALIDATION_ERROR}: the store registry (${storesFilePath(phrenPath)} or ${attachedStoresFilePath(phrenPath)}) exists but could not be fully read — ` + `refusing to rewrite it (that would drop the entries phren cannot parse). ` + `Fix the file by hand first. Problems: ${result.problems.join(" | ")}`); } return result.registry; } /** Mutations change this machine's attachments, so they lock that file. */ function registryLockPath(phrenPath) { fs.mkdirSync(runtimeDir(phrenPath), { recursive: true }); return attachedStoresFilePath(phrenPath); } /** Attach a store on this machine. Creates stores.yaml for the primary if needed. Uses file locking. */ export function addStoreToRegistry(phrenPath, entry) { withFileLock(registryLockPath(phrenPath), () => { let registry = readRegistryForMutation(phrenPath); if (!registry) { // First time — also add the implicit primary store registry = { version: 1, stores: [implicitPrimaryStore(phrenPath)] }; } const existing = registry.stores.find((s) => s.name === entry.name); if (existing) throw new Error(`${PhrenError.VALIDATION_ERROR}: Store "${entry.name}" already exists`); registry.stores.push(entry); writeStoreRegistry(phrenPath, registry); }); } /** Remove a store entry by name. Refuses to remove primary. Uses file locking. */ export function removeStoreFromRegistry(phrenPath, name) { return withFileLock(registryLockPath(phrenPath), () => { const registry = readRegistryForMutation(phrenPath); if (!registry) throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`); const idx = registry.stores.findIndex((s) => s.name === name); if (idx === -1) throw new Error(`${PhrenError.NOT_FOUND}: Store "${name}" not found`); const entry = registry.stores[idx]; if (entry.role === "primary") throw new Error(`${PhrenError.VALIDATION_ERROR}: Cannot remove the primary store`); registry.stores.splice(idx, 1); writeStoreRegistry(phrenPath, registry); return entry; }); } /** Update the projects[] claim list for a store. Uses file locking. */ export function updateStoreProjects(phrenPath, storeName, projects) { withFileLock(registryLockPath(phrenPath), () => { const registry = readRegistryForMutation(phrenPath); if (!registry) throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`); const store = registry.stores.find((s) => s.name === storeName); if (!store) throw new Error(`${PhrenError.NOT_FOUND}: Store "${storeName}" not found`); store.projects = projects.length > 0 ? projects : undefined; writeStoreRegistry(phrenPath, registry); }); } /** Add projects to a store's subscription list. Deduplicates. Uses file locking. */ export function subscribeStoreProjects(phrenPath, storeName, projects) { withFileLock(registryLockPath(phrenPath), () => { const registry = readRegistryForMutation(phrenPath); if (!registry) throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`); const store = registry.stores.find((s) => s.name === storeName); if (!store) throw new Error(`${PhrenError.NOT_FOUND}: Store "${storeName}" not found`); const existing = new Set(store.projects || []); for (const project of projects) { existing.add(project); } store.projects = Array.from(existing).sort().length > 0 ? Array.from(existing).sort() : undefined; writeStoreRegistry(phrenPath, registry); }); } /** Remove projects from a store's subscription list. Uses file locking. */ export function unsubscribeStoreProjects(phrenPath, storeName, projects) { withFileLock(registryLockPath(phrenPath), () => { const registry = readRegistryForMutation(phrenPath); if (!registry) throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`); const store = registry.stores.find((s) => s.name === storeName); if (!store) throw new Error(`${PhrenError.NOT_FOUND}: Store "${storeName}" not found`); const toRemove = new Set(projects); const remaining = (store.projects || []).filter((p) => !toRemove.has(p)); store.projects = remaining.length > 0 ? remaining : undefined; writeStoreRegistry(phrenPath, registry); }); } // ── Validation ─────────────────────────────────────────────────────────────── function validateRegistry(registry) { if (registry.version !== 1) return `Unsupported registry version: ${registry.version}`; if (!Array.isArray(registry.stores) || registry.stores.length === 0) return "Registry must have at least one store"; const names = new Set(); const ids = new Set(); for (const store of registry.stores) { if (!store.id || typeof store.id !== "string") return `Store missing id`; if (!store.name || typeof store.name !== "string") return `Store missing name`; if (!store.path || typeof store.path !== "string") return `Store "${store.name}" missing path`; if (!isStoreRole(store.role)) return `Store "${store.name}" has invalid role: ${store.role}`; if (!isStoreSyncMode(store.sync)) return `Store "${store.name}" has invalid sync mode: ${store.sync}`; if (names.has(store.name)) return `Duplicate store name: "${store.name}"`; if (ids.has(store.id)) return `Duplicate store id: "${store.id}"`; names.add(store.name); ids.add(store.id); } const primaryCount = registry.stores.filter((s) => s.role === "primary").length; if (primaryCount !== 1) return `Registry must have exactly one primary store (found ${primaryCount})`; return null; } // ── Normalization ──────────────────────────────────────────────────────────── /** Aliases accepted on read for role values other tools/humans plausibly write. */ const ROLE_ALIASES = { secondary: "team" }; /** * One bad entry used to discard the ENTIRE registry (`return null` mid-loop), * with nothing logged — a single typo'd role silently dropped every team * store. Now each invalid entry is skipped with a reason pushed to * `problems`, and the valid remainder is kept. Callers see `skippedEntries` * so mutators can refuse to persist a lossy read. */ function normalizeRegistry(parsed, problems, label, allowEmpty = false) { if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.stores)) { problems.push(`${label} has an unrecognized shape (expected \`version: 1\` and a \`stores:\` list).`); return { registry: null, skippedEntries: 0 }; } const stores = []; let skippedEntries = 0; for (const [index, raw] of parsed.stores.entries()) { const skip = (entry, reason) => { skippedEntries += 1; problems.push(`${label}: store ${entry} ${reason} — entry skipped.`); }; if (!isRecord(raw)) { skip(`#${index + 1}`, "is not a mapping"); continue; } const id = typeof raw.id === "string" ? raw.id : ""; const name = typeof raw.name === "string" ? raw.name : ""; const entryLabel = name ? `"${name}"` : `#${index + 1}`; const rawPath = typeof raw.path === "string" ? raw.path : ""; const rawRole = typeof raw.role === "string" ? raw.role : ""; const aliasedRole = ROLE_ALIASES[rawRole]; if (aliasedRole) { problems.push(`${label}: store ${entryLabel} has role "${rawRole}" — reading it as "${aliasedRole}" (valid roles: primary|team|readonly).`); } const roleValue = aliasedRole ?? rawRole; const role = isStoreRole(roleValue) ? roleValue : null; const sync = typeof raw.sync === "string" && isStoreSyncMode(raw.sync) ? raw.sync : "managed-git"; const remote = typeof raw.remote === "string" ? raw.remote : undefined; const projects = Array.isArray(raw.projects) ? raw.projects.filter((p) => typeof p === "string") : undefined; if (!id || !name || !rawPath || !role) { const missing = [ !id && "id", !name && "name", !rawPath && "path", !role && `a valid role (got "${rawRole || "(none)"}")`, ].filter(Boolean); skip(entryLabel, `is missing ${missing.join(", ")}`); continue; } stores.push({ id, name, path: path.resolve(expandHomePath(rawPath)), role, sync, remote, projects: projects && projects.length > 0 ? projects : undefined, }); } if (stores.length === 0 && !allowEmpty) { problems.push(`${label} contains no usable store entries.`); return { registry: null, skippedEntries }; } return { registry: { version: 1, stores }, skippedEntries }; } // ── Internal helpers ───────────────────────────────────────────────────────── function implicitPrimaryStore(phrenPath) { return { id: deterministicIdFromPath(phrenPath), name: "personal", path: phrenPath, role: "primary", sync: "managed-git", }; } function parseFederationPathsEnv(localPhrenPath) { const raw = process.env.PHREN_FEDERATION_PATHS ?? ""; if (!raw.trim()) return []; // Use path.delimiter (';' on Windows, ':' on Unix) so Windows drive letters aren't split return raw .split(path.delimiter) .map((p) => p.trim()) .filter((p) => p.length > 0) .map((p) => path.resolve(expandHomePath(p))) .filter((p) => p !== localPhrenPath && fs.existsSync(p)); } function isStoreRole(value) { return VALID_ROLES.has(value); } function isStoreSyncMode(value) { return VALID_SYNC_MODES.has(value); } function collapsePath(absPath) { const home = process.env.HOME || process.env.USERPROFILE || ""; if (home && absPath.startsWith(home + path.sep)) { return "~/" + absPath.slice(home.length + 1); } return absPath; }