/**
* @file project-memory.js
* @description Create a project's top-level memory file — `CLAUDE.md` for
* Claude Code, `AGENTS.md` for Codex — in ANY existing directory on this
* machine, so the dashboard can bootstrap a project it has never run in.
*
* Why this is not `cc-mutate`/`codex-mutate`: those writers resolve "project"
* scope against the server's own cwd on purpose, and `?cwd=` is ignored there
* precisely so a request can never redirect a write. This surface is the
* deliberate, narrow exception, and it pays for that with:
* - the directory must be absolute and already exist (we never mkdir -p a
* path someone typed);
* - the filename is not caller-supplied — it comes from {@link FILENAMES};
* - the file must not exist yet, so this can only ever create, never
* clobber an existing memory file;
* - a symlink at `
/CLAUDE.md` pointing elsewhere is refused.
*
* The capability is not new in kind: the Run page already spawns `claude` in
* any existing directory the same way. What it must not become is a general
* "write arbitrary content to an arbitrary path" endpoint — hence the fixed
* filename and the no-overwrite rule.
*
* @author Nguyễn Ngọc Trí Vĩ
*/
const fs = require("node:fs");
const path = require("node:path");
const { atomicWriteFile } = require("./config-write");
/** The only two filenames this module will ever write. Keyed by tool. */
const FILENAMES = { claude: "CLAUDE.md", codex: "AGENTS.md" };
/** Same cap the other config writers use — a memory file is prose, not a blob. */
const MAX_CONTENT_BYTES = 1024 * 1024;
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/**
* Create `/`.
*
* @param {{tool: "claude"|"codex", dir: string, content: string}} args
* @returns {{file: string, size: number}}
* @throws EBADTOOL, EBADDIR, EEXISTS, EBADCONTENT, EOUTOFROOT
*/
function createProjectMemory({ tool, dir, content }) {
const filename = FILENAMES[tool];
if (!filename) throw makeError("EBADTOOL", `tool must be one of ${Object.keys(FILENAMES)}`);
if (typeof dir !== "string" || !dir.trim()) {
throw makeError("EBADDIR", "dir is required");
}
if (!path.isAbsolute(dir)) {
throw makeError("EBADDIR", "dir must be an absolute path");
}
// eslint-disable-next-line no-control-regex
if (/\u0000/.test(dir)) throw makeError("EBADDIR", "dir contains a NUL byte");
let realDir;
try {
realDir = fs.realpathSync(path.resolve(dir));
} catch {
throw makeError("EBADDIR", `directory does not exist: ${dir}`);
}
if (!fs.statSync(realDir).isDirectory()) {
throw makeError("EBADDIR", `not a directory: ${dir}`);
}
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
if (Buffer.byteLength(content, "utf8") > MAX_CONTENT_BYTES) {
throw makeError("EBADCONTENT", "content is too large");
}
const file = path.join(realDir, filename);
// lstat, not existsSync: a symlink named CLAUDE.md pointing somewhere else —
// dangling or not — counts as taken, so we refuse instead of writing through
// it. That, plus `realDir` being already realpath'd, is the whole symlink
// story here; the usual `assertNoSymlinkEscape` walk has nothing left to
// catch. And if someone plants the link between this check and the write,
// `atomicWriteFile`'s rename replaces the link itself rather than following
// it.
let taken = true;
try {
fs.lstatSync(file);
} catch (err) {
if (err.code !== "ENOENT") throw err;
taken = false;
}
if (taken) throw makeError("EEXISTS", `${filename} already exists in ${realDir}`);
atomicWriteFile(file, content);
return { file, size: Buffer.byteLength(content, "utf8") };
}
module.exports = { createProjectMemory, FILENAMES, MAX_CONTENT_BYTES };