/**
* Local file sync for the no-login local mode.
*
* In local mode, created and updated plans are written to the repo as MDX so
* that "synced to local files" is literally true and the plan is round-trippable
* with `import-visual-plan-source` / `patch-visual-plan-source`.
*
* Layout (per plan), under the local plans directory:
*
*
//plan.mdx
* //canvas.mdx (when present)
* //prototype.mdx (when present)
* //.plan-state.json (when present)
*
* If another plan already owns that folder name, the mirror appends a human
* numeric suffix such as `checkout-review-flow-2`.
*
* The directory is, in priority order:
* 1. `PLAN_LOCAL_DIR` env var (absolute or relative to cwd).
* 2. `/plans` (the running app/template directory in `agent-native dev`).
*
* Writes are idempotent: the same plan content always produces the same files.
* Hosted behavior is unchanged — callers only invoke this when
* `isLocalPlanRuntime()` is true, and any filesystem error is swallowed so a
* read-only or sandboxed environment never breaks a plan mutation.
*/
import type { Dirent } from "node:fs";
import * as fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { PlanContent } from "../../shared/plan-content.js";
import type { PlanComment } from "../../shared/types.js";
import {
exportPlanContentToMdxFolder,
parsePlanMdxFolder,
type PlanMdxFolder,
} from "../plan-mdx.js";
const PLAN_FOLDER_TITLE_LIMIT = 64;
const LOCAL_COMMENTS_FILE = "comments.json";
const AGENT_NATIVE_MANIFEST_FILE = "agent-native.json";
const DEFAULT_REPO_PLANS_PATH = "plans";
export interface LocalPlanWriteInput {
planId: string;
title: string;
brief?: string | null;
content: PlanContent | null | undefined;
url?: string;
referencedBlockIds?: Iterable;
}
export interface LocalPlanFolderWriteInput extends LocalPlanWriteInput {
slug: string;
path?: string | null;
}
export interface LocalPlanReadResult {
slug: string;
folder: string;
repoPath: string | null;
routePath: string;
url: string;
suggestedRepoPath: string;
mdx: PlanMdxFolder;
content: PlanContent;
}
export interface LocalPlanLocationInput {
slug: string;
path?: string | null;
}
export interface LocalPlanPromoteInput extends LocalPlanLocationInput {
targetPath?: string | null;
overwrite?: boolean;
}
/** Absolute path to the local plans directory for this process. */
export function localPlansDir(): string {
const configured = process.env.PLAN_LOCAL_DIR;
if (configured && configured.trim().length > 0) {
return path.resolve(process.cwd(), configured.trim());
}
return path.resolve(process.cwd(), "plans");
}
function sanitizeLegacyPlanId(planId: string): string {
return planId.replace(/[\\/]/g, "_");
}
export function localPlanFolderName(title: string): string {
const slug = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, PLAN_FOLDER_TITLE_LIMIT)
.replace(/-+$/g, "");
return slug || "untitled-plan";
}
function normalizeSlash(filePath: string): string {
return filePath.replace(/\\/g, "/");
}
export function assertLocalPlanRelativePath(
filePath: string,
label = "Local plan path",
): string {
const raw = filePath.trim();
if (!raw) throw new Error(`${label} is required.`);
if (raw.includes("\0")) {
throw new Error(`${label} must not contain null bytes.`);
}
if (path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)) {
throw new Error(`${label} must be relative to the repo.`);
}
const normalized = path.posix
.normalize(normalizeSlash(raw))
.replace(/\/+$/, "");
if (
!normalized ||
normalized === "." ||
normalized === ".." ||
normalized.startsWith("../") ||
normalized.split("/").some((part) => !part || part === "." || part === "..")
) {
throw new Error(`${label} must be a safe relative path.`);
}
return normalized;
}
/** Absolute path to a single plan's local folder. */
export function localPlanFolder(planId: string, title?: string): string {
return path.join(
localPlansDir(),
title ? localPlanFolderName(title) : sanitizeLegacyPlanId(planId),
);
}
export function assertLocalPlanSlug(slug: string): string {
const normalized = slug.trim();
if (!/^[A-Za-z0-9._-]+$/.test(normalized)) {
throw new Error(
"Local plan slug may only contain letters, numbers, dots, underscores, and dashes.",
);
}
return normalized;
}
function assertInsideLocalPlansDir(folder: string): string {
const root = path.resolve(localPlansDir());
const resolved = path.resolve(folder);
const relative = path.relative(root, resolved);
if (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
) {
return resolved;
}
throw new Error("Local plan path escaped PLAN_LOCAL_DIR.");
}
function canonicalPathForContainment(target: string): string {
let current = path.resolve(target);
const missingSegments: string[] = [];
while (!fsSync.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) return path.resolve(target);
missingSegments.unshift(path.basename(current));
current = parent;
}
return path.join(fsSync.realpathSync.native(current), ...missingSegments);
}
function firstEnvValue(names: string[]): string | undefined {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) return value;
}
return undefined;
}
function findUpward(startDir: string, filename: string): string | null {
let current = path.resolve(startDir);
for (;;) {
const candidate = path.join(current, filename);
if (fsSync.existsSync(candidate)) return candidate;
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
export function localPlanRepoRoot(): string {
const configuredRoot = process.env.PLAN_REPO_ROOT?.trim();
if (configuredRoot) return path.resolve(process.cwd(), configuredRoot);
const configuredManifest = firstEnvValue([
"AGENT_NATIVE_MANIFEST",
"AGENT_NATIVE_MANIFEST_PATH",
]);
if (configuredManifest) {
return path.dirname(path.resolve(process.cwd(), configuredManifest));
}
const manifestPath = findUpward(process.cwd(), AGENT_NATIVE_MANIFEST_FILE);
if (manifestPath) return path.dirname(manifestPath);
const gitDir = findUpward(process.cwd(), ".git");
if (gitDir) return path.dirname(gitDir);
return path.resolve(process.cwd());
}
function assertInsideRepoRoot(folder: string): string {
const root = canonicalPathForContainment(localPlanRepoRoot());
const resolved = path.resolve(folder);
const containmentPath = canonicalPathForContainment(resolved);
const relative = path.relative(root, containmentPath);
if (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
) {
return resolved;
}
throw new Error("Local plan path escaped the repo root.");
}
function isRecord(value: unknown): value is Record {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function manifestRootPath(value: unknown): string | null {
if (typeof value === "string" && value.trim()) return value.trim();
if (isRecord(value) && typeof value.path === "string" && value.path.trim()) {
return value.path.trim();
}
return null;
}
async function configuredRepoPlansPath(): Promise {
const manifestPath = path.join(
localPlanRepoRoot(),
AGENT_NATIVE_MANIFEST_FILE,
);
try {
const parsed = JSON.parse(
await fs.readFile(manifestPath, "utf-8"),
) as unknown;
const apps = isRecord(parsed) && isRecord(parsed.apps) ? parsed.apps : null;
const planApp = apps && isRecord(apps.plan) ? apps.plan : null;
const roots = planApp && Array.isArray(planApp.roots) ? planApp.roots : [];
const configured = roots
.map(manifestRootPath)
.find((item): item is string => Boolean(item));
if (configured) {
return assertLocalPlanRelativePath(configured, "apps.plan.roots[0].path");
}
} catch {
// Missing or invalid manifests fall back to the long-standing local default.
}
return DEFAULT_REPO_PLANS_PATH;
}
export async function defaultLocalPlanRepoPath(slug: string): Promise {
const safeSlug = assertLocalPlanSlug(slug);
return path.posix.join(await configuredRepoPlansPath(), safeSlug);
}
function localPlanRoutePath(slug: string, repoPath?: string | null): string {
const pathPart = `/local-plans/${encodeURIComponent(slug)}`;
if (!repoPath) return pathPart;
const params = new URLSearchParams({ path: repoPath });
return `${pathPart}?${params.toString()}`;
}
function resolveLocalPlanLocation(input: string | LocalPlanLocationInput): {
slug: string;
folder: string;
repoPath: string | null;
} {
const source =
typeof input === "string" ? { slug: input, path: null } : input;
const safeSlug = assertLocalPlanSlug(source.slug);
if (source.path?.trim()) {
const repoPath = assertLocalPlanRelativePath(source.path);
return {
slug: safeSlug,
folder: assertInsideRepoRoot(path.join(localPlanRepoRoot(), repoPath)),
repoPath,
};
}
return {
slug: safeSlug,
folder: assertInsideLocalPlansDir(path.join(localPlansDir(), safeSlug)),
repoPath: null,
};
}
function resolveRepoRelativePlanFolder(repoPath: string): {
repoPath: string;
folder: string;
} {
const safePath = assertLocalPlanRelativePath(repoPath);
return {
repoPath: safePath,
folder: assertInsideRepoRoot(path.join(localPlanRepoRoot(), safePath)),
};
}
function frontmatterContainsPlanId(source: string, planId: string): boolean {
const escaped = planId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^planId:\\s*["']${escaped}["']\\s*$`, "m").test(source);
}
async function folderReferencesPlanId(folder: string, planId: string) {
try {
const planMdx = await fs.readFile(path.join(folder, "plan.mdx"), "utf-8");
if (frontmatterContainsPlanId(planMdx, planId)) return true;
} catch {
// Missing or unreadable plan.mdx: try state as a fallback.
}
try {
const state = JSON.parse(
await fs.readFile(path.join(folder, ".plan-state.json"), "utf-8"),
) as { planId?: unknown };
return state.planId === planId;
} catch {
return false;
}
}
async function findExistingLocalPlanFoldersFromEntries(
planId: string,
entries: Dirent[],
): Promise {
const dir = localPlansDir();
const folders: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const folder = path.join(dir, entry.name);
if (
entry.name === sanitizeLegacyPlanId(planId) ||
(await folderReferencesPlanId(folder, planId))
) {
folders.push(folder);
}
}
return folders;
}
async function resolveLocalPlanFolderForWrite(
planId: string,
title: string,
): Promise<{ folder: string; existingFolders: string[] }> {
const dir = localPlansDir();
const baseName = localPlanFolderName(title);
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return {
folder: path.join(dir, baseName),
existingFolders: [],
};
}
const existingFolders = await findExistingLocalPlanFoldersFromEntries(
planId,
entries,
);
const occupied = new Map();
for (const entry of entries) {
const folder = path.join(dir, entry.name);
const currentPlanOwnsEntry =
entry.isDirectory() &&
(entry.name === sanitizeLegacyPlanId(planId) ||
(await folderReferencesPlanId(folder, planId)));
occupied.set(entry.name, currentPlanOwnsEntry);
}
for (let attempt = 1; ; attempt += 1) {
const name = attempt === 1 ? baseName : `${baseName}-${attempt}`;
const currentPlanOwnsName = occupied.get(name);
if (currentPlanOwnsName !== false) {
return {
folder: path.join(dir, name),
existingFolders,
};
}
}
}
async function writePlanMdxFolderToDisk(
folder: string,
mdx: PlanMdxFolder,
existingFolders: string[] = [],
): Promise<{ written: boolean; folder: string; files: string[] }> {
await fs.mkdir(folder, { recursive: true });
const written: string[] = [];
// plan.mdx is always present.
await fs.writeFile(path.join(folder, "plan.mdx"), mdx["plan.mdx"], "utf-8");
written.push("plan.mdx");
// canvas.mdx, prototype.mdx, and .plan-state.json are optional.
if (mdx["canvas.mdx"]) {
await fs.writeFile(
path.join(folder, "canvas.mdx"),
mdx["canvas.mdx"],
"utf-8",
);
written.push("canvas.mdx");
} else {
// Remove a stale canvas file if the plan no longer has a board, so the
// mirror stays an accurate round-trip of the current content.
await fs.rm(path.join(folder, "canvas.mdx"), { force: true });
}
if (mdx["prototype.mdx"]) {
await fs.writeFile(
path.join(folder, "prototype.mdx"),
mdx["prototype.mdx"],
"utf-8",
);
written.push("prototype.mdx");
} else {
await fs.rm(path.join(folder, "prototype.mdx"), { force: true });
}
if (mdx[".plan-state.json"]) {
await fs.writeFile(
path.join(folder, ".plan-state.json"),
mdx[".plan-state.json"],
"utf-8",
);
written.push(".plan-state.json");
} else {
await fs.rm(path.join(folder, ".plan-state.json"), { force: true });
}
// Write binary assets to the local assets/ directory.
const assetEntries = Object.entries(mdx["assets/"] ?? {});
if (assetEntries.length > 0) {
const assetsDir = path.join(folder, "assets");
await fs.mkdir(assetsDir, { recursive: true });
for (const [filename, base64] of assetEntries) {
// Sanitize: no path traversal, no absolute paths.
const safe = path.basename(filename);
if (!safe || safe !== filename) continue;
const bytes = Buffer.from(base64, "base64");
await fs.writeFile(path.join(assetsDir, safe), bytes);
written.push(`assets/${safe}`);
}
}
await Promise.all(
existingFolders
.filter((existingFolder) => existingFolder !== folder)
.map((existingFolder) =>
fs.rm(existingFolder, { recursive: true, force: true }),
),
);
return { written: true, folder, files: written };
}
async function clearExistingPlanMdxFolder(folder: string, targetPath: string) {
const entries = await fs.readdir(folder, { withFileTypes: true });
const hasPlanMdx = entries.some(
(entry) => entry.isFile() && entry.name === "plan.mdx",
);
if (!hasPlanMdx) {
throw new Error(
`${targetPath} already exists and does not look like a local Plan folder.`,
);
}
await Promise.all([
fs.rm(path.join(folder, "plan.mdx"), { force: true }),
fs.rm(path.join(folder, "canvas.mdx"), { force: true }),
fs.rm(path.join(folder, "prototype.mdx"), { force: true }),
fs.rm(path.join(folder, ".plan-state.json"), { force: true }),
fs.rm(path.join(folder, "assets"), { recursive: true, force: true }),
]);
}
/**
* Write a plan's MDX folder to the local filesystem. Idempotent and best-effort:
* filesystem errors are caught and returned as `{ written: false }` so a plan
* mutation never fails just because the local mirror could not be written.
*/
export async function writePlanLocalFiles(
input: LocalPlanWriteInput,
): Promise<{ written: boolean; folder: string; files: string[] }> {
const resolved = await resolveLocalPlanFolderForWrite(
input.planId,
input.title,
);
const folder = resolved.folder;
try {
const mdx = await exportPlanContentToMdxFolder({
content: input.content,
title: input.title,
brief: input.brief,
planId: input.planId,
url: input.url ?? `/plans/${encodeURIComponent(input.planId)}`,
referencedBlockIds: input.referencedBlockIds,
});
return await writePlanMdxFolderToDisk(
folder,
mdx,
resolved.existingFolders,
);
} catch {
// Read-only FS, permissions, or a sandboxed runtime: never break the
// underlying plan operation just because the local mirror failed.
return { written: false, folder, files: [] };
}
}
/**
* Write directly back to an already-opened local plan folder. Unlike the local
* mirror path above, this preserves the folder slug even when the plan title
* changes and lets filesystem errors surface to the caller.
*/
export async function writePlanLocalFolder(
input: LocalPlanFolderWriteInput,
): Promise<{ written: boolean; folder: string; files: string[] }> {
const location = resolveLocalPlanLocation(input);
const mdx = await exportPlanContentToMdxFolder({
content: input.content,
title: input.title,
brief: input.brief,
planId: input.planId,
url: input.url ?? localPlanRoutePath(location.slug, location.repoPath),
referencedBlockIds: input.referencedBlockIds,
});
return await writePlanMdxFolderToDisk(location.folder, mdx);
}
export async function readPlanLocalFolder(
input: string | LocalPlanLocationInput,
): Promise {
const location = resolveLocalPlanLocation(input);
const folder = location.folder;
const planPath = path.join(folder, "plan.mdx");
const planMdx = await fs.readFile(planPath, "utf-8");
const mdx: PlanMdxFolder = { "plan.mdx": planMdx };
for (const file of [
"canvas.mdx",
"prototype.mdx",
".plan-state.json",
] as const) {
try {
mdx[file] = await fs.readFile(path.join(folder, file), "utf-8");
} catch {
// Optional local source file.
}
}
// Read local binary assets from the assets/ directory (if present).
try {
const assetsDir = path.join(folder, "assets");
const entries = await fs.readdir(assetsDir, { withFileTypes: true });
const assets: Record = {};
for (const entry of entries) {
if (!entry.isFile()) continue;
const bytes = await fs.readFile(path.join(assetsDir, entry.name));
assets[entry.name] = bytes.toString("base64");
}
if (Object.keys(assets).length > 0) {
mdx["assets/"] = assets;
}
} catch {
// assets/ directory absent or unreadable — skip.
}
return {
slug: location.slug,
folder,
repoPath: location.repoPath,
routePath: localPlanRoutePath(location.slug, location.repoPath),
url: localPlanRoutePath(location.slug, location.repoPath),
suggestedRepoPath: await defaultLocalPlanRepoPath(location.slug),
mdx,
// Reading a local folder is a preview path, so preserve every valid block
// and render malformed authored blocks as warnings. Verification and import
// use the strict parser and still reject invalid source.
content: await parsePlanMdxFolder(mdx, { salvageInvalidBlocks: true }),
};
}
// Read agent-only review comments from the folder's comments.json (missing = none).
export async function readLocalPlanComments(
folder: string,
): Promise {
try {
const raw = await fs.readFile(
path.join(folder, LOCAL_COMMENTS_FILE),
"utf-8",
);
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as PlanComment[]) : [];
} catch {
return [];
}
}
// Write comments.json; an empty array removes the file.
export async function writeLocalPlanComments(
folder: string,
comments: PlanComment[],
): Promise {
const target = path.join(folder, LOCAL_COMMENTS_FILE);
if (comments.length === 0) {
await fs.rm(target, { force: true });
return;
}
await fs.mkdir(folder, { recursive: true });
await fs.writeFile(target, `${JSON.stringify(comments, null, 2)}\n`, "utf-8");
}
export async function promotePlanLocalFolder(
input: LocalPlanPromoteInput,
): Promise<{
source: LocalPlanReadResult;
promoted: LocalPlanReadResult;
targetPath: string;
localFiles: { written: boolean; folder: string; files: string[] };
alreadyPromoted: boolean;
}> {
const source = await readPlanLocalFolder(input);
const targetPath = input.targetPath?.trim()
? assertLocalPlanRelativePath(input.targetPath, "Target path")
: await defaultLocalPlanRepoPath(source.slug);
const target = resolveRepoRelativePlanFolder(targetPath);
const alreadyPromoted = path.resolve(source.folder) === target.folder;
if (!alreadyPromoted) {
try {
const existing = await fs.readdir(target.folder);
if (existing.length > 0 && !input.overwrite) {
throw new Error(
`${targetPath} already exists. Choose a different path or allow overwrite.`,
);
}
if (input.overwrite) {
await clearExistingPlanMdxFolder(target.folder, targetPath);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
const localFiles = alreadyPromoted
? {
written: true,
folder: target.folder,
files: Object.keys(source.mdx).flatMap((file) =>
file === "assets/"
? Object.keys(source.mdx["assets/"] ?? {}).map(
(name) => `assets/${name}`,
)
: [file],
),
}
: await writePlanMdxFolderToDisk(target.folder, source.mdx);
const promoted = await readPlanLocalFolder({
slug: source.slug,
path: target.repoPath,
});
return {
source,
promoted,
targetPath: target.repoPath,
localFiles,
alreadyPromoted,
};
}