Ctrl K
ComunidadGuías…

lib/migrate-single-user.js

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

Archivo original ↗Preguntar a KiBot
// One-time migration: fold a legacy single-user deploy into the multi-user
// model by auto-provisioning one "admin" user that inherits the single-user
// PIN, profile, and settings. After this, the app is always internally
// multi-user; a solo deploy is just a one-user multi-user deploy (with the
// solo auto-login safety net so there's no login wall when no PIN was set).
//
// Design goals:
//   - Seamless: existing users upgrade and keep working with no manual steps.
//     Had a PIN -> same PIN still logs in. Had none -> no login wall.
//   - Safe: backs up state first, tolerates partial failure (best-effort per
//     step), and is a no-op on fresh installs and already-multi-user deploys.
//   - Idempotent: a marker in daemon config prevents re-running.
//
// NOTE: this touches on-disk state (users.json, daemon.json, profile.json,
// mates/). It runs once at daemon boot, before the server starts listening.

var fs = require("fs");
var path = require("path");
var config = require("./config");
var users = require("./users");

var SETTING_KEYS = [
  "chatLayout",
  "autoContinueOnRateLimit",
  "matesEnabled",
  "capsulesEnabled",
  "agentBehavior",
  "terminalFont",
  "deletedBuiltinKeys",
  "mateOnboardingShown",
];

function backupFile(filePath, stamp) {
  try {
    if (!fs.existsSync(filePath)) return;
    var bak = filePath + ".pre-migrate-" + stamp + ".bak";
    if (fs.existsSync(bak)) return; // don't clobber an existing backup
    fs.copyFileSync(filePath, bak);
  } catch (e) {
    console.error("[migrate] backup failed for " + filePath + ": " + (e.message || e));
  }
}

// Move legacy flat mates (CONFIG_DIR/mates/*) under the per-user directory
// (CONFIG_DIR/mates/<userId>/). Best-effort; never fatal. Mates data is
// preserved on disk regardless.
function migrateMatesToUser(userId) {
  try {
    var matesRoot = path.join(config.CONFIG_DIR, "mates");
    if (!fs.existsSync(matesRoot)) return;
    var userDir = path.join(matesRoot, userId);
    fs.mkdirSync(userDir, { recursive: true });
    var entries = fs.readdirSync(matesRoot);
    for (var i = 0; i < entries.length; i++) {
      var name = entries[i];
      // Skip per-user dirs (userId-shaped) and our target dir.
      if (name === userId) continue;
      // Only move legacy flat artifacts: mates.json and mate_* directories.
      var isLegacy = name === "mates.json" || name.indexOf("mate_") === 0;
      if (!isLegacy) continue;
      var src = path.join(matesRoot, name);
      var dst = path.join(userDir, name);
      if (fs.existsSync(dst)) continue; // don't clobber freshly-seeded builtins
      try { fs.renameSync(src, dst); }
      catch (e) { console.error("[migrate] mates move skipped for " + name + ": " + (e.message || e)); }
    }
  } catch (e) {
    console.error("[migrate] mates migration failed (non-fatal): " + (e.message || e));
  }
}

// Backfill ownerId onto legacy sessions (created before ownership existed) so
// they belong to the migrated user. Walks CONFIG_DIR/sessions/**/*.jsonl and
// patches the first "meta" line in place. Best-effort per file.
function backfillSessionOwners(userId) {
  var count = 0;
  try {
    var root = path.join(config.CONFIG_DIR, "sessions");
    if (!fs.existsSync(root)) return 0;
    var stack = [root];
    while (stack.length > 0) {
      var dir = stack.pop();
      var entries;
      try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { continue; }
      for (var i = 0; i < entries.length; i++) {
        var ent = entries[i];
        var full = path.join(dir, ent.name);
        if (ent.isDirectory()) { stack.push(full); continue; }
        if (!ent.name.endsWith(".jsonl")) continue;
        try {
          var content = fs.readFileSync(full, "utf8");
          var nl = content.indexOf("\n");
          var firstLine = nl === -1 ? content : content.slice(0, nl);
          var rest = nl === -1 ? "" : content.slice(nl); // includes leading \n
          var meta;
          try { meta = JSON.parse(firstLine); } catch (e) { continue; }
          if (!meta || meta.type !== "meta") continue;
          if (meta.ownerId) continue; // already owned
          meta.ownerId = userId;
          var tmp = full + ".tmp." + Date.now();
          fs.writeFileSync(tmp, JSON.stringify(meta) + rest);
          fs.renameSync(tmp, full);
          count++;
        } catch (e) { /* skip this file */ }
      }
    }
  } catch (e) {
    console.error("[migrate] session ownerId backfill failed (non-fatal): " + (e.message || e));
  }
  return count;
}

function readProfile() {
  try {
    var p = path.join(config.CONFIG_DIR, "profile.json");
    if (!fs.existsSync(p)) return null;
    var raw = fs.readFileSync(p, "utf8");
    var obj = JSON.parse(raw);
    return (obj && typeof obj === "object") ? obj : null;
  } catch (e) { return null; }
}

// Returns { migrated: true, userId } on success, or { skipped: <reason> }.
function migrateSingleUserToMulti() {
  try {
    // Guard 1: already multi-user -> nothing to do.
    if (users.isMultiUser()) return { skipped: "already-multi-user" };

    // Guard 2: users already exist (partial prior run) -> don't double-create.
    var existing = users.getAllUsers();
    if (existing && existing.length > 0) return { skipped: "users-exist" };

    var cfg = config.loadConfig() || {};

    // Guard 3: idempotency marker.
    if (cfg.singleUserMigratedAt) return { skipped: "already-migrated" };

    // Guard 4: only migrate deploys that show prior single-user use. A pristine
    // fresh install (no PIN, no settings, no profile) is left for the later
    // default-flip phase, so this migration is strictly an upgrade path.
    var profile = readProfile();
    var hasSettings = SETTING_KEYS.some(function (k) { return cfg[k] !== undefined; });
    var hasPin = !!cfg.pinHash;
    if (!profile && !hasSettings && !hasPin) return { skipped: "fresh-install" };

    var stamp = String(Date.now());
    console.log("[migrate] Legacy single-user deploy detected; migrating to multi-user (stamp=" + stamp + ")");

    // --- Backup before touching anything ---
    backupFile(config.configPath(), stamp);
    // users.json / auth-tokens live in CONFIG_DIR; back them up if present.
    backupFile(path.join(config.CONFIG_DIR, "users.json"), stamp);
    backupFile(path.join(config.CONFIG_DIR, "profile.json"), stamp);

    // Persist the owner credential and account mode in the same atomic write.
    var displayName = (profile && profile.name) ? String(profile.name) : "Admin";
    var created = users.createUserWithoutPin({
      username: "admin",
      displayName: displayName,
      role: "admin",
      profile: profile || undefined,
    }, { pinHash: cfg.pinHash || null });
    if (!created || !created.user) {
      console.error("[migrate] Failed to create admin user: " + (created && created.error));
      return { skipped: "create-failed" };
    }
    var userId = created.user.id;

    // --- Carry over settings ---
    try {
      if (cfg.chatLayout !== undefined && users.setChatLayout) users.setChatLayout(userId, cfg.chatLayout);
      if (cfg.autoContinueOnRateLimit !== undefined && users.setAutoContinue) users.setAutoContinue(userId, cfg.autoContinueOnRateLimit);
      if (cfg.matesEnabled !== undefined && users.setMatesEnabled) users.setMatesEnabled(userId, cfg.matesEnabled);
      if (cfg.capsulesEnabled !== undefined && users.setCapsulesEnabled) users.setCapsulesEnabled(userId, cfg.capsulesEnabled);
      if (cfg.agentBehavior && users.updateAgentBehavior) users.updateAgentBehavior(userId, require("./agent-behavior-schema").normalize(cfg.agentBehavior));
      if (cfg.terminalFont && users.setTerminalFont) {
        users.setTerminalFont(userId, cfg.terminalFont.family, cfg.terminalFont.size);
      }
      // Fields without a dedicated setter: write directly.
      if (cfg.deletedBuiltinKeys !== undefined || cfg.mateOnboardingShown !== undefined) {
        var d2 = users.loadUsers();
        for (var j = 0; j < d2.users.length; j++) {
          if (d2.users[j].id === userId) {
            if (cfg.deletedBuiltinKeys !== undefined) d2.users[j].deletedBuiltinKeys = cfg.deletedBuiltinKeys;
            if (cfg.mateOnboardingShown !== undefined) d2.users[j].mateOnboardingShown = cfg.mateOnboardingShown;
            break;
          }
        }
        users.saveUsers(d2);
      }
    } catch (e) {
      console.error("[migrate] settings carry-over failed (non-fatal): " + (e.message || e));
    }

    // --- Move legacy flat mates under the new user ---
    migrateMatesToUser(userId);

    // --- Backfill session ownership so legacy sessions belong to the user ---
    var backfilled = backfillSessionOwners(userId);
    if (backfilled > 0) console.log("[migrate] Backfilled ownerId on " + backfilled + " legacy session(s)");

    // --- Mark done (keep the old daemon-config keys for now; a later phase
    // removes the dual settings path). ---
    try {
      var freshCfg = config.loadConfig() || {};
      freshCfg.singleUserMigratedAt = Date.now();
      freshCfg.singleUserMigratedUserId = userId;
      config.saveConfig(freshCfg);
    } catch (e) {
      console.error("[migrate] Failed to write migration marker: " + (e.message || e));
    }

    console.log("[migrate] Single-user -> multi-user migration complete. admin userId=" + userId + (hasPin ? " (PIN preserved)" : " (no PIN; solo auto-login)"));
    return { migrated: true, userId: userId };
  } catch (e) {
    console.error("[migrate] Single-user migration aborted (non-fatal): " + (e.message || e));
    return { skipped: "error", error: e.message || String(e) };
  }
}

// Guarantee the app is in the multi-user model with at least one user, so the
// single-user runtime never exists. Called at daemon boot:
//   - already multi-user with users -> nothing to do
//   - legacy single-user with data   -> migrate it (preserves PIN/settings)
//   - fresh/empty                    -> provision one no-PIN "admin"
//                                       (solo auto-login: no login wall)
// OS-user isolation stays an orthogonal, settings/flag-driven toggle.
function ensureMultiUser() {
  try {
    if (users.isMultiUser()) {
      var have = users.getAllUsers();
      if (have && have.length > 0) return { skipped: "ready" };
    }

    // Try the legacy single-user migration first (handles PIN/settings/mates/
    // session carry-over for existing deploys).
    var mig = migrateSingleUserToMulti();
    if (mig && (mig.migrated || mig.skipped === "error" || mig.skipped === "create-failed")) return mig;

    // Fresh or empty deploy: provision a default admin so we're multi-user with
    // one user. No PIN -> the phase-1 solo auto-login skips the login wall.
    var existing = users.getAllUsers();
    if (!existing || existing.length === 0) {
      if (!users.isMultiUser()) users.enableMultiUser();
      var created = users.createUserWithoutPin({
        username: "admin",
        displayName: "Admin",
        role: "admin",
      });
      if (!created || !created.user) {
        console.error("[migrate] Failed to provision default admin: " + (created && created.error));
        return { skipped: "provision-failed" };
      }
      console.log("[migrate] Provisioned default admin (no PIN; solo auto-login). userId=" + created.user.id);
      return { provisioned: true, userId: created.user.id };
    }

    // Users exist but flag wasn't set (shouldn't normally happen) -> flip it.
    if (!users.isMultiUser()) users.enableMultiUser();
    return { skipped: "flag-fixed" };
  } catch (e) {
    console.error("[migrate] ensureMultiUser failed (non-fatal): " + (e.message || e));
    return { skipped: "error", error: e.message || String(e) };
  }
}

module.exports = {
  migrateSingleUserToMulti: migrateSingleUserToMulti,
  ensureMultiUser: ensureMultiUser,
};