305 lines
8.3 KiB
TypeScript
305 lines
8.3 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, cpSync } from "fs"
|
|
import os from "os"
|
|
import path from "path"
|
|
import crypto from "crypto"
|
|
|
|
interface UserRecord {
|
|
id: string
|
|
name: string
|
|
salt?: string
|
|
passwordHash?: string
|
|
isGuest?: boolean
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
interface UserStoreState {
|
|
users: UserRecord[]
|
|
activeUserId?: string
|
|
}
|
|
|
|
const CONFIG_ROOT = path.join(os.homedir(), ".config", "codenomad")
|
|
const USERS_FILE = path.join(CONFIG_ROOT, "users.json")
|
|
const USERS_ROOT = path.join(CONFIG_ROOT, "users")
|
|
const LEGACY_ROOT = CONFIG_ROOT
|
|
const LEGACY_INTEGRATIONS_ROOT = path.join(os.homedir(), ".nomadarch")
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString()
|
|
}
|
|
|
|
function sanitizeId(value: string) {
|
|
return value
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^a-z0-9-_]+/g, "-")
|
|
.replace(/-{2,}/g, "-")
|
|
.replace(/^-|-$/g, "")
|
|
}
|
|
|
|
function hashPassword(password: string, salt: string) {
|
|
return crypto.pbkdf2Sync(password, salt, 120000, 32, "sha256").toString("base64")
|
|
}
|
|
|
|
function generateSalt() {
|
|
return crypto.randomBytes(16).toString("base64")
|
|
}
|
|
|
|
function ensureDir(dir: string) {
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true })
|
|
}
|
|
}
|
|
|
|
function readStore(): UserStoreState {
|
|
try {
|
|
if (!existsSync(USERS_FILE)) {
|
|
return { users: [] }
|
|
}
|
|
const content = readFileSync(USERS_FILE, "utf-8")
|
|
const parsed = JSON.parse(content) as UserStoreState
|
|
return {
|
|
users: Array.isArray(parsed.users) ? parsed.users : [],
|
|
activeUserId: parsed.activeUserId,
|
|
}
|
|
} catch {
|
|
return { users: [] }
|
|
}
|
|
}
|
|
|
|
function writeStore(state: UserStoreState) {
|
|
ensureDir(CONFIG_ROOT)
|
|
ensureDir(USERS_ROOT)
|
|
writeFileSync(USERS_FILE, JSON.stringify(state, null, 2), "utf-8")
|
|
}
|
|
|
|
function ensureUniqueId(base: string, existing: Set<string>) {
|
|
let candidate = sanitizeId(base) || "user"
|
|
let index = 1
|
|
while (existing.has(candidate)) {
|
|
candidate = `${candidate}-${index}`
|
|
index += 1
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
function getUserDir(userId: string) {
|
|
return path.join(USERS_ROOT, userId)
|
|
}
|
|
|
|
function migrateLegacyData(targetDir: string) {
|
|
const legacyConfig = path.join(LEGACY_ROOT, "config.json")
|
|
const legacyInstances = path.join(LEGACY_ROOT, "instances")
|
|
const legacyWorkspaces = path.join(LEGACY_ROOT, "opencode-workspaces")
|
|
|
|
ensureDir(targetDir)
|
|
|
|
if (existsSync(legacyConfig)) {
|
|
cpSync(legacyConfig, path.join(targetDir, "config.json"), { force: true })
|
|
}
|
|
if (existsSync(legacyInstances)) {
|
|
cpSync(legacyInstances, path.join(targetDir, "instances"), { recursive: true, force: true })
|
|
}
|
|
if (existsSync(legacyWorkspaces)) {
|
|
cpSync(legacyWorkspaces, path.join(targetDir, "opencode-workspaces"), { recursive: true, force: true })
|
|
}
|
|
|
|
if (existsSync(LEGACY_INTEGRATIONS_ROOT)) {
|
|
cpSync(LEGACY_INTEGRATIONS_ROOT, path.join(targetDir, "integrations"), { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
export function ensureDefaultUsers(): UserRecord {
|
|
const store = readStore()
|
|
|
|
// If roman exists, ensure his password is updated to the new required one if it matches the old default
|
|
const roman = store.users.find(u => u.name === "roman")
|
|
if (roman && roman.salt && roman.passwordHash) {
|
|
const oldDefaultHash = hashPassword("q1w2e3r4", roman.salt)
|
|
if (roman.passwordHash === oldDefaultHash) {
|
|
console.log("[UserStore] Updating roman's password to new default")
|
|
const newSalt = generateSalt()
|
|
roman.salt = newSalt
|
|
roman.passwordHash = hashPassword("!@#$q1w2e3r4", newSalt)
|
|
roman.updatedAt = nowIso()
|
|
writeStore(store)
|
|
}
|
|
|
|
// NEW: Check if roman needs data migration (e.g. if he was created before migration logic was robust)
|
|
const userDir = getUserDir(roman.id)
|
|
const configPath = join(userDir, "config.json")
|
|
let needsMigration = !existsSync(configPath)
|
|
if (!needsMigration) {
|
|
try {
|
|
const config = JSON.parse(readFileSync(configPath, "utf-8"))
|
|
if (!config.recentFolders || config.recentFolders.length === 0) {
|
|
needsMigration = true
|
|
}
|
|
} catch (e) {
|
|
needsMigration = true
|
|
}
|
|
}
|
|
|
|
if (needsMigration) {
|
|
console.log(`[UserStore] Roman exists but seems to have missing data. Triggering migration to ${userDir}...`)
|
|
migrateLegacyData(userDir)
|
|
}
|
|
}
|
|
|
|
if (store.users.length > 0) {
|
|
const active = store.users.find((u) => u.id === store.activeUserId) ?? store.users[0]
|
|
return active
|
|
}
|
|
|
|
const existingIds = new Set<string>()
|
|
const userId = ensureUniqueId("roman", existingIds)
|
|
const salt = generateSalt()
|
|
const passwordHash = hashPassword("!@#$q1w2e3r4", salt)
|
|
const record: UserRecord = {
|
|
id: userId,
|
|
name: "roman",
|
|
salt,
|
|
passwordHash,
|
|
createdAt: nowIso(),
|
|
updatedAt: nowIso(),
|
|
}
|
|
|
|
store.users.push(record)
|
|
writeStore(store)
|
|
|
|
const userDir = getUserDir(record.id)
|
|
migrateLegacyData(userDir)
|
|
|
|
return record
|
|
}
|
|
|
|
export function listUsers(): UserRecord[] {
|
|
return readStore().users
|
|
}
|
|
|
|
export function getActiveUser(): UserRecord | null {
|
|
const store = readStore()
|
|
if (!store.activeUserId) return null
|
|
return store.users.find((user) => user.id === store.activeUserId) ?? null
|
|
}
|
|
|
|
export function logoutActiveUser() {
|
|
const store = readStore()
|
|
store.activeUserId = undefined
|
|
writeStore(store)
|
|
console.log("[UserStore] Active user logged out")
|
|
}
|
|
|
|
export function setActiveUser(userId: string) {
|
|
const store = readStore()
|
|
const user = store.users.find((u) => u.id === userId)
|
|
if (!user) {
|
|
throw new Error("User not found")
|
|
}
|
|
store.activeUserId = userId
|
|
writeStore(store)
|
|
return user
|
|
}
|
|
|
|
export function createUser(name: string, password: string) {
|
|
const store = readStore()
|
|
const existingIds = new Set(store.users.map((u) => u.id))
|
|
const id = ensureUniqueId(name, existingIds)
|
|
const salt = generateSalt()
|
|
const passwordHash = hashPassword(password, salt)
|
|
const record: UserRecord = {
|
|
id,
|
|
name,
|
|
salt,
|
|
passwordHash,
|
|
createdAt: nowIso(),
|
|
updatedAt: nowIso(),
|
|
}
|
|
store.users.push(record)
|
|
writeStore(store)
|
|
ensureDir(getUserDir(id))
|
|
return record
|
|
}
|
|
|
|
export function createGuestUser() {
|
|
const store = readStore()
|
|
const existingIds = new Set(store.users.map((u) => u.id))
|
|
const id = ensureUniqueId(`guest-${crypto.randomUUID().slice(0, 8)}`, existingIds)
|
|
const record: UserRecord = {
|
|
id,
|
|
name: "Guest",
|
|
isGuest: true,
|
|
createdAt: nowIso(),
|
|
updatedAt: nowIso(),
|
|
}
|
|
store.users.push(record)
|
|
store.activeUserId = id
|
|
writeStore(store)
|
|
ensureDir(getUserDir(id))
|
|
return record
|
|
}
|
|
|
|
export function updateUser(userId: string, updates: { name?: string; password?: string }) {
|
|
const store = readStore()
|
|
const target = store.users.find((u) => u.id === userId)
|
|
if (!target) {
|
|
throw new Error("User not found")
|
|
}
|
|
if (updates.name) {
|
|
target.name = updates.name
|
|
}
|
|
if (updates.password && !target.isGuest) {
|
|
const salt = generateSalt()
|
|
target.salt = salt
|
|
target.passwordHash = hashPassword(updates.password, salt)
|
|
}
|
|
target.updatedAt = nowIso()
|
|
writeStore(store)
|
|
return target
|
|
}
|
|
|
|
export function deleteUser(userId: string) {
|
|
const store = readStore()
|
|
const target = store.users.find((u) => u.id === userId)
|
|
if (!target) return
|
|
store.users = store.users.filter((u) => u.id !== userId)
|
|
if (store.activeUserId === userId) {
|
|
store.activeUserId = store.users[0]?.id
|
|
}
|
|
writeStore(store)
|
|
const dir = getUserDir(userId)
|
|
if (existsSync(dir)) {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
export function verifyPassword(userId: string, password: string): boolean {
|
|
const store = readStore()
|
|
const user = store.users.find((u) => u.id === userId)
|
|
if (!user) return false
|
|
if (user.isGuest) return true
|
|
if (!user.salt || !user.passwordHash) return false
|
|
return hashPassword(password, user.salt) === user.passwordHash
|
|
}
|
|
|
|
export function getUserDataRoot(userId: string) {
|
|
return getUserDir(userId)
|
|
}
|
|
|
|
export function clearGuestUsers() {
|
|
const store = readStore()
|
|
const guests = store.users.filter((u) => u.isGuest)
|
|
if (guests.length === 0) return
|
|
store.users = store.users.filter((u) => !u.isGuest)
|
|
if (store.activeUserId && guests.some((u) => u.id === store.activeUserId)) {
|
|
store.activeUserId = store.users[0]?.id
|
|
}
|
|
writeStore(store)
|
|
for (const guest of guests) {
|
|
const dir = getUserDir(guest.id)
|
|
if (existsSync(dir)) {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
}
|