// 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//). 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: }. 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, };