Backup before continuing from Codex 5.2 session - User storage, compaction suggestions, streaming improvements
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
||||
import path from "path"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
import {
|
||||
listUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
verifyPassword,
|
||||
setActiveUser,
|
||||
createGuestUser,
|
||||
getActiveUser,
|
||||
getUserDataRoot,
|
||||
} from "./user-store"
|
||||
|
||||
interface DialogOpenRequest {
|
||||
mode: "directory" | "file"
|
||||
@@ -40,6 +52,41 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
return cliManager.start({ dev: devMode })
|
||||
})
|
||||
|
||||
ipcMain.handle("users:list", async () => listUsers())
|
||||
ipcMain.handle("users:active", async () => getActiveUser())
|
||||
ipcMain.handle("users:create", async (_, payload: { name: string; password: string }) => {
|
||||
const user = createUser(payload.name, payload.password)
|
||||
return user
|
||||
})
|
||||
ipcMain.handle("users:update", async (_, payload: { id: string; name?: string; password?: string }) => {
|
||||
const user = updateUser(payload.id, { name: payload.name, password: payload.password })
|
||||
return user
|
||||
})
|
||||
ipcMain.handle("users:delete", async (_, payload: { id: string }) => {
|
||||
deleteUser(payload.id)
|
||||
return { success: true }
|
||||
})
|
||||
ipcMain.handle("users:createGuest", async () => {
|
||||
const user = createGuestUser()
|
||||
return user
|
||||
})
|
||||
ipcMain.handle("users:login", async (_, payload: { id: string; password?: string }) => {
|
||||
const ok = verifyPassword(payload.id, payload.password ?? "")
|
||||
if (!ok) {
|
||||
return { success: false }
|
||||
}
|
||||
const user = setActiveUser(payload.id)
|
||||
const root = getUserDataRoot(user.id)
|
||||
cliManager.setUserEnv({
|
||||
CODENOMAD_USER_DIR: root,
|
||||
CLI_CONFIG: path.join(root, "config.json"),
|
||||
})
|
||||
await cliManager.stop()
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
await cliManager.start({ dev: devMode })
|
||||
return { success: true, user }
|
||||
})
|
||||
|
||||
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
||||
const properties: OpenDialogOptions["properties"] =
|
||||
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
import { ensureDefaultUsers, getActiveUser, getUserDataRoot, clearGuestUsers } from "./user-store"
|
||||
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = dirname(mainFilename)
|
||||
@@ -225,6 +226,24 @@ function getPreloadPath() {
|
||||
return join(mainDirname, "../preload/index.js")
|
||||
}
|
||||
|
||||
function applyUserEnvToCli() {
|
||||
const active = getActiveUser()
|
||||
if (!active) {
|
||||
const fallback = ensureDefaultUsers()
|
||||
const fallbackRoot = getUserDataRoot(fallback.id)
|
||||
cliManager.setUserEnv({
|
||||
CODENOMAD_USER_DIR: fallbackRoot,
|
||||
CLI_CONFIG: join(fallbackRoot, "config.json"),
|
||||
})
|
||||
return
|
||||
}
|
||||
const root = getUserDataRoot(active.id)
|
||||
cliManager.setUserEnv({
|
||||
CODENOMAD_USER_DIR: root,
|
||||
CLI_CONFIG: join(root, "config.json"),
|
||||
})
|
||||
}
|
||||
|
||||
function destroyPreloadingView(target?: BrowserView | null) {
|
||||
const view = target ?? preloadingView
|
||||
if (!view) {
|
||||
@@ -274,7 +293,7 @@ function createWindow() {
|
||||
currentCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
if (process.env.NODE_ENV === "development" && process.env.NOMADARCH_OPEN_DEVTOOLS === "true") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
@@ -452,6 +471,8 @@ if (isMac) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
ensureDefaultUsers()
|
||||
applyUserEnvToCli()
|
||||
startCli()
|
||||
|
||||
if (isMac) {
|
||||
@@ -480,6 +501,7 @@ app.whenReady().then(() => {
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await cliManager.stop().catch(() => { })
|
||||
clearGuestUsers()
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -79,6 +79,11 @@ export class CliProcessManager extends EventEmitter {
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private userEnv: Record<string, string> = {}
|
||||
|
||||
setUserEnv(env: Record<string, string>) {
|
||||
this.userEnv = { ...env }
|
||||
}
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
@@ -100,6 +105,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
Object.assign(env, this.userEnv)
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
@@ -274,7 +280,8 @@ export class CliProcessManager extends EventEmitter {
|
||||
const args = ["serve", "--host", host, "--port", "0"]
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
const uiPort = process.env.VITE_PORT || "3000"
|
||||
args.push("--ui-dev-server", `http://localhost:${uiPort}`, "--log-level", "debug")
|
||||
}
|
||||
|
||||
return args
|
||||
|
||||
267
packages/electron-app/electron/main/user-store.ts
Normal file
267
packages/electron-app/electron/main/user-store.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
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 (store.users.length > 0) {
|
||||
const active = store.users.find((u) => u.id === store.activeUserId) ?? store.users[0]
|
||||
if (!store.activeUserId) {
|
||||
store.activeUserId = active.id
|
||||
writeStore(store)
|
||||
}
|
||||
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)
|
||||
store.activeUserId = record.id
|
||||
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 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user