@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
509 lines (508 loc) • 22.1 kB
JavaScript
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import * as fs from "fs";
import * as path from "path";
import { execFileSync } from "child_process";
import { getPhrenPath } from "../shared.js";
import { isValidProjectName, errorMessage, getOptionValue } from "../utils.js";
import { resolveAllStores, addStoreToRegistry, removeStoreFromRegistry, generateStoreId, getStoreProjectDirs, readTeamBootstrap, registeredStoreIdentity, registerStoreIdentity, } from "../store-registry.js";
import { mergeStoreUpstream } from "../sync/store-merge.js";
import { aheadBehind, logSyncOutcome } from "../sync/outcome.js";
import { getRuntimeHealth, updateRuntimeHealth } from "../shared/governance.js";
import { permissionDeniedError } from "../governance/rbac.js";
function printStoreUsage() {
console.log("Usage:");
console.log(" phren store list List registered stores");
console.log(" phren store identity [--create] Read or explicitly register this store's portable identity");
console.log(" phren store add <name> --remote <url> Add a team store");
console.log(" phren store remove <name> Remove a store (local only)");
console.log(" phren store sync Pull and push all stores");
console.log(" phren store activity [--limit N] Recent team findings");
console.log(" phren store subscribe <name> <project...> Subscribe store to projects");
console.log(" phren store unsubscribe <name> <project...> Unsubscribe store from projects");
}
/**
* Push what a sync left ahead of the upstream: the merge commit and any local
* commits. Without this a merge that resolved conflicts stayed local, and the
* store sat N commits ahead until someone ran `git push` by hand.
*/
async function pushStoreUpstream(store, git) {
if (store.sync === "pull-only" || store.role === "readonly")
return { status: "skipped", detail: "pull-only store" };
const counts = await aheadBehind(store.path, git);
if (!counts)
return { status: "skipped", detail: "no tracking remote" };
if (counts.ahead === 0)
return { status: "in-sync", detail: "nothing to push" };
const branch = await git(store.path, ["symbolic-ref", "--quiet", "HEAD"]);
const refs = branch.ok
? await git(store.path, ["for-each-ref", "--format=%(upstream:remotename)%09%(upstream:remoteref)", branch.output])
: branch;
const [remote, remoteRef] = refs.ok ? refs.output.split("\t") : [];
if (!remote || !remoteRef)
return { status: "failed", detail: "cannot read the upstream branch" };
const pushed = await git(store.path, ["push", "--quiet", "--", remote, `HEAD:${remoteRef}`]);
if (!pushed.ok)
return { status: "failed", detail: `push failed: ${(pushed.error || "unknown Git error").split("\n")[0]}` };
return { status: "pushed", detail: `pushed ${counts.ahead} commit${counts.ahead === 1 ? "" : "s"}` };
}
/** Record a sync's push on the primary store, so doctor's runtime-auto-save check reflects it. */
async function recordPrimaryPush(phrenPath, push, git) {
if (push.status === "skipped")
return;
const now = new Date().toISOString();
const previous = getRuntimeHealth(phrenPath).lastSync;
const counts = await aheadBehind(phrenPath, git);
const detail = `store sync: ${push.detail}`;
const failed = push.status === "failed";
updateRuntimeHealth(phrenPath, {
lastAutoSave: { at: now, status: failed ? "sync-failed" : "saved-pushed", detail },
lastSync: {
...previous,
lastPushAt: now,
lastPushStatus: failed ? "push-failed" : "saved-pushed",
lastPushDetail: detail,
unsyncedCommits: counts?.ahead ?? previous?.unsyncedCommits,
consecutiveFailures: failed ? (previous?.consecutiveFailures ?? 0) + 1 : 0,
...(failed ? {} : { lastSuccessfulPushAt: now }),
...(counts ?? {}),
},
});
logSyncOutcome(phrenPath, "store-sync", { ok: !failed, detail: push.detail, counts });
}
function countStoreProjects(store) {
if (!fs.existsSync(store.path))
return 0;
try {
return getStoreProjectDirs(store).length;
}
catch {
return 0;
}
}
function readHealthForStore(storePath) {
try {
const healthPath = path.join(storePath, ".runtime", "health.json");
if (!fs.existsSync(healthPath))
return null;
const raw = JSON.parse(fs.readFileSync(healthPath, "utf8"));
const lastSync = raw?.lastSync;
if (!lastSync)
return null;
const parts = [];
if (lastSync.lastPullStatus)
parts.push(`pull=${lastSync.lastPullStatus}`);
if (lastSync.lastPushStatus)
parts.push(`push=${lastSync.lastPushStatus}`);
if (lastSync.lastSuccessfulPullAt)
parts.push(`at=${lastSync.lastSuccessfulPullAt.slice(0, 16)}`);
return parts.join(", ") || null;
}
catch {
return null;
}
}
export async function handleStoreNamespace(args) {
const subcommand = args[0];
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
printStoreUsage();
return;
}
const phrenPath = getPhrenPath();
if (subcommand === "identity") {
if (args.length > 2 || (args[1] !== undefined && args[1] !== "--create")) {
console.error("Usage: phren store identity [--create]");
process.exitCode = 1;
return;
}
try {
if (args[1] === "--create") {
const denied = permissionDeniedError(phrenPath, "manage_config");
if (denied)
throw new Error(denied);
console.log(registerStoreIdentity(phrenPath));
}
else {
const id = registeredStoreIdentity(phrenPath);
if (id)
console.log(id);
else {
console.error("No portable store identity. An owner can register one with phren store identity --create, then distribute stores.yaml through the existing store sync workflow.");
process.exitCode = 1;
}
}
}
catch (error) {
console.error(errorMessage(error));
process.exitCode = 1;
}
return;
}
if (subcommand === "list") {
const stores = resolveAllStores(phrenPath);
if (stores.length === 0) {
console.log("No stores registered.");
return;
}
console.log(`${stores.length} store(s):\n`);
for (const store of stores) {
const exists = fs.existsSync(store.path) ? "ok" : "MISSING";
const syncInfo = store.remote ?? "(local)";
const projectCount = countStoreProjects(store);
console.log(` ${store.name} (${store.role})`);
console.log(` id: ${store.id}`);
console.log(` path: ${store.path} [${exists}]`);
console.log(` remote: ${syncInfo}`);
console.log(` sync: ${store.sync}`);
console.log(` projects: ${projectCount}`);
// Show last sync status if available
const health = readHealthForStore(store.path);
if (health) {
console.log(` last sync: ${health}`);
}
console.log();
}
return;
}
if (subcommand === "add") {
const name = args[1];
if (!name) {
console.error("Usage: phren store add <name> --remote <url> [--role team|readonly]");
process.exit(1);
}
// Validate store name to prevent path traversal
if (!isValidProjectName(name)) {
console.error(`Invalid store name: "${name}". Use lowercase letters, numbers, and hyphens.`);
process.exit(1);
}
const remote = getOptionValue(args.slice(2), "--remote");
if (!remote) {
console.error("--remote <url> is required. Provide the git clone URL for the team store.");
process.exit(1);
}
// Prevent git option injection via --remote
if (remote.startsWith("-")) {
console.error(`Invalid remote URL: "${remote}". URLs must not start with "-".`);
process.exit(1);
}
const roleArg = getOptionValue(args.slice(2), "--role") ?? "team";
if (roleArg !== "team" && roleArg !== "readonly") {
console.error(`Invalid role: "${roleArg}". Use "team" or "readonly".`);
process.exit(1);
}
const storesDir = path.join(path.dirname(phrenPath), ".phren-stores");
const storePath = path.join(storesDir, name);
if (fs.existsSync(storePath)) {
console.error(`Directory already exists: ${storePath}`);
process.exit(1);
}
// Clone the remote
console.log(`Cloning ${remote} into ${storePath}...`);
try {
fs.mkdirSync(storesDir, { recursive: true });
execFileSync("git", ["clone", "--", remote, storePath], {
env: nonInteractiveGitEnv(),
stdio: "inherit",
timeout: 60_000,
});
}
catch (err) {
console.error(`Clone failed: ${errorMessage(err)}`);
process.exit(1);
}
// Read .phren-team.yaml if present
const bootstrap = readTeamBootstrap(storePath);
const storeName = bootstrap?.name ?? name;
const storeRole = bootstrap?.default_role ?? roleArg;
const entry = {
id: generateStoreId(),
name: storeName,
path: storePath,
role: storeRole === "primary" ? "team" : storeRole, // Never allow adding a second primary
sync: storeRole === "readonly" ? "pull-only" : "managed-git",
remote,
};
try {
addStoreToRegistry(phrenPath, entry);
}
catch (err) {
console.error(`Failed to register store: ${errorMessage(err)}`);
process.exit(1);
}
console.log(`\nStore "${storeName}" added (${entry.role}).`);
console.log(` id: ${entry.id}`);
console.log(` path: ${storePath}`);
return;
}
if (subcommand === "remove") {
const name = args[1];
if (!name) {
console.error("Usage: phren store remove <name>");
process.exit(1);
}
try {
const removed = removeStoreFromRegistry(phrenPath, name);
console.log(`Store "${name}" removed from registry.`);
console.log(` Local directory preserved at: ${removed.path}`);
console.log(` To delete: rm -rf "${removed.path}"`);
}
catch (err) {
console.error(`${errorMessage(err)}`);
process.exit(1);
}
return;
}
if (subcommand === "activity") {
const stores = resolveAllStores(phrenPath);
const teamStores = stores.filter((s) => s.role === "team");
if (teamStores.length === 0) {
console.log("No team stores registered. Add one with: phren store add <name> --remote <url>");
return;
}
const { readTeamJournalEntries } = await import("../finding/journal.js");
const limit = Number(getOptionValue(args.slice(1), "--limit") ?? "20");
const allEntries = [];
for (const store of teamStores) {
if (!fs.existsSync(store.path))
continue;
const { getStoreProjectDirs } = await import("../store-registry.js");
const projectDirs = getStoreProjectDirs(store);
for (const dir of projectDirs) {
const projectName = path.basename(dir);
const journalEntries = readTeamJournalEntries(store.path, projectName);
for (const je of journalEntries) {
for (const entry of je.entries) {
allEntries.push({ store: store.name, project: projectName, date: je.date, actor: je.actor, entry });
}
}
}
}
allEntries.sort((a, b) => b.date.localeCompare(a.date));
const capped = allEntries.slice(0, limit);
if (capped.length === 0) {
console.log("No team activity yet.");
return;
}
console.log(`Team activity (${capped.length}/${allEntries.length}):\n`);
let lastDate = "";
for (const e of capped) {
if (e.date !== lastDate) {
console.log(`## ${e.date}`);
lastDate = e.date;
}
console.log(` [${e.store}/${e.project}] ${e.actor}: ${e.entry}`);
}
return;
}
if (subcommand === "sync") {
const stores = resolveAllStores(phrenPath);
let hasErrors = false;
let primarySynced = false;
const git = async (cwd, gitArgs) => {
try {
const output = execFileSync("git", gitArgs, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
env: nonInteractiveGitEnv(),
}).trim();
return { ok: true, output };
}
catch (err) {
return { ok: false, output: "", error: errorMessage(err) };
}
};
for (const store of stores) {
if (!fs.existsSync(store.path)) {
console.log(` ${store.name}: SKIP (path missing)`);
continue;
}
const gitDir = path.join(store.path, ".git");
if (!fs.existsSync(gitDir)) {
console.log(` ${store.name}: SKIP (not a git repo)`);
continue;
}
const result = await mergeStoreUpstream(store.path, { git });
if (result.status === "busy") {
console.log(` ${store.name}: SKIP (${result.detail})`);
hasErrors = true;
continue;
}
if (result.status === "conflict") {
console.log(` ${store.name}: NEEDS MANUAL RESOLUTION (${result.detail})`);
hasErrors = true;
continue;
}
if (result.status === "error") {
console.log(` ${store.name}: FAILED (${result.detail})`);
hasErrors = true;
continue;
}
try {
// Re-apply sparse-checkout after pull on primary store to avoid materializing all files
if (store.role === "primary") {
try {
const sparseList = execFileSync("git", ["sparse-checkout", "list"], {
env: nonInteractiveGitEnv(),
cwd: store.path,
stdio: "pipe",
timeout: 10_000,
}).toString().trim();
if (sparseList) {
execFileSync("git", ["sparse-checkout", "reapply"], {
env: nonInteractiveGitEnv(),
cwd: store.path,
stdio: "pipe",
timeout: 10_000,
});
}
}
catch {
// sparse-checkout not configured — nothing to reapply
}
}
const push = await pushStoreUpstream(store, git);
if (store.role === "primary")
await recordPrimaryPush(store.path, push, git);
if (push.status === "failed") {
console.log(` ${store.name}: PUSH FAILED (${result.detail} ${push.detail}; the store is still ahead of the remote)`);
hasErrors = true;
}
else {
console.log(` ${store.name}: ok (${result.detail}${push.status === "pushed" ? ` Then ${push.detail}.` : ""})`);
}
if (store.role === "primary")
primarySynced = true;
}
catch (err) {
console.log(` ${store.name}: FAILED (${errorMessage(err).split("\n")[0]})`);
hasErrors = true;
}
}
// A pull can bring new skills or a changed global AGENTS.md. Refresh the
// home and repo links the way SessionStart does, so doctor doesn't report
// drift until the next session or a `doctor --fix`.
if (primarySynced) {
try {
const [{ repairPreexistingInstall }, { refreshLinkedContext }, { resolveRuntimeProfile }] = await Promise.all([
import("../init/setup.js"),
import("../link/refresh.js"),
import("../runtime-profile.js"),
]);
repairPreexistingInstall(phrenPath);
refreshLinkedContext(phrenPath, resolveRuntimeProfile(phrenPath));
}
catch (err) {
console.log(` relink: FAILED (${errorMessage(err).split("\n")[0]}). Run 'phren doctor --fix'.`);
hasErrors = true;
}
}
if (hasErrors) {
console.error("\nSome stores failed to sync. Run 'phren doctor' for details.");
}
return;
}
if (subcommand === "subscribe") {
const storeName = args[1];
const projects = args.slice(2);
if (!storeName || projects.length === 0) {
console.error("Usage: phren store subscribe <store-name> <project1> [project2...]");
process.exit(1);
}
try {
const { subscribeStoreProjects } = await import("../store-registry.js");
subscribeStoreProjects(phrenPath, storeName, projects);
console.log(`Added ${projects.length} project(s) to "${storeName}"`);
}
catch (err) {
console.error(`Failed to subscribe: ${errorMessage(err)}`);
process.exit(1);
}
return;
}
if (subcommand === "unsubscribe") {
const storeName = args[1];
const projects = args.slice(2);
if (!storeName || projects.length === 0) {
console.error("Usage: phren store unsubscribe <store-name> <project1> [project2...]");
process.exit(1);
}
try {
const { unsubscribeStoreProjects } = await import("../store-registry.js");
unsubscribeStoreProjects(phrenPath, storeName, projects);
console.log(`Removed ${projects.length} project(s) from "${storeName}"`);
}
catch (err) {
console.error(`Failed to unsubscribe: ${errorMessage(err)}`);
process.exit(1);
}
return;
}
console.error(`Unknown store subcommand: ${subcommand}`);
printStoreUsage();
process.exit(1);
}
export async function handlePromoteNamespace(args) {
if (!args[0] || args[0] === "--help" || args[0] === "-h") {
console.log("Usage:");
console.log(' phren promote <project> "finding text..." --to <store>');
console.log(" Copies a finding from the primary store to a team store.");
return;
}
const phrenPath = getPhrenPath();
const project = args[0];
if (!isValidProjectName(project)) {
console.error(`Invalid project name: "${project}"`);
process.exit(1);
}
const toStore = getOptionValue(args.slice(1), "--to");
if (!toStore) {
console.error("--to <store> is required. Specify the target team store.");
process.exit(1);
}
// Everything between project and --to is the finding text
const toIdx = args.indexOf("--to");
const findingText = args.slice(1, toIdx !== -1 ? toIdx : undefined).join(" ").trim();
if (!findingText) {
console.error("Finding text is required.");
process.exit(1);
}
const stores = resolveAllStores(phrenPath);
const targetStore = stores.find((s) => s.name === toStore);
if (!targetStore) {
const available = stores.map((s) => s.name).join(", ");
console.error(`Store "${toStore}" not found. Available: ${available}`);
process.exit(1);
}
if (targetStore.role === "readonly") {
console.error(`Store "${toStore}" is read-only.`);
process.exit(1);
}
if (targetStore.role === "primary") {
console.error(`Cannot promote to primary store — finding is already there.`);
process.exit(1);
}
// Find the matching finding in the primary store
const { readFindings } = await import("../data/access.js");
const findingsResult = readFindings(phrenPath, project);
if (!findingsResult.ok) {
console.error(`Could not read findings for project "${project}".`);
process.exit(1);
}
const match = findingsResult.data.find((item) => item.text.includes(findingText) || findingText.includes(item.text));
if (!match) {
console.error(`No finding matching "${findingText.slice(0, 80)}..." found in ${project}.`);
process.exit(1);
}
// Write to target store
const targetProjectDir = path.join(targetStore.path, project);
fs.mkdirSync(targetProjectDir, { recursive: true });
const { addFindingToFile } = await import("../shared/content.js");
const result = addFindingToFile(targetStore.path, project, match.text);
if (!result.ok) {
console.error(`Failed to add finding to ${toStore}: ${result.error}`);
process.exit(1);
}
console.log(`Promoted to ${toStore}/${project}:`);
console.log(` "${match.text.slice(0, 120)}${match.text.length > 120 ? "..." : ""}"`);
}