Ctrl K
ComunidadGuías…

lib/config.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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,
};