lib/config.js
Rama main · réplica de lectura del repositorio original.
var fs = require("fs");
var path = require("path");
var os = require("os");
var net = require("net");
var execFileSync = require("child_process").execFileSync;
function isSafeSystemUserName(name) {
return typeof name === "string" && /^[a-z_][a-z0-9_-]*[$]?$/.test(name);
}
// When running under sudo, resolve the real user's home directory
// so that ~/.clay/ points to the original user's data, not /root/.clay/
function getRealHome() {
var sudoUser = process.env.SUDO_USER;
if (sudoUser && sudoUser !== "root" && isSafeSystemUserName(sudoUser)) {
// 1. Try getent passwd (works on most Linux, may fail with some NSS configs)
try {
var entry = execFileSync("getent", ["passwd", sudoUser], { encoding: "utf8", timeout: 3000 }).trim();
var home = entry.split(":")[5];
if (home && fs.existsSync(home)) return home;
} catch (e) {}
// 2. Direct path fallback (GCE, cloud VMs)
var directHome = "/home/" + sudoUser;
if (fs.existsSync(directHome)) return directHome;
// 3. SUDO_USER's original HOME (some sudo configs preserve it)
if (process.env.SUDO_HOME && fs.existsSync(process.env.SUDO_HOME)) return process.env.SUDO_HOME;
}
return os.homedir();
}
var REAL_HOME = getRealHome();
// v3: ~/.clay/ (v2 was ~/.claude-relay/, v1 was {cwd}/.claude-relay/)
// If CLAY_CONFIG is set (daemon mode), derive CLAY_HOME from it so that
// daemon.json, users.json, sessions/, etc. all live in the same directory.
var CLAY_HOME = process.env.CLAY_HOME
|| (process.env.CLAY_CONFIG ? path.dirname(process.env.CLAY_CONFIG) : null)
|| path.join(REAL_HOME, ".clay");
var LEGACY_HOME = path.join(REAL_HOME, ".claude-relay");
// Auto-migrate v2 -> v3: rename ~/.claude-relay/ to ~/.clay/ (once, before anything reads)
if (!fs.existsSync(CLAY_HOME) && fs.existsSync(LEGACY_HOME)) {
try {
fs.renameSync(LEGACY_HOME, CLAY_HOME);
console.log("[config] Migrated " + LEGACY_HOME + " → " + CLAY_HOME);
} catch (e) {
// rename failed (cross-device?), fall through — individual files will be read from old path
console.error("[config] Migration rename failed:", e.message);
}
}
// Auto-migrate dev sessions: merge ~/.clay-dev/sessions/ into ~/.clay/sessions/
var CLAY_DEV_HOME = path.join(REAL_HOME, ".clay-dev");
var devSessionsRoot = path.join(CLAY_DEV_HOME, "sessions");
if (fs.existsSync(devSessionsRoot)) {
try {
var prodSessionsRoot = path.join(CLAY_HOME, "sessions");
fs.mkdirSync(prodSessionsRoot, { recursive: true });
var projectDirs = fs.readdirSync(devSessionsRoot);
var totalMigrated = 0;
for (var di = 0; di < projectDirs.length; di++) {
var srcDir = path.join(devSessionsRoot, projectDirs[di]);
if (!fs.statSync(srcDir).isDirectory()) continue;
var destDir = path.join(prodSessionsRoot, projectDirs[di]);
fs.mkdirSync(destDir, { recursive: true });
var files = fs.readdirSync(srcDir);
for (var fi = 0; fi < files.length; fi++) {
if (!files[fi].endsWith(".jsonl")) continue;
var srcFile = path.join(srcDir, files[fi]);
var destFile = path.join(destDir, files[fi]);
if (fs.existsSync(destFile)) continue;
try {
fs.renameSync(srcFile, destFile);
totalMigrated++;
} catch (e2) {
try {
fs.copyFileSync(srcFile, destFile);
fs.unlinkSync(srcFile);
totalMigrated++;
} catch (e3) {}
}
}
// Clean up empty project dir
try {
if (fs.readdirSync(srcDir).length === 0) fs.rmdirSync(srcDir);
} catch (e4) {}
}
if (totalMigrated > 0) {
console.log("[config] Migrated " + totalMigrated + " dev session(s) from " + devSessionsRoot + " → " + prodSessionsRoot);
}
// Clean up empty dev sessions root
try {
if (fs.readdirSync(devSessionsRoot).length === 0) fs.rmdirSync(devSessionsRoot);
} catch (e5) {}
} catch (e) {
console.error("[config] Dev session migration failed:", e.message);
}
}
var CONFIG_DIR = CLAY_HOME;
var CLAYRC_PATH = path.join(REAL_HOME, ".clayrc");
var CRASH_INFO_PATH = path.join(CONFIG_DIR, "crash.json");
// Dev mode uses separate daemon files so dev and prod can run simultaneously
var _devMode = !!process.env.CLAY_DEV;
function configPath() {
return path.join(CONFIG_DIR, _devMode ? "daemon-dev.json" : "daemon.json");
}
function socketPath() {
if (process.platform === "win32") {
var pipeName = _devMode ? "clay-daemon-dev" : "clay-daemon";
return "\\\\.\\pipe\\" + pipeName;
}
return path.join(CONFIG_DIR, _devMode ? "daemon-dev.sock" : "daemon.sock");
}
function logPath() {
return path.join(CONFIG_DIR, _devMode ? "daemon-dev.log" : "daemon.log");
}
function chmodSafe(filePath, mode) {
if (process.platform === "win32") return;
try { fs.chmodSync(filePath, mode); } catch (e) {}
}
function ensureConfigDir() {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
chmodSafe(CONFIG_DIR, 0o700);
}
function loadConfig() {
try {
var data = fs.readFileSync(configPath(), "utf8");
return JSON.parse(data);
} catch (e) {
return null;
}
}
function saveConfig(config) {
ensureConfigDir();
var tmpPath = configPath() + ".tmp";
fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2));
chmodSafe(tmpPath, 0o600);
fs.renameSync(tmpPath, configPath());
chmodSafe(configPath(), 0o600);
}
// Does a process with this pid exist? Existence only — never ownership.
//
// `process.kill(pid, 0)` sends no signal; it just asks the kernel. Three
// answers matter, and conflating them has caused real damage:
//
// success -> the process exists and we may signal it
// EPERM -> the process EXISTS but belongs to another user. This is alive.
// Reading it as dead is how a daemon started by another OS user
// (or under OS-user isolation) got its config cleared and its
// socket unlinked by a second daemon, which then started
// alongside it.
// ESRCH -> no such process. This is the only "dead".
//
// Any other error is a question we could not answer, so we answer "alive":
// every caller uses this to gate a destructive or duplicating action
// (clearStaleConfig unlinks the live socket; the migration lock would be
// stolen), and doing nothing is always the recoverable choice.
//
// The pid is validated first, so a malformed value never reaches the kernel
// call. That matters beyond hygiene: process.kill accepts 0 and negative pids
// as process-GROUP selectors and returns success for them, so an unvalidated
// 0 or -1 in a config file used to report a live daemon that does not exist.
function isPidAlive(pid) {
if (typeof pid !== "number" || !isFinite(pid) || Math.floor(pid) !== pid || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (e) {
if (e && e.code === "ESRCH") return false;
return true;
}
}
function isDaemonAlive(config) {
if (!config || !config.pid) return false;
if (!isPidAlive(config.pid)) {
clearStaleConfig();
return false;
}
// Named pipes on Windows can't be stat'd, just check PID
if (process.platform === "win32") return true;
try {
fs.statSync(socketPath());
return true;
} catch (e) {
return false;
}
}
function isDaemonAliveAsync(config) {
return new Promise(function (resolve) {
if (!config || !config.pid) return resolve(false);
if (!isPidAlive(config.pid)) {
clearStaleConfig();
return resolve(false);
}
var sock = socketPath();
var client = net.connect(sock);
var timer = setTimeout(function () {
client.destroy();
resolve(false);
}, 1000);
client.on("connect", function () {
clearTimeout(timer);
client.destroy();
resolve(true);
});
client.on("error", function () {
clearTimeout(timer);
resolve(false);
});
});
}
function generateSlug(projectPath, existingSlugs) {
var base = path.basename(projectPath).toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
if (!base) base = "project";
if (!existingSlugs || existingSlugs.indexOf(base) === -1) return base;
for (var i = 2; i < 100; i++) {
var candidate = base + "-" + i;
if (existingSlugs.indexOf(candidate) === -1) return candidate;
}
return base + "-" + Date.now();
}
function clearStaleConfig() {
// Clear pid from config instead of deleting the file (preserves project settings)
try {
var data = fs.readFileSync(configPath(), "utf8");
var cfg = JSON.parse(data);
cfg.pid = null;
var tmpPath = configPath() + ".tmp";
fs.writeFileSync(tmpPath, JSON.stringify(cfg, null, 2));
chmodSafe(tmpPath, 0o600);
fs.renameSync(tmpPath, configPath());
chmodSafe(configPath(), 0o600);
} catch (e) {}
if (process.platform !== "win32") {
try { fs.unlinkSync(socketPath()); } catch (e) {}
}
}
// --- Crash info ---
function crashInfoPath() {
return CRASH_INFO_PATH;
}
function writeCrashInfo(info) {
try {
ensureConfigDir();
fs.writeFileSync(CRASH_INFO_PATH, JSON.stringify(info));
} catch (e) {}
}
function readCrashInfo() {
try {
var data = fs.readFileSync(CRASH_INFO_PATH, "utf8");
return JSON.parse(data);
} catch (e) {
return null;
}
}
function clearCrashInfo() {
try { fs.unlinkSync(CRASH_INFO_PATH); } catch (e) {}
}
// --- ~/.clayrc (recent projects persistence) ---
function clayrcPath() {
return CLAYRC_PATH;
}
function loadClayrc() {
try {
var data = fs.readFileSync(CLAYRC_PATH, "utf8");
return JSON.parse(data);
} catch (e) {
return { recentProjects: [] };
}
}
function saveClayrc(rc) {
var tmpPath = CLAYRC_PATH + ".tmp";
fs.writeFileSync(tmpPath, JSON.stringify(rc, null, 2) + "\n");
fs.renameSync(tmpPath, CLAYRC_PATH);
}
/**
* Update ~/.clayrc with the current project list from daemon config.
* Merges with existing entries (preserves addedAt, updates lastUsed).
*/
function syncClayrc(projects) {
var rc = loadClayrc();
var existing = rc.recentProjects || [];
// Build a map by path for quick lookup
var byPath = {};
for (var i = 0; i < existing.length; i++) {
byPath[existing[i].path] = existing[i];
}
// Update/add current projects
for (var j = 0; j < projects.length; j++) {
var p = projects[j];
if (byPath[p.path]) {
// Update existing entry
byPath[p.path].slug = p.slug;
byPath[p.path].lastUsed = Date.now();
if (p.title) byPath[p.path].title = p.title;
else if ("title" in p) delete byPath[p.path].title;
if (p.icon) byPath[p.path].icon = p.icon;
else if ("icon" in p) delete byPath[p.path].icon;
} else {
// New entry
byPath[p.path] = {
path: p.path,
slug: p.slug,
title: p.title || undefined,
icon: p.icon || undefined,
addedAt: p.addedAt || Date.now(),
lastUsed: Date.now(),
};
}
}
// Active projects first, preserving config order (user's drag-and-drop order),
// then inactive recent projects sorted by lastUsed descending
var activePaths = {};
var ordered = [];
for (var k = 0; k < projects.length; k++) {
activePaths[projects[k].path] = true;
if (byPath[projects[k].path]) ordered.push(byPath[projects[k].path]);
}
var inactive = [];
var paths = Object.keys(byPath);
for (var m = 0; m < paths.length; m++) {
if (!activePaths[paths[m]]) inactive.push(byPath[paths[m]]);
}
inactive.sort(function (a, b) { return (b.lastUsed || 0) - (a.lastUsed || 0); });
var all = ordered.concat(inactive);
// Keep at most 20 recent projects
rc.recentProjects = all.slice(0, 20);
saveClayrc(rc);
}
function removeFromClayrc(projectPath) {
var rc = loadClayrc();
var before = (rc.recentProjects || []).length;
rc.recentProjects = (rc.recentProjects || []).filter(function (p) {
return p.path !== projectPath;
});
if (rc.recentProjects.length !== before) saveClayrc(rc);
}
module.exports = {
CONFIG_DIR: CONFIG_DIR,
configPath: configPath,
socketPath: socketPath,
logPath: logPath,
ensureConfigDir: ensureConfigDir,
loadConfig: loadConfig,
saveConfig: saveConfig,
isPidAlive: isPidAlive,
isDaemonAlive: isDaemonAlive,
isDaemonAliveAsync: isDaemonAliveAsync,
generateSlug: generateSlug,
clearStaleConfig: clearStaleConfig,
crashInfoPath: crashInfoPath,
writeCrashInfo: writeCrashInfo,
readCrashInfo: readCrashInfo,
clearCrashInfo: clearCrashInfo,
clayrcPath: clayrcPath,
loadClayrc: loadClayrc,
saveClayrc: saveClayrc,
syncClayrc: syncClayrc,
removeFromClayrc: removeFromClayrc,
chmodSafe: chmodSafe,
isDevMode: _devMode,
REAL_HOME: REAL_HOME,
};